Recording/멋쟁이사자처럼 BE 13기

[멋쟁이사자처럼 부트캠프 TIL 회고] BE 13기_56일차_"ThreadLocal, Spring Security"

LEFT 2025. 2. 27. 18:15

🦁멋쟁이사자처럼 백엔드 부트캠프 13기 🦁
TIL 회고 - [56]일차

🚀56차에는 Thread, ThreadLocal과 함께 Filter의 동작흐름에 대해 학습하고 Spring Security 설정에 대해서도 학습하였다.

학습 목표 : Filter에 대한 전반적인 이해와 Spring Security를 사용하는 이유 공부

학습 과정 : 회고를 통해 작성


Filter

 

  • Filter는 Bean으로 등록해주어야하므로 @Component로 등록
  • 필터의 설정은 복잡해질 경우 application.yml 설정 이외로
    @Configuration 클래스를 작성해주어도 설정 적용이 가능

Builder

  • 빌더 사용의 목적
    ➡️ 메소드 체이닝을 통해 객체 생성을 편리하게 도와줌
    ➡️new 로 초기화해서 생성하는 것이 아닌 Builder를 통해서 객체 생성

Thread

  •  애플리케이션 안에서의 하나의 작업 흐름
  • Spring Boot Web Application에서는 하나의 요청을 하나의 스레드가 처리하게된다.
    (= 클라이언트로부터 HTTP요청이 들어오면, 서블릿 컨테이너는 쓰레드 풀에서 쓰레드를 할당하여 요청을 처리)

  • Thread를 너무 많이 설정하면 메모리가 부족하거나 성능이 더 나빠질 수 있음
  • 쓰레드가 생성되는 시간이 있어 서버 실행 후 요청이 많을 경우 처음 요청의 처리가 늦어질 수 있음
  • ex. 최소 쓰레드 수가 20일 경우 갑자기 요청이 많아져 쓰레드를 MAX수만큼 증가하면서
    처리가 늦어질 수 있기때문에 최대쓰레드 수와 최소 쓰레드 수를 같은 수로 저장하는 것이 좋음
@Slf4j
@RestController
public class UserController {
    @GetMapping("/hello")
    public String hello(){
        log.info("UserController hello() 실행!" + Thread.currentThread().getName())
        return "hello";
    }
    ...
}
  • currentThread() : 현재 스레드가 어떤 것인지 가져올 수 있음
  • .getName() : 현재 사용중인 스레드의 이름을 가져옴

 

Spring Boot Web Application

  • 기본적으로 내장된 서블릿 컨테이너 (ex. Tomcat, Jetty, Undertow)를 사용해 요청을 처리
  • 각 요청은 해당 컨테이너의 쓰레드 풀 (Thread pool)에 의해 처리
  • Spring Boot에서 기본적으로 사용하는 내장 Tomcat을 사용

 

내장 Tomcat에서 Thread 설정

  • maxThreads : 최대 쓰레드 수는 200 (= 동시 요청을 처리할 수 있는 최대 쓰레드 수)
    ➡️동시에 요청할 수 있는 최대 스레드 수

  • minSpareThreads : 최소 여유 쓰레드 수는 10 (= 초기화시 생성되는 기본 쓰레드 수)
    ➡️항상 유지할 수 있는 최소 대기 스레드 수
server:
  tomcat:
    max-threads: 2
    min-spare-threads: 2
  • max-threads와 min-spare-threads를 설정 ( application.yml)
  • 서버의 성능에 따라 최대 쓰레드 수는 200개를 넘을 수도 있다.

▶️실습 - 각 필터들에 스레드 추가

@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
    log.info("JunFilter doFilter() 실행 전" + Thread.currentThread().getName());
    filterChain.doFilter(servletRequest, servletResponse);
    log.info("JunFilter doFilter() 실행 후" + Thread.currentThread().getName());
}

  • 실행 후 localhost:8080/test/hi 에 접속하면 이처럼 각 요청마다 다른 스레드를 가지는 것을 확인

 

FilterConfig - 필터 설정 파일

@Configuration
public class FilterConfig{
    @Bean
    public FilterRegistrationBean<JunFilter> junFilter(){
        FilterRegistrationBean<JunFilter> registrationBean = new FilterRegistrationBean<>();
        registrationBean.setFilter(new JunFilter());
        registrationBean.addUrlPatterns("/test/*");

        registrationBean.setOrder(1);
        return registrationBean;
    }
}
  • registrationBean.addUrlPatterns("/test/*");
    ➡️URL에서 /test 밑에 필터가 요청되게끔 지정할 수 있다.
    ➡️특정 URL을 적어주면 그 URL에 대해서만 필터가 요청되게끔 설정가능

  • 실행 흐름
스레드 1: http-nio-8080-exec-1
------------------------------------------------
[JunFilter] -> [UserFilter] -> [UserFilter] -> [JunFilter]
------------------------------------------------

스레드 2: http-nio-8080-exec-2
------------------------------------------------
[UserFilter] -> [UserFilter]
------------------------------------------------
  • 두 개의 스레드가 존재하며, UserFilter가 두 번 실행되는 스레드가 따로 있음

 

만약 UrlPatterns 를 변경한다면

registrationBean.addUrlPatterns("/test/*"); 가 아닌

registrationBean.addUrlPatterns("/*"); 로 접속해서 테스트해보면

 

  • 실행 흐름
스레드 1: http-nio-8080-exec-1
------------------------------------------------
[JunFilter] -> [UserFilter] -> [UserFilter] -> [JunFilter]
------------------------------------------------
  • 모든 필터가 같은 스레드에서 실행
  • /test/hi 요청과 다르게 새로운 스레드가 생성되지 않음

  • 이처럼 URL 패턴을 "/*"로 설정하면 모든 요청이 하나의 스레드에서 처리
    ex. http-nio-8080-exec-1

ThreadLocal

  • 하나의 쓰레드에서만 접근 가능한 변수를 쉽게 사용할 수 있도록 변수를 관리하는 클래스
  • ThreadLocal 클래스 : 각 쓰레드가 고유한 값을 가질 수 있고, 다른 스레드와 공유되지 않도록 함
  • ThreadLocal에 값을 저장을 해놓으면 하나의 스레드가 동작하면서 값이 필요할때
    요청하면 바로 반환해줄 수 있도록 함
    (=따라서 쓰레드가 해당 변수를 읽거나 쓸때, ThreadLocal은 그 쓰레드만을 위한 고유한 인스턴스를 반환)

  • 하나의 요청(=스레드)에 대해서만 값이 저장되는 것

  • ThreadLocal을 사용하면 특정 스레드가 동작하는 동안 값(ex. 사용자 정보, 트랜잭션 정보 등)을 유지 가능
  • 요청이 끝나면 반드시 remove()를 호출하여 메모리 누수를 방지해야함

  • ThreadLocal 사용의 장점
    - 쓰레드 별로 데이터를 관리하기가 편하다는 것
    - 각 쓰레드가 독립적으로 ThreadLocal 변수를 갖기때문에 동시성 문제를 피함
    - 인증된 사용자 정보를 요청의 처음부터 끝까지 유지 가능
    - 전역 변수를 사용하지 않고도 요청 스코프 데이터를 저장할 수 있게되어 코드간결화

1) ThreadLocal을 사용하지 않은 경우 

[Thread-1] ───▶ [공유 변수: userId = 1] ◀─── [Thread-2]

Thread-1이 userId를 1로 설정했을때, Thread-2 또한 userId를 수정하고자하면 데이터가 덮어씌워지게됨

 

2) ThreadLocal을 사용한 경우

[Thread-1] ───▶ [ThreadLocal(userId) = 1]
[Thread-2] ───▶ [ThreadLocal(userId) = 2]
  • 각 스레드가 고유한 값을 가짐 (다른 스레드의 값에 영향을 받지 않음)

반드시 remove() 호출

try {
    threadLocalValue.set(123);
    System.out.println("ThreadLocal 값: " + threadLocalValue.get());
} finally {
    threadLocalValue.remove(); // 메모리 누수 방지
}
  • ThreadLocal은 스레드가 종료되지 않는 한 값이 유지되므로, 제거하지 않으면 메모리 누수가 발생할 수 있음

Spring에서 ThreadLocal을 활용

- 사용자 인증 정보 저장 : 로그인한 사용자 정보를 요청별로 유지
- 트랜잭션 관리 : 같은 요청 내에서 동일한 트랜잭션을 유지
- 로깅(로그 추적 ID 관리) : 요청마다 고유한 ID를 부여하여 추적


▶️실습 - ThreadLocal에서 사용자 인증 정보 저장 흐름

  • ThreadLocal을 활용하면 현재 로그인한 사용자 정보를 유지할 수 있음
  • 요청이 들어올 때 사용자 정보를 저장하고, 서비스 계층에서 가져와서 사용 후 제거

1) UserContext : 사용자 정보 저장

  • ThreadLocal을 이용하여 현재 요청(스레드)에서만 접근할 수 있는 User 객체를 저장하고 관리하는 기능
    (=User 객체를 ThreadLocal을 통해 저장하고 조회)

  • 각 요청(스레드)마다 고유한 사용자 정보를 유지 가능
public class UserContext {
    private static final ThreadLocal<String> currentUser = ThreadLocal.withInitial( () -> null);

    // 현재 로그인한 사용자 ID 설정
    public static void setUser(String userId) {
        currentUser.set(userId);
    }
    // 현재 로그인한 사용자 ID 가져오기
    public static String getUser() {
        return currentUser.get();
    }
    // 메모리 누수 방지를 위한 제거
    public static void clear() {
        currentUser.remove();
    }
}
  • ThreadLocal에는 한 번에 하나 값이 들어가고, 넣고, 꺼내는 것을 확인하는 코드
  • 스레드마다 원하는 데이터를 사용할 수 있게 지정가능
  • clear() : 작업이 끝나면 (쓰레드의 작업이 끝나면) 받았던 값을 반납하는것이 clear()메소드에서 하는 일
  • 일반적으로 사용자 인증 정보(로그인 정보)를 관리할 때 활용

  • ThreadLocal.withInitial( () -> null);
    ➡️초기값을 설정하는 람다 표현식
    만약 withInitial(() -> new User())로 설정하면, 각 스레드마다 기본 User 객체를 생성

    ➡️withInitial(() -> null)을 사용하여 기본값을 null로 설정
    (=처음에는 아무 값도 없고, 요청(스레드)이 들어오면 setUser()를 통해 값을 저장)

    정리하자면
    각 스레드는 userThreadLocal을 가지고 있지만, 기본적으로 null 상태
    이후 특정 스레드가 사용자 정보를 설정하면, 해당 스레드에서만 값이 유지

  • return currentUser.get();
    ➡️
    동일한 요청(스레드) 내
    에서는 언제든지 UserContext.getUser()를 통해 "Kaoru" 객체를 얻을 수 있음
    다른 요청(스레드)에서는 UserContext.getUser()를 호출해도 해당 값을 볼 수 없음

    필터 내부에서 UserContext.getUser()를 호출하면 "Kaoru" 객체를 반환
    컨트롤러에서 UserContext.getUser()를 호출하면 필터에서 저장한 사용자 정보("Kaoru")를 가져올 수 있음

  • currentUser.remove()
    ➡️현재 스레드에서만 User 객체가 제거 (=다른 스레드에는 영향을 주지 않음)
    즉 clear()을 반드시 호출해서 메모리 누수를 방지

2) UserFilter : 필터에서 요청이 들어오면 사용자 정보 설정

public class UserFilter implements Filter {
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        try {
            String userId = "Jun"; 

            // ThreadLocal에 사용자 정보 저장
            UserContext.setUser(userId);

            chain.doFilter(request, response);
        } finally {
            // 요청이 끝나면 반드시 제거 (메모리 누수 방지)
            UserContext.clear();
        }
    }
}
  • 요청이 들어올 때 UserContext.setUser(userId)로 사용자 정보를 저장하고
    요청이 끝나면 clear()를 호출하여 정리

  • UserContext.setUser(userId)
    ➡️현재 실행 중인 스레드에서 User 객체를 저장
    ➡️다른 스레드는 같은 userThreadLocal을 사용하더라도 각각의 고유한 User 객체

3) UserService : 사용자 정보를 사용

@Service
public class UserService {
    public String getCurrentUser() {
        return UserContext.getUser();
    }
}
  • UserContext.getUser()를 호출하면 어떠한 서비스 계층에서도 현재 요청의 사용자 정보를 가져올 수 있음

4) UserController : 사용자 정보 조회

@RestController
public class UserController {
    private final UserService userService;

    public UserController(UserService userService) {
        this.userService = userService;
    }
    @GetMapping("/loginUser")
    public String getCurrentUser() {
        return "현재 로그인한 사용자: " + userService.getCurrentUser();
    }
}

❓요청마다 다른 스레드가 불러와지는 것인지

➡️사용자의 요청이 들어올 때마다 서버의 스레드 풀에서 사용 가능한 스레드를 하나 꺼내어 요청을 처리하게됨
각 요청은 새로운 스레드에서 실행될 수도 있고, 기존에 사용했던 스레드 재사용도 가능

정리하자면
요청이 많은 경우 여러 개의 스레드가 동작
요청이 적으면
기존 스레드를 재사용


▶️실습 - /hello 요청의 동작 흐름

[요청 시작] localhost:8080/hello
---------------------------------------------
[UserFilter] - UserContext.setUser(new User("Kaoru")) 실행
---------------------------------------------
[UserController] - UserContext.getUser() 실행 → "Kaoru" 반환
---------------------------------------------
[응답 반환] "helloKaoru"
[UserFilter] - UserContext.clear() 실행 (메모리 정리)

DelegatingFilterProxy

  • Servlet Filter와 Spring의 Filter 빈을 연결해주는 역할을 하는 프록시(Proxy) 객체
  • DelegatingFilterProxy는 서블릿 컨테이너(Tomcat 등)에 등록된 필터지만,
    내부적으로 Spring의 Filter 빈을
    위임(delegate)하여 실행
    (= Spring에서 관리하는 필터(Spring Bean)를 서블릿 컨테이너에서 사용할 수 있도록 연결)
  • Spring Security가 동작하려면 반드시 필요
    (Spring Security의 FilterChainProxy가 DelegatingFilterProxy를 통해 동작하기때문)


DelegatingFilterProxy 동작 흐름

1) 클라이언트가 요청을 보냄 (/login 요청)
2) WAS(Tomcat)가 DelegatingFilterProxy를 실행
3) DelegatingFilterProxy가 Spring의 FilterChainProxy를 실행
4) FilterChainProxy가 여러 개의 Spring Security 필터를 실행
5) 최종적으로 요청이 컨트롤러(@RestController)로 전달됨

[클라이언트 요청]
	▼ 
[Tomcat(WAS)]
	▼
[DelegatingFilterProxy]
	▼
[FilterChainProxy]
	▼
[Spring Security 필터]
	▼
[컨트롤러]

Context

  • Context는 Spring이 관리하는 모든 객체(Bean)들의 환경을 담고 있는 컨테이너

DelegatingFilterProxy와 ContextLoaderListener

  • DelegatingFilterProxy : ContextLoaderListener를 통해 Spring의 Filter 빈을 찾아서 실행
    ➡️WAS가 실행하는 필터를 Spring이 관리할 수 있도록 연결
    ➡️실제 필터를 감싸는 "필터 안의 필터(프록시 필터)"

    ex. DelegatingFilterProxy가 실행되면,
    내부적으로 Spring이 관리하는 FilterChainProxy를 실행하고,
    FilterChainProxy는 다시 여러 개의 Spring Security 필터를 실행

  • ContextLoaderListener : Spring의 WebApplicationContext를 초기화하는 리스너
    (= 서블릿 컨테이너가 시작될 때 Spring 컨텍스트를 로드하고 초기화하는 역할)
    ➡️WAS가 Spring의 ApplicationContext를 자동으로 로드하도록 도와줌

 

따라서 ContextLoaderListener가 없다면 개발자가 직접 Filter를 등록해야함
(=WAS(Tomcat 등)에서 실행할 모든 필터를 직접 등록해야함)

정리하자면 이 개념들을 통해 개발자가 직접 필터를 WAS에 등록할 필요 없이,
Spring Security 같은 필터 기반 기능을 쉽게 적용할 수 있음


[WAS + DelegatingFilterProxy + Context]의 동작 흐름

[클라이언트 요청]
       ↓
[WAS (Tomcat)]
       ↓
[DelegatingFilterProxy (WAS 필터)]
       ↓
[Spring Context (ContextLoaderListener)]
       ↓
[FilterChainProxy (Spring Security 필터)]
       ↓
[인증 및 권한 검사]
       ↓
[Spring 컨트롤러 실행]

Spring Boot Security

의존성 추가

implementation 'org.springframework.boot:spring-boot-starter-security' // Spring Security 적용

  • 의존성 추가 후 localhost:8080/hello 에 접속하면 "로그인 폼"이 출력됨

  • log창의 생성된 비밀번호(매번 서버 실행시마다 다른 패스워드 발급)와 user를 입력 후 접속 성공

Spring Security 설정 실습

logging:
  level:
    org:
      springframework:
        security: TRACE
  • security: TRACE
    security의 레벨 설정 추가
    springframework 패키지 안에 있는 security의 레벨을 TRACE로 설정

  • 실행후 URL에 접속시켜보면 16개의 필터가 각 종류별로 실행되고 있는 것 확인 가능

SecurityConfig

  • 설정을 담당할 클래스
  • Security의 기본 설정, 인증을 설정하는 것
@Configuration
@EnableWebSecurity
@Slf4j
public class SecurityConfig {
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception{
        http
                .authorizeHttpRequests( auth -> auth
                    .anyRequest().authenticated()
                    // 모든 요청에 대해서 인증을 요구
                )
                .formLogin(Customizer.withDefaults());

        return http.build();
    }
}
  • @Configuration
    ➡️Spring Boot가 실행될 때 이 클래스를 읽어서 보안 설정을 적용

  • @EnableWebSecurity
    ➡️Spring Security를 활성화하는 어노테이션
    내부적으로 SecurityFilterChain을 Spring의 필터 체인(DelegatingFilterProxy)에 자동으로 등록해줌

  • SecurityFilterChain
    ➡️
    Spring Security에서 필터들을 하나의 체인으로 연결하는 객체
    여러 개의 필터를 순차적으로 실행하여 보안 로직을 적용

  • anyRequest().authenticated()
    ➡️
    모든 요청(anyRequest())에 대해 인증된 사용자만 접근 가능하도록 설정
    (=로그인하지 않으면 어떤 페이지도 접근 불가)

▶️실습 - 특정 URL에 대해서는 인증 과정없이 접속, 로그인 페이지 추가

// 2. 인증없이 접근 가능한 URL 지정 및 (.requestMatchers())
// 로그인 페이지와 로그인 성공 시 이동할 페이지 설정 (.formLogin())
http
        .authorizeHttpRequests( auth -> auth
                .requestMatchers("/hello","/loginForm").permitAll()
                .anyRequest().authenticated()
                // 모든 요청에 대해서 인증을 요구
        )
        .formLogin(formLogin -> formLogin
                .loginPage("/loginForm") 
                .defaultSuccessUrl("/success")
                .failureUrl("/fail") 
                .usernameParameter("userId") 
                .passwordParameter("password") 
        );
return http.build();
  • .requestMatchers(”/hello”, “loginForm”).permitAll()
    hello와 loginForm URL로 접속하면 인증없이 접속 가능

  • .loginPage("/loginForm") : 원하는 로그인 페이지 설정

  • .defaultSuccessUrl("/success") : 인증에 성공하면 가고싶은 페이지 설정
  • .failureUrl("/fail") : 인증에 실패하면 가고싶은 페이지 설정
  • .usernameParameter("userId") : 로그인 폼에서의 Input 상자의 ID부분과 일치해야함
  • .passwordParameter("password") : 로그인 폼에서의 Input 상자의 PASSWORD부분과 일치해야함

  • 인증되지 않은 페이지 (ex. info)같은 경우 로그인 폼이 출력되고
  • 인증된 페이지 (ex. hello, loginForm)같은 경우 로그인 폼 없이 바로 출력

🚀회고 결과 :
이번 회고에서는 인증과 인가된 페이지를 관리할 수 있는 SecurityConfig에 대해서 실습해볼 수 있었다.

- ThreadLocal 을 통한 Thread 실습
- 필터를 적용하여 인증 거치기

느낀 점 : 
확실히 Spring Security 설정 부분에서 어려움을 많이 느꼈다. 디테일한 메소드들이 많았고 설정해주기 위해 배워야할 것이 많았다. 익숙해지기 위해 노력해야할 것 같다.

향후 계획 : 

- Spring Security 메소드의 기능과 개념 공부