Develop

[Spring Boot] 게시판 - 14. Spring Security 로그인 구현 본문

백엔드/게시판 만들기

[Spring Boot] 게시판 - 14. Spring Security 로그인 구현

230801 2025. 12. 14. 23:07

안녕하세요 ~ .ᐟ

오늘은 Spring Security를 활용한 기본 로그인 기능을 구현했습니다.

 

JWT는 내일 추가할 예정이고, 오늘은 Spring Security의 핵심 아키텍처를 이해하고 로그인 플로우를 보여드리겠습니다.

 

목차

  1. Spring Security 란?
  2. Spring Security 의 아키텍처 이해
  3. UserDetailsService 구현
  4. 로그인 API 만들기
  5. 테스트 결과

 

1. Spring Security 란?

  • Spring 생태계의 인증/인가 프레임워크
  • 쉽게말하면 아래 두 가지를 처리해주는 도구
    • "이 사람 누구야?" (인증, Authentication)
    • "이 사람 이거 할 수 있어?" (인가, Authorization)
  • 장점
    • 검증된 보안 메커니즘을 쉽게 사용할 수 있음
    • CSRF, XSS 등 보안 공격 자동 방어
    • 세션/JWT 등 다양한 인증방식 지원
    • 권한 관리 (`@PreAuthorize`) 편리

 


 

2. Spring Security 아키텍처 이해

  • 전체 흐름
사용자가 /api/auth/login 호출
    ↓
AuthService.login() 실행
    ↓
AuthenticationManager.authenticate() 호출
    ↓
[내부 동작]
    1) CustomUserDetailsService.loadUserByUsername(email) 호출
    2) DB에서 User 조회
    3) CustomUserDetails로 변환
    4) PasswordEncoder로 비밀번호 비교
    ↓
성공 → Authentication 객체 반환
실패 → BadCredentialsException 던짐

 

  • 핵심 용어 정리
1) Authentication (인증 객체)
- "이 사람 누구야?"에 대한 정보
- 로그인 성공하면 이 객체에 사용자 정보가 담김

2) Principal (주체)
- Authentication 안에 들어있는 "현재 사용자"
- 우리는 CustomUserDetails를 Principal로 사용

3) SecurityContext
- 현재 요청의 인증 정보를 담는 저장소
- 우리는 STATELESS 설정이라 저장 안함

4) AuthenticationManager
- 인증의 핵심 처리기
- "비밀번호 맞아?" 확인하는 역할

5) UserDetailsService
- "이 username(email)으로 사용자 찾아줘"
- DB 조회 담당

 


 

3. UserDetailsService 구현

CustomUserDetailsService

@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {

    private final UserRepository userRepository;

    @Override
    @Transactional(readOnly = true)
    public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
        User user = userRepository.findByEmail(email)
            .orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다: " + email));

        return new CustomUserDetails(user);
    }
}

 

왜 메서드명이 loadUserByUsername인데 email 을 받나요?

  • Spring Security Interface 규칙
  • 실제로는 email을 받지만, 메서드명은 그대로 써야 합니다.

 

CustomUserDetails

@Getter
@RequiredArgsConstructor
public class CustomUserDetails implements UserDetails {

    private final User user;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
    	
        // ROLE_USER, ROLE_ADMIN 형태로 변환
        return Collections.singleton(
            new SimpleGrantedAuthority("ROLE_" + user.getRole().name())
        );
    }


    @Override
    public String getUsername() {
        return user.getEmail(); // email을 username으로 사용
    }

    // 기타 오버라이드 : 계정 상태 관련 (지금은 다 true)
    
}

 

getAuthorities()의 역할

  • 이 사용자가 어떤 권한을 가졌는지 리턴
  • ROLE_USER, ROLE_ADMIN 형태로 변환
  • 나중에 @PreAuthorize("hasRole('ADMIN')") 같은거 쓸 때 여기서 확인

 

4. 로그인 API 만들기

AuthService

@Service
@RequiredArgsConstructor
public class AuthService {

    private final AuthenticationManager authenticationManager;

    public LoginResponse login(LoginRequest request) {
        // 1. 인증 요청
        Authentication authentication = authenticationManager.authenticate(
            new UsernamePasswordAuthenticationToken(
                request.getEmail(),
                request.getPassword()
            )
        );

        // 2. 인증 성공 시 UserDetails 꺼내기
        CustomUserDetails userDetails = (CustomUserDetails) authentication.getPrincipal();

        // 3. 응답 생성
        return LoginResponse.of(
            userDetails.getUserId(),
            userDetails.getUsername(),
            userDetails.getUser().getName(),
            userDetails.getUser().getRole().name()
        );
    }
}

 

AuthenticationManager 내부 동작

1. authenticate() 호출
    ↓
2. CustomUserDetailsService.loadUserByUsername() 호출
    ↓
3. 리턴받은 UserDetails의 password와 입력 password 비교
    ↓
4. 일치 → Authentication 리턴
   불일치 → BadCredentialsException 던짐

 

 

 

 

AuthController

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

    private final AuthService authService;

    @PostMapping("/login")
    public ResponseEntity<ApiResponse<LoginResponse>> login(
            @Valid @RequestBody LoginRequest request) {
        
        LoginResponse response = authService.login(request);
        
        return ResponseEntity.ok(
            ApiResponse.success(response, "로그인에 성공했습니다.")
        );
    }
}

 

GlobalExceptionHandler에 예외 처리 추가

@ExceptionHandler(BadCredentialsException.class)
public ResponseEntity<ApiResponse<Void>> handleBadCredentialsException(
        BadCredentialsException e) {
    return buildErrorResponse(HttpStatus.UNAUTHORIZED, 
        "이메일 또는 비밀번호가 올바르지 않습니다.");
}

@ExceptionHandler(UsernameNotFoundException.class)
public ResponseEntity<ApiResponse<Void>> handleUsernameNotFoundException(
        UsernameNotFoundException e) {
    return buildErrorResponse(HttpStatus.UNAUTHORIZED, 
        "사용자를 찾을 수 없습니다.");
}

 


 

5. 테스트 결과 (로그인)

POST http://localhost:8080/api/auth/login
Content-Type: application/json

{
  "email": "test@example.com",
  "password": "Test1234!@"
}

 

  • ✅ 로그인 성공
{
  "success": true,
  "message": "로그인에 성공했습니다.",
  "data": {
    "userId": 1,
    "email": "test@example.com",
    "name": "테스트",
    "role": "USER"
  }
}

 

  • ❌ 비밀번호 틀림
{
  "success": false,
  "message": "이메일 또는 비밀번호가 올바르지 않습니다.",
  "data": null
}

 

  • ❌ 존재하지 않는 이메일
 
{
  "success": false,
  "message": "사용자를 찾을 수 없습니다.",
  "data": null
}

 

 

 

느낀 점

  • 팀프로젝트 할때도 느꼈지만, 인증된 상태에서 허용되는 URL Path를 잘 확인해야된다.
    • 로그인 페이지, / 경로, 에러 페이지, API-Doc (스웨거) 경로는 열어두어야 한다.
    • 로그인해야만 보이는 페이지들은 따로 권한을 줘야한다.
  • 지금은 세션을 쓰고있지않기때문에 로그인 후 인증상태가 유지되지 않음 -> 내일 JWT 도입해서 Stateless 인증 구현 ㄱㄱ!