유지보수 게시판 조회 성능 개선하기
by rowing0328Intro
이번 글에서는 사내 유지보수 게시판의 목록 조회 성능을 개선했던 경험을 정리해보려고 한다.
처음에는 단순히 게시판 목록 조회가 조금 느린 정도로 보였지만,
데이터가 쌓이면서 응답 시간이 점점 길어졌고 실제 업무 흐름에도 영향을 주기 시작했다.
이 게시판은 내부 운영 담당자들이 일상적으로 사용하는 기능이었다.
따라서 단순히 기술적으로 느린 API 하나를 개선하는 문제가 아니라,
반복적으로 발생하는 조회 지연을 줄여 업무 흐름을 덜 끊기게 만드는 것이 중요했다.
이번 작업은 2025년 중반에 진행했고,
Java, Spring Data JPA, JPQL, MySQL 환경에서 분석부터 구현까지 진행했다.
기존 문제 상황
유지보수 게시판은 목록 화면에서 게시글을 페이지 단위로 조회하고,
각 게시글에 필요한 부가 정보도 함께 보여주는 구조였다.
문제는 데이터가 늘어나면서 목록 조회 응답 시간이 점진적으로 느려지고 있었다는 점이다.
게시글 25건을 조회하는 기준으로 전체 응답 시간이 약 2.5초에서 3.4초까지 발생했다.
처음에는 단순히 데이터가 많아져서 느려진다고 생각할 수도 있었다.
하지만 실제로 어떤 쿼리가 얼마나 실행되는지 확인하지 않으면 정확한 원인을 알 수 없었다.
그래서 `jdbc.sqltiming` 로그를 확인했고,
게시판 목록 조회 한 번에 총 26회 이상의 쿼리가 실행되고 있는 것을 확인했다.
게시글 목록 조회 1회
+ 게시글별 최신 댓글 조회 25회
= 총 26회 이상 쿼리 실행
목록 조회 한 번에 여러 쿼리가 반복적으로 실행되고 있었고,
그중 일부 쿼리는 실행 계획상 인덱스를 제대로 활용하지 못하고 있었다.
이 지점부터 단순한 코드 수정이 아니라 실제 쿼리 흐름을 기준으로 병목을 좁혀가야겠다고 판단했다.
쿼리 로그와 실행 계획 확인
성능 문제를 볼 때 가장 먼저 확인한 것은 실제 실행되는 SQL이었다.
JPA를 사용하면 코드에서는 Repository 메서드나 JPQL만 보이지만,
실제 DB에 전달되는 SQL은 예상과 다를 수 있다.
이번에도 `jdbc.sqltiming` 로그를 통해 실제 실행 쿼리와 소요 시간을 확인했다.
그다음 MySQL의 `EXPLAIN`으로 실행 계획을 확인하면서 병목 지점을 좁혀갔다.
확인한 주요 병목은 두 가지였다.
| 구분 | 확인한 문제 | 영향 |
|---|---|---|
| 검색 조건 | `COALESCE` 패턴이 WHERE 절에 남음 | 인덱스를 활용하지 못하고 Full Scan 발생 |
| 댓글 조회 | 게시글마다 최신 댓글을 개별 조회 | 게시글 수만큼 추가 쿼리가 발생하는 N+1 문제 |
각각을 조금 더 자세히 정리해 보겠다.
원인 1. COALESCE 조건으로 인한 Full Scan
기존 게시글 조회 JPQL에는 검색 파라미터가 없을 때도 같은 쿼리를 재사용하기 위한 조건이 들어가 있었다.
예를 들면 다음과 같은 형태다.
COALESCE(:param, '') = '' OR column = :param
이 방식은 검색 파라미터가 있든 없든 하나의 쿼리로 처리할 수 있다는 장점이 있다.
코드 입장에서는 분기문이 줄어들기 때문에 간단해 보인다.
하지만 실행 계획을 확인해 보니,
검색 파라미터가 없는 경우에도 해당 조건이 WHERE 절에 남아 있었다.
그 결과 옵티마이저가 인덱스를 활용하기 어려운 형태가 되었고,
테이블 전체를 스캔하는 문제가 발생했다.
즉, 애플리케이션 코드는 단순해졌지만 DB 입장에서는 효율적으로 실행하기 어려운 쿼리가 된 것이다.
검색 조건은 업체, 작업상태, 기간 정도로 제한적이었다.
그래서 모든 조건을 하나의 JPQL로 처리하기보다,
파라미터 유무에 따라 필요한 조건만 포함하는 방식이 더 적합하다고 판단했다.
원인 2. 최신 댓글 조회에서 발생한 N+1 쿼리
두 번째 문제는 최신 댓글 조회 방식이었다.
게시글 목록을 먼저 조회한 다음,
반복문 안에서 각 게시글의 최신 댓글을 개별 쿼리로 조회하고 있었다.
개념적으로 보면 다음과 같은 코드 흐름이었다.
List<Board> boards = boardRepository.findBoards(condition, pageable);
for (Board board : boards) {
Comment latestComment = commentRepository.findLatestComment(board.getId());
board.setLatestComment(latestComment);
}
게시글이 25건이면 댓글 조회 쿼리도 25번 실행된다.
게시글 목록 조회 쿼리 1회를 포함하면 최소 26회의 쿼리가 발생하는 구조였다.
단건 댓글 조회만 보면 1회 쿼리 비용이 아주 커 보이지 않을 수 있다.
하지만 목록 조회처럼 자주 호출되는 기능에서 반복적으로 실행되면 전체 응답 시간에 직접적인 영향을 준다.
이번 사례에서도 댓글 조회만 약 800ms 정도를 차지하고 있었다.
개선 방향
이번 개선에서 가장 중요하게 생각한 것은 변경 범위였다.
QueryDSL이나 Criteria API를 도입하면 동적 쿼리를 더 타입 안전하고 유연하게 구성할 수 있다.
하지만 기존 레거시 구조에서 새로운 쿼리 작성 방식을 도입하면 변경 범위가 커지고,
팀 내 학습 비용도 함께 발생할 수 있었다.
현재 검색 조건은 많지 않았고 조건이 급격히 늘어날 가능성도 낮다고 보았다.
그래서 기존 JPQL 기반 구조를 유지하면서 조건별로 쿼리를 분기하는 방식을 선택했다.
| 개선 대상 | 선택한 방식 |
|---|---|
| 검색 조건 처리 | 파라미터 유무에 따른 조건부 JPQL 분기 |
| 최신 댓글 조회 | 게시글 번호 목록 기반 IN절 + 상관 서브쿼리 일괄 조회 |
| 애플리케이션 매핑 | 댓글 조회 결과를 Map으로 변환 후 게시글에 매핑 |
검색 조건별 JPQL 분리
먼저 게시글 조회 쿼리에서 불필요한 `COALESCE` 조건을 제거했다.
기존에는 검색 조건이 없어도 항상 같은 WHERE 절이 포함되었다.
개선 후에는 검색 파라미터가 있을 때만 해당 조건이 JPQL에 포함되도록 분기했다.
예를 들어 업체 조건이 있을 때는 업체 조건을 포함한 쿼리를 실행하고,
조건이 없을 때는 해당 WHERE 조건 자체를 제외하는 방식이다.
단순화하면 다음과 같은 방향이다.
if (hasCompanyCondition && hasStatusCondition) {
return boardRepository.findByCompanyAndStatus(companyId, status, pageable);
}
if (hasCompanyCondition) {
return boardRepository.findByCompany(companyId, pageable);
}
if (hasStatusCondition) {
return boardRepository.findByStatus(status, pageable);
}
return boardRepository.findAllBoards(pageable);
실제 코드는 프로젝트 구조에 맞게 Repository 메서드를 분리했다.
핵심은 검색 조건이 없을 때도 불필요한 조건절이 남지 않도록 만든 것이다.
이렇게 변경하자 MySQL 옵티마이저가 인덱스를 활용할 수 있는 형태로 실행 계획이 바뀌었다.
게시글 조회 시간은 기존 약 2,591ms에서 1,891ms 수준으로, 개선 후 약 500ms에서 300ms 수준으로 줄었다.
최신 댓글 일괄 조회로 변경
다음으로 게시글별 최신 댓글을 반복 조회하던 구조를 제거했다.
게시글 목록은 페이지당 최대 25건이었기 때문에,
먼저 게시글 번호 목록을 추출한 뒤 해당 번호들을 기준으로 최신 댓글을 한 번에 조회하도록 변경했다.
흐름은 다음과 같다.
1. 게시글 목록 조회
2. 게시글 번호 목록 추출
3. 게시글 번호 목록을 IN절로 전달
4. 각 게시글별 최신 댓글을 한 번에 조회
5. 조회 결과를 Map으로 변환
6. 게시글에 최신 댓글 매핑
최신 댓글만 가져오기 위해 상관 서브쿼리를 사용했다.
개념적으로는 다음과 같은 JPQL이다.
SELECT c
FROM Comment c
WHERE c.boardId IN :boardIds
AND c.commentId = (
SELECT MAX(c2.commentId)
FROM Comment c2
WHERE c2.boardId = c.boardId
)
이 쿼리는 전달받은 게시글 번호 목록에 속하는 댓글 중,
각 게시글에서 가장 큰 댓글 번호를 가진 댓글만 조회한다.
댓글 번호가 증가하는 구조였기 때문에 가장 큰 댓글 번호를 최신 댓글로 판단할 수 있었다.
이후 조회 결과를 `Map`으로 변환했다.
List<Comment> latestComments = commentRepository.findLatestComments(boardIds);
Map<Long, Comment> latestCommentMap = latestComments.stream()
.collect(Collectors.toMap(Comment::getBoardId, Function.identity()));
for (Board board : boards) {
board.setLatestComment(latestCommentMap.get(board.getId()));
}
이렇게 하면 게시글 수만큼 댓글 조회 쿼리를 실행하지 않아도 된다.
게시글 25건 기준으로 댓글 조회 쿼리는 25회에서 1회로 줄었다.
IN절 크기에 대한 우려도 있었지만,
목록 페이지 크기가 최대 25건으로 제한되어 있었기 때문에 실질적인 문제는 크지 않다고 판단했다.
개선 결과
개선 전후 성능은 다음과 같았다.
| 구분 | 개선 전 | 개선 후 | 개선율 |
|---|---|---|---|
| 게시글 조회 | 1,891 ~ 2,591ms | 300 ~ 500ms | 약 82% |
| 댓글 조회 | 700 ~ 800ms / 25회 | 16 ~ 30ms / 1회 | 약 97% |
| 총 응답 시간 | 2,591 ~ 3,391ms | 1,382 ~ 2,301ms | 약 38% |
게시글 조회는 불필요한 조건절을 제거하면서 인덱스를 활용할 수 있게 되었고,
댓글 조회는 N+1 구조를 제거하면서 쿼리 수가 크게 줄었다.
특히 댓글 조회는 25회 실행되던 쿼리를 1회로 줄인 효과가 컸다.
단순히 쿼리 하나의 속도를 개선한 것이 아니라,
반복 실행 자체를 없앤 것이 성능 개선에 더 직접적으로 작용했다.
전체 응답 시간도 약 2.5초에서 3.4초 수준에서,
약 1.3초에서 2.3초 수준으로 줄었다.
아직 더 개선할 여지는 있었지만,
기존 구조를 크게 바꾸지 않고 체감 속도를 개선했다는 점에서 의미가 있었다.
작업하면서 고민한 점
이번 작업에서 고민했던 부분은 변경 범위와 개선 효과의 균형이었다.
동적 쿼리를 다루는 관점에서는 QueryDSL이나 Criteria API가 더 깔끔한 선택일 수 있다.
하지만 당시 프로젝트에서는 해당 기술을 새로 도입하는 비용이 있었고,
검색 조건도 제한적이었다.
그래서 구조를 크게 바꾸기보다 현재 병목을 만드는 조건만 제거하는 쪽을 선택했다.
레거시 시스템에서는 항상 가장 이상적인 구조로 바꾸기보다,
현재 팀과 시스템이 감당할 수 있는 변경 범위 안에서 효과가 큰 지점을 찾는 것이 중요하다고 느꼈다.
N+1 문제도 마찬가지였다.
Fetch Join이나 EntityGraph도 선택지로 볼 수 있지만,
이번 요구사항은 게시글당 전체 댓글이 아니라 최신 댓글 1건만 필요했다.
또한 페이징과 함께 동작해야 했기 때문에 Fetch Join은 적합하지 않다고 판단했다.
결국 게시글 번호 목록을 기준으로 최신 댓글만 일괄 조회하는 방식이 가장 단순하고 목적에 맞았다.
마무리
이번 글에서는 내부 유지보수 게시판의 목록 조회 성능을 개선했던 과정을 정리해 보았다.
문제의 핵심은 두 가지였다.
| 문제 | 개선 |
|---|---|
| 검색 조건의 `COALESCE` 패턴으로 인덱스를 제대로 활용하지 못함 | 파라미터 유무에 따라 JPQL을 분기 |
| 게시글별 최신 댓글을 반복 조회하면서 N+1 쿼리 발생 | IN절과 상관 서브쿼리로 최신 댓글을 일괄 조회 |
이번 작업을 하면서 느낀 건,
성능 개선은 큰 구조 변경에서만 나오는 것이 아니라는 점이다.
쿼리 로그를 보고 실제 실행 흐름을 확인한 뒤,
병목 지점을 정확히 찾아 작은 범위로 수정해도 충분히 의미 있는 개선을 만들 수 있었다.
특히 레거시 시스템에서는 전면 개편이 항상 정답은 아닐 수 있다.
현재 구조를 이해하고,
변경 비용 대비 효과가 큰 부분부터 개선하는 접근이 실무에서는 더 현실적일 때가 많다.
앞으로도 성능 이슈를 만났을 때는 감으로 판단하기보다,
실제 쿼리 로그와 실행 계획을 먼저 확인하는 습관을 가져야겠다고 느꼈다.
'📌ETC > Development Log' 카테고리의 다른 글
| Go 기초부터 HTTP CRUD API까지 (0) | 2026.05.18 |
|---|---|
| Mac에서 LitmusChaos 설치하기 (0) | 2026.05.10 |
| MinIO 자동화 운영을 위한 환경설정과 윈도우 배치 파일 작성 (3) | 2025.07.28 |
| MinIO로 버킷 관리부터 이벤트 자동화까지 (3) | 2025.07.10 |
| Jenkins Pipeline와 DooD를 결합한 CI&CD 환경 구성하기 (0) | 2025.02.21 |
블로그의 정보
커밋 사이의 기록
rowing0328