
이 문서는 "헬프데스크 + 법인(테넌트) RLS"를 alpha에 배포하면서 신고 API가 계속 500 나던 문제를
잡아 나간 과정을, 각 단계의 증상 → 원인 → 왜 위험한가 → 고친 방법으로 처음부터 풀어 설명합니다.
관련 설계 배경은 ADR-0001 참고.
Caused by: 한 줄이증상
a88fa1a)가 우리 repo 어디에도 없는 유령 커밋.원인
on: pull_request 였다. → 모든 PR(및 PR 커밋)마다 alpha에 자동 배포.pull_request 이벤트는 GitHub이 PR을 main에 가상 머지한 임시 커밋으로 빌드한다 →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: ${{ github.event_name != 'pull_request' }} 로 PR에선 안 하게.배운 것
PR = 검증, merge = 배포.증상
ERROR: relation "help_desk_ticket" does not exist
원인
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")
배운 것
증상
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
원인
CREATE SCHEMA IF NOT EXISTS axhr_for_leader 는 DB 레벨 CREATE 권한이 필요.고친 방법 — 관리형 DB 표준 방식으로
CREATE SCHEMA 제거 + spring.flyway.create-schemas: false (Flyway도 스키마 안 만듦)CREATE SCHEMA axhr_for_leader AUTHORIZATION axhr_for_leader_user;
(플랫폼 컨벤션: <service> 스키마 ↔ <service>_user 소유)
배운 것
여기부터가 진짜 RLS 배선 버그. ADR-0001의 "데이터 접근 경로에서 set" 방향은 맞았지만 구현 디테일에서 삐끗.
증상
ERROR: new row violates row-level security policy for table "help_desk_ticket"
원인
app.tenant_id 를 "트랜잭션당 1회만" 설정하려고, TenantJdbcSupport 가DataSource key = jdbcTemplate.getDataSource();
if (TransactionSynchronizationManager.hasResource(key)) return; // 이미 했으면 skip
hasResource(dataSource) 가 항상 trueapp.tenant_id 미설정(NULL)WITH CHECK (tenant_id = current_setting(...)) → tenant_id = NULL → 위반 → 500USING (...) → NULL → 0건 (조용히 빈 결과)고친 방법
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이 이미증상
SQL [SELECT set_config('app.tenant_id', ?, true)]; A result was returned when none was expected.
원인
SELECT set_config(...) 는 값을 한 행 반환하는 쿼리다.jdbcTemplate.update()(내부적으로 executeUpdate)로 실행하면, PostgreSQL JDBC가고친 방법
// before: jdbcTemplate.update("SELECT set_config(...)", value) // ← 결과 반환 SQL을 update로 실행 (X)
// after
jdbcTemplate.queryForObject("SELECT set_config('app.tenant_id', ?, true)", String.class, value);
배운 것
update()가 아니라 query/queryForObject 로 실행한다.증상(메타)
원인
TenantJdbcSupport → 실제 INSERT/SELECT)를 진짜 Postgres에 대고 한 번도 실행 안 함.docker info 가 실패해서고친 방법
@BeforeEach 에 TRUNCATE +containsExactly → containsOnly 로 교정 → 3케이스 전부 통과:cd backend && ./gradlew :app:test --tests "*HelpDeskRlsIntegrationTest" # Docker Desktop만 켜져 있으면 됨
배운 것 (가장 중요)
docker info 가 안 되면 데몬(Docker Desktop)이 꺼져 있는지부터 재확인.| 막 | 증상(로그 키워드) | 원인 | 고침 |
|---|---|---|---|
| 1 | 유령 커밋 배포, 브랜치끼리 덮임 | CI가 PR마다 배포 | 배포는 main 머지에만 (PR=검증) |
| 2 | relation ... does not exist, Flyway 로그 없음 | Spring Boot 4.x spring-boot-flyway 모듈 누락 | 그 모듈 의존성 추가 |
| 3 | permission denied for database | 마이그레이션 CREATE SCHEMA 권한 부족 | 스키마 사전생성(소유자=앱유저) + create-schemas=false |
| 4 | new row violates row-level security policy / 목록 0건 | set_config 가드가 DataSource 키 충돌로 매번 skip | 가드 제거, 매번 set_config |
| 5 | A result was returned when none was expected | SELECT set_config를 update()로 실행 | queryForObject로 실행 |
| 6 | (위가 배포 후에야 드러남) | RLS 통합테스트를 로컬에서 안 돌림 | Docker로 통합테스트 검증 습관화 |
하나의 500 뒤에 CI 배포 · Flyway 모듈 · DB 권한 · RLS 세션변수 배선 · JDBC 실행 방식까지 5겹의
원인이 겹쳐 있었고, 대부분 에러 없이 조용히 실패해서 배포해봐야 하나씩 드러났다. 근본 원인은
"RLS 경로를 실제 DB로 검증하지 않은 것" 하나였고, Docker로 통합테스트를 돌리자 남은 버그가 배포
전에 다 잡혔다.
./gradlew :app:test --tests "*HelpDeskRlsIntegrationTest" (Docker)spring-boot-<기술> autoconfig 모듈도 넣었는지 확인TransactionSynchronizationManager resource 키로 DataSource 쓰지 않기query/queryForObject로 (update 금지)kubectl logs의 첫 Caused by: 부터 본다 (500 메시지는 다 똑같이 생겼다)