[모두의 플리] 외부 API로 콘텐츠 데이터를 수집하는 구조 만들기
[모두의 플리] 외부 API로 콘텐츠 데이터를 수집하는 구조 만들기
배경
모두의 플리 프로젝트에서 내가 맡은 Content 도메인은 관리자가 직접 등록하는 콘텐츠뿐 아니라 외부 API에서 수집한 콘텐츠도 함께 다뤄야 한다.
요구사항에서는 다음 외부 API 연동이 필요했다.
TMDB API: 영화, 드라마 콘텐츠
TheSportsDB API: 스포츠 경기 콘텐츠
이번 작업의 목표는 실제 배치 작업에 연결하기 전에, 외부 API에서 콘텐츠 후보 데이터를 가져오고 우리 서비스의 Content 모델로 변환할 수 있는 기반을 만드는 것이었다.
즉 이번 단계에서는 스케줄링이나 Spring Batch 실행 자체보다, 외부 API 연동과 데이터 변환 구조를 먼저 안정적으로 만드는 데 초점을 맞췄다.
외부 API 코드를 infra에 둔 이유
외부 API 호출 코드는 Content 도메인의 핵심 비즈니스 규칙이라기보다는 외부 시스템과 통신하는 기술 구현에 가깝다.
그래서 TMDB와 TheSportsDB 호출 코드는 infra.external 하위에 두었다.
대략적인 책임은 다음처럼 나눴다.
infra.external.tmdb
- TMDB API 요청
- TMDB 응답 DTO
- TMDB 응답을 내부 후보 모델로 변환
infra.external.sports
- TheSportsDB API 요청
- TheSportsDB 응답 DTO
- 스포츠 응답을 내부 후보 모델로 변환
domain.content.service
- 외부 콘텐츠 후보를 Content로 upsert
- 중복 여부 판단
- 저장 정책 적용
이렇게 나눈 이유는 외부 API의 응답 구조가 바뀌더라도 Content 도메인의 핵심 로직이 직접 흔들리지 않게 하기 위해서다.
직접 파싱보다 DTO를 사용한 이유
처음에는 외부 API 응답에서 필요한 필드만 바로 꺼내서 처리할 수도 있다고 생각했다.
하지만 그렇게 하면 JSON 구조를 다루는 코드와 도메인 변환 코드가 섞이기 쉽다.
예를 들어 TMDB 응답에는 영화와 TV 시리즈가 서로 다른 필드명을 사용할 수 있고, TheSportsDB는 또 다른 구조를 가진다.
이 구조를 서비스 코드에서 직접 파싱하면 다음 문제가 생길 수 있다.
- 어떤 필드가 외부 API 원본 필드인지 알기 어렵다.
- 응답 구조가 바뀌었을 때 수정 위치가 분산된다.
- 테스트에서 응답 매핑만 따로 검증하기 어렵다.
- Content 도메인 서비스가 외부 API 세부 구조를 너무 많이 알게 된다.
그래서 외부 API 응답은 DTO로 받고, Mapper에서 내부 후보 모델로 변환하는 방향으로 정리했다.
내부 후보 모델은 대략 다음 역할을 한다.
외부 API 응답 DTO -> ContentExternalCandidate -> Content Entity
이렇게 한 번 중간 모델을 두면 TMDB와 TheSportsDB가 서로 다른 응답 구조를 가지더라도 Content 저장 서비스는 같은 형태의 후보 데이터를 받을 수 있다.
외부 콘텐츠 식별 기준
외부에서 가져온 콘텐츠는 중복 저장을 막아야 한다.
그래서 외부 콘텐츠는 다음 기준으로 식별한다.
source + externalId
예를 들어 TMDB 영화 ID가 123이라면, 우리 서비스에서는 다음처럼 구분할 수 있다.
source = TMDB
externalId = 123
TheSportsDB에서 가져온 경기 데이터도 마찬가지로 source와 externalId 조합으로 중복을 판단한다.
이 기준을 사용하면 서로 다른 외부 API에서 같은 ID 숫자를 사용하더라도 충돌하지 않는다.
upsert 정책
외부 콘텐츠 수집은 한 번만 실행되는 작업이 아니다.
나중에 배치 작업으로 주기적으로 실행될 수 있기 때문에, 이미 저장된 외부 콘텐츠를 다시 만날 수 있다.
그래서 단순 insert가 아니라 upsert에 가까운 흐름이 필요하다.
다만 초기 구현에서는 외부 API의 최신 데이터로 모든 필드를 덮어쓰지 않기로 했다.
현재 정책은 다음과 같다.
새 외부 콘텐츠: Content 생성
이미 존재하는 외부 콘텐츠: lastSyncedAt만 갱신
처음부터 제목, 설명, 썸네일, 태그를 모두 갱신하면 관리자가 나중에 보정한 값까지 외부 API 값으로 덮어쓸 수 있다.
그래서 초기 구현에서는 수집 시점만 기록하고, 필드 갱신 범위는 나중에 필요해지면 별도로 정책을 정하기로 했다.
태그 매핑 정책
TMDB 응답에는 genre 정보가 포함될 수 있다.
초기 구현에서는 이 genre를 Content의 tag로 사용하기로 했다.
예를 들어 다음과 같은 식이다.
TMDB genre: Action, Drama
Content tags: Action, Drama
TheSportsDB도 리그나 종목 정보를 태그 후보로 사용할 수 있지만, 초기에는 명확하게 매핑 가능한 값 위주로만 사용했다.
태그 정책은 서비스에서 검색과 필터링에도 영향을 줄 수 있기 때문에, 외부 API에서 받을 수 있는 모든 값을 무조건 태그로 넣지는 않았다.
현재는 최소한의 태그를 넣고, 이후 검색 품질을 보면서 보강할 수 있게 남겨두었다.
timeout 설정
외부 API 호출은 우리 서버 내부 로직보다 실패 가능성이 높다.
외부 서비스가 느리거나, 네트워크가 불안정하거나, 응답이 지연될 수 있다.
그래서 timeout 설정을 추가했다.
초기값은 다음 기준으로 잡았다.
connect timeout: 3초
read timeout: 5초
이 값은 너무 길게 잡으면 배치 작업 전체가 오래 멈출 수 있고, 너무 짧게 잡으면 정상 응답도 실패로 처리될 수 있다.
초기 구현에서는 빠르게 실패하고 다음 처리로 넘어갈 수 있는 쪽에 더 무게를 두었다.
application.yml에 설정을 추가한 이유
외부 API 연동에는 API key, base URL, timeout 같은 설정값이 필요하다.
이 값들을 코드에 직접 박아두면 환경별로 바꾸기 어렵다.
그래서 application.yml에는 필요한 설정 키를 추가하고, 실제 민감값은 환경변수로 받을 수 있게 했다.
예시는 다음과 같다.
mopl:
external:
timeout:
connect-seconds: ${EXTERNAL_API_CONNECT_TIMEOUT_SECONDS:3}
read-seconds: ${EXTERNAL_API_READ_TIMEOUT_SECONDS:5}
tmdb:
api-key: ${TMDB_API_KEY:}
the-sports-db:
api-key: ${THE_SPORTS_DB_API_KEY:}
API key를 빈 값으로 둘 수 있게 한 이유는 로컬 개발이나 테스트 환경에서 외부 API 키가 없더라도 애플리케이션 전체가 바로 죽지 않게 하기 위해서다.
다만 실제 수집을 실행하려면 API key가 필요하다.
테스트한 내용
이번 작업에서는 외부 API 호출 자체보다 매핑과 저장 정책을 중심으로 테스트했다.
검증한 내용은 다음과 같다.
- TMDB 영화 응답을 Content 후보로 변환한다.
- TMDB TV 시리즈 응답을 Content 후보로 변환한다.
- TheSportsDB 경기 응답을 Content 후보로 변환한다.
- 외부 콘텐츠가 없으면 새로 저장한다.
- 이미 존재하는 외부 콘텐츠는 중복 생성하지 않는다.
- 이미 존재하는 외부 콘텐츠는 lastSyncedAt만 갱신한다.
- 외부 API 오류가 발생해도 실패를 구분해서 처리할 수 있다.
외부 API는 네트워크 상태와 API 키에 영향을 받기 때문에, 단위 테스트에서는 실제 API 호출보다 응답 매핑과 서비스 정책을 검증하는 쪽에 집중했다.
작업하면서 보완한 점
처음에는 외부 API 응답을 필요한 부분만 직접 파싱하는 방식도 고려했다.
하지만 실제 코드를 다시 보면서 DTO와 Mapper를 두는 편이 더 낫다고 판단했다.
이유는 다음과 같다.
- 응답 구조와 도메인 저장 정책을 분리할 수 있다.
- 외부 API별 매핑 테스트를 작성하기 쉽다.
- 나중에 필드가 추가되거나 응답 구조가 바뀌어도 수정 위치가 명확하다.
- Batch 작업은 ContentExternalSyncService만 호출하면 되므로 실행 구조가 단순해진다.
또 하나 보완한 점은 upsert 시 필드 갱신 범위다.
처음부터 모든 필드를 갱신하는 방식은 편하지만, 서비스 운영 관점에서는 위험할 수 있다.
그래서 초기 구현에서는 lastSyncedAt만 갱신하고, 외부 데이터로 기존 필드를 덮어쓸지는 후속 정책으로 남겼다.
정리
이번 작업은 외부 API와 Content 도메인 사이의 경계를 만드는 작업이었다.
단순히 API를 호출해서 저장하는 것보다 중요한 것은 다음 세 가지였다.
- 외부 API 응답 구조를 도메인 로직과 분리하기
- 중복 저장을 막기 위한 식별 기준 정하기
- 주기적 수집을 고려한 upsert 정책 정하기
이 기반이 있어야 이후 Spring Batch 작업에서 실제 수집 Job을 안정적으로 연결할 수 있다.
이후 실제 데이터 수집 중 발견한 문제와 해결 과정은
[모두의 플리] 외부 API 콘텐츠 수집 이슈 해결 기록 글에 따로 정리했다.