네이버 애플리케이션 등록

네이버 소셜 로그인 기능을 사용하기 위해서는 네이버 디벨로퍼스에서 네이버 애플리케이션 등록이 필요하다.

https://developers.naver.com/

 

NAVER Developers

네이버 오픈 API들을 활용해 개발자들이 다양한 애플리케이션을 개발할 수 있도록 API 가이드와 SDK를 제공합니다. 제공중인 오픈 API에는 네이버 로그인, 검색, 단축URL, 캡차를 비롯 기계번역, 음

developers.naver.com

 

디벨로퍼스에 가입을 하고 Application -> 애플리케이션 등록으로 이동한다.

권한 중 필요한 것을 추가한다.

필수로 체크한 것은 최초 로그인 시 제공 동의를 받게 된다. 

 

아래에서 PC 웹 환경울 추가하고 서비스 URL(로컬 프로젝트라 로컬 호스트 사용)와 콜백 URL을 추가한다.

콜백 URL은 클라이언트에서 네이버 로그인 후 콜백을 받은 URL이란 뜻.

등록을 완료하면 애플리케이션 정보로 이동해 Client ID와 Client Secret을 확인한다.

미리 환경 변수로 등록 해두자.

 

 

전체적인 흐름

자세한 것은 네이버 로그인 개발가이드 참고. 개인적으로 타사 api에 비해 설명이 더 잘 되어있다 느꼈다.

https://developers.naver.com/docs/login/devguide/devguide.md

 

네이버 로그인 개발가이드 - LOGIN

네이버 로그인 개발가이드 1. 개요 4,200만 네이버 회원을 여러분의 사용자로! 네이버 회원이라면, 여러분의 사이트를 간편하게 이용할 수 있습니다. 전 국민 모두가 가지고 있는 네이버 아이디

developers.naver.com

 

네이버 로그인을 수행하고 회원 정보를 가져오기 까지의 흐름은 아래와 같다.

  1. 네이버 로그인 연동 후 Callback 받기
  2. Callback에서 받은 code로 접근 토큰 발급 요청 
  3. 접근 토큰을 이용하여 프로필 API 호출하기

해당 순서대로 하나하나 살펴본다.

 

 

네이버 로그인 연동 후 Callback 받기

 

우선 네이버 로그인을 요청할 URL을 작성해야 한다.

기본 URL은 위와 같고 요청 변수 정보에 해당하는 쿼리 파라미터를 넣어 URL을 만들어야 한다.

 

요청문 샘플

https://nid.naver.com/oauth2.0/authorize?response_type=code&client_id=CLIENT_ID&state=STATE_STRING&redirect_uri=CALLBACK_URL

 

이후 우리가 등록한 콜백 URL로 응답이 오게 된다.

여기서 code는 접근 토큰을 받기 위해 다음 단계 요청에서 쓰이게 된다.

 

login.html

<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="utf-8">
    <title>네이버 로그인</title>
</head>
<body>
<a href="/naver/login">
    <button>네이버 로그인</button>
</a>
</body>
</html>

우선 네이버 로그인 버튼을 누르면 해당 주소로 리다이렉트 하도록 하였다.

 

SocialOauthController

@RestController
@RequiredArgsConstructor
public class SocialOauthController {

    private final NaverOauthService naverOauthService;
    private final NaverUserService naverUserService;

    @ResponseBody
    @GetMapping("/api/auth/social/login")
    public ResponseEntity<?> socialCallback(
            @RequestParam("code") String code,
            @RequestParam("state") String state
    ) {
        NaverProfileResponseDto.NaverUserDetail detail = this.naverOauthService.getNaverUserDetails(code, state);
        return ResponseEntity.ok(naverUserService.naverLogin(detail));
    }

    @GetMapping("/naver/login")
    public void naverLogin(HttpServletRequest request, HttpServletResponse response) {
        try {
            response.sendRedirect(naverOauthService.generateUrl());
        } catch (UnsupportedEncodingException e) {
            throw new RuntimeException(e);
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }
}

컨트롤러에서는 방금 리다이렉트로 설정한 url로 요청이 들어오면

요청문 샘플에 맞춰 url을 만들어 또다시 리다이렉트 한다.

 

서비스의 Url 생성 메서드

    public String generateUrl() throws UnsupportedEncodingException {
        return UriComponentsBuilder.fromHttpUrl(naverProperties.getNaverPopupUrl())
                .queryParam("response_type", "code")
                .queryParam("client_id", naverProperties.getNaverClientId())
                .queryParam("state", URLEncoder.encode(UUID.randomUUID().toString().substring(0, 8), "UTF-8"))
                .queryParam("redirect_uri", URLEncoder.encode(naverProperties.getNaverRedirectUrl(), "UTF-8"))
                .build().toUriString();
    }

유저가 네이버 로그인을 하면 애플리케이션에서 설정한 콜백 url로 응답이 들어오게 된다.

여기서 토큰 발급에 필요한 인증 코드를 받을 수 있다.

 

 

Callback에서 받은 code로 접근 토큰 발급 요청 

 

요청문 샘플

https://nid.naver.com/oauth2.0/token?grant_type=authorization_code&client_id=jyvqXeaVOVmV&client_secret=527300A0_COq1_XV33cf&code=EIc5bFrl4RibFls1&state=9kgsGTfH4j7IyAkg

 

이번엔 인증 코드로 토큰을 발급 받을 것이다.

필수 파라미터와 발급 때 필수인 파라미터를 조합하여 요청문을 완성한다.

 

 

서비스의 토큰 발급 과정

    public NaverProfileResponseDto.NaverUserDetail getNaverUserDetails(String code, String state) {
        NaverTokenResponseDto tokenResponseDto = this.getToken(code, state);
        return this.getUser(tokenResponseDto.getAccess_token());
    }

    private NaverTokenResponseDto getToken(String code, String state) {
        RestTemplate restTemplate = new RestTemplate();

        String url = UriComponentsBuilder.fromHttpUrl(naverProperties.getNaverTokenUrl())
                .queryParam("grant_type", "authorization_code")
                .queryParam("client_id", naverProperties.getNaverClientId())
                .queryParam("client_secret", naverProperties.getNaverClientSecret())
                .queryParam("code", code)
                .queryParam("state", state)
                .build().toUriString();

        ResponseEntity<NaverTokenResponseDto> responseDto = restTemplate.postForEntity(url, null, NaverTokenResponseDto.class);

        return responseDto.getBody();
    }

RestTemplate을 사용해 토큰을 발급하였다.

 

응답 정보에 맞는 형태로 Dto 객체를 생성하였다.

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class NaverTokenResponseDto {
    private String access_token;
    private String refresh_token;
    private String token_type;
    private int expires_in;
    private String error;
    private String error_description;
}

 

우리는 여기서 access_token만 사용할 것이다.

 

 

접근 토큰을 이용하여 프로필 API 호출하기

 

요청문 예시

curl -XGET "https://openapi.naver.com/v1/nid/me" \ -H "Authorization: Bearer AAAAPIuf0L+qfDkMABQ3IJ8heq2mlw71DojBj3oc2Z6OxMQESVSrtR0dbvsiQbPbP1/cxva23n7mQShtfK4pchdk/rc="

 

출력 결과는 더 많은 항목이 있으나 길이 문제로 잘랐다. 직접 가서 확인 바란다.

우리 프로젝트에서는 nickname, email만 사용하고 있다.

 

서비스의 프로필 획득 과정

    private NaverProfileResponseDto.NaverUserDetail getUser(String token) {
        RestTemplate restTemplate = new RestTemplate();

        String url = naverProperties.getNaverProfileUrl();

        HttpHeaders headers = new HttpHeaders();
        headers.setBearerAuth(token);
        HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(headers);
        ResponseEntity<NaverProfileResponseDto> responseDto = restTemplate.exchange(url, HttpMethod.GET, request, NaverProfileResponseDto.class);

        return responseDto.getBody().getResponse();
    }

여기서도 RestTemplate을 사용해 요청을 보냈다.

아까와는 다르게 헤더가 필요하기 때문에 메서드의 모양새가 조금 다르다.

 

@Getter
@NoArgsConstructor
@AllArgsConstructor
public class NaverProfileResponseDto {
    private String resultcode;
    private String message;

    private NaverUserDetail response;

    @Getter
    @NoArgsConstructor
    @AllArgsConstructor
    public static class NaverUserDetail {
        private String id;
        private String nickname;
        private String email;
    }
}

네이버에서는 response 안에 프로필 정보를 포함하고 있으니 계층 구조로 Dto를 설계해야 한다.

 

 

이후 과정

@Service
@RequiredArgsConstructor
public class NaverUserService {

    private final UserService userService;
    private final JwtUtil jwtUtil;

    public LoginTokenDto naverLogin(NaverProfileResponseDto.NaverUserDetail naverUserDetail) {
        User user = null;

        try {
            user = userService.getUserByEmail(naverUserDetail.getEmail());
        } catch (Exception e) {
            UserSignupRequestDto dto = new UserSignupRequestDto();
            dto.setEmail(naverUserDetail.getEmail());
            dto.setName(naverUserDetail.getNickname());
            dto.setPassword(UUID.randomUUID().toString().substring(0, 8));
            user = userService.signup(dto);
        }

        return LoginTokenDto.builder()
                .accessToken(jwtUtil.createAccessToken(user))
                .build();
    }
}

우리는 이렇게 받은 이메일과 이름 정보를 가지고 DB에 이메일 정보가 있는지 확인하여 없으면 회원가입, 있으면 바로 access 토큰을 발급해주고 있다.

여기서 부터는 각자 서비스에 맞춰 이후 로직을 개발 하면 될 것 같다!

 

다음에는 스프링 시큐리티의 OAuth2 라이브러리를 사용한 소셜 로그인으로 돌아오도록 하겠다.

 

겪은 문제

PropertyReferenceException: No property 'created' found for type 'Post'; Did you mean 'createdAt’

 

이 에러가 계속 떠서 어디서 발생하는 에러지? 하고 보니 findByTitleContainingAndCreatedAtBetween 쿼리 메서드를 호출할 때 발생했다.

    public PostsResponseDto getPosts(int page, String sortTypeStr, String search, String start, String end) {
        SortType sortType = SortType.fromColumn(sortTypeStr);
        Sort sort = Sort.by(sortType.getColumn());
        Pageable pageable = PageRequest.of(page, 10, sort);

        search = search != null ? search : "";
        LocalDateTime startDate = start != null ?
                LocalDate.parse(start, DateTimeFormatter.ISO_DATE_TIME).atStartOfDay() : LocalDateTime.MIN;
        LocalDateTime endDate = end != null ?
                LocalDate.parse(end, DateTimeFormatter.ISO_DATE_TIME).atTime(LocalTime.MAX) : LocalDateTime.MAX;
        Page<Post> posts = postRepository.findByTitleContainingAndCreatedAtBetween(search, startDate, endDate, pageable);

        List<PostGetResponseDto> postGetResponseDtoList = posts.map(PostGetResponseDto::new).getContent();
        if(posts.getTotalElements() > 0 && postGetResponseDtoList.isEmpty()) {
            throw new BusinessException(ErrorCode.POST_NOT_FOUND);
        }

        return PostsResponseDto.builder().page(page)
                .data(postGetResponseDtoList)
                .build();
    }

그래서 난 이 JPA 쿼리 메서드가 잘못된 줄 알고 CreatedAt이 왜 파싱이 안되는 거지? 하고 계속 뜯어 봤다.

그런데 아무리 봐도 메서드에는 이상이 없는 것이다.

 

애초에 메서드에 이상이 있었다면 런 하자마자 메서드를 구현하기 때문에...

그 때 에러가 났었어야 한다.

 

 

원인

        SortType sortType = SortType.fromColumn(sortTypeStr);
        Sort sort = Sort.by(sortType.getColumn());

 

황당하게도 원인은 정렬 기준을 정하는 곳이었다.

@Getter
@RequiredArgsConstructor
public enum SortType {
    CREATED_AT("created_at"),
    LIKE("like");

    private final String value;
}

 

정렬할 칼럼 이름이 created_at이라 되어 있다.

그러나 Hibernate에서 에서 파싱할 때 자바 카멜 → DB 스네이크로 변환을 해준다.

스네이크로 표기가 되어 있어서 created까지만 잘려 파싱 에러 난 것.

 

 

해결

@Getter
@RequiredArgsConstructor
public enum SortType {
    CREATED_AT("createdAt"),
    LIKE("like");

    private final String value;
}

 

카멜 표기법으로 변경을 해주면 지가 알아서 파싱 잘 해준다.

 

겪은 문제

@Service
@RequiredArgsConstructor
public class UserService {
    private final UserRepository userRepository;
    private final UserMapper userMapper;

    public final User signup(UserSignupRequestDto requestDto) {
        User user = this.userMapper.toEntity(requestDto);
        return this.userRepository.save(user);
    }

	@Transactional
    public void verify(User user) {
        user.verify();
    }
}

 

나는 verify 메서드에 트랜잭션을 설정했는데, 아무런 상관 없는 userMapper가 갑자기 null이 되어버렸다...
signup 메서드는 건들지도 않았는데?
왜 뚱딴지 같은 곳에서 문제가 생긴 걸까?

 

 

원인

그것은 signup 메서드에 final 키워드가 사용 되었다는 것이다.
@Transactional은 스프링 AOP를 통해 프록시로 작동된다.
프록시 객체는 트랜잭션이 적용 되어있는 클래스를 상속하여 구현이 되는데,

final 키워드 때문에 signup 메서드의 오버라이딩이 되지 않은 것이다.

 

 

해결

@Service
@RequiredArgsConstructor
public class UserService {
    private final UserRepository userRepository;
    private final UserMapper userMapper;

    public User signup(UserSignupRequestDto requestDto) {
        User user = this.userMapper.toEntity(requestDto);
        return this.userRepository.save(user);
    }

    @Transactional
    public void verify(User user) {
        user.verify();
    }
}

 

final 키워드를 제거 하면 해결된다.

 

 

+ Controller에 트랜잭션?

이 문제를 처음에 해결하지 못 하고, 컨트롤러에 트랜잭션을 생성했었다.

그러나 계속 요청을 받고 있는 컨트롤러에 트랜잭션 환경을 만드는 지양해야 한다.

 

개요

이메일 인증은 어떻게 구현하면 좋을까?
회원가입 요청이 들어왔을 때 request에 적힌 사용자 이메일로 인증 코드를 보내면 될 것 같다.
그리고 사용자가 인증 코드를 서버로 보내면, 우리가 보내 준 코드와 일치 하는지 검토 하자.
일치 시 회원 가입 처리를 하면 되지 않을까?

 

저보다는 더 복잡하겠지만 일단 중요한 건, Spring 서버에서 어떻게 이메일을 보낼 수 있을까이다.

 

 

SMTP

SMTP는 Simple Mail Transfer Protocol의 약자이다.
인터넷을 통해 이메일 메시지를 전송하는 데 사용되는 통신 프로토콜이다.

 

google SMTP

Gmail로도 SMTP를 사용할 수 있을까? 당연하다!

 

App passwords 등록

우리가 만든 어플리케이션에서 구글 계정에 접근하기 위해 패스워드를 등록해야 한다.

만약 App passwords라는 설정을 검색해도 보이지 않는다면 2단계 인증을 하지 않은 것이다.

 

Java Mail Sender

SMTP를 사용하기 위해서는 Java Mail Sender 디펜던시를 추가할 필요가 있다.

implementation 'org.springframework.boot:spring-boot-starter-mail'

 

JavaMailSender는 스프링 프레임워크의 인터페이스이다.

 

Docs

 

JavaMailSender (Spring Framework 6.1.8 API)

Extended MailSender interface for JavaMail, supporting MIME messages both as direct arguments and through preparation callbacks. Typically used in conjunction with the MimeMessageHelper class for convenient creation of JavaMail MimeMessages, including atta

docs.spring.io

 

이 인터페이스를 구현한 JavaMailSenderImpl 클래스를 간편하게 사용할 수 있다.

 

JavaMailSenderImpl (Spring Framework 6.1.8 API)

Obtain and connect a Transport from the underlying JavaMail Session, passing in the specified host, port, username, and password.

docs.spring.io

 

docs에서 우리가 설정 해줄 수 있는 것이 무엇인지 확인하고 필요에 따라 쓰도록 하자.

 

SetJavaMailProperties에서는 세션에 대한 프로퍼티를 추가해 줄 수 있다.

spring.mail.properties.mail.smtp.auth=true
spring.mail.properties.mail.smtp.starttls.enable=true
spring.mail.properties.mail.smtp.starttls.required=true
spring.mail.properties.mail.smtp.connectiontimeout=5000
spring.mail.properties.mail.smtp.timeout=5000
spring.mail.properties.mail.smtp.writetimeout=5000

 

    @Bean
    public JavaMailSender javaMailSender() {
        JavaMailSenderImpl mailSender = new JavaMailSenderImpl();

        mailSender.setHost(host);
        mailSender.setPort(port);
        mailSender.setUsername(username);
        mailSender.setPassword(password);
        mailSender.setDefaultEncoding("UTF-8");
        mailSender.setJavaMailProperties(getMailProperties());

        return mailSender;
    }

    private Properties getMailProperties() {
        Properties properties = new Properties();

        properties.put("mail.smtp.auth", auth);
        properties.put("mail.smtp.starttls.enable", starttlsEnable);
        properties.put("mail.smtp.starttls.required", starttlsRequired);
        properties.put("mail.smtp.connectiontimeout", connectionTimeout);
        properties.put("mail.smtp.timeout", timeout);
        properties.put("mail.smtp.writetimeout", writeTimeout);

        return properties;
    }

 

우리 팀에서는 SmtpConfig라는 config 클래스를 만들어서 JavaMailSenderImpl을 생성하고 Bean에 등록해 사용하는 방식을 채택하였다.

각자의 프로젝트 상황에 맞춰 하도록 하자.

 

    public void sendEmail(String toEmail, String title, String content) {
        SimpleMailMessage message = createEmailForm(toEmail, title, content);

        try {
            mailSender.send(message);
        } catch (Exception e) {
            log.error("MailService.sendEmail exception occur toEmail: {}, " +
                    "title: {}, content: {}", toEmail, title, content);
            throw new RuntimeException("이메일 전송 실패");
        }
    }

    private SimpleMailMessage createEmailForm(String toEmail, String title, String content) {
        SimpleMailMessage message = new SimpleMailMessage();

        message.setTo(toEmail);
        message.setSubject(title);
        message.setText(content);

        return message;
    }

 

SmtpService에서 메시지를 보내는 부분이다.

위 JavaMailSender Docs에서 볼 수 있듯 이메일을 보낼 때는 send라는 메서드를 사용한다.

또한 파라미터로는 SimpleMailMessage 가변인자 들어간다.

즉 메일 여러 개를 한 번에 보낼 수 있다.

 

Docs

 

SimpleMailMessage (Spring Framework 6.1.8 API)

Models a simple mail message, including data such as the from, to, cc, subject, and text fields. Consider JavaMailSender and JavaMail MimeMessages for creating more sophisticated messages, for example messages with attachments, special character encodings,

docs.spring.io

 

여기서는 사용자 이메일 주소, 제목, 내용만 설정해 주고 있다.

 

 

여기까지 Spring에서 Gmail을 보내는 방법에 대해 알아보았다.

+ Recent posts