IO와 성능향상을 위한 멀티쓰레드

송지윤·2026년 5월 19일

Spring Boot

목록 보기
79/81

멀티쓰레드
여러 작업을 동시에 처리하려고 실행 흐름(Thread)을 여러 개 사용하는 구조
Java 기준으로 프로세스(프로그램) 안에서 여러 개의 작업 흐름이 동시에 실행되는 것

가장 중요한 건

  • 공유 자원 충돌
  • 성능 병목
  • 쓰레드 개수 제한
  • IO/CPU 특성

설계

프로세스(Process)

실행 중인 프로그램 자체
VS Code, Chrome, Java 서버
각각 하나의 프로세스

쓰레드(Thread)

프로세스 내부에서 실제 일을 하는 실행 단위
예를 들어 Spring 서버에서는 HTTP 요청 처리, DB 조회, 파일 복사, 스케줄러, 로그 기록 같은 작업들이 각각 다른 쓰레드에서 돌아감.

멀티쓰레드란?

싱글쓰레드

한 번에 하나만 처리
작업 A 끝 -> 작업 B 시작 -> 작업 B 끝 -> 작업 C 시작

멀티쓰레드

동시에 여러 작업 수행
쓰레드 1 -> 작업 A
쓰레드 2 -> 작업 B
쓰레드 3 -> 작업 C

CPU 코어 수와 관련 있음
CPU 8코어 쓰레드 100개 가능하지만 실제로는 8개 정도만 진짜 동시에 실행됨 나머지는 CPU가 아주 빠르게 번갈아 실행하는 방식
운영체제가 스케줄링함

쓰레드 개수 제한

컴퓨터 성능 + 메모리 + OS + JVM 설정에 따라 사실상 제한됨
무한정 만들 수 있는 건 아님

쓰레드 하나 생성할 때마다

  • 메모리(Stack Memory)
  • CPU 스케줄링 비용
  • Context Switching 비용

발생함

Context Switching 이란?

CPU가 쓰레드 1 실행 -> 저장 -> 쓰레드 2 실행 -> 저장 -> 쓰레드 3 실행
이렇게 계속 전환하는 것
전환 자체도 비용이 큼
쓰레드가 너무 많으면

  • 오히려 느려짐
  • CPU 사용률 급증
  • 메모리 부족
  • 서버 다운

실무에서는 보통 Thread Pool 사용 직접 쓰레드 무한 생성 안함.
예)

ExecutorService pool =
    Executors.newFixedThreadPool(10);

최대 10개만 동시 실행 나머지는 대기

멀티쓰레드 사용 시 가장 중요한 주의사항

1. 공유 자원 문제

여러 쓰레드가 동시에 같은 변수 접근
쓰레드1 : count = 5 읽음
쓰레드2 : count = 5 읽음
둘 다 +1 후 저장하면 결과 = 6 돼버림.
원래는 7 이어야 함

해결 방법

synchronized

synchronized(lock) {
    count++;
}

AtomicInteger

AtomicInteger count =
    new AtomicInteger();

count.incrementAndGet();

원자적 연산 보장

내부 동작
1. count 값을 읽음
2. +1 계산
3. 다시 저장
즉 3 단계
Thread1, 2가 거의 동시에 count 읽어서 +1 둘 다 6 저장
-> 이걸 Race Condition 이라고 함
누가 먼저 실행되느냐에 따라 결과가 달라짐

AtomicInteger는 읽기 + 증가 + 저장
전체를 CPU 수준에서 절대 중간에 끼어들 수 없는 하나의 작업처럼 처리함
이걸 원자적(Atomic) 이라고 함.

AtomicInteger 내부가

  • CAS(compare and swap)
  • CPU atomic instruction
  • volatile menory
    같은 기술로 다른 쓰레드가 변경했는지 검사를 하면서 안전하게 처리함

쉽게 비유하면 count++ 메모장 하나 두고 둘이 동시에 숫자 수정하는 것
AtomicInteger 한 명 수정 끝날 때까지 다른 사람 잠깐 대기 같은 느낌
(실제로는 lock 없이 CAS를 많이 사용)

2. DeadLock(교착 상태)

서로 락 기다리다가 영원히 멈춤
쓰레드 1 : A 락 점유 -> B 기다림
쓰레드 2 : B 락 점유 -> A 기다림
=> 서버 멈춤

서로 락(lock)을 잡은 상태에서 상대 락 기다리다가 영원히 멈추는 현상

DeadLock 해결 방법

1. 락 순서를 항상 동일하게

항상 A -> B 순서로만 락 획득
규칙 통일 그러면 교착상태 거의 예방 가능

2. synchronized 최소화

락 범위를 짧게

// 안 좋은 예
synchronized(lock) {
    파일복사();
    DB조회();
}
// -> IO 작업까지 락 잡고 있음 엄청 위험

3. timeout 사용

tryLock(3, TimeUnit.SECONDS)

3초 안 되면 포기. 무한 대기 방지

4. 락 중첩 줄이기

가능하면 락 안에서 또 다른 락 획득 안 하기

5. immutable 객체 사용

공유 데이터를 아예 수정 불가능하게
예)

  • String
  • record
  • DTO 복사본

3. Race Condition

실행 순서에 따라 결과 달라짐
멀티쓰레드의 대표적인 버그

4. 너무 많은 쓰레드 생성

대표 증상

  • CPU 100%
  • 응답 느려짐
  • GC 과다
  • OutOfMemory
  • 서버 다운

CPU 코어와 쓰레드 개수 관계

CPU Bound 작업

계산 작업

  • 암호화
  • 영상 인코딩
  • 압축

이건 보통 코어 수 정도가 효율적
예) 8코어 -> 8~16 쓰레드

IO Bound 작업

대기 많은 작업

  • DB 조회
  • 파일 복사
  • 네트워크 통신

이건 대기 시간이 많아서 쓰레드를 더 많이 둬도 됨

예) 8코어라도 50~200 쓰레드 가능
Spring 서버가 대표적

파일 복사 멀티쓰레드

쓰레드 50개로 파일 복사

  • 디스크가 못 버팀
  • NAS 병목
  • DB connection 부족 발생

CPU는 멀쩡한데 IO가 터짐
실무에선 항상 제한해서 사용

Executors.newFixedThreadPool(10)

또는

ThreadPoolTaskExecutor

IO 작업은 대기 시간이 매우 많음

  • DB 응답 기다림
  • 파일 읽기 기다림
  • NAS 응답 기다림
  • 네트워크 응답 기다림

CPU는 실제 계산보다 기다린느 시간이 훨씬 많음

Files.copy()

이걸 실행하면 내부적으로 디스크 읽기 대기 -> 네트워크 대기 -> OS 버퍼 대기
등이 발생함
이 동안 CPU는 놀고 있을 수도 있음.

단일 쓰레드 문제

쓰레드 하나가 파일 복사 기다리는 동안
로그도 못 씀. DB도 못 감. 다른 요청도 못 받음

원래 독립적으로 처리 가능했던 작업들을 단일 쓰레드에서는 한 실행 흐름 안에서 순차 처리하게 됨

Spring 서버는 원래 멀티쓰레드 기반

예를 들어 Tomcat
사용자가 동시에 접속하면
사용자 A 요청 -> Thread-1
사용자 B 요청 -> Thread-2
사용자 C 요청 -> Thread-3
이렇게 각각 처리함

로그 기록도 IO
실제로는
파일 열기
쓰기
flush
디스크 반영
디스크 느리면 대기 발생

그래서 Async Logger 사용하기도 함
업무 쓰레드 : 로그 남겨줘 요청만 큐에 넣음
별도 로그 쓰레드 : 실제 파일 기록
log4j2 AsyncAppender 같은 거

하지만 멀티쓰레드도 무조건 좋진 않음
파일 복사 쓰레드를 너무 많이 만들면 디스크 병목, NAS 병목, DB connection 부족 발생
그래서 항상 newFixedThreadPool() 같이 제한을 둠

IO (Input/Output)

읽고 쓰는 작업

Input

외부에서 읽기

  • 파일 읽기
  • DB 조회
  • 네트워크 수신
  • 키보드 입력

Output

외부에 쓰기

  • 파일 저장
  • 로그 기록
  • DB 저장
  • 네트워크 전송

느린 이유

CPU보다 외부 장치가 훨씬 느림

CPU 속도

대략 나노초(ns) 수준

SSD/NAS/DB

마이크로초 ~ 밀리초(ms) 수준 엄청 느림

예를 들어 파일 읽기

Files.readAllBytes()

실제로는
1. 디스크에 요청
2. 디스크가 파일 찾음
3. 읽음
4. OS 버퍼 이동
5. JVM 전달

과정 거침
기다리는 시간 많음

네트워크는 더 느림

DB 서버 응답 대기
NAS 응답 대기
수 ms ~ 수백 ms.
CPU 입장에선 엄청 긴 시간

그래서 IO Bound 라고 함 CPU 계산보다 기다리는 시간이 더 많은 작업

반대로 CPU Bound 압축, 암호화, 영상 인코딩, AI 연산 이건 CPU 계산이 대부분

네트워크 드라이브 (SMB 공유 폴더 등) 읽는 것도 대표적인 IO 작업
로컬 디스크 IO + 네트워크 통신

로컬 파일 읽기
프로그램 -> OS -> SSD/HDD -> 메모리
거의 컴퓨터 내부에서 끝남

네트워크 드라이브 읽기
프로그램 -> OS -> 네트워크 카드 -> TCP/IP 통신 -> 스위치/공유기 -> NAS 서버 -> NAS 디스크 읽기 -> 다시 네트워크 전송 -> 내 PC 도착
과정 거침

네트워크 드라이브 IO가 느린 이유

1. 네트워크 왕복 시간(Latency)

파일 읽기 전에 파일 있는지 열어주고 데이터 보내고 이런 통신이 계속 발생함 (이 자체가 느림)

2. 네트워크 속도 제한

SSD 내부 속도 : 수백 MB/s ~ GB/s
근데 네트워크는 : 1Gbps = 이론상 약 125MB/s 정도
실제로는 더 느림

3. 서버 자체 디스크 속도

상대 서버도 결국 디스크를 읽어야함

4. 패킷 분할 전송

큰 파일은 네트워크에서 잘게 쪼개서(packet) 보냄
전송 중 : 재전송, 오류 수정, 순서 맞춤 등 발생

5. SMB/NFS 프로토콜 오버헤드

윈도우 공유폴더는 보통 SMB 사용
파일 하나 읽을 때도 내부적으로 인증, 권한 확인, 세션 유지 등 작업 많음

그래서 작은 파일 여러 개가 특히 느림
왜냐하면 파일 열기, 권한 확인, 네트워크 요청이 매번 발생함

0개의 댓글