스프링 WebFlux 데이터베이스
스프링 WebFlux 데이터베이스 3 - 스프링 WebFlux R2DBC 다중 연결 (multi DB)
https://youtu.be/5KqfP5wtWmU?si=ZCDiUoiVDNQm88zH
스프링 WebFlux 데이터베이스 3 - 스프링 WebFlux R2DBC 다중 연결 (multi DB)
- 스프링 WebFlux 데이터베이스 3 - 스프링 WebFlux R2DBC 다중 연결 (multi DB)
- Spring WebFlux에서 R2DBC로 여러 MySQL 데이터베이스 연결하기
- R2DBC 다중 연결이란?
- 단일 데이터베이스와 다중 데이터베이스의 차이
- 왜 application.properties만으로 구성하기 어려울까?
- 이번 구성에서 사용할 두 Database
- 데이터베이스별 패키지를 분리하기
- 패키지를 분리하는 이유
- FirstEntity 작성
- FirstEntityRepository 작성
- SecondEntity 작성
- SecondEntityRepository 작성
- 첫 번째 Database Config 작성
- @EnableR2dbcRepositories
- entityOperationsRef의 역할
- 첫 번째 ConnectionFactory Bean 만들기
- @Qualifier가 필요한 이유
- 첫 번째 EntityOperations 등록
- 첫 번째 TransactionManager 등록
- 첫 번째 Database Config 전체 구조
- 두 번째 Database Config 작성
- 두 번째 ConnectionFactory
- 두 번째 EntityOperations
- 두 번째 TransactionManager
- @Primary가 필요한 이유
- @Primary와 @Qualifier 차이
- 전체 Bean 구성
- Repository와 Config의 연결 관계
- 왜 Repository 패키지 구분이 중요한가?
- 두 데이터베이스의 데이터 조회하기
- Flux로 데이터 조회
- 조회 결과를 로그로 확인하는 구조
- 두 데이터베이스 결과를 하나의 HTTP 응답으로 결합한다면
- 하지만 두 Database를 조회한다고 하나의 Transaction이 되는 것은 아니다
- Database별 Transaction 경계
- 다중 DB에서 TransactionManager를 분리하는 이유
- application.properties에 연결 정보를 어떻게 관리할까?
- 운영 환경에서 Credential을 분리해야 하는 이유
- @ConfigurationProperties를 Database별로 사용하는 구조
- Connection Pool도 Database별로 고려해야 한다
- 하나의 MySQL 서버에서 Database만 두 개인 경우
- 서로 다른 MySQL 서버도 연결할 수 있다
- Docker 환경에서 구성한다면
- 다중 Database 구성에서 자주 발생하는 문제
- 패키지 이름을 명확하게 관리하기
- 비즈니스 목적에 따른 이름 사용하기
- Service 계층에서 두 Repository 사용하기
- Reactive 흐름 유지하기
- 두 Database 결과를 조합하는 예시
- 다중 Connection과 MSA를 혼동하지 않기
- 언제 다중 데이터베이스 연결이 필요할까?
- 다중 DB가 늘어날수록 복잡도가 증가한다
- 다중 DB 구조의 핵심 설계 포인트
- 전체 구조
- 실행 확인
- 단일 R2DBC와 다중 R2DBC 비교
- 실무에서의 활용
- 정리
Spring WebFlux에서 R2DBC로 여러 MySQL 데이터베이스 연결하기
하나의 Spring WebFlux 애플리케이션이 항상 하나의 데이터베이스만 사용하는 것은 아니다.
서비스가 커지거나 데이터의 역할이 분리되면 하나의 애플리케이션에서 두 개 이상의 데이터베이스에 접근해야 하는 상황이 발생할 수 있다.
예를 들어 다음과 같이 두 개의 MySQL Database가 존재한다고 가정해보자.
MySQL Server
├── first_db
│ └── first_entity
│
└── second_db
└── second_entity
Spring WebFlux 애플리케이션에서는 두 데이터베이스를 각각 R2DBC로 연결하여 서로 다른 Repository가 자신의 데이터베이스를 사용하도록 구성할 수 있다.
전체 구조는 다음과 같다.
flowchart TD
A[Spring WebFlux Application]
A --> F[First DB Repository]
A --> S[Second DB Repository]
F --> FC[First ConnectionFactory]
S --> SC[Second ConnectionFactory]
FC --> FDB[(first_db)]
SC --> SDB[(second_db)]
단일 데이터베이스에서는 Spring Boot의 자동 설정을 이용해 비교적 간단하게 연결할 수 있었다.
spring:
r2dbc:
url: r2dbc:mysql://localhost:3306/first_db
username: root
password: password
하지만 데이터베이스가 두 개 이상으로 늘어나면 어떤 Repository가 어떤 ConnectionFactory를 사용해야 하는지 명확하게 구분해야 한다.
따라서 다중 R2DBC 연결에서는 Java Config 클래스를 이용해 데이터베이스별 ConnectionFactory, Entity Operation, TransactionManager 등을 명시적으로 구성하는 방식이 중요해진다.
R2DBC 다중 연결이란?
R2DBC 다중 연결은 하나의 Spring WebFlux 애플리케이션에서 두 개 이상의 관계형 데이터베이스에 Reactive 방식으로 접근하는 구조를 의미한다.
예를 들어 다음 두 Database를 사용한다고 가정한다.
first_db
second_db
각각에는 서로 다른 테이블이 존재한다.
first_db
└── first_entity
second_db
└── second_entity
애플리케이션에서는 각각에 대응되는 Repository를 만든다.
FirstEntityRepository
SecondEntityRepository
그리고 Repository가 서로 다른 Database Connection을 사용하도록 연결해야 한다.
FirstEntityRepository
↓
First ConnectionFactory
↓
first_db
SecondEntityRepository
↓
Second ConnectionFactory
↓
second_db
이 구조가 다중 데이터베이스 연결의 핵심이다.
단일 데이터베이스와 다중 데이터베이스의 차이
단일 R2DBC 연결에서는 구조가 매우 단순하다.
Application
↓
ConnectionFactory
↓
Database
Repository 역시 하나의 ConnectionFactory를 사용한다.
Repository A ─┐
Repository B ─┼→ ConnectionFactory → MySQL
Repository C ─┘
하지만 두 개의 Database가 존재한다면 상황이 달라진다.
Repository A
↓
ConnectionFactory A
↓
Database A
Repository B
↓
ConnectionFactory B
↓
Database B
Spring이 어떤 Repository가 어떤 ConnectionFactory를 사용해야 하는지 알아야 한다.
따라서 Repository Scan 범위와 Connection 설정을 데이터베이스별로 구분해야 한다.
왜 application.properties만으로 구성하기 어려울까?
단일 연결에서는 다음 설정만으로 Database Connection을 구성할 수 있다.
spring.r2dbc.url=r2dbc:mysql://localhost:3306/first_db
spring.r2dbc.username=root
spring.r2dbc.password=password
Spring Boot가 이 정보를 기반으로 기본 ConnectionFactory를 자동 생성한다.
spring.r2dbc.*
↓
Spring Boot Auto Configuration
↓
ConnectionFactory
↓
first_db
하지만 데이터베이스가 두 개라면 다음과 같은 두 연결이 필요하다.
ConnectionFactory A
→ first_db
ConnectionFactory B
→ second_db
단순한 하나의 기본 spring.r2dbc.* 연결만으로는 두 Repository 영역을 명확하게 구분하기 어렵다.
따라서 데이터베이스별 Config 클래스를 만들어 Connection을 직접 정의한다.
이번 구성에서 사용할 두 Database
하나의 MySQL Server에 두 개의 Database가 있다고 가정한다.
localhost:3306
├── first_db
└── second_db
첫 번째 연결 정보는 다음과 같다.
Host
localhost
Port
3306
Database
first_db
Username
root
Password
password
두 번째 Database도 같은 MySQL Server를 사용하지만 Database 이름이 다르다.
Host
localhost
Port
3306
Database
second_db
Username
root
Password
password
따라서 R2DBC URL은 다음처럼 구분할 수 있다.
r2dbc:mysql://localhost:3306/first_db
r2dbc:mysql://localhost:3306/second_db
데이터베이스별 패키지를 분리하기
다중 데이터베이스 연결에서 중요한 부분 중 하나는 Repository 패키지를 Database별로 나누는 것이다.
예를 들어 프로젝트 구조를 다음과 같이 구성할 수 있다.
com.example.multidb
├── MultiDbApplication.java
│
├── config
│ ├── FirstDatabaseConfig.java
│ └── SecondDatabaseConfig.java
│
├── firstdb
│ ├── entity
│ │ └── FirstEntity.java
│ └── repository
│ └── FirstEntityRepository.java
│
└── seconddb
├── entity
│ └── SecondEntity.java
└── repository
└── SecondEntityRepository.java
이렇게 패키지를 분리하면 각 Config 클래스가 자신이 담당할 Repository 영역을 명확하게 지정할 수 있다.
FirstDatabaseConfig
↓
firstdb.repository
SecondDatabaseConfig
↓
seconddb.repository
패키지를 분리하는 이유
예를 들어 FirstEntityRepository는 반드시 first_db에 연결되어야 한다.
FirstEntityRepository
↓
first_db
반면 SecondEntityRepository는 다음 Database를 사용해야 한다.
SecondEntityRepository
↓
second_db
Repository가 모두 같은 패키지에 섞여 있으면 어떤 Repository가 어느 Connection을 사용해야 하는지 구성하기 어려워질 수 있다.
따라서 다음과 같이 논리적인 경계를 둔다.
firstdb
→ first_db 담당
seconddb
→ second_db 담당
Database Configuration과 Package Structure가 서로 대응되는 구조다.
FirstEntity 작성
첫 번째 데이터베이스에는 first_entity 테이블이 있다고 가정한다.
first_db
first_entity
├── id
└── name
Java 객체는 다음처럼 작성할 수 있다.
package com.example.multidb.firstdb.entity;
import lombok.Data;
import org.springframework.data.annotation.Id;
import org.springframework.data.relational.core.mapping.Table;
@Data
@Table("first_entity")
public class FirstEntity {
@Id
private Long id;
private String name;
}
FirstEntityRepository 작성
첫 번째 데이터베이스에 접근할 Repository를 만든다.
package com.example.multidb.firstdb.repository;
import com.example.multidb.firstdb.entity.FirstEntity;
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
public interface FirstEntityRepository
extends ReactiveCrudRepository<FirstEntity, Long> {
}
여러 데이터를 조회하면 Flux<FirstEntity> 형태의 Reactive Stream을 사용할 수 있다.
SecondEntity 작성
두 번째 데이터베이스에는 별도의 테이블이 존재한다고 가정한다.
second_db
second_entity
├── id
└── name
객체를 작성한다.
package com.example.multidb.seconddb.entity;
import lombok.Data;
import org.springframework.data.annotation.Id;
import org.springframework.data.relational.core.mapping.Table;
@Data
@Table("second_entity")
public class SecondEntity {
@Id
private Long id;
private String name;
}
SecondEntityRepository 작성
두 번째 Repository도 Reactive Repository로 만든다.
package com.example.multidb.seconddb.repository;
import com.example.multidb.seconddb.entity.SecondEntity;
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
public interface SecondEntityRepository
extends ReactiveCrudRepository<SecondEntity, Long> {
}
최종적으로 Repository와 Database의 관계는 다음과 같다.
flowchart LR
FR[FirstEntityRepository] --> FDB[(first_db)]
SR[SecondEntityRepository] --> SDB[(second_db)]
하지만 이 상태만으로는 어떤 ConnectionFactory를 사용할지 아직 연결되지 않았다.
이를 Java Config에서 설정한다.
첫 번째 Database Config 작성
먼저 FirstDatabaseConfig 클래스를 만든다.
package com.example.multidb.config;
import org.springframework.context.annotation.Configuration;
@Configuration
public class FirstDatabaseConfig {
}
이 클래스가 첫 번째 Database와 관련된 설정을 담당한다.
개념적으로 다음 객체들이 필요하다.
First ConnectionFactory
First Entity Operations
First TransactionManager
@EnableR2dbcRepositories
첫 번째 Database를 사용하는 Repository 패키지를 지정하기 위해 @EnableR2dbcRepositories를 사용할 수 있다.
구조는 다음과 같다.
@Configuration
@EnableR2dbcRepositories(
basePackages =
"com.example.multidb.firstdb.repository",
entityOperationsRef =
"firstEntityOperations"
)
public class FirstDatabaseConfig {
}
basePackages는 이 Config 클래스가 관리할 Repository 위치를 지정한다.
basePackages
↓
firstdb.repository
즉 다음 Repository들이 첫 번째 Database 구성과 연결된다.
com.example.multidb.firstdb.repository.*
entityOperationsRef의 역할
다중 Connection 환경에서는 Repository가 어떤 R2DBC Operations를 사용할지 지정해야 한다.
entityOperationsRef =
"firstEntityOperations"
이는 Config 클래스 내부에 생성할 Bean 이름을 참조한다.
구조는 다음과 같다.
FirstEntityRepository
↓
firstEntityOperations
↓
First ConnectionFactory
↓
first_db
두 번째 Database에는 별도의 Entity Operations를 만든다.
SecondEntityRepository
↓
secondEntityOperations
↓
Second ConnectionFactory
↓
second_db
이렇게 해서 Repository 영역과 Database Connection을 분리한다.
첫 번째 ConnectionFactory Bean 만들기
첫 번째 Database와 연결할 ConnectionFactory를 Bean으로 만든다.
@Bean
public ConnectionFactory firstConnectionFactory() {
ConnectionFactoryOptions options =
ConnectionFactoryOptions
.parse(
"r2dbc:mysql://localhost:3306/first_db"
)
.mutate()
.option(
ConnectionFactoryOptions.USER,
"root"
)
.option(
ConnectionFactoryOptions.PASSWORD,
"password"
)
.build();
return ConnectionFactories.get(options);
}
결과적으로 다음 Bean이 만들어진다.
firstConnectionFactory
↓
first_db
@Qualifier가 필요한 이유
두 개의 Database를 연결하면 같은 타입의 Bean이 여러 개 존재한다.
예를 들어 다음 두 Bean이 등록된다.
ConnectionFactory
firstConnectionFactory
secondConnectionFactory
둘 다 타입은 동일하다.
ConnectionFactory
어떤 메서드에서 단순히 다음처럼 주입받으면:
ConnectionFactory connectionFactory
Spring은 어떤 ConnectionFactory를 선택해야 하는지 판단해야 한다.
따라서 @Qualifier를 이용해 Bean을 구분할 수 있다.
@Qualifier("firstConnectionFactory")
ConnectionFactory connectionFactory
또는:
@Qualifier("secondConnectionFactory")
ConnectionFactory connectionFactory
구조는 다음과 같다.
ConnectionFactory Bean
↓
┌─────────────────────────┐
│ firstConnectionFactory │
│ secondConnectionFactory │
└─────────────────────────┘
↓
@Qualifier
↓
원하는 Bean 선택
첫 번째 EntityOperations 등록
첫 번째 Repository가 사용할 Entity Operations를 등록한다.
개념적으로 다음 역할을 한다.
FirstEntityRepository
↓
R2dbcEntityOperations
↓
firstConnectionFactory
구성 흐름은 다음과 같이 만들 수 있다.
@Bean
public R2dbcEntityOperations firstEntityOperations(
@Qualifier("firstConnectionFactory")
ConnectionFactory connectionFactory
) {
DatabaseClient databaseClient =
DatabaseClient.create(connectionFactory);
return new R2dbcEntityTemplate(
databaseClient,
MySqlDialect.INSTANCE
);
}
이 Bean의 이름은 다음과 같다.
firstEntityOperations
그리고 앞에서 지정한:
entityOperationsRef =
"firstEntityOperations"
와 연결된다.
결과적으로 첫 번째 Repository의 흐름은 다음과 같다.
flowchart TD
R[FirstEntityRepository]
R --> O[firstEntityOperations]
O --> C[firstConnectionFactory]
C --> D[(first_db)]
첫 번째 TransactionManager 등록
필요하다면 첫 번째 Database 전용 TransactionManager를 만든다.
@Bean
public ReactiveTransactionManager firstTransactionManager(
@Qualifier("firstConnectionFactory")
ConnectionFactory connectionFactory
) {
return new R2dbcTransactionManager(
connectionFactory
);
}
이 TransactionManager는 첫 번째 ConnectionFactory를 사용한다.
firstTransactionManager
↓
firstConnectionFactory
↓
first_db
첫 번째 Database Config 전체 구조
전체적인 형태는 다음과 같다.
@Configuration
@EnableR2dbcRepositories(
basePackages =
"com.example.multidb.firstdb.repository",
entityOperationsRef =
"firstEntityOperations"
)
public class FirstDatabaseConfig {
@Bean
public ConnectionFactory firstConnectionFactory() {
ConnectionFactoryOptions options =
ConnectionFactoryOptions
.parse(
"r2dbc:mysql://localhost:3306/first_db"
)
.mutate()
.option(
ConnectionFactoryOptions.USER,
"root"
)
.option(
ConnectionFactoryOptions.PASSWORD,
"password"
)
.build();
return ConnectionFactories.get(options);
}
@Bean
public R2dbcEntityOperations firstEntityOperations(
@Qualifier("firstConnectionFactory")
ConnectionFactory connectionFactory
) {
DatabaseClient databaseClient =
DatabaseClient.create(connectionFactory);
return new R2dbcEntityTemplate(
databaseClient,
MySqlDialect.INSTANCE
);
}
@Bean
public ReactiveTransactionManager firstTransactionManager(
@Qualifier("firstConnectionFactory")
ConnectionFactory connectionFactory
) {
return new R2dbcTransactionManager(
connectionFactory
);
}
}
핵심 구조는 세 가지다.
ConnectionFactory
R2dbcEntityOperations
ReactiveTransactionManager
두 번째 Database Config 작성
두 번째 Database 역시 구조는 동일하다.
차이가 나는 것은 다음 부분이다.
Repository Package
Bean Name
Database URL
예를 들어 다음과 같이 구성한다.
@Configuration
@EnableR2dbcRepositories(
basePackages =
"com.example.multidb.seconddb.repository",
entityOperationsRef =
"secondEntityOperations"
)
public class SecondDatabaseConfig {
}
두 번째 Repository Package는 다음과 같다.
com.example.multidb.seconddb.repository
두 번째 ConnectionFactory
두 번째 Database URL을 사용한다.
@Bean
public ConnectionFactory secondConnectionFactory() {
ConnectionFactoryOptions options =
ConnectionFactoryOptions
.parse(
"r2dbc:mysql://localhost:3306/second_db"
)
.mutate()
.option(
ConnectionFactoryOptions.USER,
"root"
)
.option(
ConnectionFactoryOptions.PASSWORD,
"password"
)
.build();
return ConnectionFactories.get(options);
}
구조는 다음과 같다.
secondConnectionFactory
↓
second_db
두 번째 EntityOperations
@Bean
public R2dbcEntityOperations secondEntityOperations(
@Qualifier("secondConnectionFactory")
ConnectionFactory connectionFactory
) {
DatabaseClient databaseClient =
DatabaseClient.create(connectionFactory);
return new R2dbcEntityTemplate(
databaseClient,
MySqlDialect.INSTANCE
);
}
관계는 다음과 같다.
SecondEntityRepository
↓
secondEntityOperations
↓
secondConnectionFactory
↓
second_db
두 번째 TransactionManager
필요하면 두 번째 Database를 위한 TransactionManager도 별도로 등록한다.
@Bean
public ReactiveTransactionManager secondTransactionManager(
@Qualifier("secondConnectionFactory")
ConnectionFactory connectionFactory
) {
return new R2dbcTransactionManager(
connectionFactory
);
}
즉 TransactionManager 역시 Database 단위로 분리한다.
First DB
→ firstTransactionManager
Second DB
→ secondTransactionManager
@Primary가 필요한 이유
다중 Database 환경에서는 동일 타입의 Bean이 여러 개 존재한다.
예를 들면:
ConnectionFactory
firstConnectionFactory
secondConnectionFactory
특정 Bean을 @Qualifier로 지정하지 않는 코드가 존재한다면 어떤 Bean을 기본값으로 선택할지 결정해야 할 수 있다.
이때 한쪽 Bean을 기본 Bean으로 지정하기 위해 @Primary를 사용할 수 있다.
예를 들어 첫 번째 ConnectionFactory를 기본으로 사용할 수 있다.
@Primary
@Bean
public ConnectionFactory firstConnectionFactory() {
...
}
Entity Operations 역시 기본값으로 사용할 대상을 지정할 수 있다.
@Primary
@Bean
public R2dbcEntityOperations firstEntityOperations(
...
) {
...
}
TransactionManager도 마찬가지다.
@Primary
@Bean
public ReactiveTransactionManager firstTransactionManager(
...
) {
...
}
개념적으로 다음과 같다.
동일 타입 Bean
firstConnectionFactory
secondConnectionFactory
↓
Spring이 기본값 필요
↓
@Primary
↓
firstConnectionFactory 선택
@Primary와 @Qualifier 차이
두 어노테이션은 역할이 다르다.
@Primary
같은 타입 Bean이 여러 개일 때 기본적으로 선택할 Bean을 지정한다.
@Primary
@Bean
public ConnectionFactory firstConnectionFactory() {
}
의미는 다음과 같다.
특별한 지정이 없다면
firstConnectionFactory를 사용한다.
@Qualifier
어떤 Bean을 사용할지 명확하게 지정한다.
@Qualifier("secondConnectionFactory")
ConnectionFactory connectionFactory
의미는 다음과 같다.
반드시 secondConnectionFactory를 사용한다.
둘을 비교하면 다음과 같다.
| 구분 | @Primary | @Qualifier |
|---|---|---|
| 목적 | 기본 Bean 지정 | 특정 Bean 지정 |
| 위치 | Bean 선언 | 주입받는 위치 |
| 우선순위 | 기본 선택 | 명시적 선택 |
다중 데이터베이스에서는 두 개념 모두 중요하다.
전체 Bean 구성
데이터베이스가 두 개라면 다음 Bean들이 존재하게 된다.
First DB
firstConnectionFactory
firstEntityOperations
firstTransactionManager
Second DB
secondConnectionFactory
secondEntityOperations
secondTransactionManager
구조를 그림으로 보면 다음과 같다.
flowchart TD
A[Spring Application]
A --> FC[firstConnectionFactory]
A --> FO[firstEntityOperations]
A --> FT[firstTransactionManager]
A --> SC[secondConnectionFactory]
A --> SO[secondEntityOperations]
A --> ST[secondTransactionManager]
FO --> FC
FT --> FC
SO --> SC
ST --> SC
FC --> FDB[(first_db)]
SC --> SDB[(second_db)]
Repository와 Config의 연결 관계
다중 연결에서 가장 중요한 부분을 하나로 정리하면 다음과 같다.
첫 번째 Repository는:
firstdb.repository
↓
@EnableR2dbcRepositories
↓
firstEntityOperations
↓
firstConnectionFactory
↓
first_db
두 번째 Repository는:
seconddb.repository
↓
@EnableR2dbcRepositories
↓
secondEntityOperations
↓
secondConnectionFactory
↓
second_db
즉 Repository Package를 기준으로 서로 다른 Database 설정을 연결한다.
왜 Repository 패키지 구분이 중요한가?
다음 두 Repository가 있다고 가정하자.
FirstEntityRepository
SecondEntityRepository
Spring 입장에서는 둘 다 Reactive Repository다.
하지만 애플리케이션 요구사항은 다음과 같다.
FirstEntityRepository
→ first_db
SecondEntityRepository
→ second_db
이를 명시적으로 알려주는 역할이 @EnableR2dbcRepositories의 Package 설정과 entityOperationsRef다.
@EnableR2dbcRepositories(
basePackages = "...firstdb.repository",
entityOperationsRef = "firstEntityOperations"
)
이 설정을 통해 Repository와 Connection이 연결된다.
두 데이터베이스의 데이터 조회하기
이제 각각의 Repository에서 데이터를 조회할 수 있다.
예를 들어 첫 번째 Database에는 다음 데이터가 있다고 가정한다.
first_db.first_entity
1 FIRST-A
2 FIRST-B
두 번째 Database에는 다음 데이터가 있다.
second_db.second_entity
1 SECOND-A
2 SECOND-B
Controller에서 두 Repository를 주입받는다.
@RestController
@RequiredArgsConstructor
public class MainController {
private final FirstEntityRepository firstRepository;
private final SecondEntityRepository secondRepository;
}
각각 findAll()을 호출하면 서로 다른 Database의 데이터를 조회할 수 있다.
Flux로 데이터 조회
첫 번째 Repository:
Flux<FirstEntity> first =
firstRepository.findAll();
두 번째 Repository:
Flux<SecondEntity> second =
secondRepository.findAll();
각각 다음 흐름으로 동작한다.
firstRepository.findAll()
↓
firstConnectionFactory
↓
first_db
secondRepository.findAll()
↓
secondConnectionFactory
↓
second_db
하나의 애플리케이션 내부에서 완전히 다른 Database에 Reactive Query를 실행할 수 있다.
조회 결과를 로그로 확인하는 구조
간단하게 실행 결과를 확인한다면 각각의 Flux에 처리 로직을 붙일 수 있다.
개념적인 형태는 다음과 같다.
firstRepository.findAll()
.doOnNext(System.out::println);
secondRepository.findAll()
.doOnNext(System.out::println);
Reactive Pipeline에서는 실제 Subscription이 발생해야 Stream이 실행된다는 점도 함께 이해해야 한다.
실제 HTTP Controller에서 Flux를 반환하면 WebFlux가 해당 Reactive Stream을 구독하여 응답을 처리한다.
두 데이터베이스 결과를 하나의 HTTP 응답으로 결합한다면
실제 API에서는 단순히 System Log에 출력하기보다 두 Database 결과를 조합해야 할 수도 있다.
개념적으로 다음과 같다.
first_db
↓
First Data
│
├── 조합
│
Second Data
↑
second_db
두 Reactive Stream을 조합하는 데 Reactor Operator를 사용할 수 있다.
예를 들면:
Flux.merge()
Flux.zip()
Mono.zip()
어떤 연산자를 사용할지는 데이터 간 관계와 응답 요구사항에 따라 달라진다.
하지만 두 Database를 조회한다고 하나의 Transaction이 되는 것은 아니다
다중 DB 구성에서 매우 중요하게 생각해야 할 부분이다.
첫 번째 Database에는 다음 TransactionManager가 있다.
firstTransactionManager
두 번째에는:
secondTransactionManager
가 존재한다.
각각 자신이 연결된 Database의 Transaction을 관리한다.
firstTransactionManager
↓
first_db
secondTransactionManager
↓
second_db
따라서 다음 작업을 한다고 생각해보자.
first_db INSERT 성공
second_db INSERT 실패
두 개의 서로 다른 TransactionManager를 사용한다면 단순한 하나의 로컬 Transaction으로 두 Database 작업 전체의 원자성을 보장하는 문제는 별도로 고려해야 한다.
다중 Database Connection과 다중 Database Transaction은 서로 다른 문제다.
Database별 Transaction 경계
구조를 단순화하면 다음과 같다.
flowchart LR
S[Service]
S --> T1[firstTransactionManager]
T1 --> D1[(first_db)]
S --> T2[secondTransactionManager]
T2 --> D2[(second_db)]
즉:
Transaction A
→ first_db
Transaction B
→ second_db
각각은 독립적인 Transaction 경계를 가진다.
따라서 “두 Database가 연결되었다”는 것과 “두 Database 작업이 하나의 원자적 Transaction으로 묶인다”는 것을 동일하게 보면 안 된다.
다중 DB에서 TransactionManager를 분리하는 이유
예를 들어 첫 번째 Database에서 다음 작업을 수행한다고 하자.
first_entity INSERT
first_entity_history INSERT
이 두 작업은 첫 번째 TransactionManager가 관리할 수 있다.
firstTransactionManager
↓
first_db Transaction
반면 Second DB 작업은:
secondTransactionManager
↓
second_db Transaction
으로 처리된다.
Database마다 독립적인 Transaction Manager를 명시하면 어떤 Transaction이 어느 ConnectionFactory를 사용하는지 명확해진다.
application.properties에 연결 정보를 어떻게 관리할까?
다중 Database라고 해서 Database URL과 Credential을 Java 코드에 하드코딩해야 한다는 의미는 아니다.
예를 들어 다음처럼 사용자 정의 설정을 만들 수 있다.
database:
first:
url: ${FIRST_DB_URL}
username: ${FIRST_DB_USERNAME}
password: ${FIRST_DB_PASSWORD}
second:
url: ${SECOND_DB_URL}
username: ${SECOND_DB_USERNAME}
password: ${SECOND_DB_PASSWORD}
그리고 Java Config가 이 값을 이용해 ConnectionFactory를 생성하도록 만들 수 있다.
구조는 다음과 같다.
Environment / Secret
↓
Database Properties
↓
Java Config
↓
ConnectionFactory
이렇게 하면 Connection 구성은 Java 코드로 직접 관리하면서 Credential은 소스 코드와 분리할 수 있다.
운영 환경에서 Credential을 분리해야 하는 이유
다음과 같이 Java 코드에 직접 Password를 작성하면:
.option(
ConnectionFactoryOptions.PASSWORD,
"password"
)
Git Repository에 Credential이 노출될 수 있다.
운영 환경에서는 다음과 같은 외부 Secret 관리 방식을 고려할 수 있다.
Environment Variable
Docker Secret
Kubernetes Secret
Vault
AWS Secrets Manager
Azure Key Vault
CI/CD Secret
특히 데이터베이스가 늘어나면 관리해야 하는 Credential도 증가하기 때문에 연결 설정과 Secret 관리의 책임을 분리하는 것이 더욱 중요하다.
@ConfigurationProperties를 Database별로 사용하는 구조
다중 Database 설정이 복잡해질수록 @ConfigurationProperties를 이용한 설정 객체를 만들 수 있다.
예를 들어 다음 구조를 생각할 수 있다.
DatabaseProperties
├── First
│ ├── url
│ ├── username
│ └── password
│
└── Second
├── url
├── username
└── password
Config 클래스는 이 설정값을 이용해서 ConnectionFactory만 생성한다.
역할이 명확하게 분리된다.
Properties
→ 연결 정보
Config
→ Bean 생성
Repository
→ 데이터 접근
Connection Pool도 Database별로 고려해야 한다
Database가 두 개라면 Connection Pool 역시 각각 별도로 존재할 수 있다.
Application
├── First DB Pool
│ ↓
│ first_db
│
└── Second DB Pool
↓
second_db
예를 들어 각각 Pool 크기를 50으로 지정한다고 생각해보자.
First DB Pool
50
Second DB Pool
50
하나의 Application Instance가 최대 100개의 DB Connection을 사용할 가능성이 생긴다.
애플리케이션이 10개 Scale-Out된다면:
First DB
50 × 10 = 500
Second DB
50 × 10 = 500
처럼 Database별 Connection 수가 증가할 수 있다.
따라서 다중 Database에서는 Pool 크기도 전체 시스템 관점에서 설계해야 한다.
하나의 MySQL 서버에서 Database만 두 개인 경우
이번 구조에서는 하나의 MySQL Server 내부에 두 Database를 만드는 형태도 가능하다.
MySQL Server :3306
├── first_db
└── second_db
이 경우 Host와 Port는 같고 Database 이름만 다르다.
r2dbc:mysql://localhost:3306/first_db
r2dbc:mysql://localhost:3306/second_db
하지만 구조적으로는 서로 다른 ConnectionFactory를 사용할 수 있다.
firstConnectionFactory
→ first_db
secondConnectionFactory
→ second_db
서로 다른 MySQL 서버도 연결할 수 있다
같은 구성 원리를 사용하면 Host 자체가 다른 두 Database도 연결할 수 있다.
예를 들어:
First MySQL
10.0.1.10:3306
Second MySQL
10.0.2.20:3306
각 ConnectionFactory가 서로 다른 URL을 사용한다.
r2dbc:mysql://10.0.1.10:3306/first_db
r2dbc:mysql://10.0.2.20:3306/second_db
Repository와 ConnectionFactory를 분리하는 핵심 구조는 동일하다.
Docker 환경에서 구성한다면
Docker Compose 환경에서는 다음과 같은 구조도 가능하다.
webflux-app
mysql-first
mysql-second
각 Container가 같은 Docker Network에 존재한다고 가정하면 Config의 Database Host를 Container Service 이름으로 구성할 수 있다.
개념적으로:
r2dbc:mysql://mysql-first:3306/first_db
r2dbc:mysql://mysql-second:3306/second_db
전체 구조는 다음과 같다.
flowchart TD
A[WebFlux Application]
A --> F[mysql-first]
A --> S[mysql-second]
F --> FD[(first_db)]
S --> SD[(second_db)]
컨테이너 환경에서는 localhost가 각 Container 자신을 의미할 수 있으므로 네트워크 주소를 정확하게 구분해야 한다.
다중 Database 구성에서 자주 발생하는 문제
다중 R2DBC 설정에서는 Bean과 Repository의 연결 관계가 복잡해지기 때문에 몇 가지 부분을 특히 확인해야 한다.
같은 타입 Bean이 여러 개 존재하는 경우
ConnectionFactory 2개
TransactionManager 2개
EntityOperations 2개
Spring이 어떤 Bean을 사용해야 하는지 판단하지 못할 수 있다.
따라서 다음 설정을 활용한다.
@Primary
@Qualifier
Repository Package가 잘못 지정된 경우
첫 번째 Config가 다음 Package를 관리해야 하는데:
firstdb.repository
실수로:
seconddb.repository
를 Scan하면 엉뚱한 Database에 연결되는 문제가 발생할 수 있다.
다중 Database에서는 Package Configuration이 단순한 프로젝트 정리 이상의 의미를 가진다.
entityOperationsRef 이름이 맞지 않는 경우
다음 설정이:
entityOperationsRef =
"firstEntityOperations"
이라면 실제 Bean 이름도 동일해야 한다.
@Bean
public R2dbcEntityOperations firstEntityOperations(...) {
}
이름이 일치하지 않으면 Repository가 사용할 Entity Operations를 찾지 못할 수 있다.
패키지 이름을 명확하게 관리하기
다중 DB 프로젝트에서는 다음처럼 이름을 명확하게 정해두는 것이 좋다.
firstConnectionFactory
firstEntityOperations
firstTransactionManager
secondConnectionFactory
secondEntityOperations
secondTransactionManager
Package 역시 동일한 Naming을 유지한다.
firstdb
seconddb
실제 프로젝트에서는 Database 역할을 나타내는 이름이 더 좋다.
예를 들어:
memberdb
paymentdb
Bean 역시 다음처럼 구성할 수 있다.
memberConnectionFactory
paymentConnectionFactory
이렇게 하면 코드만 보고도 어떤 Database인지 쉽게 이해할 수 있다.
비즈니스 목적에 따른 이름 사용하기
firstDb, secondDb는 구조를 설명하기에는 편하지만 실제 프로젝트에서는 역할이 드러나는 이름이 더 적합하다.
예를 들어 다음과 같은 시스템이 있다고 하자.
회원 Database
결제 Database
패키지를 다음처럼 구성한다.
member
└── repository
payment
└── repository
ConnectionFactory 역시:
memberConnectionFactory
paymentConnectionFactory
Entity Operations:
memberEntityOperations
paymentEntityOperations
TransactionManager:
memberTransactionManager
paymentTransactionManager
처럼 이름을 정할 수 있다.
이렇게 하면 설정 복잡도가 높아져도 코드의 의미가 명확해진다.
Service 계층에서 두 Repository 사용하기
실제 애플리케이션에서는 하나의 Service가 두 Database의 Repository를 사용할 수도 있다.
예를 들어 다음 구조다.
@Service
@RequiredArgsConstructor
public class DataService {
private final FirstEntityRepository firstRepository;
private final SecondEntityRepository secondRepository;
}
각 Repository는 자신에게 연결된 Database를 사용한다.
DataService
├── FirstEntityRepository
│ ↓
│ first_db
│
└── SecondEntityRepository
↓
second_db
Service에서는 Database Connection 세부 구현을 알 필요가 없다.
Repository를 통해 데이터에 접근한다.
Reactive 흐름 유지하기
두 Database를 사용한다고 해서 WebFlux의 Reactive 원칙이 달라지는 것은 아니다.
Repository에서 다음 타입을 반환한다.
Mono<T>
Flux<T>
Service에서도 Reactive Type을 유지한다.
public Flux<FirstEntity> findFirstEntities() {
return firstRepository.findAll();
}
public Flux<SecondEntity> findSecondEntities() {
return secondRepository.findAll();
}
중간에 다음과 같이 Blocking 처리하지 않는 것이 중요하다.
firstRepository.findAll()
.collectList()
.block();
대신 Reactive Operator를 이용해서 필요한 작업을 연결한다.
두 Database 결과를 조합하는 예시
예를 들어 첫 번째 Database와 두 번째 Database의 데이터를 모두 조회해야 한다고 생각해보자.
각각 결과를 List로 변환하는 Reactive 작업을 만든다.
Mono<List<FirstEntity>> first =
firstRepository.findAll()
.collectList();
Mono<List<SecondEntity>> second =
secondRepository.findAll()
.collectList();
두 작업을 하나의 Reactive 흐름으로 결합하는 구조를 만들 수 있다.
return Mono.zip(first, second)
.map(tuple -> {
List<FirstEntity> firstData =
tuple.getT1();
List<SecondEntity> secondData =
tuple.getT2();
// 응답 객체 생성
return new CombinedResponse(
firstData,
secondData
);
});
전체 구조는 다음과 같다.
flowchart LR
S[Service]
S --> F[first_db 조회]
S --> D[second_db 조회]
F --> Z[Mono.zip]
D --> Z
Z --> R[Combined Response]
이렇게 하면 중간에서 block()을 호출하지 않고 두 Reactive Query 결과를 조합할 수 있다.
다중 Connection과 MSA를 혼동하지 않기
하나의 애플리케이션이 Database를 여러 개 연결한다고 해서 그것만으로 MSA가 되는 것은 아니다.
다음 구조는 하나의 서비스에서 여러 Database를 사용하는 형태다.
Application
├── Database A
└── Database B
MSA의 Database per Service 구조는 보통 다음과 같은 관점이다.
Service A
→ Database A
Service B
→ Database B
두 구조는 목적이 다르다.
하나의 서비스에서 다중 Database를 사용하면 두 Database 사이에 애플리케이션 수준의 결합이 존재하게 된다.
따라서 다중 DB 연결이 필요한 이유가 실제 서비스 책임과 맞는지도 함께 검토할 필요가 있다.
언제 다중 데이터베이스 연결이 필요할까?
다음과 같은 상황에서 하나의 애플리케이션이 여러 DB를 연결해야 할 수 있다.
Legacy DB와 신규 DB 동시 사용
서비스 Migration
읽기 전용 별도 DB
서로 다른 업무 Database 접근
Database 단계적 분리
기존 시스템과 신규 시스템 통합
예를 들어 Migration 기간 동안 다음 구조가 존재할 수 있다.
Application
├── Legacy Database
└── New Database
신규 기능은 New Database를 사용하지만 기존 데이터 조회는 Legacy Database를 이용하는 식이다.
이런 상황에서 Multi R2DBC Configuration이 필요할 수 있다.
다중 DB가 늘어날수록 복잡도가 증가한다
Database가 두 개일 때는 다음 정도로 끝난다.
ConnectionFactory × 2
EntityOperations × 2
TransactionManager × 2
하지만 DB가 다섯 개가 되면 설정도 함께 늘어난다.
ConnectionFactory × 5
EntityOperations × 5
TransactionManager × 5
Repository Package × 5
따라서 다중 연결이 기술적으로 가능하다는 이유만으로 하나의 서비스가 지나치게 많은 Database에 의존하도록 만들면 시스템 복잡도가 급격하게 증가할 수 있다.
다중 DB 구조의 핵심 설계 포인트
다중 R2DBC 연결에서는 크게 네 가지를 명확하게 구분해야 한다.
Repository Package
ConnectionFactory
EntityOperations
TransactionManager
각각의 Database마다 이 관계가 하나의 세트로 묶인다.
첫 번째 Database:
firstdb.repository
↓
firstEntityOperations
↓
firstConnectionFactory
↓
first_db
Transaction:
firstTransactionManager
↓
firstConnectionFactory
두 번째 Database:
seconddb.repository
↓
secondEntityOperations
↓
secondConnectionFactory
↓
second_db
Transaction:
secondTransactionManager
↓
secondConnectionFactory
이 구조를 이해하는 것이 다중 R2DBC 설정의 핵심이다.
전체 구조
최종적으로 하나의 WebFlux 애플리케이션에서 두 MySQL Database를 연결하는 구조는 다음과 같다.
flowchart TD
C[Client] --> CT[WebFlux Controller]
CT --> S[Service]
S --> R1[First Repository]
S --> R2[Second Repository]
R1 --> EO1[firstEntityOperations]
R2 --> EO2[secondEntityOperations]
EO1 --> CF1[firstConnectionFactory]
EO2 --> CF2[secondConnectionFactory]
CF1 --> DB1[(first_db)]
CF2 --> DB2[(second_db)]
TM1[firstTransactionManager] --> CF1
TM2[secondTransactionManager] --> CF2
Repository Scan 구조까지 포함하면 다음과 같다.
FirstDatabaseConfig
basePackages
→ firstdb.repository
entityOperationsRef
→ firstEntityOperations
SecondDatabaseConfig
basePackages
→ seconddb.repository
entityOperationsRef
→ secondEntityOperations
이처럼 Repository 단위로 사용할 R2DBC Connection을 명확하게 분리한다.
실행 확인
두 Database가 정상적으로 연결되었다면 각각의 Repository에서 데이터를 조회할 수 있다.
예를 들어 첫 번째 Database에는:
FIRST ENTITY
두 번째 Database에는:
SECOND ENTITY
데이터가 저장되어 있다고 가정한다.
애플리케이션을 실행한다.
./gradlew bootRun
이 명령은 Spring Boot WebFlux 애플리케이션을 실행한다.
이후 API를 호출한다.
curl http://localhost:8080/
Controller에서 각각의 Repository를 호출하도록 구현했다면 하나의 애플리케이션에서 서로 다른 Database 데이터를 조회하는 것을 확인할 수 있다.
동작 과정은 다음과 같다.
HTTP Request
↓
Controller
↓
First Repository
↓
first_db
Controller
↓
Second Repository
↓
second_db
단일 R2DBC와 다중 R2DBC 비교
| 구분 | 단일 DB | 다중 DB |
|---|---|---|
| ConnectionFactory | 1개 | 여러 개 |
| Repository Scan | 기본 구성 가능 | DB별 분리 |
| EntityOperations | 기본 구성 활용 가능 | DB별 구성 |
| TransactionManager | 1개 | DB별 구성 가능 |
@Qualifier | 거의 불필요 | 중요 |
@Primary | 거의 불필요 | 기본 Bean 지정에 활용 |
| 설정 복잡도 | 낮음 | 높음 |
단일 DB에서는 Spring Boot 자동 설정의 장점을 최대한 활용할 수 있다.
다중 DB에서는 어느 Repository가 어느 Connection을 사용하는지 명시적으로 설정해야 하기 때문에 Java Config의 역할이 커진다.
실무에서의 활용
R2DBC 다중 연결의 핵심은 단순히 ConnectionFactory를 두 개 만드는 것이 아니다.
다음 관계를 정확하게 구성해야 한다.
Database
↕
ConnectionFactory
↕
EntityOperations
↕
Repository Package
그리고 Transaction이 필요하다면:
TransactionManager
↕
ConnectionFactory
의 관계도 함께 관리해야 한다.
실제 프로젝트에서는 first, second보다 Database의 업무 목적이 드러나는 이름을 사용하는 것이 좋다.
예를 들면:
MemberDatabaseConfig
PaymentDatabaseConfig
memberConnectionFactory
paymentConnectionFactory
memberEntityOperations
paymentEntityOperations
memberTransactionManager
paymentTransactionManager
처럼 구성할 수 있다.
이렇게 하면 다중 데이터베이스 설정이 늘어나더라도 어떤 Bean이 어떤 Database에 속하는지 명확하게 파악할 수 있다.
또한 다중 Database 연결 자체와 여러 Database에 걸친 Transaction 원자성은 별개의 문제라는 점도 중요하다.
연결 가능
≠
하나의 Transaction으로 자동 처리
각 Database에 별도의 TransactionManager가 존재한다면 기본적으로 각각의 Transaction 경계를 가지므로 여러 DB의 변경을 하나의 원자적인 작업으로 다뤄야 하는 요구사항은 별도로 설계해야 한다.
정리
Spring WebFlux에서 R2DBC를 이용해 두 개 이상의 MySQL Database를 연결하려면 Database별 Java Config를 구성하고 Repository가 어떤 Connection을 사용할지 명확하게 지정해야 한다.
단일 Database에서는 다음 구조로 충분했다.
Repository
↓
ConnectionFactory
↓
MySQL
하지만 다중 Database에서는 다음처럼 분리한다.
First Repository
↓
firstEntityOperations
↓
firstConnectionFactory
↓
first_db
Second Repository
↓
secondEntityOperations
↓
secondConnectionFactory
↓
second_db
이를 위해 Repository Package를 Database별로 분리한다.
firstdb.repository
seconddb.repository
그리고 각각의 Config 클래스에서 @EnableR2dbcRepositories를 통해 자신이 담당할 Repository Package와 Entity Operations를 연결한다.
@EnableR2dbcRepositories(
basePackages =
"com.example.multidb.firstdb.repository",
entityOperationsRef =
"firstEntityOperations"
)
동일한 타입의 Bean이 여러 개 만들어지기 때문에 @Qualifier를 이용해 원하는 Bean을 명확하게 주입할 수 있고, 필요한 경우 @Primary를 통해 기본 Bean을 지정할 수 있다.
@Qualifier
→ 특정 Bean 선택
@Primary
→ 기본 Bean 지정
Database별 Transaction이 필요하다면 각각의 ConnectionFactory를 사용하는 R2dbcTransactionManager도 분리해서 구성할 수 있다.
최종적인 구조는 다음과 같다.
Repository Package
↓
EntityOperations
↓
ConnectionFactory
↓
Database
그리고:
TransactionManager
↓
ConnectionFactory
의 관계가 Database마다 독립적으로 존재한다.
한 줄 요약
Spring WebFlux에서 R2DBC 다중 데이터베이스 연결을 구성하려면 Database별 Repository Package, ConnectionFactory, R2dbcEntityOperations, TransactionManager를 분리하고 @EnableR2dbcRepositories, @Qualifier, 필요 시 @Primary를 이용해 각 Repository가 정확한 Database Connection을 사용하도록 명시적으로 연결해야 한다.