[트러블슈팅] 모두의 후기 DB 커넥션 풀과 SSE 중복 연결 정리

들어가며

모두의 후기 프로젝트를 진행하면서 기능 구현만큼 크게 배운 부분은 운영 중 문제를 확인하고 고치는 과정이었다.

개발 환경에서는 잘 동작하던 기능도 실제 서버 환경에서는 다른 문제가 드러난다.
이번에 마주한 대표적인 문제는 DB 커넥션 풀 부족과 SSE 중복 연결 문제였다.

처음 로그에서 확인한 에러는 HikariCP 커넥션을 가져오지 못했다는 내용이었다.

1
2
HikariPool-1 - Connection is not available, request timed out
total=10, active=10, idle=0, waiting=0

이 로그를 보고 단순히 “DB가 느린가?”라고 생각할 수도 있었지만, 조금 더 들여다보니 커넥션을 사용하는 방식과 SSE 연결 관리 방식까지 함께 점검해야 했다.

문제 1. DB 커넥션 풀 부족

에러 로그에서 가장 먼저 보였던 것은 HikariCP의 커넥션 부족이었다.

1
2
3
4
total=10
active=10
idle=0
waiting=0

즉, 커넥션 풀이 모두 사용 중이었고 새로운 요청이 커넥션을 얻지 못해 타임아웃이 발생한 상황이었다.

물론 풀 사이즈를 무작정 늘리는 것은 좋은 해결책이 아니다.
하지만 당시 설정에서는 서비스 요청량과 SSE 연결 상황을 고려했을 때 기본값만으로는 부족할 수 있다고 판단했다.

그래서 서버 설정에 HikariCP 관련 값을 명시했다.

  • maximum-pool-size
  • connection-timeout
  • max-lifetime
  • leak-detection-threshold

이 설정은 단순히 커넥션 수를 늘리는 것뿐 아니라, 커넥션이 오래 붙잡혀 있거나 누수되는 상황을 더 빨리 감지하기 위한 목적도 있었다.

문제 2. SSE 중복 연결

SSE는 클라이언트가 서버와 연결을 유지하는 구조다.

사용자가 새로고침을 하거나 여러 탭을 열면 같은 사용자에 대해 여러 emitter가 생길 수 있다. 기존 연결이 제대로 종료되지 않으면 서버는 이미 필요 없는 연결을 계속 들고 있게 된다.

이 문제를 줄이기 위해 NotificationEmitterRegistry의 동작을 수정했다.

핵심은 동일 사용자에 대한 기존 SSE 연결이 있으면 새 연결을 등록하기 전에 기존 emitter를 종료하고 제거하는 것이었다.

흐름은 다음처럼 정리했다.

  1. 사용자가 SSE 연결을 요청한다.
  2. 기존 emitter가 있는지 확인한다.
  3. 기존 emitter가 있다면 완료 처리한다.
  4. registry에서 안전하게 제거한다.
  5. 새 emitter를 등록한다.
  6. 완료, timeout, error 상황에서 제거 로직이 실행되도록 한다.

이렇게 하면 같은 사용자가 여러 번 연결해도 서버에 emitter가 계속 쌓이는 문제를 줄일 수 있다.

문제 3. 읽기 트랜잭션 정리

DB 커넥션 문제를 보면서 서비스 계층의 트랜잭션도 함께 점검했다.

BookmarkService, CommentService처럼 조회와 쓰기가 함께 있는 서비스에서 모든 메서드가 동일한 트랜잭션 설정을 사용하고 있었다.

조회 전용 메서드는 데이터를 변경하지 않는다.
그래서 @Transactional(readOnly = true)를 명시해 JPA의 불필요한 변경 감지 비용을 줄이고, 읽기 작업이라는 의도를 코드에 드러내도록 했다.

정리한 예시는 다음과 같다.

  • 북마크 상세 조회: readOnly 적용
  • 댓글 목록 조회: readOnly 적용
  • 댓글 개수 조회: readOnly 적용
  • 댓글 삭제 로직: 쓰기 트랜잭션 유지

readOnly = true 하나로 모든 성능 문제가 해결되는 것은 아니다.
하지만 조회와 변경의 성격을 분리하는 것은 코드 유지보수와 성능 양쪽에서 의미가 있었다.

문제 4. 최신순 정렬과 쿠키 오타

운영 중 발견된 작은 버그들도 함께 수정했다.

하나는 메인 화면의 최신 리뷰 조회였다.
기존에는 오래된 순으로 가져오는 메서드를 사용하고 있어, 메인 화면에 최신 리뷰가 제대로 노출되지 않았다.

1
findTop6ByOrderByCreatedAtAsc()

이를 최신순으로 변경했다.

1
findTop6ByOrderByCreatedAtDesc()

또 다른 문제는 게시글 삭제 요청에서 사용하는 쿠키 이름 오타였다.

1
userNicnkname

1
userNickname

으로 수정했다.

작은 오타였지만 인증된 사용자를 식별하는 흐름에서는 치명적인 문제가 될 수 있었다.
이런 경험을 하면서 운영 환경에서는 사소해 보이는 문자열 하나도 실제 사용자 기능을 막을 수 있다는 것을 느꼈다.

느낀 점

이번 트러블슈팅에서 가장 크게 배운 것은 로그를 읽는 태도였다.

처음에는 에러 메시지만 보고 커넥션 풀 크기 문제라고 생각하기 쉬웠다. 하지만 실제로는 다음 요소들이 함께 연결되어 있었다.

  • DB 커넥션 풀 설정
  • SSE 연결 생명주기
  • 조회 트랜잭션 설정
  • 서비스 메서드의 정렬 조건
  • 쿠키 이름 불일치

문제는 하나의 원인만으로 발생하지 않을 때가 많았다.
그래서 로그를 기준으로 증상을 확인하고, 코드 흐름을 따라가며 가능성을 하나씩 줄이는 과정이 필요했다.

또 하나 느낀 점은 운영 설정도 코드만큼 중요하다는 것이다.

로컬에서는 문제가 없던 기능도 배포 환경에서는 트래픽, 커넥션 수, 네트워크 상태, 브라우저 재연결 등 다양한 변수가 생긴다. 그래서 기능을 만들 때부터 운영 상황을 조금 더 상상해야겠다고 생각했다.

마무리

이번 작업은 화려한 기능 추가는 아니었다.
하지만 서비스를 안정적으로 운영하기 위해 꼭 필요한 정리였다.

DB 커넥션 풀을 명시적으로 설정하고, SSE 중복 연결을 정리하고, 조회 트랜잭션을 readOnly로 분리하고, 운영 중 발견된 버그를 수정했다.

이 과정을 통해 백엔드 개발에서 중요한 것은 기능을 만드는 것뿐 아니라, 그 기능이 오래 안정적으로 동작하도록 계속 관찰하고 다듬는 일이라는 것을 배웠다.

Categories:

Updated:

Leave a comment