본문으로 건너뛰기

12.2 인증 구현 — BCrypt 암호화, UserDetailsService

회원 정보가 데이터베이스에 저장될 때 비밀번호는 절대 평문으로 저장되어서는 안 됩니다. 또한, 스프링 시큐리티가 DB에 있는 사용자 정보를 읽어올 수 있도록 다리를 놓아주어야 합니다.

1. PasswordEncoder와 BCrypt​

BCrypt는 비밀번호 해싱에 널리 사용되는 알고리즘으로, 강력한 보안성과 함께 솔트(Salt) 를 자동으로 생성하여 레인보우 테이블 공격을 방어합니다.

PasswordEncoder 빈 등록​

@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}

2. UserDetails와 UserDetailsService​

스프링 시큐리티가 사용자 정보를 이해할 수 있도록 인터페이스를 구현해야 합니다.

  • UserDetails: 사용자의 이름, 비밀번호, 권한 등의 정보를 담는 객체입니다.
  • UserDetailsService: 데이터베이스 등 저장소에서 사용자 정보를 조회하여 UserDetails를 반환하는 서비스입니다.

UserDetailsService 커스텀 구현 예시​

@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {

private final UserRepository userRepository;

@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return userRepository.findByEmail(username)
.map(user -> User.builder()
.username(user.getEmail())
.password(user.getPassword()) // DB에는 BCrypt로 암호화된 값 필수
.roles(user.getRole().name())
.build())
.orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다: " + username));
}
}

3. 회원가입 시 비밀번호 암호화​

사용자 등록 로직에서 반드시 PasswordEncoder를 사용해야 합니다.

public void signup(UserDto dto) {
String encodedPassword = passwordEncoder.encode(dto.getPassword());
User user = User.builder()
.email(dto.getEmail())
.password(encodedPassword)
.role(Role.USER)
.build();
userRepository.save(user);
}

🎯 핵심 요점​

  • 비밀번호는 반드시 BCrypt 와 같은 단방향 해시 알고리즘으로 암호화해야 합니다.
  • UserDetailsService 는 시큐리티와 DB 사이의 연결 고리 역할을 합니다.
  • loadUserByUsername 메서드에서 사용자 존재 여부를 검증하고 UserDetails 객체를 반환합니다.

2. 인증 처리 흐름과 실전 예제​

스프링 시큐리티의 인증 과정은 여러 컴포넌트가 협력하여 이루어집니다. 여기서는 가장 일반적인 폼 로그인(ID/PW) 방식의 전체 라이프사이클을 추적하며 상세히 알아봅니다.

1. 전체 인증 프로세스 (Mermaid)​

sequenceDiagram
participant User as 사용자(클라이언트)
participant Filter as AuthenticationFilter
participant Manager as AuthenticationManager
participant Provider as AuthenticationProvider
participant Service as UserDetailsService
participant DB as Database

User->>Filter: 1. 로그인 요청 (username, password)
Filter->>Filter: 2. UsernamePasswordAuthenticationToken 생성 (미인증 상태)
Filter->>Manager: 3. authenticate(token) 호출
Manager->>Provider: 4. 지원하는 Provider에게 인증 위임
Provider->>Service: 5. loadUserByUsername(username)
Service->>DB: 6. 사용자 정보 조회
DB-->>Service: 사용자 엔티티 반환
Service-->>Provider: 7. UserDetails 객체 반환
Provider->>Provider: 8. 비밀번호 검증 (BCrypt 비교)
Provider-->>Manager: 9. 인증된 Authentication 객체 반환
Manager-->>Filter: 10. 인증 완료
Filter->>Filter: 11. SecurityContextHolder에 인증 정보 저장
Filter-->>User: 12. 로그인 성공 응발/리다이렉트

2. 핵심 컴포넌트 역할​

  1. AuthenticationFilter: HTTP 요청을 낚아채서 Authentication 객체(미인증)를 만들고 Manager에게 전달합니다.
  2. AuthenticationManager: 실제 인증을 총괄하는 인터페이스입니다. (일반적으로 ProviderManager 구현체 사용)
  3. AuthenticationProvider: 실제 비즈니스 로직(ID/PW 검증 등)을 수행합니다. 여러 개를 등록하여 다중 인증(LDAP, DB, Social 등)을 처리할 수 있습니다.
  4. UserDetailsService: DB에서 사용자 정보를 가져오는 "데이터 제공자" 역할만 수행합니다.
  5. SecurityContextHolder: 인증이 완료된 정보를 애플리케이션 전역에서 참조할 수 있도록 보관하는 저장소입니다.

3. 실전 예제: 로그인 API 구현 (JWT 방식)​

최근 추세에 맞춰, 인증 완료 후 JWT 토큰을 발급하는 간단한 컨트롤러 예시입니다.

LoginRequest DTO​

public record LoginRequest(String email, String password) {}

AuthController​

@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {

private final AuthenticationManager authenticationManager;
private final TokenProvider tokenProvider;

@PostMapping("/login")
public ResponseEntity<String> login(@RequestBody LoginRequest request) {
// 1. 미인증 토큰 생성
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(request.email(), request.password());

// 2. 실제 인증 시작 (Manager -> Provider -> Service 흐름 작동)
// 인증 실패 시 Exception 발생
Authentication authentication = authenticationManager.authenticate(authenticationToken);

// 3. 인증 정보를 Context에 저장 (생략 가능하나 권장)
SecurityContextHolder.getContext().setAuthentication(authentication);

// 4. 인증된 정보를 바탕으로 JWT 토큰 생성 및 반환
String jwt = tokenProvider.createToken(authentication);
return ResponseEntity.ok(jwt);
}
}

4. 인증 정보 활용 (@AuthenticationPrincipal)​

로그인한 사용자의 정보를 컨트롤러에서 바로 꺼내 쓰고 싶을 때 사용합니다.

@GetMapping("/api/me")
public ResponseEntity<String> getMyInfo(@AuthenticationPrincipal UserDetails userDetails) {
return ResponseEntity.ok("현재 접속 유저: " + userDetails.getUsername());
}

🎯 핵심 요점​

  • 인증은 Filter -> Manager -> Provider -> Service 단계를 거칩니다.
  • AuthenticationManager 는 인증의 중심 역할을 하며, 실제 검증은 Provider 가 담당합니다.
  • 인증이 성공하면 유저 정보는 SecurityContextHolder 에 담겨 전역에서 접근 가능해집니다.