네이버 소셜 로그인 구현 과정

늘보·2025년 4월 14일

Spring

목록 보기
22/24

소셜 로그인이란?

네이버, 구글, 카카오 등과 같은 소셜 플랫폼이 제공하는 사용자 인증 기능을 다른 서비스에서 이용할 수 있도록 하는 기능을 말한다.

사용자는 별도의 회원가입 절차 없이 기존에 사용 중인 소셜 계정으로 간편하게 로그인할 수 있다.

➡️ 가입이 귀찮거나 가입한 계정이 생각나지 않아 서비스를 이탈하는 사용자를 잡을 수 있다.


네이버 소셜 로그인 적용을 위한 기본 설정

1️⃣ 애플리케이션 등록

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

  • 애플리케이션 이름 입력 및 네이버 로그인 선택


  • 제공을 원하는 정보 선택

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


  • 로그인 환경 설정: PC 웹, 모바일 등 적용할 서비스 환경에 맞게 선택


  • URL 설정


  • 서비스 이용 동의 후 등록하기


2️⃣ 소셜 로그인 흐름

💡 자세한 흐름은 네이버 소셜 로그인 가이드 참고

💡 본 프로젝트에서는 Email은 중복 불가(unique) 조건을 설정하였고 로그인 타입을 구별하여 어떤 플랫폼의 로그인인지 저장하도록 조건을 걸었다.

👉 다른 소셜 플랫폼 or 해당 사이트에서 동일한 이메일로 가입하는 것을 막기 위해 유니크 조건을 걸어두었다.


사용자 접속 및 소셜 플랫폼 선택

  • 사용자가 사이트에 처음 접속하면 보통 회원가입 또는 로그인 버튼 클릭

🔁 로그인 버튼 클릭 시, 이미 가입된 사용자인지 확인 → 없다면 회원가입 페이지로 이동

⚠️ 소셜 로그인 시 과도한 정보를 요구하면 사용자 이탈 가능성↑


소셜 플랫폼으로부터 사용자 정보 요청 및 수신

로그인 또는 회원가입 시 소셜 플랫폼(Naver 등)에서 사용자 정보를 제공

해당 정보를 기반으로 회원가입 또는 로그인 처리


사용자 정보 DB에 저장

  • 소셜 플랫폼에서 받은 정보를 기반으로 DB에 저장

  • ⚠️ 이때 별도의 비밀번호는 요구하지 않으며 간편 인증만으로 회원 가입이 가능해야 한다

📌 소셜 로그인은 간편한 사용자 인증을 통해 서비스 이용의 진입 장벽을 낮춰주고 사용자 이탈을 줄이는 데 큰 장점이 있다.

➡️따라서 복잡한 정보 입력 절차 없이 가능한 한 간단하고 빠르게 인증이 완료되도록 구현하는 것이 핵심이다.


코드 작성 과정

💡요청 URI, 요청 Param, 응답 필드 값에 대한 자세한 내용은 네이버 로그인 개발가이드 참고

🟢 application-private.yml

naver:
  client_id: 
  redirect_uri : 
  client_secret:


🟢 네이버 인증 요청 API

🟡 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();
    }

📝 정리

항목설명
stateCSRF 방지를 위한 고유 값
저장 위치세션 (또는 DB/캐시)
검증 시점콜백 요청 수신 후 접근 토큰 발급 시
비교 방법세션에서 꺼낸 값콜백 응갑 값 비교 ➡️ 아래의 접근 토큰 요청 부분 코드 참고


🟠 POSTMAN

POSTMAN을 통해 요청 후 리다이렉트 URI 확인


🟠 결과 화면

웹사이트에서 로그인 버튼 클릭 시 리다이렉트 URI로 이동


해당 URI에는 code, state 값이 포함되며 이 값을 이용해 접근 토큰을 발급

🚨 화면에 발생된 오류는 리다이렉트 URI에 연결된 HTML 파일이 없어서 발생한 것



🟢 접근 토큰 발급 API

💡접근 토큰의 용도

접근 토큰은 사용자 프로필 조회 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



🟢 회원가입 API 호출

🟡 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


🟢 로그인 API 호출

🟡 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나 캐시와 같은 다른 저장 방식과의 차이점을 비교 분석하여 보안성, 확장성, 유지보수 측면에서 더 적절한 방식으로 선택해 나가야 할 것 같다.

profile
누워만 있지 말고 제발 뭐라도 하자.

0개의 댓글