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 필요 여부
tenantsPK 테이블 (자체)
users✅ 필수
projects✅ 필수
issues✅ 필수
issue_statuses✅ 필수 (프로젝트별 커스텀 상태)
sprints✅ 필수
commentsissues에서 join으로 tenant 격리 가능
issue_logsissues에서 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 반환 확인