Spring Actuator와 Thread(+@Scheduled)
이번에 Spring Actuator를 써보면서 Thread 측면에서 잘못 알고 있었던 점을 바로 잡고 배웠던 점을 공유하려고 한다.
Actuator 설정
implementation 'org.springframework.boot:spring-boot-starter-actuator'
우선 스프링부트에서 위와 같이 의존성을 추가해 준다.
http://localhost:8080/actuator
그리고 스프링부트를 실행시키고 위의 링크를 들어가 보자
/actuator/로 접속 시 오류가 발생하니까 정확히 /actuator로 들어가자.
그럼 위와 같은 화면이 나타난다.
아직은 보이는 정보가 그렇게 많지 않을 것이다.
management.endpoints.web.exposure.include=* # 모든 endpoint에 대해 노출시킨다.
#management.endpoints.jmx.exposure.include=health,info,env,beans
server.tomcat.mbeanregistry.enabled=true # 톰캣 정보에 대한 정보를 노출시킨다.
management.endpoint.health.show-details=always # health에서 디테일 정보를 노출시킨다.
위의 설정은 actuator에서 정보를 보기위한 용도이며 실제 운영 환경에서는 보안상의 문제가 될 수 있으니 필요한 부분만 노출시켜야 한다.
톰캣의 Thread
Thread.currentThread().getName()
앞으로 아래의 코드들은 위의 명령어를 입력하면 더 직관적으로 확인이 가능하지만 내용에 전부 포함하지는 않는다.
http://localhost:8080/actuator/metrics/tomcat.threads.config.max
위 주소로 들어가서 확인해 보면 200이란 값을 확인할 수 있다.
톰캣의 기본 할당할 수 있는 스레드 개수는 200개라는 뜻이다.
그럼 톰캣은 기본적으로 스레드 개수를 200개만 할당할 수 있다.
하지만 이 200개를 전부 만들어놓고 대기 시켜놓는 것은 아니다.
일부 스레드만 대기 시켜놓는 데 이 역할을 스레드 풀이 한다.
스레드 풀 - 스레드 풀은 작업처리에 사용되는 스레드를 제한된 개수만큼 정해놓고 작업큐 (Queue)에 들어오는 작업들을 하나씩 스레드가 맡아 처리한다. 스레드들을 그룹화하여 관리함으로써 스레드 생성 및 소멸의 오버헤드를 줄이고 스레드 간의 자원 공유를 효율적으로 관리
스레드 풀 내에서 활동하는 스레드의 개수는 minSpareThreads로 결정한다.
minSpareThreads - 스레드 풀이 최소한으로 유지할 스레드 수
스프링은 사용자 1명이 클라이언트에서 요청을 하면 톰캣은 하나의 요청에 1개의 스레드를 할당해준다.
1명이 2번의 요청을 하면 당연히 2개의 스레드를 할당해준다.
일반적인 웹 애플리케이션의 동작 방식은 다음과 같다.
1. 클라이언트가 HTTP 요청을 서버로 보냅니다.톰캣(웹 서버)은 클라이언트의 요청을 받아들이고, 해당 요청을 처리할 톰캣의 스레드를 풀에서 가져옵니다.
2. 해당 스레드는 클라이언트의 요청을 처리하고, 필요한 작업을 수행합니다.
3. 만약 스프링 빈 내에서 비즈니스 로직 실행 등의 작업이 필요한 경우, 해당 스레드에서 스프링의 빈이 사용됩니다.
4. 스프링의 작업이 완료되면 결과 데이터를 톰캣의 스레드에게 반환하고, 톰캣의 스레드가 클라이언트에게 응답을 보냅니다.
server.tomcat.threads.max=200 // 최대 스레드 개수 (기본값: 200)
server.tomcat.threads.min-spare=10 // 스레드 풀이 가지고 있는 기본 스레드 개수 (기본값: 10)
http://localhost:8080/actuator/metrics/tomcat.threads.current
value가 10인 것을 확인할 수 있다.
minSpareThreads를 의미한다.
tomcat.threads.current는 현재 톰캣 스레드 풀 내에서 활성화된 (작업을 처리하거나 대기 중인) 스레드의 총개수이다.
즉, 10개 스레드가 대기 중이라고 볼 수 있다.
요청이 많아지면 이 개수는 자동으로 최대 200개까지 늘어난다고 볼 수 있다.
http://localhost:8080/actuator/metrics/tomcat.threads.busy
value가 1인 것을 확인할 수 있다.
tomcat.threads.busy는 네트워크 요청을 받아들이고 처리하는 작업을 하고 있는 상태의 스레드로 볼 수 있다.
즉, 사용자의 요청을 대기하기 위해 지속적으로 관찰하는 목적으로 실행되는 스레드다.
이 value를 강제로 늘려보자.
Tomcat의 Thread는 "http-nio-8080-exec"라는 스레드 이름을 갖는다.
/hi로 먼저 요청하면 무한 루프에 빠지게 된다.
그럼 Thread 한 개가 바쁘다라고 판단해서 value가 2가 된다.
만약에 사용자 1명당 스레드 1개만 할당해야 한다면 동시성 처리가 떨어질 것이다.
만약에 1개만 할당한다면 /hi를 요청했을 때 Thread가 Sleep에 들어갔으니 /hi2를 요청하면 응답이 안 올 것이다.
그런데 응답이 온다.
그렇다는 말은 /hi2를 요청했을 때 내가 요청한 쓰레드 풀 내에 스레드는 여러 개일 수 있다는 뜻이다.
번외로 @PostConstruct를 사용한 예제를 보자.
PostConstruct는 "main"이라는 스레드 이름을 갖는다. 자바 어플리케이션 초기화 당시 자바 main 메소드에 의해 실행되는 스레드를 말한다.
위와 같이 쓰면 /actuator든 HTTP 요청이든 어플리케이션이 마비가 되는 현상을 겪는다.
왜일까? 그냥 @PostConstruct가 안 끝나서 @Controller와 같은 bean들이 초기화되지 않아서 그렇다.
이렇듯 스프링에서 작성한 코드들은 항상 다른 스레드다라는 점을 인식해야 한다.
JVM의 Thread
http://localhost:8080/actuator/metrics/jvm.threads.live
JVM 위에서 돌아가는 모든 thread의 개수를 확인할 수 있다.
어플리케이션을 시작했을 때는 28개가 나와있다.(이 개수는 사용자의 로컬 환경마다 매번 다르다.)
JVM Thread 개수는 여러 가지 스레드를 전부 포함한다.
Spring에서 생성한 스레드, 위에서 설명한 톰캣에서 활성화되어있는 스레드(10개), GC 스레드, NIO 스레드, AWT 스레드 등 모든 Thread 개수를 포함하는 개념이다.
그래서 시간이 지나서 쓸모없는 스레드가 유휴 상태에 들어가서 Thread 개수가 25개가 된 것을 볼 수 있다.
간단하게 이 쓰레드 개수를 한 개 늘려보자.
JVM 스레드는 톰캣 스레드가 아니라 다른 방식으로 늘려야 한다.
@Scheduled는 "scheduling-" 형식의 스레드 이름을 사용한다
스프링에서 제공하는 @Scheduled를 사용하면 ThreadPoolTaskScheduler를 이용하게 되는데 해당 클래스에서는 자체적으로 스레드를 1개 생성해서 사용하므로 어플리케이션을 다시 실행했을 때 스레드 개수가 28개->29개로 늘어난 것을 볼 수 있다.
spring.task.scheduling.pool.size=2
위와 같이 2로 설정하면 스레드 개수가 28개->30개인 것을 확인할 수 있다.
@Scheduled는 자체적인 JVM Thread Pool을 생성해서 사용한다.
이 말은 Tomcat에 대한 요청이 아니기 때문에 여기서 위의 Tomcat 예제에서 만든 무한 루프를 @Scheduled 내에 걸어줘도 tomcat의 busy 영역의 Thread 개수는 증가하지 않는다.
다시 말하지만 중요한 것은 Spring 내에 사용되는 쓰레드는 다 종류가 다를 수도 있다라는 점이다.
이번엔 @Scheduled에 @Async를 걸어보자.
@Async는 비동기 함수에 사용되는 어노테이션이다.
@Scheduled(fixedDelay = 3000)
public void test() throws InterruptedException {
System.out.println("Scheduled async task is running on thread: " + Thread.currentThread().getName());
}
@Async
@Scheduled(fixedDelay = 3000) // 3초마다 실행
public void test2() throws InterruptedException {
System.out.println("Scheduled async task is running on thread: " + Thread.currentThread().getName());
Thread.sleep(2000); // 비동기적으로 실행되는 메서드 내에서 작업 수행
}
위의 스레드 풀 사이즈 설정은 지운 다음에,
Spring Application에 @EnableAsync를 설정하고 위와 같이 스케줄을 돌리면 jvm의 스레드 개수가 2개 증가한다.
왜냐면 @Scheduled는 ThreadPoolTaskScheduler에서 쓰레드를 생성하고 @Async는 ThreadPoolTaskExecutor에서 쓰레드를 생성해서 사용하기 때문이다.
일단 @Scheduled는 Asyncronous한 방식으로 동작하기 때문에 굳이 @Async를 걸어줄 이유가 없다.
@Scheduled가 Thread를 Sleep 시키는 개념이라고 생각해서 @Async를 적용하는 불상사는 없어야 한다.
@Scheduled(fixedDelay = 3000)
public void test() throws InterruptedException {
System.out.println("Scheduled async task is running on thread: " + Thread.currentThread().getName());
}
@Scheduled(fixedDelay = 1000)
public void test2() throws InterruptedException {
System.out.println("Scheduled async task is running on thread: " + Thread.currentThread().getName());
Thread.sleep(11111000);
}
만약에 두개의 스케줄 코드가 존재하는데 하나의 스케줄에서 Thread를 Sleep 시켜버리면 다른 스케줄도 당연히 동작하지 않는다. ThreadPoolTaskScheduler에서 생성한 스레드 1개가 잠겨버리는 상황이 발생하기 때문이다.
@Async
@Scheduled(fixedDelay = 3000)
public void test() throws InterruptedException {
System.out.println("Scheduled async task is running on thread1: " + Thread.currentThread().getName());
}
@Async
@Scheduled(fixedDelay = 1000)
public void test2() throws InterruptedException {
System.out.println("Scheduled async task is running on thread2: " + Thread.currentThread().getName());
Thread.sleep(11111000);
}
만약에 위와 같이 @Async를 통해 코드를 실행하면 @Scheduled에서 생성한 ThreadPoolTaskScheduler의 Thread가 해당 메서드를 실행하는 게 아니라 ThreadPoolTaskExectutor의 Thread가 메소드를 실행한다.
ThreadPoolTaskExectutor가 Thread를 8개 생성해서 실행시켰지만 전부 Sleep에 빠져 더 이상 진행이 되지 않는 현상을 볼 수 있다.
이 현상을 통해 알 수 있는 점이 하나있다.
@Async가 없다는 가정하에 @Scheduled만을 이용해서 스케줄링을 구현하면 배치작업을 할 때 배치 작업 1개에 1시간이 소요된다고 생각해보자.
그럼 뒤에 있는 배치 작업들은 1시간이 끝나지 않는 이상 시간이 도래하더라도 실행이 안된다라는 것이다.
그럴 땐 ThreadPoolTaskScheduler의 스레드 개수를 늘리는 것이 좋다.
어? 그냥 @Async를 넣으면 알아서 잘 돌아가는거 아니야?라고 생각할 수 있다.
하지만 @Async와 @Scheduled를 같이 이용하는 것은 좋은 관례가 아니다.
위에서 설명했듯이 Thread를 각각 2개 이상 생성한다. ThreadPoolTaskScheduler의 쓰레드를 사용하지 않는다라는 단점이 있다.
또 다른 단점으로는 비동기적으로 실행하는 코드가 끝나지 않았는데 @Scheduled의 시간이 도래한 경우에 다시 메서드가 실행된다면 같은 메소드가 동시에 2번 돌아가는 모양이 나올 수도 있다. 이는 원치 않는 이슈가 나타날 수도 있다.
배치작업이 아니라 1분마다 한번씩 실행되는 경우라면 @Scheduled를 쓰는 것보다 ThreadPoolTaskScheduler 클래스를 직접 구현하는 것이 좋다. @Scheduled 자체가 리플렉션 코드라서 자주 실행된다면 성능에 영향을 미치기 때문이다.
@Bean
public ThreadPoolTaskScheduler taskScheduler() {
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
scheduler.setPoolSize(5); // 스레드 풀 크기 설정
scheduler.setThreadNamePrefix("my-scheduler-thread-");
return scheduler;
}
ThreadPoolTaskScheduler 자체가 Bean이 아니기 때문에 Bean으로 설정해서 개발하면 된다.
비동기 코드라 민감하다보니 위의 코드가 new로 scheduler를 매번 새로 생성하는 것 아닌가 했는데 어차피 @Bean은 싱글톤 패턴이라 항상 같은 scheduler 객체가 생성된다.
진짜 두서가 없었던 글인 듯 하지만 직접 한번 경험해 봐서 스프링의 스레드 개념을 어느 정도 잡을 수 있었다.