Skip to content

소셜 로그인 설계(Spring Security 없이 OAuth2.0)

나경호(Na Gyeongho) edited this page May 27, 2025 · 1 revision

설계

  1. 전체 흐름
    • 1단계: 인가 코드 요청 클라이언트가 소셜 로그인 버튼 클릭 시, 백엔드에서 각 소셜에 맞는 인가 코드 요청 URL을 제공합니다. (예: /OAuth/{OAuthType} → 리다이렉트 (ex: /OAuth/GOOGLE)).
    • 2단계: 인가 코드 수신 및 로그인 요청 프론트는 소셜 서버에서 전달된 인가 코드를 백엔드 로그인 API(/OAuth/login/{OAuthType}?code=...)로 전달합니다.
    • 3단계: Access Token 및 사용자 정보 조회 각 소셜별 UserClient가 인가 코드를 받아 WebClient를 통해 토큰을 발급받고 사용자 정보를 조회합니다.
    • 4단계: 사용자 등록/조회 및 인증 조회된 사용자 정보를 기준으로 DB에서 중복(OAuthId 기준) 여부를 확인한 후 신규 등록하거나 기존 사용자를 반환합니다.
    • 5단계: JWT 발급 및 응답 인증에 성공하면, 사용자 정보를 기반으로 Access Token과 Refresh Token을 생성합니다. 생성된 토큰은 HTTP 응답 헤더에 각각 Authorization: Bearer <access token> <refresh token> 으로 설정하여 클라이언트에 전달합니다.
    • 6단계: JWT 검증 및 핸들링 클라이언트의 후속 요청에는 JWT가 포함되며, 별도의 커스텀 필터(예: JwtAuthenticationFilter 또는 HandlerInterceptor)를 통해 JWT의 유효성 및 만료를 검증합니다.
      • 로그인 성공 핸들러: JWT 생성 후, 성공 응답(예: 사용자 ID, 토큰 정보)을 반환하고 추가 로깅 및 후처리를 수행합니다.
      • 로그인 실패 핸들러: 토큰 발급 실패나 JWT 검증 실패 시 적절한 오류 응답(HTTP 401 등)을 반환하며, 실패 로그를 기록합니다.
  2. 확장성 고려
    • 인터페이스 분리
      • AuthCodeRequestUrlProvider: 소셜별 인가 코드 요청 URL 생성
      • UserClient: 인가 코드를 통해 토큰 및 사용자 정보를 조회
    • Composite 패턴 적용
      • 각 인터페이스 구현체를 모아 관리하는 Composite 클래스로 소셜 공급자 추가 시 변경을 최소화
      • Composite 패턴 참고
    • WebClient 활용
      • HttpServiceProxyFactory를 사용해 간결한 HTTP 인터페이스를 구현
    • JWT 관련 추가 구현
      • Spring Security 없이 JWT 검증 및 핸들링을 위해 별도의 필터(예: JwtAuthenticationFilter 또는 HandlerInterceptor)를 구현하여 보호 대상 API에서 JWT 유효성을 확인합니다.
      • 로그인 성공 시 JwtTokenProvider를 통해 Access/Refresh Token을 생성한 후, 성공 핸들러에서 HTTP Header로 클라이언트에 전달하며, 실패 시 오류 핸들러에서 적절한 응답을 반환합니다.
    • Refresh Token 관리
      • 실제 운영에서는 Refresh Token을 DB에 저장하여 관리하도록 구성합니다.
      • Redis 등 캐시 시스템을 활용하는 방법도 논의할 수 있습니다.

코드 예시

(아래 코드는 기존 OAuth 흐름에 더해 JWT 발급/검증 및 성공/실패 핸들링, Access/Refresh Token HTTP Header 전송과 Refresh Token의 DB 저장을 반영한 예시입니다.)

1. OAuthType

public enum OAuthType {
    GOOGLE, KAKAO, APPLE;

    public static OAuthType fromName(String name) {
        return OAuthType.valueOf(name.toUpperCase());
    }
}

2. 인가 코드 요청 URL 제공 (구글 기준)

2-1. 인터페이스

public interface AuthCodeRequestUrlProvider {
    OAuthType supportType();
    String provide();
}

2-2. GoogleOAuthConfig

import org.springframework.boot.context.properties.ConfigurationProperties;

@ConfigurationProperties(prefix = "OAuth.google")
public record GoogleOAuthConfig(
    String clientId,
    String clientSecret,
    String redirectUri,
    String[] scope
) {}

2-3. GoogleAuthCodeRequestUrlProvider

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.web.util.UriComponentsBuilder;

@Component
@RequiredArgsConstructor
public class GoogleAuthCodeRequestUrlProvider implements AuthCodeRequestUrlProvider {

    private final GoogleOAuthConfig googleOAuthConfig;

    @Override
    public OAuthType supportType() {
        return OAuthType.GOOGLE;
    }

    @Override
    public String provide() {
        return UriComponentsBuilder
                .fromUriString("https://accounts.google.com/o/OAuth2/v2/auth")
                .queryParam("client_id", googleOAuthConfig.clientId())
                .queryParam("redirect_uri", googleOAuthConfig.redirectUri())
                .queryParam("response_type", "code")
                .queryParam("scope", String.join(" ", googleOAuthConfig.scope()))
                .queryParam("access_type", "offline")
                .toUriString();
    }
}

2-4. Composite (AuthCodeRequestUrlProviderComposite)

import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
import org.springframework.stereotype.Component;

@Component
public class AuthCodeRequestUrlProviderComposite {

    private final Map<OAuthType, AuthCodeRequestUrlProvider> mapping;

    public AuthCodeRequestUrlProviderComposite(Set<AuthCodeRequestUrlProvider> providers) {
        mapping = providers.stream()
                .collect(Collectors.toMap(AuthCodeRequestUrlProvider::supportType, Function.identity()));
    }

    public String provide(OAuthType OAuthType) {
        return Optional.ofNullable(mapping.get(OAuthType))
                .orElseThrow(() -> new BusinessException(ErrorCode.OAuth_TYPE_NOT_FOUND))
                .provide();
    }
}

3. 사용자 정보 조회 (UserClient)

3-1. 인터페이스

public interface UserClient {
    OAuthType supportType();
    User fetch(String authCode);
}

3-2. GoogleApiClient (WebClient 기반 HTTP 인터페이스)

import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.service.annotation.GetExchange;
import org.springframework.web.service.annotation.PostExchange;
import org.springframework.util.MultiValueMap;

@HttpExchange
public interface GoogleApiClient {

    @PostExchange(url = "https://OAuth2.googleapis.com/token", contentType = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
    GoogleToken fetchToken(@RequestBody MultiValueMap<String, String> params);

    @GetExchange(url = "https://www.googleapis.com/OAuth2/v3/userinfo")
    GoogleUserResponse fetchUser(@RequestHeader(HttpHeaders.AUTHORIZATION) String bearerToken);
}

3-2-1. GoogleApiClient

import org.springframework.util.MultiValueMap;

public interface GoogleApiClient {
    GoogleToken fetchToken(MultiValueMap<String, String> params);
    GoogleUserResponse fetchUser(String bearerToken);
}

3-2-2. GoogleApiClient 구현체 (RestClient 기반)

import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.web.http.client.RestClient;
import org.springframework.web.http.client.RestClientResponseException;

@Component
@RequiredArgsConstructor
public class GoogleApiClientImpl implements GoogleApiClient {

    private final RestClient restClient;

    @Override
    public GoogleToken fetchToken(MultiValueMap<String, String> params) {
        try {
            return restClient.post()
                    .uri("https://OAuth2.googleapis.com/token")
                    .contentType(MediaType.APPLICATION_FORM_URLENCODED)
                    .body(params)
                    .retrieve()
                    .body(GoogleToken.class);
        } catch (RestClientResponseException e) {
            throw new BusinessException(ErrorCode.TOKEN_FETCH_ERROR, e);
        }
    }

    @Override
    public GoogleUserResponse fetchUser(String bearerToken) {
        try {
            HttpHeaders headers = new HttpHeaders();
            headers.set("Authorization", bearerToken);
            return restClient.get()
                    .uri("https://www.googleapis.com/OAuth2/v3/userinfo")
                    .headers(httpHeaders -> httpHeaders.addAll(headers))
                    .retrieve()
                    .body(GoogleUserResponse.class);
        } catch (RestClientResponseException e) {
            throw new BusinessException(ErrorCode.USER_INFO_FETCH_ERROR, e);
        }
    }
}

3-3-1. GoogleToken

@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public record GoogleUserResponse(
    String sub,         // Google 사용자 고유 ID
    String email,
    Boolean emailVerified,
    String name,
    String picture,
    String locale
) {
    public User toEntity() {
        return User.builder()
                .OAuthId(OAuthId.builder()
                        .OAuthId(sub)
                        .OAuthType(OAuthType.GOOGLE)
                        .build()
                )
                .name(name)
                .profileImage(picture)
                .build();
    }
}

3-3-2. GoogleUserResponse DTO

import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.annotation.JsonNaming;

@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public record GoogleToken(
    String accessToken,
    String idToken,
    String tokenType,
    Integer expiresIn,
    String refreshToken
) {}

3-4. GoogleUserClient

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;

@Component
@RequiredArgsConstructor
public class GoogleUserClient implements UserClient {

    private final GoogleApiClient googleApiClient;
    private final GoogleOAuthConfig googleOAuthConfig;

    @Override
    public OAuthType supportType() {
        return OAuthType.GOOGLE;
    }

    @Override
    public User fetch(String authCode) {
        MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
        params.add("code", authCode);
        params.add("client_id", googleOAuthConfig.clientId());
        params.add("client_secret", googleOAuthConfig.clientSecret());
        params.add("redirect_uri", googleOAuthConfig.redirectUri());
        params.add("grant_type", "authorization_code");

        GoogleToken tokenInfo = googleApiClient.fetchToken(params);
        GoogleUserResponse response = googleApiClient.fetchUser("Bearer " + tokenInfo.accessToken());
        return response.toEntity();
    }
}

3-5. Composite (UserClientComposite)

import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
import org.springframework.stereotype.Component;

@Component
public class UserClientComposite {

    private final Map<OAuthType, UserClient> mapping;

    public UserClientComposite(Set<UserClient> clients) {
        mapping = clients.stream()
                .collect(Collectors.toMap(UserClient::supportType, Function.identity()));
    }

    public User fetch(OAuthType OAuthType, String authCode) {
        return Optional.ofNullable(mapping.get(OAuthType))
                .orElseThrow(() -> new BusinessException(ErrorCode.OAuth_TYPE_NOT_FOUND))
                .fetch(authCode);
    }
}

4. OAuthService 및 Controller

4-1. RefreshToken 엔티티

import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;

@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
@Table(name = "refresh_tokens")
public class RefreshToken {

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

    // 실제 토큰 값
    @Column(nullable = false)
    private String token;

    // 만료 시간
    @Column(nullable = false)
    private LocalDateTime expiryDate;

    // 토큰을 발급받은 사용자와 1:1 연관관계
    @OneToOne
    @JoinColumn(name = "user_id", nullable = false)
    private User user;

    @Builder
    public RefreshToken(String token, LocalDateTime expiryDate, User user) {
        this.token = token;
        this.expiryDate = expiryDate;
        this.user = user;
    }
}

4-2. RefreshTokenRepository

import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;

public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {
    Optional<RefreshToken> findByUser(User user);
}

4-3. OAuthService

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;

@Service
@RequiredArgsConstructor
public class OAuthService {

    private final AuthCodeRequestUrlProviderComposite authCodeComposite;
    private final UserClientComposite userClientComposite;
    private final UserRepository userRepository;
    private final RefreshTokenRepository refreshTokenRepository;
    private final JwtTokenProvider jwtTokenProvider;  // JWT 생성/검증 클래스

    public String getAuthCodeRequestUrl(OAuthType OAuthType) {
        return authCodeComposite.provide(OAuthType);
    }

    public JwtResponse loginAndGenerateToken(OAuthType OAuthType, String authCode) {
        User user = userClientComposite.fetch(OAuthType, authCode);
        User savedUser = userRepository.findByOAuthId(user.getOAuthId())
                .orElseGet(() -> userRepository.save(user));

        // Access Token과 Refresh Token 생성
        String accessToken = jwtTokenProvider.createAccessToken(savedUser);
        String refreshToken = jwtTokenProvider.createRefreshToken(savedUser);

        // Refresh Token을 DB에 저장 (이미 존재하면 업데이트)
        refreshTokenRepository.findByUser(savedUser)
            .ifPresentOrElse(existingToken -> {
                existingToken.setToken(refreshToken);
                existingToken.setExpiryDate(LocalDateTime.now().plusSeconds(jwtTokenProvider.getRefreshTokenValidity()));
                refreshTokenRepository.save(existingToken);
            }, () -> {
                RefreshToken newToken = RefreshToken.builder()
                        .token(refreshToken)
                        .expiryDate(LocalDateTime.now().plusSeconds(jwtTokenProvider.getRefreshTokenValidity()))
                        .user(savedUser)
                        .build();
                refreshTokenRepository.save(newToken);
            });

        return new JwtResponse(accessToken, refreshToken);
    }
}

참고: jwtTokenProvider.getRefreshTokenValidity() 메서드는 REFRESH_TOKEN_VALIDITY를 반환하는 getter로 추가할 수 있습니다.

4-4. OAuthController

import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.io.IOException;

@RestController
@RequiredArgsConstructor
@RequestMapping("/oauth")
public class OAuthController {

    private final OAuthService oauthService;

    @GetMapping("/{oauthType}")
    public void redirectAuthRequestUrl(@PathVariable OAuthType OAuthType, HttpServletResponse response) throws IOException {
        String url = oauthService.getAuthCodeRequestUrl(OAuthType);
        response.sendRedirect(url);
    }

    @GetMapping("/login/{OAuthType}")
    public ApiResponse<String> login(@PathVariable OAuthType OAuthType, @RequestParam("code") String code) {
        // JWT 토큰 생성 및 반환
        JwtResponse jwtResponse = oauthService.loginAndGenerateToken(OAuthType, code);
        return ApiResponse.ok(jwtResponse.getAccessToken());
    }
}

4-5. RestClient 설정 (GoogleApiClient 등록)

@Configuration
public class ClientConfig {

    private static final Duration DEFAULT_CONNECTION_TIMEOUT = Duration.ofSeconds(60);
    private static final Duration DEFAULT_READ_TIMEOUT = Duration.ofSeconds(30);

    @Bean
    public RestClient.Builder restClientBuilder() { // 목 서버 테스트를 위해 Builder를 빈으로 등록함
        return RestClient.builder()
                .requestFactory(requestFactory()); // 시간 초과 설정을 위한 메서드 (시간은 각 서버가 제시하는 시간으로 설정)
    }

    private ClientHttpRequestFactory requestFactory() {
        SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory();
        requestFactory.setConnectTimeout(DEFAULT_CONNECTION_TIMEOUT);
        requestFactory.setReadTimeout(DEFAULT_READ_TIMEOUT);
        return requestFactory;
    }
}

4-5-1. RestClientTest 시 mock server 활용하는 법

@RestClientTest({/* 빈으로 등록할 클래스들 */})
class OdsaySubwayClientTest extends BaseRestClientTest {

    OdsaySubwayClient subwayClient;

    @Autowired
    OdsayUriGenerator uriGenerator;

    @Autowired
    OdsayProperty property;

    @BeforeEach
    void setUp() {
        mockServer = MockRestServiceServer.bindTo(restClientBuilder).build();
        subwayClient = new OdsaySubwayClient(restClientBuilder, property);
    }
		
		// ...
}
  • mockServer를 통해 외부 서버(우리의 경우 카카오, 구글, 애플)를 모킹하여 외부 로직을 제외한 나머지 로직만 테스트 가능하도록 한다.
  • 위 코드에서 BaseRestClientTest 클래스는 RestClientTest할 때 필요한 변수랑 메서드 옮겨놓은 곳이고, 우리 구현 시작하면 나중에 PR 올려둘게요 - 채영

4-5. WebClient 설정 (GoogleApiClient 등록)

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.support.WebClientAdapter;
import org.springframework.web.service.invoker.HttpServiceProxyFactory;

@Configuration
public class HttpInterfaceConfig {

    @Bean
    public GoogleApiClient googleApiClient() {
        WebClient client = WebClient.builder().build();
        HttpServiceProxyFactory factory = HttpServiceProxyFactory
                .builder(WebClientAdapter.forClient(client))
                .build();
        return factory.createClient(GoogleApiClient.class);
    }
}

5. JWT 검증 및 핸들링

5-1. JwtTokenProvider

import io.jsonwebtoken.*;
import org.springframework.stereotype.Component;
import java.util.Date;

@Component
public class JwtTokenProvider {

    private final String SECRET_KEY = "your-secret-key";  // 환경 변수 등으로 관리 권장
    private final long ACCESS_TOKEN_VALIDITY = 3600000;     // 1시간
    private final long REFRESH_TOKEN_VALIDITY = 604800000;   // 7일

    public String createAccessToken(User user) {
        Claims claims = Jwts.claims().setSubject(user.getId().toString());
        claims.put("name", user.getName());
        Date now = new Date();
        Date expiry = new Date(now.getTime() + ACCESS_TOKEN_VALIDITY);
        return Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(now)
                .setExpiration(expiry)
                .signWith(SignatureAlgorithm.HS256, SECRET_KEY)
                .compact();
    }

    public String createRefreshToken(User user) {
        Claims claims = Jwts.claims().setSubject(user.getId().toString());
        Date now = new Date();
        Date expiry = new Date(now.getTime() + REFRESH_TOKEN_VALIDITY);
        return Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(now)
                .setExpiration(expiry)
                .signWith(SignatureAlgorithm.HS256, SECRET_KEY)
                .compact();
    }

    public boolean validateToken(String token) {
        try {
            Jwts.parser().setSigningKey(SECRET_KEY).parseClaimsJws(token);
            return true;
        } catch (JwtException | IllegalArgumentException e) {
            // 로그 기록 및 오류 처리
            return false;
        }
    }

    public String getUserId(String token) {
        return Jwts.parser().setSigningKey(SECRET_KEY)
                   .parseClaimsJws(token).getBody().getSubject();
    }

    public long getRefreshTokenValidity() {
        return REFRESH_TOKEN_VALIDITY / 1000; // 초 단위 반환 (예: 604800초)
    }
}

5-2. JwtResponse DTO

public record JwtResponse (
    private final String accessToken,
    private final String refreshToken
)

5-3. JwtAuthenticationFilter

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;

public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtTokenProvider jwtTokenProvider;

    public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider) {
        this.jwtTokenProvider = jwtTokenProvider;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {
        String token = resolveToken(request);
        if (token != null && jwtTokenProvider.validateToken(token)) {
            // 성공: JWT 검증 성공 시 후속 처리를 진행 (예: 사용자 정보 로드 등)
        } else {
            // 실패: JWT가 없거나 유효하지 않으면 HTTP 401 오류 응답 전송
            response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid JWT token");
            return;
        }
        filterChain.doFilter(request, response);
    }

    private String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }
        return null;
    }
}

주의:

JWT 검증 필터는 Spring Security 없이도 스프링 MVC의 HandlerInterceptor나 서블릿 필터로 적용할 수 있으며, 보호 대상 API에 대해 별도 구성해야 합니다.

5-4. 로그인 성공/실패 핸들링

  • 로그인 성공 핸들러: OAuthController의 로그인 API에서 JWT를 발급한 후 HTTP Header에 토큰을 설정합니다.
  • 로그인 실패 핸들러: 인증 실패나 토큰 발급 실패 시 예외 처리 및 적절한 오류 응답(API Response 또는 HTTP status)을 반환합니다.

6. BaseTimeEntity, OAuthId, User Entity

BaseTimeEntity

import jakarta.persistence.Column;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.MappedSuperclass;
import lombok.Getter;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;

@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseTimeEntity {

    @CreatedDate
    @Column(updatable = false)
    private LocalDateTime createdAt;

    @LastModifiedDate
    private LocalDateTime updatedAt;
}

OAuthId

import jakarta.persistence.Column;
import jakarta.persistence.Embeddable;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import lombok.*;

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Embeddable
public class OAuthId {

    @Column(name = "OAuth_id", nullable = false)
    private String OAuthId;

    @Enumerated(EnumType.STRING)
    @Column(name = "OAuth_type", nullable = false)
    private OAuthType OAuthType;

    @Builder
    public OAuthId(String OAuthId, OAuthType OAuthType) {
        this.OAuthId = OAuthId;
        this.OAuthType = OAuthType;
    }
}

User Entity

import jakarta.persistence.*;
import lombok.*;

@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
@Table(name = "users", uniqueConstraints = {
        @UniqueConstraint(
                name = "OAuth_id_unique",
                columnNames = {"OAuth_id", "OAuth_type"}
        )
})
public class User extends BaseTimeEntity {

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

    // 소셜 로그인 공급자 식별 정보 (예: Google의 sub 값)
    @Embedded
    private OAuthId OAuthId;

    // 사용자 이름 (예: Google의 name)
    @Column(name = "user_name")
    private String name;

    // 프로필 이미지 URL (예: Google의 picture)
    @Column(name = "user_profile_image")
    private String profileImage;

    @Builder
    public User(OAuthId OAuthId, String name, String profileImage) {
        this.OAuthId = OAuthId;
        this.name = name;
        this.profileImage = profileImage;
    }
}

요약

  • 전체 흐름은 인가 코드 요청 → 코드 수신 및 로그인 요청 → Access Token 및 사용자 정보 조회 → 사용자 등록/조회 및 인증 → JWT 발급(Access/Refresh Token) 및 최종 응답 → 후속 요청에 대한 JWT 검증/핸들링으로 구성됩니다.
  • 로그인 성공 시:
    • Access Token과 Refresh Token을 생성한 후, HTTP Header에 각각 Authorization: Bearer <access token>Refresh-Token: <refresh token>으로 전송합니다.
  • JWT 검증:
    • 후속 요청은 JwtAuthenticationFilter(또는 HandlerInterceptor)를 통해 JWT의 유효성 및 만료를 검증하며, 검증 실패 시 HTTP 401 오류를 반환합니다.
  • BaseEntity는 생성/수정 시간을 자동 관리합니다.
  • OAuthId는 소셜 공급자별 고유 식별자(예, Google의 sub)와 공급자 타입을 함께 저장하여, *@UniqueConstraint를 사용해 (OAuth_id, OAuth_type) 조합의 유일성을 보장합니다. 동일한 OAuth_id라도 다른 공급자(Google, Kakao 등)에서는 별도 사용자로 저장할 수 있습니다.
  • User Entity는 소셜 로그인 사용자의 기본 정보를 저장하며, 확장성이 용이하도록 설계되었습니다.
  • JWT 관련
    • JwtTokenProvider를 통해 Access Token과 Refresh Token을 생성하고, 로그인 성공 시 HTTP Header에 전송합니다.
    • 후속 요청은 별도의 JWT 검증 필터 또는 인터셉터를 통해 토큰 유효성을 확인합니다.
    • 실제 운영에서는 Refresh Token을 DB에 저장하고, Redis 등 다른 저장 방식을 활용하는 방안도 논의해볼 수 있습니다.
    • 로그인 성공/실패에 따른 추가 후처리는 별도 핸들러로 구현할 수 있습니다.

Reference

Clone this wiki locally