개발자 도전기/[STUDY] etc

백엔드 개발자에게는 정말 중요하다는 멀티 스레드! 자바의 멀티 스레드와 노드의 싱글 스레드 성능 튜닝 훑어보기

답수 2024. 4. 26. 16:57
728x90
반응형

자바로 웹 애플리케이션을 개발할 때 서블릿이 http 요청 처리 등 개발에 매우 편리한 기능들을 제공한다고 했다. 그리고 이 서블릿은 바로 스레드가 호출한다.

 

스레드는 프로세스 내에서 실행되는 실행 컨텍스트이다. 프로세스는 독립된 메모리 공간을 가지는 프로그램이다. 스레드는 프로세스 내에서 동작하기 때문에 한 프로세스에서 여러 개의 스레드가 존재할 수 있으며, 각각의 스레드는 프로세스가 가지고 있는 메모리들을 공유한다.

 

자바에서도 마찬가지로 자바 애플리케이션을 실행시키는 JVM 역시 하나의 프로세스이다. 그리고 이 프로세스는 여러 개의 스레드를 가진 멀티스레드이다. 자바 메인 메서드를 처음 실행하면 main이라는 이름의 스레드가 실행되고, 스레드는 한 번에 하나의 코드 라인만 수행한다. 동시 처리가 필요하다면 스레드를 추가로 생성한다. 일반적으로 싱글 스레드와 멀티 스레드에 대한 설명은 아래 이미지와 같다.

스레드들은 코드, 데이터 등 하나의 프로세스에서 같은 자원을 공유하지만 각각의 스택 영역을 갖추어서 작업한다.

 

JVM 구조의 다이어그램은 다음과 같다.

 

흠... 운영체제를 공부할 때 프로세스의 메모리 구조에 대한 기억 좀 꺼내보자...

  • 스택 영역: 지역변수, 함수 인자 및 반환값 등을 저장. 함수가 어떻게 호출되냐에 따라 크기가 동적으로 결정됨
  • 힙 영역: 동적으로 할당되는 메모리 영역. C언어에서는 malloc(), free() 함수를 사용하여 할당 및 반환
  • 데이터 영역: 정적 할당과 관련된 부분 담당(전역 변수, static, const 등으로 선언된 변수)
  • 코드: 영역 소스 코드. 반드시 메모리에 존재해야 하고 주소 공간에 존재

 

JVM의 메모리 구조 역시 마찬가지

  • 메서드 영역(Method Area): Java8 이전에는 Permanent Gernaration으로 불리기도 했지만 Java8 이후부터는 Metaspace로 대체되었다고 함. 바이트 코드, 메서드, 상수, 정적 변수 등의 클래스에 대한 정보나 메타데이터 등을 저장
  • 힙 영역(Heap Area): 동적으로 생성되는 Java 객체들이 저장되는 영역. 가비지 컬렉션을 통해 메모리 정리가 이루어짐
  • 스택 영역(Stack Area): 각각의 스레드가 메서드를 호출할 때마다 사용되는 영역. 각 스레드는 자체 스레드 스택을 가지고 있고, 메서드 호출 시 지역 변수, 매개변수, 반환 결과, 호출된 메서드 등을 저장
  • PC 레지스터(Program Counter Register): 각 스레드가 다음에 실행할 명령어의 주소를 가리키는 영역
  • 네이티브 메서드 스택(Native Method Stack): 네이티브 코드(C, C++ 등 자바가 아닌 다른 언어로 작성된 메서드)를 실행하는데 사용되는 스택
  • 다이렉트 메모리(Direct Memory): 논블로킹IO에서 사용되는 영역. 네이티브 코드에서 직접 메모리를 할당하는 데 사용

 

멀티 스레드에 대해서 정리하려고 했는데 어쩌다 보니 프로세스에 대한 메모리 구조까지 훑어보게 되었다... JVM에 대한 자세한 학습은 나중에 하기로 하고 다시 본론으로 돌아가자.

 

멀티 스레드

김영한님의 스프링 강의에서는 자바 백엔드 개발자에게 있어서 이 멀티 스레드에 대한 지식이 필수적이라고 한다. max thread 개수를 어떻게 설정할 것인지 등을 통해 성능을 튜닝하고, 서버의 CPU를 모니터링한다고 한다.

 

자바는 요청 마다 스레드를 생성한다. 이러한 방법을 통해 여러 요청들을 동시에 처리할 수 있고, CPU, 메모리 등의 자원들을 허용 가능할 때까지 처리가 가능하다. 또한 하나의 스레드가 지연이 된다 하더라도 나머지 스레드는 정상적으로 동작한다.

그러나 스레드의 생성 비용은 매우 비싸기 때문에 고객의 요청이 들어올 때마다 스레드를 생성하게 되면 응답 속도가 늦어진다. 또한 스레드들이 컨텍스트 스위칭할 때 역시 오버 헤드가 발생하며, 스레드가 과하게 생성될 경우 CPU, 메모리 임계점을 넘어서 서버가 다운될 수도 있다.

 

Thread Pool

이러한 이유로 자바에는 스레드 풀(thrad pool)이 있다. 스레프 풀은 스레드를 효과적으로 관리하고 재사용하는데 도움을 주는 객체를 말한다. 스레드 풀은 많은 스레드를 생성하고 소멸시키는 비용을 줄이고, 여러 작업을 동시에 실행할 수 있도록 지원한다. 예를 들어 고정된 크기의 스레드 풀을 생성하고 작업을 제출하는 코드는 다음과 같다.

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class ThreadPoolExample {
    public static void main(String[] args) {
        // 고정 크기의 스레드 풀 생성 (크기: 5)
        ExecutorService executorService = Executors.newFixedThreadPool(5);

        // 작업 제출
        for (int i = 0; i < 10; i++) {
            executorService.execute(new MyRunnableTask(i));
        }

        // 스레드 풀 종료
        executorService.shutdown();
    }
}

class MyRunnableTask implements Runnable {
    private final int taskId;

    public MyRunnableTask(int taskId) {
        this.taskId = taskId;
    }

    @Override
    public void run() {
        System.out.println("Task " + taskId + " is being executed by thread " + Thread.currentThread().getName());
    }
}

 

즉 스레드 풀을 통해 요청마다 스레드를 생성하는 방식에서의 단점을 보완할 수 있다. 

 

스레드 풀 실무 팁

WAS의 주요 튜닝 포인트는 최대 스레드(max thread)의 수이다.

- 이 값을 너무 낮게 설정한 경우: 동시 요청이 많은 경우 서버 리소스는 여유롭지만 클라이언트는 금방 응답 지연
- 이 값을 너무 높게 설정한 경우: 동시 요청이 많은 경우 서버 리소스(CPU, 메모리) 임계점 초과로 서버 다운
- 장애 발생 시: 클라우드면 일단 서버부터 늘리고, 이후에 열심히 튜닝

 

예시로 아래의 그림처럼 최대 스레드를 10개를 설정한 경우 동시에 10개의 요청만 처리가 가능하며, 이렇게 되는 경우 클라이언트 측면에서는 응답에 대한 속도가 느려질 수밖에 없다.

하지만 그렇다고 이 스레드풀을 너무 크게 잡게 된다면, 서버의 CPU와 메모리 자원의 임계점을 넘게 되어 서버가 다운될 수가 있다.

 

그렇기 때문에 스레드풀의 적정 숫자를 설정하는 것이 매우 중요한 역량이라고 할 수 있다. 하지만 스레드 풀의 적정 크기를 찾는 것은 매우 어렵다고 할 수 있다. 예전에 부트캠프에서 처음 전산학을 배웠을 때 코치님께서도 이와 관련해서 그저 적당~한 값을 설정하라고만 하셨었다.... 즉 이 말은 애플리케이션의 특성이나 로직의 복잡도, CPU, 메모리, I/O 리소스 상황, 운영 환경 및 하드웨어의 제원에 따라 천차만별로 달라진다. 그러나 몇 가지 고려 사랑과 일반적인 가이드라인을 통해 적당~한 스레드 풀 크기를 결정하는 데 도움이 될 수는 있다.

  • CPU 코어 수
  • 작업의 성격
  • 메모리 사용량
  • 작업 처리 시간

이러한 조건들을 고려해서 성능 테스트를 진행하면 스레드 풀 크기를 결정할 수 있고, 이런 식으로 성능을 튜닝하면 될 것이다. 성능 테스트는 최대한 실제 서비스와 유사하게 하는 것이 중요하다.

 

노드는 성능 튜닝 어떻게 해?

자바는 멀티 스레드이고, 스레드 풀 크기 설정을 통해 성능을 튜닝할 수 있다고 정리했다. 그렇다면 노드 생태계에서는 어떻게 성능 튜닝을 진행할까?

 

우선 Node.js는 자바와는 다르게 싱글 스레드 환경이라고 불린다. 노드는 싱글 스레드 이벤트 루프를 기반으로 작동하는 런타임 환경이다. 노드를 공부하는 개발자분들이라면 적어도 이와 비슷한 그림을 봤을 것이다.

위의 그림은 이벤트 루프를 통해 클라이언트의 요청을 비동기적으로 처리하는 것을 나타낸다. 여기서 더 확장된 개념들을 보자.

자바스크립트 엔진에는 JVM처럼 힙과 스택 영역이 있다. 메모리 힙은 메모리 할당이 일어나는 곳이고, 콜스택은 코드 실행에 따라 호출 스택들이 쌓이는 곳이다. 즉 요청들은 콜스택에 쌓이게 되고, 이벤트 루프를 실행시키는 싱글 스레드가 비동기적으로 이를 처리한다. 

 

이때부터 뭔가 개념적으로 멘붕이 오기 시작한다. 싱글 스레드이고, 비동기적으로 실행한다면, 어떻게 여러 일들을 동시에 수행할 수 있는 거지? 노드가 병행적(동시성)으로 일을 하는 것이지, 병렬적으로 한다는 말은 들어본 적이 없다. 

 

위의 그림을 다시 보자. 이벤트 루프를 실행시키는 메인 스레드는 콜 스택에 있는 요청을 직접적으로 처리하지 않는다. 그렇다면 이 요청에 대한 작업은 어디에서 이루어지는 걸까? 이는 노드의 백그라운드에서 비동기 I/O 모델을 통해 비동기적인 방식으로 처리한다고 한다. 즉, 노드 내부에 있는 메인 스레드는 이벤트 루프를 통해 노드 백그라운드에서 이루어지는 비동기 작업의 완료를 감지하고, 완료된 작업에 대한 콜백 함수를 이벤트 큐에 추가하는 작업을 한다. 그리고 콜스택이 비었을 때 이 큐에 쌓여 있던 작업을 콜스택으로 보낸다.

정리하자면 노드는 내부적으로는 주 스레드가 하나이지만, 비동기 I/O 작업이 외부의 스레드 풀에서 병렬로 처리된다. 여기에서의 스레드 풀은 노드에서는 Worker Pool이라는 개념을 가지고 있는데, 이 부분 관련해서는 다음에 각잡고 제대로 정리해보자...!

 

다시 본론으로 돌아와서! 어찌 됐든 노드는 싱글 스레드라고 우기기(?) 때문에, 자바의 멀티 스레드 크기 설정과는 다른 방식으로 성능 튜닝이 이루어져야 할 것이다.

 

우선적으로 싱글 스레드는 실행하는 제어권을 가진 스레드가 하나이기 때문에 많은 멀티 스레드 생성으로 인한 컨텍스트 스위칭 오버헤드가 없고, 이 덕분에 성능적인 측면에서의 이점을 가진다. 그러나 이 강점에는 하나의 큰 약점이 존재하는데 바로 I/O작업이나 긴 계산이 필요한 작업이 발생하게 되었을 때 응답 지연이 발생할 수 있다는 것이다. 그래서 노드에서 중요한 포인트는 이벤트 루프가 특정 코드를 오래 점유하게 되면 매우 치명적이라는 것이다. 그렇기 때문에 노드로 백엔드 작업을 할 때에는 비동기적으로 코드를 작성하는 것에 대한 높은 이해도가 필요하고 I/O작업 등을 최소화하는 것이 좋다.

 

이벤트 루프가 어떤 부분을 점유하고 있는지는 어떻게 찾아낼까? 나도 아직 사용 경험이 없지만 Clinic.js와 같은 성능 모니터링 툴을 이용하면 된다. 이와 관련된 예시로는 이 유튜브의 사례를 참고했다.

const express = require('express');
const axios = require('axios');

const app = express();

app.get('/hello', async (req, res) => {
  try {
    const fetchingResult = await axios.get('...url...'); // 다른 서버로부터 6.8MB의 JSON을 받음
    const jsonResult = fetchingResult.data; // axios는 기본적으로 JSON 형식으로 디코딩하여 응답함

    res.send(jsonResult);
  } catch (error) {
    console.error(error);
    res.status(500).send('Internal Server Error');
  }
});

 

이 코드로 성능 모니터링을 테스트했을 때 약 50~60ms의 이벤트 루프 딜레이가 지속적으로 발생했다고 한다.

 

그리고 이 툴의 flame이라는 기능을 통해서 알아낸 것은 json을 호출하는 코드에서 CPU의 자원을 많이 사용한다는 것이었다. 이 코드를 다음과 같이 수정하여 성능을 개선할 수 있다.

const express = require('express');
const axios = require('axios');

const app = express();

app.get('/hello', async (req, res) => {
  try {
    const fetchingResult = await axios.get('...url...'); // 다른 서버로부터 6.8MB의 JSON을 받음
    const buffer = Buffer.from(fetchingResult.data);

    res.header('Content-Type', 'application/json');
    res.send(buffer); // 디시리얼라이징 없이 Buffer를 직접 response
  } catch (error) {
    console.error(error);
    res.status(500).send('Internal Server Error');
  }
});

여기서 바뀐 점은 데이터를 JSON 형식으로 받지 않고, 이진 데이터 날 것 그 자체를 응답 데이터로 보낸다. 대신 header에 content-type을 json 형식으로 하도록 설정한다(일반적으로는 JSON의 데이터는 디시리얼라이즈 되어 js객체로 변환된다).

 

이렇게 코드를 수정했을 때, 이벤트 루프의 딜레이가 완전히 사라졌다고 한다.

 

또한 영상을 보면, TypeORM Entity를 생성하는 과정에서 많은 이슈가 발생했었고, 이를 통해 TypeORM 엔티티를 Raw 쿼리로 수정하여 이슈를 해결했다는 사례도 보여줬다.

 

 

흠... 진짜 간단하게만 자바의 멀티 스레드와 노드의 싱글 스레드, 성능 튜닝을 정리해보려고 했는데 어쩌다 보니 생각하지 못했던 사례들도 끌고 와서 글을 쓰고 있네.. 여하튼 다음에는 스프링 학습을 하면서 정리가 필요한 글들을 계속 정리해나가보자.

728x90
반응형
LIST