본문 바로가기
Java/트러블 슈팅

세션은 어느 계층에서 처리해야 할까?

by oneny 2023. 11. 30.

세션은 어느 계층에서 처리해야 할까?

세션을 통해 사용자 관련 로그인, 로그아웃 기능을 구현하고 있을 때 현재 어떤 문제가 있는지에 대해서 살펴보고, 리팩토링해보자!

 

리팩토링 전 코드

먼저 리팩토링을 하기 전에 로그인은 어떻게 동작하고 있는지에 대해서 살펴보자.

 

@MemberLoginCheck

@Target(ElementType.METHOD)
@Retention(value = RetentionPolicy.RUNTIME)
public @interface MemberLoginCheck {
}

먼저, @MemberLoginCheck 어노테이션을 생성해줬다. Controller 레이어에서 @MemberLoginCheck 어노테이션이 붙어있는 메서드는 실행 전에 로그인 검증이 일어날 수 있도록 Aspect 클래스를 작성했다.

 

AuthCheckAspect.class

@Aspect
@Component
public class AuthCheckAspect {

    @Before("@annotation(com.flab.idolu.global.annotation.MemberLoginCheck)")
    public void memberLoginCheck(JoinPoint joinPoint) {

       HttpSession session = ((ServletRequestAttributes)(RequestContextHolder.currentRequestAttributes())).getRequest()
          .getSession();

       Long memberId = SessionUtil.getLoginMemberId(session);
       if (memberId == null) {
          throw new UnauthorizedMemberException("로그인이 필요합니다.");
       }
    }
}

위 AuthCheckAspect 클래스를 빈으로 등록하고, HttpSession을 RequestContextHolder에서 꺼낸 후 미리 만들어둔 SessionUtil을 사용하여 검사를 진행한다. 만약 세션에 아이디가 없다면 UnauthorizedMemberException 런타임 예외가 발생할 수 있도록 작성했다. 그리고 위에서 작성된 코드를 보면 SessionUtil 클래스의 getLoginMemberId static 메서드를 사용하여 memberId를 가져오는 것을 확인할 수 있다.

 

SessionUtil.class

public class SessionUtil {

    private static final String LOGIN_MEMBER_ID = "LOGIN_MEMBER_ID";

    private SessionUtil() {
    }

    public static void setLoginMemberId(HttpSession session, Long id) {
       session.setAttribute(LOGIN_MEMBER_ID, id);
    }

    public static Long getLoginMemberId(HttpSession session) {
       return (Long)session.getAttribute(LOGIN_MEMBER_ID);
    }

    public static void removeLoginMemberId(HttpSession session) {
       session.removeAttribute(LOGIN_MEMBER_ID);
    }
}

SessionUtil 클래스의 의도는 set, get, remove 메서드를 해당 유틸 클래스가 인자로 HttpSession을 넘겨받아 대신 처리하여 여러 곳에서 사용할 경우에는 SessionUtil을 사용하도록 만드려는 의도였다.

 

MemberService.class

@Service
@RequiredArgsConstructor
public class MemberService {

    private final MemberRepository memberRepository;
    private final PasswordEncoder passwordEncoder;
    private final HttpSession httpSession;
    
    @Transactional(readOnly = true)
    public void login(LoginMemberDto loginMemberDto) {
       validateLoginMemberDto(loginMemberDto);

       Member member = memberRepository.findByEmail(loginMemberDto.getEmail())
          .orElseThrow(() -> new MemberNotFoundException("존재하지 않는 이메일입니다."));
       if (!passwordEncoder.matches(loginMemberDto.getPassword(), member.getPassword())) {
          throw new PasswordNotMatchException("비밀번호가 틀렸습니다.");
       }

       SessionUtil.setLoginMemberId(httpSession, member.getId());
    }
    
    // 중략 ...
}

사용자와 관련된 기능을 처리하는 비즈니스 로직이 담긴 Service 레이어이다. 여기서 문제가 어디냐고 한다면 HttpSession을 사용하는 부분이라고 할 수 있다. 이 문제에 대해서는 프레젠테이션 레이어와 비즈니스 레이어의 차이를 알면 된다. 이와 관련하여 DTO <-> Entity 어디서 변환해야 할까? 라는 게시글로부터 각 레이어 계층에 대한 특징을 작성했는데 프레젠테이션 레이어는 사용자로부터 데이터를 수집하고 화면에 정보를 표시하는 것을 주 관심사로 대표적으로 Controller가 있고, 비즈니스 레이어는 말 그래도 비즈니스 로직을 수행하는 것이 주 관심사로 대표적인 구성요소로 Service가 있다. 

 

HttpSession은 어느 영역의 기술일까? 위에서 볼 수 있듯이 HttpSession은 서블릿 스펙에 정의된 기술이다. 즉, 서블릿은 프레젠테이션 레이어를 위해서 사용하는 기술이기 때문에 서비스 레이어에서 HttpSession을 주입받아 사용하고 있는 것 보다는 프레젠테이션 레이어에서 사용하는 것이 맞다.

좀 더 얘기하자면 현재 사용자로부터 요청을 어떤 프로토콜로 받겠다 했을 때 그 프로토콜이 HTTP인 것이고, HTTP에 대한 파싱 등과 관련하여 기능을 사용하기 위해 서블릿을 사용하고 있다. 만약 컨트롤러가 아니고 다른 서비스로부터 메시지 컨슈머가 요청을 받는다면 이 또한 프레젠테이션 계층이고, 메시지 프로토콜일 것이다. 즉, 프레젠테이션 레이어의 역할은 내가 어떤 프로토콜로 받았고, 그 프로토콜에 대한 내용을 해당 레이어에서 다 처리한다는 것인데 서블릿을 프레젠테이션 레이어에서 처리하지 않고, 서비스 레이어에서 처리하는 것은 옳지 않다고 할 수 있다.

 

이 문제는 테스트에서도 들어나고 있는 것을 확인할 수 있다. 위에서 얘기했듯이 비즈니스 레이어는 비즈니스 로직을 수행하는 것이 주 관심사고, 위 테스트 내용이 순수한 비즈니스 로직에 대한 테스트라고 할 수 있을까? 세션은 서블릿 기술이기 때문에 현재 테스트 또한 순수한 비즈니스 로직에 대한 테스트도 아니고, 마지막 then도 보면 결과를 비즈니스 로직 결과가 아닌 Session 결과를 확인하는 것을 알 수 있다. 따라서 비즈니스 레이어와 프레젠테이션 레이어 서로가 독립적으로 처리할 수 있도록 리팩토링해보자.

 

리팩토링 후 코드

가장 먼저 비즈니스 레이어에 있는 HttpSession에 대한 의존성을 제거하고 Controller에서 사용할 수 있도록 리팩토링했다. 그리고 SessionUtil에서 SessionManger 클래스로 수정하여 세션을 관리하기 위한 클래스로 따로 분리해보자.

 

SessionManager.class

@Component
@RequiredArgsConstructor
public class SessionManager {

    private static final String LOGIN_MEMBER_ID = "LOGIN_MEMBER_ID";
    private final HttpSession session;

    public void setLoginMemberId(Long id) {
       session.setAttribute(LOGIN_MEMBER_ID, id);
    }

    public Long getLoginMemberId() {
       return (Long) session.getAttribute(LOGIN_MEMBER_ID);
    }

    public void removeLoginMemberId() {
       session.removeAttribute(LOGIN_MEMBER_ID);
    }
}

Spring에서 HttpSession 객체는 HttpSession을 주입해야 할 때 내부적으로 서블릿 컨테이너에게 Session을 요청하게 된다. 그러므로 Session 객체는 아래와 같은 2가지 방법으로 생성할 수 있다.

  • @Autowired나 @Inject 같은 의존성 주입으로 HttpSession을 주입받으면, 서블릿 컨테이너에게 session을 달라고 요청하지 않고, set이나 getAttribute같은 API를 호출하는 시점에 요청과 생성이 일어난다.
  • 매개변수를 통해 HttpSession을 받으면, 선언 시 session을 요청한다.

따라서 의존성 주입을 통해서 HttpSession을 주입받고, SessionManager을 빈으로 등록할 수 있도록 @Component 어노테이션을 사용했다. 

 

MemberController.class

SessionManager 적용 전
SessionManager 적용 후

그리고 MemberController에서 빈으로 등록한 SessionManager 객체를 주입받아 login 메서드처럼 사용하면 기존에 login 메서드에서 매개변수로 넘겨받던 HttpSession을 굳이 더 이상 전달받아 활용할 필요가 없어졌다.

 

@SessionMemberId

 

@CurrentUser 어노테이션을 작성해서 Controller에서 사용하는 @RequestBody, @PathVariable와 같은 어노테이션으로 동작하도록 만들 예정이다. 위 어노테이션이 매개변수일 경우, memberId를 탐색하는 resolver가 실행되도록 실행 지점을 지정했다.

 

SessionMemberIdResolver.class

그러면 @RequestBody, @PathVariable와 같은 어노테이션들은 어떻게 동작할까? 이는 HandlerMethodArgumentResolver 인터페이스를 통해 매개변수로 사용되는 인자에 대해 공통적으로 처리해야할 로직 등이 있을 경우, 중복 코드를 줄이고 공통 기능을 추출하여 사용할 수 있다. 

 

출처: 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술(인프런 김영한님 강의)

HandlerMethodArgumentResolver 인터페이스를 구현한 클래스들은 Controller의 매개변수로 지정된 변수들을 어노테이션이나 타입에 따라 resolver를 먼저 거쳐 가공된 데이터를 Controller에 넘겨주는 역할을 한다.

supportsParameter 메서드는 해당 resolver가 지원하는지 여부를 판단하는 역할을 하는데 parameter.hasParameterAnnotation(SessionMemberId.class)은 매개변수에 SessionMemberId 어노테이션이 있는지 확인한다. 그리고 Long.class.isAssignableFrom(parameter.getParameterType())은 매개변수의 타입이 Long이나 long인지 확인하여 조건을 모두 만족하면 해당 resolver가 이 매개변수를 해결할 수 있는 것으로 판단한다.

resolveArgument 메서드는 매개변수를 실제 값으로 해결하는 로직을 정의하는데 sessionManager.getLoginMemberId()을 호출하여 현재 로그인된 사용자의 id를 반환하도록 하면 이 값은 Controller 메서드의 SessionMemberId 어노테이션이 붙은 매개변수에 주입된다.

 

WebConfig.class

위에서 구현한 HandlerMethodArgumentResolver 구현체를 등록하기 위해서는 WebConfigurer를 구현하는 WebConfig 클래스를 통해서 추가할 수 있다. addArgumentResolvers 메서드를 오버라이링하여 추가할 수 있다.

 

 

@SessionMemberId 적용

@SessionMemberId 적용 전
@SessionMemberId 적용

이전 코드와 달리 SessionManager를 생성자 주입을 통해 주입받지 않고, 해당 객체의 메서드를 통해 반복되는 로직을 작성할 필요없이 @SessionMemberId 어노테이션을 통해서 사용자의 id를 바인딩하여 사용할 수 있도록 리팩토링했다.

 

 

 

 

 

출처

[프로젝트 1] 유연한 로그인 인증 방식 변경 설계

AOP를 적용해 중복되는 로그인 검증 기능 분리하기.

[Spring] Resolver란? Resolver 구현하기(HandlerMethodArgumentResolver)