리액티브 스택(Spring Data R2DBC + MariaDB)으로 도는 배치 서비스의 DB 자격증명을 정적 비밀번호에서 AWS Secrets Manager 동적 조회로 옮겨야 했습니다. DB 비밀번호가 일정 주기로 자동 로테이션되도록 설정돼 있어서, 앱에 넣어 둔 정적 비밀번호로는 로테이션 직후 접속이 실패합니다. 같은 회사의 JDBC 서비스는 aws-secretsmanager-jdbc 드라이버로 이미 같은 secret을 읽고 있었는데, 그 방식을 R2DBC로 그대로 옮기려다 막혔습니다. 무엇이 막혔고 어떻게 우회했는지를 정리합니다.
1. 배경: 왜 Secrets Manager가 필요했나
DB 비밀번호는 일정 주기마다 로테이션 Lambda로 교체됩니다. 이렇게 비밀번호가 주기적으로 바뀌는 환경에서는 앱이 비밀번호를 직접 저장해 두면 안 됩니다. 로테이션이 일어나는 순간 앱이 가진 값은 옛 비밀번호가 되고, 그 값으로 새 connection을 맺으려 하면 인증이 실패합니다.
해법은 자격증명을 한곳(여기서는 AWS Secrets Manager)에 두고 앱이 접속할 때마다 최신 값을 읽어 오는 것입니다. JDBC 진영에는 이걸 대신해 주는 공식 드라이버가 있습니다. aws-secretsmanager-jdbc는 JDBC URL의 secret 이름으로 자격증명을 조회하고, 1시간 캐시와 인증 실패 시 재조회까지 처리합니다. 같은 회사의 JDBC 기반 서비스는 이미 이 드라이버로 같은 secret을 쓰고 있었습니다.
문제는 이번 대상이 JDBC가 아니라 R2DBC라는 점이었습니다.
참고: IAM 데이터베이스 인증으로 비밀번호 자체를 없애는 선택지도 있었습니다. 다만 기존 JDBC 서비스가 쓰던 Secrets Manager 비밀번호 방식과 로테이션 Lambda를 그대로 재사용하는 편이 추가 작업이 적다고 보고, 같은 방식을 R2DBC로 옮기기로 했습니다.
2. 막힌 지점: R2DBC에는 공식 통합이 없다
R2DBC로 옮기려고 보니 두 가지가 차례로 막혔습니다.
첫째, aws-secretsmanager-jdbc에 대응하는 R2DBC 공식 드라이버나 통합이 없습니다. spring-cloud-aws 쪽 논의(awspring Discussion #14)에서도 R2DBC용 Secrets Manager 통합은 제공하지 않는다고 확인됩니다. JDBC에서는 드라이버가 알아서 해 주던 일을 R2DBC에서는 직접 해야 한다는 뜻입니다.
둘째, 드라이버 자체에 동적 password를 끼워 넣을 후크가 없었습니다. R2DBC 드라이버 중 r2dbc-postgresql은 password로 Supplier를 받아 connection을 맺을 때마다 비밀번호를 다시 평가하게 할 수 있습니다. 그런데 이번 대상은 MariaDB였고, r2dbc-mariadb 1.1.3의 연결 설정은 password를 CharSequence로만 받습니다(드라이버 소스 확인). Supplier나 Publisher는 받지 못하니, 드라이버가 제공하는 동적 비밀번호 경로를 쓸 수 없었습니다.
정리하면 이렇습니다.
JDBC : aws-secretsmanager-jdbc 드라이버가 secret 조회·캐시·재조회 전부 처리
R2DBC + PG : 공식 통합은 없지만 r2dbc-postgresql의 password=Supplier 후크로 우회 가능
R2DBC + MariaDB : 공식 통합도 없고, 드라이버 password가 CharSequence 전용 → 후크도 없음 ← 여기가장 제약이 많은 조합이라, 자격증명을 주입하는 지점을 드라이버 바깥에서 직접 만들기로 했습니다.
3. 해결: connection 생성마다 자격증명을 주입하는 커스텀 ConnectionFactory
R2DBC에서 실제로 DB connection을 만드는 주체는 ConnectionFactory입니다. 드라이버 안에 후크가 없다면, ConnectionFactory를 감싸서 connection을 만들기 직전에 최신 자격증명을 끼워 넣으면 됩니다. 이 wrapper를 두고, 그 안에서 Secrets Manager 조회·캐시를 담당하는 provider를 호출하도록 구성했습니다.
| 구성요소 | 역할 |
|---|---|
SecretsManagerR2dbcConfig |
진입점. SM 클라이언트 → provider → ConnectionFactory(풀)를 조립한다. prod profile에서만 활성화 |
DbSecretCredentialsProvider |
Secrets Manager 조회·1시간 캐시·캐시 무효화 |
SecretRefreshingConnectionFactory |
connection 생성 시점마다 자격증명을 주입, 인증 실패 시 캐시 무효화 후 재시도 |
DbSecretCredentials |
secret JSON에서 꺼낸 username/password 레코드 |
secret 이름을 어디에 담을까 — username 필드 재사용
JDBC 서비스의 관례를 그대로 옮겼습니다. aws-secretsmanager-jdbc는 spring.datasource.username에 secret 이름을 담고 password 필드는 두지 않습니다. R2DBC 구성도 똑같이 spring.r2dbc.username을 secret 이름으로 해석하고 spring.r2dbc.password는 제거했습니다. host/port/database는 spring.r2dbc.url에서, 진짜 username/password는 secret JSON에서 가져옵니다.
spring.r2dbc.url=r2dbc:mariadb://<host>:3306/<db>?characterEncoding=UTF-8
spring.r2dbc.username=prod/app/mariadb # secret 이름 (실제 username 아님)
# spring.r2dbc.password 제거 — secret에서 가져온다자격증명 주입 지점
SecretRefreshingConnectionFactory의 핵심은 create()입니다. 풀이 새 connection을 요청할 때마다 provider에서 최신 자격증명을 읽어 delegate ConnectionFactory를 구성합니다.
private synchronized ConnectionFactory buildDelegate() {
// 자격증명이 그대로면 delegate를 재사용하고, 로테이션으로 바뀌면 재생성한다.
DbSecretCredentials credentials = credentialsProvider.get();
if (cachedDelegate == null || !credentials.equals(cachedCredentials)) {
ConnectionFactoryOptions options =
baseOptions
.mutate()
.option(ConnectionFactoryOptions.USER, credentials.username())
.option(ConnectionFactoryOptions.PASSWORD, credentials.password())
.build();
cachedDelegate = ConnectionFactories.get(options); // ← 여기서 비밀번호가 들어간다
cachedCredentials = credentials;
}
return cachedDelegate;
}baseOptions는 spring.r2dbc.url을 파싱한 접속 옵션(host/port/database/driver)이고, 여기에 secret에서 꺼낸 USER/PASSWORD만 얹어 delegate를 만듭니다. 매 호출마다 드라이버를 새로 만들지 않도록, 자격증명이 그대로면 기존 delegate를 재사용하고 값이 바뀐 경우에만 다시 만듭니다.
커스텀 ConnectionFactory 빈을 직접 등록하면 Spring Boot의 R2DBC 자동구성이 비활성화되어 spring.r2dbc.pool.* 설정이 더 이상 적용되지 않습니다. 그래서 풀 설정은 코드에서 직접 지정했습니다. 풀 크기는 기본값을 따르되, maxLifeTime만 30분으로 명시해 오래된 connection이 옛 비밀번호로 오래 남지 않고 비교적 빨리 교체되도록 했습니다.
4. 리액티브라서 더 신경 쓴 것: 블로킹 격리
여기까지는 자격증명 주입의 문제였고, 리액티브 스택에서는 한 가지를 더 따져야 했습니다. AWS SDK v2의 SecretsManagerClient는 동기 클라이언트라, getSecretValue()가 호출 스레드를 멈춥니다. 이 호출이 Reactor의 event loop(Netty) 스레드에서 일어나면 event loop가 블로킹됩니다.
그래서 Secrets Manager 조회는 boundedElastic 스케줄러로 격리했습니다.
private Mono<Connection> openConnection() {
// SM 조회·캐시 접근은 blocking이므로 event loop가 아닌 스레드에서 수행한다.
return Mono.fromSupplier(this::buildDelegate)
.subscribeOn(Schedulers.boundedElastic())
.flatMap(delegate -> Mono.from(delegate.create()));
}블로킹이 생길 만한 경로가 한 군데 더 있었습니다. ConnectionFactory.getMetadata()입니다. Spring Data R2DBC는 어떤 방언(dialect)을 쓸지 정할 때 getMetadata().getName()을 동기로 호출합니다(소스 확인). 여기서 자격증명 provider를 호출하면, dialect 해석 과정에서 블로킹 Secrets Manager 호출이 event loop에서 일어날 수 있습니다.
metadata는 드라이버 종류(예: "MariaDB")만 알면 되고 실제 접속이나 자격증명은 필요 없습니다. 그래서 metadata 경로에서는 Secrets Manager를 부르지 않고, placeholder 자격증명으로 delegate를 만들어 정적 metadata만 읽도록 했습니다.
@Override
public ConnectionFactoryMetadata getMetadata() {
if (metadata == null) {
ConnectionFactoryOptions metadataOptions =
baseOptions
.mutate()
.option(ConnectionFactoryOptions.USER, "metadata") // placeholder
.option(ConnectionFactoryOptions.PASSWORD, "metadata") // SM 호출도 DB 접속도 없음
.build();
metadata = ConnectionFactories.get(metadataOptions).getMetadata();
}
return metadata;
}5. 로테이션 대응: 캐시 + 인증 실패 시 무효화·재시도
자격증명을 connection마다 Secrets Manager에서 새로 읽으면 호출이 너무 잦아집니다. 그래서 provider는 조회 결과를 1시간 캐시합니다(JDBC 드라이버 기본값과 동일하게 맞춘 값). 다만 이 캐시 TTL은 로테이션 주기에서 유도한 값이 아닙니다. 고정 TTL일 뿐이고, 로테이션이 캐시 만료보다 먼저 일어나면 캐시에는 한동안 옛 비밀번호가 남습니다.
실제 로테이션 대응은 TTL이 아니라 인증 실패를 신호로 삼습니다. 옛 비밀번호로 새 connection을 맺으려다 인증이 실패하면, 캐시를 무효화하고 한 번 재시도합니다. 재시도에서 provider가 Secrets Manager를 다시 조회해 최신 비밀번호로 접속합니다. AWS Prescriptive Guidance가 정리한 fetch-on-failure 패턴입니다.
@Override
public Publisher<? extends Connection> create() {
return openConnection()
.onErrorResume(
this::isAuthFailure, // SQLSTATE class 28 = 인증 실패
e -> {
log.warn("DB authentication failed; invalidating credential cache and retrying once", e);
credentialsProvider.invalidate();
return openConnection(); // 캐시 무효화 후 최신 비밀번호로 재접속
});
}인증 실패를 가려내는 기준은 SQLSTATE class 28(invalid authorization specification)입니다. MariaDB의 access denied(1045)가 여기에 해당합니다. 네트워크 오류나 다른 SQL 오류까지 함께 재시도하지 않도록, 인증 실패 계열만 좁혀서 캐시를 비웁니다.
private boolean isAuthFailure(Throwable t) {
if (t instanceof R2dbcException r2dbcException) {
String sqlState = r2dbcException.getSqlState();
return sqlState != null && sqlState.startsWith("28");
}
return false;
}부팅 로그로 prod에서 다음 세 가지를 확인했습니다. prod 구성이 로드돼 자격증명 출처가 Secrets Manager로 잡히는 것, secret 조회가 boundedElastic 스레드에서 실행돼 event loop를 막지 않는 것, 컨텍스트가 정상 기동하는 것입니다. 로테이션 직후의 인증 실패·재시도 경로는 다음 자동 로테이션 시점의 로그에서 확인하기로 남겨 두었고, 강제 로테이션을 걸어 검증하는 데까지는 하지 못했습니다.
6. 마무리
JDBC에서 드라이버 한 줄로 끝나던 일이 R2DBC에서는 직접 만들어야 하는 일이 됐습니다. 같은 "Secrets Manager에서 DB 비밀번호 읽기"라도, 드라이버가 그 후크를 제공하는지에 따라 작업량이 크게 갈립니다. 게다가 R2DBC는 드라이버마다 동적 password 지원 여부가 달라서(r2dbc-postgresql은 Supplier를 받지만 r2dbc-mariadb는 받지 않음), 같은 R2DBC라도 DB가 무엇이냐에 따라 우회 방법이 달라졌습니다.
리액티브 스택에서 외부 호출을 끼워 넣을 때는, 그 호출이 동기(블로킹)인지부터 확인하는 게 먼저였습니다. Secrets Manager SDK가 동기라는 사실 하나 때문에 자격증명 조회 경로(create)와 dialect 해석 경로(getMetadata) 양쪽에서 event loop 블로킹을 따로 막아야 했습니다. 기능을 붙이기 전에 "이 호출이 event loop를 멈추는가"를 먼저 묻는 습관이 리액티브에서는 특히 중요하다는 걸 다시 확인했습니다.
참고 자료
- awspring/spring-cloud-aws Discussion #14 — R2DBC용 Secrets Manager 공식 통합 부재
- aws/aws-secretsmanager-jdbc — JDBC 측 캐시(기본 1시간)·인증 실패 재조회
- AWS Prescriptive Guidance — Rotate database credentials without restarting containers — fetch-on-failure 패턴