[FitFinder] Fetch Join과 Batch 조회를 통한 쿼리 최적화 여정기

2026. 4. 4. 00:38·Project

쿼리 구조 개선 여정기 관련 이미지
하나의 API에 대해서 33개의 쿼리가 발생

Project - FitFinder

0. 들어가며

 FitFinder 프로젝트는 공모전에 나가기 위해서 조금 타이트한 일정으로 기획 및 개발된 프로젝트입니다. 개발한 지는 꽤 시간이 지났지만, 좋은 추억을 만들어 준 애정이 있는 프로젝트였기 때문에 개선 사항이 있다면 개선해보고 싶었습니다. 현재는 유료로 전환된 공공데이터가 운이 좋게도 postgresql 컨테이너에 아직까지 잘 저장되어 있었습니다... 휴. 프론트도 Vercel에 아직 잘 살아 있어서 잘 작동해서 API 하나를 실행해 본 결과 쿼리가 "33개"가 나가는 것을 확인했습니다. "쿼리가 왜케 많이 나가지?"라는 생각과 함께 내부 코드를 살펴보던 중, 너무 급하게 개발했던 상황이 코드에 그대로 남겨진 것을 확인했습니다.

 

 그.래.서. 이번 글에서는 쿼리가 33개 나가게 된 이유를 파악하고, "배치 조회"와 "JPA 영속성"에 대한 개념을 활용하여 결과적으로 같은 결과를 얻기 위해 몇 개의 쿼리만 나가면 되는지 그 과정을 정리해보려고 합니다! 그럼 렛츠고!

1. 프로젝트 설명

쿼리 구조 개선 여정기 관련 이미지
AI 운동 루틴 생성 기능

 FitFinder 프로젝트는 KSPO 공공데이터 활용 경진대회를 준비하면서 개발한 프로젝트로, 성별, 연령, 위치, 선호 종목을 분석하여 최적의 운동 장소와 맞춤형 주간 루틴을 추천해 주는 서비스입니다. 해당 프로젝트에 사용된 데이터 셋은 국민체육진흥공단에서 제공하는 "공공체육시설 프로그램 및 시설 데이터(프로그램명, 종목, 대상, 요일, 시간대, 이용요금, 위치정보, 시설명, 주소, 좌표 등)"입니다. 해당 원본 데이터 셋에서 결측치, 이상치를 정제 및 보완 작업을 진행하여 서비스에 바로 사용할 수 있도록 했습니다.

2. 쿼리 최적화 여정기

그럼 시작해보도록 하겠습니다.

2.1 상황 파악

 우선, 복잡하지 않은 API 하나를 호출했을 뿐인데, "33개"의 쿼리가 발생했는지 원인을 알고 싶었습니다. 제가 호출한 API는 유저의 데이터(신체 정보, 위도, 경도, 좋아하는 운동 종목 등)를 토대로 10km 이내에 있는 운동 프로그램들을 찾고, 해당 운동 프로그램이 진행되는 시설과 그 시설까지의 가장 빠른 교통수단 Top2 정보를 반환하는 API였습니다. 

쿼리 구조 개선 여정기 관련 이미지
공공 데이터 ERD 일부

위 테이블 정보를 토대로 필요한 데이터들을 정리해보면 다음과 같습니다.

  1. 유저가 전달한 여러 정보들을 토대로 10km 이내에 있는 프로그램(program)을 찾는다.
  2. 프로그램 정보를 토대로 -> 프로그램이 진행되는 시설(facility)을 찾는다.
  3. 시설 정보를 토대로 -> 시설까지 갈 수 있는 교통수단(facility_transit) 중 가장 빠른 2개를 찾는다.
...
    var query = queryFactory
        .select(
            p.id,
            p.progrmNm,
            p.progrmEstblWkdayNm,
            p.progrmEstblTiznValue,
            p.facility.fcltyNm,
            p.progrmTyNm,
            p.progrmTyNmDetail,
            distance
        )
        .from(p)
        .where(where)
        .orderBy(distance.asc(), p.id.asc());

    var tuples = query
        .limit(pageSize + 1)
        .fetch();
 ...

 fetch() 명령어가 작동되는 순간, 동적으로 적용된 쿼리가 작동되면서 List <> 형태로 데이터를 가져오고, 이걸 stream().map()을 통해서 지정한 DTO 객체로 변환하여 최종적으로는 List <DTO> 형태가 됩니다. 여기서 쿼리가 "1번" 나갑니다.

...
    List<Long> programIds = programs.stream()
        .map(ProgramInfoResponse::programId)
        .toList();

    List<Program> programEntities = programRepository.findAllById(programIds);
    Map<Long, Program> programMap = programEntities.stream()
    .collect(Collectors.toMap(Program::getId, Function.identity()));
...

 그다음 진행되는 쿼리는 위와 같습니다. "programs"가 위에서 받아온 List <DTO> 객체입니다. 이 데이터에서 programId 식별자만 List <Long>으로 받아서 따로 가져옵니다. 단일 쿼리보다는 List <> 형으로 받아서 배치 조회를 하기 위함이죠. 여기까지는 좋은 것 같습니다. (참고로, 현재 제가 살고 있는 위치를 기준으로 총 "23개"의 프로그램을 얻을 수 있었습니다.) 결과적으로 배치 조회로 인해서 "findAllById(programIds)"를 통해 "1번"의 쿼리가 발생합니다. 그러고 나서는, 식별자와 객체를 연결해 주는 Map 객체를 하나생성합니다.

...	
   return programs.stream()
        .map(programInfoResponse -> {
          ProgramDetailInfoResponse programDetailInfoResponse =
              programService.getProgram(programInfoResponse.programId());

          List<TransportData> transports = programDetailInfoResponse.transportData();
          Program program = programMap.get(programInfoResponse.programId());

          double distance = 0.0;
          if (program != null && program.getFacility() != null) {
            distance = calculateDistance(
                request.latitude(),
                request.longitude(),
                program.getFacility().getFcltyLa(),
                program.getFacility().getFcltyLo()
            );
          }

          return new RecommendProgramData(
              programInfoResponse,
              transports,
              distance
          );
        })
        .toList();
  }
...

  public ProgramDetailInfoResponse getProgram(Long programId) {

    Program program = programRepository.findById(programId).orElseThrow(
        () -> new CustomException(NOT_FOUND_PROGRAM)
    );

    Long facilityId = program.getFacility().getId();
    Facility facility = facilityRepository.findById(facilityId).orElseThrow(
        () -> new CustomException(NOT_FOUND_PROGRAM)
    );

    List<TransportDataRaw> top2Transit = transitRepository.findTop2Transit(facilityId);

    return ProgramDetailInfoResponse.from(program, facility, top2Transit);
  }
...

 여기서 아주 무자비한 일이 발생했었습니다. program 식별자 값을 토대로 프로그램을 찾고, 해당 프로그램과 연결된 시설 데이터를 찾고, 시설 데이터 식별자를 통해서 해당 시설까지 갈 수 있는 가장 빠른 교통수단 Top2를 가져오는 것이었습니다. 

 

 이전에 참고사항으로 말씀드린 대로, 프로그램이 23개 있으니 프로그램과 연결된 시설도 23개가 나올 것이고, 그와 관련된 교통수단 Top2 정보다 23개가 나올 것입니다. 그럼 1 + 1 + Loop(23) * 3(program, facility, top2Transit) = "71개"가 발생해야 하는데, 왜 33개의 쿼리가 발생했을까요? 이것은 "JPA 영속성 컨텍스트"와 깊은 관련이 있습니다.

2.2 JPA 영속성 컨텍스트 (Persistance Context)

 "JPA 영속성 컨텍스트"는 JPA 구현체인 Hibernate의 "EntityManager가 관리하는 논리적인 공간"입니다. 이 공간에서는 무슨 작업을 할까요? 영속성(Persistence, 지속성)이라는 단어의 의미처럼 같은 트랜잭션 내에서 Entity를 조회하여 사용했다면 사용하고 바로 삭제하는 것이 아니라, 지속적으로 상태를 관리하고 변경 사항을 추적하는 역할을 수행합니다. 따라서, DB 접근을 줄여 효율적인 ORM을 할 수 있다는 큰 이점을 얻을 수 있는 것이죠. 3가지 대표적인 기능은 다음과 같습니다.

  1. 1차 캐시
  2. 쓰기 지연 (Write-Behind)
  3. 변경 감지 (Dirty-Checking)

여기서 1차 캐시는 JPA 영속성 컨텍스트 내부에 있는 L1 Cache로 찾고자 하는 Entity를 먼저 1차 캐시에서 찾고, 있으면 쿼리 수행없이 바로 사용하고, 없다면 DB에 접근하여 해당 Entity를 가져옵니다. 이 1차 캐시로 인해서 "71개"가 발생해야 하는 쿼리가 "33개"개만 발생하는 것입니다. 다시 살펴보겠습니다.

...
    List<Program> programEntities = programRepository.findAllById(programIds);
...

 위 코드 일부에서 programId 모아서 한 번에 배치조회하는 것을 확인할 수 있습니다. 즉, 1차 캐시에 없기 때문에 DB에 쿼리를 날려서 먼저 데이터들을 가져오고, 1차 캐시에 Entity들을 저장해 놓습니다. 

...

  public ProgramDetailInfoResponse getProgram(Long programId) {

    Program program = programRepository.findById(programId).orElseThrow(
        () -> new CustomException(NOT_FOUND_PROGRAM)
    );
...

 getProgram() 메서드에서는 이전에 불러온 programId를 가지고 한번 더 Entity를 가져옵니다. programId는 Loop를 돌면서 23개가 있고, findById(programId)로 인해 23개의 쿼리가 나가야 하는데, 실제로는 나가지 않는 것을 확인했습니다. 그 이유는 같은 트랜잭션 내에서 이미 영속성 컨텍스트 내부 1차 캐시에 해당 Entity들이 존재하기 때문에 바로 가져다 쓰면 되기 때문이죠.

 

 왜 이렇게 코드를 짰나 확인해 보았습니다. getProgram() 메서드는 이미 다른 Controller에서 프로그램 상세 조회 API를 위해 쓰이는 메서드인데, 같은 기능이 필요해서 가져다가 쓰려다 보니 쿼리가 몇 번 나가는지는 체크하지 못했던 것 같습니다. 그럼 이제 "71개"가 아니라 여기서 "23개"를 빼면 "48개"가 나와야 하는데, 왜 "33개"개가 나오는 걸까요? 이것도 마찬가지로 1차 캐시와 연관이 있습니다. 그다음 코드를 살펴보도록 하겠습니다.

...
    Long facilityId = program.getFacility().getId();
    Facility facility = facilityRepository.findById(facilityId).orElseThrow(
        () -> new CustomException(NOT_FOUND_PROGRAM)
    );
...

 이 쿼리에서도 1차 캐시가 적용되고 있었습니다. 거리가 10km 이내인 프로그램들 중에서는 같은 시설에서 진행되는 것이 있어서 중복 데이터가 존재했습니다. "166, 193, 175, 178, 42, 123, 44, 16" 이렇게 8개의 시설이 23개의 프로그램에 대해서 나온 시설의 개수입니다. 즉, 만약 이전 Loop에서 166번 식별값을 가진 시설 Entity를 추출했다면 1차 캐시에 저장이 되고, 다음 Loop에서 166번 식별값을 가진 시설이 또 나온다면 DB접근 없이 1차 캐시에서 가져다 쓰는 것이죠. 그래서 "48개"에서 "23개"를 빼고, "8개"를 추가하면 "33개"가 딱 맞아떨어집니다. 

...
    List<TransportDataRaw> top2Transit = transitRepository.findTop2Transit(facilityId);
...

 이 쿼리는 Native Query로 구현되어 있어 바로 DB로 쿼리를 날려 1차 캐시를 먼저 조회하지 않기 때문에 23번이 동시에 나갈 수밖에 없었습니다. 그래서 영락없이 23개의 Loop에 대해서 "23개"의 쿼리가 나갈 수밖에 없던 것이었죠. 그럼 여기서 비효율적인 쿼리를 어떻게 줄일 수 있을까요? 

2.3 코드 리팩터링 (with Fetch Join)

 코드를 전체적으로 봤을 때, 불필요한 코드들도 많았습니다. 우선, 가장 첫 번째로 거리를 계산해서 List <DTO>에 담아서 오기 때문에 거리 계산을 다시 할 필요는 없었습니다. 제거했습니다. 그리고, 프로그램 상세 조회 API를 활용하는 것 대신에 Service 로직을 새롭게 만드는 것이 더 효율적일 것 같다는 생각이 들었습니다. 프로그램 id 리스트를 통해서 시설 데이터까지 "fetch join"을 활용하여 한번에 가져오고, 시설 데이터에서 id들을 List 자료형에 담아 "배치 조회"로 한꺼번에 가져오면 쿼리가 나가는 숫자를 확실하게 절약할 수 있다고 생각했습니다. 구현해 보도록 하겠습니다.

 

 fetch join을 떠올린 이유는 Program과 Facility의 관계가 ManyToOne 관계이기 때문입니다. 즉, Many 측인 Program으로 가져와도 결국 같은 개수만 fetch 되어 저장이 되는 것이죠. 또한, Program -> Facility를 fetch join 해야 하는 이유가 데이터 규모에 있어서도 존재합니다.

쿼리 구조 개선 여정기 관련 이미지
공공데이터 테이블 당 개수

 프로그램(Program) 데이터는 "13,048개", 시설(Facility) 데이터는 "208개", 시설과 가까운 교통 정보(Facility_Transit)는 "65160개"가 있다는 것을 확인할 수 있었습니다. Facility -> Facility_Transit 관계는 OneToMany이기도 하고, 교통 정보 데이터가 시설데이터에 비해 워낙 많기 때문에 이것까지 fetch join으로 가져오게 된다면 불필요한 행의 수가 많아질 것이라고 판단했습니다. 그리고 교통 정보는 모든 정보가 필요한 것이 아니라, Top2 데이터만 필요하므로 쿼리를 따로 날리는 것이 더 효율적이라고 생각했습니다.

@Repository
public interface ProgramRepository extends JpaRepository<Program, Long>, ProgramRepositoryCustom {

  @Query("select p from program p join fetch p.facility where p.id in :ids")
  List<Program> findAllWithFacility(@Param("ids") List<Long> ids);

}

 이렇게 그냥 JPQL을 통해서 fetch join을 구현했습니다. 그러면 Program에 담긴 Facility 데이터도 함께 담겨서 받아올 수 있고, 리스트형 배치를 통해서 한 번의 쿼리로 모든 정보를 가져올 수 있게 됐습니다. 결과적으로 fetch join으로 Facility 데이터들도 가져오면서 영속성 컨텍스트 1차 캐시에 넣어두기 때문에 "facilityRepository.findById(facilityId)"로 나가는 쿼리 "8회"가 줄어서 "25개"의 쿼리가 나가게 될 것입니다.

쿼리 구조 개선 여정기 관련 이미지
33개 -> 25개로 쿼리 실행 수 8개 감소

 직접 호출해서 "25개"의 쿼리가 나가는 것을 확인할 수 있었습니다. 그럼 이제 다음 단계로 넘어가 보도록 하겠습니다.

2.4 배치 조회 

  private List<RecommendProgramData> convertToRecommendProgramData(
      List<ProgramInfoResponse> programs
  ) {

    List<Long> programIds = programs.stream()
        .map(ProgramInfoResponse::programId)
        .toList();

    List<Program> programAllWithFacility = programRepository.findAllWithFacility(programIds);
    List<Long> facilityIds = programAllWithFacility.stream()
        .map(program -> program.getFacility().getId())
        .distinct()
        .toList();
        
...
}

 이제 프로그램 데이터를 가져올 때, 시설 데이터들도 fetch join으로 한 번에 가져올 수 있게 됐고, stream().map().distinct().toList()를 통해서 중복 데이터도 제거한 데이터를 활용할 수 있게 됐습니다. 그럼 이제 이 시설 id 리스트 정보를 토대로 배치 조회를 날려서 데이터를 한 번에 가져오면 됩니다. PostgreSQL에서는 그룹화하여 그룹 내의 데이터 중 가장 첫 번째 행만 뽑아내는 "DISTINCT ON"이라는 자체적인 문법이 존재합니다. 

  @Query(value = """
      SELECT DISTINCT ON (facility_id, rank)
        facility_id           AS facilityId,
        pbtrnsp_fclty_sdiv_nm AS transportType,
        bstp_subwayst_nm      AS transportName,
        (wlkg_mvmn_time / 60) + 1 AS transportTime
      FROM facility_transit
      WHERE facility_id IN (:facilityIds) AND rank IN (1, 2)
      ORDER BY facility_id, rank, wlkg_mvmn_time ASC
      """,
      nativeQuery = true)
  List<TransportDataRawWithFacility> findTop2TransitByFacilityIds(@Param("facilityIds") List<Long> facilityIds);

 시설 정보와 시설과 가장 가까운 교통 정보 Top2 정보에 대해서만 추출하는 Native Query이다. (facility_id, rank) 단위로 그룹화하여 그중 가장 첫번째 행만 뽑아내서 반환한다. 단건 조회에서 배치 조회로 개선하면서 Program -> Facility -> Transit(by facilityId) 연결 관계를 Mapping해주는 작업을 해줘야 합니다. 

...
	List<Long> programIds = programs.stream()
        .map(ProgramInfoResponse::programId)
        .toList();

    List<Program> programEntities = programRepository.findAllWithFacility(programIds);

    Map<Long, Program> programMap = programEntities.stream()
        .collect(Collectors.toMap(Program::getId, Function.identity()));
...

 프로그램 식별자들을 한 번에 가져오고, 식별자에 대한 Entity 객체를 Map으로 감싼 객체를 만들어줍니다.

...
    List<Long> facilityIds = programEntities.stream()
        .map(program -> program.getFacility().getId())
        .distinct()
        .toList();

    Map<Long, List<TransportData>> transitMap = transitRepository
        .findTop2TransitByFacilityIds(facilityIds)
        .stream()
        .collect(Collectors.groupingBy(
            TransportDataRawWithFacility::facilityId,
            Collectors.mapping(
                raw -> new TransportData(
                    raw.transportType(),
                    raw.transportName(),
                    raw.transportTime().longValue()
                ),
                Collectors.toList()
            )
        ));
...

 fetch join을 통해서 가져온 Program(include Facility) Entity 객체에서 중복을 제거한 시설 식별자를 List 객체로 만들어줍니다. 그 이후에 시설 식별자에 대한 시설과 가까운 교통정보 Top2 데이터를 Mapping 해주기 위해서 stream 체이닝을 해줍니다.

...
    return programs.stream()
        .map(programInfoResponse -> {
          Program program = programMap.get(programInfoResponse.programId());
          Long facilityId = program.getFacility().getId();
          List<TransportData> transports = transitMap.getOrDefault(facilityId, List.of());

          return new RecommendProgramData(
              programInfoResponse,
              transports,
              programInfoResponse.distance()
          );
        })
        .toList();
  }
...

 getProgram() 메서드를 호출하지 않고, 자체적으로 객체들끼리 연결하여 결과를 반환하는 방법으로 코드를 리팩터링 해줍니다. 그럼 이제 시설 식별자를 통해서 23개의 단건을 조회하는 대신 1건의 배치 조회로 데이터를 한번에 가져오기 때문에 모든 과정에서 "3개"의 쿼리가 발생해야 합니다.

쿼리 구조 개선 여정기 관련 이미지
25개 -> 3개로 쿼리 실행 수 22개 감소

 예상 시나리오대로 똑같은 API를 호출했을 때, 3개의 쿼리로 같은 결과물을 내는 걸 확인할 수 있었습니다. 영속성 컨텍스트의 이해와 fetch join 그리고 In절을 활용한 batch 조회를 활용하여 "33개"의 쿼리에서 "3개"의 쿼리로 개선할 수 있었습니다.

2.5 결과 

쿼리 구조 개선 여정기 관련 이미지
개선 전: 172ms
쿼리 구조 개선 여정기 관련 이미지
개선 후: 68ms

 LLM 호출하여 응답하는 시간이 있어서 간단하게 StopWatch 유틸 클래스를 사용해서 쿼리가 나갔던 메서드 부분의 작업 시간을 측정해보았다. 눈깜빡하는 시간보다 더 적은 시간이긴 하지만, 172ms -> 68ms로 소폭 개선된 것을 확인할 수 있었다. 소폭 개선됐다곤 하지만, 일정 거리 내에 프로그램 수가 많았다면 더 많은 쿼리가 나갔을 것이고, 그런 상황에서는 더욱 큰 효과를 나타냈을 것 같다. 결과적으로 개선된 내용은 다음과 같다.

  • DB로 가는 쿼리 수: 33개 -> 3개 
  • LLM 호출 전까지 작업 소요 시간: 172ms -> 68ms

3. 정리하며

 지금까지 쿼리가 나가는 개수를 줄이는 여정기를 작성해 보았습니다. 비효율적인 쿼리가 나갔던 이유는 기존에 있는 메서드를 생각 없이 재사용한 것과 같은 트랜잭션 내에서 JPA 영속성 컨텍스트 개념을 이해하지 못하고 있었기 때문입니다. 이론 학습만 하다가 이렇게 직접 프로젝트를 하면서 부딪히며 배우니까 확실히 배웠던 이론들이 왜 중요하고, 어떤 상황에서 활용되는지 알 수 있어서 좋은 것 같습니다. 

 

 처음부터 정말 효율적인 쿼리와 JPA의 모든 것을 활용하여 완벽한 코드를 짤 수 있는 사람도 있을 것입니다. 그러나, 저처럼 조금은 부족하지만 이렇게 개선하고자 하는 욕심이 있고, 바로 시도해보고, 적용해보며 성장하는 사람도 있다고 생각합니다. 앞으로도 이렇게 작은 프로젝트라도 계속 개선점을 찾아보고, 몰랐던 개념들을 배우고 다음 프로젝트에 적용해가며 기술적으로 꾸준히 성장할 수 있는 사람이 될 수 있도록 정진하도록 하겠습니다. 그럼 이 글이 조금이나마 도움이 되길 바라며 다음 글에서 뵙도록 하겠습니다 :> 화이팅!

4. Reference 

  • 영속성 컨텍스트와 관련된 테코톡
  • 영속성 컨텍스트를 잘 설명한 블로그
  • Postgresql Distinct On에 대한 블로그 

'Project' 카테고리의 다른 글

[Stash] AWS Bedrock 활용한 북마크 플러그인 개발기  (0) 2026.05.01
[FitFinder] Haversine 공식 대신 PostGIS 도입기  (0) 2026.04.05
[ReTrip] 아키텍처 개선 여정기  (0) 2026.03.30
'Project' 카테고리의 다른 글
  • [Stash] AWS Bedrock 활용한 북마크 플러그인 개발기
  • [FitFinder] Haversine 공식 대신 PostGIS 도입기
  • [ReTrip] 아키텍처 개선 여정기
bumnote
bumnote
일상 속의 불편함을 기술로 편리하게
  • bumnote
    개발계발
    bumnote
  • 전체
    오늘
    어제
    • 분류 전체보기 (29)
      • 일상 (1)
      • 회고 (3)
      • Certificate (2)
      • Project (4)
      • Developer (14)
        • MSA (0)
        • Infra (4)
        • SpringBoot (3)
        • Java (3)
        • DB (3)
        • Git (1)
      • Activity (5)
        • Review (3)
        • Hack (1)
        • 공모전 (1)
  • 링크

    • Github
  • 인기 글

  • 태그

    서평단
    springboot
    NxtCloud
    freelec
    haversine
    SQL
    2025
    AWS
    Infra
    fitfinder
    회고
    쏘마
    java
    순열과조합
    Query
    serverless
    CloudFront
    리뷰
    SAA
    대체불가능
  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
bumnote
[FitFinder] Fetch Join과 Batch 조회를 통한 쿼리 최적화 여정기
상단으로

티스토리툴바