[사이드 프로젝트] 디지털 굿즈 플랫폼 개발기 #7 - Spring Security + JWT + Redis로 구축하는 강력한 인증 시스템 (2편: 하이브리드 로그인 구현)

1편에서는 Redis 환경 구축과 Security 설정, 그리고 JWT 컴포넌트들을 만들었다. 이제 이 부품들을 조립하여 실제로 작동하는 로그인 서비스와, 웹(Web)과 앱(App) 클라이언트 모두를 만족시키는 하이브리드 컨트롤러를 구현해 본다.


1. UserDetailsService 구현 (DB 연결고리)

Spring Security는 기본적으로 우리 DB에 User 테이블이 있는지 모른다. 시큐리티가 유저 정보를 가져올 수 있도록 연결해 주는 어댑터가 필요하다.

CustomUserDetailsService.java

UserDetailsService 인터페이스를 구현하여, 이메일로 회원을 찾고 시큐리티 전용 객체(UserDetails)로 변환해 주는 로직을 작성했다.

package com.creators.photocard.global.security;

import com.creators.photocard.member.domain.User;
import com.creators.photocard.member.repository.UserRepository;
import lombok.RequiredArgsConstructor;
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;
import org.springframework.transaction.annotation.Transactional;

import java.util.Collections;

@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {

    private final UserRepository userRepository;

    @Override
    @Transactional
    public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
        return userRepository.findByEmail(email)
                .map(this::createUserDetails)
                .orElseThrow(() -> new UsernameNotFoundException(email + " -> 데이터베이스에서 찾을 수 없습니다."));
    }

    // DB의 User 정보를 시큐리티의 UserDetails로 변환
    private UserDetails createUserDetails(User user) {
        // 권한 정보(ROLE_USER 등)를 시큐리티가 이해하는 형태로 변환
        return new org.springframework.security.core.userdetails.User(
                user.getId().toString(), // Principal(주체)로 사용할 ID (PK)
                user.getPassword(),
                Collections.singleton(() -> "ROLE_" + user.getRole().name())
        );
    }
}

2. 핵심 비즈니스 로직 (로그인 & RTR)

이제 RTR(Refresh Token Rotation) 전략이 실제로 적용되는 서비스 계층이다. Redis를 사용하여 토큰을 저장하고 교체하는 로직이 핵심이다.

AuthService.java

  • 로그인: 인증 성공 시 Access/Refresh Token을 발급하고, Refresh Token은 Redis에 저장한다.
  • 재발급(Reissue):
    1. 들어온 Refresh Token과 Redis에 저장된 토큰을 비교한다.
    2. 일치하면 기존 토큰을 폐기하고 새로운 Access/Refresh Token 쌍을 발급한다. (Rotation)
    3. 불일치하면 탈취된 것으로 간주하고 예외를 발생시킨다.
package com.creators.photocard.member.service;

import com.creators.photocard.global.jwt.JwtTokenProvider;
import com.creators.photocard.global.jwt.dto.TokenResponse;
import com.creators.photocard.global.jwt.entity.RefreshToken;
import com.creators.photocard.global.jwt.repository.RefreshTokenRepository;
import com.creators.photocard.member.dto.LoginRequest;
import com.creators.photocard.member.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
public class AuthService {

    private final AuthenticationManagerBuilder authenticationManagerBuilder;
    private final JwtTokenProvider jwtTokenProvider;
    private final RefreshTokenRepository refreshTokenRepository;

    /**
     * 로그인: 인증 -> 토큰 발급 -> Redis 저장
     */
    @Transactional
    public TokenResponse login(LoginRequest request) {
        // 1. Login ID/PW를 기반으로 AuthenticationToken 생성
        UsernamePasswordAuthenticationToken authenticationToken =
                new UsernamePasswordAuthenticationToken(request.email(), request.password());

        // 2. 검증 (사용자 비밀번호 체크)
        // authenticate() 실행 시 CustomUserDetailsService.loadUserByUsername이 호출됨
        Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);

        // 3. 인증 정보를 기반으로 JWT 토큰 생성
        TokenResponse tokenDto = jwtTokenProvider.generateTokenDto(authentication);

        // 4. Redis에 Refresh Token 저장 (TTL 설정으로 자동 삭제됨)
        RefreshToken refreshToken = RefreshToken.builder()
                .id(authentication.getName()) // ID (PK)
                .refreshToken(tokenDto.refreshToken())
                .build();

        refreshTokenRepository.save(refreshToken);

        return tokenDto;
    }

    /**
     * 토큰 재발급 (RTR 적용)
     */
    @Transactional
    public TokenResponse reissue(String accessToken, String refreshToken) {
        // 1. Refresh Token 유효성 검증
        if (!jwtTokenProvider.validateToken(refreshToken)) {
            throw new RuntimeException("Refresh Token이 유효하지 않습니다.");
        }

        // 2. Access Token에서 Member ID 가져오기
        Authentication authentication = jwtTokenProvider.getAuthentication(accessToken);

        // 3. Redis에서 Member ID를 기반으로 저장된 Refresh Token 값을 가져옴
        RefreshToken redisToken = refreshTokenRepository.findById(authentication.getName())
                .orElseThrow(() -> new RuntimeException("로그아웃 된 사용자입니다."));

        // 4. 요청받은 토큰과 Redis의 토큰 비교 (탈취 감지)
        if (!redisToken.getRefreshToken().equals(refreshToken)) {
            throw new RuntimeException("토큰의 유저 정보가 일치하지 않습니다.");
        }

        // 5. 새로운 토큰 쌍 생성
        TokenResponse tokenDto = jwtTokenProvider.generateTokenDto(authentication);

        // 6. Redis 업데이트 (RTR: 기존 토큰 덮어쓰기)
        redisToken.updateRefreshToken(tokenDto.refreshToken());
        refreshTokenRepository.save(redisToken);

        return tokenDto;
    }
}

3. 하이브리드 컨트롤러 구현 (Web & App 지원)

가장 고민이 많았던 부분이다.

  • Web (브라우저): XSS 공격 방지를 위해 Refresh Token을 HttpOnly Cookie에 담아야 한다.
  • App (iOS/Android): 쿠키 처리가 번거로우므로 JSON Body로 받는 것을 선호한다.

두 환경을 모두 지원하기 위해 "쿠키에도 담아주고, 바디에도 담아주는" 하이브리드 전략을 취했다.

AuthController.java

package com.creators.photocard.member.controller;

import com.creators.photocard.global.jwt.dto.TokenResponse;
import com.creators.photocard.member.dto.LoginRequest;
import com.creators.photocard.member.service.AuthService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseCookie;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

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

    private final AuthService authService;

    @PostMapping("/login")
    public ResponseEntity<TokenResponse> login(@RequestBody LoginRequest request) {
        TokenResponse tokenDto = authService.login(request);
        
        // 1. Refresh Token을 쿠키로 생성 (웹용)
        ResponseCookie cookie = createRefreshTokenCookie(tokenDto.refreshToken());

        // 2. Body에도 담고, Header(Set-Cookie)에도 담아서 리턴
        return ResponseEntity.ok()
                .header("Set-Cookie", cookie.toString())
                .body(tokenDto);
    }

    @PostMapping("/reissue")
    public ResponseEntity<TokenResponse> reissue(
            // 쿠키(Web) 혹은 Body(App)에서 Refresh Token을 받음
            @CookieValue(name = "refreshToken", required = false) String cookieRefreshToken,
            @RequestBody(required = false) TokenResponse bodyRequest
    ) {
        // 우선순위: 쿠키 -> 바디
        String refreshToken = cookieRefreshToken;
        if (refreshToken == null && bodyRequest != null) {
            refreshToken = bodyRequest.refreshToken();
        }
        
        if (refreshToken == null) {
            throw new RuntimeException("Refresh Token이 없습니다.");
        }

        // Access Token은 Body에서 가져온다고 가정
        String accessToken = (bodyRequest != null) ? bodyRequest.accessToken() : null; 
        
        // 재발급 로직 수행
        TokenResponse newTokenDto = authService.reissue(accessToken, refreshToken);

        // 새 토큰도 쿠키로 갱신
        ResponseCookie cookie = createRefreshTokenCookie(newTokenDto.refreshToken());

        return ResponseEntity.ok()
                .header("Set-Cookie", cookie.toString())
                .body(newTokenDto);
    }

    // 쿠키 생성 유틸 메서드
    private ResponseCookie createRefreshTokenCookie(String refreshToken) {
        return ResponseCookie.from("refreshToken", refreshToken)
                .httpOnly(true)  // JS 접근 불가 (XSS 방지)
                .secure(false)   // HTTPS 적용 시 true로 변경 필요
                .path("/")
                .maxAge(60 * 60 * 24 * 7) // 7일
                .sameSite("Strict")
                .build();
    }
}

4. CORS 설정 (Web 지원 필수)

웹 브라우저에서 쿠키(Credential)를 주고받으려면 CORS 설정이 필수다. 이를 빠뜨리면 프론트엔드에서 로그인 요청 시 쿠키가 저장되지 않는 문제가 발생한다.

WebMvcConfig.java

package com.creators.photocard.global.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
                .allowedOrigins("http://localhost:3000") // 프론트엔드 도메인
                .allowedMethods("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")
                .allowCredentials(true) // ★ 쿠키 인증 요청 허용 (핵심)
                .maxAge(3000);
    }
}

이로써 회원가입 -> 로그인(JWT 발급) -> 토큰 재발급(RTR & Redis) -> 웹/앱 하이브리드 지원까지 이어지는 긴 인증 시스템 구현을 마쳤다.

"왜 Redis를 써야 하는지", "왜 쿠키와 바디를 같이 줘야 하는지" 고민하며 설계했기에 더욱 견고한 시스템이 되었다. 이제 이 튼튼한 인증 기반 위에서 포토카드 생성, 거래 등 핵심 비즈니스 로직을 구현해 볼 것이다.