[모두의 플리] 외부 콘텐츠 동기화를 실패에 강하고 빠르게 리팩토링하기
[모두의 플리] 외부 콘텐츠 동기화를 실패에 강하고 빠르게 리팩토링하기
배경
초기 외부 콘텐츠 동기화 기능은 API에서 후보를 가져와 하나씩 조회하고 저장하는 구조였다.
기능은 동작했지만 실제 배치 작업으로 반복 실행하려면 몇 가지 위험이 있었다.
일시적인 외부 API 장애에도 바로 전체 Job이 실패함
후보 하나의 잘못된 값이 다른 정상 후보 처리까지 방해할 수 있음
후보마다 DB 조회와 트랜잭션이 반복됨
같은 응답 안에 중복 후보가 있으면 불필요한 처리가 발생함
Job이 겹쳐 실행될 가능성을 명확히 차단하지 못함
이번 리팩토링의 목표는 단순히 메서드를 정리하는 것이 아니라, 외부 시스템과 DB 양쪽의 실패를 구분하고 대량 처리 비용을 줄이는 것이었다.
외부 API 오류를 모두 똑같이 처리하면 안 된다
외부 API 호출은 네트워크 상황에 영향을 받는다.
잠깐 기다렸다가 다시 요청하면 성공할 수 있는 오류가 있는 반면, 요청을 반복해도 해결되지 않는 오류도 있다.
현재 정책은 다음과 같이 나눈다.
재시도 가능
- 네트워크 연결 실패
- timeout
- HTTP 429
- HTTP 5xx
즉시 실패
- 일반 HTTP 4xx
- 잘못된 설정이나 요청
재시도할 수 있는 오류만 ExternalApiRetryExecutor가 다시 실행한다.
지수 Backoff를 사용한 이유
실패 직후 같은 간격으로 계속 요청하면 장애가 난 외부 서버에 더 큰 부담을 줄 수 있다.
그래서 재시도 사이의 대기 시간을 점차 늘리는 지수 Backoff를 사용했다.
기본 설정은 다음과 같다.
최대 시도 횟수: 3회
최초 대기: 1초
증가 배수: 2배
최대 대기: 4초
실제 대기 시간은 다음처럼 증가한다.
첫 실패 후 1초
두 번째 실패 후 2초
최대 4초를 넘지 않음
재시도 중 스레드가 중단되면 interrupt 상태를 복구한 뒤 예외를 전달한다.
개별 항목 실패와 전체 API 실패 분리
외부 API 호출 자체가 실패하면 해당 클라이언트의 결과를 신뢰할 수 없으므로 Job을 실패시킨다.
하지만 응답 목록 중 한 항목의 필드가 잘못된 경우에는 나머지 정상 항목까지 버릴 필요가 없다.
그래서 실패 범위를 다음처럼 나눴다.
API 요청 전체 실패
-> Job 실패
개별 응답 매핑 실패
-> failedCount 증가 후 다음 항목 처리
개별 후보 검증 실패
-> failedCount 증가 후 다음 후보 처리
DB 청크 저장 실패
-> 현재 청크 롤백 후 Job 실패
이 정책 덕분에 일부 잘못된 데이터가 전체 수집 결과를 막지 않는다.
DB 제약에 닿기 전에 후보 검증하기
DB 저장 시점에만 오류를 발견하면 어떤 후보가 왜 실패했는지 파악하기 어렵고, 청크 전체가 롤백될 수 있다.
그래서 DB 접근 전에 다음 조건을 검사한다.
- 외부 콘텐츠 타입과 출처가 존재한다.
- 출처가
MANUAL이 아니다. - 외부 ID, 제목, 설명이 비어 있지 않다.
- 각 문자열이 DB 컬럼의 최대 길이를 넘지 않는다.
- 태그가 하나 이상 존재한다.
- 각 태그가 비어 있지 않고 허용 길이를 넘지 않는다.
- 선택 값인 썸네일 URL도 존재한다면 허용 길이를 지켜야 한다.
예상 가능한 후보 오류는 전용 예외로 구분해 개별 실패로 처리한다.
반대로 예상하지 못한 DB 또는 시스템 예외를 무조건 삼키지 않고 Job 실패로 전달한다.
식별키에 type을 포함한 이유
외부 콘텐츠의 중복 기준은 다음 조합이다.
(source, type, externalId)
TMDB 영화와 TV 시리즈는 서로 다른 영역에서 같은 숫자 ID를 가질 수 있다.
따라서 출처와 외부 ID만 비교하면 서로 다른 콘텐츠를 같은 데이터로 오해할 수 있다.
type까지 포함해 영화·TV 시리즈·스포츠를 구분하도록 했다.
응답 내부 중복을 먼저 제거하기
한 번의 수집 결과에 같은 식별키가 여러 번 포함될 수도 있다.
DB에 접근하기 전에 LinkedHashMap에 식별키와 후보를 저장해 최초 후보만 유지한다.
첫 번째 후보: 유지
같은 키의 다음 후보: skippedCount 증가
LinkedHashMap을 사용하면 중복을 제거하면서 원래 수집 순서도 유지할 수 있다.
후보마다 조회하던 구조 개선
초기 방식은 후보 하나마다 다음 작업을 반복했다.
후보 1개
-> 기존 콘텐츠 조회
-> 새 콘텐츠 저장 또는 동기화 시각 갱신
후보 수가 늘면 DB 왕복 횟수도 함께 증가한다.
리팩토링 후에는 후보를 최대 100개씩 나눈다.
후보 최대 100개
-> 기존 콘텐츠 일괄 조회
-> 정확한 식별키로 메모리에서 다시 매칭
-> 새 콘텐츠 saveAll
-> 기존 콘텐츠 lastSyncedAt 갱신
DB 조회에서는 출처·타입·외부 ID 집합을 사용하고, 조회 결과는 다시 정확한 (source, type, externalId) 키로 비교한다.
이렇게 해야 IN 조건의 조합으로 함께 조회된 데이터 중 실제 후보와 일치하는 콘텐츠만 처리할 수 있다.
트랜잭션을 청크 단위로 나눈 이유
모든 후보를 하나의 큰 트랜잭션으로 묶으면 후반부에서 오류가 발생했을 때 앞에서 처리한 정상 데이터까지 모두 롤백된다.
반대로 후보마다 트랜잭션을 열면 오버헤드가 커진다.
그래서 100개 단위 청크마다 별도 트랜잭션을 사용했다.
1번 청크 성공 -> 커밋
2번 청크 성공 -> 커밋
3번 청크 실패 -> 3번 청크 롤백, Job 실패
이 정책은 이전에 성공한 청크를 유지하는 부분 성공 방식이다.
같은 Job을 다시 실행해도 식별키 기반 멱등 처리로 이미 저장된 콘텐츠를 중복 생성하지 않는다.
기존 콘텐츠를 함부로 덮어쓰지 않기
외부 콘텐츠를 다시 만났을 때 제목, 설명, 썸네일, 태그를 전부 외부 값으로 덮어쓰지 않는다.
현재는 lastSyncedAt만 갱신한다.
새 콘텐츠
-> 생성
기존 콘텐츠
-> 핵심 필드 유지
-> lastSyncedAt만 갱신
관리자가 나중에 보정한 데이터를 외부 API 값으로 되돌리지 않기 위한 정책이다.
중복 Job 실행 방지
스케줄 실행과 수동 실행이 겹치면 같은 콘텐츠를 동시에 동기화할 수 있다.
공통 Spring Batch 메타데이터에서 같은 Job의 실행 상태를 확인하고, 현재 실행보다 먼저 시작된 실행이 아직 진행 중이면 새 실행을 중단한다.
비정상 종료로 실행 상태가 남아 있는 경우를 애플리케이션이 임의로 완료 처리하지는 않는다.
이 경우 운영자가 Job 메타데이터를 확인하고 판단해야 한다.
테스트한 내용
이번 리팩토링에서는 정상 동작뿐 아니라 실패 경계를 함께 테스트했다.
- 재시도 가능한 오류만 최대 횟수까지 재시도한다.
- 일반 4xx 오류는 재시도하지 않는다.
- Backoff 시간이 설정에 따라 증가한다.
- interrupt 발생 시 중단 상태를 보존한다.
- 잘못된 후보 하나가 다음 후보 처리를 막지 않는다.
- 응답 내부 중복 후보를 한 번만 처리한다.
- 영화와 TV의 같은 외부 ID를 서로 다르게 구분한다.
- 최대 100개 단위로 일괄 조회하고 저장한다.
- 청크 실패 시 해당 청크만 롤백한다.
- 기존 콘텐츠는 핵심 필드를 유지하고 동기화 시각만 갱신한다.
- 먼저 실행 중인 Job이 있으면 새 실행을 차단한다.
배운 점
배치 안정성은 단순히 예외를 잡는다고 생기지 않는다.
어디까지 재시도할지, 어디까지 건너뛸지, 어느 범위를 롤백할지를 각각 정해야 한다.
외부 API 실패 정책
개별 데이터 실패 정책
DB 트랜잭션 정책
중복 처리 정책
재실행 정책
이 기준을 먼저 나누고 나니 서비스 코드의 책임과 테스트 범위도 더 명확해졌다.
정리
이번 리팩토링으로 외부 콘텐츠 동기화는 일시적인 API 장애를 재시도하고, 잘못된 개별 데이터는 격리하며, DB 작업은 청크 단위로 처리하게 됐다.
또 정확한 복합 식별키와 멱등 처리로 중복 저장을 방지하고, 중복 Job 실행도 차단했다.
기능이 한 번 성공하는 것에서 끝나지 않고 반복 실행과 일부 실패를 견딜 수 있는 구조로 보완한 작업이었다.