[3] Spring Security Local: 도메인 설계와 UserDetails

2025. 12. 16. 03:21·Spring/Security
 

GitHub - HeoJunHyoung/bytemall

Contribute to HeoJunHyoung/bytemall development by creating an account on GitHub.

github.com

 

1. 들어가며

 이전 포스팅에서 프로젝트의 환경 설정과 패키지 구조를 잡았다면, 이제는 시스템의 주인공인 '사용자(Member)'를 정의할 차례다.

 

 Spring Security는 기본적으로 우리 프로젝트의 DB 구조를 알지 못한다. Spring Security가 아는 것은 오직 UserDetails라는 인터페이스뿐이다. 따라서 우리는 DB에 저장된 비즈니스 도메인(MemberEntity)을 Spring Security가 이해할 수 있는 보안 표준 객체(AuthMember)로 변환해 주는 작업이 필요하다. 이것이 바로 어댑터(Adapter) 패턴의 적용이다.

 

 이번 포스팅에서는 회원 엔티티를 설계하고, UserDetails와 UserDetailsService를 구현하여 DB와 보안 계층을 연결하는 과정을 다룬다.


2. 도메인 엔티티 설계 (Domain Entity)

가장 먼저, 실제 데이터를 저장할 엔티티와 관련 열거형(Enum)을 정의한다. 이 클래스들은 domain/member/entity 패키지에 위치하여 회원의 정보를 담당한다.

2.1. 권한과 등급 (Role & Grade)

회원의 권한을 관리할 Role과 등급을 관리할 Grade를 정의한다. Spring Security에서 권한은 보통 ROLE_ 접두사를 붙여 관리하므로, 추후 변환 로직에서 이를 처리할 것이다.

package com.example.bytemallbackend.domain.member.entity.enumerate;

public enum Role {
    USER,  // 일반 사용자
    ADMIN  // 관리자
}
package com.example.bytemallbackend.domain.member.entity.enumerate;

public enum Grade {
    IRON, SILVER, GOLD, DIAMOND
}

2.2. 회원 엔티티 (MemberEntity)

회원 엔티티는 JPA를 사용하여 DB 테이블과 매핑된다. 생성일/수정일 자동 관리를 위해 BaseEntity를 상속받으며, 무분별한 객체 생성을 막기 위해 생성자의 접근 제어자를 protected로 제한하고 정적 팩토리 메서드(of)를 사용한다.

@Entity
@Table(name = "members")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class MemberEntity extends BaseEntity {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id; // DB PK

    @Column(unique = true)
    private String username; // 로그인 ID

    private String password; // 암호화된 비밀번호

    @Enumerated(EnumType.STRING)
    private Role role;

    @Enumerated(EnumType.STRING)
    private Grade grade;

    // 생성자: 초기 가입 시 기본 권한(USER)과 등급(IRON) 부여
    private MemberEntity(String username, String password) {
        this.username = username;
        this.password = password;
        this.role = Role.USER;
        this.grade = Grade.IRON;
    }

    public static MemberEntity of(String username, String password) {
        return new MemberEntity(username, password);
    }
}

3. DTO 설계와 패키지 분리 전략 (DTO & Package Strategy)

 Controller에서 요청을 받을 DTO를 생성한다. 엔티티는 DB와 강하게 결합되어 있으므로, 요청 데이터는 반드시 별도의 DTO로 분리해야 한다. 여기서 주목할 점은 회원가입 요청 DTO(JoinRequest)의 위치다. 이 클래스는 domain/member가 아닌 domain/auth 패키지에 위치한다.

package com.example.bytemallbackend.domain.auth.dto.request;

@Data
@NoArgsConstructor
public class JoinRequest {
    private String username;
    private String password;
    private String passwordConfirm; // 비밀번호 확인용
}
💡 설계 Tip: 회원가입은 Member인가요 Auth인가요?

 RESTful 원칙에 따르면 회원을 생성하는 것이니 POST /api/members가 적절해 보일 수 있다. 하지만 본 프로젝트에서는 '보안(Security) 관점'에서 접근 제어를 명확히 하기 위해 회원가입 로직을 auth 패키지에 배치했다.

 Auth 패키지 (/api/auth/**): 인증되지 않은 익명 사용자가 시스템에 진입하기 위한 관문 (회원가입, 로그인, 토큰 재발급). Security 설정에서 permitAll()로 열어두는 영역이다.Member 패키지 (/api/members/**): 이미 인증된 사용자가 자신의 정보를 관리하는 영역 (내 정보 조회, 회원 수정). Security 설정에서 authenticated()로 보호하는 영역이다.

 이렇게 패키지를 분리하면 "누구나 접근 가능한 영역"과 "보호된 영역"이 코드 구조상에서 명확히 드러나며, 추후 '회원가입 후 자동 로그인(토큰 발급)' 기능을 구현할 때도 AuthService 내에서 처리가 용이해진다.

4. 리포지토리 계층 (Repository Layer)

 회원 정보를 DB에서 조회하기 위한 리포지토리를 생성한다. 여기서 가장 눈여겨봐야 할 메서드는 findByUsername이다.

public interface MemberRepository extends JpaRepository<MemberEntity, Long> {

    boolean existsByUsername(String username); // 중복 가입 방지

    Optional<MemberEntity> findByUsername(String username); // 로그인 인증 시 사용
}
💡 핵심 질문: 왜 findByUsername이 반드시 필요한가?

 단순히 로그인 로직을 짤 때 필요해서가 아니다. 이는 Spring Security의 동작 방식과 밀접한 관련이 있다. Spring Security에서 유저 정보를 가져오는 핵심 인터페이스인 UserDetailsService는 loadUserByUsername(String username)이라는 메서드 하나만을 정의하고 있다. 즉, Spring Security는 내부적으로 "유저의 이름(username)을 줄 테니, 유저 정보를 다오"라는 방식으로 동작한다.

 따라서 이 인터페이스를 구현하기 위해서는 DB에서 username 필드를 기준으로 회원을 조회할 수 있는 수단이 필수적이다. 만약 이 메서드가 없다면 UserDetailsService를 구현할 수 없으며, 결과적으로 Spring Security의 인증 프로세스와 DB를 연결할 수 없게 된다.

5. Security 연결고리: UserDetails 구현 (Adapter)

 이제 비즈니스 영역(domain)의 준비는 끝났다. 이제 이 데이터를 Security 영역(global/security)으로 끌어와야 한다. Spring Security는 로그인 시 사용자의 정보를 담는 그릇으로 UserDetails 인터페이스를 사용한다. 우리는 MemberEntity를 이 인터페이스에 맞춰 변환해 주는 어댑터 클래스(AuthMember)를 구현해야 한다.

5.1. AuthMember 구현

 AuthMember는 MemberEntity의 데이터를 감싸서 Security가 이해할 수 있는 형태로 보여주는 역할을 한다. 이 클래스에는 두 개의 생성자가 존재한다는 점에 주목해야 한다.

  1. 로그인 처리용: ID, Password, Role 등 모든 정보를 포함한다. (UserDetailsService에서 사용)
  2. JWT 인증용: Password 없이 ID와 Role만 포함한다. (이미 발급된 토큰을 검증할 때는 DB 조회 없이 토큰 내용만으로 객체를 만들기 위함이다.)
@Getter
public class AuthMember implements UserDetails {

    private Long id;        // 우리 시스템의 PK
    private String username;
    private String password;
    private Role role;

    // 1. 로그인용 생성자 (모든 정보 포함)
    public AuthMember(Long id, String username, String password, Role role) {
        this.id = id;
        this.username = username;
        this.password = password;
        this.role = role;
    }

    // 2. JWT 인증용 생성자 (비밀번호 불필요, DB 조회 최소화)
    public AuthMember(Long id, Role role) {
        this.id = id;
        this.role = role;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        // Role Enum을 Security가 인식하는 "ROLE_" 접두사 형태로 변환(★)
        return List.of(new SimpleGrantedAuthority("ROLE_" + role.name()));
    }

    @Override
    public String getPassword() { return password; }

    @Override
    public String getUsername() { return username; }

    // 계정 만료, 잠금 여부 등은 로직이 없으므로 true 반환
    @Override public boolean isAccountNonExpired() { return true; }
    @Override public boolean isAccountNonLocked() { return true; }
    @Override public boolean isCredentialsNonExpired() { return true; }
    @Override public boolean isEnabled() { return true; }
}
💡 아래처럼 "ROLE_" 접두사를 반드시 붙여야 하나요?
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
    // Role Enum을 Security가 인식하는 "ROLE_" 접두사 형태로 변환(★)
    return List.of(new SimpleGrantedAuthority("ROLE_" + role.name()));
}​

 

Spring Security에서 권한(Authority)과 역할(Role)은 미묘하게 다르다.
- Authority: 문자열 그대로의 권한 (예: "READ_PRIVILEGE", "ROLE_USER")
- Role: 'ROLE_' 접두사가 있다고 가정하고 처리하는 권한 (예: "USER"라고 쓰면 내부적으로 "ROLE_USER"를 찾음)

Spring Security의 hasRole("USER") 메서드를 사용할 때, 프레임워크는 자동으로 ROLE_ 접두사를 붙여서 검사한다. 
따라서 UserDetails 구현체인 AuthMember에서 권한을 반환할 때 이 규칙을 맞춰주기 위해 접두사를 수동으로 붙여주는 것이다. 

만약 여기서 "ROLE_"을 붙이지 않고 그냥 "USER"만 반환한다면, 나중에 시큐리티 설정에서 .hasRole("USER")로 검사할 때 매칭되지 않아 접근 거부(403)가 발생할 수 있으므로 반드시 "ROLE_" 접두사를 붙여야한다.

6. 핵심 로직: UserDetailsService 구현

 마지막으로 AuthenticationManager가 "유저가 존재하는지" 확인할 때 호출하는 UserDetailsService를 구현한다. 이 서비스는 오직 하나의 메소드, loadUserByUsername만을 가진다. 여기서 우리는 MemberRepository를 통해 DB에서 유저를 조회하고, 조회된 결과를 위에서 만든 AuthMember로 변환하여 반환한다.

6.1. CustomUserDetailsService

@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {

    private final MemberRepository memberRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // 1. DB에서 회원 조회
        MemberEntity member = memberRepository.findByUsername(username)
                .orElseThrow(() -> new UsernameNotFoundException("존재하지 않는 회원입니다."));

        // 2. AuthMember(UserDetails)로 변환하여 반환
        return new AuthMember(
                member.getId(),
                member.getUsername(),
                member.getPassword(),
                member.getRole()
        );
    }
}

6.2. 작동 원리 리마인드

 이 CustomUserDetailsService는 우리가 직접 호출하는 것이 아니다. 로그인 시 AuthenticationManager.authenticate()가 호출될 때, 내부적으로 이 클래스의 loadUserByUsername()을 실행하여 유저 정보를 가져가고, 사용자가 입력한 패스워드와 비교하게 된다.

 

 참고로 loadUserByUsername() 메서드는 추후에 작성할 AuthService에서 아래 코드에 의해 내부적으로 실행되며, 이렇게 반환한 값은 authentication에 저장되어 jwt 토큰(access/refresh)을 만드는데 사용된다.

Authentication authentication = authenticationManager.authenticate(authenticationToken);

'Spring > Security' 카테고리의 다른 글

[6] Spring Security Local: Sequence Diagram  (0) 2025.12.16
[5] Spring Security Local: 로컬 로그인과 토큰 재발급  (0) 2025.12.16
[4] Spring Security Local: JWT 발급과 인증 필터 구현  (1) 2025.12.16
[2] Spring Security Local: 프로젝트 구조 설계 및 필수 의존성 설정  (0) 2025.12.16
[1] Spring Security Local: 동작 원리 이해  (0) 2025.12.16
'Spring/Security' 카테고리의 다른 글
  • [5] Spring Security Local: 로컬 로그인과 토큰 재발급
  • [4] Spring Security Local: JWT 발급과 인증 필터 구현
  • [2] Spring Security Local: 프로젝트 구조 설계 및 필수 의존성 설정
  • [1] Spring Security Local: 동작 원리 이해
h6bro
h6bro
백엔드 개발자의 기술 블로그
  • h6bro
    Jun's Tech Blog
    h6bro
  • 전체
    오늘
    어제
    • 분류 전체보기 (241) N
      • Java (18)
        • Core (9)
        • Design Pattern (9)
      • Spring (80)
        • Core (24)
        • MVC (6)
        • DB (10)
        • JPA (26)
        • Monitoring (3)
        • Security (11)
        • WebSocket (0)
      • Database (33)
        • Redis (15)
        • MySQL (18)
      • MSA (16)
        • MSA 기본 (11)
        • MSA 아키텍처 (5)
      • Kafka (30) N
        • Core (18) N
        • Connect (12)
      • ElasticSearch (11)
        • Search (11)
        • Logging (0)
      • Test (4)
        • k6 (4)
      • Docker (9)
      • CI&CD (10)
        • GitHub Actions (6)
        • ArgoCD (4)
      • Kubernetes (18)
        • Core (12)
        • Ops (6)
      • Cloud Engineering (4)
        • AWS Infrastructure (3)
        • AWS EKS (1)
        • Terraform (0)
      • Project (8)
        • LinkFolio (1)
        • Secondhand Market (7)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

    • Cloud Engineering 포스팅 정리
  • 인기 글

  • 태그

    ㅈ
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
h6bro
[3] Spring Security Local: 도메인 설계와 UserDetails
상단으로

티스토리툴바