[모두의 플리] 개인 개발 리포트
[모두의 플리] 개인 개발 리포트
1. 프로젝트 개요
모두의 플리(MOPL)는 영화, TV 시리즈, 스포츠 콘텐츠를 탐색하고 평가하며, 플레이리스트와 같이 보기 기능을 통해 다른 사용자와 경험을 공유할 수 있는 콘텐츠 플랫폼이다.
이번 프로젝트는 단순한 CRUD 구현을 넘어 다음과 같은 기능과 운영 환경을 포함한 고급 백엔드 팀 프로젝트였다.
- 영화·TV·스포츠 콘텐츠 관리
- 리뷰와 평점
- 플레이리스트
- 콘텐츠 같이 보기와 실시간 채팅
- 사용자·팔로우·메시지·알림
- 외부 콘텐츠 API 연동
- Spring Batch 기반 자동 수집
- Redis 캐시
- OpenSearch 검색
- Kafka 기반 이벤트 처리
- AWS 기반 배포와 운영 모니터링
프로젝트의 부제는 대규모 트래픽이 예상되는 글로벌 콘텐츠 평점 및 큐레이션 플랫폼이었다. 따라서 기능이 동작하는 것뿐 아니라 데이터 증가, 외부 시스템 장애, 캐시와 검색 색인의 정합성, 배치 운영과 관측성까지 고려해야 했다.
2. 담당 범위와 기여
이번 프로젝트에서 주로 담당한 영역은 콘텐츠 데이터 관리와 콘텐츠 수집을 위한 Spring Batch 연계였다.
프로젝트가 진행되면서 콘텐츠 조회 성능과 운영 안정성을 보완하기 위해 Redis, OpenSearch, S3, 로그·메트릭 작업에도 참여했다. 다만 이 기술들의 공통 인프라 전체를 담당한 것은 아니며, 팀에서 구성한 공통 구조를 콘텐츠 도메인에 적용하고 필요한 기능을 구현하는 역할을 맡았다.
기여 범위
| 영역 | 기여 형태 | 주요 작업 |
|---|---|---|
| 콘텐츠 도메인·API | 주도 구현 | 엔티티, 타입·출처, CRUD, 조회·정렬·커서 |
| 콘텐츠 썸네일 | 주도 구현 | 파일 검증, 로컬/S3 저장, 보상 처리 |
| 외부 콘텐츠 API | 주도 구현 | TMDB·TheSportsDB 수집과 매핑 |
| 콘텐츠 성능 검증 | 주도 수행 | 10만 건 데이터 생성, 측정, 쿼리·인덱스 개선 |
| Spring Batch | 콘텐츠 작업 구현 | 공통 실행 구조에 외부 수집·보정·정리 Job 연계 |
| Redis | 콘텐츠 캐시 적용 | Cache-Aside, 캐시 분리, 벌크 조회와 fallback |
| OpenSearch | 콘텐츠 검색 연계 | 검색 문서, 증분 색인, 초성 검색과 DB fallback |
| 삭제 정책 | 콘텐츠 기준 설계·연계 | 소프트 삭제, 플레이리스트 연결 제거, 썸네일 정리 |
| 코드 리뷰·문서화 | 팀 공동 | PR 검토, 태스크 기록, 발표 자료와 기술 문서 작성 |
담당 기능 요약
▶ 콘텐츠 파트 구현 범위 전체 보기
콘텐츠 관리
- 콘텐츠 엔티티와 타입·출처 설계
- 관리자 콘텐츠 등록·수정·삭제
- 콘텐츠 단건 및 목록 조회
- 제목·설명·태그 검색
- 유형 필터
- 최신순·평점순·인기순 정렬
- 커서 페이지네이션
외부 콘텐츠
- TMDB 인기 영화·TV 시리즈 수집
- TheSportsDB 스포츠 경기 수집
- 한글 콘텐츠 선별
- 외부 콘텐츠 중복 방지
- 외부 API timeout·retry 정책
- 청크 단위 부분 성공 처리
저장과 조회 고도화
- 콘텐츠 썸네일 로컬/S3 저장
- Redis Cache-Aside
- OpenSearch 검색 및 초성 검색
- 이벤트와 배치를 이용한 인기순 동기화
- 소프트 삭제와 썸네일 보존 기간 정책
검증과 운영
- 10만 건 콘텐츠 조회 성능 측정
- PostgreSQL 검색 인덱스와 커서 조건 개선
- 콘텐츠 단위·통합 테스트
- 배치 로그와 메트릭
- 실제 배포 환경 기능 탐색 테스트
3. 콘텐츠 처리 구조
콘텐츠 파트의 전체 흐름은 다음과 같다.
TMDB / TheSportsDB
↓
Spring Batch 외부 콘텐츠 수집
↓
PostgreSQL ─────→ OpenSearch
원본 데이터 검색·초성 검색·정렬
↓
Redis Cache-Aside
반복 조회 가속
↓
Content API
구조를 설계할 때 가장 중요하게 둔 원칙은 PostgreSQL을 유일한 원본 데이터로 유지하는 것이었다.
Redis와 OpenSearch는 조회 성능을 높이기 위한 복사본이다. 두 시스템에 장애가 발생해도 DB 조회로 API 요청을 처리할 수 있도록 fallback을 구성했다.
이 구조 덕분에 검색과 캐시 성능을 활용하면서도, 보조 저장소의 장애가 콘텐츠 API 전체 장애로 이어지는 것을 막을 수 있었다.
4. 구현한 주요 기능
콘텐츠 도메인과 관리자 API
콘텐츠는 영화, TV 시리즈, 스포츠 세 가지 유형을 지원한다.
출처는 다음과 같이 구분했다.
MANUAL
TMDB
THE_SPORTS_DB
관리자가 직접 등록한 콘텐츠와 외부 API에서 수집한 콘텐츠를 하나의 Content 도메인으로 관리하되, 출처와 외부 ID를 통해 생성 경로를 구분했다.
태그는 독립적으로 수정하거나 상태를 관리하는 도메인이 아니라 콘텐츠에 종속된 문자열 값이었다. 따라서 별도의 ID를 가진 Tag 엔티티 대신 @ElementCollection과 content_tags 컬렉션 테이블을 사용했다.
관리자 CRUD
- 등록 시 콘텐츠 정보와 썸네일을 함께 저장
- 수정 시 제목, 설명, 태그와 썸네일 변경
- 새 썸네일이 없으면 기존 썸네일 유지
- 삭제 시 즉시 물리 삭제하지 않고
deletedAt기록 - 삭제 콘텐츠는 일반 조회, 캐시, 검색 결과에서 즉시 제외
커서 페이지네이션
목록 조회에는 Offset 대신 정렬값과 UUID를 함께 사용하는 커서 방식이 적용되었다.
createdAt + id
averageRating + id
watcherCount + reviewCount + averageRating + id
UUID를 마지막 정렬 조건으로 사용하여 앞의 정렬값이 모두 같은 콘텐츠도 순서가 매 요청마다 달라지지 않게 했다.
외부 콘텐츠 API 연동
외부 콘텐츠는 TMDB와 TheSportsDB에서 수집한다.
TMDB
- 인기 영화
- 인기 TV 시리즈
- 기본 언어
ko-KR - 기본 지역
KR - 한글 제목 또는 설명이 있는 콘텐츠만 선별
TheSportsDB
- 설정한 리그의 예정 경기
- 경기 대표 이미지와 팀 배지를 우선순위에 따라 썸네일로 사용
외부 콘텐츠 식별
외부 콘텐츠의 중복 기준은 단순한 externalId가 아니다.
source + type + externalId
서로 다른 외부 서비스에서 같은 ID가 존재할 수 있고, TMDB 영화와 TV 시리즈도 동일한 숫자 ID를 사용할 수 있기 때문이다.
기존 콘텐츠 갱신 정책
신규 콘텐츠는 생성하고, 이미 존재하는 콘텐츠는 lastSyncedAt만 갱신한다.
외부 API 값이 변경될 때마다 제목과 썸네일을 자동으로 덮어쓰면 운영자가 관리한 값도 예상하지 못하게 변경될 수 있다. 현재 범위에서는 수집 여부만 기록하고 화면 필드는 유지하도록 정했다.
Spring Batch와 콘텐츠 작업 연계
Spring Batch 공통 실행 구조 전체를 구축한 것은 아니지만, 콘텐츠 도메인에 필요한 Job과 Tasklet을 구현하고 운영 정보를 보강했다.
| 작업 | 기본 주기 | 역할 |
|---|---|---|
| 외부 콘텐츠 동기화 | 매일 새벽 3시 | TMDB·TheSportsDB 수집 |
| 인기 지표 보정 | 매시간 정각 | OpenSearch watcherCount 보정 |
| 삭제 썸네일 정리 | 매일 새벽 4시 30분 | 삭제 후 90일이 지난 파일 정리 |
외부 수집의 부분 성공 정책
외부 콘텐츠는 100건 단위 청크로 저장한다.
청크 1 성공 → COMMIT
청크 2 성공 → COMMIT
청크 3 실패 → 청크 3만 ROLLBACK
하나의 항목이나 후반 청크가 실패했을 때 이미 성공한 콘텐츠까지 모두 되돌리는 것보다, 성공 결과를 유지하고 다음 실행에서 보완하는 방식이 외부 수집 작업에 적합하다고 판단했다.
배치 결과는 fetched, accepted, filtered, created, skipped, failed로 나누어 로그와 메트릭에 기록했다.
Redis 콘텐츠 캐시 적용
Redis 공통 인프라 전체가 아니라 콘텐츠 조회에 Cache-Aside 패턴을 적용하는 부분을 구현했다.
Redis 조회
├─ HIT → 캐시 사용
└─ MISS 또는 장애 → DB 조회 → 캐시 저장
API 응답 DTO를 그대로 캐싱하지 않고 변경 빈도에 따라 데이터를 분리했다.
ContentBaseCache
- 제목
- 설명
- 유형
- 출처
- 썸네일
- 태그
기본 TTL은 20분이다.
ContentStatsCache
- 평균 평점
- 리뷰 수
기본 TTL은 3분이다.
watcherCount는 WatchingSession 입장과 퇴장에 따라 자주 변경되므로 콘텐츠 캐시에 포함하지 않고 별도로 조회해 응답을 조립했다.
다건 조회에서는 Redis MGET과 pipeline 저장을 사용하여 콘텐츠 수만큼 Redis 요청을 반복하지 않도록 했다.
Redis 장애 시에는 캐시를 MISS로 처리하고 DB에서 조회한다. 캐시 저장이나 무효화 실패로 API 응답까지 실패시키지 않았다.
OpenSearch 콘텐츠 검색 연계
OpenSearch 공통 인프라나 AWS 도메인 구축보다 콘텐츠 검색 문서와 조회·동기화 기능을 연계하는 역할을 담당했다.
검색 문서에는 다음 값이 포함된다.
id
title
description
initials
type
tags
createdAt
averageRating
reviewCount
watcherCount
OpenSearch는 다음 기능을 담당한다.
- 제목·설명 검색
- 초성 검색
- 유형·태그 필터
- 최신순·평점순·인기순 정렬
search_after기반 커서 조회
검색 결과에서는 콘텐츠 ID 순서만 사용하고, 실제 응답 데이터는 Redis 또는 DB에서 다시 조회한다. OpenSearch 색인은 DB의 복사본이므로 이를 API 응답의 최종 원본으로 사용하지 않기 위해서다.
OpenSearch 조회가 실패하면 PostgreSQL 검색으로 fallback한다.
인기순 동기화
인기순 기준은 다음과 같이 정했다.
1순위 watcherCount
2순위 reviewCount
3순위 averageRating
4순위 contentId
WatchingSession 입장·퇴장 이벤트가 발생하면 OpenSearch의 watcherCount 필드만 부분 갱신한다. 이벤트 누락이나 일시적 실패는 매시간 배치가 DB 값을 기준으로 보정한다.
이벤트의 빠른 반영과 배치의 복구 능력을 함께 사용하는 방식이다.
5. 주요 기술적 의사결정
DB 트랜잭션과 썸네일 파일 작업 분리
관리자 콘텐츠 등록·수정은 DB와 파일 저장소를 함께 사용한다.
처음에는 서비스 메서드에 @Transactional을 적용하면 전체 작업이 하나처럼 롤백될 것이라고 생각하기 쉽다. 하지만 DB 트랜잭션은 S3나 로컬 파일을 롤백하지 못한다.
썸네일 업로드 성공
→ DB 저장 실패
→ DB만 롤백
→ 저장소에 파일 잔존
이를 해결하기 위해 다음 순서를 선택했다.
1. 새 파일 업로드
2. TransactionTemplate로 DB 작업 실행
3. DB 실패 시 새 파일 보상 삭제
4. 수정 성공 시 기존 파일 삭제
DB를 먼저 저장하고 이후 파일을 업로드하는 방법과 임시 저장소·Outbox 방식도 검토했다.
- DB 우선 방식은 업로드 실패 시 DB가 존재하지 않는 파일을 가리킬 수 있다.
- 임시 저장소와 Outbox는 장애 복구에 강하지만 현재 범위보다 복잡도가 크다.
현재 프로젝트에서는 파일을 먼저 저장하고 실패 시 보상 삭제하는 방식을 선택했다.
DB, Redis, OpenSearch의 책임 분리
세 저장소에 같은 콘텐츠 관련 데이터가 존재하면 정합성이 복잡해질 수 있다.
따라서 책임을 다음처럼 구분했다.
| 저장소 | 책임 |
|---|---|
| PostgreSQL | 원본 데이터와 정합성 |
| Redis | 반복 조회 캐시 |
| OpenSearch | 검색과 검색 결과 정렬 |
Redis와 OpenSearch를 DB와 동시에 반드시 성공해야 하는 구조로 만들면 보조 저장소의 장애가 콘텐츠 생성·수정까지 막을 수 있다.
그래서 DB 변경을 우선 확정하고, 캐시 무효화와 검색 색인은 이후 처리했다. 보조 저장소 장애는 로그와 메트릭으로 남기고 DB fallback과 TTL, 이벤트·배치 보정으로 복구하도록 했다.
콘텐츠 삭제 정책
콘텐츠를 삭제할 때 연관 리뷰까지 모두 물리 삭제하면 사용자가 작성한 기록과 운영 데이터를 잃게 된다.
최종 정책은 다음과 같다.
- 콘텐츠는 소프트 삭제
- 일반 사용자 조회에서 즉시 제외
- 플레이리스트 연결 데이터는 물리 삭제
- 리뷰 기록은 보존
- Redis와 OpenSearch에서는 즉시 제거
- 삭제 후 90일이 지나면 썸네일 파일과 메타데이터 정리
- 콘텐츠 레코드 자체는 자동 물리 삭제하지 않음
화면 노출을 차단하면서도 기록을 유지하고, 저장 비용이 발생하는 파일만 나중에 정리하는 구조다.
6. 트러블 슈팅
10만 건 콘텐츠 검색 성능 병목
기능 테스트가 통과해도 데이터가 수십 건뿐이면 실제 조회 병목을 확인하기 어렵다.
콘텐츠 검색에는 제목·설명 부분 검색, 유형과 태그 필터, 정렬, 커서 페이지네이션과 전체 개수 조회가 함께 사용된다. 데이터 증가 상황을 확인하기 위해 공용 DB가 아닌 Docker 전용 환경에 대량 데이터를 생성했다.
| 데이터 | 건수 |
|---|---|
| 콘텐츠 | 100,000건 |
| 태그 | 300,000건 |
| 리뷰 | 300,000건 |
| WatchingSession | 20,000건 |
API는 시나리오별 100회, 동시 작업자 10명 조건으로 p95를 비교했다.
발견한 병목
- 제목·설명 부분 일치 검색
- 1~2글자 검색
- 태그가 결합된 검색
- 생성일·평점의 뒤쪽 커서 조회
적용한 개선
- 3글자 이상 검색에 PostgreSQL
pg_trgmGIN 인덱스 적용 - 1~2글자 검색은 정확도를 유지하는
LOCATE계열 경로 사용 - 커서 조건을 복합 인덱스 순서와 같은 행 값 비교로 변경
- 동일한 데이터와 요청으로 전후 재측정
결과
| 시나리오 | 개선 전 | 개선 후 | 변화 |
|---|---|---|---|
| 3글자 검색 p95 | 468.8ms | 27.7ms | 94.1% 감소 |
| 2글자+태그 p95 | 618.2ms | 97.5ms | 84.2% 감소 |
| 생성일 깊은 커서 DB 실행 | 21.467ms | 0.074ms | 약 99.7% 감소 |
처음부터 예상한 최적화를 모두 적용하지 않고, 기준선을 측정한 뒤 병목을 하나씩 개선하고 효과가 확인된 변경만 채택했다.
▶ 검토했지만 적용하지 않은 대안
시청자순 전용 네이티브 쿼리
DB 실행 시간은 줄었지만 필터·정렬·커서를 네이티브 SQL로 중복 구현해야 했다. 최종 API 응답이 목표 범위에 들어왔기 때문에 유지보수 비용을 고려해 제외했다.
검색 조건 결과 캐시
콘텐츠, 리뷰, WatchingSession 변경 시 광범위한 무효화가 필요했다. 정합성 복잡도에 비해 현재 단계의 효과가 제한적이라고 판단했다.
1~2글자 전용 검색 엔진
pg_bigm이나 ngram 검색을 사용할 수 있지만 배포와 운영 복잡도가 증가한다. 당시 범위에서는 정확도를 유지하는 DB 경로를 선택했다.
배포 환경에서 외부 수집이 실행되지 않은 문제
로컬에서는 배치 코드와 테스트가 정상 동작했지만 운영 환경에서는 외부 콘텐츠가 들어오지 않는 문제가 발생했다.
CloudWatch 로그를 확인한 결과 애플리케이션 코드 자체보다 Spring Batch 메타 테이블 스키마가 현재 프레임워크가 기대하는 구조와 달랐다.
배치 실행 이력을 저장하는 과정에서 스키마 오류가 발생했기 때문에 실제 Tasklet까지 도달하지 못했다.
마이그레이션을 통해 메타 테이블을 현재 Spring Batch 스키마와 맞춘 뒤 다음 항목을 확인했다.
- 애플리케이션 정상 시작
- 배치 Job 실행
- 외부 API 호출
- 콘텐츠 생성·건너뜀·실패 로그
- DB 콘텐츠 증가
이 경험을 통해 배치는 비즈니스 로직뿐 아니라 메타 테이블과 운영 환경의 설정까지 함께 검증해야 한다는 것을 배웠다.
데이터 표현과 API 계약 불일치
콘텐츠 유형 enum의 대소문자와 인기순 요청 필드가 프론트엔드 계약과 맞지 않아 실제 화면에서 400 응답이 발생한 적이 있었다.
내부 구현에서 더 이해하기 쉬운 이름으로 바꾸더라도 이미 제공된 API 명세와 프론트엔드 요청값이 있다면 호환성을 먼저 지켜야 했다.
최종적으로 다음 원칙을 적용했다.
- API 외부 계약은 기존
watcherCount유지 - 내부에서는 watcherCount, reviewCount, averageRating을 조합한 인기순으로 해석
- enum은 API와 DB 표현이 일치하도록 converter와 마이그레이션으로 관리
- 빌드 테스트뿐 아니라 실제 화면의 네트워크 요청까지 확인
7. 테스트와 검증
이번 프로젝트에서는 단위 테스트 통과만으로 작업이 완료되었다고 판단하지 않으려고 했다.
코드 수준 검증
- 콘텐츠 엔티티와 mapper 단위 테스트
- 관리자 콘텐츠 CRUD 테스트
- 검색·필터·정렬·커서 Repository 테스트
- Redis 캐시 HIT·MISS·fallback 테스트
- 외부 API 정상·오류·재시도 테스트
- Spring Batch Job과 Tasklet 테스트
- 썸네일 로컬/S3 저장 테스트
- OpenSearch 검색·색인 동기화 테스트
통합 검증
- Docker PostgreSQL·Redis 기반 API 실행
- 10만 건 성능 데이터 조회
- Flyway 마이그레이션 적용
- 관리자 계정 콘텐츠 등록·수정·삭제
- 배포 환경 콘텐츠 검색과 정렬
- CloudWatch 배치 로그 확인
테스트에서 배운 점
컴파일과 단위 테스트가 통과해도 다음 문제는 남을 수 있었다.
- 프론트엔드 요청값과 API 계약 불일치
- 배포 환경 변수 누락
- 운영 DB의 이전 데이터 표현
- Spring Batch 메타 스키마 불일치
- OpenSearch 초기 색인 상태
따라서 후반에는 빌드, API, 화면, 운영 로그를 함께 확인하는 방식으로 검증 범위를 넓혔다.
8. 협업과 코드 리뷰
콘텐츠는 리뷰, WatchingSession, 플레이리스트와 연결되어 있어 콘텐츠 코드만 보고 모든 정책을 결정할 수 없었다.
타 도메인과 협의한 내용
- 리뷰 생성·수정·삭제 시 Content의 평균 평점과 리뷰 수 갱신
- 리뷰 통계 변경 후 Redis stats 캐시 무효화
- 리뷰 변경 후 OpenSearch 문서 갱신
- WatchingSession 입장·퇴장 후 watcherCount 반영
- 콘텐츠 삭제 시 플레이리스트 연결 데이터 제거
- 콘텐츠 삭제 후 리뷰 기록 보존
처음에는 연관 기능을 콘텐츠 서비스에서 직접 호출하는 방식도 고민했다. 하지만 도메인 간 직접 의존이 커질 수 있어 트랜잭션 이벤트로 연결하는 방향을 사용했다.
PR 리뷰를 통해 개선한 내용
- 썸네일 파일 형식과 5MB 용량 제한
- 파일 업로드와 DB 트랜잭션 경계
- 외부 API DTO 기반 파싱
- 외부 콘텐츠 청크 저장과 식별키
- 배치 중복 실행 방지
- ExecutionContext 저장 타입
- OpenSearch 색인 우선순위와 fallback
- Redis MGET과 반복 조회 방지
초기에는 모든 스타일과 개선 가능성을 자세히 검토하려다 개인 작업 속도가 느려지기도 했다. 이후에는 치명적 버그, API 명세 위반, 담당 파트 충돌과 되돌리기 어려운 설계를 우선 검토하고, 선택 가능한 스타일은 참고 사항으로 분리했다.
9. 코드 품질과 운영 안정성
서비스 책임 분리
콘텐츠 기능이 커지면서 하나의 서비스에 모든 책임을 넣지 않고 역할별로 분리했다.
ContentService
관리자 API와 조회 조립
ContentExternalSyncService
외부 콘텐츠 동기화
ContentThumbnailService
썸네일 검증과 저장
ContentCacheService
Redis 캐시 조립
ContentSearchIndexService
OpenSearch 색인
ContentThumbnailCleanupService
삭제 콘텐츠의 파일 정리
Mapper 사용
단순한 Entity와 DTO 변환은 Mapper에 분리하고, 여러 데이터 조합이나 계산이 필요한 경우 서비스에서 필요한 통계를 전달해 DTO를 완성했다.
로그와 메트릭
콘텐츠 로그는 다음 패턴을 기준으로 남겼다.
{Domain} {action} {status}. key=value
캐시 HIT·MISS처럼 빈도가 높은 값은 건별 INFO 로그가 아니라 메트릭으로 집계했다. 외부 API 실패, DB fallback, 대량 캐시 무효화와 배치 결과처럼 운영에서 확인해야 하는 이벤트를 로그로 남겼다.
10. 아쉬웠던 점과 개선 방향
초기 설계를 작업 단위까지 충분히 연결하지 못한 점
초기 정책과 태스크 이름이 완전히 일치하지 않아 구현 중 설계를 다시 확인하고 작업 범위를 수정하는 일이 있었다.
예를 들어 태그를 @ElementCollection으로 사용하기로 했지만 임시 태스크 이름만 보고 별도 Tag 엔티티를 만드는 방향으로 진행할 뻔했다.
다음 프로젝트에서는 다음 순서로 진행하고 싶다.
요구사항
→ 도메인 정책
→ 데이터 모델
→ API 계약
→ 태스크 완료 조건
→ 구현
운영 검증을 더 일찍 시작하지 못한 점
프로젝트 후반에 실제 배포 화면과 CloudWatch 로그를 확인하면서 로컬 테스트에서 발견하지 못한 문제가 여러 개 나왔다.
다음에는 핵심 기능이 병합될 때마다 다음 최소 검증을 반복하고 싶다.
- API 명세 요청
- 실제 화면 동작
- 운영 로그
- DB 결과
- 캐시·검색 장애 fallback
문서가 많아지면서 최신 기준을 찾기 어려웠던 점
요구사항, 팀 노션, 코드, 개인 작업 문서가 함께 변경되면서 과거 정책과 최신 구현을 구분하기 어려운 순간이 있었다.
코드를 최종 기준으로 두되, 중요한 정책 변경은 결정 문서와 태스크에 함께 반영하는 흐름을 더 자동화할 필요가 있었다.
11. 배운 점
기능 구현과 운영 가능한 구현은 다르다
외부 API를 호출하고 DB에 저장하는 것만으로 작업이 끝나지 않았다.
- timeout과 retry
- 중복 실행
- 부분 성공
- 실행 건수
- 장애 로그
- 재실행과 보정
이 요소들이 있어야 운영에서 상태를 확인하고 실패에 대응할 수 있었다.
여러 저장소의 정합성에는 우선순위가 필요하다
DB, Redis, OpenSearch, S3를 모두 완벽히 하나의 트랜잭션으로 묶는 것은 현실적이지 않았다.
무엇을 원본으로 두고 어떤 실패를 허용할지 정한 뒤, 보상 처리, 이벤트, 배치, TTL과 fallback을 조합해야 했다.
성능 최적화는 측정에서 시작해야 한다
처음부터 캐시나 인덱스를 추가하는 것보다 기준선을 측정하고 실제 병목을 확인하는 것이 중요했다.
10만 건 실험을 통해 효과가 없거나 운영 복잡도가 더 큰 방법은 적용하지 않는 것도 최적화의 일부라는 점을 배웠다.
질문과 공유가 작업 속도를 높였다
타 도메인 정책을 혼자 추측해서 구현하면 나중에 더 큰 수정이 필요했다.
리뷰, WatchingSession, 플레이리스트 담당자와 변경 시점과 책임을 먼저 맞추는 것이 결과적으로 더 빠르고 안전했다.
마무리
이번 프로젝트에서는 콘텐츠 CRUD를 시작으로 외부 API 수집, 배치, 파일 저장, 캐시, 검색, 성능과 운영 로그까지 하나의 기능이 서비스 환경에서 동작하기 위해 필요한 흐름을 경험했다.
특히 DB 트랜잭션과 S3 파일 작업의 경계, PostgreSQL과 Redis·OpenSearch의 책임 분리, 10만 건 콘텐츠 검색 병목 개선은 기능 구현을 넘어 운영과 성능 관점에서 코드를 바라보게 만든 경험이었다.
모든 공통 인프라를 직접 구축한 것은 아니지만, 팀의 공통 구조를 이해하고 콘텐츠 도메인에 맞게 연결하며 장애 상황과 정합성을 보완하는 과정을 맡았다.
초기 설계와 태스크 관리에서 시행착오가 있었지만, 요구사항과 실제 코드의 차이를 계속 확인하고 테스트 범위를 운영 환경까지 넓히면서 프로젝트를 마무리할 수 있었다.
다음 프로젝트에서는 설계 결정과 태스크 완료 조건을 더 일찍 연결하고, 핵심 기능이 완성되는 시점부터 실제 운영 환경 검증을 반복하여 후반의 수정 비용을 줄이고 싶다.