2025. 12. 1. 19:15ㆍSpring
MSA 멀티테넌시 환경에서의 스키마 별 트랜잭션 관리와 안정성 확보 전략
마이크로서비스 아키텍처를 구현하며 멀티 테넌시를 유실하지 않고 요청의 시작과 끝을 향해가는 과정에 사용자와 데이터를 유실하지 않고 트랜잭션을 유지하는 것을 말합니다.
현재 구성된 서버의 경우 Spring Cloud로 이뤄진 각각의 모듈 서버들 간 통신 과정에서 어떻게 데이터 정합성과 사용자 정보 유실 없이 안정성을 확보했는지에 대해 정리할 예정입니다.
+ 서버의 데이터 베이스는 각각의 서버별 Tenant 기반 Schema 구분을 통해 사용자별 데이터를 저장하고 있습니다
테넌시 식별하는 방식과 GateWay와 X-Tenant-ID 전략 (실패)
멀티테넌시 전략의 핵심은 어떻게 사용자 요청을 식별하는 것입니다. 첫 방식은 서브 도메인을 이용해 추출하는 방식을 채택했습니다. 클라이언트가 허용된 서브도메인이 포함된 도메인 경로로 접속하면 Spring Cloud GateWay는 필터를 통해 SubDomain 값을 추출하고 이를 X-Tenant-ID라는 커스텀 헤더에 담아 다른 마이크서비스로 전달하는 방식을 구현했습니다.
// Gateway Filter
String host = exchange.getRequest().getHeaders().getFirst("Host");
String tenantId = extractSubdomain(host); // lim.kkhan.co.kr -> lim
request.mutate().header("X-Tenant-ID", tenantId).build();
하지만 추후 클라이언트와 벡엔드 서버간 분리된 환경(서브도메인 분리)에서는 브라우저가 API를 서버 요청을 보낼 때 Host 값이 클라이언트가 아닌 서버 기반 도메인으로 설정되기 때문에 결국 해당 방식을 옳바른 서브 도메인 데이터를 추출할 수 없어 포기하게 되었습니다.
해결 방식
X-Tenant-ID라는 커스텀 헤더와 Jwt 토큰 내에 저장되어 있는 Tenantm, ip, Device 정보를 헤더 ID값과 비교해 검증하는 방식으로 변경하였습니다. 만약 해당 검증 수행 없이 커스텀 헤더의 Tenant 값을 믿고 검증 완료하는 경우 로그인 완료 후 다른 서브 도메인을 알아내어 클라이언트로 접근하는 순간 일부 데이터를 접속해 확인할 수 있는 문제가 발생할 수 있습니다.
if (config.isVerifyTenant()) {
try {
String tokenTenantId = jwtUtil.getTenantId(token);
String requestTenantId = exchange.getAttribute(TenantHeaderFilter.TENANT_ATTRIBUTE_KEY);
if (requestTenantId == null) {
requestTenantId = exchange.getRequest().getHeaders().getFirst("X-Tenant-ID");
}
log.debug("[AuthFilter] Tenant Check. Token: {}, Req: {}", tokenTenantId, requestTenantId);
if (tokenTenantId != null && !tokenTenantId.equals(requestTenantId)) {
log.warn("[AuthFilter] Fail: Tenant Mismatch! ReqID: {}, TokenT: {}, ReqT: {}", reqId, tokenTenantId, requestTenantId);
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
return exchange.getResponse().setComplete();
}
} catch (Exception e) {
log.error("[AuthFilter] Error during Tenant Check. ReqID: {}", reqId, e);
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
return exchange.getResponse().setComplete();
}
}
..... Device, IP 검증 코드 추가되어 있음
동적 스키마 라우팅과 트랜잭션 관리
이렇게 식별된 Tenant-ID 값은 요청이 끝날 때까지 DB 커넥션을 결정하는 중요 키로 사용됩니다.
간단한 구현 방식으로 TenantID를 이용해 ThreadLocal에 Context를 격리 시켜 Hibernate의 SchemaMultiTenantConnectionProvider라는 커스텀 컴포넌트를 통해 데이터베이스의 스키마에 할당하는 동적으로 방식으로 데이터 쿼리 실행을 하게 됩니다
@Component
@ManagedResource(objectName="com.msa:type=MultiTenant,bean=SchemaProvider")
public class SchemaMultiTenantConnectionProvider implements MultiTenantConnectionProvider {
....
@Override
public Connection getConnection(Object tenantIdentifier) throws SQLException {
Connection connection = getAnyConnection();
String tenant = tenantIdentifier.toString().toLowerCase();
if (!isValidTenantIdentifier(tenant)) {
throw new SQLException("Invalid tenant identifier: " + tenant);
}
if (!initializedTenants.contains(tenant)) {
try (Statement stmt = connection.createStatement()) {
stmt.execute("CREATE SCHEMA IF NOT EXISTS " + tenant);
}
runMigration(tenant);
initializedTenants.add(tenant);
}
try (Statement stmt = connection.createStatement()) {
stmt.execute("SET search_path TO " + tenant);
}
return connection;
}
@Override
public void releaseConnection(Object tenantIdentifier, Connection connection) throws SQLException {
try (Statement statement = connection.createStatement()) {
// 기본 스키마로 복귀
statement.execute("SET search_path TO " + DEFAULT_SCHEMA);
}
connection.close();
}
private void runMigration(String tenant) {
Flyway.configure()
.dataSource(defaultDataSource)
.schemas(tenant)
.initSql(String.format("SET search_path TO '%s'", tenant))
.locations("classpath:db/migration")
.baselineOnMigrate(true)
.load()
.migrate();
}
....
}
서버 내부에서는 이러한 방식으로 트랜잭션의 스키마 호출을 실시합니다.
동기 통신의 안정성 FeignClient
MSA를 구성하면 필연적으로 서버간 내부 통신이 필요하게 됩니다. 이러한 서비스 간 통신 시에도 Tenant Context는 유지되어야 되는데 이를 위에 구성 방식과 동일한 X-Tenant-ID 커스텀 헤더를 통해 넣어 서비스 간 호출 시에도 일관된 Context를 유지할 수 있게 됩니다.
비동기 데이터 정합성 Kafka 헤더의 활용
비동기 처리 부분은 메시지를 큐에 넣고나면 Consumer르 통해 스레드에서 실행되는 ThreadLocal 값이 사라지게 됩니다.
Producer가 이벤트를 발행할 때 별도의 Tenant 값을 포함하는 방식으로 Tenant 값을 넣어 추출해 ThreadLocal 저장해 처리하는 방식으로 데이터의 정합성을 유지하였습니다.
'Spring' 카테고리의 다른 글
| Kafka MSA 통신 및 데이터 트랜잭션 보완 (0) | 2025.12.02 |
|---|---|
| 멀티테넌시 스키마 분리 전략 (0) | 2025.06.28 |
| SSR 기반 Spring Server 모니터링 생성기 (Prometheus, Grafana, Spring Security) (0) | 2025.03.27 |
| Jpa Save vs SaveAll 성능 차이 체감해보기 (0) | 2025.01.11 |
| [트러블 슈팅 - 중복 API 요청] API 멱등성 (feat: redis) 해결 완료 (0) | 2024.09.11 |