[Spring] 일정관리 앱 Develop 트러블슈팅

Yuri·2025년 2월 10일

Spring

목록 보기
12/21

🔫 로그인을 구현하며 겪은 문제점과 해결방법, 새로 알게된 점을 기록합니다.

로그인(인증)

🔑 Keyword: session, filter, ThreadLocal, interceptor

1. 배경

일정 관리 API 에서 Cookie 또는 Session 을 활용해 로그인 기능을 구현하고 필터를 활용해 인증처리를 구현하도록 설계

  • 이메일과 비밀번호를 활용해 로그인 기능 구현
  • 회원가입, 로그인 요청은 인증 처리에서 제외

2. 발단

로그인 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)

3. 전개

로그인 기능을 구현하며 기존 일정 CRUD, 유저 CRUD 의 수정이 필요함

🤔 문제점

  1. 유저가 로그인을 했는데 requestBody로 memberId 값을 전달
    • 로그인 유저와 requestBody로 전달한 memberId 값이 다르다면? ❌
  2. 로그인한 유저 본인의 정보 외의 다른 유저가 작성한 할일(schedule) 조회 가능

일정

  1. 일정 등록 시 로그인 유저의 id 값을 작성자 id로 생성
  2. 일정은 본인(로그인한 유저)이 작성한 일정만 조회, 수정, 삭제가 가능
  3. 다른 유저의 일정을 조회, 수정, 삭제 시 "권한 없음" 에러처리

유저

  1. 일반 유저는 유저 전체 조회를 할 수 없음
  2. 본인(로그인한 유저)만 조회, 수정, 삭제 가능

4. 위기

직접 세션 정보를 조회하는 방법은 세션이 필요한 비즈니스 로직마다 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() 메서드를 호출하여 명시적으로 값을 제거해야 한다.

  • 전역으로 사용 가능한 MemberContext 를 사용하도록 기존 코드 수정

[일정 목록 조회] ScheduleService.findAll()

▶︎ 기존 코드

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 기준으로 조회한다.

5. 절정

ThreadLocal 사용으로 인한 메모리 누수가 아직 해결되지 않음 👉 스레드가 종료되는 시점에 명시적으로 remove() 메서드 호출이 필요

Interceptor

📚 요청(Request)와 응답(Response)를 처리하는 과정에서 특정 작업을 가로채서 추가적인 로직을 수행할 수 있게 해주는 기능

주요 기능

1. 요청 전후 처리: 클라이언트 요청이 Controller 에 도달하기 전에, 또는 응답이 클라이언트로 전송되기 전에 추가 작업을 처리할 수 있다.
2. 전역 처리: 특정 로직을 애플리케이션의 여러 부분에서 중복 없이 적용할 수 있다.
3. 공통 작업 처리: 로그 기록, 인증/권한 검사, 트랜잭션 관리, 성능 측정 등 공통적인 작업을 처리할 때 유용하다.

→ AOP와 유사(공통적 관심사 처리, 코드 유지보수성 향상)

HandlerInterceptor 인터페이스

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(): 요청과 응답이 완전히 처리된 후, 즉 뷰 렌더링이 끝난 후에 실행. 주로 리소스 정리나 로그 기록 등 후속 작업을 처리할 때 사용
  • Interceptor 구현
@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())

  • Interceptor 등록
@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"); // 특정 경로 제외
    }
}

6. 결말

로그인을 통한 세션 키 등록과 등록된 세션 키를 비즈니스 로직에서 전역으로 사용하기 위해 ThreadLocalinterceptor 를 사용하여 구현하였다.

ThreadLocal은 웹 애플리케이션과 같은 멀티스레딩 환경에서 스레드마다 독립적인 데이터를 관리할 수 있게 해준다.

interceptor는 애플리케이션의 요청과 응답 흐름을 제어할 수 있는 도구로 인증, 로깅, 트랜잭션 처리 등 공통적인 작업을 하는데 유용하다.

7. 개선

튜터님의 코드리뷰를 통해 ThreadLocal 과 interceptor 를 사용한 방법 대신 전역으로 세션값을 저장하고 사용하는 방법을 알게 되었다.

Bean Scope를 지정한 수동 Bean을 등록하고 HttpSession 대신 사용하는 방법이다.

🌱 Bean Scope

Spring 컨테이너가 관리하는 Bean의 생명주기와 범위를 정의
각 스코프는 Bean이 생성되는 시점과 소멸되는 시점을 다르게 하여, 특정 상황에서 Bean을 효율적으로 관리할 수 있도록 한다.

  • Bean Scope 종류
  1. Singleton (@Scope("singleton"))

    서비스, 데이터베이스 연결 등 애플리케이션 전역에서 하나만 존재하는 인스턴스가 필요할 때 사용

    • 기본값: Spring의 기본 스코프
    • 설명: 싱글톤 범위의 빈은 Spring 컨테이너에서 애플리케이션 시작 시 딱 한 번만 생성되고, 애플리케이션이 종료될 때 까지 동일한 인스턴스를 사용. 모든 요청은 같은 인스턴스를 반환
    • 특징: 메모리 사용량을 절약할 수 있지만, 상태가 공유되므로 상태를 관리하는 데 주의가 필요
  2. Prototype (@Scope("prototype"))

    사용자 요청마다 새로운 객체가 필요한 경우, 예를 들어 객체가 매우 독립적이거나 상태를 가진 경우 사용

    • 설명: 요청마다 새로운 인스턴스를 생성. 즉, 컨테이너에서 Bean을 요청할 때마다 새로운 객체 반환
    • 특징: 객체 생명 주기가 짧고, 자원을 많이 사용하기 때문에 성능에 영향을 줄 수 있다.
  3. Request (@Scope("request"))

    HTTP 요청마다 새로운 객체가 필요한 경우, 웹 애플리케이션에서 HTTP 요청에 대한 특정 데이터를 처리할 때 사용

    • 설명: HTTP 요청마다 하나의 인스턴스를 생성하여 요청이 끝날 때까지 해당 인스턴스를 유지합니다. 주로 웹 애플리케이션에서 사용되며, 각 HTTP 요청마다 고유한 Bean 인스턴스를 생성
    • 특징:
      • HTTP 요청 단위로 빈 인스턴스가 생성되므로, 각 요청의 상태를 독립적으로 관리할 수 있다.
      • 웹 애플리케이션에서 사용된다.
  4. Session (@Scope("session"))

    사용자 세션마다 새로운 객체가 필요한 경우, 사용자가 로그인하여 세션을 유지하는 동안 특정 상태를 유지하려면 이 범위를 사용

    • 특징:
      • HTTP 세션 단위로 빈 인스턴스가 생성되므로, 각 사용자의 세션 상태를 독립적으로 관리할 수 있다.
      • 웹 애플리케이션에서 사용된다.
  5. Application (@Scope("application"))

    애플리케이션 전체에서 공유되어야 하는 객체를 사용할 경우, 예를 들어 애플리케이션 설정이나 환경 변수 등을 관리할 때 사용

    • 특징:
      • 애플리케이션 당 하나의 인스턴스가 존재
      • 애플리케이션 전체에서 공유되는 데이터를 저장할 때 유용
  6. Websocket Scope (@Scope("websocket"))

    WebSocket을 사용하는 애플리케이션에서 각 WebSocket 연결마다 하나의 빈을 생성. WebSocket 연결이 유지되는 동안 인스턴스가 살아있고, 연결이 종료되면 인스턴스도 소멸된다.

    • 특징:
      • WebSocket 연결마다 독립적인 상태를 관리
      • WebSocket 기반의 애플리케이션에서 사용

🧑💭 개선점

  1. Bean Scope 로 사용자 Session 을 만들어서 세션값을 관리하는 방법을 적용해보자
  2. @SessionAttribute 에 대해서 학습해보자
profile
안녕하세요 :)

0개의 댓글