본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
13장 : 실전 Study Log API

통합 검증·Compose·릴리스 리허설

앞 절의 smoke script는 실제 local JWT와 HTTP 사용자 흐름을 검증했습니다. 마지막 절에서는 같은 애플리케이션을 Testcontainers의 격리된 PostgreSQL에서 반복 검증하고, candidate image가 새 migration을 적용한 뒤 이전 image로 다시 기동되는지 로컬 Compose에서 리허설합니다.

테스트 성공, probe 성공, rollback 성공은 서로 대체할 수 없는 증거입니다. 어느 하나만 보고 릴리스 가능하다고 결론내리지 않습니다.


릴리스 후보는 같은 절차로 반복 검증한다

통합 테스트는 매 실행마다 새 DB를 사용해 migration과 HTTP 계약을 확인합니다. Compose는 실제 OCI image와 종료 signal을 확인하고, rollback rehearsal은 candidate가 바꾼 DB 위에서 이전 image가 다시 Ready가 되는지를 확인합니다.

ch13-2의 compose.yaml에는 PostgreSQL만 있었습니다. 아래 파일은 그 YAML 뒤에 조각을 덧붙이는 것이 아니라 같은 파일을 PostgreSQL과 API 두 서비스가 있는 최종본으로 교체합니다. 이름이 같은 servicesvolumes 키를 중복 작성하지 않습니다.


통합 테스트와 운영 파일을 완성한다

통합 테스트의 decoder mock은 권한 매핑과 Controller 이후 흐름을 빠르게 검증하기 위한 것입니다. 실제 HMAC 서명 검증은 앞 절의 smoke script가 담당하므로 둘의 역할을 섞지 않습니다.

Compose의 API에는 별도 container healthcheck를 넣지 않습니다. Buildpacks image에 curl이나 wget이 항상 들어 있다고 가정할 수 없기 때문입니다. 대신 host에서 /readyz를 제한된 횟수로 호출하는 wait_ready 함수가 기동 완료를 판단합니다.

src/test/java/com/andongmin/studylog/StudyLogApiIntegrationTest.java
package com.andongmin.studylog;

import static org.mockito.Mockito.when;

import java.time.Instant;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureRestTestClient;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.client.RestTestClient;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.postgresql.PostgreSQLContainer;

@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@AutoConfigureRestTestClient
@Testcontainers
class StudyLogApiIntegrationTest {
    @Container
    @ServiceConnection
    static final PostgreSQLContainer postgres =
            new PostgreSQLContainer("postgres:18-alpine");

    @Autowired RestTestClient client;
    @MockitoBean JwtDecoder jwtDecoder;

    @Test
    void createsSessionWithWriteScope() {
        Instant now = Instant.now();
        when(jwtDecoder.decode("writer")).thenReturn(new Jwt("writer", now,
                now.plusSeconds(300), Map.of("alg", "none"),
                Map.of("sub", "learner", "scope", "study.read study.write")));

        client.post().uri("/api/study-sessions")
                .header("Authorization", "Bearer writer")
                .body(Map.of("subject", "Spring", "topic", "Release",
                        "studyDate", "2026-07-13", "minutes", 60))
                .exchange()
                .expectStatus().isCreated()
                .expectHeader().exists("Location")
                .expectHeader().valueEquals("ETag", "\"0\"");
    }

    @Test
    void rejectsAnonymousRead() {
        client.get().uri("/api/study-sessions")
                .exchange()
                .expectStatus().isUnauthorized();
    }
}
compose.yaml
services:
  postgres:
    image: postgres:18-alpine
    environment:
      POSTGRES_DB: studylog
      POSTGRES_USER: studylog
      POSTGRES_PASSWORD: studylog
    volumes:
      - studylog-data:/var/lib/postgresql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U studylog -d studylog"]
      interval: 5s
      timeout: 3s
      retries: 20
  api:
    image: study-log-api:current
    ports:
      - "8080:8080"
    environment:
      SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/studylog
      SPRING_DATASOURCE_USERNAME: studylog
      SPRING_DATASOURCE_PASSWORD: studylog
    depends_on:
      postgres:
        condition: service_healthy
volumes:
  studylog-data:
release-rehearsal.sh
#!/usr/bin/env bash
set -euo pipefail
: "${PREVIOUS_IMAGE:?set PREVIOUS_IMAGE to a previously verified image tag or digest}"
docker image inspect "$PREVIOUS_IMAGE" >/dev/null
docker tag "$PREVIOUS_IMAGE" study-log-api:previous

rollback() {
  trap - ERR
  docker tag study-log-api:previous study-log-api:current
  docker compose up -d --force-recreate --wait api
  wait_ready
}

wait_ready() {
  for attempt in {1..60}; do
    if curl -fsS http://localhost:8080/readyz >/dev/null; then
      return 0
    fi
    sleep 1
  done
  return 1
}

trap rollback ERR
./mvnw -q clean verify
./mvnw spring-boot:build-image   -Dspring-boot.build-image.imageName=study-log-api:candidate
docker tag study-log-api:candidate study-log-api:current
docker compose up -d --wait
wait_ready
curl -fsS http://localhost:8080/livez
curl -fsS http://localhost:8080/readyz
docker compose kill -s SIGTERM api
docker compose up -d --force-recreate --wait api
wait_ready
trap - ERR
rollback
docker compose down

PREVIOUS_IMAGE를 먼저 inspect하고 별도 previous 태그로 고정하므로 candidate가 current 태그를 차지해도 복귀 대상을 잃지 않습니다. rollback은 ERR trap을 먼저 해제해 복귀 자체의 실패가 재귀적으로 rollback을 다시 호출하지 않게 합니다.


장애와 rollback을 포함한 최종 리허설을 수행한다

스크립트는 로컬 Docker image 태그와 Compose 컨테이너를 실제로 변경합니다. study-log-api:1.0.0이 앞서 검증한 이전 image로 로컬에 존재하는지 확인한 뒤, 버려도 되는 학습 환경에서만 실행합니다.

PREVIOUS_IMAGE=study-log-api:1.0.0 bash release-rehearsal.sh
Release 확인 결과
11장에서 검증한 `study-log-api:1.0.0` image를 이전 버전으로 사용합니다. verify, PostgreSQL migration, candidate readiness, SIGTERM 뒤 재기동, 이전 image로 실제 교체와 readiness 재확인이 순서대로 성공해야 합니다. 마지막 rollback은 의도된 리허설 결과이므로 candidate를 배포 상태로 남기지 않습니다.

완주는 기능 개수보다 복구 가능한 증거로 판정한다

빌드, migration, 보안, 동시성, 관측과 rollback이 반복 실행 가능한 명령과 결과로 남아야 합니다.

이 프로젝트에는 DB 백업·복원 명령이 아직 없습니다. 따라서 백업까지 검증했다고 표시하지 않고, 실제 운영 전에는 PostgreSQL snapshot 또는 pg_dump의 보관·복원 시간을 별도 runbook과 리허설로 추가합니다.


rollback 실패는 성공으로 덮어쓰지 않는다

이전 image가 새 schema에서 시작하지 못하면 스크립트는 컨테이너와 로그를 남긴 채 실패해야 합니다. 그 상태에서 volume을 삭제하거나 candidate를 다시 덮어쓰지 말고 두 image digest, Flyway 이력, API 로그, readiness 응답을 먼저 보존합니다.


이제 Study Log API는 기능 구현뿐 아니라 검증, 관측, 배포와 복구 절차까지 한 흐름으로 설명할 수 있습니다.