π’ 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
π ν
λνΈ κ²°μ μ°μ μμ:
JWT Tokenμ companyId claim (κ°μ₯ λμ μ°μ μμ)
DWP_TOKEN Cookie β DwpAuthService κ²μ¦
X-Company HTTP Header
- κΈ°λ³Έ μ€ν€λ§ 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