🏒 Boxwood Multi-Tenancy Architecture

Schema-per-Tenant λ°©μ‹μ˜ λ©€ν‹°ν…Œλ„Œμ‹œ κ΅¬ν˜„ 상세 뢄석

Kotlin 2.0 Spring Boot 3.x Exposed ORM Flyway Migration

1. System Overview (High-Level)

flowchart TB subgraph Client["🌐 Client Layer"] REQ["HTTP Request
+ JWT Token"] end subgraph SpringBoot["πŸƒ Spring Boot Application"] direction TB subgraph Security["Security Layer"] JWT_FILTER["JwtAuthenticationFilter"] TENANT_INT["TenantInterceptor"] end subgraph Context["Context Layer"] TC["TenantContext
(InheritableThreadLocal)"] TRC["TenantReactorContextAccessor
(Reactor Context)"] end subgraph Transaction["Transaction Layer"] MTTM["MultiTenantTransactionManager"] TSTM["TenantSpringTransactionManager"] TTA["TenantTransactionalAspect
(@TenantTransactional)"] end subgraph DataSource["DataSource Layer"] DSC["DataSourceConfig"] TDS["tenantDataSource
(HikariPool: 40)"] SDS["sharedDataSource
(HikariPool: 15)"] end subgraph Tenant["Tenant Management"] TCFG["TenantConfig"] TV["TenantValidator"] TDC["TenantDatabaseCache
(Caffeine)"] DDF["DatabaseDialectFactory"] end end subgraph Database["πŸ—„οΈ MariaDB"] SHARED["Shared Schema
- tbl_boxwood_tenant
- tbl_user
- 곡톡 μ½”λ“œ"] T1["tenant_A Schema"] T2["tenant_B Schema"] T3["tenant_C Schema"] end REQ --> JWT_FILTER JWT_FILTER --> TENANT_INT TENANT_INT --> TC TC --> TRC TC --> TTA TTA --> MTTM MTTM --> TSTM TSTM --> TDS TSTM --> DDF DSC --> TDS DSC --> SDS TDS --> T1 TDS --> T2 TDS --> T3 SDS --> SHARED TDC --> TDS TV --> TCFG

2. Class Diagram (상세)

classDiagram direction TB %% Security Layer class JwtAuthenticationFilter { -JwtTokenService jwtTokenService -TokenStorageService tokenStorageService -AuthenticationService authService +doFilterInternal() -extractAndValidateToken() -setTenantFromToken() } class TenantInterceptor { -DwpAuthService dwpAuthService -BoxwoodTenantRepository tenantRepository -TenantContext tenantContext -TenantConfig tenantConfig -TenantValidator tenantValidator +preHandle() boolean +afterCompletion() -handleWithDwpToken() boolean -handleWithXCompanyHeader() -convertTenantKeyToSchemaName() String } %% Context Layer class TenantContext { -InheritableThreadLocal~String~ currentTenant$ -String defaultSchema +setCurrentTenant(schema)$ TenantContextHolder +getCurrentTenant()$ String +clear()$ +isSet()$ boolean +getCurrentTenantOrDefault() String +executeWithTenant(schema, block) T } class TenantContextHolder { -String previousTenant -boolean closed +close() } class TenantReactorContextAccessor { +TENANT_CONTEXT_KEY$ String +writeToContext()$ Function +getFromContext(contextView)$ String +restoreFromContext(contextView)$ +propagateToMono(mono)$ Mono +syncWithThreadLocal()$ Mono } %% Transaction Layer class TenantSpringTransactionManager { -Database tenantDatabase -DataSource tenantDataSource -TenantValidator tenantValidator -BoxwoodTenantRepository tenantRepository -DatabaseDialectFactory dialectFactory +doGetTransaction() Object +doBegin(transaction, definition) +doCommit(status) +doRollback(status) -switchToSchema(schema) -validateTenantRegistrationAndState(schema) } class TenantTransactionalAspect { -MultiTenantTransactionManager txManager +aroundTenantTransactional() Object +aroundTenantTransactionalClass() Object -handleRegularFunction() Object -handleSuspendFunction() Object } %% DataSource Layer class DataSourceConfig { +tenantDataSource() DataSource +sharedDataSource() DataSource } %% Tenant Management class TenantConfig { +String defaultSchema +String schemaPrefix +String schemaSuffix +int maxTenantCodeLength +boolean allowDefaultFallback +boolean enableDatabaseCache +long databaseCacheMaxSize +long databaseCacheExpireMinutes +boolean validateTenantRegistration +buildSchemaName(tenantCode, profile) String -sanitizeTenantCode(tenantCode) String } class TenantValidator { +validateSchema(schema) String +validateTenantCode(code) String } class TenantDatabaseCache { -DataSource tenantDataSource -TenantValidator tenantValidator -TenantConfig tenantConfig -Cache~String,Database~ cache +getDatabase(schema) Database +invalidate(schema) +invalidateAll() +stats() CacheStats +size() long +isEnabled() boolean } class DatabaseDialectFactory { +detectDialect(dataSource) DatabaseDialect +createDialect(type) DatabaseDialect } class DatabaseDialect { [[interface]] +switchSchema(connection, schema) +getCurrentSchema(connection) String +getDatabaseType() DatabaseType } class MySQLDialect { +switchSchema(connection, schema) +getCurrentSchema(connection) String +getDatabaseType() DatabaseType } %% JWT Service class JwtTokenService { -String jwtSecret -long accessTokenExpiration -long refreshTokenExpiration +generateAccessToken() String +generateRefreshToken() String +extractUsername(token) String +extractUserId(token) Long +extractCompanyId(token) String +isTokenValid(token) boolean +extractDwpClaims(token) DwpClaims } %% Relationships JwtAuthenticationFilter --> JwtTokenService JwtAuthenticationFilter --> TenantContext TenantInterceptor --> TenantContext TenantInterceptor --> TenantConfig TenantInterceptor --> TenantValidator TenantContext --> TenantContextHolder TenantReactorContextAccessor --> TenantContext TenantSpringTransactionManager --> TenantContext TenantSpringTransactionManager --> TenantValidator TenantSpringTransactionManager --> DatabaseDialectFactory TenantTransactionalAspect --> TenantSpringTransactionManager TenantDatabaseCache --> TenantValidator TenantDatabaseCache --> TenantConfig DatabaseDialectFactory --> DatabaseDialect MySQLDialect ..|> DatabaseDialect

3. HTTP Request 처리 흐름 (Sequence)

sequenceDiagram autonumber participant Client as 🌐 Client participant Filter as JwtAuthenticationFilter participant JwtSvc as JwtTokenService participant Interceptor as TenantInterceptor participant DwpAuth as DwpAuthService participant TenantRepo as BoxwoodTenantRepository participant Context as TenantContext participant Aspect as TenantTransactionalAspect participant TxMgr as TenantSpringTransactionManager participant Validator as TenantValidator participant Dialect as DatabaseDialect participant Pool as TenantDataSource participant DB as MariaDB Client->>Filter: HTTP Request + Authorization Header rect rgb(40, 40, 80) Note over Filter,JwtSvc: πŸ” JWT 인증 단계 Filter->>Filter: extractTokenFromHeader() Filter->>JwtSvc: isTokenValid(token) JwtSvc-->>Filter: true Filter->>JwtSvc: extractCompanyId(token) JwtSvc-->>Filter: "COMPANY_A" end rect rgb(40, 80, 40) Note over Interceptor,Context: 🏒 ν…Œλ„ŒνŠΈ κ²°μ • 단계 Filter->>Interceptor: preHandle() alt JWT에 companyId 있음 Interceptor->>TenantRepo: findByTenantKey("COMPANY_A") TenantRepo-->>Interceptor: BoxwoodTenant(tenantSchema="boxwood_portal_company_a") else DWP Token μ‚¬μš© Interceptor->>DwpAuth: verifyDwpToken(cookie) DwpAuth-->>Interceptor: DwpTokenVerifyResponse(companyId) else X-Company Header μ‚¬μš© Interceptor->>Interceptor: getHeader("X-Company") end Interceptor->>Validator: validateSchema("boxwood_portal_company_a") Validator-->>Interceptor: validated schema Interceptor->>Context: setCurrentTenant("boxwood_portal_company_a") Context-->>Interceptor: TenantContextHolder end rect rgb(80, 40, 40) Note over Aspect,DB: πŸ’Ύ νŠΈλžœμž­μ…˜ & μŠ€ν‚€λ§ˆ μ „ν™˜ 단계 Client->>Aspect: @TenantTransactional λ©”μ„œλ“œ 호좜 Aspect->>TxMgr: transaction { ... } TxMgr->>Context: getCurrentTenant() Context-->>TxMgr: "boxwood_portal_company_a" TxMgr->>Validator: validateSchema() TxMgr->>TenantRepo: findByTenantSchemaAnyState() TenantRepo-->>TxMgr: BoxwoodTenant(state=ACTIVE) TxMgr->>Pool: getConnection() Pool-->>TxMgr: Connection TxMgr->>Dialect: switchSchema(conn, schema) Dialect->>DB: USE `boxwood_portal_company_a` DB-->>Dialect: OK end rect rgb(60, 60, 80) Note over Client,DB: πŸ“Š λΉ„μ¦ˆλ‹ˆμŠ€ 둜직 μ‹€ν–‰ Aspect->>DB: SELECT/INSERT/UPDATE... DB-->>Aspect: Result Aspect->>TxMgr: commit TxMgr-->>Aspect: committed end Aspect-->>Client: Response rect rgb(80, 60, 40) Note over Interceptor,Context: 🧹 정리 단계 Interceptor->>Context: clear() Context->>Context: ThreadLocal.remove() end

4. ν…Œλ„ŒνŠΈ κ²°μ • μš°μ„ μˆœμœ„ (Decision Flow)

flowchart TB START([HTTP Request 도착]) --> CHECK_JWT{JWT Token
있음?} CHECK_JWT -->|Yes| PARSE_JWT[JwtTokenService.extractCompanyId] CHECK_JWT -->|No| CHECK_DWP{DWP_TOKEN
Cookie 있음?} PARSE_JWT --> HAS_COMPANY{companyId
μΆ”μΆœ 성곡?} HAS_COMPANY -->|Yes| LOOKUP_DB1[BoxwoodTenantRepository
.findByTenantKey] HAS_COMPANY -->|No| CHECK_DWP CHECK_DWP -->|Yes| VERIFY_DWP[DwpAuthService
.verifyDwpToken] CHECK_DWP -->|No| CHECK_HEADER{X-Company
Header 있음?} VERIFY_DWP --> DWP_VALID{검증 성곡?} DWP_VALID -->|Yes| LOOKUP_DB2[BoxwoodTenantRepository
.findByTenantKey] DWP_VALID -->|No| CHECK_HEADER CHECK_HEADER -->|Yes| LOOKUP_DB3[BoxwoodTenantRepository
.findByTenantKey] CHECK_HEADER -->|No| CHECK_FALLBACK{allowDefaultFallback
== true?} LOOKUP_DB1 --> FOUND1{ν…Œλ„ŒνŠΈ
찾음?} LOOKUP_DB2 --> FOUND2{ν…Œλ„ŒνŠΈ
찾음?} LOOKUP_DB3 --> FOUND3{ν…Œλ„ŒνŠΈ
찾음?} FOUND1 -->|Yes| VALIDATE[TenantValidator
.validateSchema] FOUND1 -->|No| BUILD_SCHEMA[TenantConfig
.buildSchemaName] FOUND2 -->|Yes| VALIDATE FOUND2 -->|No| BUILD_SCHEMA FOUND3 -->|Yes| VALIDATE FOUND3 -->|No| BUILD_SCHEMA BUILD_SCHEMA --> VALIDATE VALIDATE --> SET_CONTEXT[TenantContext
.setCurrentTenant] SET_CONTEXT --> SUCCESS([βœ… μš”μ²­ 처리 계속]) CHECK_FALLBACK -->|Yes, Dev/Test| USE_DEFAULT[κΈ°λ³Έ μŠ€ν‚€λ§ˆ μ‚¬μš©
defaultSchema] CHECK_FALLBACK -->|No, Production| REJECT([❌ 401 Unauthorized
Tenant identification failed]) USE_DEFAULT --> SET_CONTEXT style START fill:#4a5568 style SUCCESS fill:#48bb78 style REJECT fill:#f56565 style VALIDATE fill:#4299e1 style SET_CONTEXT fill:#9f7aea
πŸ”‘ ν…Œλ„ŒνŠΈ κ²°μ • μš°μ„ μˆœμœ„:
  1. JWT Token의 companyId claim (κ°€μž₯ 높은 μš°μ„ μˆœμœ„)
  2. DWP_TOKEN Cookie β†’ DwpAuthService 검증
  3. X-Company HTTP Header
  4. κΈ°λ³Έ μŠ€ν‚€λ§ˆ Fallback (dev/test ν™˜κ²½λ§Œ)

5. νŠΈλžœμž­μ…˜ 관리 & μŠ€ν‚€λ§ˆ μ „ν™˜

sequenceDiagram autonumber participant Service as @TenantTransactional
Service Method participant Aspect as TenantTransactionalAspect participant TxMgr as MultiTenant
TransactionManager participant SpringTx as TenantSpring
TransactionManager participant Validator as TenantValidator participant TenantRepo as BoxwoodTenant
Repository participant Pool as TenantDataSource
(HikariPool) participant DB as MariaDB Service->>Aspect: λ©”μ„œλ“œ 호좜 rect rgb(50, 50, 80) Note over Aspect,TxMgr: AOP Advice μ‹€ν–‰ Aspect->>Aspect: isSuspendFunction? alt 일반 ν•¨μˆ˜ Aspect->>TxMgr: transaction { joinPoint.proceed() } else suspend ν•¨μˆ˜ Aspect->>Aspect: runBlocking + withContext Aspect->>TxMgr: transaction { ... } end end rect rgb(80, 50, 50) Note over TxMgr,DB: νŠΈλžœμž­μ…˜ μ‹œμž‘ (doBegin) TxMgr->>SpringTx: doBegin() SpringTx->>SpringTx: TenantContext.getCurrentTenant() SpringTx->>Validator: validateSchema(schema) Note over SpringTx,TenantRepo: ν…Œλ„ŒνŠΈ 등둝 & μƒνƒœ 검증 SpringTx->>TenantRepo: findByTenantSchemaAnyState() TenantRepo-->>SpringTx: BoxwoodTenant alt tenant.state == ACTIVE SpringTx->>SpringTx: βœ… 검증 톡과 else tenant.state == INACTIVE/SUSPENDED/DELETED SpringTx-->>Aspect: ❌ ETTenantException end Note over SpringTx,DB: μŠ€ν‚€λ§ˆ μ „ν™˜ SpringTx->>Pool: getConnection() Pool-->>SpringTx: Connection SpringTx->>DB: USE `{schema}` DB-->>SpringTx: OK end rect rgb(50, 80, 50) Note over Service,DB: λΉ„μ¦ˆλ‹ˆμŠ€ 둜직 μ‹€ν–‰ TxMgr->>Service: proceed() Service->>DB: SQL Queries DB-->>Service: Results end rect rgb(80, 80, 50) Note over TxMgr,DB: νŠΈλžœμž­μ…˜ μ’…λ£Œ alt 성곡 TxMgr->>SpringTx: doCommit() SpringTx->>DB: COMMIT else μ‹€νŒ¨ TxMgr->>SpringTx: doRollback() SpringTx->>DB: ROLLBACK end end
⚑ μŠ€ν‚€λ§ˆ μ „ν™˜ 방식 (DatabaseDialect):
Database SQL Command Class
MySQL / MariaDB USE `schema_name` MySQLDialect
MS SQL Server USE [schema_name] MSSQLDialect
PostgreSQL SET search_path TO schema_name PostgreSQLDialect

6. 법인 등둝 μ‹œ μŠ€ν‚€λ§ˆ 생성 ν”„λ‘œμ„ΈμŠ€

sequenceDiagram autonumber participant Admin as πŸ‘€ Admin participant API as REST API participant TenantSvc as TenantService participant TenantRepo as BoxwoodTenant
Repository participant SharedDS as SharedDataSource participant TenantDS as TenantDataSource participant Flyway as FlywayMigration
Service participant DB as MariaDB Admin->>API: POST /api/tenants
{tenantKey: "COMPANY_X", ...} API->>TenantSvc: createTenant(request) rect rgb(50, 80, 50) Note over TenantSvc,DB: Step 1: Shared Schema에 법인 정보 등둝 TenantSvc->>TenantSvc: TenantConfig.buildSchemaName("COMPANY_X") Note right of TenantSvc: "boxwood_portal_company_x" TenantSvc->>TenantRepo: save(BoxwoodTenant) TenantRepo->>SharedDS: INSERT INTO tbl_boxwood_tenant SharedDS->>DB: [boxwood_portal_*_shared]
INSERT INTO tbl_boxwood_tenant
(tenant_key, tenant_schema, tenant_state, ...) DB-->>SharedDS: OK end rect rgb(80, 50, 50) Note over TenantSvc,DB: Step 2: ν…Œλ„ŒνŠΈ μŠ€ν‚€λ§ˆ 생성 TenantSvc->>TenantDS: createSchema() TenantDS->>DB: CREATE SCHEMA IF NOT EXISTS
`boxwood_portal_company_x` DB-->>TenantDS: Schema Created end rect rgb(50, 50, 80) Note over TenantSvc,DB: Step 3: Flyway λ§ˆμ΄κ·Έλ ˆμ΄μ…˜ μ‹€ν–‰ TenantSvc->>Flyway: migrate("boxwood_portal_company_x") Flyway->>TenantDS: USE `boxwood_portal_company_x` loop 각 λ§ˆμ΄κ·Έλ ˆμ΄μ…˜ 파일 Flyway->>DB: V1__init_schema.sql Flyway->>DB: V2__create_tables.sql Flyway->>DB: V3__seed_data.sql Flyway->>DB: ... end Flyway->>DB: INSERT INTO flyway_schema_history DB-->>Flyway: Migration Complete end TenantSvc-->>API: TenantResponse API-->>Admin: 201 Created
{tenantKey, tenantSchema, status}
πŸ“ Flyway λ§ˆμ΄κ·Έλ ˆμ΄μ…˜ ꡬ쑰:
src/main/resources/
└── db/
    └── migration/
        β”œβ”€β”€ shared/           # Shared Schema μ „μš©
        β”‚   β”œβ”€β”€ V1__init_shared.sql
        β”‚   β”œβ”€β”€ V2__create_tenant_table.sql
        β”‚   └── V3__create_user_table.sql
        └── tenant/           # Tenant Schema μ „μš©
            β”œβ”€β”€ V1__init_tenant.sql
            β”œβ”€β”€ V2__create_process_tables.sql
            └── V3__create_workflow_tables.sql
                

7. μ£Όμš” μ»΄ν¬λ„ŒνŠΈ 상세

7.1 TenantContext (ThreadLocal 관리)

flowchart LR subgraph Thread1["Thread #1"] T1_REQ["Request A
tenant: company_a"] T1_CTX["TenantContext
ThreadLocal: company_a"] end subgraph Thread2["Thread #2"] T2_REQ["Request B
tenant: company_b"] T2_CTX["TenantContext
ThreadLocal: company_b"] end subgraph Thread3["Thread #3 (Async)"] T3_CTX["TenantContext
Inherited: company_a"] T3_NOTE["InheritableThreadLocal
λΆ€λͺ¨ Threadμ—μ„œ 상속"] end T1_REQ --> T1_CTX T2_REQ --> T2_CTX T1_CTX -.->|"@Async 호좜"| T3_CTX T3_CTX --- T3_NOTE

7.2 Database Connection μž¬μ‚¬μš© μ „λž΅

flowchart TB subgraph Pool["HikariPool (max: 40)"] C1["Connection #1"] C2["Connection #2"] C3["Connection #3"] CN["..."] end subgraph Requests["λ™μ‹œ μš”μ²­"] R1["Request: tenant_A"] R2["Request: tenant_B"] R3["Request: tenant_A"] end R1 -->|"1. getConnection()"| C1 R1 -->|"2. USE tenant_A"| C1 R1 -->|"3. Query"| C1 R1 -->|"4. Return to pool"| Pool R2 -->|"1. getConnection()"| C2 R2 -->|"2. USE tenant_B"| C2 R3 -->|"1. getConnection()"| C1 R3 -->|"2. USE tenant_A"| C1 style Pool fill:#2d3748 style C1 fill:#48bb78 style C2 fill:#4299e1

7.3 Caffeine Cache (Database μΈμŠ€ν„΄μŠ€ 캐싱)

flowchart LR subgraph Cache["TenantDatabaseCache (Caffeine)"] direction TB CONFIG["μ„€μ •
maxSize: 100
expireAfterAccess: 30min"] subgraph Entries["μΊμ‹œ μ—”νŠΈλ¦¬"] E1["tenant_a β†’ Database"] E2["tenant_b β†’ Database"] E3["tenant_c β†’ Database"] end end REQ1["getDatabase(tenant_a)"] REQ2["getDatabase(tenant_d)"] REQ1 -->|"Cache HIT"| E1 REQ2 -->|"Cache MISS"| CREATE["createDatabaseForSchema()"] CREATE --> NEW["μƒˆ Database μΈμŠ€ν„΄μŠ€"] NEW -->|"μΊμ‹œ μ €μž₯"| Cache style Cache fill:#2d3748 style E1 fill:#48bb78 style CREATE fill:#ed8936

7.4 μ»΄ν¬λ„ŒνŠΈ μ—­ν•  μš”μ•½

Component Package μ—­ν• 
JwtAuthenticationFilter auth.jwt.filter JWT 토큰 검증, companyId μΆ”μΆœν•˜μ—¬ TenantContext μ„€μ •
TenantInterceptor config DWP Token/X-Company Headerμ—μ„œ ν…Œλ„ŒνŠΈ κ²°μ • (JWT 없을 λ•Œ)
TenantContext config InheritableThreadLocal둜 ν˜„μž¬ μŠ€λ ˆλ“œμ˜ ν…Œλ„ŒνŠΈ 정보 관리
TenantReactorContextAccessor config Reactor Context ↔ ThreadLocal κ°„ ν…Œλ„ŒνŠΈ 정보 μ „νŒŒ
TenantTransactionalAspect config @TenantTransactional AOP 처리, suspend ν•¨μˆ˜ 지원
TenantSpringTransactionManager config νŠΈλžœμž­μ…˜ μ‹œμž‘ μ‹œ ν…Œλ„ŒνŠΈ 검증 + μŠ€ν‚€λ§ˆ μ „ν™˜
TenantConfig shared.config ν…Œλ„ŒνŠΈ κ΄€λ ¨ μ„€μ • (prefix, suffix, fallback λ“±)
TenantValidator shared.validator μŠ€ν‚€λ§ˆλͺ… SQL Injection λ°©μ§€ 검증
TenantDatabaseCache shared.cache Caffeine μΊμ‹œλ‘œ Database μΈμŠ€ν„΄μŠ€ μž¬μ‚¬μš©
DatabaseDialectFactory shared.database DB νƒ€μž… μžλ™ κ°μ§€ν•˜μ—¬ μ μ ˆν•œ Dialect λ°˜ν™˜
DataSourceConfig config Tenant/Shared 두 개의 HikariCP DataSource μ„€μ •

8. λ³΄μ•ˆ 고렀사항

flowchart TB subgraph Threats["πŸ”΄ μœ„ν˜‘"] T1["SQL Injection
(μŠ€ν‚€λ§ˆλͺ… μ‘°μž‘)"] T2["Tenant Isolation 우회
(λ‹€λ₯Έ ν…Œλ„ŒνŠΈ μ ‘κ·Ό)"] T3["미등둝 ν…Œλ„ŒνŠΈ μ ‘κ·Ό"] T4["λΉ„ν™œμ„± ν…Œλ„ŒνŠΈ μ ‘κ·Ό"] end subgraph Defenses["🟒 λ°©μ–΄"] D1["TenantValidator
- μ •κ·œμ‹ 검증
- ν—ˆμš© 문자만"] D2["BoxwoodTenantRepository
- DB 등둝 확인
- tenantState 검증"] D3["TenantConfig
- allowDefaultFallback=false
(Production)"] D4["TenantSpringTransactionManager
- ACTIVE μƒνƒœλ§Œ ν—ˆμš©"] end T1 --> D1 T2 --> D2 T3 --> D2 T3 --> D3 T4 --> D4 style Threats fill:#742a2a style Defenses fill:#276749