[Spring] RestTemplate을 활용한 공공데이터 API 호출하기

2025. 5. 7. 19:40·Framework/Spring

내일배움캠프 최종 프로젝트를 진행하면서,

공공데이터 API를 활용해 주차장 정보를 주기적으로 수집하고 DB에 저장하는 기능을 구현하였습니다.

 

이 과정에서 외부 API를 호출하기 위해 RestTemplate을 사용했습니다.

 

이번 글에서 해당 기능을 구현하면서 공부한 RestTemplate과 활용에 대해 정리하고자 합니다.

 


 

1. RestTemplate에 대해 알아보기


[정의 및 특징 요약]
RestTemplate은 Spring에서 제공하는 동기 방식의 HTTP 클라이언트로,
사용법이 간단하고 직관적이고 다양한 HTTP 메소드를 지원합니다.
하지만, 현재는 공식적으로 deprecated 예정되어 있어 WebClient를 권장하고 있습니다.

 

 

 

(1) RestTemplate의 특징


1️⃣ 동기 방식의 HTTP 요청 처리

RestTemplate은 동기(Synchronous) 방식으로 작동하기 때문에,

클라이언트가 외부 API에 HTTP요청을 보내면,

응답이 도착할 때까지 현재 Thread는 작업을 멈추고 대기(Blocking)합니다.

🤔 동기와 비동기란?
동기 (Synchronous) : 작업이 끝날 때까지 기다렸다가 다음 작업을 수행하는 방식
비동기 (Asynchronous) : 작업이 끝나기를 기다리지 않고 다음 작업을 수행하는 방식

🤔 Blocking 과 Non-blocking이란?
Blocking : 결과가 나올 때까지 쓰레드가 멈춰 있는 것
Non-blocking : 결과를 기다리지 않고 쓰레드가 바로 다음 작업을 수행하는 것

 


 

2️⃣ 다양한 HTTP 메소드 지원

HTTP 메소드 RestTemplate 메소드 설명
GET getForObject(url, responseType) URL에서 데이터를 조회하고, 결과를 객체로 반환
GET getForEntity(url, responseType) 상태 코드와 헤더를 포함한 응답 전체를 ResponseEntity로 반환
POST postForObject(url, request, responseType) 데이터를 전송하고 응답을 객체로 반환
POST postForEntity(url, request, responseType) 응답 상태, 헤더와 함께 전체 응답을 반환
POST postForLocation(url, request) 요청 처리 후 생성된 리소스의 URI만 반환
PUT put(url, request) 서버에 리소스를 생성/수정하며, 응답은 없음
DELETE delete(url) 지정한 URL의 리소스를 삭제
PATCH patchForObject(url, request, responseType) 리소스의 일부를 수정하고 결과 객체를 반환
HEAD headForHeaders(url) 응답 본문 없이 헤더 정보만 가져옴
OPTIONS optionsForAllow(url) 지정한 URL이 허용하는 HTTP 메소드를 확인
(모든 메소드 지원) exchange(url, method, requestEntity, responseType) 모든 HTTP 메소드를 지원하며, 커스텀 요청 구성 가능
[참고 문서]
Spring 공식문서 - RestTemplate

 


 

3️⃣ 요청/응답 객체 자동 변환

RestTemplate은 HTTP 요청과 응답 데이터를 Java 객체로 자동 변환해준다.

따라서 복잡한 JSON 코드를 직접 작성하지 않아도 원하는 타입의 객체로 쉽게 매핑할 수 있습니다.

 

[요청(Request) 객체 자동 직렬화]

HttpEntity<RequestDto> request = new HttpEntity<>(requestDto);
ResponseEntity<RequestDto> response = restTemplate.postForEntity(url, reqeust, ResponseDto.class);

POST 또는 PUT 요청 시

Java 객체를 `HttpEntity`에 담아 전송하면,

RestTemplate 내부에서 JSON 또는 XML로 자동 직렬화하여, HTTP Body에 담아 전송합니다.

 

[응답(Response) 객체 자동 역직렬화]

ResponseEntity<ResponseDto> response = restTemplate.getForEntity(url, ResponseDto.class);
ResponseDto body = response.getBody();

응답 결과가 JSON일 경우, 지정한 클래스 타입으로 자동변환해줍니다.

 

 

 

(2) RestTemplate의 작동 방식


 

 

 

(3) RestTemplate vs WebClient 비교하기


항목 RestTemplate WebClient
요청 방식 동기 동기, 비동기 
Connection Pool Apache HttpClient 등 외부 연결 필요 Netty 기반 내장
기본 인증 지원 지원
OAuth2 인증 Spring Security를 사용하여 지원 Spring Security를 사용하여 지원
JSON 처리 Jackson, Gson 등 Jackson, Gson 등
XML 처리 JAXB, Jackson 등 Jackson 등
multipart/form-data 지원 지원
쿠키 처리 지원 지원
헤더 설정 HttpHeaders 사용 빌더 패턴으로 유연하게 설정
요청 시간 초과 설정 ClientHttpRequestFactory 사용 빌더 설정으로 지원
로깅 ClientHttpReqeustInterceptor 사용 ExchangeFilterFunction 사용
성능 블로킹 방식으로 느림 논 블로킹 방식으로 빠름
설정 유연성 구현체 별 설정이 필요하므로 낮음 빌더 기반으로 높음
적합한 상황 단순한 동기 API 호출 시 고성능, 비동기, 스트리밍 등 리액티브 처리가 필요할 때

 

 

 

 

2. RestTemplate 사용하기


(1) 공공데이터 API 활용 신청 및 문서 확인


[사용한 공공데이터]
한국 교통 안전공단 - 전국 공영 주차장 정보

 

 

 

 

(2) RestTemplate으로 공공데이터 API 호출하기


1️⃣ 응답 데이터를 매핑할 DTO 클래스 정의

[ParkingLotData 클래스]

@Getter
public class ParkingLotDataResponse {

    @JsonProperty("page")
    private int page;

    @JsonProperty("perPage")
    private int perPage;

    @JsonProperty("totalCount")
    private int totalCount;

    @JsonProperty("currentCount")
    private int currentCount;

    @JsonProperty("matchCount")
    private int matchCount;

    @JsonProperty("data")
    private List<ParkingLotData> data;

}

 

[ParkingLotData 클래스]

@Getter
public class ParkingLotData {

    @JsonProperty("주차장명")
    private String name;

    @JsonProperty("주차장도로명주소")
    private String address;

    @JsonProperty("위도")
    private String latitude;

    @JsonProperty("경도")
    private String longitude;

    @JsonProperty("평일운영시작시각")
    private String openedAt;

    @JsonProperty("평일운영종료시각")
    private String closedAt;

    @JsonProperty("주차구획수")
    private String quantity;

    @JsonProperty("요금정보")
    private String chargeType;

}

 

⚠️ 응답 DTO 작성 시 주의할 점
❶ JSON 응답이 한글, 언더스코어, 대소문자가 섞였을 때, @JsonProperty로 매핑 지정할 것
❷ JSON 타입과 데이터 타입이 일치하도록 작성할 것 
❸ Jackson으로 역직렬화를 하기 때문에 @Getter 또는 public 접근자가 필요
❹ Jackson이 객체를 생성하기 때문에 파라미터가 없는 기본생성자가 필요
❺ 응답에 배열이 있을 경우, 제네릭을 정확히 지정해야 파싱 가능
❻ 사용하지 않는 응답 필드는 생략 가능

 


 

2️⃣ RestTemplate 설정값 정의

// api url 설정
@Value("${parking-lot.public-data.url}")
private String parkingLotPublicDataUrl;

// 외부 api 인증키 설정
@Value("${parking-lot.public-data.serviceKey}")
private String serviceKey;

// RestTemplate 생성
private final RestTemplate restTemplate = new RestTemplate();

RestTemplate은 클래스의 필드로 직접 생성하였고,

API 호출에 필요한 URL과 인증키는 application.yml에 정의한 값을
`@Value` 어노테이션을 통해 주입받도록 설정했습니다.

 


 

3️⃣ 공공데이터 API 호출 및 응답 처리

URI uri = UriComponentsBuilder.fromUriString(parkingLotPublicDataUrl)
        .queryParam("page", currentPage)
        .queryParam("perPage", perPage)
        .queryParam("serviceKey", serviceKey)
        .build()
        .encode()
        .toUri();

ResponseEntity<ParkingLotDataResponse> responseEntity =
        restTemplate.getForEntity(uri, ParkingLotDataResponse.class);
ParkingLotDataResponse dataResponse = responseEntity.getBody();

`UriComponentsBuilder`를 사용하여 API 요청 URL을 동적으로 생성하고,

RestTemplate의 `getForEntity()`를 사용하여 API를 호출했습니다.

이후 `getBody()`로 응답 본문을 위에서 설정한 DTO 객체로 매핑했습니다.

 


 

4️⃣ 응답 데이터 유효성 검사 및 변환

if (dataResponse == null || dataResponse.getData() == null) {
    currentPage = 1;
    log.info("받은 데이터가 없으므로, 인덱스를 1로 초기화");
    return;
}

List<ParkingLot> parkingLots = dataResponse.getData().stream()
        .map(this::convertToParkingLot)
        .toList();

응답이 없을 경우, 로그를 남기고 페이징 인덱스를 초기화하였고,

실제 응답 받은 데이터가 있다면 convertToParkingLot()메소드를 통해 엔티티로 변환하였습니다.

 


 

5️⃣ Bulk Insert로 DB 저장

    try {
        bulkInsertParkingLots(parkingLots);
        bulkInsertImages(parkingLots);
    } catch (DataIntegrityViolationException e) {
        hasDuplicate = true;;
        log.warn("중복된 위/경도를 가진 주차장이 있어 일부 저장되지 않았습니다: {}", e.getMessage());
    }

...

    private void bulkInsertParkingLots(List<ParkingLot> parkingLots) {
        String sql = """
                INSERT INTO parking_lot
                  (owner_id, name, address, latitude, longitude,
                   opened_at, closed_at, price_per_hour,
                   description, quantity, charge_type,
                   source_type, status, created_at,  modified_at)
                VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
                """;

        jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() {
            @Override
            public void setValues(PreparedStatement ps, int i) throws SQLException {
                ParkingLot pl = parkingLots.get(i);
                ps.setLong(1, pl.getOwner().getId());
                ps.setString(2, pl.getName());
                ps.setString(3, pl.getAddress());
                ps.setDouble(4, pl.getLatitude());
                ps.setDouble(5, pl.getLongitude());
                ps.setTime(6, Time.valueOf(pl.getOpenedAt()));
                ps.setTime(7, Time.valueOf(pl.getClosedAt()));
                ps.setBigDecimal(8, pl.getPricePerHour());
                ps.setString(9, pl.getDescription());
                ps.setInt(10, pl.getQuantity());
                ps.setString(11, pl.getChargeType().name());
                ps.setString(12, pl.getSourceType().name());
                ps.setString(13, pl.getStatus().name());
                Timestamp now = new Timestamp(System.currentTimeMillis());
                ps.setTimestamp(14, now);
                ps.setTimestamp(15, now);
            }

            @Override
            public int getBatchSize() {
                return parkingLots.size();
            }
        });
    }

    private void bulkInsertImages(List<ParkingLot> parkingLots) {
        String sql = """
                INSERT INTO parking_lot_image
                  (parking_lot_id, image_url, created_at, modified_at)
                SELECT pl.id, ?, NOW(), NOW()
                  FROM parking_lot pl
                 WHERE pl.longitude = ?
                  AND pl.latitude = ?
                """;

        jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() {
            @Override
            public void setValues(PreparedStatement ps, int i) throws SQLException {
                ParkingLot pl = parkingLots.get(i);
                ps.setString(1, pl.getImages().get(0).getImageUrl());
                ps.setDouble(2, pl.getLongitude());
                ps.setDouble(3, pl.getLatitude());
            }

            @Override
            public int getBatchSize() {
                return parkingLots.size();
            }
        });
    }

 

 

대량의 데이터를 빠르게 저장하기 위해 JpaRepository 대신 JdbcTemplate을 사용하여 bulk insert를 구현했습니다.

특히 한 번에 수십 건 이상의 공공데이터를 처리해야 하므로,

BatchPreparedStatementSetter를 활용해 배치 처리 방식으로 주차장 데이터를 효율적으로 저장하고 있습니다.

 

한 가지 주의할 점은, ParkingLot과 연관된 이미지 엔티티(ParkingLotImage)는 JPA처럼 자동으로 함께 저장되지 않기 때문에,

주차장 데이터를 저장한 후, 다시 한 번 위도/경도 기준으로 매칭하여 이미지 데이터를 별도로 insert하는 로직을 구현했습니다.

따라서 실제 insert 작업은 두 번의 배치 쿼리로 나뉘어 수행됩니다.

 

 

6️⃣ 전체 코드

package com.parkez.parkinglot.service;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.parkez.parkinglot.client.kakaomap.geocode.KakaoGeocodeClient;
import com.parkez.parkinglot.client.publicData.ParkingLotData;
import com.parkez.parkinglot.client.publicData.ParkingLotDataResponse;
import com.parkez.parkinglot.domain.entity.ParkingLot;
import com.parkez.parkinglot.domain.entity.ParkingLotImage;
import com.parkez.parkinglot.domain.enums.ChargeType;
import com.parkez.parkinglot.domain.enums.SourceType;
import com.parkez.parkinglot.domain.repository.ParkingLotRepository;
import com.parkez.user.domain.entity.User;
import com.parkez.user.domain.enums.UserRole;
import com.parkez.user.service.UserReader;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.ResponseEntity;
import org.springframework.jdbc.core.BatchPreparedStatementSetter;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;

import java.math.BigDecimal;
import java.net.URI;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.sql.Time;
import java.sql.Timestamp;
import java.time.LocalTime;
import java.util.ArrayList;
import java.util.List;


@Service
@Getter
@Slf4j
@RequiredArgsConstructor
@Scheduled(cron = "0 0 2 * * *")
public class ParkingLotPublicDataService {

    private final ObjectMapper mapper = new ObjectMapper();

    @Value("${parking-lot.public-data.url}")
    private String parkingLotPublicDataUrl;

    @Value("${parking-lot.public-data.serviceKey}")
    private String serviceKey;

    @Value("${parking-lot.default-image-url}")
    private String defaultParkingLotImageUrl;

    private final RestTemplate restTemplate = new RestTemplate();
    private final ParkingLotRepository parkingLotRepository;
    private final UserReader userReader;

    private final JdbcTemplate jdbcTemplate;

    private static final String description = "공공데이터로 등록한 주차장입니다.";
    private int currentPage = 1;
    private final int perPage = 2;

    @Value("${parking-lot.public-data.admin-email}")
    private String adminEmail;

    private final KakaoGeocodeClient kakaoGeocodeClient;

    @Transactional
    public void fetchAndSavePublicData() {
        try {
            URI uri = UriComponentsBuilder.fromUriString(parkingLotPublicDataUrl)
                    .queryParam("page", currentPage)
                    .queryParam("perPage", perPage)
                    .queryParam("serviceKey", serviceKey)
                    .build()
                    .encode()
                    .toUri();
            log.info("공공데이터 요청 URL : {}", uri);

            ResponseEntity<ParkingLotDataResponse> responseEntity = restTemplate.getForEntity(uri, ParkingLotDataResponse.class);
            ParkingLotDataResponse dataResponse = responseEntity.getBody();

            if (dataResponse == null || dataResponse.getData() == null) {
                currentPage = 1;
                log.info("받은 데이터가 없으므로, 인덱스를 1로 초기화");
                return;
            }

            List<ParkingLotData> dataList = dataResponse.getData();
            List<ParkingLot> parkingLots = dataList.stream()
                    .map(this::convertToParkingLot)
                    .toList();

            try {
                bulkInsertParkingLots(parkingLots);
                bulkInsertImages(parkingLots);
            } catch (DataIntegrityViolationException e) {
                hasDuplicate = true;;
                log.warn("중복된 위/경도를 가진 주차장이 있어 일부 저장되지 않았습니다: {}", e.getMessage());
            }

            currentPage = (dataList.size() < perPage) ? 1 : currentPage + 1;

        } catch (Exception e) {
            log.error("API 호출 중 에러 발생", e);
            throw e;
        }
    }

    private void bulkInsertParkingLots(List<ParkingLot> parkingLots) {
        String sql = """
                INSERT INTO parking_lot
                  (owner_id, name, address, latitude, longitude,
                   opened_at, closed_at, price_per_hour,
                   description, quantity, charge_type,
                   source_type, status, created_at,  modified_at)
                VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
                """;

        jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() {
            @Override
            public void setValues(PreparedStatement ps, int i) throws SQLException {
                ParkingLot pl = parkingLots.get(i);
                ps.setLong(1, pl.getOwner().getId());
                ps.setString(2, pl.getName());
                ps.setString(3, pl.getAddress());
                ps.setDouble(4, pl.getLatitude());
                ps.setDouble(5, pl.getLongitude());
                ps.setTime(6, Time.valueOf(pl.getOpenedAt()));
                ps.setTime(7, Time.valueOf(pl.getClosedAt()));
                ps.setBigDecimal(8, pl.getPricePerHour());
                ps.setString(9, pl.getDescription());
                ps.setInt(10, pl.getQuantity());
                ps.setString(11, pl.getChargeType().name());
                ps.setString(12, pl.getSourceType().name());
                ps.setString(13, pl.getStatus().name());
                Timestamp now = new Timestamp(System.currentTimeMillis());
                ps.setTimestamp(14, now);
                ps.setTimestamp(15, now);
            }

            @Override
            public int getBatchSize() {
                return parkingLots.size();
            }
        });
    }

    private void bulkInsertImages(List<ParkingLot> parkingLots) {
        String sql = """
                INSERT INTO parking_lot_image
                  (parking_lot_id, image_url, created_at, modified_at)
                SELECT pl.id, ?, NOW(), NOW()
                  FROM parking_lot pl
                 WHERE pl.longitude = ?
                  AND pl.latitude = ?
                """;

        jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() {
            @Override
            public void setValues(PreparedStatement ps, int i) throws SQLException {
                ParkingLot pl = parkingLots.get(i);
                ps.setString(1, pl.getImages().get(0).getImageUrl());
                ps.setDouble(2, pl.getLongitude());
                ps.setDouble(3, pl.getLatitude());
            }

            @Override
            public int getBatchSize() {
                return parkingLots.size();
            }
        });
    }

    // 받아온 정보를 엔티티로 변경
    private ParkingLot convertToParkingLot(ParkingLotData data) {
        Double latitude = parseDouble(data.getLatitude());
        Double longitude = parseDouble(data.getLongitude());

        String getAddress = kakaoGeocodeClient.getAddress(longitude, latitude);
        String address = !StringUtils.hasText(data.getAddress()) ? getAddress : data.getAddress();
        Integer quantity = parseInteger(data.getQuantity());
        LocalTime openedAt = parseTime(data.getOpenedAt());
        LocalTime closedAt = parseTime(data.getClosedAt());
        BigDecimal bigDecimal = BigDecimal.ZERO;
        SourceType sourceType = SourceType.PUBLIC_DATA;
        ChargeType chargeType = parseChargeType(data.getChargeType());

        User user = userReader.getUserByEmailAndRole(adminEmail, UserRole.ROLE_ADMIN);

        List<ParkingLotImage> images = new ArrayList<>();
        ParkingLotImage defaultImage = ParkingLotImage.builder()
                .imageUrl(defaultParkingLotImageUrl)
                .build();

        ParkingLot parkingLot = ParkingLot.builder()
                .owner(user)
                .name(data.getName())
                .address(address)
                .latitude(latitude)
                .longitude(longitude)
                .openedAt(openedAt)
                .closedAt(closedAt)
                .pricePerHour(bigDecimal)
                .description(description)
                .quantity(quantity)
                .sourceType(sourceType)
                .images(images)
                .chargeType(chargeType)
                .build();

        defaultImage.updateParkingLot(parkingLot);
        images.add(defaultImage);

        return parkingLot;
    }

    private LocalTime parseTime(String timeStr) {
        if (!StringUtils.hasText(timeStr)) {
            return LocalTime.of(0, 0);
        }
        return LocalTime.parse(timeStr);
    }

    private Double parseDouble(String value) {
        try {
            return Double.valueOf(value);
        } catch (Exception e) {
            log.error("Double로 변환 실패, 입력 값 : {}", value, e);
            return null;
        }
    }

    private Integer parseInteger(String value) {
        try {
            return Integer.valueOf(value);
        } catch (Exception e) {
            log.error("Integer로 변환 실패, 입력 값 : {}", value, e);
            return null;
        }
    }

    private ChargeType parseChargeType(String chargeTypeStr) {
        if ("무료".equalsIgnoreCase(chargeTypeStr)) {
            return ChargeType.FREE;
        } else if ("유료".equalsIgnoreCase(chargeTypeStr)) {
            return ChargeType.PAID;
        } else {
            return ChargeType.NO_DATA;
        }
    }
}

 

 

 

3. 개선점 및 회고


(1) 회고


이번 프로젝트에서 공공데이터 API 연동 시 Spring에서 제공하는 RestTemplate을 사용하여 데이터를 호출하고 처리했습니다.

 

RestTemplate을 사용한 이유는 위 기능은 주기적으로 실행되는 단일 작업이기 때문에

대규모 동시 요청이나 스트리밍 처리가 필요하지 않았기 때문입니다.

 

또한 RestTemplate은 사용법이 간단하고 직관적이기 때문에,

학습 비용 없이 빠르게 기능을 구현할 수 있었습니다.

 

위와 같은 이유로 동기 방식의 RestTemplate이 현재의 프로젝트에서 적합하다고 판단했습니다.

 

이번 경험을 통해 RestTemplate의 사용 흐름과 내부 작동 방식에 대해 더 깊이 이해할 수 있었고,

특히 URI 구성, DTO 매핑, 대량 저장 최적화 등을 경험할 수 있어 의미 있는 작업이었습니다.

 

 

(2) 개선점


현재 작성한 ParkingLotPublicDataService 클래스는

API 호출, 응답 파싱, 엔티티 변환, DB 저장 등 다양한 책임이 한 클래스에 집중되어 있어

단일 책임 원칙 (Single Responsibility Principle)에 위배되는 구조입니다.

따라서 추후 책임을 분리하는 리팩토링을 진행하고자 합니다.

 

또한 이 기능은 하루에 1~2회 정도 실행하는 작업익 때문에

효율적인 자원 사용과 서버 비용을 절감하기 위해 Spring Scheduler 대신

서버리스 기반 FaaS 서비스인 AWS Lambda + EventBridge를 사용하여 성능 최적화할 예정입니다.

 

 

[블로그 글 더 보러가기]

[Java] AWS Lambda를 이용하여 특정 시간에 외부 API 호출하기

 

728x90
저작자표시 비영리 변경금지 (새창열림)
'Framework/Spring' 카테고리의 다른 글
  • [Spring] @Builder 사용의 이점
  • [Spring] 캐시를 이용한 인기 검색어 조회 기능 개발 로그
  • Spring Security와 JWT를 이용한 인증/인가 정리
  • [플러스 주차 개인과제] QueryDSL을 사용하여 검색 기능 구현하기
leonie.
leonie.
  • leonie.
    leveloper
    leonie.
  • 글쓰기 관리
    • 분류 전체보기
      • Language
        • Java
      • Git
      • CS
      • CodingTest
        • [프로그래머스] 자바
      • Framework
        • Spring
      • Information
      • DBMS
        • Redis
        • SQL
      • AWS
      • OS
        • Mac
      • 자격증
        • 정보처리기사
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    JPA
    Hibernate
    springboot
    프로그래머스
    알고리즘
    Java
    코딩테스트
    자바
    의존성주입
    스프링
  • 최근 댓글

  • hELLO· Designed By정상우.v4.10.3
leonie.
[Spring] RestTemplate을 활용한 공공데이터 API 호출하기
상단으로

티스토리툴바