지금 진행 중인 WHOA 서비스는 @Scheduler cron annotation을 통해 매주 월요일 자정마다 외부 API 호출을 하여 꽃 정보를 가져오고 있다. 기존 스케쥴러 작동 방식은 아래와 같다.
1. localDateTime now()를 통해 dateFormatter로 외부 api 요청 형식에 맞게 포매팅을 해서 service-key로 화훼 사이트에서 데이터를 요청한다
2. restTemplate를 통해 apiUrl로 get 요청을 보내 JsonNode 형식의 객체를 응답 받는다
3. 응답 json 형식에 따라 JsonNode 객체를 읽기 위해 readTree, path 내장함수를 이용해 key-value 형식의 값을 읽어들인다
4. 외부 api 응답 값 중 db에 저장해야 하는 데이터를 선별하여 Map<String, String> 객체에 저장한 뒤 update를 친다
(비즈니스 로직 상 매번 외부 api를 정해진 개수만큼 읽어들이고 그것만 반환해야 하기 때문에 insert가 아니라 update를 해서 외부 api 응답 결과를 저장하는 테이블의 row는 항상 일정하다)
문제 상황
1. 스프링 공식 문서에서 restTemplate보다 webClient를 통한 호출을 추천하고 있다. restTemplate는 deprecated 상태인데 이 말이 없어진다는 것이라는 개념보다는 유지 보수 모드에 들어갔다라는 관점으로 나는 해석을 하고 있어서 앞으로 새롭게 업데이트될 가능성이 많은 webClient를 선택하기로 했다.
2. 현재 스케쥴러가 외부 api를 주기적으로 호출을 하지만 response가 빈 배열일 경우 업데이트할 데이터가 없어 업데이트 날짜가 갱신 되지 않아서 프론트는 매번 과거의 날짜 데이터만을 보여주게 되는 문제가 있었다. 그래서 이 상황일 경우 스케쥴러가 돌아가고 있다는 암시를 하기 위해 flower_rank_date 칼럼을 scheduler가 데이터를 받아오는 시점으로 매번 업데이트 시키는 로직이 필요했다.
해결 방안
모든 코드는 위 링크에서 확인이 가능하다.
위 문제 상황에 대한 각각의 해결 방안은 아래와 같다.
webClient + response dto 추가
기존에 restTemplate 로직의 경우 아래와 같은 한계가 존재했다. 이 부분을 webClient 도입을 통해 해결한 방법도 함께 작성한다.
WebClient webClient = WebClient.builder().baseUrl(apiUrl).build();
try {
WebClientResponse response = webClient.get()
.retrieve()
.onStatus(status -> status.is4xxClientError(), clientResponse -> {
return Mono.error(new RuntimeException("4xx 에러 발생"));
})
.onStatus(status -> status.is5xxServerError(), clientResponse -> {
return Mono.error(new RuntimeException("5xx 에러 발생"));
})
.bodyToMono(WebClientResponse.class)
.block();
if (response != null && response.getResponse() != null) {
logger.info("외부 API 호출 응답 : {}", response.getResponse());
processResponseData(response.getResponse(), formattedDate);
} else {
logger.error("외부 API Response는 응답 실패");
}
} catch (RuntimeException e) {
throw new RuntimeException("스케쥴러 동작 중 에러 발생", e);
}
1. restTemplate이 data를 받아오는 로직에 집중한 나머지 스케쥴러에서 외부 api 요청을 할 때 httpStatus에 따른 예외 처리를 하지 못하였다.
=> 이 부분을 webClient에서 지원하는 retrieve()를 통해 응답을 받고 onStatus 절차를 통해 4xx, 5xx 응답에 대해서 별도로 예외처리를 하여 통신 진행 상황을 점검할 수 있었다.
2. restTemplate에서 JsonNode 형태로 데이터를 응답받아 tree, map 구조 형식을 이용해서 응답의 key에 따른 value를 하나하나 찾아 실제 비즈니스 로직에 쓰일 외부 데이터를 Map<String, String> 형식으로 담으려고 했기에 데이터 타입을 추가적으로 string으로 파싱해서 put해야했던 불편함이 존재했다.
=> 이 부분을 WebClientResponse dto를 별도로 정의하여 외부 api 응답 구조에 맞게 필드를 작성하고 내부에 static class까지 중첩 시켜 api 응답을 dto로 응답해 가독성을 향상시켰다. 또한, dto를 통해 타입 안정성을 높였고 map으로 받을 경우 key 오타가 발생할 우려가 있는데 필드명이 코드 상에서 명확하게 관리되어 유지 보수성도 높일 수 있었다.
=> webClient의 bodyToMono를 통해 간단하게 내가 정의한 dto 객체로 비동기로 역직렬화 매핑을 시킬 수 있었다.
3. 동기적 처리
이 스케쥴러는 db에 update 시켜야 하기 때문에 순차적으로 처리가 되어야 했다. restTemplate는 동기적 처리만 가능하고 webClient는 비동기까지 지원하여 여러 선택지가 있는데 응답이 준비 될 때까지 현재 쓰레드를 막는 block 처리를 하여 동기 처리를 하였다.
bulk update
기존 restTemplate 로직에는 통신에 성공하여 응답이 성공적으로 왔으나 응답되는 data가 빈 배열로 올 경우를 처리하지 않아 db에 최신 data를 update를 할 수 없어 프론트에서 이러한 상황일 때 과거의 데이터를 누적해서 볼 수 밖에 없는 상황이었다.
그래서 dto로 기준이 되는 응답 필드가 빈배열일 경우 일단 스케쥴러가 어떤 시간대에 실행이 되었는지 날짜 데이터를 갱신하여 프론트한테 백에서는 업데이트를 계속 하고 있음을 응답함으로써 프론트에서 표시할 수 있게끔 하였다.
그 로직은 아래와 같다.
스케쥴러 실행 날짜 String 형식의 LocalDateTime의 날짜를 formatter를 통해 yyyy-mm-dd 형식으로 update를 해야 했고 insert가 아니라 외부 데이터 update이기 때문에 서두에서 말한 것처럼 행의 개수가 5개로 항상 일정했다. (쿼리 개수 때문에 row 수가 중요했다.)
@Transactional
public void updateOnlyFlowerRankingDateToFormattedDate(String formattedDate) {
List<FlowerRanking> flowerRankings = flowerRankingRepository.findAll();
for (FlowerRanking flowerRanking : flowerRankings) {
flowerRanking.updateFlowerRankingDate(formattedDate);
}
flowerRankingRepository.saveAll(flowerRankings);
}
public void updateFlowerRankingDate(String schedulerTime) {
this.flowerRankingDate = schedulerTime;
}
그 결과 쿼리는 아래와 같았다.
findAll을 하는 select 쿼리 1번, row 5개에 대해서 update를 진행하는 5번의 쿼리이다.
2024-12-07T15:04:02.494+09:00 INFO 22700 --- [ restartedMain] c.whoa.whoaserver.WhoaserverApplication : Started WhoaserverApplication in 16.084 seconds (process running for 18.012)
스케쥴러 실행 시작
2024-12-07T15:05:00.539+09:00 DEBUG 22700 --- [ scheduling-1] o.s.w.r.f.client.ExchangeFunctions : [5281df4d] HTTP GET https://flower.at.or.kr/api/returnData.api?kind=f001&serviceKey=서비스키&baseDate=2024-12-07&flowerGubn=1&dataType=json
2024-12-07T15:05:02.274+09:00 DEBUG 22700 --- [ctor-http-nio-2] o.s.w.r.f.client.ExchangeFunctions : [5281df4d] [ec9c22e4-1] Response 200 OK
2024-12-07T15:05:02.408+09:00 DEBUG 22700 --- [ctor-http-nio-2] org.springframework.web.HttpLogging : [5281df4d] [ec9c22e4-1] Decoded [WebClientResponse(response=WebClientResponse.Response(resultMsg=OK, numOfRows=0, items=[]))]
WebClientResponse.Response(resultMsg=OK, numOfRows=0, items=[])
2024-12-07T15:05:02.408+09:00 INFO 22700 --- [ scheduling-1] c.w.w.scheduler.FlowerCrawlerScheduler : 외부 API Response는 정상적으로 얻었으나 새로운 데이터가 없어 기존 데이터를 유지
Hibernate:
select
fr1_0.flower_ranking_id,
fr1_0.flower_id,
fr1_0.flower_image,
fr1_0.flower_ranking_date,
fr1_0.flower_ranking_language,
fr1_0.flower_ranking_name,
fr1_0.flower_ranking_price
from
flower_ranking fr1_0
// 랭킹 테이블 row 5개 있어서 update 쿼리 5번이 찍힌다
Hibernate:
update
flower_ranking
set
flower_id=?,
flower_image=?,
flower_ranking_date=?,
flower_ranking_language=?,
flower_ranking_name=?,
flower_ranking_price=?
where
flower_ranking_id=?
2024-12-07T15:05:02.823+09:00 TRACE 22700 --- [ scheduling-1] org.hibernate.orm.jdbc.bind : binding parameter (1:BIGINT) <- [2]
2024-12-07T15:05:02.823+09:00 TRACE 22700 --- [ scheduling-1] org.hibernate.orm.jdbc.bind : binding parameter (2:VARCHAR) <- [https://whoa-bucket.s3.ap-northeast-2.amazonaws.com/flower/8424e9c7-ff57-4b8d-8640-00ba1caa988e%E1%84%80%E1%85%AE%E1%86%A8%E1%84%92%E1%85%AA_%E1%84%87%E1%85%A9.png]
2024-12-07T15:05:02.823+09:00 TRACE 22700 --- [ scheduling-1] org.hibernate.orm.jdbc.bind : binding parameter (3:VARCHAR) <- [2024-12-07]
2024-12-07T15:05:02.823+09:00 TRACE 22700 --- [ scheduling-1] org.hibernate.orm.jdbc.bind : binding parameter (4:VARCHAR) <- [나는 당신을 사랑합니다]
2024-12-07T15:05:02.824+09:00 TRACE 22700 --- [ scheduling-1] org.hibernate.orm.jdbc.bind : binding parameter (5:VARCHAR) <- [국화]
2024-12-07T15:05:02.824+09:00 TRACE 22700 --- [ scheduling-1] org.hibernate.orm.jdbc.bind : binding parameter (6:VARCHAR) <- [1951]
2024-12-07T15:05:02.824+09:00 TRACE 22700 --- [ scheduling-1] org.hibernate.orm.jdbc.bind : binding parameter (7:BIGINT) <- [1]
Hibernate:
update
flower_ranking
set
flower_id=?,
flower_image=?,
flower_ranking_date=?,
flower_ranking_language=?,
flower_ranking_name=?,
flower_ranking_price=?
where
flower_ranking_id=?
2024-12-07T15:05:02.841+09:00 TRACE 22700 --- [ scheduling-1] org.hibernate.orm.jdbc.bind : binding parameter (1:BIGINT) <- [4]
2024-12-07T15:05:02.841+09:00 TRACE 22700 --- [ scheduling-1] org.hibernate.orm.jdbc.bind : binding parameter (2:VARCHAR) <- [https://whoa-bucket.s3.ap-northeast-2.amazonaws.com/flower/79f17f87-d131-4c0e-a325-d5df542f53fe%EA%B8%88%EC%96%B4%EC%B4%88_%EB%B3%B4.png]
2024-12-07T15:05:02.841+09:00 TRACE 22700 --- [ scheduling-1] org.hibernate.orm.jdbc.bind : binding parameter (3:VARCHAR) <- [2024-12-07]
2024-12-07T15:05:02.842+09:00 TRACE 22700 --- [ scheduling-1] org.hibernate.orm.jdbc.bind : binding parameter (4:VARCHAR) <- [우아함, 애정, 기쁨, 감사]
2024-12-07T15:05:02.842+09:00 TRACE 22700 --- [ scheduling-1] org.hibernate.orm.jdbc.bind : binding parameter (5:VARCHAR) <- [금어초]
2024-12-07T15:05:02.842+09:00 TRACE 22700 --- [ scheduling-1] org.hibernate.orm.jdbc.bind : binding parameter (6:VARCHAR) <- [2158]
2024-12-07T15:05:02.843+09:00 TRACE 22700 --- [ scheduling-1] org.hibernate.orm.jdbc.bind : binding parameter (7:BIGINT) <- [2]
Hibernate:
update
flower_ranking
set
flower_id=?,
flower_image=?,
flower_ranking_date=?,
flower_ranking_language=?,
flower_ranking_name=?,
flower_ranking_price=?
where
flower_ranking_id=?
2024-12-07T15:05:02.853+09:00 TRACE 22700 --- [ scheduling-1] org.hibernate.orm.jdbc.bind : binding parameter (1:BIGINT) <- [11]
2024-12-07T15:05:02.853+09:00 TRACE 22700 --- [ scheduling-1] org.hibernate.orm.jdbc.bind : binding parameter (2:VARCHAR) <- [https://whoa-bucket.s3.ap-northeast-2.amazonaws.com/flower/be4a486a-07e8-4da2-b2af-f9e87d7e442d%E1%84%87%E1%85%A2%E1%86%A8%E1%84%92%E1%85%A1%E1%86%B8_%E1%84%92%E1%85%B4%E1%86%AB.png]
2024-12-07T15:05:02.853+09:00 TRACE 22700 --- [ scheduling-1] org.hibernate.orm.jdbc.bind : binding parameter (3:VARCHAR) <- [2024-12-07]
2024-12-07T15:05:02.853+09:00 TRACE 22700 --- [ scheduling-1] org.hibernate.orm.jdbc.bind : binding parameter (4:VARCHAR) <- [순수, 깨끗한 사랑, 당신과 함께 있으니 꿈만 같아요]
2024-12-07T15:05:02.854+09:00 TRACE 22700 --- [ scheduling-1] org.hibernate.orm.jdbc.bind : binding parameter (5:VARCHAR) <- [백합]
2024-12-07T15:05:02.854+09:00 TRACE 22700 --- [ scheduling-1] org.hibernate.orm.jdbc.bind : binding parameter (6:VARCHAR) <- [3215]
2024-12-07T15:05:02.854+09:00 TRACE 22700 --- [ scheduling-1] org.hibernate.orm.jdbc.bind : binding parameter (7:BIGINT) <- [3]
Hibernate:
update
flower_ranking
set
flower_id=?,
flower_image=?,
flower_ranking_date=?,
flower_ranking_language=?,
flower_ranking_name=?,
flower_ranking_price=?
where
flower_ranking_id=?
2024-12-07T15:05:02.863+09:00 TRACE 22700 --- [ scheduling-1] org.hibernate.orm.jdbc.bind : binding parameter (1:BIGINT) <- [1]
2024-12-07T15:05:02.863+09:00 TRACE 22700 --- [ scheduling-1] org.hibernate.orm.jdbc.bind : binding parameter (2:VARCHAR) <- [https://whoa-bucket.s3.ap-northeast-2.amazonaws.com/flower/cf28036a-6b38-452b-b93a-ffbd238a7281%E1%84%80%E1%85%A5%E1%84%87%E1%85%A6%E1%84%85%E1%85%A1_%E1%84%87%E1%85%A9.png]
2024-12-07T15:05:02.863+09:00 TRACE 22700 --- [ scheduling-1] org.hibernate.orm.jdbc.bind : binding parameter (3:VARCHAR) <- [2024-12-07]
2024-12-07T15:05:02.864+09:00 TRACE 22700 --- [ scheduling-1] org.hibernate.orm.jdbc.bind : binding parameter (4:VARCHAR) <- [불타는 신비의 사랑]
2024-12-07T15:05:02.864+09:00 TRACE 22700 --- [ scheduling-1] org.hibernate.orm.jdbc.bind : binding parameter (5:VARCHAR) <- [거베라]
2024-12-07T15:05:02.864+09:00 TRACE 22700 --- [ scheduling-1] org.hibernate.orm.jdbc.bind : binding parameter (6:VARCHAR) <- [3244]
2024-12-07T15:05:02.864+09:00 TRACE 22700 --- [ scheduling-1] org.hibernate.orm.jdbc.bind : binding parameter (7:BIGINT) <- [4]
Hibernate:
update
flower_ranking
set
flower_id=?,
flower_image=?,
flower_ranking_date=?,
flower_ranking_language=?,
flower_ranking_name=?,
flower_ranking_price=?
where
flower_ranking_id=?
2024-12-07T15:05:02.875+09:00 TRACE 22700 --- [ scheduling-1] org.hibernate.orm.jdbc.bind : binding parameter (1:BIGINT) <- [null]
2024-12-07T15:05:02.875+09:00 TRACE 22700 --- [ scheduling-1] org.hibernate.orm.jdbc.bind : binding parameter (2:VARCHAR) <- [null]
2024-12-07T15:05:02.875+09:00 TRACE 22700 --- [ scheduling-1] org.hibernate.orm.jdbc.bind : binding parameter (3:VARCHAR) <- [2024-12-07]
2024-12-07T15:05:02.875+09:00 TRACE 22700 --- [ scheduling-1] org.hibernate.orm.jdbc.bind : binding parameter (4:VARCHAR) <- [null]
2024-12-07T15:05:02.875+09:00 TRACE 22700 --- [ scheduling-1] org.hibernate.orm.jdbc.bind : binding parameter (5:VARCHAR) <- [유칼립투스]
2024-12-07T15:05:02.875+09:00 TRACE 22700 --- [ scheduling-1] org.hibernate.orm.jdbc.bind : binding parameter (6:VARCHAR) <- [3827]
2024-12-07T15:05:02.876+09:00 TRACE 22700 --- [ scheduling-1] org.hibernate.orm.jdbc.bind : binding parameter (7:BIGINT) <- [5]
하지만 항상 일관된 5개의 row에 대해서 한꺼번에 update를 시키기 위해 bulk insert를 해서 성능을 개선해야겠다는 생각을 하게 되었다.
@Transactional
public void updateOnlyFlowerRankingDateToFormattedDate(String formattedDate) {
flowerRankingRepository.updateAllFlowerRankingDates(formattedDate);
entityManager.clear();
}
public interface FlowerRankingRepository extends JpaRepository<FlowerRanking, Long>, FlowerRankingRepositoryCustom {
// 생략
@Modifying
@Transactional
@Query(value = "UPDATE flower_ranking SET flower_ranking_date = :formattedDate", nativeQuery = true)
void updateAllFlowerRankingDates(@Param("formattedDate") String formattedDate);
}
그 결과 아래와 같이 findAll select 쿼리 없이 하나의 쿼리로 모든 row를 업데이트 할 수 있었다.
2024-12-07T16:02:00.003+09:00 INFO 23920 --- [ scheduling-1] c.w.w.scheduler.FlowerCrawlerScheduler : 스케쥴러 실행 시작
2024-12-07T16:02:00.004+09:00 DEBUG 23920 --- [ scheduling-1] o.s.w.r.f.client.ExchangeFunctions : [62c8ed6b] HTTP GET https://flower.at.or.kr/api/returnData.api?kind=f001&serviceKey=서비스키&baseDate=2024-12-07&flowerGubn=1&dataType=json
2024-12-07T16:02:00.605+09:00 DEBUG 23920 --- [ctor-http-nio-1] o.s.w.r.f.client.ExchangeFunctions : [62c8ed6b] [26be5c10-1] Response 200 OK
2024-12-07T16:02:00.606+09:00 DEBUG 23920 --- [ctor-http-nio-1] org.springframework.web.HttpLogging : [62c8ed6b] [26be5c10-1] Decoded [WebClientResponse(response=WebClientResponse.Response(resultMsg=OK, numOfRows=0, items=[]))]
2024-12-07T16:02:00.607+09:00 INFO 23920 --- [ scheduling-1] c.w.w.scheduler.FlowerCrawlerScheduler : 외부 API 호출 응답 : WebClientResponse.Response(resultMsg=OK, numOfRows=0, items=[])
2024-12-07T16:02:00.607+09:00 INFO 23920 --- [ scheduling-1] c.w.w.scheduler.FlowerCrawlerScheduler : 외부 API Response는 정상적으로 얻었으나 새로운 데이터가 없어 기존 데이터를 유지
Hibernate:
UPDATE
flower_ranking
SET
flower_ranking_date = ?
2024-12-07T16:02:00.624+09:00 TRACE 23920 --- [ scheduling-1] org.hibernate.orm.jdbc.bind : binding parameter (1:VARCHAR) <- [2024-12-07]
배운점
1. bulk update를 통해 유의해야 했었던 점은 entityManager를 clear 시켜야 한다는 점이었다.
=> jpa는 entity를 영속성 컨텍스트라는 캐시에 저장을 하기 때문에 엔티티 수정/삭제 시 이 캐시를 먼저 변경하고 트랜잭션 종료 시 db에 반영하는 절차를 거치고 있다. 하지만 이 상황에서 bulk update를 할 경우 jpa를 우회하여 바로 db 값을 변경한 것이므로 영속성 컨텍스트 초기화를 시키지 않는다면 bulk update 전에 repository 메소드를 통해 가져왔던 entity 객체의 정보가 bulk update 이후에 db 값이 변경되었음에도 캐시가 아직 bulk 전의 상태를 유지하고 있어 bulk가 실행된 이후에 이전에 가져왔던 entity의 필드 값을 가져올 경우 변경 전의 data가 출력 된다.
// 영속성 컨텍스트에 FlowerRanking 엔티티가 존재한다고 가정
FlowerRanking flowerRanking = flowerRankingRepository.findById(1L).get();
System.out.println(flowerRanking.getDate()); // 출력: 2024-11-17 (영속성 컨텍스트 값)
// Bulk Update 수행
flowerRankingRepository.updateAllFlowerRankingDates("2024-12-07");
// 여전히 영속성 컨텍스트 값을 사용
System.out.println(flowerRanking.getDate()); // 출력: 2024-11-17 (변경되지 않음)
따라서 영속성 컨텍스트 캐시를 초기화함으로써 "분리 상태"를 만들어 엔티티를 조회할 때 새로운 db 상태를 반영해서 가져오도록 해야 했다.
2. jpa는 dirty checking을 통해 엔티티 업데이트를 시킨다.
첫번째 findAll로 select를 하고 updateFlowerRankingDate를 하게 될 경우 업데이트 할 값과 이미 db에 저장된 값이 같으면 update 쿼리가 나가지 않음을 확인했다. 하지만 query annotation을 통해서 정의한 dump update 쿼리는 상관 없이 매번 실행되었다. 이 원인이 무엇일까 하고 공부를 해볼 수 있는 계기가 되었다.
jpa는 영속성 컨텍스트 내의 엔티티에 대해 변경 감지 dirty checking을 통해 변경 사항이 있을 때만 쿼리를 실행하고 있었다. 영속 상태의 엔티티를 처음 조회하면 jpa는 스냅샷을 생성하여 원래 entity 상태를 저장한다. 이때 enitty 값을 변경할 경우 트랜잭션이 종료되기 전에 스냅샷과 현재 엔티티의 상태를 비교하여 변경된 값이 발견되면 update 쿼리를 생성한다. 그렇기 때문에 나는 flowerRanking의 flower_rank_date 칼럼만 바꿔서 이 부분만 set으로 쿼리가 나갈 것이라고 생각했지만 엔티티 자체의 상태를 비교하기 때문에 전체 필드에 대해서 set이 나간 것이었음도 알게 되었다. 즉, 변경 사항이 있다면 쿼리를 생성하여 실행하고 없다면 쿼리를 실행하지 않는다.
하지만 native query annotation의 경우 jpa 변경 감지 메커니즘을 우회하여 db에 직접적으로 sql을 실행하기 때문에 jpa의 변경 감지를 고려하지 않아 기존 값과 새로운 값이 같더라도 update set 쿼리는 항상 실행된다. (그래서 위에서 영속성 컨텍스트 캐시를 초기화 해서 새로 반영된 db entity를 다시 가져와야 했다.)
해당 스케쥴러는 일주일에 한번 실행 되고 날짜 기준으로 update를 하기 때문에 jpa update를 할경우 항상 6번(1번은 조회 + 5번의 update 쿼리)인데 반해 native query는 항상 한번의 쿼리를 실행하므로 후자의 방식을 선택해서 성능을 개선할 수 있었다.
이렇게 또 리펙토링 하면서 배울 수 있어서 좋았다...
이제 기말고사 가장 첫 시험 3일 남은 과목 공부하자!!!
'SpringBoot > 성능 개선 흔적들' 카테고리의 다른 글
[Spring] 반복적인 추가 쿼리 @EntityGraph와 @Cacheable을 통한 성능 개선 (2) | 2024.12.08 |
---|---|
[Spring] DTO 반환 시 필요한 응답만 QueryProjection으로 매핑하여 반환 (0) | 2024.12.07 |
[Spring] Local Cache vs Global Cache 성능 비교 실험을 통해 배운 점 (0) | 2024.08.26 |
[Spring] 레디스 없이 caffeine cache로 조회 API 성능 개선 (0) | 2024.08.26 |
[Spring] Redis @Cacheable를 이용한 조회 API 성능 개선 (0) | 2024.08.15 |