1. 모니터링 인프라 및 기술 스택 구성
성능 개선의 첫걸음은 정확한 상태 파악을 위한 관측성(Observability) 확보였습니다. Fast Retro는 다음과 같은 도구들을 단일 서버에 구축하고 Kamal 2를 통해 배포했습니다.
- Prometheus: 5초 간격으로 메트릭을 수집하고 30일간의 데이터를 저장합니다.
- Grafana: 수집된 데이터를 시각화하고 대시보드를 통해 실시간 모니터링을 수행합니다.
- Loki & Promtail: 컨테이너 로그를 집계하고 분석합니다.
- Yabeda Gems: Rails 내부의 컨트롤러 지연 시간, 요청 수, ActiveJob 및 ActionCable 메트릭을 Prometheus 형식으로 노출합니다.
특히 보안을 위해 모든 모니터링 스택은 Tailscale 네트워크 내에 배치되어 외부 노출 없이 안전하게 관리됩니다.
2. 세 가지 주요 성능 병목 현상 식별
Grafana 대시보드를 통해 p95 지연 시간이 높은 세 가지 컨트롤러 액션을 특정했습니다.
① 토론 단계(DiscussionsController#show) - 400ms
이 화면은 피드백 카드를 렌더링할 때 작성자 정보, ActionText 내용, 투표 수 등을 개별적으로 쿼리하고 있었습니다. 피드백이 20개일 경우 약 80개 이상의 쿼리가 발생하는 전형적인 N+1 문제였습니다.
* 해결책: .includes(:user, :rich_text_content)를 사용하여 연관 관계를 사전 로딩하고, SQL COUNT(*)를 유발하는 .count 대신 메모리에 로드된 배열 크기를 확인하는 .size를 사용하도록 변경했습니다.
② 레트로 목록(RetrosController#index) - 360ms
각 레트로 카드마다 포함된 피드백 개수를 표시하기 위해 루프 내에서 개별 쿼리를 실행하고 있었습니다.
* 해결책: 컨트롤러에서 GROUP BY를 사용하여 모든 레트로의 피드백 개수를 단일 쿼리로 가져온 뒤, 해시 형태로 뷰 컴포넌트에 전달하는 배치 로딩 방식을 도입했습니다.
③ 투표 단계(VotingsController#show) - 243ms
투표 버튼 컴포넌트가 렌더링될 때마다 사용자의 남은 투표 권한과 개별 아이템의 투표 수를 반복적으로 조회했습니다. 특히 can_add_vote? 메서드는 모든 버튼에서 동일한 쿼리를 중복 실행하고 있었습니다.
* 해결책: 사용자의 투표 내역을 한 번에 사전 로딩한 후, 데이터베이스 재조회 없이 Ruby의 .select 메서드를 사용하여 메모리 내에서 필터링하도록 로직을 개선했습니다.
3. 최적화 패턴 및 교훈
발견된 모든 버그는 공통된 패턴을 따르고 있었습니다. 개별적으로는 합리적으로 보이는 컴포넌트 로직이 루프 내에서 반복 실행되면서 O(1) 작업이 O(N)으로 확장된 것입니다. 이를 해결하기 위해 다음과 같은 최적화 원칙을 적용했습니다.
- Eager Loading: 쿼리 수준에서
includes를 적극 활용하여 연관 데이터를 미리 가져옵니다. - Collection Usage: preloaded 데이터를 활용하기 위해
.count대신.size를 사용합니다. - Batch Loading: 집계 데이터가 필요한 경우
GROUP BY를 통해 한 번에 조회합니다. - Ruby-side Filtering: 이미 로드된 컬렉션은 DB를 다시 호출하는 대신 Ruby 내부 로직으로 필터링합니다.
결론적으로, Prometheus와 같은 메트릭 도구는 개발 환경에서는 체감하기 어려운 생산 환경의 성능 병목을 가시화하여 개발자가 올바른 최적화 지점을 찾을 수 있도록 돕는 필수적인 도구임을 입증했습니다.