ADR-0001: 왜 RLS 세션변수를 "데이터 접근 순간"에 설정하는가

정세현·2026년 7월 28일

이 문서는 원본 ADR-0001(RLS 테넌트 세션변수를 Aspect가 아니라 데이터 접근 경로에서 설정한다)을 예시 코드와 함께 처음부터 차근차근 풀어 설명합니다.


1. 배경: 지금 우리가 뭘 하려는 건가

헬프데스크 서비스는 여러 회사(테넌트)가 같은 데이터베이스, 같은 테이블을 공유해서 씁니다. 예를 들어 티켓 테이블 하나에 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의 전부입니다.


2. 왜 이게 위험한 문제인가

이 세션변수는 이렇게 설정합니다.

SET LOCAL app.tenant_id = 'tenant-A';

SET LOCAL은 두 가지 제약이 있습니다.

  1. 현재 트랜잭션 안에서만 유효하고, 커밋/롤백하면 자동으로 사라집니다. (좋은 점 — 커넥션 풀에 반납된 다음 다른 테넌트가 그 커넥션을 재사용해도 값이 안 남습니다.)
  2. 하지만 "실제 쿼리가 나가는 그 커넥션, 그 트랜잭션"에서 설정해야만 의미가 있습니다.

만약 엉뚱한 커넥션에서 설정하거나, 트랜잭션이 시작되기 전(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)"라고 부르는 상황입니다. 설정 순서를 한 곳이라도 잘못 짜면, 컴파일도 되고 배포도 되고 테스트도 어쩌다 통과하는데, 프로덕션에서 조용히 오작동합니다. 그래서 "어디서 설정하느냐"를 신중하게 골라야 합니다.


3. 검토했지만 버린 대안들

대안 A: @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번에서 말한 것처럼 "조용히" 실패합니다. → 위험해서 기각.

대안 B: 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"인데 그걸 걸 자리가 없는 것 — 그래서 부분 기각.


4. 채택한 방안 (C): 데이터 접근 경로에서 직접 설정

핵심 아이디어: 트랜잭션 시작 이벤트를 별도로 잡으려 하지 말고, 실제로 쿼리를 실행하는 바로 그 코드 안에서 세션변수를 설정한다.

@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를 실행하면 → 반드시 실제 쿼리가 나갈 그 커넥션에 설정됩니다.
  • 순서를 신경 쓸 필요가 없습니다. aspect 순서, advice 우선순위 이런 거 상관없이, "이 메서드가 호출되는 시점 = 트랜잭션이 이미 시작돼 있고 커넥션도 바인딩된 시점"이기 때문입니다.

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);
}

5. 정리: A/B/C안 비교표

구분설정 시점문제점
A. @Aspect@Transactional 진입 시advice 순서에 의존 → 순서 꼬이면 트랜잭션 밖에서 설정될 수 있음
B. TransactionSynchronization트랜잭션 시작 직후그런 콜백(afterBegin)이 애초에 없음 → 구현 불가
C. 데이터 접근 경로 (채택)실제 쿼리 실행 직전JdbcTemplate이 현재 트랜잭션의 커넥션을 그대로 쓰므로 항상 정확

6. 이 결정으로 얻는 것 / 여전히 지켜야 하는 것

장점

  • advice 실행 순서에 의존하지 않음 → 설정 실수로 인한 조용한 실패 위험 제거
  • 커넥션 풀 재사용 시에도 테넌트 값이 섞이지 않음 (SET LOCAL이 트랜잭션 종료 시 자동 리셋되기 때문)
  • AOP(관점지향 프로그래밍) 설정에 대한 암묵적 의존이 사라짐

여전히 지켜야 할 규칙 (제약)

  • 세션변수를 읽는 모든 코드 경로는 반드시 @Transactional 경계 안에서 일어나야 합니다. 서비스 계층에서 감싸야 하고, 조회만 하는 로직도 예외가 아닙니다.
  • 만약 실수로 트랜잭션 밖에서 DB에 접근하면 → 이번엔 조용히 통과되는 게 아니라, FORCE ROW LEVEL SECURITY + USING/WITH CHECK 정책 때문에 조회 결과가 0건으로 확실하게 막힙니다(fail-closed). 즉 사고가 나도 티가 나게 실패합니다.
  • 검증은 두 테넌트 상호 격리 테스트 + 세션변수 미설정 시 fail-closed가 되는지를 @SpringBootTest(Testcontainers Postgres)로 확인해서, 일반 룰로 고정합니다.

한 줄 요약

"트랜잭션 시작할 때 미리 세션변수를 걸어두자"는 방식들은 순서 문제로 조용히 실패할 위험이 있어서 버렸고, "쿼리가 실제로 나가는 그 순간, 그 쿼리가 쓰는 진짜 커넥션에 직접 설정하자"로 결정했습니다. 그리고 혹시 설정을 빠뜨리면 데이터가 새는 게 아니라 아예 안 보이도록(fail-closed) 만들어서, 실수해도 안전한 쪽으로 넘어지게 설계했습니다.

profile
I'm the best

0개의 댓글