티스토리 뷰

반응형

Spring Boot 3.x + WebClient + JWT 조합으로 카카오/네이버 소셜 로그인을 구현한 경험을 정리한다.

 

✏️ 패키지 구조

domain/oauth/
├── controller/
│   ├── OAuthController.java
│   └── docs/
│       └── OAuthControllerDocs.java
├── service/
│   ├── OAuthKakaoService.java
│   └── OAuthNaverService.java
├── client/
│   ├── OAuthKakaoClient.java       ← WebClient로 카카오 API 직접 호출
│   └── OAuthNaverClient.java       ← WebClient로 네이버 API 직접 호출
├── properties/
│   ├── OAuthKakaoProperties.java
│   └── OAuthNaverProperties.java
├── dto/
│   ├── request/
│   │   ├── OAuthKakaoLoginRequest.java
│   │   └── OAuthNaverLoginRequest.java
│   └── response/
│       ├── OAuthKakaoLoginUrlResponse.java
│       └── OAuthNaverLoginUrlResponse.java
└── provider/
    └── dto/
        ├── OAuthUserInfo.java              ← 플랫폼 공통 사용자 정보
        ├── OAuthKakaoTokenResponse.java
        ├── OAuthNaverTokenResponse.java
        ├── OAuthKakaoUserInfoResponse.java
        └── OAuthNaverUserInfoResponse.java

common/config/
└── WebClientConfig.java

✏️ 전체 흐름

소셜 로그인의 전체 흐름은 아래와 같다.

[클라이언트]
    │
    ├─ 1. GET /api/oauth/kakao/login-uri  → 로그인 URL 반환
    │
    ├─ 2. 카카오/네이버 로그인 페이지에서 사용자 로그인
    │
    ├─ 3. 인가 코드(code) 전달받아 POST /api/oauth/kakao/login 호출
    │
    └─ 4. 서버에서 Access Token + 사용자 정보 조회 → JWT 발급 → 응답

✏️ 프로퍼티 설정

민감한 값들은 환경 변수로 분리하고, @ConfigurationProperties로 주입한다.

@ConfigurationProperties(prefix = "oauth.kakao")
public record OAuthKakaoProperties(
        @NotBlank
        String clientId,

        @NotBlank
        String clientSecret,

        @NotBlank
        String redirectUri,

        @NotBlank
        String authBaseUrl,

        @NotBlank
        String apiBaseUrl
) {}


@ConfigurationProperties(prefix = "oauth.naver")
public record OAuthNaverProperties(
        @NotBlank
        String clientId,

        @NotBlank
        String clientSecret,

        @NotBlank
        String redirectUri,

        @NotBlank
        String authBaseUrl,

        @NotBlank
        String apiBaseUrl
) {}

application.yml에는 아래처럼 환경 변수를 참조한다.

oauth:
  kakao:
    client-id: ${KAKAO_CLIENT_ID}
    client-secret: ${KAKAO_CLIENT_SECRET}
    redirect-uri: ${KAKAO_REDIRECT_URI}
    auth-base-url: https://kauth.kakao.com
    api-base-url: https://kapi.kakao.com
  naver:
    client-id: ${NAVER_CLIENT_ID}
    client-secret: ${NAVER_CLIENT_SECRET}
    redirect-uri: ${NAVER_REDIRECT_URI}
    auth-base-url: https://nid.naver.com
    api-base-url: https://openapi.naver.com

toString() 오버라이딩 - 보안 로깅

OAuthKakaoProperties와 OAuthNaverProperties에서 toString()을 명시적으로 오버라이딩을 하였다.

Java의 record는 기본적으로 모든 필드를 포함한 toString()을 자동 생성한다. 즉, 아무것도 하지 않으면 아래처럼 출력된다.

OAuthNaverProperties[clientId=p4hAMwtBzHcEb9Jccr3B, clientSecret=b2KnBXZND_, redirectUri=http://..., ...]

이 상태에서 Spring이 애플리케이션 구동 시 Bean 정보를 로깅하거나, 개발 중 실수로 log.info("{}", naverProperties) 같은 코드를 작성하면 clientSecret이 로그에 그대로 노출된다. 이를 방지하기 위해 toString()을 오버라이딩해 민감한 값을 ***로 마스킹하였다.

@Override
public String toString() {
    return "OAuthNaverProperties[" +
            "clientId=" + clientId +
            ", clientSecret=***" +   // ← 마스킹
            ", redirectUri=" + redirectUri +
            ", authBaseUrl=" + authBaseUrl +
            ", apiBaseUrl=" + apiBaseUrl +
            ']';
}

OAuthUserInfo record에서도 같은 이유로 oauthId와 email을 마스킹했다. OAuth ID와 이메일은 개인식별정보(PII)이므로 로그에 남기지 않는 것이 원칙이다. 즉, record의 자동 생성 toString()을 그대로 두면 민감 정보가 로그에 유출될 수 있다. 보안 관련 설정 값이나 사용자 개인정보를 담는 record라면 반드시 toString()을 오버라이딩해 마스킹 처리하는 것을 권장한다.  toString 메소드와 관련된 게시글도 향후 업로드할 예정이다.

@Override
public String toString() {
    return "OAuthUserInfo[" +
            "oauthProvider=" + oauthProvider +
            ", oauthId=***" +      // ← PII 마스킹
            ", email=***" +        // ← PII 마스킹
            ", nickname=" + nickname +
            ']';
}

✏️  WebClient 설정

RestTemplate 대신 WebClient를 사용했고 카카오/네이버 각각 인증용과 API용 두 개씩 총 4개의 Bean을 등록하였다.
Bean을 두 개씩 나눈 이유 =>  카카오/네이버 모두 토큰 발급 도메인(Auth)과 사용자 정보 조회 도메인(API)이 다르다. Base URL이 다르므로 별도 Bean으로 분리해 @Qualifier로 주입한다.
Qualifier 관련 이슈가 있었는데 이는 해당 게시글에서 작성하였다. => https://pooreumjung.tistory.com/561

@Configuration
@RequiredArgsConstructor
public class WebClientConfig {

    private static final int MAX_IN_MEMORY_SIZE = 2 * 1024 * 1024; // 2MB
    private final OAuthNaverProperties oAuthNaverProperties;
    private final OAuthKakaoProperties oAuthKakaoProperties;

    private WebClient buildWebClient(String baseUrl) {
        return WebClient.builder()
                .baseUrl(baseUrl)
                .defaultHeader(HttpHeaders.CONTENT_TYPE,
                        MediaType.APPLICATION_FORM_URLENCODED_VALUE)
                .exchangeStrategies(
                        ExchangeStrategies.builder()
                                .codecs(config -> config.defaultCodecs()
                                        .maxInMemorySize(MAX_IN_MEMORY_SIZE))
                                .build()
                )
                .build();
    }

    @Bean("kakaoAuthWebClient")
    public WebClient kakaoAuthWebClient() {
        return buildWebClient(oAuthKakaoProperties.authBaseUrl()); // kauth.kakao.com
    }

    @Bean("kakaoApiWebClient")
    public WebClient kakaoApiWebClient() {
        return buildWebClient(oAuthKakaoProperties.apiBaseUrl()); // kapi.kakao.com
    }

    @Bean("naverAuthWebClient")
    public WebClient naverAuthWebClient() {
        return buildWebClient(oAuthNaverProperties.authBaseUrl()); // nid.naver.com
    }

    @Bean("naverApiWebClient")
    public WebClient naverApiWebClient() {
        return buildWebClient(oAuthNaverProperties.apiBaseUrl()); // openapi.naver.com
    }
}

✏️ 1단계 - 로그인 URL 생성

카카오

public OAuthKakaoLoginUrlResponse getKakaoLoginUrl() {
    return new OAuthKakaoLoginUrlResponse(
            UriComponentsBuilder
                    .fromUriString(oAuthKakaoProperties.authBaseUrl() + "/oauth/authorize")
                    .queryParam("client_id", oAuthKakaoProperties.clientId())
                    .queryParam("redirect_uri", oAuthKakaoProperties.redirectUri())
                    .queryParam("response_type", "code")
                    .queryParam("scope", "profile_nickname,account_email")
                    .build()
                    .toUriString()
    );
}
 

네이버 - state를 Redis에 저장

네이버는 CSRF 방지를 위해 state 파라미터가 필수다. SecureRandom으로 생성한 값을 Redis에 5분 TTL로 저장하고, 로그인 콜백 시 검증한다.

public OAuthNaverLoginUrlResponse getNaverLoginUrl() {
    String state = generateNaverState();
    saveNaverState(state); // Redis에 5분 TTL로 저장

    return new OAuthNaverLoginUrlResponse(
            UriComponentsBuilder
                    .fromUriString(oAuthNaverProperties.authBaseUrl() + "/oauth2.0/authorize")
                    .queryParam("client_id", oAuthNaverProperties.clientId())
                    .queryParam("redirect_uri", oAuthNaverProperties.redirectUri())
                    .queryParam("response_type", "code")
                    .queryParam("state", state)
                    .build()
                    .toUriString()
    );
}

private String generateNaverState() {
    byte[] bytes = new byte[32];
    new SecureRandom().nextBytes(bytes);
    return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}

private void saveNaverState(String state) {
    stringRedisTemplate.opsForValue()
            .set("oauth:naver:state:" + state, "1", Duration.ofMinutes(5));
}

✏️  2단계 - 인가 코드 → Access Token

카카오는 POST + form-data, 네이버는 GET + query parameter 방식이다.

카카오

public OAuthKakaoTokenResponse getToken(String code) {
    MultiValueMap<String, String> form = new LinkedMultiValueMap<>();
    form.add("grant_type", "authorization_code");
    form.add("client_id", kakaoProperties.clientId());
    form.add("redirect_uri", kakaoProperties.redirectUri());
    form.add("code", code);
    if (StringUtils.hasText(kakaoProperties.clientSecret())) {
        form.add("client_secret", kakaoProperties.clientSecret());
    }

    return kakaoAuthWebClient.post()
            .uri("/oauth/token")
            .body(BodyInserters.fromFormData(form))
            .retrieve()
            .onStatus(HttpStatusCode::isError,
                    r -> r.bodyToMono(String.class)
                            .flatMap(body -> handleKakaoError(r.statusCode(), body)))
            .bodyToMono(OAuthKakaoTokenResponse.class)
            .block();
}

네이버

네이버의 독특한 에러 처리: 네이버 토큰 API는 실패하더라도 HTTP 200을 반환하면서 응답 body에 error 필드를 담아 내려보낸다. HTTP 상태 코드만 보고 성공 여부를 판단하면 안 되므로 validateTokenResponse()를 별도로 호출한다.

public OAuthNaverTokenResponse getToken(String code, String state) {
    String url = UriComponentsBuilder
            .fromUriString(naverProperties.authBaseUrl() + "/oauth2.0/token")
            .queryParam("grant_type", "authorization_code")
            .queryParam("client_id", naverProperties.clientId())
            .queryParam("client_secret", naverProperties.clientSecret())
            .queryParam("code", code)
            .queryParam("state", state)
            .build(true)
            .toUriString();

    OAuthNaverTokenResponse response = naverAuthWebClient.get()
            .uri(url)
            .accept(MediaType.APPLICATION_JSON)
            .retrieve()
            .onStatus(HttpStatusCode::isError,
                    r -> r.bodyToMono(String.class)
                            .flatMap(body -> handleNaverApiError(r.statusCode(), body)))
            .bodyToMono(OAuthNaverTokenResponse.class)
            .block();

    validateTokenResponse(response); // 네이버는 200 OK로 에러를 내려보내므로 별도 검증 필요
    return response;
}

private void validateTokenResponse(OAuthNaverTokenResponse response) {
    if (response == null ||
            response.error() != null ||
            !StringUtils.hasText(response.accessToken())) {
        throw new GeneralException(ErrorStatus.INTERNAL_SERVER_ERROR);
    }
}

✏️ 3단계 - 사용자 정보 조회

Access Token을 Authorization: Bearer {token} 헤더에 담아 사용자 정보를 조회한다.

두 플랫폼의 응답 구조가 다르기 때문에 공통 record인 OAuthUserInfo로 통일해 이후 로직을 공유할 수 있도록 했다.

// 플랫폼 무관 공통 사용자 정보
public record OAuthUserInfo(
        OAuthProvider oauthProvider,
        String oauthId,
        String email,
        String nickname
) {}

카카오 응답 구조

// 카카오 API 응답 - 중첩 구조
public record OAuthKakaoUserInfoResponse(
        Long id,
        @JsonProperty("kakao_account") KakaoAccount kakaoAccount
) {
    public record KakaoAccount(String email, Profile profile) {}
    public record Profile(String nickname) {}
}

네이버 응답 구조

// 네이버 API 응답 - response 래퍼 구조
public record OAuthNaverUserInfoResponse(
        String resultcode,
        String message,
        Response response
) {
    public record Response(
            String id, String nickname, String name,
            String email, String gender, String age, ...
    ) {}
}

✏️  4단계 - 회원 처리 및 JWT 발급

사용자 정보를 받은 후 신규 회원이면 자동 가입, 기존 회원이면 조회해서 바로 JWT를 발급한다.
Refresh Token은 탈취 대비를 위해 원문이 아닌 해시값을 DB에 저장한다.

public LoginResponse kakaoLogin(OAuthKakaoLoginRequest request) {
    OAuthKakaoTokenResponse kakaoToken = oAuthKakaoClient.getToken(request.code());
    OAuthUserInfo userInfo = oAuthKakaoClient.getUserInfo(kakaoToken.accessToken());

    // 신규면 INSERT, 기존이면 SELECT
    User user = userCommandService.getOrRegisterUser(
            userInfo.oauthId(), userInfo.oauthProvider(),
            userInfo.nickname(), userInfo.email()
    );

    String accessToken = jwtService.generateAccessToken(user);
    String refreshToken = jwtService.generateRefreshToken(user);

    // Refresh Token은 해시 후 저장
    userCommandService.updateRefreshToken(user, refreshTokenHasher.hash(refreshToken));
    return LoginResponse.from(user, accessToken, refreshToken);
}

네이버는 로그인 처리 전에 state 검증을 먼저 수행한다. + delete() 반환값이 true인 경우에만 통과시킨다. 존재하지 않는 key이거나 이미 삭제된 경우(재사용 시도)는 모두 거부한다.

public LoginResponse naverLogin(OAuthNaverLoginRequest request) {
    validateAndDeleteNaverState(request.state()); // Redis 검증 + 즉시 삭제
    // 이후 로직은 카카오와 동일
    ...
}

private void validateAndDeleteNaverState(String state) {
    Boolean deleted = stringRedisTemplate.delete("oauth:naver:state:" + state);
    if (deleted == null || !deleted) {
        throw new GeneralException(ErrorStatus.BAD_REQUEST);
    }
}

✏️ 에러 처리

WebClient의 onStatus()를 통해 에러 상태를 감지하고, 4xx/5xx를 구분해 예외를 던진다.

// 카카오 에러 처리
private Mono<? extends Throwable> handleKakaoError(HttpStatusCode status, String body) {
    log.error("[KAKAO][{}] {}", status.value(), body);
    if (status.is4xxClientError()) {
        return Mono.error(new GeneralException(ErrorStatus.UNAUTHORIZED));
    }
    return Mono.error(new GeneralException(ErrorStatus.INTERNAL_SERVER_ERROR));
}

// 네이버 에러 처리 (동일 구조)
private Mono<? extends Throwable> handleNaverApiError(HttpStatusCode status, String body) {
    log.error("[NAVER][API][{}] {}", status.value(), body);
    if (status.is4xxClientError()) {
        return Mono.error(new GeneralException(ErrorStatus.UNAUTHORIZED));
    }
    return Mono.error(new GeneralException(ErrorStatus.INTERNAL_SERVER_ERROR));
}

✏️  카카오 vs 네이버 차이점 정리

항목 카카오 네이버
토큰 요청 방식 POST + form-data GET + query parameter
state 필수 여부 선택 필수
state 검증 방법 - Redis TTL + 즉시 삭제
에러 응답 방식 HTTP 상태 코드 200 OK + body의 error 필드
사용자 정보 엔드포인트 /v2/user/me /v1/nid/me
사용자 정보 응답 구조 kakao_account.profile.nickname response.nickname

✏️  보안 포인트 요약

  • 네이버 state: SecureRandom 생성 → Redis 5분 TTL 저장 → 검증 즉시 삭제 (재사용 방지)
  • Refresh Token 해시 저장: DB에 원문 대신 해시값 저장 (DB 유출 시 피해 최소화)
  • 에러 구분: 4xx는 인증 실패(401), 5xx는 서버 에러(500)로 명확히 분리

 

반응형
반응형
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2026/07   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
글 보관함