7장 · 권한(RBAC) & 예외 처리
로그인한 사용자가 무엇을 볼 수 있고 무엇을 할 수 있는지를 정하는 게 권한(RBAC)?입니다. 이 장에서는 사용자→역할→메뉴권한 모델, 접근 가능한 메뉴를 계산하는 방법, 스프링 시큐리티?의 메서드 보안, 그리고 오류가 났을 때 응답을 한 형태로 모아 돌려주는 전역 예외 처리를 봅니다.
RBAC 모델 — 사용자 → 역할 → 메뉴 권한
이 프로젝트의 권한은 세 테이블이 사슬처럼 이어집니다. 사용자에게 직접 권한을 주지 않고, 역할(Role)을 거쳐서 줍니다. 그래서 "이 사람은 무엇을 할 수 있나"가 아니라 "이 사람은 어떤 역할이고, 그 역할이 무엇을 할 수 있나"로 관리해요.
User ──(tb_user_role)── Role ──(tb_menu_role)── Menu
사용자 연결 역할 연결 메뉴
· tb_user_role : 사용자에게 어떤 역할(ROLE_ADMIN 등)이 있는지
· tb_menu_role : 역할이 각 메뉴에서 무엇을 할 수 있는지
(읽기/생성/수정/삭제/내보내기 5개 플래그)
엔티티? User는 자신이 가진 역할 코드를 직접 알려주는
편의 메서드를 가지고 있습니다. 스프링 시큐리티의 UserDetails도 함께 구현해서,
인증 정보로 그대로 쓰입니다.
// core/model/entity/User.java
public Set<String> getRoleCodes() { // 역할 코드 목록 (ROLE_ADMIN 등)
return this.userRoles.stream()
.map(ur -> ur.getId().getRoleCode())
.collect(Collectors.toSet());
}
public boolean isReadonly() { // ROLE_READONLY 보유 여부
return hasRole("ROLE_READONLY");
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return getRoleCodes().stream() // 역할 코드를 시큐리티 권한으로
.map(r -> (GrantedAuthority) () -> r)
.toList();
}
tb_menu_role은 메뉴 1개 × 역할 1개마다 다섯 개의 권한 플래그를 가집니다.
아래처럼 모두 false가 기본값이에요(권한은 명시적으로 켜야 생김).
// core/model/entity/MenuRole.java (발췌) @Column(name = "can_read") private Boolean canRead = false; @Column(name = "can_create") private Boolean canCreate = false; @Column(name = "can_update") private Boolean canUpdate = false; @Column(name = "can_delete") private Boolean canDelete = false; @Column(name = "can_export") private Boolean canExport = false;
접근 가능한 메뉴 계산 — OR 병합
프론트는 로그인 후 GET /api/menus/accessible을 불러, 이 사용자가 볼 수 있는 메뉴 트리와
메뉴별 권한을 한 번에 받습니다(권한 없는 GET /api/menus는 전체 트리만 줍니다).
핵심은 한 사용자가 여러 역할을 가질 수 있고, 같은 메뉴에 대해 역할마다 권한이 다를 수 있다는 점이에요.
이때 각 권한 플래그를 OR로 합칩니다 — 한 역할에서라도 켜져 있으면 켜진 것으로 봅니다.
// web/endpoint/MenuEndpoint.java (요지)
boolean isAdmin = userRoles.contains("ROLE_ADMIN");
for (Menu menu : allMenus) {
if (isAdmin)
permissionMap.put(menu.getCode(), MenuPermission.fullAccess()); // 전부 허용
else
permissionMap.put(menu.getCode(), MenuPermission.noAccess()); // 전부 차단으로 초기화
}
if (!isAdmin && !userRoles.isEmpty()) {
// 내가 가진 역할들의 메뉴-권한을 한 번에 조회
List<MenuRole> menuRoles = menuRoleRepo.findAllByRoleCodes(new ArrayList<>(userRoles));
for (MenuRole mr : menuRoles) {
var current = permissionMap.getOrDefault(menuCode, MenuPermission.noAccess());
var rolePerm = new MenuPermission(mr.getCanRead(), mr.getCanCreate(),
mr.getCanUpdate(), mr.getCanDelete(), mr.getCanExport());
permissionMap.put(menuCode, current.merge(rolePerm)); // ← OR 병합
}
}ROLE_ADMIN이면 메뉴-권한 테이블을 보지 않고 모든 메뉴에 fullAccess()를 줍니다.
나머지 사용자는 일단 모두 차단(noAccess)으로 깔고, 가진 역할의 권한을 하나씩 OR로 더해 올려요.
계산이 끝나면 parentCode 기준으로 트리를 만들어
AccessibleMenuResponse(menuTree, permissionMap)로 응답합니다.
→ 프론트가 이 권한 맵을 어떻게 받아 화면(버튼 활성/비활성 등)에 쓰는지: 연동 4장 · CRUD 계약
메서드 보안 — @PreAuthorize
위 메뉴 권한이 "화면에 무엇을 보여줄지"라면, @PreAuthorize는 "이 API 메서드를 누가 호출할 수 있나"를
메서드 단위로 거는 잠금장치입니다. RoleEndpoint는 역할 관리 API라서 대부분
관리자만 호출하도록 표시되어 있어요.
// web/endpoint/RoleEndpoint.java (발췌)
@PostMapping("")
@PreAuthorize("hasRole('ADMIN')") // 관리자만 역할 생성
public Role create(@Valid @RequestBody RoleCreateRequest request) { ... }
@DeleteMapping("/{code}")
@PreAuthorize("hasRole('ADMIN')")
@Transactional
public void delete(@PathVariable String code) {
Role role = roleRepo.findById(code).orElseThrow(...);
if (role.getIsSystem()) // 시스템 필수 역할은 보호
throw new BizRuleException("시스템 필수 역할은 삭제할 수 없습니다.");
...
}
시스템 역할(isSystem)은 비활성화·삭제·권한 수정이 코드로 막혀 있습니다(BizRuleException 발생).
관리자라도 시스템의 뼈대가 되는 역할은 함부로 못 건드리게 한 안전장치예요.
@PreAuthorize는 표시만 한다고 켜지지 않습니다.
메서드 레벨 보안을 켜는 @EnableMethodSecurity(구버전 @EnableGlobalMethodSecurity)가
설정 어디에도 없어서, 이 프로젝트의 @PreAuthorize는 현재 전혀 적용되지 않습니다.
즉 관리자가 아니어도 막히지 않아요. "어노테이션만 붙이면 되겠지"가 흔한 함정입니다 —
메서드 보안은 스위치(설정)를 따로 켜야 합니다.
hasRole('X')는 비교할 때 ROLE_ 접두사를 자동으로 붙입니다.
그래서 올바른 사용은 hasRole('ADMIN')이고, 이게 실제로는 ROLE_ADMIN 보유를 검사해요.
그런데 RoleEndpoint.updatePermissions 한 곳만 @PreAuthorize("hasRole('ROLE_ADMIN')")로 적혀 있습니다.
이러면 접두사가 한 번 더 붙어 사실상 ROLE_ROLE_ADMIN을 요구하는 셈이라,
(보안이 켜졌다면) 진짜 관리자도 막히는 버그입니다. 접두사를 직접 쓰지 마세요.
// 다른 메서드들 — 올바름
@PreAuthorize("hasRole('ADMIN')") // 검사 대상: ROLE_ADMIN
// updatePermissions 한 곳만 — 잘못됨
@PreAuthorize("hasRole('ROLE_ADMIN')") // 검사 대상: ROLE_ROLE_ADMIN (버그)전역 예외 처리 — @ControllerAdvice
컨트롤러 곳곳에서 try-catch를 반복하는 대신, 예외를 한곳에 모아 처리합니다.
GlobalExceptionHandler가 그 자리이고, @ExceptionHandler 메서드들이
예외 종류별로 응답을 만들어요.
// web/endpoint/GlobalExceptionHandler.java
@ControllerAdvice // 모든 컨트롤러의 예외를 가로채는 전역 처리기
@Slf4j
public class GlobalExceptionHandler {
@ExceptionHandler(BizRuleException.class)
public ResponseEntity<ActionResult> handleBizRuleException(BizRuleException ex) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(ActionResult.fail(ex.getMessage()));
}
}흔히 쓰는 @RestControllerAdvice(= @ControllerAdvice + @ResponseBody)가 아니라
그냥 @ControllerAdvice입니다. 그래서 각 핸들러가 반환값을 JSON으로 자동 변환하지 않고,
모두 직접 ResponseEntity를 만들어 반환합니다. 형태가 통일돼 있는 건 이 때문이에요.
응답이 두 형태로 나뉜다
오류 응답은 두 가지 모양이 섞여 있습니다. 이 점을 모르면 프론트에서 오류를 파싱할 때 헷갈려요.
형태 ① — 입력 검증 오류(@Valid 실패 등): 필드별 메시지를 담은 Map으로 응답합니다.
// MethodArgumentNotValidException 등 → validationErrors 맵
{
"status": 400,
"error": "Bad Request",
"message": "입력이 올바르지 않습니다",
"validationErrors": {
"userid": "필수 입력입니다",
"email": "이메일 형식이 아닙니다"
}
}형태 ② — 비즈니스/일반 오류: 공통 래퍼 ActionResult.fail(메시지)로 응답합니다.
// BizRuleException, NotFoundException, IllegalArgumentException 등
{
"ok": false,
"message": "시스템 필수 역할은 삭제할 수 없습니다.",
"type": "warn",
"data": null,
"errors": null
}
DTO? 관점에서 보면, 검증 오류만 별도의 Map 형태이고
나머지는 모두 ActionResult 하나로 통일됩니다.
ActionResult — 성공/실패 공통 래퍼
SuccessResponse/ErrorResponse 같은 별도 클래스를 두지 않고,
ActionResult 하나로 ok 플래그를 켜고 끄며 성공·실패를 모두 표현합니다.
// web/dto/ActionResult.java (요지)
public class ActionResult {
private boolean ok = false; // 성공 여부
private String message = ""; // 실패 사유 또는 성공 문구
private String type; // success / info / warn
private Object data; // 성공 시 전달할 데이터
private List<ObjectError> errors; // 검증 오류 목록(선택)
public static ActionResult ok(Object data) { ... } // 성공 + 데이터
public static ActionResult fail(String message) { ... } // 실패 + 사유
}주요 예외 → 응답 매핑
| 예외 | HTTP 상태 | 응답 형태 |
|---|---|---|
MethodArgumentNotValidException | 400 | 검증 Map(validationErrors) |
BizRuleException | 400 | ActionResult.fail |
EntityNotFoundException | 400 | ActionResult.fail |
UniqViolationException / NotValidException | 400 | 검증 Map(validationErrors) |
IllegalArgumentException | 400 | ActionResult.fail |
UndeletableException | 400 | ActionResult.fail |
DataIntegrityViolationException | 500 | ActionResult.fail |
MaxUploadSizeExceededException | 413 | ActionResult.fail |
unique(중복) 위반 처리에는 두 갈래가 있습니다.
① 컨트롤러가 DataIntegrityViolationException을 잡아 handleUniqViolation(...)으로
UniqViolationException을 던지면 → 400 + 검증 Map(필드별 메시지).
② 변환 없이 그대로 흘러간 제약 위반은 전역 핸들러가 받아, 아래처럼 제약 이름으로 메시지를 분기해 500으로 응답합니다.
// DataIntegrityViolationException 처리 시 제약명으로 메시지 분기 (getConstraintViolationMessage → 500)
if (name.contains("unique")) return "중복된 데이터가 존재합니다.";
else if (name.contains("fk")) return "사용 또는 참조 중이므로 삭제할 수 없습니다.";
else if (name.contains("not_null")) return "필수 값이 누락되었습니다.";- 새 화면에 권한 적용 — 메뉴를 추가하면
tb_menu_role에 역할별 권한을 넣어야 보이고, 버튼(생성/수정/삭제)은accessible이 준 권한 맵으로 활성/비활성을 정한다 - 관리자 전용 API —
@PreAuthorize("hasRole('ADMIN')")(단, 이 프로젝트는 메서드 보안 스위치가 꺼져 있어 현재 무효 — 켜려면@EnableMethodSecurity추가) - 오류 응답 파싱 — 검증 오류면
validationErrors맵, 그 외에는ok=false의message를 본다 - 업무 규칙 위반 — 컨트롤러/서비스에서
throw new BizRuleException("...")하면 자동으로 400 + ActionResult로 응답된다
정리
- 권한은 사용자 → 역할 → 메뉴권한의 사슬. 직접이 아니라 역할을 거쳐 부여한다.
/api/menus/accessible은 여러 역할의 권한을 OR 병합해 접근 메뉴 트리 + 권한 맵을 돌려준다. 관리자는 전부 허용.@PreAuthorize는 표시일 뿐 — 메서드 보안 스위치가 꺼져 있어 현재 무효이고,hasRole('ROLE_ADMIN')같은 접두사 중복은 버그다.- 예외는
@ControllerAdvice에서 한곳에 모아 처리하고, 응답은 검증 Map vs ActionResult 두 형태. ActionResult하나로ok플래그를 켜고 끄며 성공·실패를 통일한다(검증 오류만 예외).
→ 이 응답을 프론트가 어떻게 소비하는지: 연동 4장 · CRUD 계약