이번 글에서는 P6Spy를 이용해 API 쿼리 로그를 분석하고,
각 주요 API의 쿼리 구조를 리팩토링하면서 성능을 개선했던 과정을 정리했습니다.
로컬 환경 기준으로 테스트를 진행했으며,
이후 더미 데이터를 이용한 부하 테스트를 추가로 진행할 예정입니다.
참고
P6Spy는 로컬 전용으로만 사용하며 운영 환경에는 포함하지 않습니다.
/api/academic/record)이 API는 사용자의 학기별 수강 정보를 불러오는 핵심 기능입니다.
초기에는 학업 관련 테이블들이 분리되어 있어 중복 쿼리가 다수 발생했고,
API 전체 처리 시간이 약 546ms에 달했습니다.
이를 개선하기 위해 복합 인덱스 추가와 Fetch Join 적용을 중심으로 리팩토링을 진행했습니다.
| 순번 | 실행 시간 | 카테고리 | 주요 테이블 | 설명 요약 |
|---|---|---|---|---|
| 1 | 80ms | statement | users | 사용자 정보 조회 (user_id → student_id 연결용) |
| 2 | 86ms | statement | students, graduation_progress, academic_records | 기본 정보 및 누적 학업 상태 조회 |
| 3 | 80ms | statement | semester_academic_records | 특정 학기 성적 요약 조회 |
| 4 | 85ms | statement | student_courses, course_offerings, courses, professor | 수강 강의 전체 조회 |
| 5 | 80ms | statement | graduation_progress | 졸업 진척도 재조회 |
| 6 | 76ms | statement | academic_records | 누적 학점/성적 재조회 |
총 6개의 쿼리가 실행되며,
API 처리 시간은 약 546ms, 쿼리 실행 시간은 564ms로 측정되었습니다.
| 순번 | 실행 시간 | 카테고리 | 주요 테이블 | 설명 요약 |
|---|---|---|---|---|
| 1 | 80ms | statement | users | 사용자 정보 조회 |
| 2 | 70ms | statement | students | 기본 정보 조회 |
| 3 | 88ms | statement | semester_academic_records | 특정 학기 성적 요약 (복합 인덱스 적용) |
| 4 | 83ms | statement | student_courses, course_offerings, courses, professor | 해당 학기 수강 강의 전체 조회 |
쿼리 수는 6개 → 4개로 줄었고,
API 처리 시간도 546ms → 321ms, 약 41% 단축되었습니다.
graduation_progress와 academic_records의 중복 조회가 제거되었으며,
연관 관계에 Fetch Join을 적용해 N+1 문제도 해결했습니다.
(student_id, year, semester) 복합 인덱스 추가로 단건 조회 성능 향상 SemesterAcademicRecord 테이블에 복합 인덱스 추가
CREATE INDEX CONCURRENTLY idx_semester_record_student_year_semester ON semester_academic_records (student_id, year, semester);
/api/academic/summary)이 API는 학생의 전체 성적과 졸업 요건 상태를 함께 조회합니다.
초기 버전은 academic_records와 graduation_progress를 매번 다시 불러와
총 5개의 SELECT 쿼리가 실행되는 구조였습니다.
| 순번 | 실행 시간 | 카테고리 | 주요 테이블 | 설명 요약 |
|---|---|---|---|---|
| 1 | 115ms | statement | users | 사용자 정보 조회 |
| 2 | 146ms | statement | students + graduation_progress + academic_records | 학업 상태 JOIN |
| 3 | 82ms | statement | academic_records | 누적 성적 재조회 |
| 4 | 85ms | statement | students + graduation_progress + academic_records | 재조회 (중복) |
| 5 | 213ms | statement | department_area_requirements | 학과별 이수 요건 조회 |
총 5개 SELECT 쿼리,
API 처리 시간은 약 556ms, 쿼리 시간 합계는 641ms였습니다.
Redis를 도입하여 학과별 이수 요건(department_area_requirements)을 캐싱하고,
중복된 성적·진행도 조회를 제거했습니다.
| 순번 | 실행 시간 | 주요 테이블 | 설명 요약 |
|---|---|---|---|
| 1 | 81ms | users | 사용자 조회 |
| 2 | 80ms | students | 학번 및 학과 조회 |
| 3 | 83ms | student_academic_records | 누적 성적 조회 |
쿼리 수는 5개 → 3개,
API 처리 시간은 556ms → 207ms,
약 63% 단축되었습니다.
최종적으로 계산된 성적 요약 DTO 자체를 Redis에 캐싱하여
DB 조회를 완전히 제거했습니다.
| 순번 | 실행 시간 | 주요 테이블 | 설명 요약 |
|---|---|---|---|
| 1 | 84ms | users | 사용자 정보 조회 |
| 2 | 83ms | students | 학생 엔티티 조회 |
| 3 | 0ms | redis | ✅ 캐시에서 최종 DTO 조회 |
쿼리 수는 2개,
API 처리 시간은 17ms로 단축되었습니다.
결과적으로 DB 접근을 완전히 제거하면서
약 96.9%의 성능 향상(556ms → 17ms) 을 달성했습니다.
/api/semester)이 API는 학생의 학기별 성적 요약 데이터를 반환합니다.
초기 버전에서는 매 요청마다 DB에서 모든 데이터를 불러와
불필요한 중복 조회와 Stream 정렬로 인한 오버헤드가 존재했습니다.
이후 Redis 캐싱 및 DTO 캐싱 전략을 단계적으로 도입하여
조회 효율성과 응답 속도를 대폭 개선했습니다.
| 순번 | 실행 시간 | 카테고리 | 주요 테이블 | 설명 요약 |
|---|---|---|---|---|
| 1 | 83ms | statement | users | 사용자 정보 조회 |
| 2 | 90ms | statement | students + graduation_progress + academic_records | 학생 상태 조회 |
| 3 | 88ms | statement | semester_academic_records | 전체 학기 성적 요약 조회 |
총 3개의 SELECT 쿼리가 실행되었으며,
API 처리 시간은 198ms, 쿼리 시간 합계는 261ms였습니다.
모든 데이터를 DB에서 직접 조회하고,
Java Stream을 이용해 응답 직전에 정렬을 수행했습니다.
Redis에 학기별 성적 요약 데이터를 캐싱하고,
최종 응답 DTO까지 캐싱하는 2단계 구조를 도입했습니다.
또한, Java Stream을 이용해 정렬하는 방식에서 DB 정렬 후 반환하는 방식으로 변경했습니다.
| 순번 | 실행 시간 | 카테고리 | 주요 테이블 | 설명 요약 |
|---|---|---|---|---|
| 1 | 82ms | statement | users | 사용자 정보 조회 |
| 2 | 79ms | statement | students | studentId 조회 |
| 3 | 0ms | redis | ✅ 학기 성적 요약 정보 + DTO 캐시 조회 |
쿼리 수는 3개 → 2개 (SELECT) + 1개 Redis 조회로 줄었고,
전체 API 처리 시간은 198ms → 62ms,
쿼리 합계는 261ms → 161ms로 개선되었습니다.
DTO 캐싱은 빠르지만 Redis 메모리 점유율이 높다는 단점이 있었습니다.
따라서 캐싱 단위를 “DTO 전체”에서 “요약 데이터 리스트”로 축소하고,
서비스 레이어에서 DTO 변환을 매번 수행하도록 구조를 변경했습니다.
| 순번 | 실행 시간 | 카테고리 | 주요 테이블 | 설명 요약 |
|---|---|---|---|---|
| 1 | 89ms | statement | users | 사용자 정보 조회 |
| 2 | 121ms | statement | students | studentId 조회 |
| 3 | 0ms | redis | ✅ SemesterSummaryResponse 리스트 캐시 조회 |
쿼리 수는 동일하지만,
API 전체 처리 시간은 62ms → 7ms로 대폭 단축되었고,
Redis 메모리 효율 및 캐시 재사용성이 크게 향상되었습니다.
/api/semester 및 /api/semester/grades 동시 활용 가능 /api/semester/grades)이 API는 학생의 모든 학기 성적 요약 데이터를 조회합니다.
초기 버전에서는 매 요청마다 전체 학기 데이터를 DB에서 직접 조회했기 때문에 쿼리 부하와 정렬 비용이 컸습니다.
리팩토링을 통해 Redis 캐싱을 적용하면서,
DB 접근을 최소화하고 응답 속도를 대폭 단축했습니다.
| 순번 | 실행 시간 | 카테고리 | 주요 테이블 | 설명 요약 |
|---|---|---|---|---|
| 1 | 81ms | statement | users | 사용자 정보 조회 (userId → studentId 매핑) |
| 2 | 82ms | statement | students + graduation_progress + academic_records | 학생 상태 및 학업 식별 조회 |
| 3 | 82ms | statement | semester_academic_records | 전체 학기 성적 요약 및 정렬 포함 조회 |
총 3개의 SELECT 쿼리가 실행되었으며,
API 처리 시간은 178ms, 쿼리 시간 합계는 245ms였습니다.
모든 학기 성적 데이터를 매번 DB에서 불러오고 정렬까지 수행했습니다.
Redis 캐시를 도입하여 전체 학기 성적 요약 데이터를
semester-summaries 키로 캐싱했습니다.
이후 동일 요청에서는 DB 접근 없이 Redis에서 즉시 데이터를 반환합니다.
| 순번 | 실행 시간 | 카테고리 | 주요 테이블 | 설명 요약 |
|---|---|---|---|---|
| 1 | 76ms | statement | users | 사용자 정보 조회 |
| 2 | 77ms | statement | students | studentId 조회 (userId 매핑) |
| 3 | 0ms | redis | ✅ 전체 학기 성적 요약 정보 캐시 조회 (semester-summaries) |
쿼리 수는 3개 → 2개 (SELECT) + 1개 Redis 조회로 줄었으며,
API 전체 처리 시간은 178ms → 36ms,
쿼리 합계 시간은 245ms → 153ms로 개선되었습니다.
semester-summaries 키) /api/semester와 동일한 캐싱 구조를 사용해 재활용성 확보 /api/graduation/progress)이 API는 학생의 이수 과목과 학과별 졸업 요건을 비교하여
현재 졸업 진행 상태를 계산하는 기능입니다.
초기 버전은 CTE(Common Table Expression)를 사용하여
모든 데이터를 실시간 계산하는 구조였기 때문에
매 요청마다 무거운 쿼리가 실행되었고, 처리 시간이 길었습니다.
리팩토링을 통해 CTE 쿼리를 분해하고,
Redis 캐싱을 단계적으로 도입하면서 성능을 크게 개선했습니다.
| 순번 | 실행 시간 | 카테고리 | 주요 테이블 | 설명 요약 |
|---|---|---|---|---|
| 1 | 118ms | statement | users | 사용자 정보 조회 |
| 2 | 213ms | statement | students + graduation_progress + academic_records | 학생 상태 조회 |
| 3 | 83ms | statement | students + graduation_progress + academic_records | 중복 재조회 |
| 4 | 479ms | statement | student_courses + course_offerings + department_area_requirements | 졸업요건 CTE 쿼리 수행 |
총 4개의 SELECT 쿼리가 실행되었으며,
API 처리 시간은 891ms, 쿼리 시간 합계는 약 893ms였습니다.
매 요청마다 모든 졸업 요건과 이수 과목을 실시간 계산했기 때문에
중복 조회와 무캐싱으로 인한 DB 부하가 매우 컸습니다.
복잡한 CTE 쿼리를 단계별로 분해하고,
입학년도 및 학과별 졸업 요건 데이터를 Redis에 캐싱했습니다.
이를 통해 반복되는 요건 계산 쿼리를 제거했습니다.
| 순번 | 실행 시간 | 카테고리 | 주요 테이블 | 설명 요약 |
|---|---|---|---|---|
| 1 | 81ms | statement | users | 사용자 정보 조회 |
| 2 | 97ms | statement | students | userId → studentId 매핑 |
| 3 | 93ms | statement | students | studentId → 상태 조회 |
| 4 | 98ms | statement | student_courses + course_offerings + courses | 이수 과목 최신 버전 필터링 |
| 5 | 82ms | statement | department_area_requirements | ✅ 졸업 요건 Redis 캐시 활용 |
쿼리 수는 5개로 소폭 증가했지만,
중복 계산이 제거되어 API 처리 시간은 891ms → 431ms,
쿼리 합계 시간은 893ms → 351ms로 크게 개선되었습니다.
1차 리팩토링에서 남아있던 상태 조회 및 계산 과정을 제거하고,
최종 계산된 졸업 진행 정보를 DTO 단위로 Redis에 캐싱했습니다.
이로써 API 호출 시 DB 접근 없이
Redis에서 즉시 결과를 반환할 수 있게 되었습니다.
| 순번 | 실행 시간 | 카테고리 | 주요 테이블 | 설명 요약 |
|---|---|---|---|---|
| 1 | 173ms | statement | users | 사용자 정보 조회 |
| 2 | 97ms | statement | students | userId → studentId 매핑 |
| 3 | 0ms | redis | ✅ 최종 졸업 진행 정보 DTO 캐시 조회 (graduation-progress:{studentId}) |
쿼리 수는 2개 SELECT + 1개 Redis 조회,
API 처리 시간은 891ms → 32ms로 단축되었습니다.
/api/student/target-gpa)이 API는 학생의 목표 학점을 수정하는 기능입니다.
초기 버전에서는 단순한 필드 업데이트임에도 불구하고
save() 메서드를 사용해 엔티티 전체를 로딩했기 때문에
불필요한 쿼리와 과도한 I/O가 발생했습니다.
리팩토링을 통해 중복 조회 제거 및 JPQL 기반 경량 업데이트를 도입하면서
명시적이고 빠른 업데이트가 가능하도록 개선했습니다.
| 순번 | 실행 시간 | 카테고리 | 주요 테이블 | 설명 요약 |
|---|---|---|---|---|
| 1 | 80ms | statement | users | 사용자 정보 조회 |
| 2 | 81ms | statement | students + graduation_progress + academic_records | 상태 조회 |
| 3 | 81ms | statement | students + graduation_progress + academic_records | 중복 재조회 |
| 4 | 80ms | statement | students | 목표 학점 업데이트 수행 |
총 4개의 쿼리(SELECT 3 + UPDATE 1)가 실행되었으며,
API 처리 시간은 256ms, 쿼리 시간 합계는 322ms였습니다.
단순 필드 업데이트에 비해 과도한 쿼리가 실행된 구조였습니다.
중복 조회를 제거하고,
Dirty Checking 대신 @Modifying JPQL 쿼리를 사용해
엔티티 전체 로딩 없이 특정 필드만 직접 수정하도록 변경했습니다.
| 순번 | 실행 시간 | 카테고리 | 주요 테이블 | 설명 요약 |
|---|---|---|---|---|
| 1 | 80ms | statement | users | 사용자 정보 조회 |
| 2 | 80ms | statement | students | userId → studentId 조회 |
| 3 | 98ms | statement | students | ✅ JPQL 기반 목표 학점 업데이트 수행 (@Modifying) |
쿼리 수는 4개 → 3개 (SELECT 2 + UPDATE 1)로 줄었고,
API 처리 시간은 256ms → 250ms,
쿼리 시간 합계는 322ms → 258ms로 약 19.8% 단축되었습니다.
@Modifying 쿼리 사용 → 명시적·빠른 업데이트 /api/student/profile)이 API는 학생의 기본 정보, 학과, 사용자 계정 정보를 통합하여 조회합니다.
초기 버전에서는 학생과 사용자 엔티티를 여러 번 중복 조회하면서
불필요한 쿼리가 다수 발생했습니다.
리팩토링을 통해 Fetch Join을 적용하고
서비스 레이어에서 객체 재사용 구조로 변경하여
쿼리 수를 절반 이하로 줄이고 응답 속도를 개선했습니다.
| 순번 | 실행 시간 | 카테고리 | 주요 테이블 | 설명 요약 |
|---|---|---|---|---|
| 1 | 97ms | statement | users | 사용자 정보 1차 조회 |
| 2 | 91ms | statement | students + graduation_progress + academic_records | 학생 정보 조회 (1차) |
| 3 | 89ms | statement | students + graduation_progress + academic_records | 학생 정보 재조회 (2차) |
| 4 | 100ms | statement | departments | 학과 정보 조회 |
| 5 | 81ms | statement | users | 사용자 재조회 (2차) |
| 6 | 80ms | statement | students + graduation_progress + academic_records | 학생 정보 재조회 (3차) |
총 6개의 SELECT 쿼리가 실행되었으며,
API 처리 시간은 453ms, 쿼리 시간 합계는 538ms였습니다.
사용자와 학생 정보가 중복 조회되고,
학과 정보가 별도의 쿼리로 불필요하게 분리되어 있었습니다.
Fetch Join을 통해 학과, 전공, 사용자 정보를 한 번에 조회하도록 변경했습니다.
| 순번 | 실행 시간 | 카테고리 | 주요 테이블 | 설명 요약 |
|---|---|---|---|---|
| 1 | 90ms | statement | users | 사용자 정보 1회 조회 |
| 2 | 87ms | statement | students | userId → student 매핑 조회 |
| 3 | 100ms | statement | students + departments + users | ✅ Fetch Join으로 학과/전공/사용자 통합 조회 |
쿼리 수는 6개 → 3개로 감소했고,
API 처리 시간은 453ms → 292ms,
쿼리 합계 시간은 538ms → 277ms로 약 48.5% 단축되었습니다.
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 <-> Student가 OneToOne 관계임에도,
대부분의 API에서 studentService.getById(studentId)를 통해 중복 조회가 반복되고 있습니다.
이에 따라 다음과 같은 개선 방향을 고려 중입니다:
개선안
// 기존
private final UUID studentId;
// 개선안
private final Optional<Student> student;
이를 통해 다음과 같은 개선 효과를 기대할 수 있습니다:
studentService.getById(...) 호출 생략 가능userDetails.getStudent().get().getStatus() 형태로 직접 접근 가능장점
중복 조회 제거
studentId 기반 조회 로직을 제거할 수 있습니다.쿼리 수 감소
Student를 조회하여 CustomUserDetails에 포함함으로써,서비스 코드 간결화
studentService.getById(...) 호출 없이, userDetails.getStudent()를 통해 직접 필요한 데이터에 접근할 수 있습니다.단점 및 주의사항
세션/토큰 크기 증가
CustomUserDetails는 보안 세션 또는 JWT에 포함되므로,Student 엔티티까지 포함할 경우 전체 세션 크기 또는 Claim 용량이 커질 수 있습니다.정합성 문제 발생 가능
Student 객체는 이후 DB에서 변경되더라도 자동 반영되지 않습니다.targetGpa가 변경된 경우에도, 여전히 변경 전 값을 반환할 수 있습니다.JPA Entity 포함은 잠재적 안티패턴
Detached 상태가 됩니다. 결론
Student 객체를 CustomUserDetails에 포함하는 방식은
중복 조회 제거와 코드 간결화 측면에서 의미 있는 시도라고 생각합니다.
하지만 다음과 같은 이슈를 충분히 고려해야 합니다:
이번 리팩토링을 통해
를 단계적으로 적용하면서, 대부분의 주요 API에서 30~95% 이상의 성능 개선을 달성할 수 있었습니다.
특히 DTO 단위 캐싱 → 요약 데이터 단위 캐싱으로 구조를 전환한 점은,
확장성과 유지보수성을 동시에 확보한 결정적인 변화였습니다.
향후에는 쿼리 최적화뿐 아니라, 캐시 TTL 및 무효화 전략 고도화까지 이어나가며
지속적으로 시스템의 성능과 운영 효율성을 향상시킬 계획입니다.