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

권한 체크를 세션에 캐싱하고, 무효화 지점을 찾기
AI Claude Code Spring 권한관리

문제

권한 모델이 user_level 정수 하나였다. 숫자가 클수록 많은 걸 할 수 있다는 식이다. 이 모델은 요구가 단순할 때만 버틴다. “이 사람은 조회는 되는데 삭제는 안 되고, 대신 다른 화면에서는 승인 권한이 있다” 같은 요구가 들어오는 순간 정수 하나로는 표현이 안 된다. 새 권한이 생길 때마다 숫자 구간을 재해석해야 하고, 그 해석이 코드 여기저기에 흩어진다.

권한 코드 기반 모델로 갈아엎기로 했다. PERM_METER_DELETE 같은 코드를 정의하고, 역할에 코드를 매핑하고, 사용자에게 역할을 준다.

캐싱은 쉬웠다

새 모델의 hasPermission(code)는 사용자의 권한 코드 집합에 그 코드가 있는지 보는 것이다. 문제는 이 호출이 아주 잦다는 점이다. 컨트롤러의 @PreAuthorize, 인터셉터의 페이지 접근 체크, JSP에서 버튼 노출 여부까지 — 요청 하나에 수십 번 불린다. 매번 DB를 때리면 안 된다.

권한은 요청 중에 바뀌지 않고 사용자별로 고정이니 세션이 자연스러운 저장소다. 로그인 시 권한 코드를 전부 읽어 HttpSessionSet<String>으로 넣고, 이후 조회는 세션에서 본다.

// AuthorizationService: hasPermission / getCurrentUserPermissions 세션 캐시 적용

여기까지는 30분짜리 작업이다. 어려운 건 다음이었다.

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

캐시를 넣으면 즉시 질문이 생긴다. 권한이 바뀌면 그 세션의 캐시는 어떻게 되나?

관리자가 어떤 사용자의 권한을 회수했는데 그 사용자 세션에 옛 권한 집합이 남아 있으면, 회수가 반영되지 않는다. 화면상으로는 회수됐다고 나오는데 실제로는 계속 쓸 수 있다. 권한 시스템에서 이건 그냥 버그가 아니라 보안 결함이다.

무효화가 필요한 지점을 세어보면 생각보다 많다.

사건 영향
권한 부여 해당 사용자 캐시 무효화
권한 회수 해당 사용자 캐시 무효화
역할 변경 해당 사용자 캐시 무효화
역할에 매핑된 권한 코드 변경 그 역할을 가진 모든 사용자
권한 만료 만료된 사용자 전부, 그것도 시간이 지나면 자동으로

앞의 셋은 명시적으로 호출하면 된다. 커밋 6889789에서 권한 부여·회수 서비스에 캐시 무효화 메서드 호출을 붙였다.

네 번째와 다섯 번째가 까다롭다. 역할 단위 변경은 영향받는 사용자가 누구인지 조회해야 알 수 있고, 만료는 아무도 아무것도 하지 않았는데 권한이 바뀌는 경우다.

만료는 스케줄러로 처리했다. PermissionExpiryScheduler가 매일 새벽 2시에 만료된 권한을 비활성화한다. 새벽 2시로 잡은 건 사용자가 적은 시간대라 그 시점에 세션이 남아 있을 가능성이 낮기 때문이다. 정확한 해법은 아니다 — 만료 시각과 실제 비활성화 사이에 최대 하루의 창이 열린다. 권한 만료가 분 단위 정확도를 요구하는 요건이 아니라고 판단해서 받아들인 트레이드오프다.

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

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

같은 개편에서 접근 제어를 두 곳에 뒀다.

페이지 접근MenuAccessInterceptor가 막는다. URL을 MENU_* 코드로 매핑해두고, 요청 URL에 해당하는 메뉴 권한이 없으면 진입 자체를 차단한다.

API 접근은 컨트롤러의 @PreAuthorize가 막는다. 고객 관리, 이미지, 이상 감지, 장애 분석, 알림 등 각 컨트롤러에 전면 적용했다.

둘 다 필요하다. 인터셉터만 있으면 화면을 안 거치고 API를 직접 호출하는 경로가 열린다. @PreAuthorize만 있으면 권한 없는 사용자가 빈 화면을 보게 되고, 그건 나쁜 UX다. 화면 진입은 인터셉터가, 실제 데이터는 애노테이션이 지킨다.

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

새 시스템을 만들었다고 옛 시스템이 바로 사라지지는 않는다.

2026-03-30  6889789  feat: 권한 관리 시스템 전면 개편 (Phase 0~3 + V172/V173/V174)
   ...4일간 신구 시스템 공존...
2026-04-03  9478ceb  refactor: 권한 기반 API 보호 및 레거시 PermissionService 제거

3월 30일 커밋이 새 시스템을 세운다. V172가 44개 버튼 권한 코드와 역할별 기본 매핑을 넣고, V173이 UI 힌트 컬럼(설명·아이콘·영향 영역)을 추가하고, V174가 카테고리 값을 정리한다.

이 시점에 옛 user_level 기반 PermissionService는 그대로 살아 있다. 지우지 않았다. 새 시스템이 실제 화면들에서 제대로 도는지 확인하는 동안, 옛 경로가 안전망으로 남아 있어야 하기 때문이다.

4일 뒤 9478ceb가 마감한다. 레거시 PermissionService, PermissionDao, 매퍼 XML, 관련 DTO를 전부 삭제한다. 매퍼 XML만 196줄, 서비스 121줄, 합쳐 453줄이 사라졌다. 같은 커밋에 미보호 엔드포인트에 @PreAuthorize를 마저 붙이고, 권한 부여·회수 이력 조회 API도 추가했다.

이 4일이 이 작업에서 제일 중요한 부분이다. 신구를 한 커밋에 갈아끼웠다면 문제가 생겼을 때 되돌릴 곳이 없다. 공존 기간을 두면 새 시스템이 잘못됐을 때 옛 코드가 아직 저장소에 있고, 되돌리는 게 커밋 하나다.

동시에 이 기간을 길게 끌면 안 된다. 두 권한 시스템이 동시에 존재하는 동안은 “어느 쪽이 진짜인가”가 애매하고, 그 애매함 자체가 보안 위험이다. 4일은 검증에는 충분하고 잊어버리기에는 짧은 길이였다.

남는 교훈

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

권한 캐시는 특히 틀린 방향이 정해져 있다. 캐시가 오래돼서 권한이 실제보다 적게 보이면 사용자가 불편하다. 많게 보이면 보안 사고다. 그래서 회수 계열 경로의 무효화가 부여 계열보다 훨씬 중요하다.

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