HERA-V4 접근제어(access_secure) 가이드¶
조회 API가 반환하는 데이터 범위를 로그인 사용자의 권한만큼으로 자동으로 좁혀주는 access_secure 기능과, 이를 선언적으로 적용하는 @AccessSecureScope 어노테이션의 사용법을 설명한다.
목차¶
1. 개요¶
access_secure는 목록 조회 API의 반환 데이터 범위를 로그인 사용자의 조직 권한만큼으로 자동 제한하는 기능이다. 예를 들어 사용자 목록 화면에서, 전사 권한을 가진 관리자는 전체를, 부서 관리자는 자기 부서 서브트리만, 일반 사용자는 본인만 보게 만든다.
기존에는 이 로직이 컨트롤러마다 복붙되어 있었고, 세션·컨텍스트·권한 타입을 확인할 수 없으면 전체 데이터를 반환하는 fail-open 상태였다. #501에서 이를 @AccessSecureScope 어노테이션 한 줄 + AOP로 공통화하고, 판단이 불가능하면 차단(0건)하는 fail-closed로 정책을 뒤집었다.
두 개의 독립된 설정¶
접근제어를 이해하려면 성격이 다른 두 스위치를 구분해야 한다. 둘은 AND로 동작한다 — ①이 켜져 있고 ②에 범위가 지정돼 있어야 실제로 제한된다.
| ① 켤까/말까 (마스터 스위치) | ② 얼마나 볼까 (스코프 타입) | |
|---|---|---|
| 값 | Y / N | ORG / OWN_DEPT / USER (+ 제한레벨) |
| 저장 위치 | sys_menu.config_attrs.secureAccess | sys_rbac_auth_entry.access_secure |
| 설정 화면 | sys0103 메뉴관리 | 롤(권한) 부여 화면 |
| 성격 | 라이브 (메뉴 캐시, 즉시 반영) | 로그인 시점 스냅샷 |
스코프 타입 3종의 의미:
ORG— 전사. 제한 없음(전체 조회).OWN_DEPT— 내 소속 조직 서브트리만.accessLimitLevel로 하위 몇 단계까지 볼지 조절.USER— 나 자신의 데이터만.
전체 데이터 흐름¶
flowchart TD
subgraph setup["설정 (관리자)"]
A["sys0103 메뉴관리
접근제어 체크박스
secureAccess = Y/N"]
B["롤 부여 화면
access_secure = ORG/OWN_DEPT/USER"]
end
subgraph login["로그인 시점 (스냅샷)"]
C["AccessSecureMerger
롤별 정책 병합"]
D["AccessControlManager
+ 사용자 조직정보 결합"]
E["SessionUser.accessSecureContextMap
{ menuId → type, scopeOrg, limit }"]
end
subgraph runtime["조회 API 호출 시점"]
F["@AccessSecureScope
(메뉴는 클래스명에서 자동 추출)"]
G["AccessSecureScopeAspect
스코프 필드 주입"]
H["Service
WHERE 조건으로 번역"]
end
B --> C --> D --> E
A -. 라이브 판정 .-> G
E --> G
F --> G --> H
style A fill:#e3f2fd
style E fill:#fff3e0
style G fill:#e8f5e9
적용 대상 / 비대상¶
| 적용한다 (When to use) | 적용하지 않는다 (When NOT to use) |
|---|---|
| 조직/사용자 단위로 범위를 좁혀야 하는 목록 조회 GET API | 저장·삭제 등 검색 파라미터가 없는 메서드 |
스코프를 받을 SP가 AccessSecureApplicable을 구현한 경우 | 단건 조회 대상 검증 (별도 isInScope 담당) |
API 실행 권한 체크 (Spring Security @Secured 담당) |
2. 빠른 시작¶
접근제어가 적용된 기존 컨트롤러의 최소 형태다. 어노테이션 한 줄이 전부다.
기존 조회 API에 접근제어를 붙인 실제 코드:
// hera-webapp/.../web/controller/api/sys/Sys0301ApiController.java:83-86
import kr.co.dandisoft.hera.web.security.access.secure.AccessSecureScope;
@AccessSecureScope
@GetMapping("/users/pageable/jvo")
public PageResponse<UserJVO> findAllOnPageable4JVO(PageRequest<UserVO, UserSP> pageRequest, UserSP userSP) {
pageRequest.setSearchParams(userSP);
// ... 이하 일반 조회 로직 (접근제어 코드 없음)
}
대상 메뉴(SYS0301)는 값으로 쓰지 않는다. 클래스명 Sys0301ApiController에서 자동으로 추출한다.
이 한 줄이 동작하려면 세 가지가 모두 갖춰져야 한다.
flowchart LR
A["① 어노테이션
@AccessSecureScope
(클래스명에서 메뉴 추출)"] --> B["② SP가 인터페이스 구현
UserSP implements
AccessSecureApplicable"]
B --> C["③ Service가 필드를
쿼리 조건으로 번역
UserServiceImpl"]
style A fill:#e3f2fd
style B fill:#fff3e0
style C fill:#e8f5e9
- ②만 있고 ③이 없으면 스코프 값은 세팅되지만 쿼리에 반영되지 않는다.
- ①만 있고 ②가 없으면 기동 시점에 검증 실패로 애플리케이션이 뜨지 않는다 (3단계 Step 5 참조).
결과 확인¶
기동 로그에 검증 통과 메시지가 찍히면 정상이다.
sys0103에서 해당 메뉴의 접근제어 체크박스를 켜고 재로그인한 뒤, 롤에 OWN_DEPT가 지정된 사용자로 목록을 조회하면 자기 부서 데이터만 반환된다.
3. 단계별 적용 가이드¶
신규 조회 API에 접근제어를 적용하는 절차다. 참조 구현은 Sys0301ApiController + UserSP + UserServiceImpl이다.
Step 1. 조회 SP에 인터페이스 구현¶
목적: aspect가 스코프 값을 실어줄 자리를 만든다.
SP마다 스코프 필드명이 다르므로, aspect가 필드명을 모르도록 인터페이스에 위임한다. 4개 연산의 의미만 보장하면 되고, 실제 어떤 필드를 세팅할지는 SP가 자유롭게 정한다.
작업 파일: AccessSecureApplicable.java
UserSP의 실제 구현:
// hera-domain-data/.../domain/sys/user/UserSP.java:78-107
/**
* 스코프 전용 4필드만 초기화한다.
* userId / exactUserId 는 제외한다 — 사용자 검색의 정상 조건이라
* 초기화하면 아이디 검색이 동작하지 않는다.
*/
@Override
public void resetAccessSecureScope() {
this.accessSecureOrgCodes = null;
this.accessSecureScopeOrgCode = null;
this.accessSecureScopeOrgLevel = null;
this.accessSecureLimitLevel = null;
}
@Override
public void applyUserScope(String loginId) {
this.userId = loginId;
this.exactUserId = true;
}
@Override
public void applyOwnDeptScope(String scopeOrgCode, Integer scopeOrgLevel, Integer limitLevel) {
this.accessSecureScopeOrgCode = scopeOrgCode;
this.accessSecureScopeOrgLevel = scopeOrgLevel;
this.accessSecureLimitLevel = limitLevel;
}
/**
* 빈 목록을 세팅해 UserServiceImpl 의 __NO_ACCESS__ 센티널 경로로 보낸다.
* null 이 아니라 빈 리스트여야 한다 — null 은 "조건 없음"(전체 조회)으로 처리된다.
*/
@Override
public void applyDenyAll() {
this.accessSecureOrgCodes = List.of();
}
주의:
applyDenyAll()은 반드시 매칭 불가능한 조건을 넣어야 한다. 조건을 생략(null)하면 "제한 없음 = 전체 조회"가 되어 fail-closed가 깨진다.
Step 2. Service에서 필드를 쿼리 조건으로 번역¶
목적: SP에 세팅된 스코프 값을 실제 WHERE로 옮긴다. 이 단계가 없으면 값만 세팅되고 쿼리는 그대로다.
작업 파일: UserServiceImpl.java:169
// hera-system/.../domain/sys/user/UserServiceImpl.java:169-183
if (accessSecureOrgCodes != null) {
if (CollectionUtils.isEmpty(accessSecureOrgCodes)) {
queryWrapper.and(USER_DETAILS.as("ud").ORG_CODE.eq("__NO_ACCESS__"));
} else {
queryWrapper.and(USER_DETAILS.as("ud").ORG_CODE.in(accessSecureOrgCodes));
}
}
if (accessSecureScopeOrgCode != null) {
queryWrapper.and(USER_DETAILS.as("ud").FULL_ORG_CODE.like(accessSecureScopeOrgCode));
if (accessSecureScopeOrgLevel != null && accessSecureLimitLevel != null && accessSecureLimitLevel >= 0) {
queryWrapper.and(USER_DETAILS.as("ud").ORG_LEVEL.between(accessSecureScopeOrgLevel, accessSecureScopeOrgLevel + accessSecureLimitLevel));
}
}
- 빈 리스트 →
ORG_CODE = '__NO_ACCESS__'(매칭 0건,applyDenyAll의 종착지). OWN_DEPT→FULL_ORG_CODE LIKE(서브트리) +ORG_LEVEL BETWEEN(깊이 제한).
Step 3. 컨트롤러 메서드에 어노테이션 부착¶
목적: 조회 메서드에 접근제어를 선언한다.
작업 파일: 대상 *ApiController.java의 조회 메서드
@AccessSecureScope
@GetMapping("/users/pageable/jvo")
public PageResponse<UserJVO> findAllOnPageable4JVO(PageRequest<UserVO, UserSP> pageRequest, UserSP userSP) {
규칙:
- 메서드에만 붙인다. 클래스에 붙이면 저장·삭제 메서드까지 대상이 되어 기동 검증에 걸린다.
- 파라미터에
AccessSecureApplicable구현체(UserSP)가 있어야 한다. - 값을 받지 않는다. 대상 메뉴는 컨트롤러 클래스명에서 자동 추출한다(
Sys0301ApiController→SYS0301). 화면 컨트롤러는 클래스명과 담당 메뉴가 항상 1:1이라, 같은 사실을 값으로 다시 적는 이중 관리가 없다. - 여러 화면이 공유하는 공용 컨트롤러에는 붙이지 않는다 — 클래스명에서 메뉴 코드를 뽑을 수 없어 기동이 실패한다.
Step 4. sys0103에서 마스터 스위치 ON¶
목적: 해당 메뉴의 접근제어를 실제로 활성화한다. 어노테이션만으로는 제한되지 않는다.
sys0103 메뉴관리 그리드의 "접근제어" 체크박스가 마스터 스위치다.
// hera-webapp/src/main/resources/static/js/sys/sys0103.js:339
{dataIndx: 'secureAccess', width: 80, title: '접근제어' + RF, type: 'checkbox', align: 'center',
cb: {check: 'Y', uncheck: 'N'},
editable: ui => ui && ui.rowData && ui.rowData.level === 2 || ui?.rowData?.isNewRow},
level === 2인 메뉴(=실제 화면 메뉴)에서만 편집 가능하다. 폴더/그룹 메뉴에는 못 켠다.- 체크 시
sys_menu.config_attrs.secureAccess = "Y"로 저장된다. - 스위치 변경은 즉시 파생 정책 재계산으로 전파된다 (AccessSecureRebuildServiceImpl.java:64).
sys0103은 "켤까/말까"만 결정한다. "누가 얼마나 볼지"(스코프 타입)는 롤 부여 화면에서 지정한다.
Step 5. 기동 검증 확인¶
목적: 어노테이션이 잘못 붙어 "적용된 것처럼 보이지만 아닌" 상태를 기동 시점에 잡는다.
AccessSecureScopeStartupValidator가 모든 싱글턴 초기화 직후 2종을 검사하고, 위반이 있으면 기동을 중단한다.
| 검증 | 내용 | 위반 예시 |
|---|---|---|
| ① 인터페이스 구현 | 어노테이션이 붙은 핸들러가 AccessSecureApplicable 파라미터를 갖는가 | SP가 인터페이스 미구현 → 기동 실패 |
| ② 메뉴 해석 가능 | 클래스명에서 메뉴 코드를 추출할 수 있고, 그 코드가 실재하는 Domain인가 | 공용 컨트롤러(번호 없음) → 기동 실패 |
②는 AccessSecureMenuCodeExtractor를 aspect(런타임)와 공유한다 — 두 곳이 같은 규칙으로 메뉴를 판정한다.
기동 로그 확인:
4. 핵심 패턴 & 안티패턴¶
런타임 판정 흐름 (fail-closed)¶
aspect의 판정 로직이 이 기능의 핵심이다. 판단이 불가능하면 전체 노출이 아니라 차단한다.
flowchart TD
Start["@AccessSecureScope 조회 호출"] --> Reset["resetAccessSecureScope()
클라이언트 위조 스코프 제거"]
Reset --> Judge{"메뉴 스위치 판정
AccessSecureMenuRegistry"}
Judge -->|OFF| Pass["통과 (제한 없음)
= 전체 조회"]
Judge -->|UNKNOWN
메뉴 못 읽음| Deny["applyDenyAll()
= 0건 차단"]
Judge -->|ON| Ctx{"세션 컨텍스트
존재?"}
Ctx -->|없음| Deny
Ctx -->|있음| Type{"스코프 타입"}
Type -->|USER| U["applyUserScope(loginId)
본인만"]
Type -->|OWN_DEPT| O["applyOwnDeptScope(...)
소속 서브트리"]
Type -->|ORG| R["조건 주입 안 함
전사"]
Type -->|미지 타입| Deny
style Pass fill:#e8f5e9
style Deny fill:#ffebee
style R fill:#e8f5e9
핵심은 OFF와 UNKNOWN(차단)을 3-state로 구분한다는 점이다. 스위치를 안 켠 메뉴는 "제한하지 않겠다는 의도된 설정"이라 통과시키고, 켰는데 판단이 안 되는 경우만 막는다. 관련 소스: AccessSecureScopeAspect.java:64.
패턴: 다중 롤은 넓은 권한이 이긴다¶
한 사용자가 여러 롤을 가지면 병합된다. 우선순위는 ORG(0) < OWN_DEPT(2) < USER(3)이고 숫자가 낮은(더 넓은) 쪽이 승리한다. 즉 ORG 롤 하나라도 있으면 전사 조회가 된다. 같은 OWN_DEPT끼리는 accessLimitLevel이 큰(더 깊게 보는) 쪽이 이긴다. 소스: AccessSecureMerger.java:14.
안티패턴¶
| 안티패턴 | 문제 | 올바른 방법 |
|---|---|---|
| 클래스에 어노테이션 부착 | 저장·삭제 메서드까지 대상이 되어 기동 검증 실패 | 조회 메서드에만 부착 |
| 공용 컨트롤러(번호 없는 클래스명)에 부착 | 메뉴를 특정할 수 없어 기동 실패 | 화면 전용 컨트롤러에만 부착 |
applyDenyAll()에서 null 세팅 | null = "조건 없음" = 전체 조회 → fail-closed 붕괴 | 빈 리스트 등 매칭 불가능한 조건 |
resetAccessSecureScope()에서 검색 조건 필드까지 초기화 | userId 등을 지우면 일반 검색이 깨짐 | 스코프 전용 필드만 초기화 |
| 어노테이션만 믿고 Service 번역 생략 | 값만 세팅되고 쿼리는 그대로 → 무제한 조회 | Step 2의 WHERE 번역 필수 |
5. 트러블슈팅¶
| 증상 | 원인 | 해결 |
|---|---|---|
기동 실패: @AccessSecureScope 검증 실패 [인터페이스 미구현] | 어노테이션은 붙었으나 SP가 AccessSecureApplicable 미구현 | SP에 인터페이스 구현 추가 (Step 1) |
기동 실패: [메뉴 코드 추출 불가] | 클래스명에 메뉴 번호 패턴이 없는 공용 컨트롤러에 부착 | 화면 전용 컨트롤러가 아니면 어노테이션을 제거 |
기동 실패: [존재하지 않는 메뉴] | 클래스명에서 뽑은 코드가 Domain enum에 없음 | 클래스명 오탈자 확인 |
| 스위치를 켰더니 기존 사용자가 0건만 봄 | ON/OFF는 라이브지만 권한 컨텍스트는 로그인 스냅샷이라 갱신 안 됨 | 스위치를 켠 뒤 해당 메뉴 사용자에게 재로그인 안내 (의도된 fail-closed 동작) |
| 접근제어를 켰는데 여전히 전체가 보임 | Service의 WHERE 번역(Step 2) 누락, 또는 롤에 ORG 지정 | Service 번역 확인 / 롤 스코프 타입 확인 |
| 어노테이션 붙였는데 제한이 전혀 안 걸림 | sys0103 마스터 스위치가 OFF(N) | sys0103에서 접근제어 체크박스 ON (Step 4) |
| 화면이 통째로 빈 화면(0건) | 메뉴 캐시 조회 실패(UNKNOWN) 또는 컨텍스트 부재 → 차단 | 아래 로그로 원인 판정 |
로그 확인: 판정 실패는 warn 레벨로 남는다. 로그 경로는 _temp/logs/.
AccessSecureScopeAspect :: 메뉴 판정 불가 → 차단. menu=SYS0301 # UNKNOWN
AccessSecureScopeAspect :: 컨텍스트 없음 → 차단. menu=SYS0301 # ON인데 세션 컨텍스트 부재
AccessSecureScopeAspect :: 알 수 없는 권한 타입 → 차단. menu=SYS0301 # 미지 타입
6. 레퍼런스 링크¶
핵심 코드¶
| 계층 | 파일 |
|---|---|
| 어노테이션 | AccessSecureScope.java |
| AOP 주입 | AccessSecureScopeAspect.java |
| 클래스명→메뉴 추출 (공유) | AccessSecureMenuCodeExtractor.java |
| 마스터 스위치 판정 | AccessSecureMenuRegistry.java |
| 기동 검증 | AccessSecureScopeStartupValidator.java |
| 스코프 인터페이스 | AccessSecureApplicable.java |
| SP 구현 예시 | UserSP.java |
| 세션 컨텍스트 구성 | AccessControlManager.java:159 |
| 롤 정책 병합 | AccessSecureMerger.java |
| 쿼리 번역 | UserServiceImpl.java:169 |
| sys0103 스위치 UI | sys0103.js:339 |
관련 PDCA archive¶
- #501 접근권한 조회 스코프 주입 공통화 (AOP) — 본 기능의 설계·구현·리뷰 전체
-
603 — 메뉴별 접근제어 스위치 전파 결함 (판정 전제)¶
-
618 — 접근제어 스위치 전파 수동 검증¶
변경 이력¶
| 날짜 | 버전 | 변경 요약 | 작성자 |
|---|---|---|---|
| 2026-08-05 | 1.0 | 초안 작성 (#501 기준) | 장태욱 |
| 2026-08-05 | 1.1 | @ApplyAccessSecureScope → @AccessSecureScope 리네임 + 값 인자 제거(클래스명 자동 추출)로 전면 갱신 | 장태욱 |