![]()
시리즈 안내
- Part 1: 하나의 계정, 여러 학원, 다양한 역할
- Part 2: 멀티테넌시에서 데이터 유출 막는 4-Tier 보안 아키텍처
- Part 3: 여러 캠퍼스-여러 역할 JWT 설계와 ThreadLocal 안전성 ← 현재 글
- Part 4: Row-Level Security 5가지 구현 방법 비교와 선택 가이드
- Part 5: 레거시 시스템 멀티테넌시 전환 (준비 중)
이전 이야기
Part 2에서는 CheckUS의 4-Tier 아키텍처 구현을 살펴봤습니다. 프론트엔드 Axios Interceptor부터 백엔드 AOP까지, 4단계 보안 체크가 어떻게 작동하는지 이해했습니다.
이번 글에서는 이 아키텍처를 실전 환경에 적용하면서 마주한 보안과 성능 이슈, 그리고 그 해결 과정을 공유합니다.
JWT 토큰 설계: 여러 캠퍼스 역할 담기
요구사항
CheckUS 사용자는 여러 캠퍼스에서 여러 역할을 가질 수 있습니다.
[선생님 A]
├─ 강남 독서실: TEACHER
├─ 분당 수학학원: TEACHER
└─ 대치 영어학원: ADMIN
[학생 B]
├─ 강남 독서실: STUDENT
└─ 분당 수학학원: STUDENT
이 정보를 JWT 토큰에 어떻게 담을까요?
시도 1: 단순 배열 (❌ 역할 구분 불가)
{
"userId": 100,
"username": "teacher_a",
"campusIds": [1, 2, 3],
"roles": ["TEACHER", "ADMIN"]
}
문제점
- ❌ 어느 캠퍼스에서 어떤 역할인지 알 수 없음
- ❌ 캠퍼스 1에서는 TEACHER, 캠퍼스 3에서는 ADMIN인데 구분 불가
시도 2: 중첩 객체 배열 (✅ 채택)
{
"userId": 100,
"username": "teacher_a",
"roles": [
{ "campusId": 1, "role": "TEACHER" },
{ "campusId": 2, "role": "TEACHER" },
{ "campusId": 3, "role": "ADMIN" }
]
}
장점
- ✅ 캠퍼스별 역할 명확히 구분
- ✅ 권한 검증 시
roles.find(r => r.campusId === 1 && r.role === 'ADMIN')가능
JWT Claims 클래스
// Backend 구현
@Getter
@Builder
public class JwtClaims {
private Long userId;
private String username;
private List<CampusRole> roles;
@Getter
@AllArgsConstructor
public static class CampusRole {
private Long campusId;
private String role; // STUDENT, TEACHER, GUARDIAN, ADMIN
}
}
JWT 생성 및 파싱
// Backend 구현
@Component
public class JwtTokenProvider {
@Value("${jwt.secret}")
private String secretKey;
public String generateToken(Long userId, String username, List<CampusRole> roles) {
// roles를 JSON 문자열로 직렬화
String rolesJson = new ObjectMapper().writeValueAsString(roles);
return Jwts.builder()
.setSubject(username)
.claim("userId", userId)
.claim("roles", rolesJson)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + 3600000)) // 1시간
.signWith(SignatureAlgorithm.HS512, secretKey)
.compact();
}
public Set<Long> getCampusIds(String token) {
Claims claims = parseClaims(token);
String rolesJson = claims.get("roles", String.class);
List<CampusRole> roles = new ObjectMapper().readValue(
rolesJson,
new TypeReference<List<CampusRole>>() {}
);
return roles.stream()
.map(CampusRole::getCampusId)
.collect(Collectors.toSet());
}
public boolean hasRole(String token, Long campusId, String role) {
Claims claims = parseClaims(token);
String rolesJson = claims.get("roles", String.class);
List<CampusRole> roles = new ObjectMapper().readValue(
rolesJson,
new TypeReference<List<CampusRole>>() {}
);
return roles.stream()
.anyMatch(r -> r.getCampusId().equals(campusId) && r.getRole().equals(role));
}
}
ThreadLocal 성능과 안전성
ThreadLocal이란?
ThreadLocal은 각 스레드마다 독립적인 변수 저장소를 제공합니다.
Request 1 (Thread 1) → ThreadLocal: campusId = 1
Request 2 (Thread 2) → ThreadLocal: campusId = 2
Request 3 (Thread 1) → ThreadLocal: campusId = 3 (Request 1 완료 후)
위험 1: 메모리 누수
Spring Boot는 Thread Pool을 사용합니다. 스레드가 재사용되므로:
// Request 1 (Thread A)
CampusContextHolder.setCampusIds(Set.of(1L));
// ... 처리 ...
// clear() 안 하면?
// Request 2 (Thread A 재사용)
CampusContextHolder.getCampusIds(); // 💥 이전 요청의 1L 반환!
해결책: Interceptor에서 자동 정리
@Override
public void afterCompletion(HttpServletRequest request,
HttpServletResponse response,
Object handler,
Exception ex) {
CampusContextHolder.clear(); // ✅ 반드시 정리
}
위험 2: 비동기 작업에서 ThreadLocal 손실
@CampusFiltered
public void sendNotifications() {
Long campusId = CampusContextHolder.getSingleCampusId(); // ✅ 1L
// 비동기 작업
CompletableFuture.runAsync(() -> {
Long asyncCampusId = CampusContextHolder.getSingleCampusId();
// 💥 null! 다른 스레드에서 실행되므로 ThreadLocal 접근 불가
});
}
해결책: TaskDecorator로 ThreadLocal 전파
// Backend 구현
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean
public Executor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(10);
executor.setMaxPoolSize(20);
executor.setTaskDecorator(new CampusContextTaskDecorator()); // ✅
executor.initialize();
return executor;
}
}
// 데코레이터: 부모 스레드의 ThreadLocal을 자식에게 복사
public class CampusContextTaskDecorator implements TaskDecorator {
@Override
public Runnable decorate(Runnable runnable) {
Set<Long> campusIds = CampusContextHolder.getCampusIds(); // 부모 스레드에서 복사
return () -> {
try {
CampusContextHolder.setCampusIds(campusIds); // 자식 스레드에 설정
runnable.run();
} finally {
CampusContextHolder.clear(); // 작업 후 정리
}
};
}
}
ThreadLocal 성능 측정
// 10,000 requests 부하 테스트 결과
- Without ThreadLocal: 평균 15ms/request
- With ThreadLocal: 평균 15.2ms/request
성능 영향: 약 1.3% (무시 가능한 수준)
결론
- ✅ ThreadLocal 성능 오버헤드는 거의 없음
- ✅ 메모리 누수만 방지하면 안전하게 사용 가능
통합 테스트: 캠퍼스 격리 검증
테스트 전략
단위 테스트만으로는 부족합니다. 실제 HTTP 요청부터 데이터베이스 쿼리까지 End-to-End로 검증해야 합니다.
테스트 시나리오
1. 강남 독서실(campusId=1) 학생 5명 생성
2. 분당 수학학원(campusId=2) 학생 3명 생성
3. X-Campus-Id: 1로 API 호출 → 5명만 조회되는지 확인
4. X-Campus-Id: 2로 API 호출 → 3명만 조회되는지 확인
5. 권한 없는 캠퍼스(campusId=999) 요청 → 403 에러 확인
통합 테스트 코드
// Backend 구현
@SpringBootTest
@AutoConfigureMockMvc
@Transactional
class StudentControllerIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private StudentRepository studentRepository;
@Autowired
private JwtTokenProvider jwtTokenProvider;
@BeforeEach
void setup() {
// 테스트 데이터 생성
createStudent("학생A", 1L); // 강남 독서실
createStudent("학생B", 1L);
createStudent("학생C", 2L); // 분당 수학학원
}
@Test
void 강남_독서실_학생만_조회() throws Exception {
// JWT 토큰 생성 (campusId 1, 2 권한 있음)
String token = jwtTokenProvider.generateToken(
100L,
"teacher",
List.of(
new CampusRole(1L, "TEACHER"),
new CampusRole(2L, "TEACHER")
)
);
// API 호출
MvcResult result = mockMvc.perform(
get("/students")
.header("Authorization", "Bearer " + token)
.header("X-Campus-Id", "1") // 강남 독서실 요청
)
.andExpect(status().isOk())
.andReturn();
// 응답 검증
List<StudentDto> students = parseResponse(result);
assertThat(students).hasSize(2); // 강남 독서실 학생 2명만
assertThat(students).extracting("name")
.containsExactlyInAnyOrder("학생A", "학생B"); // 학생C 포함 안 됨
}
@Test
void 권한_없는_캠퍼스_요청_시_403() throws Exception {
String token = jwtTokenProvider.generateToken(
100L,
"teacher",
List.of(new CampusRole(1L, "TEACHER")) // campusId 1만 권한
);
mockMvc.perform(
get("/students")
.header("Authorization", "Bearer " + token)
.header("X-Campus-Id", "999") // 권한 없는 캠퍼스
)
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.errorCode").value("CAMPUS_ACCESS_DENIED"));
}
@Test
void X_Campus_Id_헤더_없으면_400() throws Exception {
String token = jwtTokenProvider.generateToken(100L, "teacher", List.of());
mockMvc.perform(
get("/students")
.header("Authorization", "Bearer " + token)
// X-Campus-Id 헤더 없음
)
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.errorCode").value("CAMPUS_ID_REQUIRED"));
}
}
테스트 실행 결과
✅ 강남_독서실_학생만_조회 (120ms)
✅ 권한_없는_캠퍼스_요청_시_403 (85ms)
✅ X_Campus_Id_헤더_없으면_400 (92ms)
Total: 3 tests, 3 passed, 0 failed
모니터링: 캠퍼스 필터링 누락 감지
문제 인식
개발자가 실수로 @CampusFiltered 없이 메서드를 작성하면?
// ❌ @CampusFiltered 누락
public List<Student> getDangerousMethod() {
return studentRepository.findAll(); // 전체 데이터 노출
}
통합 테스트로 모든 엔드포인트를 커버하기는 어렵습니다. 실시간 모니터링이 필요합니다.
해결책 1: AOP 로깅
@Aspect
@Component
public class CampusFilterAspect {
@Before("@annotation(CampusFiltered)")
public void checkCampusContext(JoinPoint joinPoint) {
Set<Long> campusIds = CampusContextHolder.getCampusIds();
// ✅ 항상 로깅
log.info("CampusFiltered: method={}, campusIds={}",
joinPoint.getSignature().toShortString(),
campusIds);
if (campusIds == null || campusIds.isEmpty()) {
throw new BusinessException("CAMPUS_CONTEXT_EMPTY");
}
}
}
해결책 2: Sentry 통합
@Aspect
@Component
public class CampusFilterAspect {
@Before("@annotation(CampusFiltered)")
public void checkCampusContext(JoinPoint joinPoint) {
Set<Long> campusIds = CampusContextHolder.getCampusIds();
if (campusIds == null || campusIds.isEmpty()) {
// Sentry에 에러 전송
Sentry.captureException(new BusinessException(
"CAMPUS_CONTEXT_EMPTY",
String.format("Method: %s", joinPoint.getSignature())
));
throw new BusinessException("CAMPUS_CONTEXT_EMPTY");
}
}
}
해결책 3: 통합 테스트 커버리지 강제
// Backend 구현
test {
finalizedBy jacocoTestReport
}
jacocoTestReport {
dependsOn test
afterEvaluate {
classDirectories.setFrom(files(classDirectories.files.collect {
fileTree(dir: it, exclude: [
'**/dto/**',
'**/config/**'
])
}))
}
}
jacocoTestCoverageVerification {
violationRules {
rule {
limit {
minimum = 0.80 // 80% 커버리지 강제
}
}
}
}
check.dependsOn jacocoTestCoverageVerification
실전 엣지 케이스
Case 1: Soft Delete와 캠퍼스 필터링
// students 테이블
id | campus_id | name | deleted_at
---+-----------+--------+------------
1 | 1 | 학생A | NULL
2 | 1 | 학생B | 2025-01-01 (삭제됨)
3 | 2 | 학생C | NULL
문제: Soft Delete된 데이터도 캠퍼스 필터링을 적용해야 할까?
// ❌ 삭제된 데이터도 다른 캠퍼스에서 보임
@Query("SELECT s FROM Student s WHERE s.deletedAt IS NOT NULL")
List<Student> findDeletedStudents();
// ✅ 삭제된 데이터도 캠퍼스 필터링
@CampusFiltered
public List<Student> getDeletedStudents() {
Long campusId = CampusContextHolder.getSingleCampusId();
return studentRepository.findByCampusIdAndDeletedAtIsNotNull(campusId);
}
원칙: 모든 쿼리는 삭제 여부와 무관하게 캠퍼스 필터링 적용
Case 2: 통계 쿼리 (여러 캠퍼스 집계)
// 요구사항: 전체 캠퍼스의 학생 수 집계
public Map<Long, Long> getStudentCountPerCampus() {
// ❌ @CampusFiltered 사용하면 한 캠퍼스만 조회됨
// ✅ 명시적으로 "전체 조회"임을 표시
return studentRepository.findAll().stream()
.collect(Collectors.groupingBy(
Student::getCampusId,
Collectors.counting()
));
}
해결책: @CampusFiltered 사용 안 함 + 메서드명에 AllCampuses 명시
/**
* ⚠️ 전체 캠퍼스 데이터 조회 (관리자 전용)
* 캠퍼스 필터링 미적용
*/
@PreAuthorize("hasRole('SUPER_ADMIN')")
public Map<Long, Long> getStudentCountForAllCampuses() {
return studentRepository.countGroupByCampusId();
}
Case 3: 캠퍼스 간 데이터 이동
// 요구사항: 학생을 강남 독서실(1) → 분당 수학학원(2)로 이동
@Transactional
public void transferStudent(Long studentId, Long targetCampusId) {
// 1. 현재 캠퍼스에서 학생 조회
Long currentCampusId = CampusContextHolder.getSingleCampusId();
Student student = studentRepository.findByIdAndCampusId(studentId, currentCampusId)
.orElseThrow(() -> new BusinessException("STUDENT_NOT_FOUND"));
// 2. targetCampusId 권한 검증
if (!hasAccessToCampus(targetCampusId)) {
throw new BusinessException("CAMPUS_ACCESS_DENIED");
}
// 3. campusId 변경
student.setCampusId(targetCampusId);
studentRepository.save(student);
}
주의점: ThreadLocal은 요청 시작 시 설정된 캠퍼스만 가지므로, 다른 캠퍼스로 변경 시 별도 권한 검증 필요
성능 최적화
1. 인덱스 설계
-- ✅ 복합 인덱스: campus_id + 다른 조건
CREATE INDEX idx_students_campus_grade ON students(campus_id, grade);
CREATE INDEX idx_study_times_campus_student ON study_times(campus_id, student_id);
-- ❌ 단일 인덱스만 있으면 성능 저하
CREATE INDEX idx_students_grade ON students(grade); -- campus_id 없음!
쿼리 성능 비교
-- 복합 인덱스 사용 (✅)
SELECT * FROM students
WHERE campus_id = 1 AND grade = 3;
-- Execution time: 2ms (인덱스 스캔)
-- 단일 인덱스 사용 (❌)
SELECT * FROM students
WHERE grade = 3; -- campus_id 필터링 누락
-- Execution time: 150ms (풀 테이블 스캔)
2. N+1 문제 해결
// ❌ N+1 쿼리 발생
@CampusFiltered
public List<StudentWithClassDto> getStudentsWithClass() {
Long campusId = CampusContextHolder.getSingleCampusId();
List<Student> students = studentRepository.findByCampusId(campusId);
return students.stream()
.map(s -> new StudentWithClassDto(
s,
classRepository.findById(s.getClassId()).orElse(null) // 💥 N번 쿼리
))
.collect(Collectors.toList());
}
// ✅ Fetch Join 사용
@Query("""
SELECT s FROM Student s
LEFT JOIN FETCH s.classEntity
WHERE s.campusId = :campusId
""")
List<Student> findByCampusIdWithClass(@Param("campusId") Long campusId);
3. 캐싱 전략
// ThreadLocal 조회는 매우 빠르므로 (1μs 이하), 캐싱 불필요
// 대신 DB 쿼리 결과를 캐싱
@Cacheable(value = "students", key = "#campusId")
public List<Student> getStudentsByCampus(Long campusId) {
return studentRepository.findByCampusId(campusId);
}
// ⚠️ 주의: 캠퍼스별로 캐시 키를 분리해야 함
보안 체크리스트
개발 시 필수 확인 사항
- 모든 Service 메서드에
@CampusFiltered어노테이션 추가 - ThreadLocal에서 campusId 가져와서 Repository 메서드 호출
- Native Query에도
WHERE campus_id = :campusId포함 - 통합 테스트로 캠퍼스 격리 검증
- Frontend Request DTO에 campusId 필드 없는지 ESLint 확인
- 비동기 작업 시 TaskDecorator로 ThreadLocal 전파
- Interceptor afterCompletion에서 ThreadLocal 정리
코드 리뷰 체크리스트
@CampusFiltered없는 Service 메서드가 있는가?- Repository에서
findAll()직접 호출하는 곳이 있는가? - 통계 쿼리가 전체 캠퍼스 데이터를 조회하는가? (권한 확인 필요)
- Soft Delete 쿼리도 캠퍼스 필터링이 적용되었는가?
다음 편 예고
Part 3에서는 JWT 토큰 설계, ThreadLocal 안전성, 통합 테스트, 모니터링, 실전 엣지 케이스까지 보안과 성능 최적화 전략을 살펴봤습니다.
Part 4: 다양한 구현 방법 비교에서는:
- 🔍 PostgreSQL Native RLS vs CheckUS AOP
- ⚡ Hibernate Global Filter로 완전 자동화는 가능한가?
- 🚀 Redis 캐싱을 추가하면 얼마나 빨라질까?
- 🎯 AspectJ Load-Time Weaving의 장단점
- 📊 실제 사례 4가지 비교 분석
CheckUS의 방식과 다른 업계 구현 방법들을 객관적으로 비교합니다.
👉 Part 4: Row-Level Security 5가지 구현 방법 비교와 선택 가이드에서 계속됩니다.
CheckUS 아키텍처 시리즈
- Part 1: 하나의 계정, 여러 학원, 다양한 역할
- Part 2: 멀티테넌시에서 데이터 유출 막는 4-Tier 보안 아키텍처
- Part 3: 여러 캠퍼스-여러 역할 JWT 설계와 ThreadLocal 안전성 ← 현재 글
- Part 4: Row-Level Security 5가지 구현 방법 비교와 선택 가이드
- Part 5: 레거시 시스템 멀티테넌시 전환
Comments