최원종의 개발 블로그

V11 - 1 역할 기반 접근 제어 본문

Spring boot 입문

V11 - 1 역할 기반 접근 제어

chl6698 2026. 5. 26. 15:23

역할 기반 접근 제어(RBAC, Role-Based Access Control)

소프트웨어 시스템에서 사용자가 부여받은 역할(Role)에 따라
특정 페이지나 기능에 대한 접근을 허락하거나 차단하는 보안 기법

단순히 로그인을 했느냐 안 했느냐를 넘어,
로그인한 사람이 '누구인가'를 판별하여 권한을 제어

사용자에게 역할(Role)을 부여하고, 역할에 따라 관리자 전용 메뉴/페이지에 접근을 제어하는 기능구현

- ADMIN (관리자)
    - 권한: 시스템의 모든 것을 제어할 수 있는 최고 권한.
    - 접근 가능 영역: 일반 페이지는 물론, '관리자 전용 대시보드',
    '회원 관리 메뉴', '설정 페이지' 등 민감한 관리자 전용 URL에 자유롭게 접근할 수 있음.

- USER (일반 사용자)
    - 권한: 서비스의 기본적인 기능만 사용할 수 있는 제한된 권한.
    - 접근 제어: 본인의 프로필이나 일반 게시판은 볼 수 있지만, 
    브라우저 주소창에 관리자 대시보드 주소를 직접 치고 들어가려고 하면 
    시스템이 이를 인식하고 접근 거부(403 Forbidden) 처리를 하여 쫓아냄.

자바 열거 타입(enum) 활용

타입 안전성 확보 (Type Safety) : 허용되지 않은 엉뚱한 값이 들어오는 것을 
컴파일 시점에 완벽히 차단하여 런타임 오류 방지

자바 enum은 숨겨진 클래스 : 내부적으로 java.lang.Enum 클래스를
상속받는 고유 클래스 구조이며 상수는 각각 독립된 객체로 존재

데이터와 로직의 한 묶음 관리 : 상수 오른쪽에 추가 속성(값)을 부여하고 
필드와 생성자를 통해 데이터와 비즈니스 로직을 하나로 결합

강력한 내장 메서드 기본 제공 : 문자열 변환(name), 순서(ordinal),
배열 반환(values), 문자열 기반 낚아채기(valueOf) 기본 지원

가독성과 유지보수성 폭발 : 소스 코드 내 하드코딩된 의미 없는 숫자나
문자열을 직관적인 상수명으로 대체하여 가치 명확화

/user/Role.java

package com.tenco.blog.user;

/**
 * - ADMIN : 관리자
 * - USER : 일반 사용자
 */
public enum Role {
    ADMIN, USER
}
"ADMIN", "USER" 문자열 대신 타입 안전한 enum 사용하면
오타를 컴파일 시점에 잡을 수 있어서 보다 안정적인 코드로 활용 될 수 있음.

UserRole 엔티티 만들기

UserRole 엔티티를 만들어 주는 이유는 회원가입시 이 사용자의 역할을 1:N 으로 관리하기 위함.
즉, 한 명의 사용자가 여러 개의 권한(예: 일반 유저이면서 동시에 관리자 임)을 가질 수 있도록
User와 권한 정보를 1:N(일대다) 관계로 안전하고 유연하게 관리하기 위해 설계 한다.

SQL문

create table user_role_tb(
	  id int primary key auto_increment, 
    user_id int not null ,
    role varchar(20) not null,
    unique key uk_user_role (user_id, role),
    foreign key (user_id) references user_tb(id)
);

 

중요내용 요약

테이블 유니크 복합 제약 조건 : @UniqueConstraint를 활용해
user_id와 role의 조합이 테이블 내에 단 하나만 존재하도록 uk_user_role 제약 명시

문자열 기반의 열거형 매핑 : @Enumerated(EnumType.STRING) 설정을 적용하여
무의미한 숫자 인덱스가 아닌 enum 상수 이름 자체를 DB에 안전하게 기록

데이터 식별자 자동 생성 전략 : GenerationType.IDENTITY 방식을 채택하여
MySQL 등 인메모리 및 영속성 환경에서 PK(id) 번호가 자동 증가하도록 설정

자율적 빌더 패턴 적용 : @Builder를 클래스 상단이 아닌 필요한 필드만
수용하는 커스텀 생성자에 한정 배치하여 엔티티 생성 시 가독성과 안정성 확보

Lombok 어노테이션 조화 : @Data와 @NoArgsConstructor의 조합을 통해
비즈니스 DTO 및 JPA 영속 컨텍스트 엔티티 프레임 구조의 표준 명세 준수

UserRole 코드⬇️

더보기
package com.tenco.blog.user;

import jakarta.persistence.*;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
@Entity
@Table(name = "user_role_tb",
       uniqueConstraints = {
        @UniqueConstraint(name = "uk_user_role", columnNames = {"user_id", "role"})
       })
public class UserRole {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer id;

    // private Integer user_id 컬럼은 User 부모 엔티에서 명시가 되어 자동 생성 됨.

    @Enumerated(EnumType.STRING)
    @Column(nullable = false, length = 20)
    private Role role;

    @Builder
    public UserRole(Integer id, Role role) {
        // id 는 UserRole 에 pk 이다.
        this.id = id;
        this.role = role;
    }
}

User 핵심 코드

일대다(1:N) 단방향 매핑에서 @JoinColumn의 작동 원리

@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.EAGER)
@JoinColumn(name = "user_id")
private List<UserRole> roles = new ArrayList<>();
이유: 주석 설명에 적힌 것처럼 관계형 데이터베이스(RDB)의 대원칙상
외래키(FK) 컬럼은 무조건 N쪽 테이블(user_role_tb)에 생성되어야 함.

하지만 자바 코드에서는 1쪽 엔티티(User)가 자식 리스트(List<UserRole>)를 쥐고 흔드는 단방향 구조.
이때 @JoinColumn(name = "user_id")를 선언해 주면, JPA는 "주소는 부모 클래스에 적혀있지만
실제 FK 컬럼은 상대방 테이블인 user_role_tb에 user_id라는 이름으로 만들고 
내가 제어하겠다"라고 인식하여 매핑을 성공시킴.

User 코드 ⬇️

더보기
package com.tenco.blog.user;

import jakarta.persistence.*;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.CreationTimestamp;

import java.sql.Timestamp;
import java.util.ArrayList;
import java.util.List;

@Data
@NoArgsConstructor
@Table(name = "user_tb")
@Entity
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer id;
    // 사용자명 중복 방지를 위한 유니크 제약 조건 설정
    @Column(unique = true)
    private String username;

    private String password;
    private String email;
    // 엔티티가 영속화 될 때 자동으로 현재 시간을 주입해라 pc -> db
    @CreationTimestamp
    private Timestamp createdAt;

    // User 테이블에는 이미지 파일명만 저장할 예정 (실제 데이터는 내 서버 컴퓨터 로컬에 저장할 예정)
    @Column(nullable =  true) // null 허용, 기본값
    private String profileImage;  // 프로필 이미지는 선택 사항(회원 가입 시)

    // User : UserRole 연관 관계를 단방향 1 : N 구조 설계
    // JPA가 1 : N 구조일 경우 (User , UserRole) , @JoinColumn(name ="user_id") 의미는
    // 여기 테이블에 컬럼 user_id 생성해 라는 의미이다. 그런데 1 : N 구조에서 FK 컬럼이
    // 1 쪽 테이블에 생성되는 경우는 없다. 무조건 N 쪽에 FK 컬럼이 만들어져야 하기 때문에
    // 자동으로 User 테이블에 @JoinColumn("user_id") 하더라도 알아서 UserRole 컬럼을 지가 생성 한다

    /**
     * 사용자 권한 목록
     * User (1) : UserRole (N) 연관 관계를 정의 함
     *
     * 1. @OneToMany + @JoinColumn(name ="user_id")
     * - User 가 UserRole 리스트를 관리 합니다. (단방향)
     * - 실제 DB user_role_tb 테이블에 FK 컬럼은 user_id 명이 user_role_tb 생성 된다.
     *
     * 2. CascadeType.ALL (운명 공동체)
     * Java 기준에서 User 저장하면 Role 도 자동 저장되고, User 삭제하면 가지고 있던
     * Role들도 다 삭제가 됩니다. DB 에서 실제 delete 쿼리가 발생 됩니다.
     *
     * 3. orphanRemoval (리스와 DBㄹ르 동기화)
     *  DB 에서 실제 delete 쿼리가 발생 됩니다. = true 처리
     *
     * 4. fetch = FetchType.EAGER (특별취급)
     * 데이터 양이 얼마 되지 않습니다. 그래서 한번에 데이터를 채워서 가지고 오는것이
     * 편리하다
     */
    @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.EAGER)
    @JoinColumn(name = "user_id")
    private List<UserRole> roles = new ArrayList<>();


    @Builder
    public User(Integer id, String username, String password,
                String email, Timestamp createdAt,
                String profileImage) {
        this.id = id;
        this.username = username;
        this.password = password;
        this.email = email;
        this.createdAt = createdAt;
        this.profileImage = profileImage;
    }

    // 편의 기능 추가 - 회원 정보 수정
    public void update(UserRequest.UpdateDTO updateDTO, String newProfileImageFileName) {
        this.password = updateDTO.getPassword();
        this.profileImage = newProfileImageFileName;
    }


    // ==  User 엔티티에 권한 관련 편의 기능 만들기 보기 ==

    // Role 추가 편의 메서드
    // Role.ADMIN, Role.USER
    public void addRole(Role role) {
        //this.roles.get(0) = new UserRole(1, Role.USER);
        this.roles.add(UserRole.builder().role(role).build());
    }

    // 해당 Role 을 가지고 있는 여부 확인
    // boolean isAdmin = user.haRole(Role.USER);
    public boolean hasRole(Role role) {
        // 1. 방어적 코드 작성
        if (this.roles == null || this.roles.isEmpty()) {
            // Role(해당 유저에 권한이) 자체가 설정 되지 않은 상태
            return false;
        }
        for(UserRole userRole: this.roles) {
            if(userRole.getRole() == role) {
                return true;
            }
        }
        return false;
    }

    // 관리지 여부 확인 편의 메서드
    public boolean isAdmin() {
        return hasRole(Role.ADMIN);
    }

    // 머스태치 화면에서 사용할 편의 메서드
    public String getRoleDisplay() {
        return isAdmin() ? "ADMIN" : "USER";
    }

}

​팁💡​

권한(Role) 정보에 한해서는 EAGER(즉시 로딩)로 특별 취급하는 것이
실무에서도 매우 흔하고 안전하며, 권장되는 패턴이다.
일반적으로 @OneToMany는 성능 문제(N+1 문제 등) 때문에 LAZY(지연 로딩)를 사용하는 것이 철칙.
하지만 UserRole은 예외적으로 항상 함께 사용하는 단짝 데이터이고 데이터의 양이 극히 적어 안전함