네이버, 구글, 카카오 등과 같은 소셜 플랫폼이 제공하는 사용자 인증 기능을 다른 서비스에서 이용할 수 있도록 하는 기능을 말한다.
사용자는 별도의 회원가입 절차 없이 기존에 사용 중인 소셜 계정으로 간편하게 로그인할 수 있다.
➡️ 가입이 귀찮거나 가입한 계정이 생각나지 않아 서비스를 이탈하는 사용자를 잡을 수 있다.

Open API를 적용하기 위해 먼저 애플리케이션을 등록해야 한다.
👉 애플리케이션 등록 바로가기


⚠️ 비밀번호 정보는 제공되지 ❌


💡 자세한 흐름은 네이버 소셜 로그인 가이드 참고
💡 본 프로젝트에서는 Email은 중복 불가(unique) 조건을 설정하였고 로그인 타입을 구별하여 어떤 플랫폼의 로그인인지 저장하도록 조건을 걸었다.
👉 다른 소셜 플랫폼 or 해당 사이트에서 동일한 이메일로 가입하는 것을 막기 위해 유니크 조건을 걸어두었다.
회원가입 또는 로그인 버튼 클릭
🔁 로그인 버튼 클릭 시, 이미 가입된 사용자인지 확인 → 없다면 회원가입 페이지로 이동
⚠️ 소셜 로그인 시 과도한 정보를 요구하면 사용자 이탈 가능성↑
로그인 또는 회원가입 시 소셜 플랫폼(Naver 등)에서 사용자 정보를 제공
해당 정보를 기반으로 회원가입 또는 로그인 처리


소셜 플랫폼에서 받은 정보를 기반으로 DB에 저장
⚠️ 이때 별도의 비밀번호는 요구하지 않으며 간편 인증만으로 회원 가입이 가능해야 한다


📌 소셜 로그인은 간편한 사용자 인증을 통해 서비스 이용의 진입 장벽을 낮춰주고 사용자 이탈을 줄이는 데 큰 장점이 있다.
➡️따라서 복잡한 정보 입력 절차 없이 가능한 한 간단하고 빠르게 인증이 완료되도록 구현하는 것이 핵심이다.
💡요청 URI, 요청 Param, 응답 필드 값에 대한 자세한 내용은 네이버 로그인 개발가이드 참고
naver:
client_id:
redirect_uri :
client_secret:
🟡 Controller
@Operation(summary = "네이버 로그인 인증 요청", description = "네이버 로그인 인증 요청을 위한 API입니다.")
@GetMapping
public Response<URI> getNaverLoginRedirectUrl () {
return Response.of(authNaverService.getNaverLoginRedirectUrl());
}
🟡Client
/**
* response_type: 인증 과정에 대한 내부 구분 값 (반드시 code)로 전송
* client_id: 등록된 Client ID
* redirect_uri: callback URL
* state: 위조 공격 방지를 위한 상태값
*/
private URI buildNaverApiUri() {
//고유의 UUID 생성
String state = String.valueOf(UUID.randomUUID());
return UriComponentsBuilder
.fromUriString("https://nid.naver.com/oauth2.0/authorize")
.queryParam("response_type", "code")
.queryParam("client_id", clientId)
.queryParam("redirect_uri", redirectUri)
.queryParam("state",state)
.encode()
.build()
.toUri();
}
인증 요청 시 리다이렉트 URI 반환
🚨 위 코드의 문제점
state는 인증 요청 시 임의의 값을 생성해서 전달하는 용도임과 동시에
콜백 시 전달된 state가 진짜 서버에서 발급한 것인지 확인하기 위한 값⚠️ 하지만 위 코드는 state를 검증할 기준이 없어 공격자가 만든 요청도 통과될 수 있다.
[수정된 코드]
private URI buildApiUri() {
//고유의 UUID 생성
String state = String.valueOf(UUID.randomUUID());
//세션에 저장
httpSession.setAttribute("oauth_state", state);
return UriComponentsBuilder
.fromUriString("https://nid.naver.com/oauth2.0/authorize")
.queryParam("response_type", "code")
.queryParam("client_id", clientId)
.queryParam("redirect_uri", redirectUri)
.queryParam("state",state)
.encode()
.build()
.toUri();
}
| 항목 | 설명 |
|---|---|
| state | CSRF 방지를 위한 고유 값 |
| 저장 위치 | 세션 (또는 DB/캐시) |
| 검증 시점 | 콜백 요청 수신 후 접근 토큰 발급 시 |
| 비교 방법 | 세션에서 꺼낸 값과 콜백 응갑 값 비교 ➡️ 아래의 접근 토큰 요청 부분 코드 참고 |
🟠 POSTMAN
POSTMAN을 통해 요청 후 리다이렉트 URI 확인

🟠 결과 화면
웹사이트에서
로그인 버튼 클릭 시리다이렉트 URI로 이동

해당 URI에는
code,state값이 포함되며 이 값을 이용해 접근 토큰을 발급🚨 화면에 발생된 오류는 리다이렉트 URI에 연결된 HTML 파일이 없어서 발생한 것

💡접근 토큰의 용도
접근 토큰은 사용자 프로필 조회 API를 호출하거나 네이버에서 제공하는 로그인 OpenAPI를 이용할때 사용자 인증값으로 이용
🟡 Controller
리다이렉트 URI에 있던 code와 state 입력
@Operation(summary = "네이버 접근 토큰 발급", description = "redirect_uri를 통해 얻은 code, state로 접근 토근 발급하는 API 입니다.")
@PostMapping("/token")
public Response<NaverApiResponse> requestToken(
@RequestParam String code,
@RequestParam String state,
HttpSession session
) {
//세션에 저장되어 있던 state값
String sessionState = (String) session.getAttribute("oauth_state");
return Response.of(authNaverService.requestToken(code, state, sessionState));
}
🟡 Service
public NaverApiResponse requestToken(String code, String state, String savedState) {
//state 검증
if (!state.equals(savedState)) {
throw new UnauthorizedException(INVALID_STATE.getMessage());
}
return naverClient.issueToken(code, state);
}
🟡 Client
public NaverApiResponse issueToken(String code, String state) {
URI uri = buildAccessTokenApiUri();
try {
return webClient.post()
.uri(uri)
.body(BodyInserters.fromFormData("grant_type", "authorization_code")
.with("client_id", clientId)
.with("client_secret", clientSecret)
.with("code", code)
.with("state", state)
)
.retrieve()
.bodyToMono(NaverApiResponse.class)
.block();
} catch (Exception e) {
throw new RuntimeException(NAVER_PARSING_FAILED.getMessage(), e);
}
}
🟠 POSTMAN

🟡 Controller
반환되었던 접근 토큰 입력
@Operation(summary = "네이버를 통한 회원가입", description = "접근 토근을 통해 프로필을 조회한 후 해당 값으로 회원가입을 하는 API입니다.")
@PostMapping("/sign-up")
public Response<AuthAccessTokenResponse> signUpWithNaver(
@RequestBody AuthNaverAccessTokenRequest authNaverAccessTokenRequest,
HttpServletResponse httpServletResponse
) {
AuthTokensResponse tokensResponseDto = authNaverService.signUpWithNaver(authNaverAccessTokenRequest);
setRefreshTokenCookie(httpServletResponse, tokensResponseDto.refreshToken());
return Response.of(AuthAccessTokenResponse.of(tokensResponseDto.accessToken()));
}
🟡 Service
@Transactional
public AuthTokensResponse signUpWithNaver(AuthNaverAccessTokenRequest authNaverAccessTokenRequest) {
NaverApiProfileResponse profile = getProfile(authNaverAccessTokenRequest);
//이미 등록된 이메일
if (userService.existsByEmail(profile.email())) {
throw new BadRequestException(DUPLICATE_EMAIL.getMessage());
}
Users users = Users.of(profile.email(), profile.nickname(), profile.mobile(), NAVER);
userRepository.save(users);
return authService.getTokenResponse(users);
}
🟡 Client
public NaverApiProfileResponse findProfile(String accessToken) {
URI uri = buildNaverUserProfileApiUri();
String responseBody = webClient.get()
.uri(uri)
.headers(h -> h.setBearerAuth(accessToken)) // 접근 토큰
.retrieve()
.onStatus(status -> !status.is2xxSuccessful(),
res -> Mono.error(new RuntimeException(NAVER_API_RESPONSE_FAILED.getMessage())))
.bodyToMono(String.class)
.block();
try {
//json 형태의 데이터 파싱
NaverApiProfileWrapper naverProfile = objectMapper.readValue(responseBody, NaverApiProfileWrapper.class);
NaverApiProfileResponse profile = naverProfile.response();
//검색된 프로필이 없는 경우
if (ObjectUtils.isEmpty(profile)) {
throw new NotFoundException(NOT_FOUND_PROFILE.getMessage());
}
return profile;
} catch (Exception e) {
throw new RuntimeException(NAVER_PASING_FAILED.getMessage(), e);
}
}
private URI buildNaverUserProfileApiUri() {
return UriComponentsBuilder
.fromUriString("https://openapi.naver.com/v1/nid/me")
.encode()
.build()
.toUri();
}
접근 토큰을 통해 얻은 사용자 프로필 정보를 이용해 DB에 사용자를 등록
🟠 POSTMAN

🟡 Controller
반환되었던 접근 토큰 입력
@Operation(summary = "네이버를 통한 로그인", description = "DB에 저장된 유저를 통해 로그인 진행")
@PostMapping("/sign-in")
public Response<AuthAccessTokenResponse> signInWithNaver(
@RequestBody AuthNaverAccessTokenRequest authNaverAccessTokenRequest,
HttpServletResponse httpServletResponse
) {
AuthTokensResponse tokensResponseDto = authNaverService.signInWithNaver(authNaverAccessTokenRequest);
setRefreshTokenCookie(httpServletResponse, tokensResponseDto.refreshToken());
return Response.of(AuthAccessTokenResponse.of(tokensResponseDto.accessToken()));
}
🟡 Service
@Transactional(readOnly = true)
public AuthTokensResponse signInWithNaver(AuthNaverAccessTokenRequest authNaverAccessTokenRequest) {
NaverApiProfileResponse profile = getProfile(authNaverAccessTokenRequest);
Users users = userService.findByEmailOrElseThrow(profile.email());
if (users.isDeleted()) {
throw new UnauthorizedException(DEACTIVATED_USER_EMAIL.getMessage());
}
if (!NAVER.equals(users.getLoginType())) {
throw new UnauthorizedException(NOT_NAVER_USER.getMessage());
}
return authService.getTokenResponse(users);
}
🟡 Client
회원가입과 동일
➡️ 접근 토큰을 통해 사용자 정보를 가져오고 DB에 해당 정보가 있다면 로그인 성공
🟠 POSTMAN

현재는 OAuth 인증 과정을 직접 구현한 상태이며, OAuth2 라이브러리를 사용하지 않았습니다.
따라서, 추후에는 OAuth2를 적용하여 인증 및 보안 측면에서 더 안정적이고 표준화된 구조로 개선해보고 싶다.
현재는 세션을 사용하고 있지만 DB나 캐시와 같은 다른 저장 방식과의 차이점을 비교 분석하여 보안성, 확장성, 유지보수 측면에서 더 적절한 방식으로 선택해 나가야 할 것 같다.