Spring

권한 체크를 세션에 캐싱하고, 무효화 지점을 찾기

문제

기존 권한 모델:

  • user_level 정수 하나
  • 숫자가 클수록 많은 권한

한계:

  • “조회는 되는데 삭제는 안 되고, 다른 화면에서는 승인 권한이 있다” 같은 요구 → 정수 하나로 표현 불가
  • 새 권한이 생길 때마다 숫자 구간 재해석 필요
  • 그 해석이 코드 여기저기에 분산

  • 결정: 권한 코드 기반 모델로 전면 교체

  • PERM_<리소스>_<행위> 형태의 권한 코드 정의
  • 역할에 코드 매핑
  • 사용자에게 역할 부여

캐싱은 쉬웠다

  • 새 모델의 hasPermission(code) — 사용자의 권한 코드 집합에 해당 코드가 있는지 조회
  • 호출 지점이 매우 많음
    • 컨트롤러의 @PreAuthorize
    • 인터셉터의 페이지 접근 체크
    • JSP의 버튼 노출 여부
  • 요청 하나에 수십 번 호출 → 매번 DB 조회 불가

저장소 선택:

  • 권한은 요청 중에 불변, 사용자별 고정 → 세션이 자연스러운 저장소
  • 로그인 시 권한 코드 전량을 HttpSessionSet<String>으로 적재
  • 이후 조회는 세션에서 수행
// AuthorizationService: hasPermission / getCurrentUserPermissions 세션 캐시 적용
  • 여기까지 소요: 30분
  • 진짜 난제는 그 다음

어려운 건 무효화 지점을 빠짐없이 찾는 것이었다

캐시 도입 즉시 발생하는 질문 — 권한이 바뀌면 그 세션의 캐시는 어떻게 되나?

  • 관리자가 권한 회수 → 사용자 세션에 옛 권한 집합 잔존 → 회수 미반영
  • 화면상으로는 회수 표시, 실제로는 계속 사용 가능

  • 권한 시스템에서의 성격: 단순 버그가 아니라 보안 결함

무효화가 필요한 지점:

사건 영향
권한 부여 해당 사용자 캐시 무효화
권한 회수 해당 사용자 캐시 무효화
역할 변경 해당 사용자 캐시 무효화
역할에 매핑된 권한 코드 변경 그 역할을 가진 모든 사용자
권한 만료 만료된 사용자 전부, 그것도 시간이 지나면 자동으로
  • 앞의 셋 — 명시적 호출로 처리. 권한 부여·회수 서비스에 캐시 무효화 메서드 호출 추가
  • 네 번째 — 영향받는 사용자를 조회해야 판별 가능
  • 다섯 번째 — 아무도 아무것도 하지 않았는데 권한이 바뀌는 경우

만료 처리:

  • PermissionExpiryScheduler매일 새벽 2시에 만료된 권한 비활성화
  • 새벽 2시 선택 이유 — 사용자가 적어 그 시점에 세션이 남아 있을 가능성이 낮음
  • 한계 — 만료 시각과 실제 비활성화 사이에 최대 하루의 창

  • 정확한 해법은 아님
  • 수용 근거: 권한 만료가 분 단위 정확도를 요구하는 요건이 아니라는 판단

캐시를 넣는 순간 “권한이 바뀌는 모든 경로”를 완전히 열거해야 하는 숙제가 생긴다. 그리고 그 목록이 완전한지는 증명하기 어렵다. 이게 캐싱의 실제 비용이다 — 코드 몇 줄이 아니라, 시스템 전체에서 특정 상태를 바꾸는 지점을 전부 안다는 주장.

접근 제어를 두 층으로 나눴다

담당 방식
페이지 접근 MenuAccessInterceptor URL을 MENU_* 코드로 매핑, 메뉴 권한 없으면 진입 차단
API 접근 컨트롤러 @PreAuthorize 고객 관리·이미지·이상 감지·장애 분석·알림 등 전면 적용

둘 다 필요한 이유:

  • 인터셉터만 있으면 → 화면을 안 거치고 API를 직접 호출하는 경로가 열림
  • @PreAuthorize만 있으면 → 권한 없는 사용자가 빈 화면을 보게 됨. 나쁜 UX

  • 역할 분담: 화면 진입은 인터셉터, 실제 데이터는 애노테이션

스트랭글러 패턴: 4일간의 공존

  • 전제: 새 시스템 구축이 곧 옛 시스템 소멸은 아님
2026-03-30  feat: 권한 관리 시스템 전면 개편 (Phase 0~3 + 마이그레이션 3건)
   ...4일간 신구 시스템 공존...
2026-04-03  refactor: 권한 기반 API 보호 및 레거시 PermissionService 제거

3월 30일 커밋 — 새 시스템 수립:

  • 마이그레이션 1: 버튼 권한 코드 44개와 역할별 기본 매핑
  • 마이그레이션 2: UI 힌트 컬럼(설명·아이콘·영향 영역)
  • 마이그레이션 3: 카테고리 값 정리

  • 이 시점에 옛 user_level 기반 PermissionService는 그대로 생존 — 삭제하지 않음
  • 이유: 새 시스템이 실제 화면들에서 제대로 도는지 확인하는 동안 옛 경로가 안전망

4일 뒤 4월 3일 커밋이 마감:

  • 레거시 PermissionService·PermissionDao·매퍼 XML·관련 DTO 전량 삭제
  • 삭제 규모 — 매퍼 XML 196줄, 서비스 121줄, 합계 453줄
  • 같은 커밋에 미보호 엔드포인트 @PreAuthorize 마감
  • 권한 부여·회수 이력 조회 API 추가

이 4일이 이 작업에서 제일 중요한 부분이다.

  • 신구를 한 커밋에 교체했을 경우: 문제 발생 시 되돌릴 지점 부재
  • 공존 기간 확보 시: 새 시스템 오류 발생해도 옛 코드가 저장소에 잔존 → 롤백은 커밋 1개

동시에 이 기간을 길게 끌면 안 된다.

  • 두 권한 시스템 공존 중에는 “어느 쪽이 진짜인가”가 모호
  • 그 모호함 자체가 보안 위험
  • 4일 = 검증에 충분하고 잊어버리기에는 짧은 길이

남는 교훈

캐싱의 난이도는 캐시를 채우는 쪽이 아니라 비우는 쪽에 있다. 세션에 Set<String>을 넣는 건 몇 줄이다. 그 집합이 언제 틀려지는지를 전부 아는 것이 실제 작업이다.

권한 캐시는 특히 틀린 방향이 정해져 있다.

  • 캐시가 오래돼서 권한이 실제보다 적게 보임 → 사용자 불편
  • 많게 보임 → 보안 사고

  • 따라서 회수 계열 경로의 무효화가 부여 계열보다 훨씬 중요

레거시 제거 커밋을 별도로 남기면 작업이 끝났다는 표시가 된다. 453줄 삭제 커밋이 없었다면 옛 PermissionService는 “혹시 쓰는 데가 있을까 봐” 몇 달을 더 살아남았을 것이다. 교체 작업의 완료 조건은 새 코드가 도는 게 아니라 옛 코드가 없어지는 것이다.