🔫
로그인을 구현하며 겪은 문제점과 해결방법, 새로 알게된 점을 기록합니다.
🔑 Keyword: session, filter, ThreadLocal, interceptor
일정 관리 API 에서 Cookie 또는 Session 을 활용해 로그인 기능을 구현하고 필터를 활용해 인증처리를 구현하도록 설계
로그인 API를 구현하여 일치하는 유저가 존재할 경우, 세션에 유저 식별자 id를 저장
@PostMapping("/login")
public ResponseEntity<Void> login(@RequestBody LoginRequestDto dto, HttpServletRequest request) {
MemberResponseDto responseDto = loginService.login(dto.getEmail(), dto.getPassword());
HttpSession session = request.getSession();
session.setAttribute(Const.SESSION_KEY, responseDto.getId());
return ResponseEntity.ok().build();
}
로그인 필터를 구현하여 세션에 유저 식별자 id가 들어있는 지 검증 → 각각의 요청 메서드에서 인증 로직이 필요 없음
if(!isWhiteList(requestURI)) {
HttpSession session = httpRequest.getSession(false);
if(session == null || session.getAttribute(Const.SESSION_KEY) == null) {
throw new RuntimeException("로그인이 필요한 요청입니다.");
}
log.info("로그인 성공");
}
세션에 저장된 세션 키(member_id) 값을 비즈니스 로직에서 활용할 필요가 있음 👉 인증(Authentication)
로그인 기능을 구현하며 기존 일정 CRUD, 유저 CRUD 의 수정이 필요함
🤔 문제점
직접 세션 정보를 조회하는 방법은 세션이 필요한 비즈니스 로직마다 HttpServletRequest를 인자로 받아서 처리 시 번거롭고 코드 중복이 발생
세션에 들어있는 유저 식별자 id를 전역적으로 사용할 수 있는 방법 검토
ThreadLocal을 활용하여 id 값을 저장하고 가져올 수 있는 객체 생성public class MemberContext {
private static ThreadLocal<Long> memberIdThreadLocal = new ThreadLocal<>();
public static void setMemberId(Long memberId) {
memberIdThreadLocal.set(memberId);
}
public static Long getMemberId() {
return memberIdThreadLocal.get();
}
public static void clear() {
memberIdThreadLocal.remove();
}
}
✏️
ThreadLocal
멀티스레딩 환경에서 각 스레드가 독립적인 값을 가지도록 할 수 있는 Java 클래스
여러 스레드가 동시에 실행될 때, thread-safe 한 값을 관리할 수 있음
= 각 스레드에 고유한 값을 저장하고, 다른 스레드는 그 값을 공유하지 않도록 보장
⚠️ 메모리 누수 문제: 스레드가 종료되거나 해당 스레드가 더 이상 사용되지 않아도 계속해서 메모리에 남을 수 있다. 이를 방지하려면remove()메서드를 호출하여 명시적으로 값을 제거해야 한다.
▶︎ 기존 코드
public List<ScheduleResponseDto> findAll() {
List<Schedule> schedules = scheduleRepository.findAll();
List<ScheduleResponseDto> scheduleResponseDtos = new ArrayList<>();
for (Schedule schedule : schedules) {
scheduleResponseDtos.add(new ScheduleResponseDto(schedule.getId(), schedule.getMember().getUsername(), schedule.getTitle(), schedule.getContents()));
}
return scheduleResponseDtos;
}
▶︎ 수정 코드
public List<ScheduleResponseDto> findAll() {
List<Schedule> schedules = scheduleRepository.findAllByMember_Id(MemberContext.getMemberId());
List<ScheduleResponseDto> scheduleResponseDtos = new ArrayList<>();
for (Schedule schedule : schedules) {
scheduleResponseDtos.add(new ScheduleResponseDto(schedule.getId(), schedule.getMember().getUsername(), schedule.getTitle(), schedule.getContents()));
}
return scheduleResponseDtos;
}
현재 로그인한 유저의 일정 목록을 조회하도록 로직 수정
사용자는 requestBody에 별도로 memberId 를 따로 전달할 필요 없이 현재 로그인 된 유저의 id 기준으로 조회한다.
ThreadLocal 사용으로 인한 메모리 누수가 아직 해결되지 않음 👉 스레드가 종료되는 시점에 명시적으로 remove() 메서드 호출이 필요
Interceptor📚 요청(Request)와 응답(Response)를 처리하는 과정에서 특정 작업을 가로채서 추가적인 로직을 수행할 수 있게 해주는 기능
✨ 주요 기능
1. 요청 전후 처리: 클라이언트 요청이 Controller 에 도달하기 전에, 또는 응답이 클라이언트로 전송되기 전에 추가 작업을 처리할 수 있다.
2. 전역 처리: 특정 로직을 애플리케이션의 여러 부분에서 중복 없이 적용할 수 있다.
3. 공통 작업 처리: 로그 기록, 인증/권한 검사, 트랜잭션 관리, 성능 측정 등 공통적인 작업을 처리할 때 유용하다.
→ AOP와 유사(공통적 관심사 처리, 코드 유지보수성 향상)
Spring에서 제공하는 interceptor 인터페이스
사용하려면, WebMvcConfigurer 를 구현하여 설정파일에 등록해야 한다.
public interface HandlerInterceptor {
default boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
return true;
}
default void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, @Nullable ModelAndView modelAndView) throws Exception {
}
default void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, @Nullable Exception ex) throws Exception {
}
}
preHandle(): 요청이 컨트롤러 핸들러 메서드에 도달하기 전에 실행. 주로 인증/권한 검사를 하거나 요청 전처리를 할 때 사용postHandle(): 핸들러 메서드 실행 후, 뷰가 렌더링 되기 전에 실행. 주로 응답 후처리를 하거나 모델 데이터를 수정할 때 사용afterCompletion(): 요청과 응답이 완전히 처리된 후, 즉 뷰 렌더링이 끝난 후에 실행. 주로 리소스 정리나 로그 기록 등 후속 작업을 처리할 때 사용
@Slf4j
public class SessionInterceptor implements HandlerInterceptor {
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
HandlerInterceptor.super.afterCompletion(request, response, handler, ex);
log.info("afterCompletion memberContext: {}", MemberContext.getMemberId());
MemberContext.clear();
}
}
👉 HandlerInterception 를 구현하여 afterCompletion() 에서 리소스 정리 필요(remove())
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new SessionInterceptor())
.addPathPatterns("/**") // 모든 경로에 적용
.excludePathPatterns("/api/v1/members/signup", "/api/v1/login"); // 특정 경로 제외
}
}
로그인을 통한 세션 키 등록과 등록된 세션 키를 비즈니스 로직에서 전역으로 사용하기 위해 ThreadLocal 과 interceptor 를 사용하여 구현하였다.
ThreadLocal은 웹 애플리케이션과 같은 멀티스레딩 환경에서 스레드마다 독립적인 데이터를 관리할 수 있게 해준다.
interceptor는 애플리케이션의 요청과 응답 흐름을 제어할 수 있는 도구로 인증, 로깅, 트랜잭션 처리 등 공통적인 작업을 하는데 유용하다.
튜터님의 코드리뷰를 통해 ThreadLocal 과 interceptor 를 사용한 방법 대신 전역으로 세션값을 저장하고 사용하는 방법을 알게 되었다.
Bean Scope를 지정한 수동 Bean을 등록하고 HttpSession 대신 사용하는 방법이다.
Spring 컨테이너가 관리하는 Bean의 생명주기와 범위를 정의
각 스코프는 Bean이 생성되는 시점과 소멸되는 시점을 다르게 하여, 특정 상황에서 Bean을 효율적으로 관리할 수 있도록 한다.
Singleton (@Scope("singleton"))
서비스, 데이터베이스 연결 등 애플리케이션 전역에서 하나만 존재하는 인스턴스가 필요할 때 사용
Prototype (@Scope("prototype"))
사용자 요청마다 새로운 객체가 필요한 경우, 예를 들어 객체가 매우 독립적이거나 상태를 가진 경우 사용
Request (@Scope("request"))
HTTP 요청마다 새로운 객체가 필요한 경우, 웹 애플리케이션에서 HTTP 요청에 대한 특정 데이터를 처리할 때 사용
Session (@Scope("session"))
사용자 세션마다 새로운 객체가 필요한 경우, 사용자가 로그인하여 세션을 유지하는 동안 특정 상태를 유지하려면 이 범위를 사용
Application (@Scope("application"))
애플리케이션 전체에서 공유되어야 하는 객체를 사용할 경우, 예를 들어 애플리케이션 설정이나 환경 변수 등을 관리할 때 사용
Websocket Scope (@Scope("websocket"))
WebSocket을 사용하는 애플리케이션에서 각 WebSocket 연결마다 하나의 빈을 생성. WebSocket 연결이 유지되는 동안 인스턴스가 살아있고, 연결이 종료되면 인스턴스도 소멸된다.
@SessionAttribute 에 대해서 학습해보자