티스토리 뷰
[Spring Boot / 스프링 부트] 카카오 & 네이버 소셜 로그인 구현하기 (OAuth 2.0)
poopooreum 2026. 3. 28. 02:20Spring 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)로 명확히 분리
'Back-End > 개인 공부' 카테고리의 다른 글
| 무중단 배포 방식 정리 - Rolling, Blue-Green, Canary (0) | 2026.03.30 |
|---|---|
| [Spring Boot / 스프링 부트] toString()을 왜 오버라이딩해야 하는가 - 보안 로깅 관점 (0) | 2026.03.28 |
| [AWS] - AWS CodeDeploy와 Blue-Green 배포로 무중단 배포 환경 구축하기 (0) | 2026.03.23 |
| [AWS] - SSH 터널링 구성과 AWS Subnet 구조 (0) | 2026.03.09 |
| [Spring Boot / 스프링 부트] - Flyway와 Hibernate의 실행 순위 비교 (0) | 2026.01.18 |
- Total
- Today
- Yesterday
- 스프링 부트 crud 게시판 구현
- 자바
- CSS
- 이분 매칭
- 알고리즘
- 반복문
- Do it!
- 카운팅 정렬
- 백준
- c++ string
- BFS
- DFS
- 알고리즘 공부
- HTML5
- 우선순위 큐
- 자바스크립트
- 스택
- C++ Stack
- 에라토스테네스의 체
- html
- 백준 풀이
- C++
- js
- 세그먼트 트리
- 유니온 파인드
- 투 포인터
- 유클리드 호제법
- java
- 자료구조
- DP
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 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 |
