-
Notifications
You must be signed in to change notification settings - Fork 0
인증 인가 전략
Cupcakes33 edited this page Feb 17, 2026
·
3 revisions
프로젝트명: MARU
작성일: 2026-01-19
최종 수정일: 2026-01-22
수정 이력
| 수정일 | 수정 내용 |
|---|---|
| 2026-01-19 | 권한 전략 초안 작성 |
| 2026-01-21 | 실제 구현 기반 전면 개정 |
| 2026-01-22 | AOP 기반 권한 검증 상세화, 캐싱 아키텍처 추가 |
모든 API 요청은 4단계 인증/인가 파이프라인을 거칩니다.
flowchart TD
Request([HTTP Request<br/>Cookie: accessToken]) --> Step1
subgraph Step1 ["1단계: JWT Filter"]
A1[토큰 검증]
A2[TenantContextHolder 설정]
A3[SecurityContext 설정]
A1 --> A2 --> A3
end
Step1 --> Step2
subgraph Step2 ["2단계: Dojang Access AOP"]
B1[테넌트/도장 활성 확인]
B2[OWNER → 즉시 통과]
B3[INSTRUCTOR → 소속 확인]
end
Step2 --> Step3
subgraph Step3 ["3단계: Permission Check AOP"]
C1[OWNER/SYSTEM → 즉시 통과]
C2[INSTRUCTOR → 권한 확인]
end
Step3 --> Step4
subgraph Step4 ["4단계: Query Filtering"]
D1["WHERE tenant_id = ?<br/>AND dojang_id = ?"]
end
Step4 --> Logic([비즈니스 로직 실행])
style Request fill:#f9f9f9,stroke:#333
style Logic fill:#e1f5fe,stroke:#01579b,stroke-width:2px
style Step1 fill:#fff3e0,stroke:#ff9800
style Step2 fill:#f1f8e9,stroke:#4caf50
style Step3 fill:#e8eaf6,stroke:#3f51b5
style Step4 fill:#fce4ec,stroke:#e91e63
MARU는 멀티테넌시 환경에서 데이터 격리와 보안을 보장하기 위해 ThreadLocal 기반의 컨텍스트를 활용합니다.
MARU의 멀티테넌시는 사업자(Tenant) 와 물리적 지점(Dojang) 으로 계층화되어 있습니다.
| 계층 | 설명 | 역할 |
|---|---|---|
| Tenant | 관장(Owner) 소유의 최상위 조직 | 데이터 격리의 기준 |
| Dojang | 실제 수련이 이루어지는 단위 | 권한 및 운영의 기준 |
요청이 들어오는 시점부터 나가는 시점까지, 현재 사용자의 정보를 공유하는 ThreadLocal 기반 저장소입니다.
주요 메서드:
-
getTenantId(),getUserId(),getDojangId(),getRole()- 컨텍스트 조회 -
isOwner()- 관장 여부 확인 -
withSystemContext()- 배치/스케줄러용 시스템 컨텍스트
flowchart TD
A[HTTP Request] --> B{Cookie에<br/>토큰 존재?}
B -- No --> C[인증 없이<br/>다음 필터로]
B -- Yes --> D{토큰 유효성 검증}
D -- 만료됨 --> E[TOKEN_EXPIRED<br/>에러 응답]
D -- 변조됨 --> F[인증 실패<br/>Context 비움]
D -- 유효함 --> G[JwtClaims 파싱]
G --> H[TenantContextHolder 설정]
H --> I[SecurityContext 설정]
I --> J[다음 필터 / Controller]
style A fill:#f9f,stroke:#333,stroke-width:2px
style J fill:#bbf,stroke:#333,stroke-width:2px
style E fill:#ffebee,stroke:#c62828
style F fill:#ffebee,stroke:#c62828
Spring Security Filter 체인의 시작 지점입니다. 모든 API 요청이 컨트롤러에 매핑하기 전에 "해당 사용자가 누구인가" 확인하고, 해당 정보를 시스템 전체가 사용할 수 있도록 세팅합니다.
| 저장소 | 용도 | 사용처 |
|---|---|---|
| TenantContextHolder | 비즈니스 로직용 (tenantId, dojangId 등) | Service, Repository, AOP |
| SecurityContextHolder | Spring Security용 (인증 객체) | Filter Chain |
특정 도장의 데이터에 접근할 때, "해당 도장이 활성 상태인지", "사용자가 해당 도장에 접근 권한이 있는지" 검증합니다.
public final class AopOrder {
public static final int SECURITY_VALIDATION = Ordered.HIGHEST_PRECEDENCE + 100; // 1순위: 도장 접근
public static final int PERMISSION_CHECK = Ordered.HIGHEST_PRECEDENCE + 200; // 2순위: 권한 검증
}flowchart LR
A[Service 메서드 호출] --> B["@ValidateDojangAccess<br/>(Order: HIGHEST_PRECEDENCE + 100)"]
B --> C["@RequirePermission<br/>(Order: HIGHEST_PRECEDENCE + 200)"]
C --> D[비즈니스 로직]
style B fill:#f1f8e9,stroke:#4caf50
style C fill:#e8eaf6,stroke:#3f51b5
@ValidateDojangAccess 어노테이션이 붙은 Service는 메서드 실행 전 단계별 검증을 거칩니다.
flowchart TD
Start([요청 시작]) --> TenantCheck{테넌트<br/>활성 상태?}
TenantCheck -- No --> TenantInactive[TENANT_INACTIVE]
TenantCheck -- Yes --> DojangCheck{도장<br/>활성 상태?}
DojangCheck -- No --> DojangInactive[DOJANG_INACTIVE]
DojangCheck -- Yes --> RoleCheck{관장<br/>OWNER?}
RoleCheck -- Yes --> Pass([즉시 통과])
RoleCheck -- No --> EmploymentCheck{해당 도장 소속?<br/>Employment 확인}
EmploymentCheck -- No --> Unauthorized[UNAUTHORIZED_ACCESS]
EmploymentCheck -- Yes --> Allow([접근 허용])
style Start fill:#f3f3f3,stroke:#333
style Pass fill:#e1f5fe,stroke:#01579b
style Allow fill:#e1f5fe,stroke:#01579b
style TenantInactive fill:#ffebee,stroke:#c62828
style DojangInactive fill:#ffebee,stroke:#c62828
style Unauthorized fill:#ffebee,stroke:#c62828
핵심 원칙:
- 관장님(OWNER): 테넌트 내 모든 도장에 자유롭게 접근
- 사범님(INSTRUCTOR): ACTIVE 상태의 Employment가 있는 도장만 접근
@ValidateDojangAccess // 클래스에 선언하면 모든 public 메서드에 적용
@Service
public class StudentService {
public void getStudent(String dojangId, ...) { } // 자동 검증
@SkipDojangValidation // 검증 제외
public void getStatistics() { }
}사용자의 역할(Role) 과, 사범님 개별에게 부여된 세부 권한(Permission) 을 분리하여 제어합니다.
| 구분 | 설명 | 예시 |
|---|---|---|
| 역할 (Role) | 시스템 접근 수준 | OWNER, INSTRUCTOR |
| 권한 (Permission) | 기능별 세부 권한 | STUDENT_UPDATE, PAYMENT_VIEW |
메서드 레벨에서 세부 권한을 검증합니다.
@ValidateDojangAccess // 클래스: 도장 접근 검증
@Service
public class StudentService {
@RequirePermission(PermissionType.STUDENT_CREATE) // 메서드: 권한 검증
@Transactional
public StudentRes createStudent(String dojangId, StudentCreateReq req) { }
@RequirePermission(PermissionType.STUDENT_UPDATE)
@Transactional
public StudentRes updateStudent(String dojangId, String studentId, StudentUpdateReq req) { }
@RequirePermission(PermissionType.STUDENT_DELETE)
@Transactional
public void deleteStudent(String dojangId, String studentId) { }
}복합 권한 검증:
// OR: 하나라도 있으면 통과 (기본값)
@RequirePermission({PermissionType.STUDENT_VIEW, PermissionType.ATTENDANCE_VIEW})
// AND: 모두 있어야 통과
@RequirePermission(
value = {PermissionType.STUDENT_VIEW, PermissionType.PAYMENT_VIEW},
operator = LogicalOperator.AND
)flowchart TD
Start([메서드 호출]) --> OwnerCheck{OWNER 또는<br/>SYSTEM 사용자?}
OwnerCheck -- Yes --> Pass([즉시 통과])
OwnerCheck -- No --> ContextCheck{컨텍스트 정보<br/>존재?}
ContextCheck -- No --> Denied1[AccessDeniedException<br/>인증 정보 없음]
ContextCheck -- Yes --> CacheCheck[PermissionCache에서<br/>권한 확인]
CacheCheck --> HasPerm{권한 보유?}
HasPerm -- Yes --> Allow([접근 허용])
HasPerm -- No --> Denied2[AccessDeniedException<br/>권한 없음]
style Pass fill:#e1f5fe,stroke:#01579b
style Allow fill:#e1f5fe,stroke:#01579b
style Denied1 fill:#ffebee,stroke:#c62828
style Denied2 fill:#ffebee,stroke:#c62828
| 권한 | 코드 | 설명 | 기본 부여 |
|---|---|---|---|
STUDENT_VIEW |
student:view | 원생 정보 조회 | ✅ (필수) |
STUDENT_CREATE |
student:create | 원생 등록 | ✅ |
STUDENT_UPDATE |
student:update | 원생 정보 수정 | ✅ |
STUDENT_DELETE |
student:delete | 원생 삭제 | ❌ |
ATTENDANCE_VIEW |
attendance:view | 출석 조회 | ✅ |
ATTENDANCE_UPDATE |
attendance:update | 출석 체크/수정 | ✅ |
PAYMENT_VIEW |
payment:view | 수납 조회 | ✅ |
PAYMENT_UPDATE |
payment:update | 청구서/수납 관리 | ✅ |
DOJANG_UPDATE_INFO |
dojang:updateInfo | 도장 정보 수정 | ❌ |
DOJANG_MANAGE_CLASS |
dojang:manageClass | 수련반 관리 | ✅ |
STATS_VIEW_DASHBOARD |
stats:viewDashboard | 대시보드 조회 | ✅ |
- 기본 부여 (✅): 사범 등록 시 자동 부여
- 선택 부여 (❌): 관장이 개별 부여
-
필수 권한:
STUDENT_VIEW는 해제 불가
권한 검증은 모든 API 요청마다 발생하므로, 인터페이스 기반 캐시로 DB 부하를 최소화합니다.
flowchart TB
subgraph "권한 검증 흐름"
AOP[PermissionCheckAspect] --> Cache[PermissionCache<br/>인터페이스]
end
subgraph "구현체 - 교체 가능"
Cache --> Local[LocalPermissionCache<br/>Caffeine]
Cache -.-> Redis[RedisPermissionCache<br/>미구현]
end
Local --> DB[(Employment 테이블)]
Redis -.-> RedisServer[(Redis Server)]
style Cache fill:#fff3e0,stroke:#ff9800
style Local fill:#e8f5e9,stroke:#4caf50
style Redis fill:#f5f5f5,stroke:#9e9e9e,stroke-dasharray: 5 5
| 캐시 | 키 | 용도 |
|---|---|---|
tenant-active |
tenantId | 테넌트 활성화 상태 |
dojang-active |
dojangId | 도장 활성화 상태 |
tenant-owner |
tenantId:userId | 테넌트 오너 여부 |
employment |
userId:dojangId | 도장 소속 여부 |
// 인터페이스
public interface PermissionCache {
boolean hasPermission(String userId, String tenantId, String dojangId,
String resource, String action);
void invalidate(String userId, String tenantId, String dojangId);
}캐시 키 형식:
tenant:{tenantId}:user:{userId}:dojang:{dojangId}
현재 구현 (LocalPermissionCache):
@Component
public class LocalPermissionCache implements PermissionCache {
private final Cache cache; // Caffeine Cache
@Override
public boolean hasPermission(...) {
String key = buildKey(tenantId, userId, dojangId);
// 캐시 미스 시 DB 조회
Set<PermissionType> permissions = cache.get(key, () -> {
return employmentRepository
.findByUserIdAndTenantIdAndDojangIdAndStatus(...)
.map(Employment::getPermissions)
.orElse(Set.of());
});
return permissions.contains(required);
}
}