온프레미스 제품을 단일 노드에 설치하려는 환경에서는 애플리케이션 하나를 실행하기 위해 PostgreSQL까지 운영하는 일이 부담이 될 수 있었습니다. 중앙에서 설정을 수정하고 여러 인스턴스가 공유해야 하는 환경에는 데이터베이스가 잘 맞았지만, 설정이 거의 바뀌지 않는 소규모 설치에는 같은 구성이 지나치게 무거웠습니다.
처음에는 기존 Repository에 isOnprem 조건을 추가하면 두 환경을 함께 지원할 수 있다고 생각했습니다. 조회 메서드 안에서 환경변수를 읽거나 PostgreSQL을 호출하도록 나누면 빠르게 기능을 붙일 수 있었지만, 조건문은 곧 startup lifecycle과 초기 데이터 생성, 수정 가능 여부까지 번졌습니다.
문제는 DB 연결이 필요 없는 profile에서도 JPA bean이 만들어지면서 시작에 실패할 수 있다는 점이었습니다. 반대로 조건을 피하려고 모듈을 통째로 복제하면 같은 도메인 규칙과 API가 두 갈래로 진화합니다. 결국 필요한 것은 두 제품이 아니라 같은 계약을 서로 다른 저장 방식으로 구현하는 두 adapter였습니다.
조건문으로 저장 방식을 나눌 때 흐려진 경계
DB 없는 모드를 기존 Repository 구현 안에 넣으면 처음 몇 개 메서드는 단순해 보입니다. find에서는 설정 파일을 읽고, 수정이 필요한 경로에서는 지원하지 않는다는 오류를 반환하면 됩니다. 그러나 어떤 메서드가 어느 모드에서 가능한지 서비스 계층까지 알아야 하는 순간 저장 방식의 차이가 도메인 규칙으로 올라옵니다.
초기화 순서도 같은 문제를 드러냈습니다. PostgreSQL 모드에서는 Flyway가 스키마를 준비한 뒤 초기 데이터를 확인해야 하지만, 경량 모드에는 migration 자체가 없어야 합니다. 하나의 runner가 두 환경을 모두 처리하려 하면 실행 조건과 의존 순서를 설명하기 어려워지고, 잘못된 설정은 애플리케이션이 뜬 뒤 첫 요청에서야 발견될 수 있습니다.
반대편 선택지는 별도 모듈이나 제품 fork였습니다. 각 환경은 독립적으로 만들 수 있지만 조회 규칙, not-found 처리, 정렬 순서가 조금씩 달라질 가능성이 생깁니다. 한쪽에서 버그를 수정할 때 다른 쪽에도 같은 변경을 옮겨야 하므로 배포 형태 하나를 추가하려다 유지할 제품이 둘이 됩니다.
도메인에는 저장 방식이 아니라 조회 계약만 남겼다
공통 서비스는 설정이 YAML에서 왔는지 PostgreSQL에서 왔는지 알 필요가 없었습니다. 특정 zone을 찾고 공개 가능한 zone 목록을 정해진 순서로 반환하면 충분했습니다. 그래서 도메인 쪽에는 ZoneCatalog 같은 작은 port만 남겼습니다.
interface ZoneCatalog {
fun find(zoneId: UUID): ZoneConfig?
fun listPublic(provider: Provider): List<ZoneConfig>
}
이 계약은 두 구현이 반드시 같은 의미를 가져야 한다는 기준이 됐습니다. 존재하지 않는 ID를 null로 반환할지 예외로 처리할지, 공개 목록을 어떤 순서로 정렬할지, 중복 ID를 허용할지 같은 동작을 interface 바깥의 암묵적인 관례로 두지 않았습니다.
DB 없는 profile은 검증된 설정을 시작할 때 한 번 읽어 메모리 index로 만듭니다. PostgreSQL profile은 repository를 통해 같은 모델을 반환합니다. 서비스는 port에만 의존하므로 실행 중에 if (onprem)을 반복하지 않고, 선택된 adapter가 해당 환경의 생명주기를 책임집니다. 초기화 완료 여부는 Kubernetes의 Startup과 Readiness Probe에도 같은 의미로 노출해야 했습니다.
Profile은 런타임 분기가 아니라 조립 시점의 선택이다
두 구현을 모두 bean으로 만든 뒤 메서드 안에서 하나를 고르면 DB 없는 환경에서도 JPA 초기화와 driver 설정이 필요해집니다. 따라서 어떤 구현을 사용할지는 요청 처리 시점이 아니라 Spring context를 조립할 때 결정했습니다.
@Profile("onprem")
@Bean
fun fileZoneCatalog(props: OnpremProperties): ZoneCatalog =
InMemoryZoneCatalog(validateAndIndex(props.zones))
@Profile("cloud")
@Bean
fun databaseZoneCatalog(repo: ZoneRepository): ZoneCatalog = JpaZoneCatalog(repo)
onprem profile에서는 데이터베이스와 관련된 bean이 아예 생성되지 않아야 합니다. DB URL이나 driver가 없는 상태로 전체 application context를 띄우는 테스트를 둔 이유입니다. 조건부 bean을 적용했더라도 다른 configuration이 JPA repository를 직접 참조하면 다시 연결을 시도할 수 있으므로, 개별 bean보다 context 전체를 확인해야 했습니다.
PostgreSQL profile에는 반대의 순서 제약이 있습니다. Flyway가 완료되기 전에 초기 데이터 runner가 실행되면 빈 스키마를 조회하거나 아직 존재하지 않는 테이블을 만납니다. migration 완료 이벤트 또는 명시적인 의존 순서를 사용해 스키마 준비와 초기화를 나누고, 트래픽은 두 단계가 끝난 뒤에만 받도록 했습니다.
시작 시점에 끝내는 DB 없는 설정 검증
경량 모드는 런타임 수정 기능을 제공하지 않고 설정을 불변 값으로 취급했습니다. 이 선택은 구현을 단순하게 만들지만 잘못된 값이 배포된 경우 재시작 전까지 고칠 수 없다는 뜻이기도 합니다. 그래서 누락된 값이나 잘못된 URL을 사용 시점까지 미루지 않고 startup validation에서 실패시켰습니다.
app:
mode: onprem
zones:
- id: 2a9c...
provider: PRIVATE
storages:
- internal-url: http://storage:8080
public-url: https://storage.example.com
설정은 객체로 변환한 뒤 URL, UUID, 최소 storage 수를 검사합니다. 특히 zone ID가 중복된 상태에서 바로 Map으로 만들면 뒤의 값이 앞의 값을 조용히 덮을 수 있으므로, index를 만들기 전에 중복을 오류로 처리했습니다. 내부 URL과 외부 URL도 문자열이 비어 있지 않은지만 보지 않고 애플리케이션이 기대하는 형식으로 파싱되는지 확인했습니다.
오류 메시지에는 잘못된 설정 키와 위치를 남기되 비밀 값은 포함하지 않습니다. 운영자가 로그만 보고 어느 항목을 고쳐야 하는지는 알아야 하지만, 설정 전체를 출력해 credential이 노출돼서는 안 됩니다. 설정 변경은 재시작 후 반영된다는 동작도 운영 계약으로 명시했습니다.
두 Adapter를 묶는 Contract Test
메모리 구현과 데이터베이스 구현이 같은 interface를 구현한다고 해서 자동으로 같은 동작을 하지는 않습니다. 메모리의 Map 조회는 자연스럽게 한 건을 반환하지만, 데이터베이스에는 중복 행이나 정렬 조건 누락 같은 별도 실패 가능성이 있습니다. 반대로 데이터베이스가 보장하던 제약을 YAML 검증에서 빠뜨릴 수도 있습니다.
그래서 port의 동작을 테스트하는 공통 contract suite를 만들고 두 adapter에 그대로 실행했습니다. 정상 ID 조회, 존재하지 않는 ID, 공개 목록 필터, 안정적인 정렬, 중복 설정 거부를 같은 기대값으로 검증했습니다. 구현별 테스트는 YAML 파싱이나 실제 repository 쿼리처럼 adapter 내부의 관심사만 추가했습니다.
빈 데이터베이스에서 처음 시작하는 경우와 기존 스키마를 업그레이드하는 경우도 따로 확인했습니다. 경량 profile은 DB driver와 URL이 전혀 없는 조건에서 context가 올라와야 했습니다. 이 두 시작 시나리오를 함께 테스트해야 개발 환경의 우연한 의존성이 제품 모드의 요구사항으로 굳는 일을 막을 수 있었습니다.
서비스 곳곳에 isOnprem 분기를 넣는 방식은 당장은 빠르지만 저장 방식이 도메인까지 번집니다. 제품을 별도로 fork하면 환경은 독립적이어도 규칙과 수정 사항을 계속 두 벌로 관리해야 합니다. 공통 계약은 유지하면서 조립 시점에 구현만 교체하는 port와 profile adapter가 두 비용을 가장 예측 가능한 범위로 제한했습니다.
port와 adapter 방식도 공짜는 아닙니다. interface와 변환 계층이 생기고, 두 구현에 같은 contract test를 유지해야 합니다. 설정을 런타임에 수정해야 하는 요구가 추가되면 불변 메모리 구현의 한계도 다시 검토해야 합니다.
그럼에도 이 비용은 조건문이 여러 계층으로 퍼지거나 제품을 두 벌 유지하는 비용보다 예측하기 쉬웠습니다. 변하는 지점을 저장 방식과 startup lifecycle에 한정하면 도메인 규칙을 한곳에서 수정할 수 있고, 새로운 배포 형태가 추가돼도 공통 서비스의 계약은 흔들리지 않습니다.
Profile별 시작 조건을 보는 운영 점검
경량 모드는 잘못된 설정을 즉시 거부하는지가 가장 중요합니다. 시작 성공 여부뿐 아니라 어떤 키가 왜 잘못됐는지, 중복 ID가 조용히 덮이지 않는지, URL과 UUID가 기대한 형식인지 확인합니다. 설정 파일이 바뀌어도 재시작 전에는 반영되지 않는다는 점도 배포 절차에 포함했습니다.
PostgreSQL 모드는 migration과 초기화의 순서를 관찰합니다. 빈 데이터베이스 시작, 기존 버전 업그레이드, 연결 실패를 각각 재현하고 준비가 끝나기 전에 요청을 받지 않는지 확인합니다. 선택되지 않은 profile의 bean 때문에 불필요한 연결이 생기지 않는지도 context 테스트로 계속 고정합니다.
공통 지표를 억지로 늘리기보다 각 모드의 실패 지점을 직접 보여주는 신호를 남겼습니다. 경량 모드에서는 설정 검증 실패와 로딩한 항목 수가 중요하고, DB 모드에서는 migration과 repository 초기화 상태가 중요합니다. 서로 다른 생명주기를 같은 dashboard 모양에 맞추는 것보다 시작 실패의 원인을 바로 설명할 수 있어야 했습니다.
저장 방식과 도메인 계약을 분리한 결과
DB 없는 온프레미스 모드를 추가하면서 가장 먼저 버린 것은 “기존 Repository에 조건 하나만 넣으면 된다”는 생각이었습니다. 저장 방식의 차이는 조회 코드 한 줄에 머물지 않고 bean 생성, migration, 초기 데이터, 수정 가능 여부와 설정 검증까지 연결됐습니다.
해결의 핵심은 도메인과 API 계약을 공유하면서 조립 시점에 adapter를 고르는 것이었습니다. 경량 모드는 검증된 불변 설정을 메모리에서 제공하고, PostgreSQL 모드는 migration이 끝난 영속 저장소를 사용합니다. 두 구현에 같은 contract test를 적용해 환경에 따라 의미가 달라지지 않게 했습니다.
Profile은 곳곳의 조건문을 감추는 이름이 아니라 명시적인 제품 모드여야 합니다. 각 모드가 필요로 하는 의존성과 시작 조건을 분리해야, 데이터베이스가 없는 환경은 정말 데이터베이스 없이 시작하고 중앙 관리가 필요한 환경은 PostgreSQL의 장점을 그대로 사용할 수 있습니다.
답글 남기기