SpringBoot를 학습한 지 얼마 되지 않았을 때 생긴 궁금점이었습니다. Spring은 Bean 객체를 싱글톤으로 관리하는데 어떻게 다중 요청을 처리하는지에 대하여 궁금증이 생겼습니다. Thread에 관한 지식이 있었다면 쉽게 해결 가능했습니다. Thread부터 하나씩 알아보겠습니다.


1. Thread란? 

프로세스(process) 내에서 실제로 작업을 수행하는 주체를 의미합니다. 여러 개의 Thread를 가지는 프로세스를 멀티 스레드 프로세스라고 합니다. 이해를 돕기 위해 코드로 보겠습니다.

class Example extends Thread {
    int num;
    public Example(int num) {
        this.num = num;
    }

    public void run() {
        System.out.println(this.num + " thread start.");  // 쓰레드 시작
        try {
            Thread.sleep(1000);  // 1초 대기한다.
        } catch (Exception e) {

        }
        System.out.println(this.num + " thread end.");  // 쓰레드 종료
    }
}

public class Main {

    public static void main(String[] args) {
        for (int i = 0; i < 10; i++) {  // 10개 쓰레드 실행
            Thread t = new Example(i);
            t.start();
        }
        System.out.println("main end.");  // main 메소드 종료
    }
}

 

실행 결과가 순서대로 나오길 바랐지만 뒤죽박죽으로 나온 것을 볼 수 있습니다. 따라서 순서와 상관없이 실행이 됩니다. Thread를 생성하는 비용은 많이 듭니다. Spring에서는 ThreadPool을 사용하여 Thread를 미리 만들어놓고 요청 시 사용하고 반납하는 사이클입니다.


2. 실제로 Spring에서 그렇게 진행되는가??

@RestController
public class HelloController {
    private final MemberService memberService;

    @Autowired
    public HelloController(MemberService memberService) {
        this.memberService = memberService;
    }

    @RequestMapping("/")
    public String hello(@RequestParam(name = "n") Integer n) throws InterruptedException {
        memberService.print(n);
        return "ok";
    }
}
@Slf4j
public class MemberService {

    private final MemberRepository memberRepository;

    @Autowired
    public MemberService(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }

    public void print() throws InterruptedException {
        log.info("number: {} service start", n);
        Thread.sleep(3000);
        log.info("number:{} service end", n);
    }
}
HelloController에 요청을 10번 호출하면 HelloController는 MemberService의 로직을 호출합니다. 이때 MemberService는 시작하는 log를 찍고 3초 쉰 뒤에 마치는 log를 찍습니다.  
localhost:8080? n=1
localhost:8080? n=2
localhost:8080? n=3
localhost:8080? n=4
localhost:8080? n=5
localhost:8080? n=6
localhost:8080? n=7
localhost:8080? n=8
localhost:8080? n=9
localhost:8080? n=10
호출 url입니다.

결과를 보시면 요청들은 들어온 동시에 바로 실행이 되며 3초가 지난 뒤에 끝나는 것을 볼 수 있습니다.

2022-05-13 추가

# Spring ThreadPool

SpringBoot를 사용하며, Spring-Web 디펜던시를 추가하면 내장 서블릿 컨테이너로 Tomcat을 사용하게 됩니다. 

 

Tomcat은 위에서의 다중 요청을 처리하기 위해서 Thread Pool 전략을 사용합니다. Thread Pool 전략은 Thread를 미리 만들어 놓습니다. 작업을 할 시 Thread Pool에서 가져가 사용하고, Thread 자원을 해제하는 것이 아닌 Thread Pool에 다시 반납합니다. 

 

이러한 Thread Pool을 사용하는 이유는 Thread를 생성하고 할당을 해제하는데 많은 비용이 들기 때문입니다. 따라서 미리 Thread를 만들어놓고 사용과 반납 사이클을 가집니다. 저희는 이러한 Thread Pool의 size를 application.yml 혹은 application.properties를 이용해서 설정할 수 있습니다. 

 

각 설정에 대한 설명은 주석으로 달아놨습니다. 

server:
  tomcat:
    threads:
      min-spare: 10 # 아무 작업이 없어도 활성화 되어있는 Thread 개수
      max: 200 # Thread Pool의 최대 개수 (계속해서 Thread가 부족할 경우 추가 생성해서 최대 한도가 200이라는 뜻)
    accept-count: 100 # 작업큐 아직 할당받지 못한 요청들이 대기하는 큐의 크기
    connection-timeout:
    max-connections: # 지정된 시간에 서버가 승인하고 처리할 수 있는 최대 연결 수이며, 놀고 있는 Thread가 없다면 accept-count을 기반으로 연결할 수 있음

 

해당 설정이 설정되는 곳은 ServerProperties입니다.

다른 설정을 더 보고 싶으시다면 해당 class로 가서 살펴보시면 좋을 거 같습니다.

 

그렇다면 애플리케이션의 Thread Pool의 적절한 size를 설정하는 기준은 무엇일까요??

-> 정답은 애플리케이션마다 다릅니다. 

 

일반적으로 사용하는 공식이 있습니다.  -> 적정 스레드 개수 = cpu 수 * (1+ 대기, 유휴 시간/서비스 시간) 

 

1. cpu 대기시간이 서비스 시간보다 짧다면 cpu 개수보다 스레드가 적어야 성능이 좋습니다.
이유는 대기가 짧기에 (콘텍스트 스위칭 비용이 적기에) 스레드 개수 적어도 상관없습니다.


2. 반대로 대기시간이 서비스 처리 시간보다 많다면 스레드 수는 cpu개수보다 많아야 성능이 좋습니다.
이유는 대기가 길기에 (콘텍스트 스위칭 비용이 많기에) 스레드 개수를 늘려 대기를 줄여야 합니다.

 

따라서 어플리케이션마다 서비스 시간, 환경 등등이 다르므로 적절한 Thread Pool의 크기를 애플리케이션을 실행하며 튜닝하는 것이 가장 좋습니다. 


3. Spring bean을 사용할 때 주의점

코드를 먼저 보겠습니다.
@RestController
public class HelloController {
    private final MemberService memberService;

    @Autowired
    public HelloController(MemberService memberService) {
        this.memberService = memberService;
    }

    @RequestMapping("/")
    public String hello(@RequestParam(name = "n") Integer n, @RequestParam(name = "string")String string) throws InterruptedException {
        memberService.print(n, string);
        return "ok";
    }
}
@Slf4j
public class MemberService {

    private final MemberRepository memberRepository;
    private String serviceString;
    @Autowired
    public MemberService(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }

    public void print(int n, String string) throws InterruptedException {
        serviceString=string;
        log.info("number: {} string: {} service start", n, serviceString);
        Thread.sleep(3000);
        log.info("number:{} string: {} service end", n, serviceString);
    }
}
localhost:8080/? n=1&string=a
localhost:8080/? n=2&string=b
localhost:8080/? n=3&string=c
localhost:8080/? n=4&string=d
localhost:8080/? n=5&string=e
호출 url입니다. 

number: 1 string: a ~처럼 (1, a) (2, b) (3, c) (4, d) (5, e)으로 생각하고 코드를 작성했습니다.

하지만 숫자와 알파벳 결과가 뒤죽박죽인 것을 볼 수 있습니다. 이런 결과가 나온 이유는 싱글톤 객체 안에 변환이 가능한 멤버 변수를 사용했기 때문입니다. 멤버 변수는 공용으로 사용하는 변수이므로 계속해서 변경되는 상황이 나온 것

입니다. 이러한 문제를 해결하는 법은 간단합니다. 싱글톤 객체 안에 멤버 변수를 사용하지 않으면 됩니다!!

 

읽어주셔서 감사합니다. 

 

 

'SpringBoot > spring 개념' 카테고리의 다른 글

Spring Lombok 어노테이션 사용  (0) 2022.01.03
spring 컨테이너와 bean 개념  (0) 2021.12.30
Springboot 프로젝트 생성  (0) 2021.12.30
Spring, SpringBoot란? 개념정리  (0) 2021.12.30