이 글에서 얻는 것

  • 멀티테넌시에서 가장 중요한 것(보안 격리/운영/비용)을 기준으로, Shared/Schema/DB 분리 전략을 선택할 수 있습니다.
  • “테넌트 데이터 유출” 사고를 막기 위한 강제 장치(애플리케이션/DB/관측)를 설계할 수 있습니다.
  • 테넌트별 마이그레이션/백업/과금 같은 운영 문제를 시스템적으로 풀어가는 감각을 얻습니다.
  • 실제 코드(Spring Boot, JPA, PostgreSQL RLS, 모니터링)를 통해 즉시 적용할 수 있습니다.

0) 멀티테넌시는 ‘기능’이 아니라 ‘제약’이다

멀티테넌시는 결국 다음을 동시에 만족해야 합니다.

  • 테넌트 간 데이터 격리(보안/규정)
  • 운영 효율(한 플랫폼으로 여러 고객을 운영)
  • 비용 효율(전용 인프라를 테넌트마다 둘 수는 없음)

어떤 격리 모델을 선택하든, 이 셋의 트레이드오프가 있습니다.


1) 격리 모델 3가지(실무 비교)

1-1) Shared DB + Shared Schema (tenant_id 컬럼)

CREATE TABLE orders (
    id          BIGSERIAL PRIMARY KEY,
    tenant_id   UUID NOT NULL,
    user_id     BIGINT NOT NULL,
    amount      DECIMAL(10, 2) NOT NULL,
    status      VARCHAR(20) NOT NULL,
    created_at  TIMESTAMP DEFAULT NOW()
);

CREATE INDEX idx_orders_tenant ON orders(tenant_id, created_at DESC);
  • 장점: 가장 저렴, 운영 단순(스키마 하나), 리소스 공유 효율적
  • 단점: 격리 사고 리스크가 가장 큼(필터 누락이 곧 유출), noisy neighbor(핫 테넌트)

1-2) Shared DB + Separate Schema (테넌트별 스키마)

-- 테넌트별 스키마 생성
CREATE SCHEMA tenant_acme;
CREATE SCHEMA tenant_globex;

-- 각 스키마에 동일 테이블
CREATE TABLE tenant_acme.orders (
    id          BIGSERIAL PRIMARY KEY,
    user_id     BIGINT NOT NULL,
    amount      DECIMAL(10, 2) NOT NULL
);
  • 장점: 논리적 격리가 더 명확, 테넌트별 마이그레이션/인덱스 제어 가능
  • 단점: 스키마 수가 늘며 운영 복잡도 증가, DB 커넥션 풀 관리 어려움

1-3) Separate DB (테넌트별 DB/인스턴스)

  • 장점: 격리/보안/성능 측면에서 가장 강력, 핫 테넌트 분리에 유리, 테넌트별 백업/복구 독립
  • 단점: 비용/운영 부담 큼(백업/모니터링/마이그레이션을 테넌트 수만큼)

1-4) 격리 모델 비교표

기준Shared SchemaSeparate SchemaSeparate DB
비용★★★ 최저★★ 중간★ 최고
격리 강도★ 약함★★ 중간★★★ 최강
운영 복잡도★★★ 단순★★ 중간★ 복잡
노이지 네이버 방지★ 약함★★ 가능★★★ 완전
테넌트별 백업/복구★ 어려움★★ 가능★★★ 독립
테넌트별 스키마 커스텀★ 불가★★★ 가능★★★ 가능
최대 테넌트 수수천~수만수백~수천수십~수백
규정 준수(GDPR 등)★ 추가 장치 필요★★ 상대적 용이★★★ 가장 용이

1-5) 실무에서 자주 쓰는 결론

대부분은 (1) Shared Schema로 시작하고,
규모/요구가 커지면 (2) Separate Schema 또는
"핫 테넌트만 (3) Separate DB"로 하이브리드로 갑니다.

하이브리드 예시:

  • 일반 테넌트 → Shared Schema (비용 효율)
  • Enterprise 테넌트 → Separate Schema 또는 DB (계약/규정 요구)
  • 핫 테넌트 (트래픽 상위 5%) → Separate DB (성능 격리)

2) 격리 강제: ‘실수로 유출되는 길’을 닫아라

2-1) 애플리케이션 레벨 강제 (Spring Boot)

TenantContext 구현:

public class TenantContext {

    private static final ThreadLocal<String> CURRENT_TENANT = new ThreadLocal<>();

    public static void setTenantId(String tenantId) {
        CURRENT_TENANT.set(tenantId);
    }

    public static String getTenantId() {
        String tenantId = CURRENT_TENANT.get();
        if (tenantId == null) {
            throw new TenantNotSetException("테넌트 컨텍스트가 설정되지 않았습니다");
        }
        return tenantId;
    }

    public static void clear() {
        CURRENT_TENANT.remove();
    }
}

Filter로 자동 주입:

@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class TenantFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                     HttpServletResponse response,
                                     FilterChain chain) throws ServletException, IOException {
        try {
            String tenantId = extractTenantId(request);
            if (tenantId == null) {
                response.sendError(HttpServletResponse.SC_BAD_REQUEST, "X-Tenant-Id 헤더 필수");
                return;
            }
            TenantContext.setTenantId(tenantId);
            MDC.put("tenantId", tenantId);  // 로그에 자동 태깅
            chain.doFilter(request, response);
        } finally {
            TenantContext.clear();
            MDC.remove("tenantId");
        }
    }

    private String extractTenantId(HttpServletRequest request) {
        // 1순위: 헤더
        String tenantId = request.getHeader("X-Tenant-Id");
        if (tenantId != null) return tenantId;

        // 2순위: JWT claim
        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
        if (auth instanceof JwtAuthenticationToken jwt) {
            return jwt.getToken().getClaimAsString("tenant_id");
        }
        return null;
    }
}

JPA Hibernate Filter로 자동 WHERE 조건 추가:

// Entity
@Entity
@Table(name = "orders")
@FilterDef(name = "tenantFilter", parameters = @ParamDef(name = "tenantId", type = String.class))
@Filter(name = "tenantFilter", condition = "tenant_id = :tenantId")
public class Order {
    @Id @GeneratedValue
    private Long id;

    @Column(name = "tenant_id", nullable = false)
    private String tenantId;

    // ... 나머지 필드
}

// Interceptor로 모든 쿼리에 자동 적용
@Component
@RequiredArgsConstructor
public class TenantHibernateInterceptor implements HibernateInterceptor {

    @Override
    public void applyFilter(Session session) {
        session.enableFilter("tenantFilter")
               .setParameter("tenantId", TenantContext.getTenantId());
    }
}

// Aspect로 Repository 메서드마다 자동 활성화
@Aspect
@Component
public class TenantFilterAspect {

    @Autowired
    private EntityManager entityManager;

    @Before("execution(* com.example..repository.*.*(..))")
    public void enableTenantFilter() {
        Session session = entityManager.unwrap(Session.class);
        session.enableFilter("tenantFilter")
               .setParameter("tenantId", TenantContext.getTenantId());
    }
}

핵심: 개발자가 매번 WHERE tenant_id = ?를 붙이게 하지 않는다. 한 번이라도 빠지면 전 테넌트 데이터가 유출됩니다.

2-2) DB 레벨 강제: PostgreSQL Row Level Security (RLS)

RLS는 DB가 직접 접근 제어를 하므로 애플리케이션 버그로 인한 유출을 구조적으로 차단합니다.

-- 1. RLS 활성화
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE orders FORCE ROW LEVEL SECURITY;  -- 테이블 소유자에게도 적용

-- 2. 정책 정의
CREATE POLICY tenant_isolation ON orders
    USING (tenant_id = current_setting('app.current_tenant')::uuid)
    WITH CHECK (tenant_id = current_setting('app.current_tenant')::uuid);

-- 3. 애플리케이션에서 세션 변수 설정
-- (매 트랜잭션 시작 시)
SET LOCAL app.current_tenant = 'acme-tenant-uuid';

-- 4. 이제 모든 쿼리에 자동 필터 적용
SELECT * FROM orders;
-- 실제 실행: SELECT * FROM orders WHERE tenant_id = 'acme-tenant-uuid'

INSERT INTO orders (tenant_id, ...) VALUES ('wrong-tenant-uuid', ...);
-- 실패: new row violates row-level security policy

Spring Boot에서 RLS 연동:

@Component
@RequiredArgsConstructor
public class TenantConnectionInterceptor implements ConnectionCustomizer {

    @Override
    public void customize(Connection connection) throws SQLException {
        String tenantId = TenantContext.getTenantId();
        try (var stmt = connection.createStatement()) {
            stmt.execute("SET LOCAL app.current_tenant = '" + tenantId + "'");
        }
    }
}

// 또는 AOP 방식
@Aspect
@Component
public class RlsAspect {

    @Autowired
    private EntityManager em;

    @Before("@annotation(org.springframework.transaction.annotation.Transactional)")
    public void setTenantForRls() {
        em.createNativeQuery("SET LOCAL app.current_tenant = :tenantId")
          .setParameter("tenantId", TenantContext.getTenantId())
          .executeUpdate();
    }
}

2-3) 관측/감사

// 테넌트 교차 접근 감지 (로그 분석)
@Aspect
@Component
@Slf4j
public class TenantAuditAspect {

    @AfterReturning(pointcut = "execution(* com.example..repository.*.*(..))", returning = "result")
    public void auditTenantAccess(JoinPoint jp, Object result) {
        if (result instanceof Collection<?> items) {
            String currentTenant = TenantContext.getTenantId();
            for (Object item : items) {
                if (item instanceof TenantAware ta 
                    && !ta.getTenantId().equals(currentTenant)) {
                    log.error("[SECURITY] 테넌트 교차 접근 감지! " +
                        "current={}, data={}, method={}",
                        currentTenant, ta.getTenantId(), jp.getSignature());
                    // 즉시 알림 발송
                    alertService.sendCritical("TENANT_CROSS_ACCESS", currentTenant);
                }
            }
        }
    }
}

테넌트 감사 로그 스키마:

CREATE TABLE tenant_audit_log (
    id          BIGSERIAL PRIMARY KEY,
    tenant_id   UUID NOT NULL,
    user_id     BIGINT,
    action      VARCHAR(50) NOT NULL,   -- READ, WRITE, DELETE
    resource    VARCHAR(100) NOT NULL,   -- orders, users
    query       TEXT,
    ip_address  INET,
    created_at  TIMESTAMP DEFAULT NOW()
);

CREATE INDEX idx_audit_tenant_time ON tenant_audit_log(tenant_id, created_at DESC);

3) 성능/인덱스: tenant_id는 설계의 중심이 된다

3-1) 인덱스 설계

Shared schema 모델에서 tenant_id는 거의 모든 쿼리에 들어갑니다.

-- ❌ tenant_id 없는 인덱스: 테넌트 격리 시 쓸모없음
CREATE INDEX idx_orders_status ON orders(status);

-- ✅ tenant_id를 선두에 둔 복합 인덱스
CREATE INDEX idx_orders_tenant_status ON orders(tenant_id, status, created_at DESC);
CREATE INDEX idx_orders_tenant_user ON orders(tenant_id, user_id);

-- 유니크 제약도 tenant_id 포함
ALTER TABLE orders ADD CONSTRAINT uq_orders_tenant_orderno 
    UNIQUE (tenant_id, order_number);

3-2) 파티셔닝 전략

-- 테넌트별 파티셔닝 (핫 테넌트 격리)
CREATE TABLE orders (
    id          BIGSERIAL,
    tenant_id   UUID NOT NULL,
    amount      DECIMAL(10,2),
    created_at  TIMESTAMP DEFAULT NOW()
) PARTITION BY LIST (tenant_id);

-- 핫 테넌트는 전용 파티션
CREATE TABLE orders_tenant_acme PARTITION OF orders
    FOR VALUES IN ('acme-uuid');

-- 나머지는 해시 파티셔닝으로 분산
CREATE TABLE orders_default PARTITION OF orders DEFAULT;

-- 파티션 내부 인덱스
CREATE INDEX ON orders_tenant_acme (created_at DESC);

3-3) 커넥션 풀 격리 (noisy neighbor 방지)

# application.yml
spring:
  datasource:
    hikari:
      maximum-pool-size: 50
      # 테넌트별 동적 제한은 코드로
@Component
public class TenantConnectionLimiter {

    // 테넌트별 동시 커넥션 제한
    private final Map<String, Semaphore> tenantLimits = new ConcurrentHashMap<>();
    private static final int DEFAULT_LIMIT = 5;
    private static final int PREMIUM_LIMIT = 20;

    public <T> T withTenantLimit(String tenantId, Supplier<T> action) {
        Semaphore semaphore = tenantLimits.computeIfAbsent(
            tenantId, 
            id -> new Semaphore(isPremium(id) ? PREMIUM_LIMIT : DEFAULT_LIMIT)
        );

        if (!semaphore.tryAcquire(3, TimeUnit.SECONDS)) {
            throw new TenantRateLimitException(
                "테넌트 " + tenantId + " 동시 연결 제한 초과"
            );
        }
        try {
            return action.get();
        } finally {
            semaphore.release();
        }
    }
}

4) 마이그레이션/배포: 테넌트 수만큼 반복되는 문제

4-1) Flyway 멀티테넌시 마이그레이션

@Component
@RequiredArgsConstructor
public class TenantMigrationRunner implements CommandLineRunner {

    private final TenantRepository tenantRepository;
    private final DataSource dataSource;

    @Override
    public void run(String... args) {
        List<Tenant> tenants = tenantRepository.findAllActive();

        for (Tenant tenant : tenants) {
            try {
                migrateSchema(tenant);
                log.info("마이그레이션 성공: tenant={}", tenant.getId());
            } catch (Exception e) {
                log.error("마이그레이션 실패: tenant={}", tenant.getId(), e);
                // 한 테넌트 실패가 다른 테넌트에 영향 주지 않음
                alertService.sendWarning("MIGRATION_FAILED", tenant.getId());
            }
        }
    }

    private void migrateSchema(Tenant tenant) {
        Flyway flyway = Flyway.configure()
                .dataSource(dataSource)
                .schemas(tenant.getSchemaName())
                .locations("classpath:db/migration/tenant")
                .baselineOnMigrate(true)
                .load();
        flyway.migrate();
    }
}

4-2) Expand/Contract 패턴 (무중단 스키마 변경)

-- Phase 1: Expand (새 컬럼 추가, 기존 유지)
ALTER TABLE orders ADD COLUMN shipping_address_v2 JSONB;

-- Phase 2: 듀얼 라이트 (양쪽 다 씀)
-- 애플리케이션에서 v1, v2 동시 기록

-- Phase 3: 데이터 마이그레이션 (배치로)
UPDATE orders SET shipping_address_v2 = to_jsonb(shipping_address)
WHERE shipping_address_v2 IS NULL
LIMIT 10000;  -- 테넌트별 배치 크기 제어

-- Phase 4: Contract (기존 컬럼 제거)
-- 모든 테넌트 마이그레이션 확인 후
ALTER TABLE orders DROP COLUMN shipping_address;

4-3) 테넌트별 마이그레이션 상태 추적

CREATE TABLE tenant_migration_status (
    tenant_id       UUID NOT NULL,
    migration_id    VARCHAR(100) NOT NULL,
    status          VARCHAR(20) NOT NULL,  -- PENDING, RUNNING, COMPLETED, FAILED
    started_at      TIMESTAMP,
    completed_at    TIMESTAMP,
    error_message   TEXT,
    PRIMARY KEY (tenant_id, migration_id)
);

5) 백업/복구/삭제: 테넌트 단위 운영을 준비하라

5-1) 테넌트별 데이터 익스포트 (GDPR 대응)

@Service
@RequiredArgsConstructor
public class TenantDataExporter {

    private final JdbcTemplate jdbcTemplate;

    /**
     * GDPR Article 20: 데이터 이동권
     * 테넌트의 모든 데이터를 JSON으로 내보냅니다.
     */
    public Path exportTenantData(String tenantId) {
        Path exportDir = Path.of("/exports", tenantId, 
            LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss")));
        Files.createDirectories(exportDir);

        // 테넌트 관련 테이블 목록
        List<String> tables = List.of("users", "orders", "payments", "audit_logs");

        for (String table : tables) {
            List<Map<String, Object>> rows = jdbcTemplate.queryForList(
                "SELECT * FROM " + table + " WHERE tenant_id = ?", tenantId
            );
            
            Path file = exportDir.resolve(table + ".json");
            objectMapper.writeValue(file.toFile(), rows);
            log.info("테넌트 데이터 익스포트: tenant={}, table={}, rows={}", 
                tenantId, table, rows.size());
        }

        return exportDir;
    }

    /**
     * GDPR Article 17: 삭제권 (잊혀질 권리)
     * 소프트 삭제 → 30일 후 하드 삭제
     */
    @Transactional
    public void requestTenantDeletion(String tenantId) {
        // Phase 1: 소프트 삭제 (즉시)
        jdbcTemplate.update(
            "UPDATE tenants SET status = 'PENDING_DELETION', " +
            "deletion_requested_at = NOW() WHERE id = ?", tenantId
        );

        // Phase 2: 데이터 비활성화
        jdbcTemplate.update(
            "UPDATE orders SET status = 'DEACTIVATED' WHERE tenant_id = ?", tenantId
        );

        // Phase 3: 30일 후 배치로 하드 삭제 (별도 스케줄러)
        log.info("테넌트 삭제 요청 등록: tenant={}, 30일 후 하드 삭제 예정", tenantId);
    }
}

5-2) 테넌트별 백업 전략

격리 모델백업 방식복구 난이도
Shared Schema전체 백업 + 테넌트별 논리 추출★ 어려움 (다른 테넌트 영향)
Separate Schemapg_dump --schema=tenant_xxx★★ 중간
Separate DBpg_dump 인스턴스 단위★★★ 쉬움
# Separate Schema 백업 예시
pg_dump --schema=tenant_acme -Fc mydb > backup_acme_$(date +%Y%m%d).dump

# 복구 (다른 테넌트 영향 없음)
pg_restore --schema=tenant_acme -d mydb backup_acme_20251216.dump

6) 과금/쿼터: 플랫폼 운영의 현실

6-1) 테넌트별 사용량 메트릭

@Component
@RequiredArgsConstructor
public class TenantUsageTracker {

    private final MeterRegistry meterRegistry;
    private final RedisTemplate<String, String> redis;

    /**
     * API 호출 수 추적 (실시간)
     */
    public void trackApiCall(String tenantId, String endpoint) {
        // Prometheus 메트릭
        meterRegistry.counter("tenant.api.calls",
            "tenant", tenantId, "endpoint", endpoint).increment();

        // Redis 일별 카운터 (과금용)
        String key = String.format("usage:%s:%s", tenantId, LocalDate.now());
        redis.opsForHash().increment(key, "api_calls", 1);
        redis.expire(key, Duration.ofDays(90));
    }

    /**
     * 스토리지 사용량 추적 (배치)
     */
    @Scheduled(cron = "0 0 * * * *")  // 매시간
    public void trackStorageUsage() {
        List<TenantStorageUsage> usages = jdbcTemplate.query(
            "SELECT tenant_id, SUM(pg_total_relation_size(schemaname || '.' || tablename)) as bytes " +
            "FROM pg_tables WHERE schemaname LIKE 'tenant_%' GROUP BY tenant_id",
            (rs, _) -> new TenantStorageUsage(rs.getString(1), rs.getLong(2))
        );

        for (var usage : usages) {
            meterRegistry.gauge("tenant.storage.bytes",
                Tags.of("tenant", usage.tenantId()), usage.bytes());
        }
    }
}

6-2) 요금제별 쿼터/레이트 리밋

@Component
public class TenantRateLimiter {

    // 요금제별 제한
    private static final Map<String, TenantQuota> QUOTAS = Map.of(
        "FREE",       new TenantQuota(1000, 100, 1_000_000),    // 일 1000 req, 초 100, 1MB 스토리지
        "STARTER",    new TenantQuota(10_000, 500, 10_000_000),
        "BUSINESS",   new TenantQuota(100_000, 2000, 100_000_000),
        "ENTERPRISE", new TenantQuota(-1, -1, -1)                // 무제한
    );

    public void checkQuota(String tenantId, String plan) {
        TenantQuota quota = QUOTAS.get(plan);
        if (quota.dailyLimit() < 0) return;  // 무제한

        long todayUsage = getTodayUsage(tenantId);
        if (todayUsage >= quota.dailyLimit()) {
            throw new TenantQuotaExceededException(
                String.format("일일 API 호출 한도 초과: %d/%d (plan=%s)",
                    todayUsage, quota.dailyLimit(), plan)
            );
        }
    }
}

record TenantQuota(long dailyLimit, long rpsLimit, long storageLimitBytes) {}

6-3) 요금제 비교표 예시

기능FreeStarterBusinessEnterprise
일 API 호출1,00010,000100,000무제한
RPS1005002,000커스텀
스토리지1GB10GB100GB커스텀
데이터 보존30일1년3년커스텀
격리 모델SharedSharedSchema 분리DB 분리
SLA99%99.5%99.9%99.99%

7) 테넌트 온보딩/오프보딩 자동화

7-1) 온보딩 플로우

@Service
@RequiredArgsConstructor
@Transactional
public class TenantOnboardingService {

    public TenantOnboardingResult onboard(TenantCreateRequest request) {
        // 1. 테넌트 메타데이터 생성
        Tenant tenant = tenantRepository.save(Tenant.create(
            request.name(), request.plan(), request.adminEmail()
        ));

        // 2. 격리 모델에 따른 인프라 프로비저닝
        switch (tenant.getIsolationModel()) {
            case SHARED_SCHEMA -> createSharedSchemaSetup(tenant);
            case SEPARATE_SCHEMA -> createSchema(tenant);
            case SEPARATE_DB -> provisionDatabase(tenant);
        }

        // 3. 초기 데이터 시드
        seedInitialData(tenant);

        // 4. 관리자 계정 생성
        User admin = userService.createAdmin(tenant.getId(), request.adminEmail());

        // 5. 모니터링 설정
        monitoringService.setupTenantDashboard(tenant);

        log.info("테넌트 온보딩 완료: id={}, plan={}", tenant.getId(), tenant.getPlan());
        return new TenantOnboardingResult(tenant, admin);
    }

    private void createSchema(Tenant tenant) {
        String schema = "tenant_" + tenant.getId().toString().replace("-", "_");
        jdbcTemplate.execute("CREATE SCHEMA " + schema);
        
        // Flyway로 스키마 마이그레이션
        Flyway.configure()
            .dataSource(dataSource)
            .schemas(schema)
            .locations("classpath:db/migration/tenant")
            .load()
            .migrate();
    }
}

7-2) 오프보딩 체크리스트

public enum OffboardingStep {
    NOTIFY_ADMIN,           // 관리자에게 삭제 예정 알림
    EXPORT_DATA,            // 데이터 익스포트 (GDPR)
    REVOKE_API_KEYS,        // API 키 비활성화
    DEACTIVATE_USERS,       // 사용자 비활성화
    ARCHIVE_DATA,           // 데이터 아카이브 (cold storage)
    DELETE_SCHEMA,          // 스키마/DB 삭제
    CLEANUP_MONITORING,     // 모니터링 정리
    FINAL_AUDIT_LOG         // 최종 감사 로그
}

8) 모니터링과 알람

8-1) 핵심 메트릭

# 테넌트별 API 에러율
sum(rate(tenant_api_calls_total{status="5xx"}[5m])) by (tenant)
/ sum(rate(tenant_api_calls_total[5m])) by (tenant) > 0.01

# 테넌트별 레이턴시 p99
histogram_quantile(0.99, 
  sum(rate(http_request_duration_seconds_bucket[5m])) by (le, tenant)
) > 2.0

# 쿼터 사용률 80% 이상 (사전 경고)
tenant_quota_usage_ratio > 0.8

# 테넌트 간 데이터 크기 불균형 (핫 테넌트 감지)
max(tenant_storage_bytes) / avg(tenant_storage_bytes) > 10

8-2) 알람 설정

알람임계값심각도대응
테넌트 교차 접근1건Critical즉시 차단 + 인시던트
쿼터 80% 도달-Warning테넌트에 알림
쿼터 100% 도달-HighAPI 차단 + 업그레이드 안내
단일 테넌트 에러율 5%5분간High테넌트별 격리 확인
스토리지 증가율 비정상일 10%+Warning남용 확인

9) 안티패턴과 트러블슈팅

9-1) 흔한 안티패턴

안티패턴결과해결
WHERE tenant_id 수동 추가언젠가 누락 → 유출Hibernate Filter/RLS
테넌트 없는 글로벌 캐시캐시에서 교차 접근캐시 키에 tenant_id 포함
공유 시퀀스/AUTO_INCREMENTID로 테넌트 데이터 크기 유추UUID 또는 테넌트별 시퀀스
테넌트 미설정 허용NullPointerException 또는 전체 조회Filter에서 필수 검증
백그라운드 잡에서 컨텍스트 누락잘못된 테넌트로 실행Job 파라미터로 명시 전달

9-2) 캐시 키 설계

// ❌ 테넌트를 무시한 캐시
@Cacheable("orders")
public Order getOrder(Long orderId) { ... }
// → 테넌트 A의 캐시를 테넌트 B가 읽을 수 있음!

// ✅ 테넌트를 포함한 캐시
@Cacheable(value = "orders", key = "#tenantId + ':' + #orderId")
public Order getOrder(String tenantId, Long orderId) { ... }

// 또는 CacheKeyGenerator 공통 적용
@Component
public class TenantAwareCacheKeyGenerator implements KeyGenerator {
    @Override
    public Object generate(Object target, Method method, Object... params) {
        return TenantContext.getTenantId() + ":" + 
               method.getName() + ":" + 
               Arrays.stream(params).map(Object::toString).collect(Collectors.joining(":"));
    }
}

9-3) 비동기/백그라운드 잡에서 테넌트 전파

// ❌ ThreadLocal이 전파되지 않음
@Async
public void processAsync(Long orderId) {
    // TenantContext.getTenantId() → null!
}

// ✅ 명시적으로 전달
@Async
public void processAsync(String tenantId, Long orderId) {
    TenantContext.setTenantId(tenantId);
    try {
        // 처리
    } finally {
        TenantContext.clear();
    }
}

// 또는 TaskDecorator로 자동 전파
@Bean
public TaskExecutor taskExecutor() {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    executor.setTaskDecorator(runnable -> {
        String tenantId = TenantContext.getTenantId();
        return () -> {
            TenantContext.setTenantId(tenantId);
            try {
                runnable.run();
            } finally {
                TenantContext.clear();
            }
        };
    });
    return executor;
}

10) 부트스트랩 체크리스트

Day 1 (설계 결정)

  • 격리 모델 선택 (Shared/Schema/DB) + 근거 문서화
  • TenantContext + Filter 구현
  • 모든 쿼리에 tenant_id 자동 적용 (Hibernate Filter 또는 RLS)
  • 테넌트 교차 접근 탐지 로직 + 테스트

Week 1 (안정화)

  • tenant_id 포함 인덱스 설계
  • 캐시 키에 tenant_id 포함
  • 비동기 작업 tenant 전파 검증
  • 온보딩/오프보딩 자동화

Month 1 (운영 성숙)

  • 테넌트별 사용량 메트릭 + 대시보드
  • 쿼터/레이트 리밋 구현
  • GDPR 데이터 익스포트/삭제 자동화
  • 핫 테넌트 분리 플레이북 작성
  • 테넌트 격리 침투 테스트 (분기 1회)

연습(추천)

  • 내 서비스가 멀티테넌시라면 “격리 요구사항"을 숫자로 정의해보기(예: 다른 테넌트 데이터 접근 0, 핫 테넌트가 전체 p99에 영향 주면 안 됨 등)
  • Hibernate Filter를 적용하고, 의도적으로 tenant_id 필터를 빼본 쿼리가 차단되는지 확인해보기
  • PostgreSQL RLS를 설정하고, 다른 테넌트의 데이터를 INSERT/SELECT 시도해서 차단되는지 검증해보기
  • 테넌트 온보딩 API를 만들고, 스키마 생성부터 관리자 계정까지 자동화해보기

관련 심화 학습