NOTE일본어 학습 커뮤니티 서비스를 개발하며 겪은 성능 병목 현상을 분석하고,
JPA Fetch Join, Redis Write-Back, DB Indexing을 통해 응답 속도를 최대 27배(2700%) 개선한 과정을 공유합니다.
테스트 중 2.3초의 수준의 심각한 성능저하 식별
로컬 개발 환경에서는 빠르게 동작하였지만, DB 서버와의 지연을 고려하여
특히 댓글이 많은 게시글 상세 조회 API는 평균 응답 속도가 2.3초에 달하는 치명적인 수준을 확인했습니다.
이를 해결하기 위해 읽기와 쓰기 영역을 나누어 단계별 최적화를 진행했습니다.
읽기 최적화 (게시글 목록 조회 N+1 문제)
문제 상황
WARNING
Post조회 시 작성자 정보(member_id)를 함께 보여줘야하는데, JPA 지연 로딩으로 인해 게시글 조회 후 각 게시글마다 작성자를 찾기 위한 추가 쿼리가 발생하는 N+1 문제 발생
@ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "member_id", nullable = false) private Member member;- 현상 : 게시글 20개 조회 시 총 21회의 쿼리 실행
- 영향 : 트래픽 증가 시 DB 커넥션 풀 고갈 위험
해결
NOTESpring Data JPA의
@Query와JOIN FETCH를 사용하여, 연관된 엔티티를 한 번의 쿼리로 가져오도록 변경
// 변경 전: N + 1 발생Page<Post> findByPostStatusAndPostType(PostStatus postStatus, PostType postType, Pageable pageable);
// 변경 후: Fetch Join 적용@Query("SELECT p FROM Post p " + "JOIN FETCH p.member m " + "LEFT JOIN FETCH p.category c " + "WHERE p.postStatus = :postStatus AND p.postType = :postType")Page<Post> findPostsWithMember(@Param("postStatus") PostStatus postStatus, @Param("postType") PostType postType, Pageable pageable);결과
| Avg Latency | Query Count | 개선율 | |
|---|---|---|---|
| Before | 59.73ms | 21 | 약 17% 개선 |
| After | 49.48ms | 1 | 95% 감소 |
- 로컬 테스트이므로 속도 차이는 크지 않지만, 쿼리 횟수를 줄여 DB 리소스 효율을 극대화
쓰기 최적화 (조회수 동시성 및 Lock 문제 해결)
문제 상황
WARNING게시글을 클릭할 때마다 조회수를 업데이트하기위해
UPDATE post SET view_count = view_count + 1쿼리 실행
- 이는 k6 부하 테스트 과정에서
DB Lock에 의한 평균 응답이 1.35초까지 오름
해결
- DB에 직접 쓰지 않고 Redis의 인메모리 연산을 활용한 Write-Back 패턴 도입
방식
- 조회 시
INCR명령어로 메모리 상에 카운트 - 1분 단위로 스케줄러로 Redis 데이터를 DB에 일괄 반영
- 사용자에게는 DB + Redis 값 합산으로 실시간성 보장
결과
| 구분 | Before | After | 개선 효과 |
|---|---|---|---|
| Avg Latency | 1.35s | 0.25s | 5.4배 개선 |
| TPS | 67/s | 232/s | 4.2배 증가 |
- Lock 대기 시간이 사라지며 처리량 상승
댓글 조회 최적화 (2700% 성능 개선)
- 댓글 영역에도 동일하게 처음에는 N + 1 문제를 식별하고 해결을 준비하였습니다.
NOTE댓글 최적화는 가장 큰 값 변화가 있었으며, 많은 배움을 얻은 부분입니다
N + 1 해결 시작
해결 전에 성능 테스트부터 진행했습니다.
테스트 환경
- 사용자 1명의 댓글 100개
- DB : Azure / PostgreSQL
█ TOTAL RESULTS
checks_total.......: 2800 91.30/s checks_succeeded...: 96.85% 2712 out of 2800 checks_failed......: 3.14% 88 out of 2800
✓ status is 200 ✗ time < 100ms ↳ 93% — ✓ 1312 / ✗ 88
HTTP http_req_duration..............: avg=84.84ms { expected_response:true }...: avg=84.84ms p(95)=105.48ms- 측정했을 때 N + 1 문제치곤 빠르다 생각해서 의심을 했었어야했습니다
@Query("SELECT c FROM Comment c JOIN FETCH c.member WHERE c.post.id = :postId AND c.commentStatus = :status") Page<Comment> findByPost_IdAndCommentStatus(@Param("postId") Long postId, @Param("status") CommentStatus status, Pageable pageable);처음에는 다른 것과 다름없이 N + 1 해결을 위해 Fetch Join으로 해결을 시도했습니다.
어느 정도 속도가 빨리지겠다 생각했지만 결과는 예상과 크게 달랐습니다.
테스트 결과
█ TOTAL RESULTS
checks_total.......: 2800 90.58/s checks_succeeded...: 91.14% 2552 out of 2800 checks_failed......: 8.85% 248 out of 2800
✓ status is 200 ✗ time < 100ms ↳ 82% — ✓ 1152 / ✗ 248
HTTP http_req_duration..............: avg=97.61ms { expected_response:true }...: avg=97.61ms max=582.1ms- N + 1 문제는 해결되었지만, 오히려 속도는 느려지고 에러율은 증가했습니다.
어째서?
- 원인은 성능 관련해서 찾아보니 PostgreSQL에 있었습니다
- 우리가 MySQL을 사용할 때는 자동으로 인덱스를 진행해줘서 신경쓰지 않았지만, PostgreSQL는 오히려 인덱스가 성능을 저하할 수 있다 생각하나봅니다.
- 따라서 인덱스 없이 발생한
JOIN은 Full Table Scan으로 이어져 성능이 오히려 저하되었습니다.
(2차 개선 시도) 인덱스 튜닝
post_id와 created_at을 묶은 복합 인덱스를 수동 생성하여 Full Scan을 막았습니다
CREATE INDEX idx_comment_post_created ON comment (post_id, created_at DESC);테스트 결과
█ TOTAL RESULTS
checks_total.......: 2800 91.41/s checks_succeeded...: 97.21% 2722 out of 2800 checks_failed......: 2.78% 78 out of 2800
✓ status is 200 ✗ time < 100ms ↳ 94% — ✓ 1322 / ✗ 78
HTTP http_req_duration..............: avg=87.41ms { expected_response:true }...: avg=87.41ms p(95)=102.73ms- 이렇게 했을 때 개선이 분명 되긴했는데.. 뭔가 이상합니다.
| 구분 | 기존 | 1차 개선 | 2차 개선 |
|---|---|---|---|
| 상태 | N + 1 발생 | Fetch Join 적용 | Fetch Join + Index |
| Avg | 84.84ms | 97.61ms | 87.41ms |
| Fail Rate | 3.14% | 8.85% | 2.78% |
| P95 | 105.48ms | 120.45ms | 102.73ms |
- 개선이 되지 않았다기엔 개선이 되었지만, 드라마틱한 변화가 없었어요
- 굳이? 싶은 행동을 했다 싶어서 어째서인가? 고민했습니다
테스트 문제
사실 개선 과정은 나쁘지 않았다고 생각합니다. 다만 지식이 부족해서 예상 범주 밖이였던 거 같았습니다.
생각하지 않았던 건 JPA의 1차 캐시 였습니다.
이유는?
- 이 전에 제가 테스트 과정을
사용자 1명의 댓글 100개이라 했었는데 저는 이게 N + 1로 터질 줄 알았습니다. - 다만, 작성자가 동일하면 Hibernate 1차 캐시로 N+1이 발생하지 않을줄 전혀 몰랐습니다
따라서 개선이 잘못된 건 아니지만 테스트가 잘못되었다 생각하며, 다시 한번 검증을 진행했습니다.
마지막 검증 테스트
최적화 전

최적화 후

최종 결과
| 구분 | 최적화 전 | 최적화 후 | 개선율 |
|---|---|---|---|
| Avg | 2,310ms | 84.95ms | 2700% 개선 |
| TPS | 14.2 / sec | 45.4 / sec | 3.2배 처리량 증가 |
| P95 | 2,950ms | 115.5ms | 25배 개선 |
| 총 거리 요청 수 | 476건 | 1,400건 | 3배 처리량 증가 |
최종 테스트를 진행했을 때 약 2700%라는 앞도적 성능 개선이 있었습니다!
스스로 반성하기
- 최종 테스트를 통해 알다시피 N+1 문제에 의한 네트워크 비용은 굉장히 성능 저하가 발생한다는 것을 알았습니다.
- 또한 JPA가 좋아도 DB의 특성이 다 다르므로 이에 잘 알 필요가 있을 거 같습니다.
- 마지막으로 테스트는 단순 더미 데이터보단 확실하게 다양한 더미 데이터로 진행하여 병목을 확인해야할 필요가 있을 거 같습니다