본문 바로가기
2025 Dev Log/2025.Backend

[#3][다시, 처음부터 #7] DB 연동 인증

by Dev.후크리 2025. 9. 27.

6일차에서 JWT 필터로 “토큰 → 필터 → SecurityContext” 흐름을 이미 실습했으니까,

이번엔 실제 서비스처럼 DB 사용자 계정과 권한(Role)을 기반으로 인증을 붙이는 단계

 


1. 개념 설명 (Concept)
  • 지금까지는 토큰 안에 username을 “직접 적어두고” 그대로 신뢰했지?
  • 하지만 실제 서비스에서는 사용자가 DB에 저장돼 있고, 로그인 시 DB에서 비밀번호 검증 → JWT 발급을 거쳐야 해.

흐름

  1. 클라이언트: /auth/login 요청 (username + password)
  2. 서버: AuthenticationManager → UserDetailsService 통해 DB에서 사용자 조회
  3. PasswordEncoder로 비밀번호 검증
  4. 성공 시 JWT 발급
  5. 이후 요청은 6일차에서 만든 JWT 필터가 토큰 검증 → SecurityContext 주입

👉 즉, 오늘 배우는 건 UserDetailsService + DB 엔티티 + PasswordEncoder.

 


2. 올바른 용어 (Terminology)

 

  • UserDetails (사용자 상세 정보): 스프링 시큐리티가 이해할 수 있는 사용자 모델 인터페이스
  • UserDetailsService (사용자 조회 서비스): username으로 UserDetails를 DB에서 가져오는 서비스
  • PasswordEncoder (비밀번호 인코더): 비밀번호 비교 시 해시 사용 (BCryptPasswordEncoder)
  • AuthenticationManager (인증 매니저): 로그인 요청을 인증하는 핵심 매니저
  • GrantedAuthority (권한 정보): ROLE_USER, ROLE_ADMIN 같은 역할 문자열

 


3. 잘못 쓰기 쉬운 표현 + 실제 맥락 (Misuse & Context)
  • 오해: “DB에 비밀번호 그냥 저장해도 되지 않나요?”
    → ❌ 절대 금지. 항상 BCryptPasswordEncoder 등 단방향 해시로 암호화 저장.
  • 오해: “User 엔티티를 바로 UserDetails로 쓰면 되지 않나요?”
    → 가능은 하지만, **UserDetails 인터페이스를 구현한 별도 클래스(UserPrincipal)**를 만들어서 보안용 객체와 엔티티를 분리하는 게 현업에서 선호됨.

 


4. 면접 예시 (Interview Q&A)

 

Q1. UserDetails와 User 엔티티를 왜 분리하나요?
A1. User 엔티티는 JPA 비즈니스 객체고, UserDetails는 보안 프레임워크 전용 객체라 책임이 달라서 분리하는 게 유지보수에 유리합니다.

Q2. PasswordEncoder가 꼭 필요한 이유는?
A2. 비밀번호를 평문으로 저장하면 보안 위협이 크므로 반드시 단방향 해시(BCrypt 등)로 저장하고, 로그인 시 해시를 비교해야 합니다.

 


5. 검증 요약 (Self-Check)

 

  • 가정/전제: H2/MySQL 같은 DB 연결됨. User/Role 테이블 존재.
  • 확인 포인트: UserDetailsService → DB 조회, PasswordEncoder 일치 검증, JWT 발급 흐름 연결.
  • 리스크/대안: 권한/사용자 테이블 관계(N:1) 잘못 매핑 시 인증 실패.

 


📌 예시 코드 (Java)

1) User 엔티티

package com.example.security.domain;

import jakarta.persistence.*;
import java.util.HashSet;
import java.util.Set;

@Entity
@Table(name = "users")
public class UserEntity {

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

    @Column(nullable = false, unique = true)
    private String username;

    @Column(nullable = false)
    private String password; // 암호화 저장

    @ManyToMany(fetch = FetchType.EAGER)
    @JoinTable(name = "user_roles",
            joinColumns = @JoinColumn(name = "user_id"),
            inverseJoinColumns = @JoinColumn(name = "role_id"))
    private Set<RoleEntity> roles = new HashSet<>();

    // getter/setter 생략
}
 

2) Role 엔티티

package com.example.security.domain;

import jakarta.persistence.*;

@Entity
@Table(name = "roles")
public class RoleEntity {

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

    @Column(nullable = false, unique = true)
    private String name; // "ROLE_USER", "ROLE_ADMIN"

    // getter/setter 생략
}

3) UserRepository

package com.example.security.repository;

import com.example.security.domain.UserEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;

public interface UserRepository extends JpaRepository<UserEntity, Long> {
    Optional<UserEntity> findByUsername(String username);
}

4) UserPrincipal (UserDetails 구현체)

package com.example.security.service;

import com.example.security.domain.UserEntity;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.Collection;
import java.util.stream.Collectors;

public class UserPrincipal implements UserDetails {

    private final UserEntity user;

    public UserPrincipal(UserEntity user) {
        this.user = user;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return user.getRoles().stream()
                .map(role -> new SimpleGrantedAuthority(role.getName()))
                .collect(Collectors.toSet());
    }

    @Override
    public String getPassword() {
        return user.getPassword();
    }

    @Override
    public String getUsername() {
        return user.getUsername();
    }

    @Override
    public boolean isAccountNonExpired() { return true; }

    @Override
    public boolean isAccountNonLocked() { return true; }

    @Override
    public boolean isCredentialsNonExpired() { return true; }

    @Override
    public boolean isEnabled() { return true; }
}

5) CustomUserDetailsService

package com.example.security.service;

import com.example.security.domain.UserEntity;
import com.example.security.repository.UserRepository;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

@Service
public class CustomUserDetailsService implements UserDetailsService {

    private final UserRepository userRepository;

    public CustomUserDetailsService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        UserEntity user = userRepository.findByUsername(username)
                .orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다: " + username));

        return new UserPrincipal(user);
    }
}

6) SecurityConfig

 
package com.example.security.service;

import com.example.security.domain.UserEntity;
import com.example.security.repository.UserRepository;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

@Service
public class CustomUserDetailsService implements UserDetailsService {

    private final UserRepository userRepository;

    public CustomUserDetailsService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        UserEntity user = userRepository.findByUsername(username)
                .orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다: " + username));

        return new UserPrincipal(user);
    }
}

👉 여기까지 하면 DB에 있는 사용자 + 비밀번호 검증 후 JWT 발급까지 완성.
👉 8일차에서 이제 권한(ROLE)에 따라 접근 제한을 추가할 수 있음.