[최적화] 수강신청 메인 페이지 성능 개선 (N+1, Redis)

2025. 10. 15. 15:09·최적화

수강신청은 모든 학생이 동시에 접속하는 순간적인 트래픽 집중 구간이다. 페이지가 잠깐만 늦어져도 학생 입장에선 수강 실패로 이어진다. 이 글은 수강신청 “메인 페이지”에서 발생한 N+1 문제를 추적·해결하고, Redis 캐시로 응답 시간을 더 줄여 실시간 트래픽을 버티도록 만든 과정을 정리했다.

 

1. 처음 문제: N+1이 실제로 어떻게 터졌나

사용자에게 과목들을 보여주는 초기 코드는 단순했다.

public List<Course> findCourses(CourseSearch courseSearch) {
    return courseRepository.findAll(courseSearch);
}

이때 화면에서는 과목(Course) 목록과 함께 교수(Professor)·강의계획서(Syllabus) 정보를 같이 보여줬다. JPA의 기본 지연 로딩(LAZY) 때문에, 목록을 한 번 가져온 뒤 각 과목마다 연관 엔티티를 “따로따로” 조회하게 되며 N+1이 발생했다.

 

실제로 찍힌 쿼리 패턴은 이랬다:

  • Course 전체 조회: SELECT * FROM course → 1회
  • Syllabus 로딩: SELECT * FROM syllabus WHERE course_id=? → 약 60~70회
  • Professor 로딩: SELECT * FROM professor WHERE professor_id=? → 약 10회
  • Enrollment 로딩(학생별 본인 수강내역): SELECT * FROM enrollment WHERE student_id=? → 1회

 

왜 이렇게 되나? 흐름을 따라가 보면 이해가 쉬울 것이다.

  1. 컨트롤러가 “과목 목록”을 요청한다 → course 테이블에서 한 번에 가져온다.
  2. 뷰 템플릿에서 각 과목의 “교수명, 강의계획서”를 접근한다.
  3. course.professor, course.syllabus는 LAZY라 접근 시마다 개별 쿼리가 추가로 나간다.
  4. 과목이 60~70개면 교수·강의계획서 각각 60~70개의 추가 쿼리 → 총 수십~백여 개의 쿼리.

즉, 목록 1번 + 항목 수만큼의 추가 쿼리들이 연달아 나가며 응답 시간이 치솟았다.

 

성능 요약 (N+1 발생 시)

평균 응답 시간 2.28초 (2286ms)
95퍼센타일 응답 5.35초
최대 응답 시간 7.92초
SQL 쿼리 수 약 100회 이상 / 1요청당
초당 처리량 (Throughput) 약 76 req/s
실패율 0% (정상 응답)

이런 성능이라면 1초라도 중요한 수강신청에 영향이 가기 때문에 개선이 필요하다고 느껴질 것이다.

 

2. Fetch Join을 통한 N+1 해결

N+1 문제의 원인은 연관 데이터를 개별 쿼리로 조회하기 때문이다.

이를 해결하기 위해 JPA의 Fetch Join을 적용하여 필요한 데이터를 한 번의 쿼리로 가져오도록 수정했다.

 

즉 모든 연관관계에 대해 모두 Join하여 한번의 쿼리로 모든 데이터를 가져오는 방식이다.

@Repository
public interface CourseJPARepository extends JpaRepository<Course, Long> {

    @Query("""
        SELECT DISTINCT c 
        FROM Course c
        LEFT JOIN FETCH c.professor p
        LEFT JOIN FETCH c.syllabus s
        WHERE (:courseName IS NULL OR c.courseName LIKE %:courseName%)
          AND (:professorName IS NULL OR p.name LIKE %:professorName%)
    """)
    List<Course> findCoursesWithRelations(
        @Param("courseName") String courseName,
        @Param("professorName") String professorName
    );
}

 

이후 쿼리는 아래처럼 단순화되었다.

Course + Professor + Syllabus 1회
Enrollment (학생별) 1회

결과적으로 전체 조회가 두 번의 쿼리로 끝나게 되었다.

성능은 다음과 같이 개선되었다.

지표                                                             N+1 발생 시                   Fetch Join                           적용 후변화
평균 응답 시간 2.28초 132ms 94% 단축
최대 응답 시간 7.92초 1.23초 83% 감소
초당 요청 처리량 76 req/s 200 req/s 2.6배 증가

 

3. Redis 캐시 도입

Fetch Join으로 쿼리 수는 크게 줄었지만,
여전히 모든 요청이 DB를 조회한다는 점은 남아 있었다.
수강신청 페이지는 수천 명이 동일한 목록을 동시에 조회하기 때문에
DB 접근 자체를 최소화할 필요가 있었다.

이를 위해 Redis를 도입하여 자주 조회되는 데이터를 캐시했다.

 

 

Redis 설정

/**
 * Redis 캐시 설정 클래스
 *
 * - @EnableCaching : 스프링 캐시 기능 활성화
 * - RedisCacheManager : @Cacheable, @CachePut, @CacheEvict 등의 캐시 어노테이션이 동작할 때
 *   Redis를 캐시 저장소로 사용하도록 관리
 * - LettuceConnectionFactory : Redis 서버와의 연결을 관리 (기본 localhost:6379)
 * - GenericJackson2JsonRedisSerializer : 객체를 JSON 형식으로 직렬화/역직렬화
 */
@Configuration
@EnableCaching
public class RedisConfig {

    /**
     * Redis 연결 설정
     * 기본 설정은 localhost:6379 (application.yml에서 별도 설정 가능)
     */
    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        return new LettuceConnectionFactory();
    }

    /**
     * RedisCacheManager 설정
     * - 캐시 값은 JSON 직렬화를 사용해 저장
     * - 스프링의 @Cacheable에서 자동으로 사용됨
     */
    @Bean
    public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
        RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
            .serializeValuesWith(
                RedisSerializationContext.SerializationPair.fromSerializer(
                    new GenericJackson2JsonRedisSerializer()
                )
            );

        return RedisCacheManager.builder(connectionFactory)
            .cacheDefaults(config)
            .build();
    }
}
 

서비스 계층에 캐시를 적용했다.

@Cacheable(key = "#courseSearch.courseName + '_' + #courseSearch.professorName")
public List<Course> findCourses(CourseSearch courseSearch) {
    return courseJPARepository.findCoursesWithRelations(
        courseSearch.getCourseName(),
        courseSearch.getProfessorName()
    );
}

첫 요청 시에는 DB에서 데이터를 조회하고 Redis에 저장한다.
이후 동일한 요청은 DB를 거치지 않고 Redis 캐시에서 즉시 반환된다.
TTL(Time To Live)은 10분으로 설정하여 데이터 신선도를 유지했다.

 

또한 정보가 업데이트나 삭제가 되는 경우에는 캐시를 지워야 하기에 다음과 같이 설정했다.

    //==강의 삭제==//
    @Transactional
    //캐시 무효화
    @CacheEvict(value = "courses", allEntries = true)
    public void deleteCourse(Long courseId) {
        Course course = courseRepository.findOne(courseId);
        courseRepository.delete(course);
    }

 

4. 주의사항 

Redis는 객체를 JSON 형태로 직렬화하여 저장한다.
이 과정에서 JPA 엔티티의 양방향 연관관계가 그대로 포함되면,
객체 내부에서 서로를 계속 참조하게 되어 직렬화 과정이 끝없이 반복될 수 있다.
그 결과 아래와 같은 예외가 발생한다.

com.fasterxml.jackson.databind.exc.InvalidDefinitionException:
Document nesting depth (1001) exceeds the maximum allowed (1000)

이는 Jackson 직렬화가 객체를 순환 참조하며 무한히 중첩되는 상황을 의미한다.
예를 들어, Course가 Professor를 가지고, Professor가 다시 Course 리스트를 참조하는 구조에서는
직렬화 중 무한 루프가 발생한다.

 

이 문제를 해결하기 위해 다음과 같은 조치를 적용했다.

 

양방향 관계 중 한쪽을 직렬화에서 제외

@JsonIgnoreProperties("course")
private List<Assignment> assignments;

 

5. 결과

Redis 캐시를 적용한 후 부하 테스트 결과는 다음과 같다.

지표                                                      Fetch Join 적용 후           Redis                              캐시 적용 후  변화
평균 응답 시간 132ms 71ms 46% 추가 단축
95퍼센타일 응답 435ms 272ms 1.6배 개선
초당 요청 처리량 200 req/s 424 req/s 2.1배 향상
HTTP 실패율 0% 0% 유지

Fetch Join만으로도 충분히 빨랐지만,
Redis 캐시를 추가하면서 실시간 트래픽에서도 안정적인 응답 속도를 확보할 수 있었다.
특히 오픈 직후 수천 건의 요청이 동시에 들어올 때에도
DB 부하가 거의 발생하지 않았다.

 

마무리하며:

성능 개선 과정을 정리하면 다음과 같다.

  1. N+1 문제: 지연 로딩으로 인해 수십~수백 개의 쿼리가 발생
  2. Fetch Join 적용: 한 번의 쿼리로 연관 데이터까지 조회
  3. Redis 캐시 도입: 자주 조회되는 데이터는 메모리에서 즉시 응답

결과적으로 평균 응답 시간은 2.28초 → 71ms,
약 32배 개선되었다.

수강신청처럼 동시 접속이 폭발적으로 증가하는 서비스에서는
DB 튜닝보다 쿼리 최적화 + 캐시 계층 설계가 가장 효과적인 해법이었다.

 

성능 측정은 실제 운영 환경과 유사한 조건에서 진행되었다.
테스트는 k6 부하 테스트 도구를 사용하여 수행했다.

항목                                 내용
테스트 도구 k6 (HTTP 부하 테스트)
테스트 기간 약 40초
가상 사용자(VUs) 최대 500명 동시 접속
요청 시나리오 수강신청 메인 페이지 진입 (강의 목록 조회 요청 반복)
데이터 규모 Member 약 500명, Course 약 120개, Professor 약 20명, Syllabus 약 120개
DB MySQL 8.0 (로컬 환경)
캐시 Redis (로컬, 기본 포트 6379)

 

 

감사합니다.

'최적화' 카테고리의 다른 글

[DB] DMBS 및 SQL 활용 쿼리 최적화  (0) 2026.01.16
'최적화' 카테고리의 다른 글
  • [DB] DMBS 및 SQL 활용 쿼리 최적화
0kingki_
0kingki_
자바 + 스프링 웹 개발
  • 0kingki_
    0kingki_
    0kingki_
  • 전체
    오늘
    어제
    • 분류 전체보기 (134)
      • 코딩 테스트 (54)
      • 자바 (21)
      • 스프링 (27)
      • 타임리프 (16)
      • 스프링 데이터 JPA (8)
      • 최적화 (2)
      • QueryDSL (4)
      • AWS (2)
  • 블로그 메뉴

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

  • 공지사항

  • 인기 글

  • 태그

    스프링 데이터 JPA
    dfs
    자바
    BFS
    다형성
    fetch join
    LocalDateTime
    최적화
    SpringDataJpa
    컬렉션
    mvc
    SOLID
    thymeleaf
    코딩 테스트
    불변객체
    스프링 컨테이너
    백준
    예외처리
    예외 처리
    객체지향
    쿼리dsl
    Java
    코딩테스트
    QueryDSL
    쿼리
    타임리프
    재귀
    spring
    JPA
    스프링
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.4
0kingki_
[최적화] 수강신청 메인 페이지 성능 개선 (N+1, Redis)
상단으로

티스토리툴바