[척척학사] 1차 쿼리 튜닝: P6Spy 기반 병목 진단과 캐싱 설계

박상민·2025년 10월 7일
2

척척학사

목록 보기
16/17
post-thumbnail

P6Spy 기반 쿼리 분석과 API 성능 리팩토링 과정

이번 글에서는 P6Spy를 이용해 API 쿼리 로그를 분석하고,
각 주요 API의 쿼리 구조를 리팩토링하면서 성능을 개선했던 과정을 정리했습니다.

로컬 환경 기준으로 테스트를 진행했으며,
이후 더미 데이터를 이용한 부하 테스트를 추가로 진행할 예정입니다.

참고
P6Spy는 로컬 전용으로만 사용하며 운영 환경에는 포함하지 않습니다.


📌 학기 수강 정보 조회 (/api/academic/record)

이 API는 사용자의 학기별 수강 정보를 불러오는 핵심 기능입니다.
초기에는 학업 관련 테이블들이 분리되어 있어 중복 쿼리가 다수 발생했고,
API 전체 처리 시간이 약 546ms에 달했습니다.

이를 개선하기 위해 복합 인덱스 추가Fetch Join 적용을 중심으로 리팩토링을 진행했습니다.


리팩토링 전 쿼리 로그

순번실행 시간카테고리주요 테이블설명 요약
180msstatementusers사용자 정보 조회 (user_id → student_id 연결용)
286msstatementstudents, graduation_progress, academic_records기본 정보 및 누적 학업 상태 조회
380msstatementsemester_academic_records특정 학기 성적 요약 조회
485msstatementstudent_courses, course_offerings, courses, professor수강 강의 전체 조회
580msstatementgraduation_progress졸업 진척도 재조회
676msstatementacademic_records누적 학점/성적 재조회

총 6개의 쿼리가 실행되며,
API 처리 시간은 약 546ms, 쿼리 실행 시간은 564ms로 측정되었습니다.


리팩토링 후 쿼리 로그

순번실행 시간카테고리주요 테이블설명 요약
180msstatementusers사용자 정보 조회
270msstatementstudents기본 정보 조회
388msstatementsemester_academic_records특정 학기 성적 요약 (복합 인덱스 적용)
483msstatementstudent_courses, course_offerings, courses, professor해당 학기 수강 강의 전체 조회

쿼리 수는 6개 → 4개로 줄었고,
API 처리 시간도 546ms → 321ms, 약 41% 단축되었습니다.
graduation_progressacademic_records의 중복 조회가 제거되었으며,
연관 관계에 Fetch Join을 적용해 N+1 문제도 해결했습니다.


개선 요약

  • 중복 조회 제거로 DB 접근 최소화
  • (student_id, year, semester) 복합 인덱스 추가로 단건 조회 성능 향상
  • 주요 연관 객체 Fetch Join 적용으로 N+1 문제 제거
  • 전체 API 처리 시간 약 41% 단축 (546ms → 321ms)

SemesterAcademicRecord 테이블에 복합 인덱스 추가

CREATE INDEX CONCURRENTLY idx_semester_record_student_year_semester
ON semester_academic_records (student_id, year, semester);

📌 전체 성적 요약 (/api/academic/summary)

이 API는 학생의 전체 성적과 졸업 요건 상태를 함께 조회합니다.
초기 버전은 academic_recordsgraduation_progress를 매번 다시 불러와
총 5개의 SELECT 쿼리가 실행되는 구조였습니다.


리팩토링 전 쿼리 로그

순번실행 시간카테고리주요 테이블설명 요약
1115msstatementusers사용자 정보 조회
2146msstatementstudents + graduation_progress + academic_records학업 상태 JOIN
382msstatementacademic_records누적 성적 재조회
485msstatementstudents + graduation_progress + academic_records재조회 (중복)
5213msstatementdepartment_area_requirements학과별 이수 요건 조회

총 5개 SELECT 쿼리,
API 처리 시간은 약 556ms, 쿼리 시간 합계는 641ms였습니다.


1차 리팩토링 (중복 제거 + Redis 캐시 도입)

Redis를 도입하여 학과별 이수 요건(department_area_requirements)을 캐싱하고,
중복된 성적·진행도 조회를 제거했습니다.

순번실행 시간주요 테이블설명 요약
181msusers사용자 조회
280msstudents학번 및 학과 조회
383msstudent_academic_records누적 성적 조회

쿼리 수는 5개 → 3개,
API 처리 시간은 556ms → 207ms,
63% 단축되었습니다.


2차 리팩토링 (성적 요약 응답 DTO 캐싱)

최종적으로 계산된 성적 요약 DTO 자체를 Redis에 캐싱하여
DB 조회를 완전히 제거했습니다.

순번실행 시간주요 테이블설명 요약
184msusers사용자 정보 조회
283msstudents학생 엔티티 조회
30msredis✅ 캐시에서 최종 DTO 조회

쿼리 수는 2개,
API 처리 시간은 17ms로 단축되었습니다.

결과적으로 DB 접근을 완전히 제거하면서
96.9%의 성능 향상(556ms → 17ms) 을 달성했습니다.


개선 요약

  • Redis 캐시를 통해 학과별 졸업 요건 및 성적 정보를 즉시 반환
  • 계산된 DTO 캐싱으로 DB I/O 제거
  • 로그인 시점에만 캐시 초기화 → 재연결 시에도 빠른 응답
  • API 처리 시간 약 96.9% 단축

📌 수강 학기 정보 조회 (/api/semester)

이 API는 학생의 학기별 성적 요약 데이터를 반환합니다.
초기 버전에서는 매 요청마다 DB에서 모든 데이터를 불러와
불필요한 중복 조회와 Stream 정렬로 인한 오버헤드가 존재했습니다.

이후 Redis 캐싱 및 DTO 캐싱 전략을 단계적으로 도입하여
조회 효율성과 응답 속도를 대폭 개선했습니다.


리팩토링 전 쿼리 로그

순번실행 시간카테고리주요 테이블설명 요약
183msstatementusers사용자 정보 조회
290msstatementstudents + graduation_progress + academic_records학생 상태 조회
388msstatementsemester_academic_records전체 학기 성적 요약 조회

총 3개의 SELECT 쿼리가 실행되었으며,
API 처리 시간은 198ms, 쿼리 시간 합계는 261ms였습니다.
모든 데이터를 DB에서 직접 조회하고,
Java Stream을 이용해 응답 직전에 정렬을 수행했습니다.


1차 리팩토링 (Redis + DTO 캐싱 적용)

Redis에 학기별 성적 요약 데이터를 캐싱하고,
최종 응답 DTO까지 캐싱하는 2단계 구조를 도입했습니다.
또한, Java Stream을 이용해 정렬하는 방식에서 DB 정렬 후 반환하는 방식으로 변경했습니다.

순번실행 시간카테고리주요 테이블설명 요약
182msstatementusers사용자 정보 조회
279msstatementstudentsstudentId 조회
30msredis✅ 학기 성적 요약 정보 + DTO 캐시 조회

쿼리 수는 3개 → 2개 (SELECT) + 1개 Redis 조회로 줄었고,
전체 API 처리 시간은 198ms → 62ms,
쿼리 합계는 261ms → 161ms로 개선되었습니다.


2차 리팩토링 (DTO 캐싱 제거 → 단일 캐시 구조)

DTO 캐싱은 빠르지만 Redis 메모리 점유율이 높다는 단점이 있었습니다.
따라서 캐싱 단위를 “DTO 전체”에서 “요약 데이터 리스트”로 축소하고,
서비스 레이어에서 DTO 변환을 매번 수행하도록 구조를 변경했습니다.

순번실행 시간카테고리주요 테이블설명 요약
189msstatementusers사용자 정보 조회
2121msstatementstudentsstudentId 조회
30msredisSemesterSummaryResponse 리스트 캐시 조회

쿼리 수는 동일하지만,
API 전체 처리 시간은 62ms → 7ms로 대폭 단축되었고,
Redis 메모리 효율 및 캐시 재사용성이 크게 향상되었습니다.


개선 요약

  • 캐싱 단위를 DTO → 요약 리스트로 변경하여 메모리 효율 개선
  • Stream 정렬 제거 및 DB 정렬 후 캐싱으로 연산 부하 감소
  • 캐싱 데이터 재사용으로 /api/semester/api/semester/grades 동시 활용 가능
  • API 처리 시간 약 96.5% 단축 (198ms → 7ms)

📌 전체 학기 성적 조회 (/api/semester/grades)

이 API는 학생의 모든 학기 성적 요약 데이터를 조회합니다.
초기 버전에서는 매 요청마다 전체 학기 데이터를 DB에서 직접 조회했기 때문에 쿼리 부하와 정렬 비용이 컸습니다.

리팩토링을 통해 Redis 캐싱을 적용하면서,
DB 접근을 최소화하고 응답 속도를 대폭 단축했습니다.


리팩토링 전 쿼리 로그

순번실행 시간카테고리주요 테이블설명 요약
181msstatementusers사용자 정보 조회 (userId → studentId 매핑)
282msstatementstudents + graduation_progress + academic_records학생 상태 및 학업 식별 조회
382msstatementsemester_academic_records전체 학기 성적 요약 및 정렬 포함 조회

총 3개의 SELECT 쿼리가 실행되었으며,
API 처리 시간은 178ms, 쿼리 시간 합계는 245ms였습니다.
모든 학기 성적 데이터를 매번 DB에서 불러오고 정렬까지 수행했습니다.


리팩토링 후 쿼리 로그 (Redis 캐시 적용)

Redis 캐시를 도입하여 전체 학기 성적 요약 데이터를
semester-summaries 키로 캐싱했습니다.
이후 동일 요청에서는 DB 접근 없이 Redis에서 즉시 데이터를 반환합니다.

순번실행 시간카테고리주요 테이블설명 요약
176msstatementusers사용자 정보 조회
277msstatementstudentsstudentId 조회 (userId 매핑)
30msredis✅ 전체 학기 성적 요약 정보 캐시 조회 (semester-summaries)

쿼리 수는 3개 → 2개 (SELECT) + 1개 Redis 조회로 줄었으며,
API 전체 처리 시간은 178ms → 36ms,
쿼리 합계 시간은 245ms → 153ms로 개선되었습니다.


개선 요약

  • 전체 학기 성적 데이터를 Redis에 캐싱 (semester-summaries 키)
  • /api/semester와 동일한 캐싱 구조를 사용해 재활용성 확보
  • DB 정렬 비용 제거 → 캐싱 시점에 미리 정렬된 데이터 저장
  • API 처리 시간 약 80% 단축 (178ms → 36ms)
  • DB I/O 부하 감소 및 사용자 응답 속도 향상

📌 졸업 요건 조회 (/api/graduation/progress)

이 API는 학생의 이수 과목과 학과별 졸업 요건을 비교하여
현재 졸업 진행 상태를 계산하는 기능입니다.

초기 버전은 CTE(Common Table Expression)를 사용하여
모든 데이터를 실시간 계산하는 구조였기 때문에
매 요청마다 무거운 쿼리가 실행되었고, 처리 시간이 길었습니다.

리팩토링을 통해 CTE 쿼리를 분해하고,
Redis 캐싱을 단계적으로 도입하면서 성능을 크게 개선했습니다.


리팩토링 전 쿼리 로그 (CTE + 무캐싱)

순번실행 시간카테고리주요 테이블설명 요약
1118msstatementusers사용자 정보 조회
2213msstatementstudents + graduation_progress + academic_records학생 상태 조회
383msstatementstudents + graduation_progress + academic_records중복 재조회
4479msstatementstudent_courses + course_offerings + department_area_requirements졸업요건 CTE 쿼리 수행

총 4개의 SELECT 쿼리가 실행되었으며,
API 처리 시간은 891ms, 쿼리 시간 합계는 약 893ms였습니다.
매 요청마다 모든 졸업 요건과 이수 과목을 실시간 계산했기 때문에
중복 조회와 무캐싱으로 인한 DB 부하가 매우 컸습니다.


1차 리팩토링 (CTE 분해 + 졸업 요건 캐싱)

복잡한 CTE 쿼리를 단계별로 분해하고,
입학년도 및 학과별 졸업 요건 데이터를 Redis에 캐싱했습니다.
이를 통해 반복되는 요건 계산 쿼리를 제거했습니다.

순번실행 시간카테고리주요 테이블설명 요약
181msstatementusers사용자 정보 조회
297msstatementstudentsuserId → studentId 매핑
393msstatementstudentsstudentId → 상태 조회
498msstatementstudent_courses + course_offerings + courses이수 과목 최신 버전 필터링
582msstatementdepartment_area_requirements✅ 졸업 요건 Redis 캐시 활용

쿼리 수는 5개로 소폭 증가했지만,
중복 계산이 제거되어 API 처리 시간은 891ms → 431ms,
쿼리 합계 시간은 893ms → 351ms로 크게 개선되었습니다.


2차 리팩토링 (최종 응답 DTO 캐싱)

1차 리팩토링에서 남아있던 상태 조회 및 계산 과정을 제거하고,
최종 계산된 졸업 진행 정보를 DTO 단위로 Redis에 캐싱했습니다.

이로써 API 호출 시 DB 접근 없이
Redis에서 즉시 결과를 반환할 수 있게 되었습니다.

순번실행 시간카테고리주요 테이블설명 요약
1173msstatementusers사용자 정보 조회
297msstatementstudentsuserId → studentId 매핑
30msredis✅ 최종 졸업 진행 정보 DTO 캐시 조회 (graduation-progress:{studentId})

쿼리 수는 2개 SELECT + 1개 Redis 조회,
API 처리 시간은 891ms → 32ms로 단축되었습니다.


개선 요약

  • 복잡한 CTE 쿼리를 분해하여 각 단계별 캐싱 포인트 확보
  • 학과/입학년도별 졸업 요건 1차 캐싱 → 중복 계산 제거
  • 최종 계산 결과를 DTO 단위로 2차 캐싱 → 즉시 응답 가능
  • API 처리 시간 약 96.4% 단축 (891ms → 32ms)
  • DB I/O 부하 감소 및 재조회 제거로 서버 효율 극대화

📌 목표 학점 저장 (/api/student/target-gpa)

이 API는 학생의 목표 학점을 수정하는 기능입니다.
초기 버전에서는 단순한 필드 업데이트임에도 불구하고
save() 메서드를 사용해 엔티티 전체를 로딩했기 때문에
불필요한 쿼리와 과도한 I/O가 발생했습니다.

리팩토링을 통해 중복 조회 제거JPQL 기반 경량 업데이트를 도입하면서
명시적이고 빠른 업데이트가 가능하도록 개선했습니다.


리팩토링 전 쿼리 로그 (불필요한 재조회 포함)

순번실행 시간카테고리주요 테이블설명 요약
180msstatementusers사용자 정보 조회
281msstatementstudents + graduation_progress + academic_records상태 조회
381msstatementstudents + graduation_progress + academic_records중복 재조회
480msstatementstudents목표 학점 업데이트 수행

총 4개의 쿼리(SELECT 3 + UPDATE 1)가 실행되었으며,
API 처리 시간은 256ms, 쿼리 시간 합계는 322ms였습니다.
단순 필드 업데이트에 비해 과도한 쿼리가 실행된 구조였습니다.


리팩토링 후 쿼리 로그 (중복 제거 + JPQL 경량 업데이트 적용)

중복 조회를 제거하고,
Dirty Checking 대신 @Modifying JPQL 쿼리를 사용해
엔티티 전체 로딩 없이 특정 필드만 직접 수정하도록 변경했습니다.

순번실행 시간카테고리주요 테이블설명 요약
180msstatementusers사용자 정보 조회
280msstatementstudentsuserId → studentId 조회
398msstatementstudents✅ JPQL 기반 목표 학점 업데이트 수행 (@Modifying)

쿼리 수는 4개 → 3개 (SELECT 2 + UPDATE 1)로 줄었고,
API 처리 시간은 256ms → 250ms,
쿼리 시간 합계는 322ms → 258ms로 약 19.8% 단축되었습니다.


개선 요약

  • 중복 상태 조회 제거로 불필요한 SELECT 최소화
  • Dirty Checking 대신 JPQL @Modifying 쿼리 사용 → 명시적·빠른 업데이트
  • 엔티티 전체 로딩 없이 필드 단위 수정 가능
  • 단순 수정 API에도 동일 패턴 적용 가능
  • 쿼리 성능 약 19.8% 개선 (322ms → 258ms)

📌 학생 프로필 조회 (/api/student/profile)

이 API는 학생의 기본 정보, 학과, 사용자 계정 정보를 통합하여 조회합니다.
초기 버전에서는 학생과 사용자 엔티티를 여러 번 중복 조회하면서
불필요한 쿼리가 다수 발생했습니다.

리팩토링을 통해 Fetch Join을 적용하고
서비스 레이어에서 객체 재사용 구조로 변경하여
쿼리 수를 절반 이하로 줄이고 응답 속도를 개선했습니다.


리팩토링 전 쿼리 로그 (중복 조회 다수)

순번실행 시간카테고리주요 테이블설명 요약
197msstatementusers사용자 정보 1차 조회
291msstatementstudents + graduation_progress + academic_records학생 정보 조회 (1차)
389msstatementstudents + graduation_progress + academic_records학생 정보 재조회 (2차)
4100msstatementdepartments학과 정보 조회
581msstatementusers사용자 재조회 (2차)
680msstatementstudents + graduation_progress + academic_records학생 정보 재조회 (3차)

6개의 SELECT 쿼리가 실행되었으며,
API 처리 시간은 453ms, 쿼리 시간 합계는 538ms였습니다.
사용자와 학생 정보가 중복 조회되고,
학과 정보가 별도의 쿼리로 불필요하게 분리되어 있었습니다.


리팩토링 후 쿼리 로그 (Fetch Join + 단일 조회 최적화)

Fetch Join을 통해 학과, 전공, 사용자 정보를 한 번에 조회하도록 변경했습니다.

순번실행 시간카테고리주요 테이블설명 요약
190msstatementusers사용자 정보 1회 조회
287msstatementstudentsuserId → student 매핑 조회
3100msstatementstudents + departments + users✅ Fetch Join으로 학과/전공/사용자 통합 조회

쿼리 수는 6개 → 3개로 감소했고,
API 처리 시간은 453ms → 292ms,
쿼리 합계 시간은 538ms → 277ms로 약 48.5% 단축되었습니다.


개선 요약

  • 사용자·학생 엔티티 중복 조회 제거 (6회 → 3회)
  • Fetch Join 적용으로 Lazy 로딩 제거 및 즉시 로딩 최적화
  • 학과/전공 정보를 한 번의 조인으로 조회
  • 서비스 내 동일 엔티티 재활용으로 불필요한 로딩 제거
  • API 처리 시간 약 48.5% 단축 (453ms → 292ms)
  • 조회 효율성과 코드 가독성 모두 개선

추가 개선 고려: CustomUserDetails에 Student 직접 보유

현재 구조

@Getter
@RequiredArgsConstructor
public class CustomUserDetails implements UserDetails {
    private final UUID id;
    private final String email;
    private final String profileNickname;
    private final String profileImage;
    private final boolean isDeleted;
    private final UUID studentId;

    public CustomUserDetails(User user) {
        this.id = user.getId();
        this.email = user.getEmail();
        this.profileNickname = user.getProfileNickname();
        this.profileImage = user.getProfileImage();
        this.isDeleted = user.getIsDeleted();
        this.studentId = user.getStudent() != null ? user.getStudent().getId() : null;
    }
}

Controller 단

public void method(@AuthenticationPrincipal CustomUserDetails userDetails) {
    UUID studentId = userDetails.getStudentId();
    Student student = studentService.getById(studentId);
    // 이후 비즈니스 로직 수행
}

현재는 User <-> StudentOneToOne 관계임에도,
대부분의 API에서 studentService.getById(studentId)를 통해 중복 조회가 반복되고 있습니다.

이에 따라 다음과 같은 개선 방향을 고려 중입니다:

개선안

// 기존
private final UUID studentId;

// 개선안
private final Optional<Student> student;

이를 통해 다음과 같은 개선 효과를 기대할 수 있습니다:

  • 모든 API에서 studentService.getById(...) 호출 생략 가능
  • userDetails.getStudent().get().getStatus() 형태로 직접 접근 가능

장점

  1. 중복 조회 제거

    • 대부분의 API에서 반복적으로 수행되던 studentId 기반 조회 로직을 제거할 수 있습니다.
  2. 쿼리 수 감소

    • 로그인 시 1회만 Student를 조회하여 CustomUserDetails에 포함함으로써,
      이후 요청에서는 추가적인 DB 접근 없이 사용 가능합니다.
  3. 서비스 코드 간결화

    • studentService.getById(...) 호출 없이, userDetails.getStudent()를 통해 직접 필요한 데이터에 접근할 수 있습니다.

단점 및 주의사항

  1. 세션/토큰 크기 증가

    • CustomUserDetails는 보안 세션 또는 JWT에 포함되므로,
      Student 엔티티까지 포함할 경우 전체 세션 크기 또는 Claim 용량이 커질 수 있습니다.
  2. 정합성 문제 발생 가능

    • 로그인 시점에 조회된 Student 객체는 이후 DB에서 변경되더라도 자동 반영되지 않습니다.
      예를 들어 targetGpa가 변경된 경우에도, 여전히 변경 전 값을 반환할 수 있습니다.
  3. JPA Entity 포함은 잠재적 안티패턴

    • JPA 엔티티는 영속성 컨텍스트에서 관리되며, 세션에 저장되는 순간 Detached 상태가 됩니다.
    • 이로 인해 Lazy 로딩 예외, Dirty Checking 누락 등 예기치 않은 사이드 이펙트가 발생할 수 있습니다.

결론

Student 객체를 CustomUserDetails에 포함하는 방식은
중복 조회 제거와 코드 간결화 측면에서 의미 있는 시도라고 생각합니다.
하지만 다음과 같은 이슈를 충분히 고려해야 합니다:

  • 데이터 정합성 (로그인 이후 변경 반영 여부)
  • 직렬화/세션 용량 문제
  • JPA 엔티티의 관리 범위 이탈

마무리

이번 리팩토링을 통해

  • 중복 조회 제거
  • Fetch Join 도입
  • 복합 인덱스 추가
  • Redis 캐시 전략 고도화

를 단계적으로 적용하면서, 대부분의 주요 API에서 30~95% 이상의 성능 개선을 달성할 수 있었습니다.

특히 DTO 단위 캐싱 → 요약 데이터 단위 캐싱으로 구조를 전환한 점은,
확장성과 유지보수성을 동시에 확보한 결정적인 변화였습니다.

향후에는 쿼리 최적화뿐 아니라, 캐시 TTL 및 무효화 전략 고도화까지 이어나가며
지속적으로 시스템의 성능과 운영 효율성을 향상시킬 계획입니다.

0개의 댓글