어노테이션 기반 구글 로그인 리팩토링 + 네이버 로그인 구현

짱J·2022년 6월 29일
0
post-thumbnail
post-custom-banner

어노테이션 기반 구글 로그인 리팩토링

프로젝트에서 같은 코드가 반복되면 유지보수성이 떨어지며, 혹시나 수정이 반영되지 않은 반복 코드가 있다면 문제가 발생한다.

SessionUser user = (SessionUser) httpSession.getAttribute("user");

지금은 index 메소드에서만 사용되지만, 다른 컨트롤러와 메소드에서 세션값이 필요하면 그때마다 직접 세션에서 값을 가져와야 한다.

이 부분을 메소드 인자로 세션값을 바로 받을 수 있도록 변경해보자 !

먼저 config.auth 패키지에 @LoginUser 어노테이션을 생성한다.

package com.example.demo.config.auth;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface LoginUser {
}

🥨 @Target(ElementType.PARAMETER)

  • 어노테이션이 생성될 수 있는 위치를 지정
  • PARAMETER → 메소드의 파라미터로 선언된 객체에서만 사용 가능

🥨 @interface

  • 해당 파일을 어노테이션 클래스로 지정 → 이제 LoginUser라는 어노테이션이 생긴거임!

그 다음, 같은 위치에 LoginUserArgumentResolver를 생성한다.
LoginUserArgumentResolver는 HandlerMethodArgumentResolver 인터페이스를 구현한 클래스이다.

HandlerMethodArgumentResolver는 조건에 맞는 경우 메소드가 있다면 구현체가 지정한 값으로 해당 메소드의 파라미터를 넘길 수 있다.

자세한 사용법은 만들면서 배워보자 ㅎ.ㅎ

package com.example.demo.config.auth;

import com.example.demo.config.auth.dto.SessionUser;
import lombok.RequiredArgsConstructor;
import org.springframework.core.MethodParameter;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;

import javax.servlet.http.HttpSession;

@RequiredArgsConstructor
@Component
public class LoginUserArgumentResolver implements HandlerMethodArgumentResolver {
    private final HttpSession httpSession;
    
    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        boolean isLoginUserAnnotation = parameter.getParameterAnnotation(LoginUser.class) != null;
        boolean isUserClass = SessionUser.class.equals(parameter.getParameterType());
        
        return isLoginUserAnnotation && isUserClass;
    }
    
    @Override
    public Object resolveArgument(MethodParameter parameter, 
                                  ModelAndViewContainer mavContainer, NativeWebRequest webRequest, 
                                  WebDataBinderFactory binderFactory) throws Exception {
        return httpSession.getAttribute("user");
    }
}

🥨 supportsParameter()

  • 컨트롤러 메서드의 특정 파라미터를 지원하는지 판단
  • 코드에서는 파라미터에 @LoginUser 어노테이션이 붙어 있고, 파라미터 클래스 타입이 SessionUser.class인 경우 true를 반환한다.

🥨 resolveArgument()

  • 파라미터에 전달할 객체를 생성
  • 여기서는 세션에서 객체를 가져온다.

여기까지가 @LoginUser를 사용하기 위한 환경 구성이다.

이제 생성된 LoginUserArgumentResolver가 스프링에서 인식될 수 있도록 WebMvcConfigurer에 추가하자.

config 패키지에 WebConfig 클래스를 생성하여 다음과 같이 설정을 추가한다.

package com.example.demo.config;

import com.example.demo.config.auth.LoginUserArgumentResolver;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

import java.util.List;

@RequiredArgsConstructor
@Configuration
public class WebConfig implements WebMvcConfigurer {
    private final LoginUserArgumentResolver loginUserArgumentResolver;
    
    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) {
        argumentResolvers.add(loginUserArgumentResolver);
    }
}

HandlerMethodArgumentResolver는 항상 WebMvcConfigurer의 addArgumentResolvers()를 통해 추가하여야 한다.
다른 HandlerMethodArgumentResolver가 필요하다면 같은 방식으로 추가해주면 된다.

최종적인 패키지 구조는 아래와 같다.

이제 모든 설정이 완료되었으니 IndexController의 코드에서 반복되는 부분을 @LoginUser로 개선해보자!

@GetMapping("/")
public String index(Model model, @LoginUser SessionUser user) {
    model.addAttribute("posts", postsService.findAllDesc());

    if (user != null) {
        model.addAttribute("userName", user.getName());
    }
    return "index";
}

기존에 (User) httpSessoin.getAttribute("user")로 가져오던 세션 정보 값을 파라미터로 가져올 수 있다.
이제 어느 컨트롤러든 @LoginUser만 사용하면 세션 정보를 가져올 수 있게 되었다 !


세션 저장소로 데이터베이스 사용하기

우리가 만든 서비스는 애플리케이션을 재실행하면 로그인이 풀린다 :(
이는 세션이 내장 톰캣의 메모리에 저장되기 때문이다.

내장 톰캣처럼 애플리케이션 실행 시 실행되는 구조에선 항상 초기화가 된다.

또 만약 2대 이상의 서버에서 서비스를 하고 있다면 톰캣마다 세션 동기화 설정을 해야 한다.

실제 현업에서는 다음 3가지 중 한 가지를 세션 저장소로 이용한다

  • 톰캣 세션을 이용
    • 기본적인 방식
    • 2대 이상의 WAS가 구동되는 환경에서는 톰캣들 간의 세션 공유를 위한 추가 설정이 필요
  • MySQL과 같은 데이터베이스를 세션 저장소로 이용
    • 여러 WAS 간의 공용 세션을 사용할 수 있는 가장 쉬운 방법
    • 로그인 요청마다 DB IO가 발생하여 성능상 이슈가 발생할 수 있음
    • 로그인 요청이 많이 없는 백오피스, 사내 시스템 용도에서 사용
  • Redis, Memcached와 같은 메모리 DB를 세션 저장소로 이용
    • B2C 서비스에서 가장 많이 사용
    • 실제 서비스로 사용하기 위해서는 외부 메모리 서버 필요

여기서는 2번째 방식인 데이터베이스를 세션 저장소로 사용하여 진행해본다.

  • 설정이 간단
  • 비용 절감

레디스와 같은 서비스는 엘라스틱 캐시를 사용해야 하기 때문에 추후 AWS를 이용한 배포를 할 때 별도로 사용료를 지불해야 한다.

spring-session-jdbc

먼저 build.gradle에 의존성을 추가한다.

implementation 'org.springframework.session:spring-session-jdbc'

그리고 application.properties에 세션 저장소를 jdbc로 선택하도록 코드를 추가한다.

spring.session.store-type=jdbc


h2-console을 보면 세션을 위한 테이블 2개(SPRING_SESSION, SPRING_SESSION_ATTRIBUTES)가 생성된 것을 볼 수 있다.

로그인을 하면 다음과 같이 세션이 등록되는 것을 확인할 수 있다.
지금은 H2를 사용하고 있기 때문에 스프링을 재시작하면 세션이 풀린다. (H2도 재시작되기 때문)

하지만 나중에 RDS를 사용하면 세션이 풀리지 않게 될 것이다!


네이버 로그인

네이버 API 등록

https://developers.naver.com/apps/#/register?api=nvlogin

네이버 서비스 등록을 하면 구글 로그인 때처럼 ClientIDdhk Client Secret을 발급 받는다.
해당 키 값들을 application-oauth.properties에 추가해주자.

네이버는 스프링 시큐리티를 공식 지원하지 않기 때문에 그동안 Common-OAuth2Provider에서 해주던 값들도 전부 수동으로 입력하여야 한다.

# registration
spring.security.oauth2.client.registration.naver.client-id=(비밀)
spring.security.oauth2.client.registration.naver.client-secret=(비밀)
spring.security.oauth2.client.registration.naver.redirect-uri={baseUrl}/{action}/oauth2/code/{registrationId}
spring.security.oauth2.client.registration.naver.authorization-grant-type=authorization_code
spring.security.oauth2.client.registration.naver.scope=name,email,profile_image
spring.security.oauth2.client.registration.naver.client-name=Naver

# provider
spring.security.oauth2.client.provider.naver.authorization-uri=https://nid.naver.com/oauth2.0/authorize
spring.security.oauth2.client.provider.naver.token-uri=https://nid.naver.com/oauth2.0/token
spring.security.oauth2.client.provider.naver.user-info-uri=https://openapi.naver.com/v1/nid/me
spring.security.oauth2.client.provider.naver.user_name_attribute=response

🥨 user_name_attribute=response

  • 기준이 되는 user_name의 이름을 네이버에서 response로 하도록 함

네이버 오픈 API의 로그인 회원 결과는 아래와 같다.

{
	"resultcode": "00",
    "message": "success",
    "response": {
    	"email": "openapi@naver.com",
        "nickname": "OpenAPI",
        "profile_image": "https://ssl.pstatic.net/static/pwe/address/nodata_33x33.gif",
        "age": "40-49",
        "gender": "F",
        "id": "32742776",
        "name": "오픈 API",
        "birthday": "10-01"
    }
}

스프링 시큐리티에선 하위 필드를 명시할 수 없다.
최상위 필드들만 user_name으로 지정 가능하다.
하지만 네이버의 응답값 최상위 필드는 resultcode, message, response이다.

그러므로 response를 user_name으로 지정하고 추후 response의 id를 user_name으로 지정한다.

스프링 시큐리티 설정 등록

구글 로그인을 등록하면서 대부분의 코드가 확장성 있게 작성되었다보니 네이버는 쉽게 등록이 가능하다.

먼저 OAuthAttributes에 네이버인지 판단하는 코드와 네이버 생성자를 추가해준다.

  • of() 메서드에 조건문 추가
  • ofNaver() 추가
package com.example.demo.config.auth.dto;

import com.example.demo.domain.user.Role;
import com.example.demo.domain.user.User;
import lombok.Builder;
import lombok.Getter;

import java.util.Map;

@Getter
public class OAuthAttributes {
    private Map<String, Object> attributes;
    private String nameAttributeKey;
    private String name;
    private String email;
    private String picture;

    @Builder
    public OAuthAttributes(Map<String, Object> attributes, String nameAttributeKey, String name, String email, String picture) {
        this.attributes = attributes;
        this.nameAttributeKey = nameAttributeKey;
        this.name = name;
        this.email = email;
        this.picture = picture;
    }

    public  static OAuthAttributes of(String registrationId, String userNameAttributeName, Map<String, Object> attributes) {
        if ("naver".equals(registrationId)) {
            return ofNaver("id", attributes);
        }
        return ofGoogle(userNameAttributeName, attributes);
    }

    private static OAuthAttributes ofGoogle(String userNameAttributeName, Map<String, Object> attributes) {
        return OAuthAttributes.builder()
                .name((String) attributes.get("name"))
                .email((String) attributes.get("email"))
                .picture((String) attributes.get("picture"))
                .attributes(attributes)
                .nameAttributeKey(userNameAttributeName)
                .build();
    }
    
    private static OAuthAttributes ofNaver(String userNameAttributeName, Map<String, Object> attributes) {
        Map<String, Object> response = (Map<String, Object>) attributes.get("response");
        
        return OAuthAttributes.builder()
                .name((String) response.get("name"))
                .email((String) response.get("email"))
                .picture((String) response.get("profile_image"))
                .attributes(response)
                .nameAttributeKey(userNameAttributeName)
                .build();
    }

    public User toEntity() {
        return User.builder()
                .name(name)
                .email(email)
                .picture(picture)
                .role(Role.GUEST)
                .build();
    }
}

그 다음 index.mustache에서 구글 로그인 버튼 아래 네이버 로그인 버튼을 추가한다.

<a href="/oauth2/authorization/naver" class="btn btn-secondary active" role="button">Naver Login</a>

짜잔 ^ㅅ^

굿이애옹 ~


테스트에 시큐리티 적용

기존에는 바로 API를 호출할 수 있어 테스트 코드 역시 바로 API를 호출하도록 구성하였다.
하지만, 시큐리티 옵션이 활성화되면 인증된 사용자만 API를 호출할 수 있다.
기존 API 테스트 코드들이 모두 인증에 대한 권한을 받지 못하였으므로, 테스트 코드마다 인증한 사용자가 호출한 것처럼 작동하도록 수정하자.

전체 테스트를 수행해보자.
책에서는 Tasks > verification > test를 눌러 수행하라 했지만, 그렇게 하면 DemoApplicationTests만 실행되어
좌측 디렉토리에서 test를 우클릭하여 'Run All Test'를 눌러주었다.

테스트 실패 원인을 하나씩 살펴보며, 수정해보자!

hello가_리턴된다

No qualifying bean of type 'com.example.demo.config.auth.CustomOAuth2UserService' available: expected at least 1 bean which qualifies as autowire candidate. Dependency annotations: {}

CustomOAuth2UserService를 생성하는데 필요한 소셜 로그인 관련 설정값들이 없어 발생한 오류이다.

test에 application.properties가 없으면 main의 설정을 그대로 가져오지만, application-oauth.properties는 test에 없다고 가져오는 파일이 아니다.

그러므로 문제를 해결하기 위해 테스트 환경을 위한 application.properties를 만들고 가짜 설정값들을 등록해준다.

spring.jpa.show-sql=true
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL57Dialect
spring.jpa.properties.hibernate.dialect.storage_engine=innodb
spring.datasource.hikari.jdbc-url=jdbc:h2:mem://localhost/~/testdb;MODE=MYSQL
spring.h2.console.enabled=true
spring.session.store-type=jdbc

# Test OAuth
spring.security.oauth2.client.registration.google.client-id=test
spring.security.oauth2.client.registration.google.client-secret=test
spring.security.oauth2.client.registration.google.scope=profile, email

책에는 spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL5InnoDBDialect를 사용하도록 나와있는데, 버전 차이 때문에 아래 코드를 대신 사용하였다. (이래야지 오류 안 남)

spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL57Dialect
spring.jpa.properties.hibernate.dialect.storage_engine=innodb
spring.datasource.hikari.jdbc-url=jdbc:h2:mem://localhost/~/testdb;MODE=MYSQL

참고: https://github.com/jojoldu/freelec-springboot2-webservice/issues/67

Posts 등록

  • 200 - 정상 응답
  • 302 - 리다이렉션 응답

이는 스프링 시큐리티 설정 때문에 인증되지 않은 사용자의 요청은 이동시키기 때문이다.
이러한 API 요청은 임의로 인증된 사용차를 추가하여 API만 테스트해볼 수 있도록 한다.

먼저, build.gradle에 spring-security-test를 추가한다.

testImplementation('org.springframework.security:spring-security-test')

그리고 PostsControllerTest의 2개 테스트 메소드에 임의 사용자 인증을 추가한다.

@Test
@WithMockUser(roles = "USER")
public void Posts_등록() throws Exception {
	// 내부 코드는 생략
}

@Test
@WithMockUser(roles = "USER")
public void Posts_수정() throws Exception {
	// 내부 코드는 생략
}

🥨 @WithMockUser(roles = "USER")

  • 인증된 가짜 사용자를 만들어 사용
  • roles를 통해 권한을 추가 → ROLE_USER 권한을 가진 사용자가 API를 요청하는 것과 동일한 효과

하지만 이것만 해서 테스트가 작동하지는 않는다 ㅠ,ㅠ
@WithMockUser가 MockMvc에서만 작동하기 때문이다.

MockMvc 사용

현재 테스트코드는 @SpringBootTest로만 되어 있는데, @SpringBootTest에서 MockMvc를 사용하는 방법을 알아보자.
PostsControllerTest.java를 아래와 같이 수정한다.

package com.example.demo.web;

import com.example.demo.domain.posts.Posts;
import com.example.demo.domain.posts.PostsRepository;
import com.example.demo.web.dto.PostsSaveReqDto;
import com.example.demo.web.dto.PostsUpdateReqDto;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.boot.web.server.LocalServerPort;
import org.springframework.http.*;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MockMvcBuilder;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;

import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment =  SpringBootTest.WebEnvironment.RANDOM_PORT)
public class PostsControllerTest {
    @LocalServerPort
    private int port;

    @Autowired
    private TestRestTemplate restTemplate;

    @Autowired
    private PostsRepository postsRepository;

    @Autowired
    private WebApplicationContext context;

    private MockMvc mvc;

    @Before
    public void setup() {
        mvc = MockMvcBuilders
                .webAppContextSetup(context)
                .apply(springSecurity())
                .build();
    }

    @After
    public void tearDown() throws Exception {
        postsRepository.deleteAll();
    }

    @Test
    @WithMockUser(roles = "USER")
    public void Posts_등록() throws Exception {
        // given
        String title = "title";
        String content = "content";
        PostsSaveReqDto reqDto = PostsSaveReqDto.builder()
                .title(title)
                .content(content)
                .author("author")
                .build();

        String url = "http://localhost:" + port + "/api/v1/posts";

        // when
        mvc.perform(post(url)
                .contentType(MediaType.APPLICATION_JSON)
                .content(new ObjectMapper().writeValueAsString(reqDto)))
                .andExpect(status().isOk());
        
        // then
        List<Posts> all = postsRepository.findAll();
        assertThat(all.get(0).getTitle()).isEqualTo(title);
        assertThat(all.get(0).getContent()).isEqualTo(content);
    }

    @Test
    @WithMockUser(roles = "USER")
    public void Posts_수정() throws Exception {
        // given
        Posts savedPosts = postsRepository.save(Posts.builder()
                .title("title")
                .content("content")
                .author("author")
                .build());

        Long updateId = savedPosts.getId();
        String expectedTitle = "title2";
        String expectedContent = "content2";

        PostsUpdateReqDto reqDto = PostsUpdateReqDto.builder()
                .title(expectedTitle)
                .content(expectedContent)
                .build();

        String url = "http://localhost:" + port + "/api/v1/posts/" + updateId;

        HttpEntity<PostsUpdateReqDto> requestEntity = new HttpEntity<>(reqDto);

        // when
        mvc.perform(put(url)
                .contentType(MediaType.APPLICATION_JSON)
                .content(new ObjectMapper().writeValueAsString(reqDto)))
                .andExpect(status().isOk());

        // then
        List<Posts> all = postsRepository.findAll();
        assertThat(all.get(0).getTitle()).isEqualTo(expectedTitle);
        assertThat(all.get(0).getContent()).isEqualTo(expectedContent);
    }
}

🥨 @Before

  • 매번 테스트가 시작되기 전에 MockMvc 인스턴스를 생성

🥨 mvc.perform

  • 생성된 MockMvc를 통해 API를 테스트
  • 본문 영역은 문자열로 표현하기 위해 ObjectMapper를 통해 문자열 JSON으로 변환

이로써 Post 관련 테스트 오류들을 해결할 수 있었다 :)

마지막 남은 테스트들을 해결해보자

No qualifying bean of type 'com.example.demo.config.auth.CustomOAuth2UserService' available: expected at least 1 bean which qualifies as autowire candidate. Dependency annotations: {}

가장 처음 다룬 테스트 오류와 동일한 에러 메시지이다.
HelloControllerTest는 PostsControllerTest와 다르게 @SpringBootTest가 아닌 @WebMvcTest를 사용한다.

스프링 시큐리티 설정은 해결했지만, @WebMvcTest가 CustomOAuth2UserService를 스캔하지 못해 문제가 발생한 것이다.

@WebMvcTest는 @ControllerAdvice, @Controller를 읽지만,
@Repository, @Service, @Component는 스캔 대상이 아니다.

즉, SecurityConfig는 읽었지만, SecurityConfig를 생성하기 위해 필요한 CustomOAuth2UserService를 읽을 수 없어 에러가 발생한 것이다.


이를 해결하기 위해 스캔 대상에서 SecurityConfig를 제거하자.

@WebMvcTest(controllers = HelloController.class,
    excludeFilters = {
            @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE,
            classes = SecurityConfig.class)
    }
)

더불어, @WithMockUser를 사용하여 가짜로 인증된 사용자를 생성한다.
@WithMockUser(roles = "USER")를 각 테스트 코드 위에 추가한다


JPA metamodel must not be empty!

마지막으로 이 오류를 해결하고 마무리하자!

이 에러는 @EnableJpaAuditing으로 인해 발생한 오류이다.
@EnableJpaAuditing을 사용하기 위해서는 최소 하나의 @Entity가 필요하지만,@WebMvcTest에는 없다 !!

@EnableJpaAuditing이 @SpringBootApplication과 함께 있어 @WebMvcTest에서도 스캔하게 된 것이다.

그러므로 @EnableJpaAuditing과 @SpringBootApplication을 분리하자 !

DemoApplication에서 @EnableJpaAuditing을 지우고,
config 패키지에 JpaConfig를 생성하여 @EnableJpaAuditing를 추가한다.

package com.example.demo.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;

@Configuration
@EnableJpaAuditing
public class JpaConfig {
}

모든 테스트 성공 ~!

profile
[~2023.04] 블로그 이전했습니다 ㅎㅎ https://leeeeeyeon-dev.tistory.com/
post-custom-banner

0개의 댓글