1. 프로젝트 개요
모두의 플리(ModuPly)는 대규모 트래픽을 고려한 글로벌 콘텐츠 큐레이션 소셜 플랫폼이다. 영화, 드라마, 스포츠 등 다양한 콘텐츠를 기반으로 플레이리스트를 생성하고 공유하며, 실시간 같이 보기 기능을 통해 다른 사용자와 함께 콘텐츠를 즐길 수 있다. 핵심 기능으로는 플레이리스트 큐레이션, 리뷰 및 평점, 실시간 알림(SSE), 팔로우 기반 소셜 기능, DM이 있다.
2. 담당한 작업
플레이리스트, 리뷰, 알림, SSE 도메인 전체와 ELK 스택(Logstash, Kibana) 구성을 담당했다.
플레이리스트
- CRUD, 콘텐츠 추가/삭제, 구독/구독취소 구현
- 구독자 수(subscribeCount) 기준 정렬 추가
- Caffeine 캐싱 적용(PLAYLIST_DETAIL) 및 수정/삭제/구독 시 캐시 무효화
- N+1 쿼리 개선: 소유자/구독자/콘텐츠 정보를 일괄 조회로 최적화
리뷰
- 생성/수정/삭제/목록 조회 구현
- rating:createdAt 복합 커서 기반 페이지네이션 구현
- 작성자 프로필 이미지 URL 생성(BinaryContentService.generateUrl) 연동
알림
- 플레이리스트 구독·콘텐츠 추가·팔로우·DM 수신·권한 변경 알림 발송
- 커서 기반 알림 목록 조회(읽지 않은 알림만 반환), 읽음 처리 구현
- NotificationType enum에 title/messageTemplate 필드 추가로 하드코딩 제거
SSE
- 실시간 알림 전송을 위한 SSE 연결 관리(SseEmitterManager)
- Redis Pub/Sub 기반 다중 서버 브로드캐스트 구현(SseRedisPublisher, SseRedisSubscriber)
- createdAt:id 복합 커서 기반 Last-Event-ID 유실 알림 재전송 구현
ELK
- Logstash 파이프라인 구성(grok 파싱, MDC 필드 추출)
- Filebeat 로그 수집 설정
- Kibana 대시보드 구성(레벨별 로그 건수, 시간대별 추이, HTTP 메서드별 분포)
3. 기술적 성과
기술 스택
- Java 17, Spring Boot, Spring Security, Spring Batch, Spring Data JPA, QueryDSL, PostgreSQL, Redis
- WebSocket(STOMP), SSE, Redis Pub/Sub
- AWS(ECS, ECR), Docker Compose, GitHub Actions, Codecov, CodeRabbit
- TMDB, The Sports DB
- ELK Stack(Elasticsearch, Logstash, Kibana), Filebeat
- k6
주요 구현
- 복합 커서 페이지네이션: 리뷰 목록 조회에서 rating:createdAt 복합 커서를 설계하여 rating 정렬 시에도 정확한 페이지네이션 구현
- Redis Pub/Sub SSE: 다중 서버 환경에서 SSE 이벤트가 모든 인스턴스에 전달되도록 Redis Pub/Sub 기반 브로드캐스트 구현
- Last-Event-ID 재전송: SSE 재연결 시 createdAt:id 복합 커서 기반으로 유실된 알림을 DB에서 조회하여 재전송
- N+1 쿼리 개선: 플레이리스트 목록 조회에서 findAllById, countByPlaylistIds 등 일괄 조회로 최적화
- ELK 로그 모니터링: Logstash grok 파싱으로 MDC 필드를 구조화된 필드로 추출하고 Kibana 대시보드로 시각화
4. 문제점 및 해결 과정
4-1. Redis Pub/Sub 메시지 역직렬화 타입 불일치로 인한 SSE Last-Event-ID 설정 실패
✭ Situation
다중 서버 환경에서 SSE 알림 전달을 위해 Redis Pub/Sub을 도입했다. Redis에서 메시지를 수신한 후 SSE로 전달할 때 Last-Event-ID 기반 유실 알림 재전송이 동작하지 않는 문제가 발생했다.
✭ Task
SSE 이벤트에 id가 설정되지 않아 클라이언트 재연결 시 Last-Event-ID 헤더가 전달되지 않고, 결과적으로 유실 알림 재전송이 불가능한 상황을 해결해야 했다.
✭ Action
원인을 분석한 결과, ObjectMapper.readValue(..., Object.class)가 JSON 객체를 LinkedHashMap으로 역직렬화하여 instanceof NotificationDto 체크가 항상 false를 반환하는 것을 발견했다. Object.class 대신 NotificationDto.class로 명시적 타입을 지정하여 해결했다.
// 수정 전
Object data = objectMapper.readValue(message.getBody(), Object.class);
// 수정 후
NotificationDto data = objectMapper.readValue(message.getBody(), NotificationDto.class);
✭ Result
SSE 이벤트에 createdAt:id 형식의 id가 정상적으로 설정되어 클라이언트 재연결 시 유실 알림 재전송이 동작하게 되었다.
4-2. SSE 통합 테스트로 인한 다른 테스트 컨텍스트 오염
✭ Situation
SSE 연결/전송 검증을 위한 통합 테스트를 작성했는데, 해당 테스트 이후 WatchingSessionApiIntegrationTest, UserRepositoryTest 등 다른 테스트가 DataIntegrityViolationException으로 실패하는 문제가 발생했다.
✭ Task
SSE 통합 테스트가 다른 테스트에 영향을 주지 않도록 해결해야 했다.
✭ Action
원인 분석 결과, SSE 테스트에서 mockMvc.perform(get("/api/sse"))를 호출하면 비동기 연결이 열린 채로 대기 상태가 되고, 테스트 종료 후에도 SseEmitter가 남아있어 DB 연결 풀을 선점하는 것을 발견했다. removeAllEmitters() 메서드 추가, @DirtiesContext 적용 등을 시도했으나 해결되지 않아 SSE 통합 테스트를 제거하고 단위 테스트로 핵심 로직을 커버하는 방향으로 결정했다.
✭ Result
SseEmitterManagerTest에서 연결, 재연결, Heartbeat, 타임아웃 등 핵심 로직을 단위 테스트로 커버하여 CI 테스트가 안정적으로 통과하게 되었다.
4-3. SseEmitterManager 순환 의존성 문제
✭ Situation
Last-Event-ID 유실 알림 재전송 구현을 위해 SseEmitterManager에 NotificationService를 주입하려 했으나 순환 의존성 문제가 발생했다.
✭ Task
순환 의존성을 해결하면서 유실 알림 재전송 로직을 구현해야 했다.
✭ Action
@Lazy 어노테이션으로 임시 해결할 수 있었으나, 근본적인 해결을 위해 SseMissedNotificationSender라는 별도 클래스로 재전송 로직을 분리했다.
✭ Result
단일 책임 원칙을 지키면서 순환 의존성 문제를 해결했고, 각 클래스의 역할이 명확해졌다.
5. 협업 및 피드백
5인 팀 프로젝트를 진행하며 각자 도메인을 나누어 개발했다. 알림 이벤트 발행 시점과 방식을 각 도메인 담당자와 협의하여 충돌 없이 구현했고, 특히 팔로우, DM 도메인 담당자와 이벤트 인터페이스를 맞추는 과정에서 사전 소통의 중요성을 느꼈다.
코드래빗을 통해 자동 코드 리뷰를 적극 반영했다. 동시성 버그, N+1 쿼리, 예외 처리 누락 등 미처 발견하지 못한 문제들을 발견하는 데 도움이 되었으며, 자동화된 코드 리뷰 툴이 실제 개발 품질 향상에 기여한다는 것을 경험했다.
팀원의 코드 리뷰를 통해 findAllByPlaylist를 프로젝션 쿼리(findSubscriberIdsByPlaylistId)로 개선하는 등 성능 최적화 아이디어를 얻었다. 단순히 오류를 찾는 것을 넘어 왜 이렇게 설계했는지를 함께 고민하는 시간이었다.
6. 코드 품질 및 최적화
- 테스트: 서비스/컨트롤러 단위 테스트, QueryDSL 레포지토리 통합 테스트를 작성했다. SseMissedNotificationSender, SseRedisSubscriber 등 새로 추가된 클래스에 대한 테스트도 함께 작성하여 회귀를 방지했다.
- 캐싱 전략: 플레이리스트 단건 조회에 PLAYLIST_DETAIL 캐시를 적용했다. 캐시 키를 playlistId:currentUserId로 설계하여 사용자별 구독 상태가 다르게 캐싱되도록 했고, 수정/삭제/구독 시 allEntries = true로 무효화했다.
- 방어 코드: Logstash grok 파싱 실패 시 message 필드가 삭제되는 문제를 _grokparsefailure 태그 체크로 방어하여 원본 로그 유실을 방지했다.
- 단일 책임 원칙: SseEmitterManager에서 알림 재전송 로직을 SseMissedNotificationSender로 분리하고, 알림 이벤트 발행을 SseRedisPublisher로 분리하여 각 클래스의 역할을 명확히 했다.
7. 향후 개선 사항 및 제안
- SSE 구독자 수 커서 페이지네이션: 현재 subscribeCount 정렬은 첫 페이지만 지원하며, 복합 커서 구현이 필요하다.
- Redis Pub/Sub → Kafka 전환: 현재 Redis Pub/Sub은 메시지 유실 가능성이 있어 Kafka 도입 시 SSE 알림 전달의 신뢰성을 높일 수 있다.
- ELK Kibana 알림 설정: ERROR 로그 급증 시 Slack 알림 연동으로 운영 모니터링을 강화할 수 있다.
- 캐시 무효화 전략 개선: 현재 allEntries = true로 전체 캐시를 비우고 있어 불필요한 캐시 미스가 발생할 수 있다. 향후에는 변경된 데이터와 관련된 캐시만 선택적으로 무효화하는 전략을 적용하면 더욱 효율적인 캐싱이 가능할 것이다.
- 프로필 이미지 캐싱: presigned URL 만료(10분)마다 재생성하는 비용을 줄이기 위해 URL 캐싱 전략 도입을 검토할 수 있다.
8. 링크
'Project > Project' 카테고리의 다른 글
| [모뉴] 개인 개발 리포트 (2) | 2026.05.08 |
|---|---|
| [Findex] 개인 개발 리포트 (2) | 2026.03.20 |