
이 문서는 원본 ADR-0001(RLS 테넌트 세션변수를 Aspect가 아니라 데이터 접근 경로에서 설정한다)을 예시 코드와 함께 처음부터 차근차근 풀어 설명합니다.
헬프데스크 서비스는 여러 회사(테넌트)가 같은 데이터베이스, 같은 테이블을 공유해서 씁니다. 예를 들어 티켓 테이블 하나에 A회사 티켓과 B회사 티켓이 같이 들어 있어요.
CREATE TABLE ticket (
id bigint PRIMARY KEY,
tenant_id text NOT NULL,
title text,
...
);
문제는: A회사 로그인 유저가 쿼리를 날렸을 때 실수로라도 B회사 티켓이 보이면 절대 안 됩니다. 이걸 애플리케이션 코드(WHERE tenant_id = ?를 매번 붙이는 방식)로 막으면, 개발자가 딱 한 줄 빠뜨리는 순간 데이터가 샙니다. 사람이 실수하기 쉬운 방식이에요.
그래서 RLS(Row Level Security)를 씁니다. DB 자체에 "이 테이블은 현재 세션의 tenant_id와 일치하는 행만 보여준다"는 규칙을 박아버리는 겁니다. 애플리케이션 코드가 실수해도 DB가 막아줍니다.
ALTER TABLE ticket ENABLE ROW LEVEL SECURITY;
ALTER TABLE ticket FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON ticket
USING (tenant_id = current_setting('app.tenant_id', true))
WITH CHECK (tenant_id = current_setting('app.tenant_id', true));
여기서 current_setting('app.tenant_id', true)가 핵심입니다. "지금 이 DB 커넥션에 누가 로그인해 있는지"를 저장해두는 세션 변수를 읽는 겁니다. 즉 이 변수를 누가, 언제, 어디서 채워 넣느냐가 이 ADR의 전부입니다.
이 세션변수는 이렇게 설정합니다.
SET LOCAL app.tenant_id = 'tenant-A';
SET LOCAL은 두 가지 제약이 있습니다.
만약 엉뚱한 커넥션에서 설정하거나, 트랜잭션이 시작되기 전(autocommit 상태)에 설정해버리면 어떻게 될까요?
// 트랜잭션 밖에서 SET LOCAL을 실행한 경우
jdbcTemplate.execute("SET LOCAL app.tenant_id = 'tenant-A'"); // autocommit 커넥션에 설정됨
// → 이 문장이 끝나자마자 값이 사라짐 (autocommit이라 즉시 커밋된 것과 같음)
// 이후 실제 쿼리는 다른 커넥션을 풀에서 받아옴
List<Ticket> tickets = ticketRepository.findAll();
// current_setting('app.tenant_id', true) → NULL
여기서 진짜 무서운 부분: tenant_id = NULL 비교는 SQL에서 참도 거짓도 아닌 NULL이 됩니다. USING 절이 NULL이 되면 Postgres는 그 행을 그냥 "안 보여줌" 처리합니다. 즉:
이게 이 ADR이 "침묵 실패(silent failure)"라고 부르는 상황입니다. 설정 순서를 한 곳이라도 잘못 짜면, 컴파일도 되고 배포도 되고 테스트도 어쩌다 통과하는데, 프로덕션에서 조용히 오작동합니다. 그래서 "어디서 설정하느냐"를 신중하게 골라야 합니다.
@Aspect로 트랜잭션 시작 시 자동 설정@Aspect
@Component
public class TenantContextAspect {
@Before("@annotation(org.springframework.transaction.annotation.Transactional)")
public void setTenantContext(JoinPoint jp) {
String tenantId = TenantContext.getCurrentTenant();
jdbcTemplate.execute("SET LOCAL app.tenant_id = '" + tenantId + "'");
}
}
얼핏 깔끔해 보이지만 문제는 Spring이 advice(부가기능)들의 실행 순서를 보장하는 방식 때문에 생깁니다. Spring의 트랜잭션 관리 자체도 내부적으로 TransactionInterceptor라는 advice로 동작합니다. 그리고 Spring 공식 문서에는 이렇게 나와 있습니다.
Ordered.LOWEST_PRECEDENCE(가장 낮은 우선순위)로 설정하면 트랜잭션 advice가 가장 안쪽에서 실행된다.
즉 커스텀 aspect가 트랜잭션 advice보다 바깥쪽에서 실행되도록 순서를 지정해두지 않으면, 이런 순서로 실행될 수 있습니다.
1. TenantContextAspect.setTenantContext() ← 아직 트랜잭션 시작 안 됨! (autocommit 커넥션)
2. TransactionInterceptor가 진짜 트랜잭션 시작
3. 실제 비즈니스 로직 실행 (이때는 새 트랜잭션/커넥션)
1번에서 SET LOCAL을 실행한 커넥션과, 3번에서 실제 쿼리가 나가는 커넥션이 다를 수 있습니다. 이걸 고치려면 @EnableTransactionManagement(order=...) 같은 설정을 정확히 맞춰야 하는데, 이건 개발자가 나중에 실수로 건드리기 매우 쉬운 설정입니다. 그리고 잘못 건드려도 2번에서 말한 것처럼 "조용히" 실패합니다. → 위험해서 기각.
TransactionSynchronization으로 트랜잭션 시작 직후 설정TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterBegin() { // ← 이런 메서드는 존재하지 않음
jdbcTemplate.execute("SET LOCAL app.tenant_id = '" + tenantId + "'");
}
});
이 방식은 Spring의 TransactionSynchronization 인터페이스에 "트랜잭션 시작 직후"를 의미하는 콜백이 아예 존재하지 않아서 구현 자체가 불가능합니다. 실제로 있는 콜백은 이것뿐입니다.
public interface TransactionSynchronization {
void beforeCommit(boolean readOnly); // 커밋 직전
void beforeCompletion(); // 완료(커밋/롤백) 직전
void afterCommit(); // 커밋 후
void afterCompletion(int status); // 완료 후
}
전부 트랜잭션 "끝나갈 때" 또는 "끝난 후"에 관한 콜백이지, "막 시작했을 때"를 잡아주는 게 없습니다. 우리가 원하는 건 "트랜잭션 시작 직후 SET"인데 그걸 걸 자리가 없는 것 — 그래서 부분 기각.
핵심 아이디어: 트랜잭션 시작 이벤트를 별도로 잡으려 하지 말고, 실제로 쿼리를 실행하는 바로 그 코드 안에서 세션변수를 설정한다.
@Service
public class TicketService {
@Autowired
private JdbcTemplate jdbcTemplate; // Spring이 관리하는 표준 JdbcTemplate
@Transactional
public List<Ticket> getTickets(String tenantId) {
// 트랜잭션의 "첫 DB 접근" 시점에 직접 설정
jdbcTemplate.update(
"SELECT set_config('app.tenant_id', ?, true)", // 세 번째 인자 true = LOCAL과 동일 효과
tenantId
);
return ticketRepository.findAll(); // 이후 쿼리들은 같은 커넥션을 그대로 씀
}
}
이 방식이 안전한 이유는 JdbcTemplate의 동작 방식 때문입니다.
// Spring 내부에서 JdbcTemplate이 커넥션을 가져오는 방식 (개념적으로)
Connection con = DataSourceUtils.getConnection(dataSource);
DataSourceUtils.getConnection()은 "지금 활성화된 트랜잭션에 이미 바인딩되어 있는 커넥션"을 그대로 반환합니다. 새 커넥션을 풀에서 받아오는 게 아니라, 이 트랜잭션이 계속 써온(혹은 처음 시작하는) 바로 그 커넥션이에요. 그래서:
@Transactional 메서드 안에서 jdbcTemplate으로 set_config를 실행하면 → 반드시 실제 쿼리가 나갈 그 커넥션에 설정됩니다.TransactionSynchronizationManager.isActualTransactionActive()는 이 흐름에서 "세션변수를 몇 번 설정했는지 세는 용도"가 아니라, "지금 진짜 트랜잭션 안에 있는지"를 확인하는 가드로만 씁니다. 리소스(커넥션) 바인딩 여부를 트랜잭션당 1회 확인하는 정도입니다.
public void ensureTenantContext(String tenantId) {
if (!TransactionSynchronizationManager.isActualTransactionActive()) {
throw new IllegalStateException(
"트랜잭션 밖에서 데이터 접근 시도 - RLS 세션변수를 설정할 수 없음"
);
}
jdbcTemplate.update("SELECT set_config('app.tenant_id', ?, true)", tenantId);
}
| 구분 | 설정 시점 | 문제점 |
|---|---|---|
A. @Aspect | @Transactional 진입 시 | advice 순서에 의존 → 순서 꼬이면 트랜잭션 밖에서 설정될 수 있음 |
B. TransactionSynchronization | 트랜잭션 시작 직후 | 그런 콜백(afterBegin)이 애초에 없음 → 구현 불가 |
| C. 데이터 접근 경로 (채택) | 실제 쿼리 실행 직전 | JdbcTemplate이 현재 트랜잭션의 커넥션을 그대로 쓰므로 항상 정확 |
장점
여전히 지켜야 할 규칙 (제약)
@Transactional 경계 안에서 일어나야 합니다. 서비스 계층에서 감싸야 하고, 조회만 하는 로직도 예외가 아닙니다.FORCE ROW LEVEL SECURITY + USING/WITH CHECK 정책 때문에 조회 결과가 0건으로 확실하게 막힙니다(fail-closed). 즉 사고가 나도 티가 나게 실패합니다.fail-closed가 되는지를 @SpringBootTest(Testcontainers Postgres)로 확인해서, 일반 룰로 고정합니다."트랜잭션 시작할 때 미리 세션변수를 걸어두자"는 방식들은 순서 문제로 조용히 실패할 위험이 있어서 버렸고, "쿼리가 실제로 나가는 그 순간, 그 쿼리가 쓰는 진짜 커넥션에 직접 설정하자"로 결정했습니다. 그리고 혹시 설정을 빠뜨리면 데이터가 새는 게 아니라 아예 안 보이도록(fail-closed) 만들어서, 실수해도 안전한 쪽으로 넘어지게 설계했습니다.