서론
길었던 프로젝트 막바지로 달려가면서, 아쉬운 점이 눈에 계속 밟힌다.
아무것도 모르고 시키는 것만 하던 신입시절을 지나서, 이제 어느 정도 업무에 익숙해지니
"이렇게 하면 좀 더 좋지 않았을까?" 생각이 들거나 "이렇게 했었어야 했는데..."하고 아쉬움이 드는 몇 가지를 나누어 정리하려 한다.
물론 고작 주니어 개발자의 생각이기 때문에 이 글엔 틀린 점이 많을 수도 있다.
글 제목에서도 보이듯이, 이 글은 Session은 Controller에서만 사용하자는 것이다.
대부분의 기업에서는 아마 당연하게 하고있는 것들이겠지만, 아쉽게도 내가 참가한 프로젝트에서는 그러지 못했다.
Service 영역에는 비즈니스로직과 관련된 것만 있어야 한다. 표현 계층에 의존해서는 안된다.
본론
Service 영역에서 Session에 접근하면 문제가 뭘까? 처음에는 별 문제가 될 거라고 생각 못했다. 하지만 프로젝트가 진행되면서, 여러 문제점이 생겼다.
먼저 배치프로젝트에서 문제가 발생했다. 웹 환경이 아닌, 배치 프로젝트에서 세션에 접근하면서 발생한 문제다.
또한 연계 프로세스에서, 외부 프로젝트에서 내부 프로젝트 API를 호출해야 하는 상황에서도 문제가 됐다. 인증방식과 Header로 들어오는 인터페이스가 달라 세션에 로그인 회원정보를 넣어주기 어려움이 있었다.
이 밖에도 크고 작은 여러 문제가 발생했고, 해결하기 위해 같은 서비스 로직을 중복해서 구현하는 지저분한 코드를 낳게 됐다.
아무튼 요점은 어플리케이션 로직만을 다뤄야 하는 Service 영역에서, 사용자 환경에 의존적인 Controller의 역할까지 침범해서는 안됐다는 것이다.
@어노테이션 방식으로 로그인 회원정보를 제공하자
흔히 웹프로젝트에서 로그인을 하면 Session에 회원정보를 담는다. 그리고 이 회원정보는, 사용할 일이 아주 많기 때문에 개발자들이 편하게 사용할 수 있도록 어떤 도구를 줘야 한다. 그게 어노테이션이 됐든, 유틸 함수가 됐든.
진행 중인 프로젝트에서는 유틸 클래스로 로그인한 회원정보를 제공했다.
public static Member getLoginUser() {
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest();
HttpSession session = request.getSession(false);
if (session == null) {
throw new IllegalStateException("401");
}
Object loginUser = session.getAttribute("LOGIN_USER");
if (loginUser instanceof Member member) {
return member;
}
return new Member();
}
Member loginMember = SessionUtils.getLoginUser();
이렇게 제공하다보니, Service 레이어에서 유틸 메서드를 사용하면서 위와 같은 문제점이 발생했다.
로그인한 회원을 의미하는 어노테이션을 제공해 컨트롤러에서만 세션 정보에 접근하도록 하게 할 수 있다.
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.PARAMETER)
public @interface LoginUser {
}
파라미터에서만 받아올 수 있도록 범위를 제한한다.
public class LoginUserResolver implements HandlerMethodArgumentResolver {
@Override
public boolean supportsParameter(MethodParameter parameter) {
return parameter.hasParameterAnnotation(LoginUser.class)
&& Member.class.isAssignableFrom(parameter.getParameterType());
}
@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
Object loginUser = webRequest.getAttribute("LOGIN_USER", RequestAttributes.SCOPE_SESSION);
if (loginUser instanceof Member loginMember) {
return loginMember;
}
return new Member();
}
}
@Configuration
public class WebMvcConfigure implements WebMvcConfigurer {
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
WebMvcConfigurer.super.addArgumentResolvers(resolvers);
resolvers.add(new LoginUserResolver());
}
}
로그인 할 때 세션에 담긴 회원정보를 반환하는 리졸버를 정의하고 스프링 프레임워크에 등록한다.
@PostMapping("/test")
public void test(@LoginUser Member member, @RequestBody RoomUpdateRequest request) {
RoomService.create(request.getTitle(), request.getDescription(), member);
}
로그인한 회원의 정보가 필요한 경우 컨트롤러 메서드에서 어노테이션을 이용해 받아올 수 있다. 이를 서비스 영역에 엔티티 혹은 DTO로 전달한다. 이렇게 되면 서비스로 넘기는 인자가 하나 더 늘어나 번거로울 순 있으나, 이게 좀 더 깔끔한 코드라고 생각한다.
추가로 만약 서비스 영역에서 로그인 정보를 직접 접근하고 싶을 때는, 세션이라는 웹 환경에 의존적인 하는 것보다 로컬 스레드에 회원정보를 저장해 놓고, 이를 조회하도록 하는 유틸 함수를 제공하는 것이 더 좋을 듯하다. 스프링 시큐리티에서 아래와 같이 설명하고 있다.
You shouldn't interact directly with the HttpSession for security purposes. There is simply no justification for doing so - always use the SecurityContextHolder instead.
결론
비즈니스 로직은 환경에 의존적이면 안된다. 의존하는 경우에도 인터페이스를 사용해 최대한 느슨하게 결합해야 한다.
비즈니스 로직(서비스레이어)에서 웹 환경에 종속적인 세션에 직접 접근하지 말고, 인자로 주입받아 사용하자.
혹시 필요한 경우, 차라리 AOP로 세션에서 회원정보를 꺼내 ThreadLocal에 저장하고, 서비스 영역은 ThreadLocal을 바라보자.
https://stackoverflow.com/questions/1629211/how-do-i-get-the-session-object-in-spring
'Backend > Java' 카테고리의 다른 글
공통 lib -> spring-boot-starter로 바꾸기(2) (0) | 2023.03.20 |
---|---|
공통 lib -> spring-boot-starter로 바꾸기(1) (0) | 2023.02.13 |
Spring Boot 2.4.x 이슈 (0) | 2022.07.08 |
UUID, 정말 안전할까? (2) | 2021.11.06 |
Tika로 파일 MIME 타입 검사 (0) | 2021.07.24 |