ID: key_26_30_05_26_tenant_01 Created date: 5월 30 2026 토요일
연관 문서
현상
CIRA 프로젝트에서 데이터 CRUD(이슈 생성, 조회 등) 수행 시 tenant 정보가 누락되어 정상 동작하지 않음.
원인 분석
멀티테넌시 아키텍처 상 예상 원인
flowchart TD A[클라이언트 요청] --> B[JWT 포함] B --> C[Security Filter / Interceptor] C --> D{tenant 추출 로직} D -- 누락 또는 미구현 --> E[TenantContext 비어있음] E --> F[Service 레이어: tenantId = null] F --> G[DB 조회 실패 / 데이터 미생성] D -- 정상 --> H[TenantContext 설정] H --> I[정상 CRUD]
| 원인 유형 | 설명 | 점검 위치 |
|---|---|---|
| JWT에 tenantId 미포함 | 토큰 발급 시 claim에 tenantId를 넣지 않음 | JwtService.generateToken() |
| Filter/Interceptor 미등록 | tenantId 추출 필터가 Spring Security 체인에 등록되지 않음 | SecurityConfig |
| TenantContext 미설정 | 필터가 존재하나 ThreadLocal 기반 Context에 값 미설정 | TenantContextHolder |
| Repository 조건 누락 | 조회 쿼리에 WHERE tenant_id = ? 조건 없음 | IssueRepository, ProjectRepository |
| 회원가입 시 tenant 미생성 | 사용자 등록 시 tenant 레코드 미생성 | AuthService.register() |
개선 방안
1. Tenant 처리 흐름 정의
sequenceDiagram participant Client participant JwtFilter participant TenantContext participant Service participant Repository Client->>JwtFilter: 요청 (Authorization: Bearer {token}) JwtFilter->>JwtFilter: JWT 파싱 → tenantId 추출 JwtFilter->>TenantContext: TenantContextHolder.set(tenantId) JwtFilter->>Service: 요청 전달 Service->>Repository: findByIdAndTenantId(id, TenantContextHolder.get()) Repository-->>Service: 결과 반환 Service-->>Client: 응답 Note over JwtFilter,TenantContext: finally: TenantContextHolder.clear()
2. 구현 체크리스트
Step 1 — JWT Claim에 tenantId 포함 (JwtService)
// JwtService.generateToken() 내부
Claims claims = Jwts.claims()
.subject(user.getEmail())
.add("userId", user.getId().toString())
.add("tenantId", user.getTenantId().toString()) // ← 추가
.build();// tenantId 추출 메서드 추가
public UUID extractTenantId(String token) {
return UUID.fromString(
extractClaim(token, claims -> claims.get("tenantId", String.class))
);
}Step 2 — TenantContextHolder 구현
// com.tsh.starter.befw.common.tenant.TenantContextHolder
public class TenantContextHolder {
private static final ThreadLocal<UUID> CONTEXT = new ThreadLocal<>();
public static void setTenantId(UUID tenantId) { CONTEXT.set(tenantId); }
public static UUID getTenantId() { return CONTEXT.get(); }
public static void clear() { CONTEXT.remove(); }
}Step 3 — JwtAuthenticationFilter에 tenantId 설정
// JwtAuthenticationFilter.doFilterInternal() 내
try {
String token = extractToken(request);
if (token != null && jwtService.isTokenValid(token)) {
UUID tenantId = jwtService.extractTenantId(token);
TenantContextHolder.setTenantId(tenantId); // ← 추가
// 기존 SecurityContext 설정 로직 유지
UsernamePasswordAuthenticationToken auth = ...;
SecurityContextHolder.getContext().setAuthentication(auth);
}
} finally {
TenantContextHolder.clear(); // ← 요청 종료 시 반드시 초기화
}Step 4 — Repository 조건 추가
JPA 사용 시:
// IssueRepository
Optional<Issue> findByIdAndTenantId(UUID id, UUID tenantId);
List<Issue> findAllByProjectIdAndTenantId(UUID projectId, UUID tenantId);Service 레이어 호출 패턴:
// IssueService
UUID tenantId = TenantContextHolder.getTenantId();
Issue issue = issueRepository.findByIdAndTenantId(issueId, tenantId)
.orElseThrow(() -> new BusinessException(ErrorCode.ISSUE_NOT_FOUND));Step 5 — 이슈 생성 시 tenantId 자동 주입
// IssueService.createIssue()
Issue issue = Issue.builder()
.tenantId(TenantContextHolder.getTenantId()) // ← 자동 주입
.projectId(request.projectId())
.title(request.title())
// ...
.build();Step 6 — 회원가입 시 Tenant 자동 생성
// AuthService.register()
@Transactional
public void register(RegisterRequest request) {
// 1. Tenant 생성
Tenant tenant = tenantRepository.save(
Tenant.builder()
.name(request.email()) // 또는 별도 tenantName 입력
.build()
);
// 2. User 생성 (tenantId 연결)
User user = User.builder()
.tenantId(tenant.getId())
.email(request.email())
.name(request.name())
.passwordHash(passwordEncoder.encode(request.password()))
.build();
userRepository.save(user);
}3. DB 스키마 확인 항목
아래 테이블에 tenant_id UUID NOT NULL 컬럼이 존재하는지 확인:
| 테이블 | tenant_id 필요 여부 |
|---|---|
tenants | PK 테이블 (자체) |
users | ✅ 필수 |
projects | ✅ 필수 |
issues | ✅ 필수 |
issue_statuses | ✅ 필수 (프로젝트별 커스텀 상태) |
sprints | ✅ 필수 |
comments | issues에서 join으로 tenant 격리 가능 |
issue_logs | issues에서 join으로 tenant 격리 가능 |
누락된 컬럼이 있으면 Flyway 마이그레이션 추가:
-- V{N}__add_tenant_id_to_issues.sql
ALTER TABLE issues ADD COLUMN IF NOT EXISTS tenant_id UUID NOT NULL
REFERENCES tenants(id);
CREATE INDEX idx_issues_tenant_id ON issues(tenant_id);4. 검증 방법
# 1. 회원가입 후 tenants 테이블에 레코드 생성 확인
SELECT * FROM tenants ORDER BY created_at DESC LIMIT 5;
# 2. 이슈 생성 후 tenant_id 값 확인
SELECT id, tenant_id, title FROM issues ORDER BY created_at DESC LIMIT 5;
# 3. 다른 tenant 사용자로 타 tenant 이슈 조회 시 404 반환 확인 (보안)Claude Code 작업 수행서
코드 프로젝트에서 아래 지시를 실행:
befw-app-server에서 Tenant 관련 이슈를 수정해줘.
[경로]
- C:\workspace\tsh\boilerplate\be\befw-app-server
[수행 순서]
1. JwtService에 tenantId claim 추가 (generateToken, extractTenantId)
2. TenantContextHolder 클래스 신규 생성 (ThreadLocal 기반)
3. JwtAuthenticationFilter에 tenantId 추출 및 TenantContextHolder 설정 추가 (finally에서 clear)
4. IssueRepository, ProjectRepository에 tenantId 조건 메서드 추가
5. IssueService, ProjectService에서 TenantContextHolder.getTenantId() 사용하도록 수정
6. 이슈/프로젝트 생성 시 tenantId 자동 주입
7. AuthService.register()에서 Tenant 자동 생성 로직 추가
8. 누락된 tenant_id 컬럼에 대한 Flyway 마이그레이션 파일 작성
[완료 기준]
- 로그인 → 이슈 생성 → 이슈 조회 Postman 테스트 통과
- 다른 계정으로 타 tenant 이슈 조회 시 404 반환 확인