DEVELOPMENT NOTE3

콘텐츠 동기화를 애플리케이션 시작에서 분리한 이유

API 서버를 재시작하지 않고 콘텐츠만 동기화하도록 일회성 실행 단위를 만든 과정

#spring-boot#docker-compose#content-sync#one-shot#application-lifecycle#operations
아카이브로 돌아가기

콘텐츠를 갱신하려고 서버를 재시작했다

첫 구현에서는 PostManualFullResyncRunner라는 ApplicationRunner가 콘텐츠 동기화를 실행했다. houkago.resync.enabledtrue이면 Spring Boot가 시작될 때 글 원본 경로와 커밋 해시를 읽어 전체 동기화를 한 번 수행했다. false이면 동기화 없이 API 서버만 시작했다.

text
Spring Boot 시작
  -> 전체 콘텐츠 동기화
  -> API 서버 실행

이미 만들어 둔 Spring 빈과 데이터베이스 연결을 그대로 사용할 수 있었고 별도 실행 환경도 필요하지 않았다. 로컬 Docker 환경에서 Markdown 원본부터 MySQL 조회 모델까지 연결됐는지 확인하기에도 충분했다.

문제는 이 방식을 운영에서도 계속 사용할 때 생겼다. 콘텐츠만 갱신하려고 해도 상시 실행 중인 애플리케이션을 다시 만들어야 했다. 동기화가 잘못된 frontmatter나 데이터베이스 오류로 실패하면 콘텐츠 작업의 실패와 API 서버 시작 실패도 하나의 사건으로 겹쳤다.

서버 실행과 동기화의 생명주기를 나눴다

API 서버와 콘텐츠 동기화는 같은 코드를 사용하지만 같은 생명주기를 가질 필요는 없었다.

기존 구조에는 다음과 같은 문제가 있었다.

  • 콘텐츠 변경과 백엔드 배포가 같은 실행 계기를 공유했다.
  • 동기화만 다시 실행하고 종료 결과를 확인하기 어려웠다.
  • API 상태와 콘텐츠 동기화 성공 여부를 구분하기 어려웠다.
  • 콘텐츠를 갱신하기 위해 정상 운영 중인 서버를 건드려야 했다.

ApplicationRunner 자체가 문제는 아니었다. 서버가 시작되기 전에 반드시 끝나야 하는 초기화 작업이라면 적절한 위치다. 하지만 콘텐츠 동기화가 실패하더라도 API 프로세스는 중단되지 않고, 그 시점까지 데이터베이스에 반영된 조회 모델을 계속 제공할 수 있어야 했다.

그래서 서버 실행과 콘텐츠 동기화를 서로 독립적으로 성공하거나 실패할 수 있는 작업으로 나눴다.

같은 이미지에 두 가지 실행 모드를 뒀다

별도 배치 프로젝트나 동기화 전용 이미지를 만들지 않고, 기존 Spring Boot 애플리케이션에 sync 프로파일을 추가했다.

text
같은 백엔드 이미지
  -> app 모드: API 서버를 계속 실행
  -> sync 모드: 콘텐츠를 한 번 동기화하고 종료

일반 프로파일에서는 기존 PostManualFullResyncRunner를 등록하고, sync 프로파일에서는 PostOneShotFullResyncRunner를 등록한다. 두 실행기는 모두 같은 PostManualFullResyncService를 호출한다.

스캐너와 파서, checksum, upsert와 누락된 글 처리도 그대로 공유한다. 달라진 것은 동기화 규칙이 아니라 실행 방식이다.

운영 환경에서는 기존 시작 실행기를 남겨 두되 HOUKAGO_RESYNC_ENABLED=false로 비활성화했다. 콘텐츠 동기화는 더 이상 API 서버가 시작될 때 자동으로 실행되지 않는다.

application-sync.yml에는 다음 설정을 적용했다.

yaml
spring:
  main:
    web-application-type: none
  flyway:
    enabled: false

WebApplicationType.NONE을 사용하므로 동기화 프로세스는 HTTP 포트를 열지 않는다. 필요한 Spring 컨텍스트와 저장소, 서비스 빈만 사용한다. 실행기가 입력값을 확인하고 전체 동기화를 한 번 실행하면 프로세스가 종료된다.

일회성 실행의 책임을 제한했다

운영 Compose의 appsync 서비스는 같은 백엔드 이미지와 MySQL 스키마를 사용한다. Posts checkout도 두 서비스에 읽기 전용으로 연결한다.

같은 이미지를 사용하면 API와 동기화 프로세스가 같은 파서와 메타데이터 정책, 엔티티 매핑을 사용한다. 서로 다른 이미지가 배포돼 콘텐츠 규칙이나 스키마 기대치가 달라지는 문제도 피할 수 있다.

운영에서는 mutable tag 대신 릴리스 환경이 가리키는 같은 이미지 digest를 두 서비스에 제공한다.

sync 서비스에는 호스트 포트와 재시작 정책이 없다. 기본 Compose 실행에도 포함되지 않으며 sync 프로파일을 지정했을 때만 임시 컨테이너로 만들어진다.

공식 실행 명령은 다음과 같다.

bash
docker compose run --rm sync

run --rm으로 실행하기 때문에 작업이 끝난 임시 컨테이너는 제거된다. 기존 app과 MySQL 컨테이너는 계속 실행된다.

스키마 마이그레이션은 app이 담당한다. Sync도 JPA 저장소와 기존 스키마를 사용하지만 Flyway 마이그레이션을 직접 실행하지는 않는다. 새 마이그레이션이 필요한 이미지라면 app 릴리스와 스키마 준비가 먼저 성공해야 한다.

이렇게 하면 콘텐츠 동기화가 실패해도 기존 API 프로세스는 중단되지 않고, 그 시점까지 데이터베이스에 반영된 조회 모델을 계속 제공할 수 있다. 동기화를 재시도하면서 마이그레이션까지 반복하지 않아도 된다.

종료 코드와 운영 환경으로 확인했다

상시 서버는 상태 확인 엔드포인트로 상태를 확인하지만, 일회성 프로세스는 작업이 성공했는지를 종료 결과로 알려야 한다.

동기화가 성공하면 종료 코드 0status=SUCCESS 요약 로그를 남긴다. 요약 로그에는 다음 정보가 포함된다.

  • 원본 커밋 해시
  • 후보 수
  • CREATED
  • UPDATED
  • TOUCHED
  • DELETED

실패하면 종료 코드를 1로 설정하고 예외를 다시 던진다. 오류를 로그에만 남기고 정상 종료처럼 처리하지 않기 위해서다.

다만 종료 코드 0만으로 올바른 리비전이 반영됐다고 판단할 수는 없다. 운영에서는 요약 로그의 커밋 hash와 처리 건수를 확인하고, 필요한 경우 공개 API 결과도 함께 확인한다.

OCI에서는 기존 app과 MySQL을 유지한 상태에서 sync container만 실행했다. 검증한 항목은 다음과 같다.

  • 프로세스가 종료 코드 0status=SUCCESS를 반환하는가
  • 요약 로그의 커밋 해시가 실제 posts checkout과 일치하는가
  • App 컨테이너의 ID와 재시작 횟수가 유지되는가
  • MySQL 컨테이너의 ID와 상태가 유지되는가
  • 실행 중과 실행 후에도 공개 API가 정상 응답하는가
  • 실행이 끝난 뒤 임시 sync 컨테이너가 남지 않는가

후속 운영 검증에서는 당시 265개 글이 TOUCHED=265, DELETED=0으로 처리됐다. App과 MySQL은 재시작되지 않았다. 이 수치는 당시 checkout과 데이터베이스 상태에서 관찰한 결과이며 현재 글 수나 항상 나와야 하는 고정값은 아니다.

실행을 분리해도 자동화가 해결되지는 않는다

일회성 실행 단위를 만들었다고 모든 운영 문제가 해결된 것은 아니다.

  • 누가 동기화를 실행할 것인가
  • Git push와 실행 시점을 어떻게 연결할 것인가
  • 같은 요청의 중복 실행을 어떻게 막을 것인가
  • 실행 중 다른 작업이 들어오면 어떤 순서로 처리할 것인가
  • 실패한 요청과 결과를 어디에 남기고 어떻게 재시도할 것인가

전체 동기화의 트랜잭션 경계도 달라지지 않았다. 하나의 일회성 프로세스로 실행하더라도 전체 작업이 하나의 데이터베이스 트랜잭션이 되는 것은 아니다.

실행하는 쪽에서는 올바른 글 원본 경로와 커밋 해시, 데이터베이스 환경, app과 같은 릴리스 이미지도 제공해야 한다.

이번 변경이 해결한 것은 자동화 자체가 아니었다. API 서버를 재시작하지 않고 콘텐츠 동기화만 실행할 수 있고, 그 작업의 성공과 실패를 종료 코드와 요약 로그로 확인할 수 있는 경계를 만든 것이었다.