백엔드 · 7장 권한(RBAC) · 예외

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가 지금은 동작하지 않는다

@PreAuthorize는 표시만 한다고 켜지지 않습니다. 메서드 레벨 보안을 켜는 @EnableMethodSecurity(구버전 @EnableGlobalMethodSecurity)가 설정 어디에도 없어서, 이 프로젝트의 @PreAuthorize현재 전혀 적용되지 않습니다. 즉 관리자가 아니어도 막히지 않아요. "어노테이션만 붙이면 되겠지"가 흔한 함정입니다 — 메서드 보안은 스위치(설정)를 따로 켜야 합니다.

학습 주의점 ② — hasRole의 ROLE_ 접두사 함정

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

흔히 쓰는 @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) { ... } // 실패 + 사유
}
택배 송장 같은 한 장의 양식이에요. "도착(ok=true) / 반송(ok=false)" 체크칸 하나로 결과를 적고, 같은 자리에 사유(message)와 내용물(data)을 함께 적습니다. 양식이 하나라 받는 쪽(프론트)도 늘 같은 칸만 보면 됩니다.

주요 예외 → 응답 매핑

예외HTTP 상태응답 형태
MethodArgumentNotValidException400검증 Map(validationErrors)
BizRuleException400ActionResult.fail
EntityNotFoundException400ActionResult.fail
UniqViolationException / NotValidException400검증 Map(validationErrors)
IllegalArgumentException400ActionResult.fail
UndeletableException400ActionResult.fail
DataIntegrityViolationException500ActionResult.fail
MaxUploadSizeExceededException413ActionResult.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=falsemessage를 본다
  • 업무 규칙 위반 — 컨트롤러/서비스에서 throw new BizRuleException("...") 하면 자동으로 400 + ActionResult로 응답된다

정리

  • 권한은 사용자 → 역할 → 메뉴권한의 사슬. 직접이 아니라 역할을 거쳐 부여한다.
  • /api/menus/accessible은 여러 역할의 권한을 OR 병합해 접근 메뉴 트리 + 권한 맵을 돌려준다. 관리자는 전부 허용.
  • @PreAuthorize는 표시일 뿐 — 메서드 보안 스위치가 꺼져 있어 현재 무효이고, hasRole('ROLE_ADMIN') 같은 접두사 중복은 버그다.
  • 예외는 @ControllerAdvice에서 한곳에 모아 처리하고, 응답은 검증 Map vs ActionResult 두 형태.
  • ActionResult 하나로 ok 플래그를 켜고 끄며 성공·실패를 통일한다(검증 오류만 예외).

→ 이 응답을 프론트가 어떻게 소비하는지: 연동 4장 · CRUD 계약