헬프데스크 RLS를 alpha에 띄우기까지 — 삽질 기록 (post-mortem)

정세현·2026년 7월 29일

이 문서는 "헬프데스크 + 법인(테넌트) RLS"를 alpha에 배포하면서 신고 API가 계속 500 나던 문제
잡아 나간 과정을, 각 단계의 증상 → 원인 → 왜 위험한가 → 고친 방법으로 처음부터 풀어 설명합니다.
관련 설계 배경은 ADR-0001 참고.


0. 배경: 뭘 하려던 거였나

  • 목표: 헬프데스크 기능(문제 신고/목록/해결) + 여러 법인이 한 DB·한 테이블을 공유하되 서로 못 보게
    하는 RLS 테넌트 격리를, CI/CD로 alpha 환경에 배포.
  • 겪은 일: 배포했더니 신고 API가 계속 500. 그런데 원인이 하나가 아니라 여러 겹이었고,
    하나를 고쳐 배포하면 그다음 원인이 드러나는 식으로 총 5겹을 벗겨야 했다.
  • 공통 교훈 먼저: 여기 나오는 실패는 대부분 "조용히(silent)" 실패였다. 500 메시지는 똑같아 보여도
    원인이 매번 달랐고, 로그를 끝까지 파야 진짜 원인이 나왔다. 그래서 로그의 첫 Caused by: 한 줄
    매번 열쇠였다.

1막 — 배포가 뒤섞였다 (CI 트리거 문제)

증상

  • 화면은 뜨는데 동작이 이상. 배포된 이미지 태그의 커밋 sha(a88fa1a)가 우리 repo 어디에도 없는 유령 커밋.

원인

  • CI가 on: pull_request 였다. → 모든 PR(및 PR 커밋)마다 alpha에 자동 배포.
  • 그래서 서로 다른 사람의 PR이 같은 alpha를 번갈아 덮어씀.
  • 게다가 pull_request 이벤트는 GitHub이 PR을 main에 가상 머지한 임시 커밋으로 빌드한다 →
    그 sha가 a88fa1a. repo에 없어서 "이게 대체 뭐지?" 하고 헤맴.
# before
on:
  pull_request: {}        # ← PR마다 빌드+배포

고친 방법

# after
on:
  push: { branches: [main] }   # 배포는 main 머지에만
  pull_request: {}             # PR은 "빌드 검증"만 (배포/이미지 push 안 함)
  • Bump image tag/trigger-cd 스텝을 if: github.event_name != 'pull_request' 로 막음.
  • 이미지 push도 push: ${{ github.event_name != 'pull_request' }} 로 PR에선 안 하게.

배운 것

  • 배포 트리거는 "실제로 머지된 것"에만 걸어야 한다. PR = 검증, merge = 배포.

2막 — 마이그레이션이 아예 안 돌았다 (Flyway autoconfig 모듈 누락)

증상

ERROR: relation "help_desk_ticket" does not exist
  • 그리고 시작 로그에 Flyway 관련 줄이 한 줄도 없음(배너/Migrating 전무).

원인

  • Spring Boot 4.x는 auto-configuration을 기술별 모듈로 쪼갰다. Flyway 자동설정이
    spring-boot-autoconfigure 에서 spring-boot-flyway 모듈로 이사했다.
  • 우리는 flyway-core(마이그레이션 엔진)만 넣고 이 autoconfig 모듈을 안 넣어서,
    spring.flyway.enabled=true 여도 부팅 시 마이그레이션이 자동 실행되지 않았다(그래서 로그도 없음).

진단법 (핵심 트릭)

# 배포된 jar 안에 Flyway 라이브러리/모듈이 실제로 들어있나 (unzip 없이)
kubectl exec ... -- sh -c 'grep -ac BOOT-INF/lib/spring-boot-flyway /app/app.jar'   # 0 → 모듈 없음

고친 방법

// build.gradle.kts
implementation("org.springframework.boot:spring-boot-flyway")   // ← 이게 빠져 있었다
implementation("org.flywaydb:flyway-core")
implementation("org.flywaydb:flyway-database-postgresql")

배운 것

  • Spring Boot 4.x에선 라이브러리(flyway-core) + Spring 연동 모듈(spring-boot-flyway)
    둘 다 넣어야 autoconfig가 산다. 라이브러리만 있으면 조용히 아무 일도 안 일어난다.

3막 — 스키마를 만들 권한이 없었다 (DB 권한)

증상

  • Flyway가 이제 돌긴 하는데:
Migrating schema "axhr_for_leader" to version "1 - create help desk ticket"
Migration ... failed! Changes successfully rolled back.
Message : ERROR: permission denied for database postgres
  • → 부팅 실패 → 새 pod가 CrashLoop, 옛 pod가 계속 트래픽 받아서 "여전히 500"처럼 보임.

원인

  • 마이그레이션 첫 문장 CREATE SCHEMA IF NOT EXISTS axhr_for_leaderDB 레벨 CREATE 권한이 필요.
  • 관리형 공유 DB의 앱 유저에게는 그 권한이 없다(있으면 아무 스키마나 만들 수 있어 위험).

고친 방법 — 관리형 DB 표준 방식으로

  • (앱) 마이그레이션에서 CREATE SCHEMA 제거 + spring.flyway.create-schemas: false (Flyway도 스키마 안 만듦)
  • (DBA/플랫폼) 스키마를 미리 만들고 앱 유저를 소유자로:
CREATE SCHEMA axhr_for_leader AUTHORIZATION axhr_for_leader_user;

(플랫폼 컨벤션: <service> 스키마 ↔ <service>_user 소유)

배운 것

  • 관리형 DB에선 스키마는 DBA가 만들고, 앱은 "이미 있다고 전제". 앱 유저는 스키마 소유자로서
    그 안에서만 테이블/RLS를 다룰 권한만 갖는다(최소 권한).

4막 — 세션변수가 안 걸렸다 (set_config를 매번 건너뜀)

여기부터가 진짜 RLS 배선 버그. ADR-0001의 "데이터 접근 경로에서 set" 방향은 맞았지만 구현 디테일에서 삐끗.

증상

  • 테이블은 생겼는데 신고 등록(INSERT) 에서:
ERROR: new row violates row-level security policy for table "help_desk_ticket"
  • 목록(SELECT)은 에러는 없지만 0건.

원인

  • 세션변수 app.tenant_id 를 "트랜잭션당 1회만" 설정하려고, TenantJdbcSupport
    DataSource를 중복방지 키로 썼다:
DataSource key = jdbcTemplate.getDataSource();
if (TransactionSynchronizationManager.hasResource(key)) return;  // 이미 했으면 skip
  • 그런데 Spring은 트랜잭션 커넥션(ConnectionHolder)을 바로 그 DataSource를 키로 이미 바인딩해둔다.
    → 트랜잭션이 열리는 순간부터 hasResource(dataSource)항상 true
    set_config를 매번 건너뜀app.tenant_id 미설정(NULL)
  • 결과:
    • INSERT: WITH CHECK (tenant_id = current_setting(...))tenant_id = NULL → 위반 → 500
    • SELECT: USING (...) → NULL → 0건 (조용히 빈 결과)

고친 방법

  • 과한 "1회 가드"를 제거하고 매 접근마다 set_config 실행. SET LOCAL은 같은 값 재설정이 무해(idempotent).
// after
if (!TransactionSynchronizationManager.isActualTransactionActive())
    throw new IllegalStateException("@Transactional 경계 안에서만");  // 가드는 "트랜잭션 여부"만
jdbcTemplate.queryForObject("SELECT set_config('app.tenant_id', ?, true)", String.class, value);

배운 것

  • TransactionSynchronizationManager 의 resource 키로 DataSource를 쓰면 안 된다 — Spring이 이미
    그 키로 커넥션을 관리한다. 굳이 최적화하려다 조용한 버그를 만들었다.

5막 — set_config를 잘못된 방식으로 실행했다 (update vs query)

증상

  • 4막을 고치니 이번엔:
SQL [SELECT set_config('app.tenant_id', ?, true)]; A result was returned when none was expected.

원인

  • SELECT set_config(...)값을 한 행 반환하는 쿼리다.
  • 그걸 jdbcTemplate.update()(내부적으로 executeUpdate)로 실행하면, PostgreSQL JDBC가
    "결과를 반환하는데 update로 불렀다"며 예외를 던진다.

고친 방법

// before: jdbcTemplate.update("SELECT set_config(...)", value)   // ← 결과 반환 SQL을 update로 실행 (X)
// after
jdbcTemplate.queryForObject("SELECT set_config('app.tenant_id', ?, true)", String.class, value);

배운 것

  • 결과를 반환하는 SQL(SELECT/RETURNING 등)update()가 아니라 query/queryForObject 로 실행한다.

6막 — 진짜 근본 원인: 검증을 안 했다

증상(메타)

  • 4·5막의 버그는 왜 배포해봐야 드러났나?

원인

  • RLS 경로(TenantJdbcSupport → 실제 INSERT/SELECT)를 진짜 Postgres에 대고 한 번도 실행 안 함.
  • 그 경로를 타는 통합테스트(Testcontainers) 가 있었는데, 초반에 docker info 가 실패해서
    "Docker 없음"으로 단정하고 안 돌렸다. 사실 Docker Desktop을 켜니 됐다.

고친 방법

  • 마지막에 실행 → 테스트 데이터 오염(테스트끼리 공유 테이블) 발견 → @BeforeEachTRUNCATE +
    격리 시맨틱에 맞게 containsExactlycontainsOnly 로 교정 → 3케이스 전부 통과:
    상호 격리 / 세션변수 없으면 fail-closed / 다른 테넌트 위조 저장 거부(WITH CHECK).
cd backend && ./gradlew :app:test --tests "*HelpDeskRlsIntegrationTest"   # Docker Desktop만 켜져 있으면 됨

배운 것 (가장 중요)

  • RLS/DB 변경은 배포 전에 위 통합테스트로 로컬 검증한다. 이걸 처음부터 했으면 4·5막을 안 겪었다.
  • docker info 가 안 되면 데몬(Docker Desktop)이 꺼져 있는지부터 재확인.

전체 타임라인 한눈에

증상(로그 키워드)원인고침
1유령 커밋 배포, 브랜치끼리 덮임CI가 PR마다 배포배포는 main 머지에만 (PR=검증)
2relation ... does not exist, Flyway 로그 없음Spring Boot 4.x spring-boot-flyway 모듈 누락그 모듈 의존성 추가
3permission denied for database마이그레이션 CREATE SCHEMA 권한 부족스키마 사전생성(소유자=앱유저) + create-schemas=false
4new row violates row-level security policy / 목록 0건set_config 가드가 DataSource 키 충돌로 매번 skip가드 제거, 매번 set_config
5A result was returned when none was expectedSELECT set_configupdate()로 실행queryForObject로 실행
6(위가 배포 후에야 드러남)RLS 통합테스트를 로컬에서 안 돌림Docker로 통합테스트 검증 습관화

한 줄 요약

하나의 500 뒤에 CI 배포 · Flyway 모듈 · DB 권한 · RLS 세션변수 배선 · JDBC 실행 방식까지 5겹의
원인이 겹쳐 있었고, 대부분 에러 없이 조용히 실패해서 배포해봐야 하나씩 드러났다. 근본 원인은
"RLS 경로를 실제 DB로 검증하지 않은 것" 하나였고, Docker로 통합테스트를 돌리자 남은 버그가 배포
전에 다 잡혔다.

재발 방지 체크리스트

  • DB/RLS 변경 → 올리기 전에 ./gradlew :app:test --tests "*HelpDeskRlsIntegrationTest" (Docker)
  • Spring Boot 4.x에서 어떤 기술 쓰면 spring-boot-<기술> autoconfig 모듈도 넣었는지 확인
  • 마이그레이션은 앱 유저 권한 안에서만(새 스키마/확장 등은 DBA)
  • TransactionSynchronizationManager resource 키로 DataSource 쓰지 않기
  • 결과 반환 SQL은 query/queryForObject로 (update 금지)
  • 500이 반복되면 kubectl logs의 첫 Caused by: 부터 본다 (500 메시지는 다 똑같이 생겼다)
profile
I'm the best

0개의 댓글