Pingu
영차영차! Backend

멀티테넌시 LMS 서비스 구축: Organization Subdomain 기반 아키텍처

2026년 1월 25일
7개 태그
멀티테넌시
LMS
아키텍처
Subdomain
Spring Boot
Kotlin
도메인 설계

멀티테넌시 LMS 서비스 구축: Organization Subdomain 기반 아키텍처

들어가며

"학원 A의 학생 데이터가 학원 B에서 보인다면 어떨까요?"

이런 끔찍한 시나리오를 상상해보세요. 한 학원의 선생님이 학생 목록을 조회했는데, 다른 학원의 학생 정보가 섞여서 나타나는 상황입니다. 개인정보 보호법 위반은 물론이고, 서비스 자체의 신뢰도가 땅에 떨어질 수밖에 없습니다.

저는 여러 교육 기관이 각자의 데이터를 완전히 독립적으로 관리하면서도, 동일한 LMS 플랫폼을 사용할 수 있는 멀티테넌시 시스템을 구축해야 했습니다. 각 조직(Organization)은 마치 별도의 건물에 살고 있지만, 같은 인프라와 코드베이스를 공유해야 하는 상황이었죠.

마치 아파트 단지처럼요. 각 세대(Organization)는 완전히 독립된 공간이지만, 같은 엘리베이터와 계단을 사용하는 것처럼 말이에요. 그런데 만약 101호 사람이 201호 집 열쇠로 문을 열 수 있다면?

처음에는 단순히 헤더에 X-Organization-Id를 넣어서 처리하면 될 거라고 생각했습니다.

개발자: "아, 헤더만 추가하면 되겠네요!"
실제 상황: 매번 헤더 추가하는 것 잊어버림
프론트엔드: "왜 API가 안 되죠?" 
백엔드: "헤더가 없어서요..."
프론트엔드: "아, 또 까먹었네요..."

하지만 프론트엔드 개발자들이 매번 헤더를 추가해야 하는 번거로움, 실수로 헤더를 빼먹으면 발생하는 버그, 그리고 코드 곳곳에 흩어진 OrganizationId 검증 로직... 이 모든 것이 기술 부채로 쌓여갔습니다.

마치 매번 집 열쇠를 들고 다니면서 "이 집이 내 집이 맞나?" 확인하는 것처럼 말이에요.

이번 글에서는 이런 문제들을 해결하기 위해 Organization Subdomain 기반 라우팅자동 OrganizationId 추출 메커니즘을 구축한 과정을 공유합니다. 특히 "어떻게 하면 개발자가 실수할 여지를 줄이면서도, 안전하게 데이터를 격리할 수 있을까?"라는 질문에 대한 답을 찾아가는 여정을 담았습니다.

전체 아키텍처 개요

멀티테넌시 시스템을 설계할 때 가장 중요한 것은 **테넌트 식별(Tenant Identification)**과 **데이터 격리(Data Isolation)**입니다. 저는 다음과 같은 아키텍처를 선택했습니다:

시스템 구조

다이어그램 로딩 중...

아키텍처 레이어

다이어그램 로딩 중...

핵심 설계 원칙

  1. Subdomain 기반 라우팅: 각 조직은 고유한 subdomain을 가짐
  2. 자동 OrganizationId 추출: ArgumentResolver를 통한 투명한 주입
  3. 애플리케이션 레벨 데이터 격리: 모든 쿼리에 organization_id 필터링
  4. Shared Database 전략: 단일 데이터베이스에서 스키마 레벨로 격리

Organization Subdomain 기반 라우팅

Subdomain 구조

각 조직은 고유한 subdomain을 가지며, 이를 통해 자동으로 조직을 식별합니다:

academy1.lms.s-class.com  → Organization ID: org-abc-123
academy2.lms.s-class.com  → Organization ID: org-def-456
admin.lms.s-class.com     → Organization ID: org-admin-789

Organization 엔티티 설계

data class Organization(
    val id: OrganizationId,
    val name: String,
    val subdomain: String,        // 고유한 subdomain (예: "academy1")
    val domain: String?,           // 도메인 (예: "lms.s-class.com")
    val logoUrl: String?,
    val settings: Map<String, Any>?,
    val status: OrganizationStatus,
    val createdAt: Instant,
    val updatedAt: Instant,
)

데이터베이스 스키마에서는 subdomain에 UNIQUE 제약조건을 걸어 중복을 방지합니다:

CREATE TABLE organizations (
    id VARCHAR(26) PRIMARY KEY,
    name VARCHAR(200) NOT NULL,
    subdomain VARCHAR(100) NOT NULL UNIQUE,  -- 고유 제약조건
    domain VARCHAR(255) UNIQUE,
    -- ...
);

CREATE UNIQUE INDEX idx_organizations_subdomain ON organizations(subdomain);

DNS 설정: 와일드카드 서브도메인 구성

"그런데 새로운 조직이 생길 때마다 DNS 레코드를 하나씩 추가해야 하나요?"

아니요. 와일드카드 서브도메인을 사용하면 한 번의 설정으로 모든 서브도메인을 처리할 수 있습니다.

CNAME 레코드 설정

도메인 제공업체(예: 가비아, Route53 등)에서 다음과 같이 설정합니다:

타입: CNAME
이름: *.lms (또는 *.lms.s-class.com)
값: 104cfe08c7b3656f.vercel-dns-017.com. (또는 실제 서버 주소)
TTL: 600

실제 DNS 설정 화면 예시:

DNS CNAME 설정

DNS CNAME 설정

이렇게 설정하면:

  • academy1.lms.s-class.com → 자동으로 서버로 라우팅
  • academy2.lms.s-class.com → 자동으로 서버로 라우팅
  • admin.lms.s-class.com → 자동으로 서버로 라우팅
  • 새로운 조직 추가 시 → DNS 설정 불필요

와일드카드 서브도메인 동작 방식

와일드카드 서브도메인(*.lms)을 설정하면, 모든 *.lms.s-class.com 형태의 도메인이 자동으로 같은 서버로 라우팅됩니다.

이제 새로운 조직을 추가할 때:

  1. 데이터베이스에 Organization 레코드 추가 (subdomain: "academy999")
  2. 완료

예를 들어 academy999.lms.s-class.com으로 접속하면, DNS가 자동으로 설정된 서버로 라우팅합니다.

DNS 설정은 이미 되어 있으므로, 별도 작업이 필요 없습니다.

실제 설정 예시 (Vercel 사용 시)

만약 Vercel을 사용한다면:

  1. Vercel 대시보드에서 도메인 추가

    • lms.s-class.com 도메인 추가
    • Vercel이 자동으로 DNS 레코드 생성
  2. 도메인 제공업체에서 CNAME 설정

    *.lms → cname.vercel-dns.com
    
  3. 완료

    • 이제 모든 *.lms.s-class.com 요청이 Vercel로 라우팅됨
    • Vercel이 요청을 백엔드 서버로 전달

다른 호스팅 서비스 사용 시

AWS Route53:

레코드 타입: CNAME
이름: *.lms
값: your-alb-123456789.us-east-1.elb.amazonaws.com

Cloudflare:

타입: CNAME
이름: *.lms
프록시: DNS 전용 (또는 프록시)
대상: your-server.com

일반 DNS 제공업체:

타입: CNAME
호스트: *.lms
값: your-backend-server.com

주의사항

  1. 루트 도메인과의 충돌

    • *.lms.s-class.com은 설정 가능
    • 하지만 *.s-class.com은 보통 불가능 (일부 DNS 제공업체만 지원)
    • 그래서 lms.s-class.com 서브도메인을 사용
  2. SSL 인증서

    • 와일드카드 서브도메인을 사용하려면 와일드카드 SSL 인증서 필요
    • Let's Encrypt: *.lms.s-class.com 인증서 발급 가능
    • 또는 각 서브도메인마다 인증서 발급 (비추천)
  3. DNS 전파 시간

    • DNS 설정 변경 후 전 세계에 전파되는데 시간이 걸림
    • 보통 5분~24시간 (TTL에 따라 다름)
    • 테스트할 때는 dig 또는 nslookup 명령어로 확인

DNS 설정 확인 방법

# 특정 서브도메인의 DNS 레코드 확인
dig academy1.lms.s-class.com CNAME

# 또는
nslookup academy1.lms.s-class.com

# 결과 예시:
# academy1.lms.s-class.com → 104cfe08c7b3656f.vercel-dns-017.com

실제 운영 시나리오

새로운 학원 가입
↓
관리자가 "academy-new" 서브도메인 선택
↓
데이터베이스에 Organization 추가
  - subdomain: "academy-new"
  - name: "새로운 학원"
↓
DNS는 이미 *.lms로 설정되어 있음
↓
즉시 academy-new.lms.s-class.com 접속 가능

이렇게 하면 새로운 조직 추가가 매우 간단해집니다. DNS 설정은 한 번만 하면 되고, 이후에는 데이터베이스에 레코드만 추가하면 됩니다!

OrganizationId 자동 추출: OrganizationIdArgumentResolver

"모든 Controller 메서드에 @RequestHeader("X-Organization-Id")를 추가하고, UseCase에도 organizationId를 전달하고... 이게 정말 최선일까?"

이런 반복적인 코드를 보면서, 더 나은 방법이 없을까 고민했습니다. Spring의 HandlerMethodArgumentResolver를 활용하면 이런 번거로움을 한 번에 해결할 수 있었습니다.

문제 상황: 반복되는 보일러플레이트 코드

Before (문제가 있던 코드):

@GetMapping("/students")
fun getStudents(
    @RequestHeader("X-Organization-Id") organizationIdHeader: String?,
): ResponseEntity<ApiResponse<StudentListResponse>> {
    // 매번 검증 로직 반복
    val organizationId = OrganizationId.of(
        organizationIdHeader ?: throw IllegalArgumentException("X-Organization-Id 헤더가 필요합니다.")
    )
    
    val (students, pagination) = studentManagementUseCase.getStudents(
        organizationId = organizationId,
        // ...
    )
    return ResponseEntity.ok(ApiResponse.success(students, pagination))
}

이 방식의 문제점:

  • 프론트엔드 개발자가 매번 헤더를 추가해야 함 → "아, 또 까먹었네요..."
  • 헤더를 빼먹으면 런타임 에러 발생 → 프로덕션에서 에러 발생 가능성
  • 코드 중복이 심함 → Ctrl+C, Ctrl+V의 향연 🎭
  • 테스트 작성이 번거로움 → 매번 헤더 모킹해야 함

이건 마치 매번 "이 집이 내 집이 맞나요?"라고 물어봐야 하는 것과 같아요. 집 주소를 매번 말해야 하는 거죠. 🏠

해결책: ArgumentResolver로 자동화

가장 핵심적인 부분은 HTTP 요청에서 자동으로 OrganizationId를 추출하는 메커니즘입니다. Spring의 HandlerMethodArgumentResolver를 활용하여 구현했습니다.

ArgumentResolver 구현

@Component
class OrganizationIdArgumentResolver(
    private val organizationRepository: OrganizationRepository,
) : HandlerMethodArgumentResolver {

    override fun supportsParameter(parameter: MethodParameter): Boolean {
        return parameter.hasParameterAnnotation(OrganizationId::class.java) &&
            parameter.parameterType == DomainOrganizationId::class.java
    }

    override fun resolveArgument(
        parameter: MethodParameter,
        mavContainer: ModelAndViewContainer?,
        webRequest: NativeWebRequest,
        binderFactory: WebDataBinderFactory?,
    ): Any? {
        val request = webRequest.getNativeRequest(HttpServletRequest::class.java)
            ?: throw IllegalStateException("HttpServletRequest를 가져올 수 없습니다.")

        // 1. Origin 헤더에서 추출 시도 (우선순위 1)
        val origin = request.getHeader("Origin")
        if (origin != null) {
            val originSubdomain = extractSubdomainFromUrl(origin)
            if (originSubdomain != null) {
                val organization = organizationRepository.findBySubdomain(originSubdomain)
                if (organization != null) {
                    return organization.id
                }
            }
        }

        // 2. Referer 헤더에서 추출 시도 (우선순위 2)
        val referer = request.getHeader("Referer")
        if (referer != null) {
            val refererSubdomain = extractSubdomainFromUrl(referer)
            if (refererSubdomain != null) {
                val organization = organizationRepository.findBySubdomain(refererSubdomain)
                if (organization != null) {
                    return organization.id
                }
            }
        }

        // 3. Host 헤더에서 추출 (우선순위 3)
        val host = request.getHeader("Host") ?: request.serverName
        val subdomain = extractSubdomain(host)
        if (subdomain != null) {
            val organization = organizationRepository.findBySubdomain(subdomain)
            if (organization != null) {
                return organization.id
            }
        }

        throw IllegalArgumentException(
            "OrganizationId를 찾을 수 없습니다. " +
                "Origin, Referer, 또는 Host 헤더에서 subdomain을 추출할 수 없습니다."
        )
    }
}

Subdomain 추출 로직

private fun extractSubdomain(host: String?): String? {
    if (host.isNullOrBlank()) return null

    // localhost인 경우 null 반환 (개발 환경)
    if (host.contains("localhost") || host.contains("127.0.0.1")) {
        return null
    }

    // subdomain.domain.com 형식에서 subdomain 추출
    val parts = host.split(".")
    return when {
        parts.size >= 3 -> {
            // subdomain.domain.com 또는 subdomain.domain.co.kr
            val subdomain = parts[0]
            if (subdomain != "www" && subdomain.isNotBlank()) {
                subdomain
            } else {
                null
            }
        }
        else -> null
    }
}

추출 우선순위

OrganizationIdArgumentResolver는 다음 순서로 OrganizationId를 추출합니다:

다이어그램 로딩 중...
  1. Origin 헤더 (최우선)

    • 프론트엔드에서 CORS 요청 시 자동으로 포함됨
    • 예: Origin: https://academy1.lms.s-class.com → subdomain: academy1
  2. Referer 헤더

    • 브라우저에서 직접 페이지 접근 시 포함됨
    • 예: Referer: https://academy1.lms.s-class.com/students → subdomain: academy1
  3. Host 헤더

    • 직접 subdomain으로 접근한 경우
    • 예: Host: academy1.lms.s-class.com → subdomain: academy1
  4. 예외 발생

    • 위 방법으로 찾을 수 없으면 IllegalArgumentException 발생

Controller에서 사용하기: Before & After

After (개선된 코드):

@RestController
@RequestMapping("/api/v1/students")
class StudentController(
    private val studentManagementUseCase: StudentManagementUseCase,
) {
    
    @GetMapping
    fun getStudents(
        @OrganizationId organizationId: OrganizationId,  // 자동 주입!
        @RequestParam(defaultValue = "1") page: Int,
        @RequestParam(defaultValue = "20") size: Int,
    ): ResponseEntity<ApiResponse<StudentListResponse>> {
        // organizationId는 이미 추출되어 있음
        val (students, pagination) = studentManagementUseCase.getStudents(
            organizationId = organizationId,
            page = page,
            size = size,
        )
        return ResponseEntity.ok(ApiResponse.success(students, pagination))
    }
}

변화:

  • ✅ 헤더 처리 코드 제거 → "이제 헤더 신경 안 써도 되네요!"
  • ✅ 검증 로직 제거 (ArgumentResolver에서 처리) → "자동으로 검증해주네요!"
  • ✅ 프론트엔드에서 헤더 추가 불필요 → "프론트엔드 개발자들이 좋아해요!"
  • ✅ 코드가 훨씬 간결해짐 → "가독성이 훨씬 좋아졌어요!"

Before: "매번 헤더 추가하고, 검증하고, 에러 처리하고..." After: "어노테이션 하나면 끝!"

DNS CNAME 설정

DNS CNAME 설정

@OrganizationId 어노테이션만 추가하면 자동으로 OrganizationId가 주입됩니다. 개발자는 subdomain 추출 로직을 전혀 신경 쓸 필요가 없습니다. 마치 Spring이 자동으로 @RequestParam을 주입해주는 것처럼 말이죠.

개발자: "어? OrganizationId는 어디서 오는 거죠?"
ArgumentResolver: "제가 알아서 처리했어요~ 😎"
개발자: "와, 신기해요!" ✨

이제는 집 주소를 말할 필요 없이, 자동으로 "아, 이 사람은 101호구나!"라고 알아서 인식하는 거예요. 🎯

실제 동작 시나리오

사용자가 https://academy1.lms.s-class.com/students에 접근하면:

  1. 프론트엔드: academy1.lms.s-class.com에서 API 호출
  2. HTTP 요청: Origin: https://academy1.lms.s-class.com 헤더 포함
  3. OrganizationIdArgumentResolver:
    • Origin 헤더에서 academy1 subdomain 추출
    • OrganizationRepository.findBySubdomain("academy1") 호출
    • OrganizationId 반환
  4. Controller: @OrganizationId 파라미터에 자동 주입
  5. UseCase: 해당 Organization의 학생만 조회

전체 과정이 투명하게 처리되어, 개발자는 비즈니스 로직에만 집중할 수 있습니다.

도메인 설계와 데이터 격리: "모든 데이터는 Organization의 소유"

데이터베이스 ERD (Organization 중심)

"학생 데이터가 어느 조직에 속하는지 어떻게 보장할까?"

이 질문에 대한 답은 간단했습니다. 모든 엔티티가 Organization을 부모로 가져야 한다는 것이죠.

마치:

  • 모든 파일이 폴더 안에 있어야 하는 것처럼
  • 모든 편지가 우편함에 있어야 하는 것처럼
  • 모든 물고기가 수족관에 있어야 하는 것처럼

모든 데이터는 Organization 안에 있어야 합니다. 그렇지 않으면 데이터 유출 위험이 있습니다.

다이어그램 로딩 중...

모든 엔티티는 organization_id를 외래 키로 가지며, 이를 통해 데이터가 완전히 격리됩니다.

Shared Database, Shared Schema 전략: 세 가지 선택지

멀티테넌시를 구현하는 방법은 크게 세 가지가 있습니다. 각각의 트레이드오프를 고민해야 했습니다:

옵션 1: Shared Database, Shared Schema (우리가 선택한 방법)

구조:

  • 모든 테이블에 organization_id 컬럼 추가
  • 애플리케이션 레벨에서 데이터 격리
  • 하나의 데이터베이스에 모든 조직 데이터 저장

장점:

  • ✅ 간단한 구조: 마이그레이션이 쉬움
  • ✅ 리소스 효율적: 하나의 DB 인스턴스로 관리
  • ✅ 비용 절감: 인프라 비용이 낮음
  • ✅ 쿼리 최적화: 복잡한 조인 쿼리 가능

단점:

  • ⚠️ 코드 레벨 주의 필요: 실수로 organization_id 필터링을 빼먹으면 데이터 유출 가능
  • ⚠️ 스키마 변경 시 영향: 모든 조직에 동시 적용

실제 사용 예시:

-- 모든 쿼리에 organization_id 필터링 필수
SELECT * FROM students 
WHERE organization_id = 'org-abc-123'  -- 이게 없으면 다른 조직 데이터도 조회됨!
  AND status = 'ACTIVE';

옵션 2: Shared Database, Separate Schema

구조:

  • 각 조직마다 별도 스키마 생성 (academy1, academy2, ...)
  • 스키마 레벨에서 격리

장점:

  • ✅ 완전한 데이터 격리: 스키마 자체가 격리 경계
  • ✅ 실수 방지: 다른 스키마 접근 불가

단점:

  • ❌ 스키마 관리 복잡: 조직 추가/삭제 시 스키마 생성/삭제 필요
  • ❌ 마이그레이션 어려움: 모든 스키마에 동일한 마이그레이션 적용
  • ❌ 쿼리 복잡도 증가: 동적 스키마 전환 필요

옵션 3: Separate Database

구조:

  • 각 조직마다 완전히 별도의 데이터베이스
  • 최고 수준의 격리

장점:

  • ✅ 최고 수준의 격리: 물리적으로 완전 분리
  • ✅ 독립적 스케일링: 조직별로 리소스 할당 가능

단점:

  • ❌ 리소스 비용 매우 높음: 조직 수만큼 DB 인스턴스 필요
  • ❌ 관리 복잡도 극대화: 백업, 모니터링 등 모든 것이 배수로 증가
  • ❌ 크로스 조직 쿼리 불가능

우리의 선택: 옵션 1

저는 **옵션 1 (Shared Database, Shared Schema)**을 선택했습니다.

선택 이유:

  1. 비용 효율성: 스타트업 단계에서 인프라 비용이 중요한 고려사항
  2. 개발 속도: 빠른 기능 개발과 배포가 필요
  3. 관리 편의성: 하나의 DB로 모든 것을 관리

리스크 완화 전략:

  • Repository 인터페이스에 organizationId를 필수 파라미터로 강제
  • 코드 리뷰 체크리스트에 "organization_id 필터링 확인" 항목 추가
  • 통합 테스트에서 다른 조직 데이터 접근 시나리오 검증

실제로 운영하면서 코드 리뷰와 테스트를 통해 데이터 유출 사고는 한 번도 발생하지 않았습니다.

운영 1년차: 데이터 유출 사고 0건
보안팀: "잘 하고 있네요!"
개발팀: "안심하고 개발할 수 있어요!"

물론 항상 조심해야 하지만, 이런 다층 방어 전략 덕분에 안심하고 개발할 수 있었습니다.

도메인 모델 설계

모든 도메인 엔티티는 organizationId를 필수로 가집니다:

// Student 도메인 모델
data class Student(
    val id: StudentId,
    val organizationId: OrganizationId,  // 필수 필드
    val teacherId: UserId,
    val name: String,
    val school: String?,
    val grade: Grade?,
    // ...
)

// Course 도메인 모델
data class Course(
    val id: CourseId,
    val organizationId: OrganizationId,  // 필수 필드
    val name: String,
    val description: String?,
    // ...
)

// ClassSession 도메인 모델
data class ClassSession(
    val id: ClassSessionId,
    val organizationId: OrganizationId,  // 필수 필드
    val courseId: CourseId,
    val sessionDate: LocalDate,
    // ...
)

Repository 레벨에서의 데이터 격리

모든 Repository 메서드는 organizationId를 필수 파라미터로 받습니다:

interface StudentRepository {
    fun findByOrganizationId(
        organizationId: OrganizationId,
        page: Int,
        size: Int,
    ): Pair<List<Student>, Pagination>
    
    fun findByIdAndOrganizationId(
        studentId: StudentId,
        organizationId: OrganizationId,
    ): Student?
    
    fun save(student: Student): Student
}

JPA Repository 구현에서는 항상 organization_id 조건을 포함합니다:

@Repository
class JpaStudentRepository(
    private val jpaRepository: SpringDataStudentRepository,
) : StudentRepository {
    
    override fun findByIdAndOrganizationId(
        studentId: StudentId,
        organizationId: OrganizationId,
    ): Student? {
        return jpaRepository.findByIdAndOrganizationId(
            studentId.value,
            organizationId.value,
        )?.toDomain()
    }
}

Spring Data JPA 인터페이스:

interface SpringDataStudentRepository : JpaRepository<StudentEntity, String> {
    fun findByIdAndOrganizationId(
        id: String,
        organizationId: String,
    ): StudentEntity?
    
    fun findByOrganizationId(
        organizationId: String,
        pageable: Pageable,
    ): Page<StudentEntity>
}

UseCase 레벨에서의 검증

UseCase에서도 항상 organizationId를 검증합니다:

@Service
class StudentManagementUseCaseImpl(
    private val studentRepository: StudentRepository,
    private val organizationRepository: OrganizationRepository,
) : StudentManagementUseCase {
    
    override fun getStudent(
        studentId: StudentId,
        organizationId: OrganizationId,
    ): Student {
        // Organization 존재 여부 확인
        val organization = organizationRepository.findById(organizationId)
            ?: throw IllegalArgumentException("Organization을 찾을 수 없습니다.")
        
        // Student 조회 (자동으로 organization_id 필터링됨)
        return studentRepository.findByIdAndOrganizationId(studentId, organizationId)
            ?: throw IllegalArgumentException("Student를 찾을 수 없습니다.")
    }
}

파일 저장소의 멀티테넌시

GCP Object Storage를 사용하는 파일 저장소도 Organization별로 격리합니다:

버킷 구조

다이어그램 로딩 중...

각 Organization은 완전히 독립된 디렉토리 구조를 가지며, 파일 경로에 organization_id가 포함되어 자동으로 격리됩니다.

각 파일의 저장 경로에 organization_id를 포함시켜 완전히 격리합니다:

fun buildStoragePath(
    organizationId: OrganizationId,
    fileType: FileType,
    fileName: String,
): String {
    val date = LocalDate.now()
    return "${organizationId.value}/${fileType.path}/${date.year}/${date.monthValue}/$fileName"
}

보안 고려사항: "한 번의 실수가 서비스 전체를 망칠 수 있다"

1. Row Level Security (RLS): 코드 레벨에서의 방어

가장 무서운 시나리오:

// 실수로 organization_id 필터링을 빼먹은 경우
fun getAllStudents(): List<Student> {
    return studentRepository.findAll()  // 모든 조직의 학생이 조회됨!
}

이런 코드가 프로덕션에 배포되면?

개발자: "학생 목록 조회 기능 완성!"
학원 A 선생님: "어? 우리 학원 학생이 아닌데?"
학원 B 선생님: "어? 우리 학원 학생도 보이네요?"
개발자: "...죄송합니다"
법무팀: "개인정보 보호법 위반입니다"

개인정보 보호법 위반, 서비스 신뢰도 하락, 심각한 법적 문제까지 발생할 수 있습니다. 한 줄의 실수가 서비스 전체를 망칠 수 있습니다.

방어 전략: 다층 방어선 구축

  1. Repository 인터페이스 레벨에서 강제

    // organizationId 없이는 조회 불가능하도록 설계
    interface StudentRepository {
        // ❌ 이런 메서드는 절대 만들지 않음
        // fun findAll(): List<Student>
        
        // ✅ 항상 organizationId 필수
        fun findByOrganizationId(organizationId: OrganizationId): List<Student>
    }
  2. UseCase 레벨에서 검증

    override fun getStudent(studentId: StudentId, organizationId: OrganizationId): Student {
        // Organization 존재 여부 먼저 확인
        val organization = organizationRepository.findById(organizationId)
            ?: throw IllegalArgumentException("Organization을 찾을 수 없습니다.")
        
        // organizationId와 함께 조회 (자동 필터링)
        return studentRepository.findByIdAndOrganizationId(studentId, organizationId)
            ?: throw IllegalArgumentException("Student를 찾을 수 없습니다.")
    }
  3. 코드 리뷰 체크리스트

    • 모든 Repository 메서드에 organizationId 파라미터가 있는가?
    • SQL 쿼리에 WHERE organization_id = ? 조건이 있는가?
    • UseCase에서 Organization 존재 여부를 검증하는가?
  4. 통합 테스트로 검증

    @Test
    fun `다른 조직의 데이터는 조회되지 않아야 한다`() {
        // Given: 두 개의 조직과 각각의 학생
        val org1 = createOrganization("org1")
        val org2 = createOrganization("org2")
        val student1 = createStudent(org1.id)
        val student2 = createStudent(org2.id)
        
        // When: org1의 컨텍스트에서 학생 조회
        val students = studentRepository.findByOrganizationId(org1.id)
        
        // Then: org1의 학생만 조회되어야 함
        assertThat(students).containsExactly(student1)
        assertThat(students).doesNotContain(student2)
    }

2. API 인증 및 권한

JWT 토큰에는 userIdorganizationId가 포함됩니다. API Gateway에서 토큰을 검증하고 X-User-Id 헤더를 추가합니다.

// CurrentUserArgumentResolver에서도 OrganizationId 추출
private fun extractOrganizationId(request: HttpServletRequest): OrganizationId {
    // 동일한 subdomain 추출 로직 사용
    // ...
}

3. 감사 로그

모든 데이터 접근에 organizationId를 기록하여 추적 가능성을 확보합니다.

마이그레이션 전략

기존 단일 테넌트 시스템을 멀티테넌시로 전환하는 경우:

  1. organizations 테이블 생성

    CREATE TABLE organizations (
        id VARCHAR(26) PRIMARY KEY,
        name VARCHAR(200) NOT NULL,
        subdomain VARCHAR(100) NOT NULL UNIQUE,
        -- ...
    );
  2. 기본 Organization 생성

    INSERT INTO organizations (id, name, subdomain, status, created_at, updated_at)
    VALUES ('default-org-id', 'Default Organization', 'default', 'ACTIVE', NOW(), NOW());
  3. 모든 테이블에 organization_id 컬럼 추가

    ALTER TABLE students ADD COLUMN organization_id VARCHAR(26);
    UPDATE students SET organization_id = 'default-org-id';
    ALTER TABLE students ALTER COLUMN organization_id SET NOT NULL;
  4. 인덱스 생성

    CREATE INDEX idx_students_organization_id ON students(organization_id);
    CREATE INDEX idx_courses_organization_id ON courses(organization_id);
    -- 모든 테이블에 organization_id 인덱스 생성
  5. 외래 키 제약조건 추가

    ALTER TABLE students 
    ADD CONSTRAINT fk_students_organization 
    FOREIGN KEY (organization_id) REFERENCES organizations(id);

성능 최적화

인덱스 전략

모든 테이블의 organization_id에 인덱스를 생성합니다:

-- 단일 컬럼 인덱스
CREATE INDEX idx_students_organization_id ON students(organization_id);

-- 복합 인덱스 (자주 함께 조회되는 컬럼과)
CREATE INDEX idx_students_org_status ON students(organization_id, status);
CREATE INDEX idx_class_sessions_org_date ON class_sessions(organization_id, session_date);

쿼리 최적화

organization_id를 먼저 필터링하여 데이터 범위를 줄입니다:

-- 좋은 예: organization_id를 먼저 필터링
SELECT * FROM students 
WHERE organization_id = 'org-123' 
  AND status = 'ACTIVE'
  AND name LIKE '%홍%';

-- 나쁜 예: organization_id 필터링이 없음
SELECT * FROM students 
WHERE status = 'ACTIVE' 
  AND name LIKE '%홍%';

결론: "단순함과 안전함의 균형"

Organization Subdomain 기반 멀티테넌시 아키텍처를 구축하면서 얻은 것들:

달성한 목표

  1. 개발자 경험 개선

    • @OrganizationId 어노테이션 하나로 자동 주입
    • 보일러플레이트 코드 제거
    • 실수할 여지 최소화
  2. 안전한 데이터 격리

    • 애플리케이션 레벨에서 강제되는 격리
    • 코드 리뷰와 테스트로 이중 검증
    • 운영 중 데이터 유출 사고 0건
  3. 확장성과 비용 효율성

    • 새로운 조직 추가가 단순함 (subdomain만 등록)
    • Shared Database로 인프라 비용 절감
    • 하나의 코드베이스로 모든 조직 지원

💡 배운 교훈

"완벽한 격리보다 실용적인 격리가 더 중요하다"

처음에는 Separate Database나 Separate Schema를 고려했지만, 실제로는 Shared Database + 애플리케이션 레벨 격리로 충분했습니다. 코드 리뷰와 테스트를 통해 보안을 보장하면서도, 개발 속도와 비용 효율성을 확보할 수 있었습니다.

"자동화는 실수를 줄이는 가장 좋은 방법"

ArgumentResolver를 통해 OrganizationId 추출을 자동화한 것이 가장 큰 성과였습니다. 개발자가 신경 쓸 부분이 줄어들수록, 실수할 여지도 줄어들었죠.

다음 단계

현재 구조로도 충분히 안전하고 효율적이지만, 더 나아가려면:

  1. 데이터베이스 레벨 RLS 구현: PostgreSQL의 Row Level Security를 활용하여 애플리케이션 레벨 실수를 추가로 방어
  2. 모니터링 강화: 다른 조직 데이터 접근 시도 감지 및 알림
  3. 자동화된 테스트: 모든 Repository 메서드에 자동으로 크로스 조직 접근 테스트 추가

하지만 지금도 충분히 잘 작동하고 있습니다.

완벽한 솔루션: "모든 조직에 별도 DB를 만들어야 해요!"
충분히 좋은 솔루션: "Shared DB + 코드 레벨 격리면 충분해요!"
결과: 비용 절감 + 개발 속도 향상 + 안전성 확보

때로는 "충분히 좋은" 솔루션이 "완벽한" 솔루션보다 더 가치 있을 수 있으니까요.

마치 완벽한 집을 짓기 위해 평생 모으는 것보다, 지금 살 수 있는 집에서 행복하게 사는 게 더 나을 수도 있는 것처럼요.

댓글

?