개요
운영 중인 러닝 앱에서 가장 먼저 해결한 문제는 활동 저장이 레벨, 챌린지, 훈련 계획 업데이트까지 한 트랜잭션에 묶여 응답이 느려지는 점이었습니다. Spring Events와 @TransactionalEventListener(AFTER_COMMIT) + @Async로 후속 처리를 분리해 POST /activities를 약 100ms에서 5ms로 줄였고, Redis 캐시와 JOIN FETCH로 조회 성능도 함께 정리했습니다. 웹과 iOS 클라이언트까지 직접 구현했지만, 이 프로젝트의 핵심은 실제 사용하는 서비스를 운영하면서 백엔드 병목을 하나씩 없앤 경험입니다.
기술적 도전
- ›활동 저장 시 Spring Events + @Async로 비동기 분리 → 응답 시간 95% 단축
- ›Redis 캐싱으로 조회 API 응답 70-86% 개선
- ›k6 부하테스트로 전체 처리량 29% 향상 검증
실제 서비스 운영
- ›NCP(네이버 클라우드)에 HTTPS 배포, 현재 운영 중
- ›데모: https://jinhyuk-portfolio1.shop
- ›개인 러닝 기록용으로 실제 사용 중 (주 3-4회 활동 기록)
플랫폼 구성
- ›Backend: Spring Boot 3 + PostgreSQL + Redis
- ›Web: React 18 + TypeScript + Tailwind CSS
- ›iOS: SwiftUI + HealthKit + Core Location
주요 기능
- ›활동 기록, 레벨 시스템(Lv.1-10)
- ›챌린지(6종), 훈련 계획(5K/10K/하프 9종)
- ›GPS 경로 추적, HealthKit 연동(심박·케이던스·걸음 수)
아키텍처
전체 아키텍처▼
ERD
전체 ERD▼
시퀀스 다이어그램
활동 기록 (이벤트 비동기)▼
챌린지 참여▼
훈련 계획 시작▼
문제 원인
- 01활동 저장 시 레벨·챌린지·훈련 계획 업데이트를 동기로 처리하면 응답 시간이 길어지고 트랜잭션 범위가 커집니다.
- 02활동 요약, 챌린지 목록 등 빈번한 조회 API가 매번 DB를 조회해 응답 시간이 느렸습니다.
- 03JOIN FETCH 없이 연관 엔티티를 조회하면 N+1 쿼리가 발생했습니다.
- 04최적화 효과를 정량적으로 측정·비교할 부하 테스트 환경이 없었습니다.
해결 과정
- 01Spring Events + @TransactionalEventListener(AFTER_COMMIT) + @Async로 레벨·챌린지·계획 업데이트를 비동기 분리했습니다. @Retryable(maxAttempts=3)로 실패 시 재시도합니다.
- 02Redis 캐싱을 도입해 activitySummary(5분), activeChallenges(10분), plans(30분) TTL로 조회 부하를 줄였습니다.
- 03JOIN FETCH 쿼리와 11개 복합 인덱스를 추가해 N+1 문제를 해결했습니다.
- 04k6 스크립트로 baseline, optimized, async-event, compression 시나리오를 작성하고 100 VU 부하로 Before/After를 측정했습니다.
결과
- 01POST /activities 응답 시간: ~100ms → ~5ms (95% 감소), 비동기 이벤트로 메인 트랜잭션 분리.
- 02GET /activities/summary 응답 시간: 7.43ms → 1.01ms (86% 감소), Redis 캐시 적중.
- 03N+1 쿼리(5건 조회 시): 6회 → 1회 (83% 감소), JOIN FETCH 적용.
- 04전체 처리량(TPS): 69.88 → 90.16 req/s (29% 향상), 에러율 0% 유지.
- 05Docker 이미지 크기: 350MB → 220MB (37% 감소), 멀티스테이지 빌드 + Alpine.