[구현 7] 완성 뒤가 진짜 시작이다

앞선 여섯 편에서는 상태 머신, 예약 가능 시간, 전체 최적화, 제약 조건, 휴진과 장비 고장 재배정을 차례로 살펴봤습니다. 각 기능은 테스트를 통과하고 API로 호출할 수 있게 됐지만, 프로젝트는 그때 끝나지 않았습니다. 코드 리뷰가 빠진 계약을 발견했고, 여러 병원을 한 서비스에서 운영하려니 접근 경계가 달라졌습니다. 인스턴스를 늘리자 스케줄러 중복 실행을 막아야 했고, H2에서 통과한 마이그레이션을 PostgreSQL과 MySQL에서도 검증해야 했습니다.
이 글은 완성된 기능 목록을 요약하지 않습니다. 작은 리뷰 지적과 운영 조건이 어떻게 다음 요구사항이 되고, 설계·구현·테스트·회고를 거쳐 다시 시스템의 기준으로 들어왔는지 살펴봅니다.
리뷰는 놓친 계약을 가장 작은 요구사항으로 바꾼다
섹션 제목: “리뷰는 놓친 계약을 가장 작은 요구사항으로 바꾼다”초기 예약 API는 주요 상태 전이를 수행했지만 사용하는 사람이 필요한 모든 계약을 제공하지는 않았습니다. 리뷰와 이슈 처리를 거치며 다음과 같은 작은 요구사항이 추가됐습니다.
| 발견한 빈틈 | 새로 고정한 계약 | 구현에서 배운 점 |
|---|---|---|
| 상태가 바뀌었지만 외부에서 이력을 읽기 어렵다 | 상태 이력 조회 API | DB 레코드를 그대로 노출하지 않고 상태 이름을 평탄화한 응답 DTO를 둔다 |
| 취소 사유가 고정 문자열이다 | 선택적 취소 사유와 하위 호환 기본값 | 상태 머신, 이력, 도메인 이벤트에 같은 effectiveReason을 전달한다 |
| 목록이 커져도 전부 반환한다 | ExposedPage<T> 기반 페이지네이션 | 응답 형태가 바뀌면 프런트엔드 소비자도 함께 바꾼다 |
| DTO 제약은 있지만 검증이 실행되지 않는다 | 모든 요청 본문에 @Valid | Kotlin에서는 @field: use-site target과 JSON 파싱 오류 처리가 필요하다 |
| API 동작이 소스 밖에서 잘 보이지 않는다 | OpenAPI 어노테이션과 응답 문서 | 모든 파라미터가 아니라 의미가 불분명한 입력을 선별해 설명한다 |
각 변경은 작지만 공통점이 있습니다. “코드가 있으니 동작할 것이다”를 믿지 않고 소비자가 관찰할 수 있는
계약으로 바꿨습니다. 예를 들어 상태 이력 API는 예약 존재 확인과 이력 조회를 한 트랜잭션에서 수행하고,
changedAt DESC, id DESC로 정렬해 같은 시각의 변경도 결정적인 순서로 반환합니다.
취소 사유도 컨트롤러에서 받는 데서 끝나지 않습니다. 같은 값이 상태 전이, 감사 이력, 취소 도메인 이벤트에 전달돼야 환자 응대와 알림이 서로 다른 이유를 보여 주지 않습니다. 리뷰의 가치는 지적 개수보다 이런 횡단 계약을 찾아 작은 회귀 테스트로 고정하는 데 있습니다.
단일 병원 가정이 깨지면 모든 ID의 의미가 달라진다
섹션 제목: “단일 병원 가정이 깨지면 모든 ID의 의미가 달라진다”여러 병원 그룹이 같은 SaaS를 사용하면서 가장 큰 변화는 테이블에 tenant_group_id를 추가한 일이 아니었습니다.
URL에 있는 테넌트와 JWT가 허용하는 테넌트, 경로의 병원 ID, 본문이나 하위 경로의 의사·진료 유형·장비 ID를
하나의 소유 경계로 묶어야 했습니다.
현재 API는 /api/{tenantCode}/... 경로를 사용합니다.
TenantAuthorizationManager는
URL의 tenantCode가 JWT allowedTenants에 들어 있는지 확인합니다. 인증되지 않은 요청은 테넌트 조회를 하지
않고 401 경로로 보내며, 인증된 요청에서 모르는 테넌트는 404, 허용되지 않은 테넌트는 403으로 구분합니다.
val tenantCode = context.variables["tenantCode"] ?: TenantPathResolver.resolve(context.request) ?: return AuthorizationDecision(false)
return AuthorizationDecision(tenantCode in principal.allowedTenants)인가가 통과했다고 해서 임의의 doctorId나 equipmentId를 믿을 수는 없습니다.
TenantClinicAccessChecker.verifySchedulingResources()는 병원, 의사, 진료 유형과 선택 장비가 모두 같은
테넌트와 같은 병원에 속하는지 확인합니다. 저장소의 findByIdAndTenant()는 JOIN 조건으로 다른 테넌트의
자원 ID를 null로 만들고, API는 이를 일반적인 404로 반환해 존재 여부 노출을 줄입니다.
또 하나의 결정은 TenantContext를 서비스 내부의 암묵적 전역 상태로 퍼뜨리지 않는 것이었습니다. 컨트롤러
경계에서 테넌트를 해석한 뒤 suspend 서비스와 저장소에는 tenantGroupId를 명시적으로 넘깁니다. 비동기
실행과 코루틴 컨텍스트 전환이 늘어날수록 암묵적 ThreadLocal보다 함수 인자가 추적과 테스트에 안전합니다.
알림은 이벤트 분리, 중복 방지, 장애 격리를 함께 본다
섹션 제목: “알림은 이벤트 분리, 중복 방지, 장애 격리를 함께 본다”예약 생성·확정·취소·재배정은 알림을 유발하지만 API 서비스가 문자나 이메일 구현을 직접 호출하면 상태 변경
트랜잭션이 외부 채널 장애에 묶입니다. 현재 AppointmentService는 Spring
ApplicationEventPublisher로 도메인 이벤트를 발행하고, NotificationEventListener가 같은 프로세스 안에서
이벤트를 받아 채널을 호출합니다.
여기서 “이벤트 기반”을 메시지 브로커나 아웃박스가 구현된 것처럼 확대해석하면 안 됩니다. 현재 리스너는
@EventListener 기반의 프로세스 내부 경계입니다. 프로세스가 커밋 직후 종료돼도 반드시 전달된다는 보장은
별도 신뢰성 설계가 필요합니다.
리마인더는 또 다른 문제를 만납니다. 애플리케이션 인스턴스가 세 대라면 세 스케줄러가 같은 환자에게 메시지를
보낼 수 있습니다. AppointmentReminderScheduler는 Redis 기반 LettuceLeaderGroupElector의
runIfLeader() 안에서만 오늘·내일의 CONFIRMED 예약을 조회합니다. 성공 이력에 같은 예약과 이벤트 유형이
있으면 건너뛰어 다음 실행의 중복도 줄입니다.
외부 알림 채널은 ResilientNotificationChannel이 Resilience4j의 CircuitBreaker, Retry, Bulkhead로 감쌉니다.
재시도는 일시 오류를 흡수하고, CircuitBreaker는 반복 실패 호출을 줄이며, Bulkhead는 동시에 막힐 수 있는 호출
수를 제한합니다. 다만 래퍼가 최종 예외를 로그로 흡수하므로 호출이 반환됐다는 사실만으로 실제 전달 성공을
판단해서는 안 됩니다. 알림 이력의 성공·실패 상태와 외부 공급자 응답을 관찰해야 합니다.
데이터베이스 호환성은 실행 증거로만 증명된다
섹션 제목: “데이터베이스 호환성은 실행 증거로만 증명된다”개발 중 H2가 편리하다고 해서 H2 마이그레이션 성공이 운영 DB의 안전을 증명하지는 않습니다. 테넌트 기능은 기존 unique constraint와 index를 바꾸면서 DB별 DDL 차이를 드러냈습니다. H2는 테이블을 재생성해야 했고, MySQL과 PostgreSQL은 서로 다른 제약·인덱스 제거 문법과 이름을 처리해야 했습니다.
그래서 V3부터 V6까지 테넌트 마이그레이션을 h2, mysql, postgresql 디렉터리로 나눴습니다. 테스트도
FlywayMigrationTest, FlywayMySQLMigrationTest, FlywayPostgreSQLMigrationTest로 분리해 각 엔진에서 실제로
적용합니다. “SQL이 비슷해 보인다”보다 빈 스키마와 기존 스키마에 마이그레이션을 실행한 결과가 증거입니다.
이후 Exposed Gradle plugin을 적용하면서 핵심, API, 이벤트, 알림 모듈에 generateMigrations 작업도
노출했습니다. 생성 도구가 있어도 검토 없이 운영 SQL을 대신 결정하게 할 수는 없습니다. DB별 수동 마이그레이션,
생성된 diff, 실제 테스트 컨테이너 결과를 함께 비교해야 합니다.
의존성 업그레이드는 컴파일보다 의미 보존을 검증한다
섹션 제목: “의존성 업그레이드는 컴파일보다 의미 보존을 검증한다”Timefold Solver 2.1로 옮길 때 점수(score), ConstraintVerifier, 점수 디렉터(score director), 이동 API(move API)가 한꺼번에 바뀌었습니다.
import를 고쳐 컴파일을 통과하는 것만으로는 충분하지 않습니다. 제약 가중치(Constraint weight) 타입이 Long으로 바뀌고,
제약 ID에 허용되지 않는 문자가 생겼으며, move API는 preview 인터페이스와 SequencedCollection을 사용했습니다.
마이그레이션의 목표는 새 API를 사용하는 것이 아니라 기존 일정 의미를 보존하는 것이었습니다. 그래서 ConstraintVerifier 테스트와 solver 테스트를 다시 실행하고, 로컬 벤치마크 기준선으로 실행 시간·점수·입력 규모를 비교했습니다. 벤치마크는 일반 CI에서 제외돼 있으므로 의존성 변경 PR에서는 별도 실행 증거를 남겨야 합니다.
성능 숫자도 영구 보증이 아닙니다. 2026년 4월 Apple M4 Pro와 JDK 25에서 얻은 10·30·100개 예약 결과는 회귀를 발견할 출발점입니다. 실제 병원의 데이터 분포, 고정 예약 비율, 장비 수량과 JVM 버전이 바뀌면 기준선도 다시 측정해야 합니다.

문서가 살아 있으려면 코드와 같은 피드백 주기에 있어야 한다
섹션 제목: “문서가 살아 있으려면 코드와 같은 피드백 주기에 있어야 한다”이 프로젝트는 docs/requirements에 현재 요구사항을, docs/superpowers/specs와 plans에 설계와 실행 계획을,
docs/lessons에 구현 중 발견한 교훈을 남겼습니다. 세 문서의 역할은 다릅니다.
- Requirements: 지금 사용자가 기대할 수 있는 동작과 경계
- Spec / Plan: 변경 전에 합의한 설계, 작업 순서와 검증 방법
- Lessons: 실제 구현과 리뷰에서 달라진 점, 다시 밟지 말아야 할 실패 경로
문서가 많다고 살아 있는 문서는 아닙니다. 경로가 테넌트 방식으로 바뀌었는데 README가 예전 /api/...를
가리키거나, Solver 제약 수가 늘었는데 요구사항 표가 그대로라면 문서는 오히려 위험합니다. 구현 변경과 함께
현재 문서를 갱신하고, 역사적 설계 문서는 당시 결정 기록으로 분명히 구분해야 합니다.
좋은 회고는 “어려웠다”로 끝나지 않습니다. 근본 원인, 선택한 해결책, 거절한 대안, 검증 명령과 재발 방지 기준을 남깁니다. 그러면 다음 개발자는 같은 문제를 처음부터 추측하지 않고 현재 코드와 테스트에서 출발할 수 있습니다.
아직 다루지 못한 것들: 다음 주기는 무결성에서 시작한다
섹션 제목: “아직 다루지 못한 것들: 다음 주기는 무결성에서 시작한다”이 시리즈가 예약 업무의 모든 문제를 해결한 것은 아닙니다. 지금까지는 상태 전이, 가용 시간 계산, 재배정, 테넌트 격리와 알림 장애 격리에 집중했습니다. 실제 서비스를 오래 운영하려면 기능을 더 붙이기 전에 다음 경계를 먼저 단단하게 만들어야 합니다.
| 우선순위 | 아직 부족한 경계 | 다음 단계 |
|---|---|---|
| 1. 예약 무결성 | 슬롯 조회와 예약 생성 사이의 수용 인원 경쟁 조건 | 가용성 확인과 저장을 하나의 원자적 경계로 묶고, DB 잠금 또는 제약 전략으로 동시 쓰기를 직렬화한다 |
| 2. 이벤트 전달 | 프로세스 내부 이벤트는 프로세스 종료와 DB 커밋 이후의 전달을 보장하지 않는다 | 트랜잭셔널 아웃박스, 이벤트 ID·스키마 버전, 소비자 중복 제거, 재시도·배달 불능·대사 작업을 설계한다 |
| 3. 운영 관찰 | 로그만으로는 환자 요청부터 알림 공급자까지 한 흐름을 복원하기 어렵다 | 예약 충돌률, 알림 성공률·지연, 리더 전환, Solver 시간·점수에 SLO와 추적·상관관계 ID를 연결한다 |
| 4. 환자 업무 | 관리자 중심 흐름만으로는 자가 예약 이후의 실제 경험을 다루기 어렵다 | 환자 인증·포털, 대기 목록, 미방문 후속 처리, 알림 동의, 접근성·다국어와 개인정보 보존 정책을 정의한다 |
| 5. 최적화 통제 | 좋은 점수가 환자와 운영자에게 납득 가능한 배정이라는 보장은 없다 | 제약 버전과 추천 이유를 기록하고, 공정성 지표·수동 조정·섀도 실행으로 정책 변화를 검증한다 |
첫 번째 항목은 특히 기능 추가보다 앞섭니다. 현재
AppointmentService.create()는
동일한 Idempotency-Key와 요청 본문을 다시 보내면 처음 만든 예약을 반환하고, 같은 키에 다른 요청을 보내면
충돌로 거부합니다. 예약과 멱등성 레코드는 같은 트랜잭션에 저장하며, 동시 요청의 고유 키 충돌도 커밋된
레코드를 다시 읽어 같은 결과로 수렴시킵니다. 따라서 네트워크 재시도에 대한 요청 멱등성은 현재 구현된
경계입니다.
그러나 별도의 슬롯 조회에서 “남은 자리 1”을 본 서로 다른 두 요청이 동시에 들어오면, 조회 결과만으로 어느 요청이
자리를 차지했는지 결정할 수 없습니다. resolveMaxConcurrent()처럼 허용
수량을 계산하는 규칙과 실제 동시 쓰기를 직렬화하는 장치는 다른 문제입니다. 서버가 가용성 재검증과 저장을 같은
트랜잭션에서 수행하고 충돌을 일관된 409 응답으로 바꾸는 수용 인원 무결성은 여전히 남은 경계입니다.
메시지 브로커를 추가하는 것만으로 두 번째 항목도 해결되지 않습니다. 예약 커밋은 성공했지만 이벤트 발행이 실패하는 이중 쓰기 문제를 막으려면 이벤트를 같은 DB 트랜잭션의 아웃박스에 기록하고, 발행자와 소비자가 중복 전달을 견디도록 만들어야 합니다. 그래도 최종 전달이 누락될 수 있으므로 알림 이력과 예약 상태를 비교해 복구하는 대사 작업까지 있어야 운영자가 “재시도하면 된다”보다 구체적인 복구 절차를 가질 수 있습니다.
그다음부터는 무엇을 측정할지 결정해야 합니다. 평균 응답 시간만으로는 예약 서비스의 건강을 설명하기 어렵습니다. 중복 예약 차단률, 알림 전달 지연, 재배정 승인까지 걸린 시간, 테넌트별 오류율, Solver가 제안한 일정의 수동 수정률 같은 업무 지표가 기술 지표와 연결돼야 합니다. 백업 복구, Redis 장애, 알림 공급자 장애와 DB 마이그레이션 실패를 연습하는 운영 훈련도 같은 수준의 요구사항으로 다뤄야 합니다.
마지막으로 최적화는 자동화 범위를 넓힐수록 설명 책임이 커집니다. 응급도나 환자 선호, 의사 업무 부하처럼 서로 충돌하는 기준을 어떤 순서와 가중치로 적용했는지 남기고, 새 제약은 기존 일정에 바로 적용하기 전에 섀도 실행으로 비교하는 편이 안전합니다. Solver의 답은 결정을 돕는 제안이며, 운영자가 이유를 확인하고 수정한 기록도 다음 제약을 개선하는 입력이 돼야 합니다.
이 시리즈가 남긴 설계 원칙
섹션 제목: “이 시리즈가 남긴 설계 원칙”일곱 편을 지나며 반복해서 확인한 원칙은 다음과 같습니다.
- 예약은 CRUD 행이 아니라 상태와 이력, 시간과 자원을 함께 관리하는 모델이다.
- 병원과 의사마다 다른 정책은 데이터로 만들고 계산 경계를 분리한다.
- 한 건의 빠른 후보 조회와 여러 예약의 전체 최적화는 서로 다른 유스케이스다.
- Solver 제약은 알고리즘 설정이 아니라 설명하고 테스트해야 하는 운영 정책이다.
- 휴진과 장비 고장은 원본, 후보, 승인, 알림을 잇는 업무 흐름을 요구한다.
- 테넌트, 트랜잭션, 이벤트와 외부 채널 경계는 ID와 함수 호출마다 다시 검증한다.
- 리뷰와 운영 증거를 작은 요구사항과 회귀 테스트로 되돌려 보내야 시스템이 진화한다.
“완성”은 더 이상 코드를 바꾸지 않는 상태가 아닙니다. 현재 계약이 무엇인지 설명할 수 있고, 대표 실패 경로를 테스트하며, 다음 변화가 왔을 때 어느 경계를 다시 검증해야 하는지 아는 상태에 가깝습니다. 병원 예약 SaaS의 첫 개발 주기는 여기서 닫히지만, 실제 운영이 만드는 다음 주기는 이미 시작됐습니다.
구현 코드와 자료 살펴보기
섹션 제목: “구현 코드와 자료 살펴보기”- 상태 이력 API 회고: 응답 DTO, 단일 트랜잭션과 결정적 정렬을 정리합니다.
- 취소 사유 회고: 하나의 실제 사유를 상태 머신, 이력과 이벤트에 전달합니다.
- 다중 테넌시 회고: URL과 JWT 이중 검증, 하위 자원 소유권, DB별 마이그레이션을 설명합니다.
- 테넌트 접근 검사기: 테넌트와 병원, 의사, 진료 유형, 장비 ID를 한 경계로 검증합니다.
- 리마인더 스케줄러: Redis 리더 선출과 알림 이력으로 중복 실행을 줄입니다.
- 복원력 알림 채널: CircuitBreaker, Retry, Bulkhead로 외부 채널 호출을 격리합니다.
- 예약 생성 서비스: 멱등 요청 저장과 동시 재시도 복구, 아직 남은 수용 인원 경쟁 조건을 확인할 수 있습니다.
- 프로젝트 백로그: 환자 포털과 비동기 메시징을 포함한 후속 기능 후보를 추적합니다.
- Timefold Solver 2 마이그레이션 회고: API 변화 속에서 기존 solver 의미를 보존한 검증을 기록합니다.
- Solver 벤치마크 보고서: 실행 환경, 입력 규모, 점수와 시간 기준선을 함께 제공합니다.
댓글
GitHub 계정으로 의견을 남기거나 reaction을 남길 수 있습니다.