Secret을 파일로 마운트하고 SPRING_CONFIG_IMPORT로 그 파일을 읽게 했다. Pod 안에서 cat을 해 보면 새 DB 주소가 정확히 들어 있었고, 환경변수도 제대로 박혀 있었다. 그런데 앱은 여전히 원래 DB에 붙었다. 에러는 하나도 없었다.
하나의 서비스를 여러 고객사가 각자 쓰도록 바꾸는 멀티 테넌트 SaaS 전환 프로젝트에서 겪은 일이다. 고객사마다 DB가 달라야 하는데, 앱의 DB 접속 정보는 소스 안 application-dev.yml에 들어 있었다. 앱은 다른 팀이 개발하고 있었고, 시작 스크립트에는 -Dspring.profiles.active=dev가 고정돼 있었다. 우리는 그 소스를 고칠 수 없었다. 이미지를 건드리지 않고, 밖에서 넣은 값이 application-dev.yml의 값을 이겨야 했다.
환경변수로 들어간 건 “이 파일도 읽어라”였다
처음 구조는 관리 화면에 입력한 설정 JSON을 Kubernetes Secret으로 만들고, Pod에 파일로 마운트한 뒤 환경변수로 그 파일을 읽게 하는 것이었다.
# 배포 매니페스트에 들어간 내용 (당시 방식)
env:
- name: SPRING_CONFIG_IMPORT
value: optional:file:/etc/secrets/db-group/config.yaml
volumeMounts:
- name: db-group-secret
mountPath: /etc/secrets/db-group
결과는 키마다 갈렸다. application-dev.yml에 없는 키는 적용됐고, 같은 키가 있는 키는 무시됐다. 바꾸고 싶었던 jdbcUrl이 바로 뒤쪽이었다.
SPRING_CONFIG_IMPORT는 OS 환경변수이고 환경변수는 설정 파일보다 우선하니 이긴다고 생각했다. 하지만 환경변수로 들어간 건 “이 파일도 읽어라”라는 지시 하나뿐이고, 파일 안의 키들은 환경변수가 되지 않는다. 읽힌 키는 설정 파일과 같은 층(Config data)으로 들어가 그 안에서 프로파일 파일과 경쟁한다.
우선순위는 두 겹이다
Spring Boot 문서의 Externalized Configuration 장은 PropertySource를 15단계로 나열하고, 뒤에 오는 것이 앞의 것을 덮는다. 필요한 부분만 추리면 이렇다.
| 순서 | PropertySource |
|---|---|
| 3 | Config data (application.yml, application-{profile}.yml, import한 파일) |
| 5 | OS 환경변수 |
| 6 | Java System properties |
| 10 | SPRING_APPLICATION_JSON |
| 11 | Command line arguments |
3번 안에도 순서가 있다. 우리 경우 import한 파일은 application-dev.yml보다 아래에 놓였다. 정확히 어느 칸인지를 문서 문장으로 단정하지는 않았고, 같은 키를 양쪽에 넣어 어느 값이 살아남는지 직접 확인한 결과를 기준으로 삼았다. SPRING_APPLICATION_JSON은 Config data 층 바깥인 10번에 있어서, 그 안의 순서와 상관없이 프로파일 파일도 import한 파일도 전부 이긴다.
SPRING_DATASOURCE_URL 같은 개별 환경변수도 5번이라 프로파일 파일은 이긴다. 다만 넣을 값이 관리 화면에서 받은 JSON 한 덩어리였다. 키마다 환경변수 이름으로 바꿔 늘어놓기보다 Secret의 키 하나를 환경변수 하나에 그대로 연결하는 쪽이 단순했다.
spec:
template:
spec:
containers:
- name: tenant-a-sp-back
env:
# ❌ 이전: 파일을 읽으라는 지시만 환경변수였다
# - name: SPRING_CONFIG_IMPORT
# value: optional:file:/etc/secrets/db-group/config.yaml
# ✓ 이후: JSON 자체가 환경변수로 들어간다
- name: SPRING_APPLICATION_JSON
valueFrom:
secretKeyRef:
name: tenant-a-sp-back-info # 앱 전용 Secret
key: config.yaml
JSON은 앱이 시작할 때 펼쳐져 Spring Environment에 들어간다. 준 키만 덮어쓰고, 주지 않은 형제 키는 yml 값이 그대로 남는다. jdbcUrl만 주면 username과 password는 application-dev.yml의 값을 쓴다. 비밀번호를 jasypt ENC(...) 형태로 넣어도 그대로 복호화됐다.
대가는 시작할 때 한 번만 읽는다는 것이다. 값을 바꾸면 Pod를 다시 띄워야 하고, Pod 안 파일이 보여 주는 값과 앱이 실제로 쓰는 값이 다를 수 있다. 앱은 Blue-Green으로 배포하고 있어서 재시작이 곧 서비스 중단은 아니었다. 값이 바뀐 앱만 골라 다시 띄우는 장치는 ESO를 빼고 설정 해시로 바뀐 앱만 재배포하기에 따로 적었다.
막힌 곳은 원리 바깥에 있었다
배포 모듈 19개의 DB 설정을 하나씩 맞춰 보니 키 경로가 앱마다 달랐다. datasource.pri.jdbcUrl 단일 풀인 앱, datasource.pri.hikari.read/write로 나뉜 앱, DataSource가 3개인 auth-back까지 있었다. SPRING_APPLICATION_JSON은 앱이 어떤 키를 읽는지 모른다. 엉뚱한 경로에 값을 넣어도 에러 없이 Environment에 한 줄 더 생길 뿐이다.
c2c-park-back은 저장소의 application-dev.yml이 spring.datasource.*를 쓰고 있었는데, 실제로 배포된 바이너리는 소스가 없는 jar 의존성의 설정 클래스가 datasource.pri.*를 읽고 있었다. 결국 datasource.pri의 jdbcUrl, driverClassName, username, password 네 개를 전부 줘야 떴다. 덮어쓸 키는 저장소의 설정 파일이 아니라 배포된 바이너리가 실제로 읽는 키여야 한다.
jdbc:log4jdbc:mysql://... 형태의 URL을 넣었을 때는 앱이 NPE로 죽었다. Spring과 Hikari는 jdbc:log4jdbc: 접두어에서 드라이버를 추론하지 못하고, 프로파일 파일이 앱이 읽는 경로에 driverClassName을 주고 있지 않으면 드라이버 클래스가 비어 버린다. 준 키만 덮어쓰니, yml에 원래 없던 키는 어디서도 오지 않는다.
{
"datasource": {
"pri": {
"jdbcUrl": "jdbc:log4jdbc:mysql://db.example.com:3306/db_b",
"driverClassName": "net.sf.log4jdbc.sql.jdbcapi.DriverSpy"
}
}
}
배치 모듈은 읽기용과 쓰기용 DataSource가 나뉘어 있어서 read와 write 둘 다, 각각 드라이버까지 넣었다. 한쪽만 바꾸면 읽기와 쓰기가 서로 다른 DB를 본다. 배치는 CronJob이라 실행할 때마다 새 Pod가 뜨니, Secret을 고쳐 두면 별도 재배포 없이 다음 실행부터 새 값을 읽는다.