NDM

확장성을 고려한 로그인 세션 관리하기 본문

[프로젝트] 타임딜 API 서버

확장성을 고려한 로그인 세션 관리하기

ndm.jr 2023. 2. 27. 15:40

넘블 타임딜 챌린지에는 다음과 같은 요구사항이 명시되어 있습니다.

 

  • 로그인 관리는 세션으로만 하며, Spring Security나 JWT의 사용은 제한한다
  • WAS 서버는 1대로만 제한한다.

 

아마 이번 챌린지의 주된 목표는 재고의 동시성 처리와 상품조회에 있다고 생각해 다음과 같은 제약사항을 걸어두신 것으로 예상합니다. 구현하는데 시간이 더 걸리고, 인프라 구성 비용도 늘어나기 때문입니다.

 

HttpServletRequest와 HttpSession을 이용해 손쉽게 구현할 수 있었지만, 다음과 같은 상황을 고려해보기로 했습니다.

이왕 참가한 만큼 무언가를 더 얻어가고 싶었기 때문입니다.

 

  • WAS가 1대로 제한되어있으나, 이후 확장 가능성까지 고려하지 말라는 말은 없었다. 이후 확장 가능성을 고려한다면 어떻게 세션을 관리하는 것이 좋을까?

 


# 문제점

 

HttpSession을 이용해 세션을 로컬에서 관리하게 된다면 다음과 같은 문제점이 있습니다.

 

WAS서버가 Scale-out 될 때, 사용자의 지속 로그인을 보장할 수 없게 됩니다.

 

 

  • 첫번째 요청에서 1번서버로 로드밸런싱 된 사용자의 정보는 1번 서버에서 관리하게 됩니다.
  • 다음 요청에서 로드밸런서가 1번이 아닌 다른 서버로 로드밸런싱 하게 되면, 사용자의 세션 정보는 1번 서버에서 관리하고 있기에 로그인이 풀리는 현상이 일어나게 되고, 이는 서비스 품질 저하로 이어지게 됩니다.

# 해결방법 ?

 

Sticky Session

 

첫번째 요청에서 세션을 저장한 서버로 이후 요청을 고정하는 방식입니다.

 

다만 특정 서버에 과부하가 될 수 있어 로드밸런싱의 성능을 떨어뜨린다는 단점이 있고,

특정 서버가 Fail될 경우 모든 세션정보는 소실됩니다.

 

 

Session Clustering

 

 

 

하나의 서버가 갖고있는 세션 정보를 모든 서버들이 공유하는 방식입니다.

 

 

Session 분리

 


 

# 나는 어떻게 해결했나

 

이번 프로젝트에서 세션은 Redis라는 외부 저장소를 사용해 별도로 관리하는 방법을 택했습니다.

사용자 정보라는 특성상 read가 많고, 캐시 특성상 일정 시간이 되면 풀려버리는 것 까지 로그인 관련 기능에 알맞은 데이터 저장소라고 판단하였습니다.

 

SpringBoot에서는 이러한 Redis로의 세션 관리를 위한 여러 도구를 지원합니다.

 

@EnableCaching
@EnableRedisHttpSession
@SpringBootApplication
public class TimedealApplication {

    public static void main(String[] args) {
        SpringApplication.run(TimedealApplication.class, args);
    }

}

 

먼저 @EnableRedisHttpSession 어노테이션을 사용하겠습니다.

HttpSession을 담아둘 저장소로 Redis를 사용하겠다. 이런 뜻입니다.

 

다음은 로그인 기능을 간단하게 작성하겠습니다. 제 경우는 Spring Security를 사용하지 않고

정말 간단하게 아이디랑 비밀번호로만 로그인한다.. 까지만 구현했습니다.

@Service
@RequiredArgsConstructor
public class SessionLoginService implements LoginService {

    public static final String USER_SESSION_KEY = "USER_ID";
    private final UserRepository userRepository;
    private final HttpSession httpSession;

    @Override
    public UserSelectResponse logIn(UserLoginRequest request) {

        User user = userRepository.findByUserNameAndPassword(request.getUsername(), request.getPassword())
                .orElseThrow(() -> new BusinessException(ErrorCode.LOG_IN_FAILURE));

        httpSession.setAttribute(USER_SESSION_KEY, AuthUser.of(user));

        return UserSelectResponse.of(getCurrentUser());
    }

    @Override
    public void logOut() {
        httpSession.invalidate();
    }

    @Override
    public AuthUser getCurrentUser() {
        return (AuthUser) httpSession.getAttribute(USER_SESSION_KEY);
    }
}

 

LoginCheck 기능은 AOP로 해결하겠습니다. 관리자 / 사용자 정도만 구현하겠습니다.

@Before("@annotation(com.example.timedeal.common.annotation.LoginCheck) && @annotation(target)")
public void LoginCheck(LoginCheck target) {
    AuthUser currentUser = loginService.getCurrentUser();

    if(StringUtils.equals(target.role().name(), "GENERAL")) {
        checkLogin(currentUser);
    }

    else if(StringUtils.equals(target.role().name(), "ADMINISTRATOR")) {
        checkAdmin(currentUser);
    }

}

 

여기까지 왔으면 

 

처음 : 로그인 -> 세션 정보 저장

이후 : api 요청 -> 로그인 정보 확인

 

까지는 완료되었습니다. 마지막으로 로그인 되어있을 때, 해당 유저 정보를 가져오겠습니다.

저는 @CurrentUser 어노테이션을 하나 생성하고, 컨트롤러 메서드 파라미터로 사용해 정보를 가져올 생각입니다.

 

저는 HandlerMethodArgumentResolver를 확장해 해결하였습니다.

 

@Component
@RequiredArgsConstructor
public class CurrentUserResolver implements HandlerMethodArgumentResolver {

    private final LoginService loginService;
    private final UserService userService;

    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        return parameter.hasParameterAnnotation(CurrentUser.class);
    }

    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {

        AuthUser currentUser = loginService.getCurrentUser();

        if(currentUser == null) {
            throw new BusinessException(ErrorCode.LOG_IN_ESSENTIAL);
        }

        return userService.findUser(currentUser.getId());
    }
}

 

CurrentUser 어노테이션을 설정하고, 해당 어노테이션에 알맞는 동작을 작성해주었습니다.

물론 ArgumentResolver 이기 떄문에, WebMvcConfigurer 에서 설정해주어야 합니다.

 

이렇게 하면

  1. Redis에 로그인 정보가 저장되고,
  2. LoginCheck를 통해 로그인 여부를 알 수 있으며, 조건에 부합하지 못할 시 예외처리를 해줄 수 있습니다.
  3. 로그인 여부가 통과되었고 해당 엔드포인트 비즈니스 로직에서 유저 세션 정보가 필요할 때, @CurrentUser를 통해 간단히 가져올 수 있습니다.

# 출처

 

https://kchanguk.tistory.com/146

 

Sticky Session

Sticky Session이 나오게 된 배경 일반적으로 대용량 트래픽을 장애 없이 처리하기위해 여러 대의 서버에 적절히 트래픽을 분배하는 로드 밸런서를 사용합니다. 그림으로 살펴보면 아래와 같은 상

kchanguk.tistory.com

https://tlatmsrud.tistory.com/48

 

[스프링 부트] 4. Argument Resolver란 / 예제

1. 개요 - Argument Resolver 란? - 예제 2. Argument Resolver 란? - Controller로 들어온 파라미터를 가공하거나 수정 기능을 제공하는 객체이다. 교재에서는 이를 사용해 Controller로 들어온 특정 파라미터에 세

tlatmsrud.tistory.com