티스토리 뷰

반응형

스프링 시큐리티 완전 처음부터 8편 (Kotlin)

멀티 테넌트(Multi-Tenant) 환경에서 인증/인가 설계 – B2B SaaS 실무 구조


이쯤 되면 질문이 하나 바뀐다.

“한 서비스에 여러 회사(조직) 가 들어오면
이 시큐리티 구조, 그대로 써도 되나요?”

정답부터 말하면
그대로 쓰면 100% 사고 난다.

왜냐면 지금까지 만든 구조는
‘사용자 단위’ 보안까지만 커버하고 있기 때문이다.

B2B, SaaS, 내부 업무 시스템에서는
보안의 기준이 하나 더 생긴다.

“이 사용자는 누구인가?” +
“어느 조직(테넌트)에 속해 있는가?”

이번 글은
멀티 테넌트 환경에서의 인증/인가를 어떻게 설계해야 하는지
진짜 실무 관점에서 풀어본다.


0. 멀티 테넌트, 말부터 정리하자

멀티 테넌트란 이거다.

하나의 서비스에 여러 ‘조직(회사)’이 존재하고,
각 조직의 데이터와 권한이 서로 완전히 분리된 구조

예시:

  • 사내 협업툴
  • 전자계약 서비스
  • 회계/ERP SaaS
  • 교육/관리 플랫폼

여기서 가장 위험한 사고는 단 하나다.

A 회사 사용자가
B 회사 데이터에 접근하는 사고

이건 장애가 아니라 보안 사고다.


1. 멀티 테넌트에서 절대 피해야 할 오해

반응형

❌ “ROLE 만 잘 나누면 되지 않나요?”

안 된다.

ROLE_ADMIN

이 정보만으로는:

  • 어느 회사의 관리자?
  • 어떤 조직 범위의 관리자?

를 알 수 없다.

👉 테넌트 정보는 ROLE과 다른 축이다.


2. 멀티 테넌트 보안의 핵심 원칙 (이건 외워야 한다)

🔒 원칙 1

모든 인증 정보에는 ‘tenant 식별자’가 포함되어야 한다

🔒 원칙 2

모든 데이터 접근은 tenant 기준으로 필터링된다

🔒 원칙 3

인가 판단은 (user + role + tenant) 조합으로 한다

이 원칙이 깨지는 순간
“우연히 다른 회사 데이터가 보였다” 같은 사고가 난다.


3. 테넌트 모델링부터 잡자 (DB 설계 관점)

3-1. Tenant 테이블

@Entity
@Table(name = "tenants")
class Tenant(
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long? = null,

    @Column(nullable = false, unique = true)
    val tenantKey: String, // 예: companyA, corp-123

    val name: String
)

3-2. User 테이블에 tenant 연결

@Entity
@Table(name = "users")
class User(
    val username: String,
    val password: String,
    val role: String,

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "tenant_id")
    val tenant: Tenant,

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long? = null
)

📌 중요 포인트

  • User는 반드시 하나의 Tenant에 속한다
  • “전사 관리자” 같은 케이스는 별도 설계가 필요 (나중 이야기)

4. JWT에 tenant 정보를 반드시 포함시켜야 하는 이유

지금까지 JWT에는 보통 이 정도만 넣었다.

{
  "sub": "username",
  "role": "ADMIN"
}

멀티 테넌트에서는 절대 부족하다.


4-1. JWT Payload – 최소 권장 형태

{
  "sub": "username",
  "role": "ADMIN",
  "tenantId": 3
}

또는

{
  "sub": "username",
  "role": "ADMIN",
  "tenantKey": "companyA"
}

👉 tenant 식별자는 JWT 안에 반드시 있어야 한다.


4-2. JwtProvider 수정 (개념 코드)

fun generateToken(username: String, role: String, tenantId: Long): String {
    return Jwts.builder()
        .setSubject(username)
        .claim("role", role)
        .claim("tenantId", tenantId)
        .setExpiration(expiry)
        .signWith(secretKey)
        .compact()
}

5. JwtAuthenticationFilter – tenant 정보까지 SecurityContext에 올리기

이제 인증 객체에는 이런 정보가 있어야 한다.

  • username
  • authorities (ROLE)
  • tenantId

5-1. Custom Principal 만들기 (실무 핵심)

class TenantUserPrincipal(
    val username: String,
    val role: String,
    val tenantId: Long
)

5-2. 인증 객체 생성 시 principal로 사용

val principal = TenantUserPrincipal(
    username = username,
    role = role,
    tenantId = tenantId
)

val authentication = UsernamePasswordAuthenticationToken(
    principal,
    null,
    listOf(SimpleGrantedAuthority("ROLE_$role"))
)

SecurityContextHolder.getContext().authentication = authentication

👉 이제 모든 요청에서 tenantId를 꺼낼 수 있다.


6. 컨트롤러 / 서비스에서 tenant 기준 데이터 접근

6-1. tenantId 꺼내기 유틸

fun currentTenantId(): Long {
    val auth = SecurityContextHolder.getContext().authentication
    val principal = auth.principal as TenantUserPrincipal
    return principal.tenantId
}

6-2. 서비스 레벨에서 필터링 (이게 정석)

@Service
class ContractService(
    private val contractRepository: ContractRepository
) {

    fun findContracts(): List<Contract> {
        val tenantId = currentTenantId()
        return contractRepository.findByTenantId(tenantId)
    }
}

📌 절대 하지 말아야 할 것

findAll() // ❌

멀티 테넌트에서 findAll() 은 거의 범죄다.


7. @PreAuthorize + tenant 조건 결합 (고급 패턴)

@PreAuthorize("hasRole('ADMIN') and #tenantId == principal.tenantId")
fun adminOnlyAction(tenantId: Long) {
    ...
}

👉 ROLE + tenant 조건을 동시에 체크

이 패턴은:

  • 관리자 API
  • 조직 설정 변경
  • 과금/결제 관련 API

에서 자주 쓰인다.


8. 실무에서 가장 많이 터지는 멀티 테넌트 사고 Top 5

❌ 1. JWT에 tenant 정보 없음

→ 인증은 됐는데, 데이터 분리가 안 됨

❌ 2. 컨트롤러에서만 tenant 체크

→ 다른 서비스에서 우회 호출 가능

❌ 3. Repository에 tenant 조건 빠짐

→ 실수 한 번으로 전사 데이터 노출

❌ 4. 관리자 계정 예외 처리 남발

→ “임시로” 만든 코드가 영원히 남음

❌ 5. 테스트 데이터가 tenant 1개뿐

→ 사고는 항상 두 번째 테넌트에서 터진다


9. 이 구조의 장점 (현실적인 평가)

  • 테넌트 단위 데이터 완전 분리
  • 인증/인가/데이터 접근 책임 명확
  • JWT 기반이라 확장성 좋음
  • 나중에 DB 분리(tenant 별)도 가능

👉 B2B SaaS에서 바로 써먹을 수 있는 구조


10. 여기까지 이해했다면, 이건 분명히 말할 수 있다

  • “멀티 테넌트 보안 구조 설계 경험 있습니다”
  • “JWT에 tenant 정보 포함해서 인증 구현했습니다”
  • “서비스 레벨에서 테넌트 격리 처리합니다”
  • “B2B SaaS 보안 사고 포인트 이해하고 있습니다”

이건 주니어 레벨에서 거의 안 나오는 경험이다.

 


출처

  • Spring Security Reference
  • JWT RFC 7519
  • 실무 B2B SaaS 보안 설계 경험 정리

 

스프링시큐리티, springsecurity, 코틀린, kotlin, 멀티테넌트, saas보안, jwt, 인증인가, 백엔드개발, 개발블로그

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2026/03   »
1 2 3 4 5 6 7
8 9 10 11 12 13 14
15 16 17 18 19 20 21
22 23 24 25 26 27 28
29 30 31
글 보관함
반응형