Skip to content

Conversation

@hardwoong
Copy link
Member

관련 이슈

closed #14

작업한 내용

실습 1: Session 방식 구현

1. SecurityConfig 설정

Session 방식을 사용하기 위해 SecurityConfig에서 다음과 같이 설정:

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
        .csrf(AbstractHttpConfigurer::disable)
        .authorizeHttpRequests(authz -> authz
            .requestMatchers(allowUris).permitAll()
            .requestMatchers("/admin/**").hasRole("ADMIN")
            .anyRequest().authenticated())
        .formLogin(form -> form
            .loginProcessingUrl("/api/v1/members/login")
            .passwordParameter("password")
            .successHandler(loginSuccessHandler())
            .failureHandler(loginFailureHandler())
            .permitAll())
        .logout(logout -> logout
            .logoutUrl("/api/v1/members/logout")
            .logoutSuccessUrl("/login?logout")
            .permitAll());
    
    return http.build();
}

주요 설정 내용:

  • formLogin(): Spring Security의 기본 폼 로그인 활성화
  • loginProcessingUrl(): 로그인 처리 URL을 /api/v1/members/login으로 설정
  • logout(): 로그아웃 URL 및 성공 후 리다이렉트 URL 설정

2. 로그인 서비스 구현

Session 방식에서는 AuthenticationManager를 사용하여 인증을 처리하고, Spring Security가 자동으로 세션 생성:

@Override
@Transactional(readOnly = true)
public MemberResDTO.LoginDTO login(@Valid MemberReqDTO.LoginDTO dto) {
    // AuthenticationManager로 인증 처리
    Authentication authentication = authenticationManager.authenticate(
        new UsernamePasswordAuthenticationToken(dto.email(), dto.password())
    );
    
    // SecurityContext에 저장 (Session에도 자동 저장됨)
    SecurityContextHolder.getContext().setAuthentication(authentication);
    
    // User 조회
    User user = memberRepository.findByEmail(dto.email())
        .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND));
    
    // Session 방식이므로 토큰 없이 사용자 정보만 반환
    return MemberConverter.toLoginDTO(user);
}

동작 과정:

  1. AuthenticationManager.authenticate() 호출
  2. CustomUserDetailsService에서 사용자 조회
  3. PasswordEncoder로 비밀번호 검증
  4. 인증 성공 시 Authentication 객체 생성
  5. SecurityContextHolder에 저장 → 자동으로 HttpSession에도 저장됨
  6. 클라이언트에 JSESSIONID 쿠키 발급

3. 로그아웃 구현

서버 측 세션 무효화:

@PostMapping("/logout")
@Operation(summary = "로그아웃", description = "현재 세션을 종료합니다.")
public ApiResponse<Void> logout(HttpServletRequest request, HttpServletResponse response) {
    Authentication auth = SecurityContextHolder.getContext().getAuthentication();
    if (auth != null) {
        new SecurityContextLogoutHandler().logout(request, response, auth);
    }
    return ApiResponse.onSuccess(MemberSuccessCode.MEMBER_LOGOUT_SUCCESS, null);
}

동작 과정:

  1. 현재 SecurityContext에서 Authentication 객체 가져오기
  2. SecurityContextLogoutHandler.logout() 호출
  3. HttpSession 무효화
  4. JSESSIONID 쿠키 삭제

4. 회원가입 구현

Session 방식과 JWT 방식 모두 동일하게 구현:

@Override
@Transactional
public MemberResDTO.JoinDTO signup(MemberReqDTO.JoinDTO dto) {
    // 비밀번호 암호화 (BCrypt 사용)
    String encodedPassword = passwordEncoder.encode(dto.password());
    
    // 사용자 생성
    User member = MemberConverter.toMember(dto, encodedPassword, Role.ROLE_USER);
    
    // DB 저장
    memberRepository.save(member);
    
    // 선호 카테고리 저장 (있는 경우)
    if (dto.preferCategory() != null && dto.preferCategory().size() > 0) {
        // ... 선호 카테고리 저장 로직
    }
    
    return MemberConverter.toJoinDTO(member);
}

비밀번호 처리:

  • BCryptPasswordEncoder를 사용하여 비밀번호를 해시
  • BCrypt는 자동으로 랜덤 솔트를 생성하고 해시 결과에 포함
  • DB에는 해시된 비밀번호만 저장 (예: $2a$10$...)

5. DB 저장 확인

회원가입 후 users 테이블을 확인하면:

  • password 컬럼에 BCrypt 해시 값이 저장됨
  • 원본 비밀번호는 저장되지 않음
  • created_at, updated_at은 JPA Auditing으로 자동 설정됨

실습 2: JWT 방식 구현

1. SecurityConfig 설정

JWT 방식을 사용하기 위해 SecurityConfig에서 다음과 같이 설정:

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
        .csrf(AbstractHttpConfigurer::disable)
        .authorizeHttpRequests(authz -> authz
            .requestMatchers(allowUris).permitAll()
            .requestMatchers("/admin/**").hasRole("ADMIN")
            .anyRequest().authenticated())
        .httpBasic(AbstractHttpConfigurer::disable)
        .formLogin(AbstractHttpConfigurer::disable)  // 폼 로그인 비활성화
        .logout(logout -> logout
            .logoutUrl("/logout")
            .logoutSuccessUrl("/login?logout")
            .permitAll())
        .addFilterBefore(
            jwtAuthFilter(),  // JWT 필터 추가
            UsernamePasswordAuthenticationFilter.class)
        .exceptionHandling(exception -> exception
            .authenticationEntryPoint(authenticationEntryPoint()));  // 인증 실패 처리
    
    return http.build();
}

주요 설정 내용:

  • formLogin().disable(): Session 기반 로그인 비활성화
  • addFilterBefore(): JwtAuthFilter를 필터 체인에 추가
  • exceptionHandling(): 인증 실패 시 커스텀 에러 응답 반환

2. JwtUtil 구현

JWT 토큰 생성 및 검증을 위한 유틸리티 클래스 구현:

@Component
public class JwtUtil {
    private final SecretKey secretKey;
    private final Duration accessExpiration;
    
    // AccessToken 생성
    public String createAccessToken(CustomUserDetails user) {
        Instant now = Instant.now();
        String authorities = user.getAuthorities().stream()
            .map(GrantedAuthority::getAuthority)
            .collect(Collectors.joining(","));
        
        return Jwts.builder()
            .subject(user.getUsername())  // 이메일
            .claim("role", authorities)
            .claim("email", user.getUsername())
            .issuedAt(Date.from(now))
            .expiration(Date.from(now.plus(accessExpiration)))  // 4시간
            .signWith(secretKey)
            .compact();
    }
    
    // 토큰 유효성 검증
    public boolean isValid(String token) {
        try {
            getClaims(token);
            return true;
        } catch (JwtException e) {
            return false;
        }
    }
    
    // 토큰에서 이메일 추출
    public String getEmail(String token) {
        try {
            return getClaims(token).getPayload().getSubject();
        } catch (JwtException e) {
            return null;
        }
    }
}

설정 값:

  • jwt.secret: 90ee33b340aed322434e3b5b1b142b19 (HMAC SHA-256 키)
  • jwt.expiration: 14400000 (4시간, 밀리초)

3. JwtAuthFilter 구현

모든 요청에 대해 JWT 토큰을 검증하는 필터 구현:

@RequiredArgsConstructor
public class JwtAuthFilter extends OncePerRequestFilter {
    private final JwtUtil jwtUtil;
    private final CustomUserDetailsService customUserDetailsService;
    
    @Override
    protected void doFilterInternal(
        HttpServletRequest request,
        HttpServletResponse response,
        FilterChain filterChain) throws ServletException, IOException {
        
        // Authorization 헤더에서 토큰 추출
        String token = request.getHeader("Authorization");
        
        // 토큰이 없거나 Bearer 형식이 아니면 그냥 넘어감
        if (token == null || !token.startsWith("Bearer ")) {
            filterChain.doFilter(request, response);
            return;
        }
        
        // Bearer 제거
        token = token.replace("Bearer ", "");
        
        // 토큰이 있는데 유효하지 않으면 401 에러
        if (!jwtUtil.isValid(token)) {
            throw new BadCredentialsException("유효하지 않은 토큰입니다.");
        }
        
        // 유효한 토큰이면 사용자 정보 로드 및 인증 처리
        String email = jwtUtil.getEmail(token);
        UserDetails user = customUserDetailsService.loadUserByUsername(email);
        Authentication auth = new UsernamePasswordAuthenticationToken(
            user, null, user.getAuthorities());
        SecurityContextHolder.getContext().setAuthentication(auth);
        
        filterChain.doFilter(request, response);
    }
}

동작 과정:

  1. Authorization 헤더에서 Bearer {token} 형식의 토큰 추출
  2. 토큰이 없으면 그냥 넘어감 (인증이 필요한 경로는 나중에 Spring Security가 차단)
  3. 토큰이 있지만 유효하지 않으면 BadCredentialsException 발생 → 401 반환
  4. 유효한 토큰이면 이메일 추출 후 사용자 정보 로드
  5. SecurityContext에 인증 정보 저장

4. AuthenticationEntryPointImpl 구현

인증 실패 시 커스텀 에러 응답을 반환하는 클래스 구현:

public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint {
    private final ObjectMapper objectMapper = new ObjectMapper();
    
    @Override
    public void commence(
        HttpServletRequest request,
        HttpServletResponse response,
        AuthenticationException authException) throws IOException {
        
        response.setContentType("application/json;charset=UTF-8");
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        
        ApiResponse<Void> errorResponse = ApiResponse.onFailure(
            ErrorStatus._UNAUTHORIZED.getReasonHttpStatus().getCode(),
            ErrorStatus._UNAUTHORIZED.getReasonHttpStatus().getMessage(),
            null);
        
        objectMapper.writeValue(response.getOutputStream(), errorResponse);
    }
}

동작:

  • JwtAuthFilter에서 발생한 AuthenticationException을 받아서 처리
  • 일관된 형식의 에러 응답 반환

5. 로그인 서비스 구현

JWT 방식에서는 로그인 성공 시 토큰을 발급:

@Override
@Transactional(readOnly = true)
public MemberResDTO.LoginDTO login(@Valid MemberReqDTO.LoginDTO dto) {
    // User 조회
    User user = memberRepository.findByEmail(dto.email())
        .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND));
    
    // 비밀번호 검증
    if (!passwordEncoder.matches(dto.password(), user.getPassword())) {
        throw new MemberException(MemberErrorCode.INVALID_PASSWORD);
    }
    
    // JWT 토큰 발급용 UserDetails 생성
    CustomUserDetails userDetails = new CustomUserDetails(user);
    
    // AccessToken 발급
    String accessToken = jwtUtil.createAccessToken(userDetails);
    
    // 토큰을 포함한 응답 반환
    return MemberConverter.toLoginDTO(user, accessToken);
}

동작 과정:

  1. 이메일로 사용자 조회
  2. 비밀번호 검증 (passwordEncoder.matches())
  3. CustomUserDetails 생성
  4. JWT 토큰 발급
  5. 사용자 정보와 토큰을 함께 반환

6. 로그인 응답 DTO

JWT 방식에서는 accessToken을 포함:

@Builder
public record LoginDTO(
    Long memberId,
    String email,
    String name,
    Role role,
    String accessToken,  // JWT 토큰
    LocalDateTime createdAt
) {}

7. 회원가입 구현

JWT 방식과 Session 방식 모두 동일하게 구현 (비밀번호 해싱 방식 동일).


🔄 두 방식의 차이점

Session 방식

  • 서버 측 세션 저장: HttpSession에 인증 정보 저장
  • 쿠키 사용: JSESSIONID 쿠키로 세션 식별
  • 상태 유지: 서버에서 세션 상태 관리 필요
  • 확장성: 서버 확장 시 세션 공유 필요 (Redis 등)
  • 로그아웃: 서버에서 세션 무효화 필요

JWT 방식

  • Stateless: 서버에 상태 저장 불필요
  • 토큰 기반: 클라이언트가 토큰 보관
  • 확장성: 서버 확장이 용이 (상태 없음)
  • 토큰 만료: 설정한 만료 시간(4시간) 후 자동 만료
  • 로그아웃: 클라이언트에서 토큰 삭제만 하면 됨 (또는 블랙리스트 구현)

📝 구현된 API 목록

공통 (Session/JWT 모두)

  • POST /api/v1/members/signup: 회원가입
  • POST /api/v1/members/login: 로그인

Session 방식 전용

  • POST /api/v1/members/logout: 로그아웃 (세션 무효화)

JWT 방식

  • 로그아웃은 클라이언트에서 토큰 삭제만 하면 됨

🎯 결론

두 가지 인증 방식을 모두 구현하여 각각의 특징과 장단점을 이해. Session 방식은 전통적이고 안정적이며, JWT 방식은 확장성이 좋고 Stateless한 구조를 제공. 프로젝트의 요구사항에 따라 적절한 방식을 선택할 수 있음.

PR Point 및 참고사항, 스크린샷

@hardwoong hardwoong self-assigned this Dec 15, 2025
@hardwoong hardwoong added the enhancement New feature or request label Dec 15, 2025
@hardwoong hardwoong linked an issue Dec 15, 2025 that may be closed by this pull request
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEAT] Week10 Mission

2 participants