반응형
About Me

안녕하세요, cool & soft한 백엔드 개발자가 되고싶은 토니입니다.

Notice
Recent Posts
Recent Comments
«   2025/07   »
1 2 3 4 5
6 7 8 9 10 11 12
13 14 15 16 17 18 19
20 21 22 23 24 25 26
27 28 29 30 31
Archives
Today
Total
관리 메뉴

Code Art Online

[Spring Boot] Spring Security 권한 분리하기 본문

Spring Boot

[Spring Boot] Spring Security 권한 분리하기

kiritoni 2025. 5. 30. 22:23
반응형

서비스를 개발하다보면 여러가지 권한을 가진 회원들이 생기는 경우가 많다.

방문하는 모든 사람들이 접근할 수 있는 리소스, 그리고 회원만 접근할 수 있는 리소스가 있을 것이다.

작은 프로젝트에서는 회원만 접근할 수 있는 리소스는 토큰이 필요하게끔하면 되겠지만,

관리자 계정이 필요한 경우도 있다. 

아주 간단하게는 리눅스처럼 관리자용 특정 `user_id`를 주어 해결할 수 있다. 

하지만 권한을 나눠야하는 case가 3개 이상인 경우, `user_id`로 구분하는 방식은 유연성과 확장성이 떨어진다.

따라서 내가 Spring Security로 권한을 나누고 관리하는 방법에 대해 적어보고자 한다. 

Spring Security를 이미 사용하고 있다면, 권한을 나누는 방법은 아주 간단하다.

 

1. Role enum을 만든다.

2. Spring Security에 추가한다. 

3. 크게 나누고, 작게 나눈다.

 

세 가지만 머릿속에 가지고 있다면 5분안에 금방 적용할 수 있다. 

Spring Security는 인증뿐만 아니라, 역할(Role) 기반의 권한 제어를 손쉽게 구현할 수 있도록 해준다. 

 

 

* JWT + 소셜로그인 + Spring Security를 사용한다는 전제 하에 작성하였다.

 

 

 


1. 역할(Role) 정의

# enum

먼저 `Role`은 `Enum`으로 정의하고 `GrantedAuthority`를 구현하여 Spring Security와 호환되도록 설정해준다.

package nova.backend.domain.user.entity;

import lombok.Getter;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.GrantedAuthority;

@Getter
@RequiredArgsConstructor
public enum Role implements GrantedAuthority {
    USER("일반 회원"),
    OWNER("카페 사장"),
    STAFF("카페 직원");

    private final String title;

    @Override
    public String getAuthority() {
        return name();
    }
}

 

`getAuthority()`는 Spring Security에서 인식하는 권한이름을 반환할 수 있도록 해준다. 보통은 ROLE_ 접두어를 사용하여 붙인 문자열을 사용해야 한다. 이후 `SecurityConfig`에서 `.hasRole("OWNER")` 등으로 간단히 검사할 수 있게 된다.

내 프로젝트에서는 일반 회원, 카페 사장, 카페 직원 (+ 추후에는 관리자 계정이 필요하다)으로 권한을 나누어주어야 한다. ) 으로 다소 복잡했다.

 

# entity

`User` 엔티티에도 `Role` 을 추가해준다.

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

 

참고로 @Enumerated(EnumType.STRING)은 enum 타입을 JPA의 엔티티에 매핑할 때 사용하는 annotation이다.
Role이라는 enum이 DB에는 문자열(String) 형태로 저장된다.
ORDINAL이 기본값인데, enum의 순서(index)를 저장한다. (0, 1, 2 ...)
ORDINAL은 DB에서 가독성이 떨어지고, 순서가 바뀌면 매핑 오류가 나므로 STRING으로 꼭 명시해주는 것을 잊지 말자.

 


 

 

 

 

2. 사용자 인증 정보와 권한 부여

# UserLoginRequestDTO

내 프로젝트에서는 소셜로그인을 사용하고 있다. `UserService`에서 새로운 유저를 저장할 때 `role`이 저장되로록 해준다.

우선 로그인할 때 필요한 `UserLoginRequestDTO`에 `Role`을 추가해준다.

package nova.backend.domain.user.dto.request;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import nova.backend.domain.user.entity.Role;
import nova.backend.domain.user.entity.SocialType;

public record UserLoginRequestDTO(
        @NotBlank String code,
        @NotNull SocialType socialType,
        @NotNull Role role
) {}

 

 

# UserService

그리고 `UserService`의 사용자 정보를 저장하는 `saveUser`라는 메서드에서 이 DTO를 사용하고 있다. 

여기에도 `role`을 추가해준다.

User newUser = User.builder()
    .socialId(socialId)
    .socialType(userLoginRequest.socialType())
    .profileImageUrl(imageUrl)
    .role(userLoginRequest.role()) // USER, OWNER, STAFF 중 하나
    .name(nickname)
    .qrCodeValue(qrCode)
    .build();

 

# OauthService

필요한 부분이 있다면 `role` 관련 코드를 프로젝트에 맞게 추가해준다.

나는 `OauthService`에 필요한 `.getRole`을 추가했다.

@Transactional
    public UserTokenResponseDTO socialLogin(UserLoginRequestDTO userLoginRequest) {
        SocialType socialType = userLoginRequest.socialType();

        // 1. OAuth 토큰 가져오기
        String oauthToken = oauthTokenProvider.getOauthToken(userLoginRequest.code(), socialType);

        // 2. 사용자 정보 가져오기
        JsonNode userResource = oauthUserResourceProvider.getUserResource(oauthToken, socialType);
        Map<String, String> userInfo = oauthUserResourceProvider.extractUserInfo(userResource, socialType);
        String socialId = userInfo.get("socialId");
        String imageUrl = userInfo.get("imageUrl");
        String name = userInfo.get("name");

        // 3. 사용자 저장
        User user = userService.saveUser(socialId, imageUrl, name, userLoginRequest);

        // 4. JWT 토큰 발급
        return tokenService.issueToken(user.getUserId(), user.getRole());
    }

 


 

 

 

 

3. Spring Security에서 인증 객체 구성

# CustomUserDetails

`UserDetails`를 구현한 `CustomUserDetails` 클래스를 통해 사용자 정보를 인증 객체로 감싸준다. 권한은 `ROLE_` 접두어를 붙여준다.

@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
    return Collections.singletonList(new SimpleGrantedAuthority("ROLE_" + role.name()));
}

`CustomUserDetails`는 필수가 아니다. 하지만 JWT에 추가정보를 담아줘야 한다면 커스텀을 해주는 것이 좋다. 내 프로젝트의 경우, JWT에 `OWNER`/`STAFF`는 `selectedCafeId`를 추가적으로 담아야 했기 때문에 `CustomUserDetails`를 구현했다. 한 번만 만들어두면 편하게 꺼내 쓸 수 있다.

 

 

 

 

 


4. JWT와 사용자 인증 연결

# CustomUserDetails

JWT에서 `userId`를 추출하고, 이를 통해 유저 정보를 불러와서 `CustomUserDetails`로 포장한다. 

    @Override
    public UserDetails loadUserByUsername(String userId) throws UsernameNotFoundException {
        Long id;
        try {
            id = Long.parseLong(userId);
        } catch (NumberFormatException e) {
            throw new UnauthorizedException(ErrorCode.INVALID_ACCESS_TOKEN);
        }

        User user = userRepository.findById(id)
                .orElseThrow(() -> new UnauthorizedException(ErrorCode.USER_NOT_FOUND));

        return new CustomUserDetails(user, null);
    }

 

 

 


 

 

 

5. URL 기반 권한 분리 설정 (크게 묶어주기)

# SecurityConfig

`SecurityConfig`에서 역할에 따라 접근 가능한 API를 명시해준다. 

.authorizeHttpRequests(auth -> auth
    .requestMatchers("/api/owner/**").hasAnyAuthority("ROLE_OWNER")
    .requestMatchers("/api/staff/**").hasAnyAuthority("ROLE_OWNER", "ROLE_STAFF")
    .requestMatchers(SecurityWhitelist.SPRING_WHITE_LIST).permitAll()
    .anyRequest().authenticated()
)

이 설정을 통해 `/api/owner/**`는 `OWNER`만, 

`/api/staff/**`는 `OWNER`와 `STAFF`로 모두 접근이 가능하다.

 


 

 

6. 메서드 수준 권한 체크 (작게 나눠주기)

# Controller

경로 기반으로는 큰 단위로, 작은 메서드 단위를 관리하는 방법은 세 가지가 있다.

@Secured

  • Spring Security 전용 annotation
  • `ROLE_` 접두어를 반드시 붙여야 한다.
  • 배열 형태로 여러 역할을 지정할 수 있다. 
  • 단순한 역할 기반 접근 제어만 가능하다. (조건식 사용 불가능)
@Secured("ROLE_OWNER")
public void updateCafeInfo() { ... }

@Secured({"ROLE_OWNER", "ROLE_STAFF"})
public void accessStaffPage() { ... }

 

 

@RolesAllowed

  • JSR-250 표준 annotation이라고 한다. 
  • Spring Security에서도 사용이 가능하다.
  • `ROLE_` 접두어를 반드시 붙여야 한다.
  • Java EE 표준이기 때문에 다른 보안 프레임워크에서도 사용할 수 있다고 한다.
@RolesAllowed("ROLE_USER")
public void viewMyPage() { ... }

 

 

@PreAuthorize

  • SpEL(Spring Expression Language) 기반으로, 매우 유연하다.
  • `hasRole`, `hasAuthority`, `hasAnyRole`, `hasPermission` 등으로 다양하게 표현할 수 있다. 
  • 접두어를 생략할 수 있다.
  • 역할뿐만 아니라 사용자 조건 기반의 복잡한 검사를 할 수 있다. 
  • `@AuthenticationPrincipal`, 파라미터 비교 등의 다양한 보안 로직을 적용할 수 있다.
  • 나는 작은 메서드 단위는 `@PreAuthorize`를 사용한다.
@PreAuthorize("hasRole('OWNER')")
public void updateCafeInfo() { ... }

@PreAuthorize("hasAnyRole('OWNER', 'STAFF')")
public void accessStaffPage() { ... }

@PreAuthorize("#userId == authentication.principal.userId")
public void deleteUser(Long userId) { ... }

 

 

 

이 부분이 3. 크게 나누고, 작게 나눈다. 이다. 그러면 URL 기반 접근 제어(`authorizeHttpRequests`)와 메서드 수준 제어(`@PreAuthorize`)는 무슨 차이가 있을까? 모두 '접근 제어'라는 공통 기능을 갖고 있지만 적용 시점에 따라서 차이가 있다. 

 

1. `authorizeHttpRequests` (URL 기반 접근 제어)

HTTP 요청이 컨트롤러에서 도달하기 전에 필터 체인에서 처리한다.

https://docs.spring.io/spring-security/reference/servlet/authorization/authorize-http-requests.html

 

Authorize HttpServletRequests :: Spring Security

While using a concrete AuthorizationManager is recommended, there are some cases where an expression is necessary, like with or with JSP Taglibs. For that reason, this section will focus on examples from those domains. Given that, let’s cover Spring Secu

docs.spring.io

 

 

2. `@PreAuthorize` (메서드 수준 접근 제어)

메서드가 호출되기 직전에 AOP를 통해 처리한다.

https://docs.spring.io/spring-security/reference/servlet/authorization/method-security.html

 

Method Security :: Spring Security

There are some scenarios where you may not wish to throw an AuthorizationDeniedException when a method is invoked without the required permissions. Instead, you might wish to return a post-processed result, like a masked result, or a default value in cases

docs.spring.io

 

따라서 `authorizeHttpRequest`으로는 전역적인 보안을 관리하고, 세밀한 비즈니스 로직 수준 보안은 `@PreAuthorize`로 관리하면 된다.

 

 

 


 

 

 

 

7. 컨트롤러에 CustomUserDetails 사용하기

# Controller

Spring Security를 사용하면 사용자 정보를 `SecurityContextHolder`를 통해 가져올 수 있다. 단순히 이메일이나 `username`만 필요한 게 아니라 `userId`, `role`, `selectedCafeId`, `nickname`같은 추가 사용자 정보가 필요하다면 `CustomUserDetails`로 구조화해줄 수 있다. 

 

Spring Security는 `UserDetails`라는 인터페이스로 인증 객체를 정의하고, 기본 구현체는 `username`, `password`, `GrantedAuthority` 세 가지만 제공한다. 

 

여기서 `selectedCafeId`의 설명을 덧붙이자면, `OWNER`(카페 사장), `STAFF`(카페 직원)은 서비스 진입 단계에서 자신이 소속된 카페 목록에서 조회할 카페 정보를 선택해야 한다. 우리가 아는 쉬운 서비스로 예를 들어보자면, 배달의 민족이 있다.
우리는 여러개의 집 주소를 등록할 수 있고, 그것을 기준으로 식당이나 카페 등을 조회하게 된다. 
만약에 이 정보를 DB에 직접 넣어서 메서드를 호출할 때마다 또 타고타고 들어가...
DB에서 정보를 꺼내와야 한다면 조회량이 많아질 수밖에 없다.
그래서 나는 정보를 JWT에 넣는 방법을 선택했다.
정리하자면 비즈니스 로직에서 필요한 정보마다 DB를 다시조회해야 하므로, 이를 방지하기 위해서 `CustomUserDetails`를 사용했다. 
    @GetMapping("/stampbook-designs")
    public ResponseEntity<SuccessResponse<?>> getAllStampBookDesigns(
            @AuthenticationPrincipal CustomUserDetails userDetails
    ) {
        List<StampBookDesignDetailDTO> designs = ownerCafeService
                .getAllStampBookDesigns(userDetails.getUserId(), userDetails.getSelectedCafeId());
        return SuccessResponse.ok(designs);
    }

 

컨트롤러에서는 이렇게 사용자 정보를 바로 주입받아서 사용할 수 있다.

 

 

 

 


 

 

8. 경로, 클래스명 깔끔하게 만들기

경로 기반 제어를 한다면 모든 경로를 Spring Security에서 설정한 것과 같이 수정해주어야 한다.

나는 일반 회원 or 비회원 기능은 서비스/컨트롤러를 만들 때, 가장 기본적인 경로를 사용했다. (예: /api/cafes/)

owner, staff 권한을 가진 회원만 사용할 수 있는 기능은 서비스/컨트롤러에 모두 맞는 권한을 기본 경로 앞에 적어주었다. (예: /api/owner/cafes, /api/staff/cafes)

또한 클래스명도 권한별로 구분할 수 있도록 하였다. (예: CafeController -> StaffCafeController -> OwnerCafeController)

staff 관련 클래스는 staff 권한 이상인 STAFF, OWNER만 접근할 수 있도록 구성하였다.

다시 Spring Security를 살펴보면 확인할 수 있다. 

.authorizeHttpRequests(auth -> auth
    .requestMatchers("/api/owner/**").hasAnyAuthority("ROLE_OWNER")
    .requestMatchers("/api/staff/**").hasAnyAuthority("ROLE_OWNER", "ROLE_STAFF")
    .requestMatchers(SecurityWhitelist.SPRING_WHITE_LIST).permitAll()
    .anyRequest().authenticated()
)

 

표로 정리해보면 다음과 같다. 

URI 경로 또는 클래스 접근 가능
/api/**, ~Controller, ~Service 비회원, 일반회원(USER), 직원(STAFF), 사장(OWNER)
/api/staff/**, Staff~Controller, Staff~Service 직원(STAFF), 사장(OWNER)
/api/owner/**, Owner~Controller, Owner~Service 사장(OWNER)

 

USER에 대해 따로 경로를 막아두지 않은 이유는, 직원/사장들도 언제든지 일반회원 기능을 사용할 수 있기 때문이다. 내 프로젝트에서는 모두 소셜로그인을 사용했는데, 그렇다 보니 하나의 계정으로 직원/사장도 일반회원 서비스를 사용할 수 있어야 했다. 예를 들어 배달의 민족 서비스를 사용하는 식당이라고 해보자. 식당의 사장도 집에서 배달을 시켜먹을 수 있어야 한다. 그런데 사장이라고 해서 일반회원 서비스가 막혀있다면 서비스가 이상해진다. 

 

최선의 방법이라고 할 수는 없지만, 우선 개발하는 사람이 헷갈리지 않고 잘 구분되도록 만드는 것이 좋을 것 같다. 

 

 

 


 

9. + 문서 잘 쓰기

다 적용했으면 API 문서(Swagger 등)에도 잘 적어서 프론트엔드 개발자님들이 헷갈리지 않도록 해주자. 권한을 여러개로 분리하면 필요한 api를 찾기가 힘들어진다.

 

나는 Swagger UI를 사용하는데, `@Tag(name="")`에 적은대로 정렬이 된다. 

그래서 기본적이면서 인증 단계에서 사용하는 api 는 1번을 붙이고, 일반회원은 2번, 직원은 3번, 사장은 4번을 붙여 문서가 뒤죽박죽되지 않고 순서대로 찾아볼 수 있도록 하였다. Swagger UI에서 순서를 따로 지정하는 기능은 없었는데, 앞에 숫자를 붙이면 순서가 유지되어 힘들게 스크롤을 읽고 문자를 읽는 스트레스는 조금이나마 해소될 수 있다.

 

Swagger-ui

 

 

 

 

반응형