세션은 어느 계층에서 처리해야 할까?
세션을 통해 사용자 관련 로그인, 로그아웃 기능을 구현하고 있을 때 현재 어떤 문제가 있는지에 대해서 살펴보고, 리팩토링해보자!
리팩토링 전 코드
먼저 리팩토링을 하기 전에 로그인은 어떻게 동작하고 있는지에 대해서 살펴보자.
@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
그리고 MemberController에서 빈으로 등록한 SessionManager 객체를 주입받아 login 메서드처럼 사용하면 기존에 login 메서드에서 매개변수로 넘겨받던 HttpSession을 굳이 더 이상 전달받아 활용할 필요가 없어졌다.
@SessionMemberId
@CurrentUser 어노테이션을 작성해서 Controller에서 사용하는 @RequestBody, @PathVariable와 같은 어노테이션으로 동작하도록 만들 예정이다. 위 어노테이션이 매개변수일 경우, memberId를 탐색하는 resolver가 실행되도록 실행 지점을 지정했다.
SessionMemberIdResolver.class
그러면 @RequestBody, @PathVariable와 같은 어노테이션들은 어떻게 동작할까? 이는 HandlerMethodArgumentResolver 인터페이스를 통해 매개변수로 사용되는 인자에 대해 공통적으로 처리해야할 로직 등이 있을 경우, 중복 코드를 줄이고 공통 기능을 추출하여 사용할 수 있다.
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 적용
이전 코드와 달리 SessionManager를 생성자 주입을 통해 주입받지 않고, 해당 객체의 메서드를 통해 반복되는 로직을 작성할 필요없이 @SessionMemberId 어노테이션을 통해서 사용자의 id를 바인딩하여 사용할 수 있도록 리팩토링했다.
출처
[Spring] Resolver란? Resolver 구현하기(HandlerMethodArgumentResolver)
'Java > 트러블 슈팅' 카테고리의 다른 글
Blue/Green 방식으로 서비스 중단없이 배포하기 (1) | 2023.12.17 |
---|---|
Jenkins으로 CI/CD 구축하기 (0) | 2023.12.09 |
Redis로 Session Store 적용하기 (1) | 2023.11.27 |
사용자 인증 방식에 대한 고찰 : JWT vs Session (3) | 2023.11.25 |
테스트 커버리지 확인을 위한 jacoco 설정 (1) | 2023.11.23 |