Project/Mopl

[Mopl] 개인 개발 리포트

승주우에요 2026. 7. 27. 10:51

1. 프로젝트 개요

MOPL(Movie Platform)은 대용량 트래픽을 고려한 글로벌 콘텐츠 평점 및 큐레이션 플랫폼입니다.

사용자는 영화·TV 시리즈 등 콘텐츠를 탐색하고, 리뷰를 작성하며, 실시간으로 함께 시청하는 경험을 할 수 있습니다. 플레이리스트 큐레이션, 팔로우 기반 소셜 기능, DM, 실시간 알림 등 종합적인 콘텐츠 소비 플랫폼을 목표로 합니다.

핵심 기능

  • 콘텐츠 탐색 및 검색 (TMDB/TheSportsDB 자동 수집, OpenSearch 한글 형태소 분석)
  • 리뷰 및 평점 시스템
  • 실시간 같이 보기 (WebSocket STOMP 기반 시청 세션 + 실시간 채팅)
  • 플레이리스트 큐레이션 및 구독
  • 팔로우, DM, 실시간 알림 (Kafka + SSE)

기술 스택

구분 기술
Backend Java 17, Spring Boot 3.5, Spring Security, OAuth2 (Google, Kakao)
Database PostgreSQL, Redis (ElastiCache), OpenSearch
Messaging Apache Kafka (Confluent Cloud), Redis Pub/Sub
Real-time WebSocket (STOMP), SSE (Server-Sent Events)
Infra AWS (ECS, ECR, EC2, RDS, ElastiCache, S3, Route53), Docker, Nginx
Testing JUnit5, Testcontainers, k6 (부하 테스트), JaCoCo
Code Quality SpotBugs, Checkstyle, QueryDSL, MapStruct
Monitoring Prometheus, Grafana, Micrometer

2. 담당한 작업

프로젝트 내에서 콘텐츠 도메인, 리뷰 도메인, 실시간 같이 보기(시청 세션), 배포를 담당했습니다.

담당 영역 주요 내용
콘텐츠 엔티티 설계, CRUD API, TMDB/TheSportsDB 배치 수집, 캐싱, 인덱싱 최적화
리뷰 엔티티 설계, CRUD API, QueryDSL 기반 목록 조회, 물리 삭제 배치, 평점 재계산 분산 락
실시간 같이 보기 WebSocket STOMP 설정, 시청 세션 관리, 실시간 채팅, Redis 전환, 멀티탭 참조 카운팅
배포 AWS 인프라 구축, CI/CD 파이프라인, 멀티 서버 로드밸런싱, SSL/도메인 설정
부하 테스트 k6 시나리오 설계 (단일 API 9개), 성능 병목 분석 및 최적화
공통 DB 스키마 설계, Spring 프로필 분리, Redisson 분산 락, S3 파일 저장소, Swagger 문서화

커밋 통계 (총 420건)

타입 건수 비중
feat (기능 구현) 137 32.6%
fix (버그 수정) 122 29.0%
test (테스트) 72 17.1%
refactor (리팩토링) 38 9.0%
chore / style / docs / perf / infra 51 12.1%

3. 기술적 성과

3.1 콘텐츠 도메인

외부 API 연동 배치 시스템 구축

  • TMDB API 클라이언트를 구현하여 영화·TV 시리즈 데이터를 자동 수집하는 배치 Job을 설계했습니다.
  • TheSportsDB API를 통한 스포츠 리그·경기 데이터 수집 배치도 별도로 구현했습니다.
  • Spring Batch의 Tasklet 기반으로 초기 수집(서버 시작 시 1회)과 주기 수집(스케줄러)을 분리하고, 비즈니스 로직을 CollectService로 이관하여 Tasklet의 책임을 최소화했습니다.
  • 장르 태그 자동 저장, Rate Limit/Retry 전략을 포함한 API 클라이언트를 구현했습니다.

Cache-Aside 캐싱 전략

  • 콘텐츠 목록 조회에 Redis 기반 Cache-Aside 패턴을 적용했습니다.
  • 리뷰 평점 변경 시 콘텐츠 목록 캐시를 무효화하여 데이터 정합성을 유지했습니다.

3.2 리뷰 도메인

리뷰 CRUD 및 배치 물리 삭제

  • 리뷰 생성·수정·삭제 API를 구현하고, QueryDSL 기반 커서 페이지네이션으로 목록 조회를 구현했습니다.
  • 논리 삭제된 리뷰를 물리 삭제하는 Spring Batch Job을 구현하고 스케줄러로 자동 실행되도록 했습니다.
  • 콘텐츠 삭제 시 연관 리뷰를 벌크 UPDATE로 논리 삭제하여 N+1 문제를 방지했습니다.

평점 재계산 분산 락

  • 동시에 여러 리뷰가 생성·수정될 때 콘텐츠 평균 평점의 정합성을 보장하기 위해, contentId 기준 Redisson 분산 락을 적용했습니다.

3.3 실시간 같이 보기 (시청 세션)

STOMP over WebSocket 기반 시청 세션 관리

STOMP 프레임 라이프사이클 자체를 도메인 이벤트로 해석하는 구조를 설계했습니다.

  • SUBSCRIBE /watch = 시청 시작, UNSUBSCRIBE /watch = 시청 종료, DISCONNECT = 전체 정리
  • 별도 REST API 호출 없이 구독 행위만으로 시청 세션이 관리됩니다.

StompWatchingInterceptor에서 ChannelInterceptor.preSend()를 활용하여 인바운드 프레임을 가로채고, SUBSCRIBE/UNSUBSCRIBE/DISCONNECT 각각에 대한 세션 관리 로직을 수행합니다.

인메모리 → Redis 전환 및 멀티탭 참조 카운팅

  • 초기에는 인메모리 Store(ConcurrentHashMap)로 구현한 뒤, 스케일 아웃 대비를 위해 Redis로 전환했습니다.
  • 같은 사용자가 여러 탭에서 동일 콘텐츠를 시청하는 상황을 처리하기 위해 참조 카운팅 패턴을 적용했습니다.
    • 첫 탭 입장 시에만 JOIN 이벤트 발행, 마지막 탭 퇴장 시에만 LEAVE 이벤트 발행
    • 시청자 수에는 항상 1명으로만 카운트
  • SADD + SCARD를 Lua 스크립트로 묶어 원자성을 보장하여, 동시 요청 간 경합 조건을 제거했습니다.

Redis Pub/Sub 멀티 서버 브로드캐스트

  • 다중 서버 환경에서 STOMP 메시지(채팅, 시청 세션 변경)가 모든 서버의 클라이언트에게 전달되도록 Redis Pub/Sub을 적용했습니다.
  • 각 서버에 고유 SERVER_ID(UUID)를 부여하고, 자기가 발행한 메시지는 구독 시 skip하여 중복 수신을 방지했습니다.

순환 의존성 회피

  • 인터셉터에서 직접 SimpMessagingTemplate.convertAndSend()를 호출하면 빈 순환 의존성이 발생하는 문제를 ApplicationEventPublisher + WatchingSessionEventListener 구조로 해결했습니다.

3.4 배포 및 인프라

AWS 인프라 구축

리소스 구성
네트워크 VPC, Public/Private 서브넷, NAT Gateway, 보안 그룹
컴퓨팅 ECS 클러스터, EC2 (nginx: t3.micro, app: t3.small)
데이터 RDS (PostgreSQL), ElastiCache (Redis), S3
도메인 mopl.kr (가비아 → Route53), Let's Encrypt SSL

CD 파이프라인

  • GitHub Actions 기반으로 release 브랜치 머지 시 자동 배포되는 파이프라인을 구축했습니다.
  • Gradle 빌드 → Docker 빌드 → ECR push → 환경변수 주입 → ECS 배포의 흐름으로, GitHub Secrets/Variables 31개를 관리합니다.
  • Nginx 설정 변경 시 별도 워크플로우로 자동 배포됩니다.

멀티 서버 로드밸런싱

  • app EC2를 2대로 확장하고 Nginx upstream에 라운드 로빈 방식으로 트래픽을 분산했습니다.
  • WebSocket(STOMP) 업그레이드, SSE 버퍼링 off 등 프로토콜별 Nginx 프록시 설정을 적용했습니다.

3.5 부하 테스트 및 성능 최적화

k6 부하 테스트 체계 구축

단일 API 격리 테스트 9개를 설계·실행했습니다.

  • 단일 API: content-list, content-detail, content-search, playlist-list, playlist-detail, review-create, review-list, subscribe, watching-session

인덱싱 최적화

콘텐츠 목록 조회 API에 Partial 복합 인덱스 2개를 추가하여 다음과 같은 성과를 달성했습니다:

지표 Before After 개선
p95 응답 시간 215ms 36ms -83.0%
Postgres CPU 86% 15% -82.2%
App CPU 135% 56% -58.1%
커넥션 사용 시간 0.511s 0.078s -84.7%
처리량 127 req/s 140 req/s +10.1%

리뷰 목록 조회에도 예방적 인덱스를 추가하여 Postgres CPU 32% 감소를 확인했습니다.


4. 문제점 및 해결 과정

4.1 JPA 1차 캐시와 @Modifying 쿼리의 stale 데이터 문제

문제: 리뷰 좋아요 기능에서 @Modifying 쿼리(UPDATE ... likeCount + 1)로 DB는 업데이트되지만, JPA 1차 캐시에 있는 엔티티는 갱신되지 않아 응답 DTO에 증가 전 값이 담기는 현상이 발생했습니다.

시행착오:

  1. clearAutomatically = true 추가 → 캐시는 비워지지만 이미 변수에 할당된 객체는 구버전 값 참조, lazy 접근 시 N+1 발생
  2. 엔티티 incrementLikeCount() 직접 호출 → dirty check가 구버전 값으로 덮어쓸 위험
  3. findById로 재조회 → 캐시가 DB보다 우선하여 stale 값 반환
  4. save() 호출 → 이미 managed 상태라 아무 효과 없음

해결: @Modifying(clearAutomatically = true)로 캐시를 먼저 비운 뒤, JOIN FETCH로 연관관계까지 한 번에 fresh한 엔티티를 재조회하여 응답 DTO를 구성했습니다.

교훈: JPA 1차 캐시는 DB보다 우선순위가 높으며, @Modifying 쿼리 후에는 반드시 캐시 클리어 + 재조회 순서를 지켜야 합니다.

4.2 Spring Data Elasticsearch ↔ AWS OpenSearch 의존성 충돌

문제: spring-boot-starter-data-elasticsearch의 ES 8 Java Client가 Content-Type: application/vnd.elasticsearch+json 헤더를 강제하여 OpenSearch에서 406 Not Acceptable 응답이 발생했습니다.

시행착오:

  1. OpenSearch 호환 모드 설정 → 실패
  2. Spring Data OpenSearch 스타터로 전환 → 코드는 바뀌었지만 런타임에 여전히 ES 8 클라이언트가 주입
  3. nori_tokenizer 플러그인 추가 → 별개의 문제였으나 근본 원인은 아님

해결: spring-boot-starter-data-elasticsearch가 클래스패스에 남아있어 @ConditionalOnClass로 ES 8 자동설정이 활성화되는 것이 근본 원인이었습니다. @SpringBootApplication(exclude = {...}) 으로 ES 관련 자동설정 4개를 명시적으로 제외하여 해결했습니다.

교훈: Spring Boot 자동설정은 클래스 존재 여부로 활성화되므로, 라이브러리 교체 시 기존 의존성이 클래스패스에서 완전히 제거되었는지 반드시 확인해야 합니다.

4.3 시청 세션 Redis 원자성 문제

문제: SADD(세션 추가) 후 SCARD(크기 조회)를 별도 명령으로 실행하면, 그 사이에 다른 요청이 끼어들어 잘못된 크기를 읽을 수 있었습니다. 이로 인해 멀티탭 참조 카운팅의 JOIN/LEAVE 이벤트가 잘못 발행될 수 있는 경합 조건이 존재했습니다.

해결: Lua 스크립트(SADD + SCARD / SREM + SCARD)로 두 연산을 하나의 원자적 연산으로 묶어 실행했습니다. Redis는 Lua 스크립트를 싱글 스레드로 원자적 실행하므로 경합 조건이 발생하지 않습니다.

4.4 STOMP 인터셉터 순환 의존성

문제: StompWatchingInterceptor에서 직접 SimpMessagingTemplate.convertAndSend()로 broadcast하면, 인터셉터가 WebSocketConfig에 의존하고 SimpMessagingTemplate도 같은 채널 인프라에 의존하여 빈 순환 의존성이 발생했습니다.

해결: ApplicationEventPublisherWatchingSessionEvent를 발행하고, 별도 WatchingSessionEventListener가 수신하여 broadcast하는 구조로 인터셉터와 브로커 전송을 디커플링했습니다.

4.5 AWS 배포 트러블 기록

배포 과정에서 대략 12건의 문제를 해결했으며, 주요 사례는 다음과 같습니다:

문제 원인 해결
Docker 이미지 아키텍처 불일치 Mac(ARM64)에서 빌드한 이미지를 x86_64 EC2에서 실행 --platform linux/amd64 옵션 추가
t3.micro 메모리 부족 app 태스크(768MB)가 t3.micro(1GB)에 부적합 t3.small(2GB)로 변경
PostgreSQL ENUM 타입 불일치 schema.sql ENUM vs JPA VARCHAR 타입 충돌 ENUM 컬럼을 VARCHAR로 통일
nginx 태스크 잘못된 EC2 배치 ECS가 SSL 인증서가 없는 app EC2에 nginx 배치 배치 제약 조건(attribute:role == nginx) 추가
CD 포트 충돌 minimumHealthyPercent: 100으로 기존+신규 태스크 동시 실행 minimumHealthyPercent: 0으로 변경
ElastiCache TLS 연결 실패 Redisson SSL 설정 누락 Java Config로 SSL 직접 설정

5. 협업 및 피드백

팀 협업 방식

  • 브랜치 전략: feature → dev (PR) → release (머지) → GitHub Actions 자동 배포 흐름을 사용했습니다. main, dev, release 브랜치에 삭제/force push 금지 규칙을 적용했습니다.
  • 코드 리뷰: CodeRabbit을 활용한 자동 리뷰와 팀원 간 PR 리뷰를 병행했습니다.
  • QA: 배포 후 Postman 기반 시나리오 테스트를 수행하고, 발견된 버그를 문서화하여 우선순위별로 수정했습니다.

배운 점

  • 설계 결정의 근거를 남기는 습관: 시청 세션을 인메모리로 시작한 뒤 Redis로 전환하는 과정에서, "왜 DB가 아닌 Redis인지", "왜 Lua 스크립트가 필요한지" 등 기술 선택의 이유를 문서화하는 것이 후속 의사결정에 큰 도움이 되었습니다.
  • 부하 테스트의 접근법: 처음에는 단일 API 호출만으로 충분하다고 생각했으나, 유저 플로우 기반 테스트가 커넥션 풀 점유, API 간 간섭, 동시성 이슈 등 단일 테스트로는 발견할 수 없는 문제를 찾아낼 수 있음을 배웠습니다.
  • 배포 삽질의 가치: 12건의 배포 문제를 하나씩 해결하면서 VPC, 서브넷, 보안 그룹, ECS Task Definition 등 AWS 인프라의 전체적인 그림을 체득할 수 있었습니다.

아쉬운 점

  • 부하 테스트를 로컬 환경에서 밖에 실행을 못했습니다.

6. 코드 품질 및 최적화

가독성과 유지보수성

  • 계층 분리: Controller → Service → Repository 구조를 일관되게 유지하고, 비즈니스 로직이 Tasklet에 과도하게 있던 구조를 CollectService로 이관하여 책임을 분리했습니다.
  • MapStruct 활용: 엔티티-DTO 변환을 MapStruct로 자동화하되, watcherCount 같은 실시간 데이터는 매개변수로 주입하여 영속 데이터 + 휘발성 데이터를 응답에서 합성하는 패턴을 적용했습니다.
  • 커스텀 예외 체계: 도메인별 예외 코드(ContentErrorCode, ReviewErrorCode 등)를 정의하여 에러 응답을 일관되게 관리했습니다.

성능 최적화

최적화 항목 기법 효과
콘텐츠 목록 조회 Partial 복합 인덱스 p95 -83%, Postgres CPU -82%
콘텐츠 목록 캐싱 Redis Cache-Aside DB 부하 감소, 반복 조회 응답 속도 개선
리뷰 삭제 벌크 UPDATE (N+1 방지) 단건 삭제 대비 쿼리 수 대폭 감소
콘텐츠 태그 수정 전체 삭제 후 재생성 → 부분 업데이트 불필요한 DELETE/INSERT 제거
평점 재계산 Redisson 분산 락 동시성 환경에서 데이터 정합성 보장
시청 세션 Redis Lua 스크립트 원자적 연산 경합 조건 제거, 멀티탭 안전 처리
배치 실행 Redis 분산 락 멀티 서버 환경에서 배치 중복 실행 방지

테스트

  • 단위 테스트: Service, Controller(슬라이스), Repository, Entity 비즈니스 메서드, STOMP 인터셉터 등
  • 통합 테스트: Testcontainers 기반 콘텐츠·리뷰 도메인 통합 테스트, 배치 Job 통합 테스트
  • 부하 테스트: k6 기반 단일 API 9개 테스트

7. 향후 개선 사항 및 제안

단기 개선

  • 아웃박스 패턴 도입: Redis가 다운되었을 때 시청 세션 이벤트가 유실될 수 있으므로, DB에 이벤트를 기록하고 나중에 실행하는 아웃박스 패턴 적용이 필요합니다.
  • 커넥션 풀 튜닝: 부하 테스트에서 playlist-detail API의 pending이 28건까지 발생했으므로, HikariCP 풀 크기를 20~30으로 확대하고 쿼리 최적화를 병행할 필요가 있습니다.
  • 배포 환경 부하 테스트: 로컬에서 Acceptance Test를 통과했지만, 실제 AWS 환경에서의 네트워크 지연, TLS 오버헤드, 오토스케일링 동작 등을 검증하는 테스트가 추가로 필요합니다.

중장기 개선

  • 집계 테이블 분리: 현재 콘텐츠 테이블에 reviewCount, averageRating 등 집계 컬럼이 함께 있는데, 트래픽이 늘어나면 별도 집계 테이블로 분리하거나 Kafka 이벤트 파이프라인으로 비동기 집계하는 구조가 필요합니다.
  • WebSocket Sticky Session: 멀티 서버 환경에서 Redis Pub/Sub으로 메시지를 브로드캐스트하고 있지만, 로드밸런서 레벨에서 WebSocket Sticky Session을 적용하면 불필요한 Pub/Sub 트래픽을 줄일 수 있습니다.
  • Blue/Green 배포: 현재 minimumHealthyPercent: 0으로 다운타임이 발생할 수 있는 배포 방식을 사용하고 있어, 무중단 배포를 위한 Blue/Green 또는 Rolling 배포 전략 도입이 필요합니다.