Spring Cloud Config는 왜 Git을 설정 저장소로 쓰는가
12-Factor Config 원칙부터 PropertySource 우선순위, RefreshScope 프록시 메커니즘, 암호화, 고가용성까지 — Spring Cloud Config Server의 설계 결정을 추적한다.
- 01 Spring Cloud Config는 왜 Git을 설정 저장소로 쓰는가
- 02 Eureka는 왜 AP를 선택했는가
- 03 분산 추적은 어떻게 서비스 경계를 넘는가
- 04 Spring Cloud LoadBalancer는 어떻게 서비스 이름을 IP:Port로 바꾸는가
- 05 Circuit Breaker는 어떻게 연쇄 장애를 막는가
- 06 Spring Cloud Gateway는 왜 Reactive 기반일까
- 07 MSA의 데이터 문제는 어떻게 푸는가
Spring Cloud Config Server는 Git 저장소를 설정 데이터베이스로 쓴다. 왜 DB가 아닌 Git인가? 그리고 수십 개의 마이크로서비스가 하나의 Config Server에 의존하는 구조에서, Config Server가 잠깐 다운되면 무슨 일이 생기는가?
코드에서 설정을 분리해야 하는 이유
12-Factor App의 세 번째 원칙은 간단하다 — “코드베이스와 설정을 완전히 분리하라.” 판단 기준도 명확하다. “이 값을 오픈소스로 공개해도 문제없는가?” 답이 No라면 Config로 분리해야 한다.
단일 서비스 시절에는 application-prod.yml을 Git에 넣는 방식이 그나마 관리 가능했다. 서비스가 수십 개로 늘어나면 상황이 달라진다. DB 커넥션 풀 크기 하나를 바꾸려면 서비스 N개의 yml을 수정하고 N번 배포해야 한다. 어떤 서비스가 어떤 설정값을 쓰는지 아무도 모르는 상태에 이른다. 더 심각한 문제는 password: SuperSecret123!이 Git 히스토리에 영구 기록된다는 것이다. 퇴사한 직원도 조회할 수 있고, 실수로 public 저장소에 push하면 수분 내에 탐지된다.
Config Server는 이 세 가지 문제를 한 번에 겨냥한다. 설정을 중앙화하고, 민감 정보를 암호화하며, 재배포 없이 설정을 바꿀 수 있게 한다.
PropertySource 우선순위 — 어떤 값이 이기는가
Spring은 설정 소스를 PropertySource 추상화로 균일하게 다룬다. Environment.getProperty(key)는 PropertySource 스택을 순서대로 탐색하다 처음 찾은 값을 반환한다. 높은 순서의 소스가 낮은 소스를 덮어쓴다.
MutablePropertySources (높은 우선순위 → 낮은 우선순위):
[0] systemProperties // JVM -D 옵션
[1] systemEnvironment // OS 환경 변수
[2] Config Server 설정
└── service-a-prod.yml // 가장 구체적
└── service-a.yml
└── application-prod.yml
└── application.yml // 가장 일반적
[3] 로컬 application-prod.yml
[4] 로컬 application.yml
Config Server의 EnvironmentController가 GET /service-a/prod 요청을 받으면, JGitEnvironmentRepository가 Git 저장소에서 해당 파일들을 체크아웃하고 NativeEnvironmentRepository가 yml을 파싱해 PropertySource 목록으로 조립한다. 클라이언트가 시작할 때 이 응답이 로컬 application.yml보다 높은 우선순위로 Environment에 삽입된다.
결과적으로 service-a-prod.yml의 spring.datasource.url이 application-prod.yml의 같은 키를 덮어쓴다. 더 구체적인 파일이 이기는 규칙이다.
{application}-{profile}.yml → {application}.yml → application-{profile}.yml → application.yml. Config Server는 네 파일을 모두 로드한 뒤 이 순서로 PropertySource를 배치한다. 동일 키는 앞쪽이 이긴다.
@RefreshScope — 재배포 없는 설정 갱신
운영 중 Circuit Breaker 임계값을 50%에서 70%로 올려야 한다고 하자. Config Server 없이는 코드 수정 → commit → CI/CD → 배포 완료까지 최소 15분이다. Config Server가 있으면 Git 파일 수정 → POST /actuator/refresh 호출로 약 1분이다.
그런데 일반 @Service Bean에 @Value로 설정값을 주입했다면 refresh를 호출해도 필드 값이 바뀌지 않는다. @Value는 Bean 생성 시점에 한 번 주입되고 끝이기 때문이다.
@RefreshScope는 이 문제를 CGLIB 프록시로 해결한다. Spring은 @RefreshScope Bean을 직접 주입하는 대신 프록시를 주입한다. 프록시는 메서드 호출이 올 때마다 RefreshScope 캐시에서 실제 Bean을 조회해 위임한다.
// @RefreshScope Bean의 실제 동작
public class CircuitBreakerConfigService$$EnhancerByCGLIB {
public int getThreshold() {
// 매번 RefreshScope 캐시에서 실제 Bean 조회
CircuitBreakerConfigService real = scope.get(beanName, ...);
return real.getThreshold();
}
}
POST /actuator/refresh가 호출되면 ContextRefresher.refresh()가 실행된다. ① Config Server를 재호출해 새 PropertySources로 Environment를 교체하고, ② EnvironmentChangeEvent를 발행해 @ConfigurationProperties를 재바인딩하고, ③ RefreshScope.refreshAll()로 Bean 캐시를 비운다. 다음 메서드 호출 시 캐시 miss → 새 Bean 생성 → 새 @Value 주입이 일어난다.
@RefreshScope Bean이 내부 상태(카운터, 캐시 맵)를 가지면 refresh 시 초기화된다. 상태는 별도 Singleton Bean에 두고, 설정만 읽는 Bean에만 @RefreshScope를 적용하라.
{cipher} — Git에 암호화된 값을 저장하는 방법
민감한 값을 Git에 평문으로 넣으면 안 된다. Config Server는 {cipher} 접두사로 이 문제를 해결한다.
# config-repo/service-a-prod.yml
spring:
datasource:
password: '{cipher}AQBhBhJ4...(암호화된 값)...'
암호화된 값 생성은 간단하다.
curl -X POST http://config-server:8888/encrypt -d "MyDatabasePassword123"
# 응답: AQBhBhJ4Xk9...
Config Server가 클라이언트에 응답할 때 EnvironmentEncryptorDecorator가 {cipher} 접두사를 감지하고 복호화한다. 클라이언트는 평문 값을 받는다. Git 저장소에는 암호화된 값만 존재하므로, 저장소 접근 권한이 있어도 복호화 키 없이는 평문을 알 수 없다.
대칭키(AES)는 ENCRYPT_KEY 환경 변수 하나로 설정된다. 같은 평문을 두 번 암호화해도 결과가 다른데, 이는 매번 다른 IV를 사용하기 때문이다. Rainbow Table 공격 방어를 위한 의도된 설계다. 비대칭키(RSA)를 쓰면 암호화(공개키)와 복호화(개인키) 권한을 분리할 수 있어, 개발자가 암호화된 값을 생성해 Git에 커밋할 수 있지만 복호화는 Config Server만 할 수 있다.
트레이드오프
Config Server가 해결하는 것: 설정 중앙화, 재배포 없는 갱신, 암호화, 변경 이력(Git 히스토리). Config Server가 새로 만드는 문제: Config Server 자체가 모든 서비스의 공통 의존성 — 즉, 단일 장애점(SPOF)이 된다.
Config Server는 본질적으로 Stateless다 — “Git 저장소를 읽어서 HTTP로 응답하는 서버.” 상태가 없으므로 다중 인스턴스를 띄워도 동기화 문제가 없다. 단, 모든 인스턴스가 같은 Git URI를 바라봐야 하고, 암호화 키(ENCRYPT_KEY)가 동일해야 한다. 키가 다르면 인스턴스 A가 암호화한 값을 인스턴스 B가 복호화하지 못한다.
클라이언트 fail-fast: true와 retry 설정은 일시적 네트워크 오류를 방어하지만 지속적 장애에는 무력하다. Git 서버 장애에 대비해서는 basedir을 영구 볼륨에 마운트해 로컬 clone 캐시를 보존하는 것이 최후의 방어선이다. Git fetch가 실패해도 마지막으로 성공한 캐시에서 설정을 응답할 수 있다.
spring:
cloud:
config:
server:
git:
basedir: /var/config-repo-cache # 영구 볼륨 마운트
force-pull: false # fetch 실패 시 로컬 캐시 허용
refresh-rate: 30 # 30초 캐시 (Git 서버 부하 감소)
정리
- 12-Factor Config 원칙의 판단 기준은 하나다 — 오픈소스로 공개해도 문제없는 값만 코드에 있어도 된다.
- Config Server의 PropertySource 우선순위는 구체적인 것이 일반적인 것을 덮는다.
{application}-{profile}.yml이 최우선이다. @RefreshScope는 CGLIB 프록시를 통해 Bean 재생성 없이 설정 갱신을 가능하게 한다. 내부 상태가 있는 Bean에는 적용하지 마라.{cipher}접두사로 Git에 암호화된 값만 저장하고, 복호화 키는 Config Server 환경 변수로만 보유하라.- Config Server는 Stateless이므로 다중화가 단순하다. Git 캐시 영구화로 Git 서버 장애에도 버티는 구조를 만들어라.
다음 글에서는 서비스가 늘어날수록 복잡해지는 또 다른 문제 — “서비스가 서로를 어떻게 찾는가” — 를 Eureka Service Discovery와 함께 추적한다.