스프링 Session 과 어노테이션 기반으로 Session User 관리하기

JungWooLee·2022년 8월 10일
1

SpringBoot ToyProject

목록 보기
6/14

이어서 진행하기

1. 어노테이션 기반으로 개선하기

기존 구현된 코드중 반복되는 코드를 제거합니다

2. 카카오톡, 네이버 로그인 추가하기

기존 구글 OAuth 뿐만아니라 카카오톡, 네이버 Oauth 로그인 기능을 추가합니다


어노테이션 기반으로 개선

기존 구현된 코드중 반복되는 코드를 제거합니다

ex) IndexController 에서 세션값을 가져오는 부분

Session user = (SessionUser) httpSession.getAttribute(“user”);

index 메소드 외에 다른 컨트롤러와 메소드에서 세션값이 필요하면 그때마다 직접 세션에서 값을 가져와야합니다

이러한 반복되는 코드를 피하기 위해 메소드 인자로 세션값을 바로 받을 수 있도록 변경합니다

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

@Target

  • 이 어노테이션이 생성될 수 있는 위치를 지정합니다

  • PARAMETER 로 지정했으니 메소드의 파라미터로 선언된 객체에서만 사용할 수 있습니다

  • 이 외에도 클래스 선언문에 쓸 수 있는 TYPE등이 있습니다

@interface

  • 이 파일을 어노테이션 클래스로 지정합니다

  • LoginUser 라는 이름을 가진 어노테이션이 생성되었다고 보면 됩니다

같은 위치에 LoginUserArgumentResolver를 생성합니다

이는 HandlerMethodArgumentResolver 인터페이스를 구현한 클래스입니다

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

@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()

  • 파라미터에 전달할 객체를 생성합니다

  • 여기서는 세션에서 객체를 가져옵니다

@RequiredArgsConstructor
@Configuration
public class WebConfig implements WebMvcConfigurer {
    private final LoginUserArgumentResolver loginUserArgumentResolver;

    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(loginUserArgumentResolver);
    }
}

스프링에서 인식될 수 있도록 WebMvcConfigurer 에 추가합니다

HandlerMethodArgumentResolver 는 항상 WebMvcConfigurer 의 addArgumentResolvers()를 통해 추가해야 합니다

다른 HandlerMethodArgumentResolver가 필요하다면 같은 방식으로 추가해주면 됩니다

@RequiredArgsConstructor
@Controller
@Slf4j
public class IndexController {
    private final HttpSession httpSession;

    @GetMapping
    public String index(Model model, @LoginUser SessionUser user){
        log.info("user : "+user);
        if(user!=null){
            model.addAttribute("userName", user.getName());
        }
        return "index";
    }
}

IndexController 의 코드에서 반복되는 부분들을 모두 @LoginUser로 개선합니다

@LoginUser SessionUser user

  • 기존에 (User) httpSession.getAttribute(“user”)로 가져오던 세션 정보 값이 개선되었습니다

  • 이제는 어느 컨트롤러든 @LoginUser 만 사용하면 세션 정보를 가져올 수 있게 되었습니다

  • 로그를 통해 user 정보를 확인하기 위해 User 클래스에 @ToString 또한 추가하였습니다


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

현재 서비스는 애플리케이션이 재실행하면 로그인이 풀립니다. 이는 세션이 내장 톰캣의 메모리에 저장되기 떄문입니다

기본적으로 세션은 실행되는 WAS 의 메모리에서 저장되고 호출됩니다

메모리에 저장되다 보니 내장 톰캣처럼 애플리케이션 실행 시 실행되는 구조에선 항상 초기화됩니다

즉, 배포할 때마다 톰캣이 재시작됩니다. 이외에도 만약 2대 이상의 서버에서 서비스 하고 있다면 톰캣마다 세션 동기화 설정을 해야만 합니다.

이를 해결하는 법은 세가지가 있습니다

  1. 톰캣 세션을 사용한다
  • 일반적으로 별다른 설정을 하지 않을 때 기본적으로 선택되는 방식

  • 이렇게 될 경우 톰캣에 세션이 저장되기 때문에 2대 이상의 WAS 가 구동되는 환경에서는 톰캣들 간의 세션 공유를 위한 추가 설정이 필요합니다

  1. MySQL 과 같은 데이터베이스를 세션 저장소로 사용한다
  • 여러 WAS 간의 공용 세션을 사용할 수 있는 가장 쉬운 방법

  • 많은 설정이 필요 없지만, 결국 로그인 요청마다 DB IO가 발생하여 성능상 이슈가 발생할 수 있습니다

  • 보통 로그인 요청이 많이 없는 백오피스, 사내 시스템 용도에서 사용합니다

  1. Redis, Memcached 와 같은 메모리 DB를 세션 저장소로 사용한다
  • B2C 서비스에서 가장 많이 사용하는 방식

  • 실제 서비스로 사용하기 위해서는 Embedded Redis 와 같은 방식이 아닌 외부 메모리 서버가 필요합니다

이중 두번째 방식으로 진행합니다

이유는 설정이 간단하고 사용자가 많은 서비스가 아니며 비용절감을 위해서입니다

이후 AWS에서 이 서비스를 배포하고 운영할 때를 생각하면 레디스와 같은 메모리 DB를 사용하기는 부담스럽습니다(별도의 사용료를 지불해야 하기 때문)

사용자가 없는 단계에서는 데이터베이스로 모든 기능을 처리하는게 부담이 적습니다

※ Redis 란?

NoSQL로서 Key-Value 타입의 저장소인 레디스(Redis, Remote Dictionary Server)의 주요 특징은 아래와 같습니다.

  • 영속성을 지원하는 인메모리 데이터 저장소
  • 읽기 성능 증대를 위한 서버 측 복제를 지원
  • 쓰기 성능 증대를 위한 클라이언트 측 샤딩(Sharding) 지원
  • 다양한 서비스에서 사용되며 검증된 기술
  • 문자열, 리스트, 해시, 셋, 정렬된 셋과 같은 다양한 데이터형을 지원. 메모리 저장소임에도 불구하고 많은 데이터형을 지원하므로 다양한 기능을 구현

다만 많은 데이터를 담기에는 부담이 되며 일종의 캐시 시스템으로서 이를 개발에 활용하기에는 아직 공부가 미숙하여 이후의 과제로 남겨둡니다

세션 저장소 사용하기

build.gradle에 spring-session-jdbc 의존성 추가

application.properties 에 세션 저장소를 jdbc로 선택하도록 추가

# 세션 저장소
spring.session.store-type=jdbc

애플리케이션을 재실행하여 h2-console을 확인합니다

세션을 위한 테이블 (SPRING_SESSION, SPRING_SESSION_ATTRIBUTES)가 생성된 것을 볼 수 있습니다

JPA 로 인해 세션 테이블이 자동 생성되었기 때문에 별도로 해야할일은 없습니다

로그인 → 서비스에서 세션에 추가 → DB에 세션 테이블 자동 생성, 등록


현재는 h2 DB를 사용중이기 때문에 스프링을 재시작하면 풀리지만 이후 AWS로 배포하게 되면 AWS의 데이터베이스 서비스인 RDS 를 사용하게 되니 이때부터는 세션이 풀리지 않습니다


네이버 OAuth 설정하기

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


Callback URL은 구글에서 등록한 리디렉션 URL과 같은 역할을 합니다

login/oauth2/code/naver 로 등록합니다

등록 완료시 ClientID와 ClientSecret이 생성됩니다

해당 키값들을 application-auth.properties 에 등록합니다

네이버에서는 스프링 시큐리티를 공식 지원하지 않기에 Common-OAuth2Provider에서 해주던 값들도 입력해야합니다

여기서 중요한 점이 user-name-attribute값을 직접 설정해주어야 한다는 점입니다.
네이버는 redirect 주소로 로그인 요청을 보내게 될때에 반환하는 데이터로 다음과 같은 json 형태의 response를 던져줍니다

{
  resultcode=00, 
  message=success, 
  response={
  			id=QnW7vIukcKcXZiC8BuIzBMGNHycffZr4eGAAJ-wYlZQ,
  			profile_image=https://ssl.pstatic.net/static/pwe/address/img_profile.png, 
            email=wjddn3711@naver.com, 
  			name=이정우}
}

스프링 시큐리티에서는 하위 필드를 명시할 수 없습니다

즉, 최상위 필드들만 user-name으로 지정할 수 있습니다

하지만 네이버의 응답값 최상위 필드는 resultCode, message, response 입니다

이중 response를 user-name으로 지정하고 이후 자바코드로 response의 id를 user-name으로 바꾸어줍니다

만약 id 값을 지정하지 않는다면 response에서 response 필드를 찾을 것이고 이는 에러를 발생시킵니다!

private static OAuthAttributes ofNaver(String userNameAttributeName,
                                           Map<String,Object> attributes) {
        Map<String,Object> response = (Map<String, Object>) attributes.get("response");
        log.info("response : "+attributes);
        log.info("userNameAttribute : "+userNameAttributeName);
        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 static OAuthAttributes of(String registrationId,
                                     String userNameAttributeName,
                                     Map<String, Object> attributes){
        OAuthAttributes target = null;
        log.info("of 에서의 userNameAttribute : "+userNameAttributeName);
        switch (registrationId){
            case "naver":
                target = ofNaver("id",attributes);
                break;
            case "google":
                target = ofGoogle(userNameAttributeName, attributes);
                break;
        }
        return target;
    }

테스트

<h1>OAuth Test 중</h1>
<div class="col-md-12">
    <!--    로그인 기능 영역 -->
    <div class="row">
        <div class="col-md-6">
            {{#userName}}
                Logged in as: <span id="user">{{userName}}</span>
                <a href="/logout" class="btn btn-info active" role="button">Logout</a>
            {{/userName}}
            {{^userName}}
                <a href="/oauth2/authorization/google" class="btn btn-success active"
                   role="button">Google Login</a>
                <a href="/oauth2/authorization/naver" class="btn btn-secondary active"
                   role="button">Naver Login</a>
            {{/userName}}
        </div>
    </div>
</div>


0개의 댓글