스레드란 무엇인가: Spring Boot 모니터링을 하다 생긴 궁금증(6)

단2025.05.18 - [Developer/JAVA] - 스레드란 무엇인가: Spring Boot 모니터링을 하다 생긴 궁금증(5)

 

스레드란 무엇인가: Spring Boot 모니터링을 하다 생긴 궁금증(5)

2025.05.18 - [Developer/JAVA] - 스레드란 무엇인가: Spring Boot 모니터링을 하다 생긴 궁금증 (4) 스레드란 무엇인가: Spring Boot 모니터링을 하다 생긴 궁금증 (4)자바 스레드에 학습한 내용을 정리하는 포스

doitwojae.tistory.com

 

썸네일

 

자바 스레드 synchronized 에 대해서 학습 후 정리한 내용입니다.

🐻  Java Synchronized

synchronized 란 한글로 번역하면 동기화라는 의미를 갖는 단어이다. 사전적 정의는 시스템을 동시에 작동시키기 위해 여러 사건들을 조화시키는 것을 의미한다. 

 

하나의 프로세스가 진행될 때 다른 프로세스가 간섭하지 못하게 해당 프로세스만을 수행하도록 하는 것이라고 설명할 수 있다. 자바에서는 이러한 동기화를 어떻게 하면 문제를 해결할 수 있고, 개념이 무엇인가에 대해서 알아본다.

@warning
자바는 초기 버전부터 멀티스레드 프로그래밍이 가능하도록 Thread 클래스와 Runnable 인터페이스를 제공했다.
JDK 1.5부터 concurrency API가 많이 추가되었다. Excutor, Callable, Futre

 

🐻 쓰레드와 관련이 많은 synchronized

synchronized는 자바의 예약어 중 하나다. (클래스 명이나 변수명으로 사용할 수 없다)

Thread와 synchronized는 떼려야 뗄 수 없는 관계이다. 종종 스레드에 안전하다고 이야기를 들어보았을 것이다. 또는 스레드 세이프 하다. 어떤 클래스나 메서드가 스레드에 안전하려면 synchronized를 사용해야 한다.

 

Thread Safe에 대해서 예를 들면, 평소에 100만 원에 팔던 물건을 1만 원에 100개 판다고 했을 때 사람들이 줄을 서지 않고, 너도 나도 물건을 가져가게 놔둔다면 가장 늦게 온 사람이 물건을 사거나, 가장 먼저 온 사람이 물건을 못 살 수도 있다.

 

여러 스레드가 한 객체에 선언된 메서드에 접근하여 공유 데이터를 처리하려고 할 때 동시에 연산을 수행하여 값이 꼬이는 경우가 발생할 수 있다. 단 인스턴스 변수를 수정하려고 할 때에만 이러한 문제가 생긴다. 매개 변수나 메서드에서만 사용하는 지역변수만 다루는 메서드는 전혀 synchronized를 선언할 필요가 없다.

 

public synchronized void plus(int value) {
	amount += value;
}

 

메서드 선언부에 synchronized가 존재하면, 동일한 객체의 이 메서드에 2개 이상의 스레드가 접근해도 한 순간에는 하나의 스레드만 이 메서드를 수행하게 된다. 

amount += value; 라는 연산은 amount의 값을 읽어온 후에 value 값을 더한 후에 우측 항의 amount 변수에 담는다. 이때 다른 스레드가 들어와서 더한 값의 결과를 대입하기 전에 동일한 연산을 수행하려고 한다. 아직 amount는 기존의 값과 변경이 일어나지 않았지만, 스레드가 접근해서 읽은 후 동일한 작업을 수행하게 된다. 결과적으로는 두 번의 연산을 한 결과가 나와야 하지만, 한 번만의 수행 결괏값만 저장되게 된다.

 

이러한 문제를 해결하기 위한 것이 바로 synchronized이다. 이 예약어를 추가하여 동일한 객체를 참조하는 다른 스레드에서 이 메서드를 변경하려고 하면 먼저 들어온 스레드가 종료될 때까지 기다린다.

 

synchronized 블록은 이렇게 사용한다.

간단하게 메서드에 synchronized를 추가해 주면 되는 것으로 보인다. 하지만, 이렇게 메서드 레벨에서 선언하게 되면 성능상 문제점이 발생할 수 있다. 예를 들어 어떤 클래스에 30줄짜리 메서드가 있다고 가정하자.

 

클래스의 인스턴스 변수인 amountu 가 있고, 30줄 짜리 메서드에서 딱 한 줄에서만 값을 변경하는 연산을 한다. 만약 해당 메서드 전체를 synchronized로 선언한다면, 나머지 29 줄을 처리하고 끝날 때까지 필요 없는 대기 시간이 발생하게 된다.

 

public void plus(int value) {
	synchronized(this) {
    	amount += value;
    }
}

 

이렇게 하면 synchronized(this) 이후에 잇는 중괄호 내에 있는 연산만 동시에 여러 스레드에서 처리하지 않겠다는 의미다. 소괄호 안에 this가 있는 부분에는 잠금 처리를 위한 객체를 선언한다. 이 this는 모니터 락이라는 것이다. 모든 객체는 모니터락을 가지고 있다.

 

 

🐻 동시성 문제

멀티 스레드를 사용할 때 가장 주의해야 할 점은, 같은 자원 (리소스)에 여러 스레드가 동시에 접근할 때 발생하는 동시성 문제이다.

참고로 여러 스레드가 접근하는 자원을 공유 자원이라 한다. 대표적인 공유 자원은 인스턴스 필드(멤버 변수)이다.

 

🐻 임계 영역

동시성 문제가 발생하는 근본 원인은 여러 스레드가 함께 사용하는 공유 자원을 여러 단계로 나누어 사용하기 때문이다.

 

  1. 검증 단계 : 통장의 잔액이 출금액 보다 많은지 확인한다.
  2. 출금 단계 : 잔액을 출금액만큼 줄인다.
출금() {
	1. 검증 단계 : 잔액 확인
    2. 출금 단계 : 잔액 감소
}

 

스레드 하나의 관점에서 출금의 로직을 보면 검증 단계에서 확인한 잔액 1000원은 출금 단계에서 계산을 끝마칠 때까지 같은 1000원으로 유지되어야 한다.

검증 단계에서 확인한 1000원이 중간에 다른 스레드가 잔액의 값을 변경한다면, 큰 혼란이 발생한다. 1000원이라 생각한 잔액이 다른 값으로 변경되면 잔액이 전혀 다른 값으로 계산될 수 있다.

 

공유 자원

잔액은 여러 스레드가 함께 사용하는 공유 자원이다. 따라서 출금 로직을 수행하는 중간에 다른 스레드에서 이 값을 얼마든지 변경할 수 있다.

 

한 번에 하나의 스레드만 실행

출금 이라는 메서드를 한 번에 하나의 스레드만 실행할 수 있게 제한한다. 예를 들어 두 스레드가 함께 출금을 호출하면 먼저 실행한 스레드가 종료될 때까지 기다리고, 다음 스레드가 처음부터 끝까지 출금 메서드를 완료하는 것이다.

 

임계영역(critical section)

여러 스레드가 동시에 접근하면 데이터 불일치나 예상치 못한 동작이 발생할 수 있는 위험하고 또 중요한 코드 부분을 말한다. 은행 출금 예제에서는 검증, 차감이라는 부분이 임계영역이다. 여러 스레드가 동시에 접근해서는 안 되는 공유 자원을 접근하거나 수정하는 부분을 의미한다.

 

synchronized 분석

앞서 보았든이 모든 객체는 모니터 락이라는 것을 가지고 있다. 이는 synchronized를 사용할 때 사용한다.

모든 객체 (인스턴스)는 내부에 자신만의 락(Lock)을 가지고 있다.

 

모니터 락은 객체 내부에 존재하고 우리가 확인하기는 어렵다. 스레드가 synchronized 키워드가 있는 메서드에 진입하려면 해당 인스턴스의 Lock이 반드시 있어야 한다.

 

스레드 t1이 해당 인스턴스의 Lock을 가지게 되면 t2 스레드에서는 해당 인스턴스의 락이 없으므로 진입하지 못하고 스레드의 상태는 BLOCKED 가 된다 이렇게 락이 없으면 락을 획득할 때까지 무한정 대기한다.. t1가 모니터 락을 반납하면 t2 스레드는 RUNNABLE 상태로 변하고 LOCK을 획득하고 실행된다.

 

참고로 BLOCKED 상태가 되면 락을 획득하기 전까지는 계속 대기하고, CPU 실행 스케줄링에 들어가지 않는다.

 

synchronized Lock 획득

락을 획득하는 순서는 보장되지 않는다. 어느 스레드가 먼저 와서 락을 획득하기 위해서 대기하고 있더라도, 늦게 온 스레드가 먼저 락을 획득하게 될 수 있다. 해당 인스턴스의 락을 기다리는 수많은 스레드 중에 하나의 스레드만 락을 획득한다. 이때 어떤 순서로 락을 획득하는지는 자바 표준에 정의되어 있지 않다. 따라서 순서를 보장하지 않는다.

 

이런 동기화를 통해서 다음 문제를 해결할 수 있다.

  • 경합 조건(Race Condition) : 두 개 이상의 스레드가 경쟁적으로 동일한 자원을 수정할 때 발생하는 문제
  • 데이터 일관성 : 여러 스레드가 동시에 읽고 쓰는 데이터의 일관성을 유지한다.
동기화는 멀티 스레드 환경에서 필수적인 기능이지만, 과도하게 사용할 경우 성능 저하를 초래할 수 있으므로 꼭 필요한 곳에 적절하게 사용해야 한다.

 

 

🐻 정리

자바는 처음부터 멀티스레드를 고려하고 나온 언어이다. 그래서 자바 1.0 부터 synchronized 같은 동기화 방법을 프로그래밍 언어의 문법에 포함해서 제공한다.

 

장점

  • 프로그래밍 언어에 문법으로 제공한다.
  • 아주 편리한 사용
  • 자동 잠금 해제 : synchronized 메서드나 블록이 완료되면 자동으로 락을 대기 중인 다른 스레드의 잠금이 해제된다.

단점

  • 무한 대기 : BLOCKED 상태의 스레드는 락이 풀릴 때까지 무한 대기한다.
  • 공정성 : 락이 돌아왔을 때 BLOCKED 상태의 여러 스레드 중에 어떤 스레드가 락을 획득할 지 알 수 없다. 최악의 경우 특정 스레드가 너무 오랜 기간 락을 획득하지 못할 수 있다.

 

synchronized의 단점을 개선하기 위해 더 유연하고, 더 세밀한 제어가 가능한 방법들이 필요하게 되었다. 이러한 문제를 해결하기 위해 자바 1.5부터 java.util.concurrent라는 동시성 문제 해결을 위한 패키지가 추가된다.

 

🐻 고급 동기화

synchronized의 단점을 해결하기 위해서 제공된 패키지 중에 가장 기본이 되는 LockSupport에 대해서 알아보자. 

 

🐻 LockSupport 기능

기존에 모니터락을 획득하기 위해서 BLOCKED 상태로 외부에서 깨울 수도 없는 문제가 있었다. LockSupport는 스레드를 WAITING 상태로 변경한다.

WAITING 상태는 누가 깨워 주기 전까지는 계속 대기한다. 그리고 CPU 실행 스케줄링에 들어가지 않는다. 무한 대기를 하지 않고 외부에서 interrupt를 발생시켜 깨울 수 있다.

 

  • park() : 스레드를 WAITING 상태로 변경한다.
  • parkNanos(nanos) : 스레드를 나노초 동안만 TIMED_WAITING 상태로 변경한다.
  • unpark(thread) : WAITING 상태의 대상 스레드를 RUNNABLE 상태로 변경한다.
public class LockSupportMain {
	
    public static void main(String[] args) {
    	Thread thread = new Thread(LockSupport::park, "Thread-1");
        thread.start();	// WAITING
        
        LockSupport.unpark(thread);	// RUNNABLE
        // thread.interrup(); 인터럽트로 꺠울 수도 있다.
    }
}

 

너무 로우 레벨에서의 구현이다. 이걸 이용해서 무한 대기 문제를 해결하고 락을 획득하기를 기다릴 수 있도록 구현을 한다.

 

  • BLOCKED 상태는 synchronized에서만 사용하는 특별한 대기 상태라고 이해하면 된다.
  • WAITING, TIMED_WAITING 상태는 범용적으로 활용할 수 있는 대기 상태라고 이해하면 된다.

LcokSupport는 너무 저수준이다. synchronized처럼 더 고수준의 기능이 필요하다. 이러한 해결을 자바에서는 Lock 인터페이스와 ReentrantLock 이라는 구현체로 이런 기능을 이미 다 구현해 두었다. ReentrantLockLockSupport를 활용해서 synchronized의 단점을 극복하면서도 매우 편리하게 임계 영역을 다룰 수 있는 기능을 제공한다. 

 

🐻 ReentrantLock

자바는 1.0부터 존재한 synchronized와 BLOCKED 상태를 통한 임계 영역 관리의 한계를 극복하기 위해 자바 1.5부터 Lock 인터페이스와 ReentrantLock 구현체를 제공한다. 

 

public interface Lock {

	void lock();
    void lockInterruptibly() throws InterruptedException;
    boolean tryLock();
    boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
    void unlock();
    Condition newCondition();
}

 

Lock 인터페이스가 제공하는 명세이다. Java Docs를 살펴보면 간단하게 사용하는 방법도 첨부되어 있다.

Lcok 인터페이스 사용 예제

 

lock( )

락을 획득한다. 만약 다른 스레드가 이미 락을 획득했다면, 락이 풀릴 때까지 현재 스레드는 WAITING 한다. 이 메서드는 인터럽트에 응답하지 않는다.

 

lcokInterruptibly( )

락 획득을 시도하되, 다른 스레드가 인터럽트 할 수 있도록 한다. 현재 스레드는 락을 획득할 때까지 대기한다. 대기 중 인터럽트가 발생하면 락 획득을 포기한다.

 

tryLock( )

락 획득을 시도하고, 즉시 성공 여부를 반환한다. 만약 락을 획득했다면 true를 반환한다.

 

tryLock(long time, TimeUnit unit)

주어진 시간동안 락 획득을 시도한다.

 

unlock( )

락을 해제한다. 락을 해제하면 락 획득을 대기 중인 스레드 중 하나가 락을 획득할 수 있다.

락을 획득한 스레드가 호출해야 하며, 그렇지 않을 경우 IllegalMonitorStateException 이 발생한다.

 

Condition newCondition()

Contition 객체를 생성하여 반환한다. Condition 객체는 락과 결합되어 사용되며, 스레드가 특정 조건을 기다리거나 신호를 받을 수 있도록 한다. 이는 Object 클래스의 wait, notify, notifyAll 메서드와 유사한 기능을 한다.

 

공정성

Lock인터페이스가 제공하는 기능 덕분에 무한 대기 문제를 해결했지만, 공정성 문제가 남아있다. Lock 인터페이스의 구현체로 ReentrantLock이 있는데, 이 클래스는 스레드가 공정하게 락을 얻을 수 있는 모드를 제공한다.

 

public class ReentrantLockExample {
	
    private final Lock nonFairLock = new ReentarntLock();
    
    private final Lock fairLock = new ReentrantLock(true);
    
    public void nonFairLockTest() {
    	nonFairLock.lock();
        
        try {
        	// 임계 영역
        } finally {
        	nonFairLock.unlock();
        }
    }
    
    public void fairLockTest() {
    	fairLock.lock();
        
        try {
        	// 임계 영역
        } catch {
        	fairLock.unlock();
        }
    }
}

 

 

비공정 모드 (Non-fair mode)

비공정 모드는 기본 모드이다. 이 모드에서는 락을 먼저 요청한 스레드가 락을 먼저 획득한다는 보장이 없다. 락을 풀었을 때 대기 중인 스레드 중 아무나 락을 획득할 수 있다. 이는 락을 빨리 획득할 수 있지만, 특정 스레드가 장기간 락을 획득하지 못할 가능성이 있다.

 

  • 성능 우선 : 락을 획득하는 속도가 상대적으로 빠르다.
  • 선점 가능 : 새로운 스레드가 기존 대기 스레드보다 먼저 락을 획득할 수 있다.
  • 기아 현상 가능성 : 특정 스레드가 계속해서 락을 획득하지 못할 수 있다.

 

공정 모드 (Fair mode)

생성자에서 true를 전달하면 된다.

공정 모드는 락을 요청한 순서대로 스레드가 락을 획득할 수 있게 한다. 이는 먼저 대기한 스레드가 먼저 락을 획득하게 되어 스레드 간의 공정성을 보장한다. 그러나 이로 인해 성능이 저하될 수 있다.

 

  • 공정성 보장 : 대기 큐에 먼저 대기한 스레드가 락을 먼저 획득
  • 기아 현상 방지 : 모든 스레드가 언젠간 락을 획득할 수 있게 보장
  • 성능 저하 : 락을 획득하는 속도가 느려질 수 있다.

 

🐻 ReentrantLock - 활용

앞서 Java Docs에서 제공한 사용 방법대로 try - finally 구문을 통해서 락을 획득하고 임계영역 처리가 완료되면 락을 실행이 성공 실패 모두 반환하도록 한다.

 

public class BankAccount {
	
    private final Lock lock = new ReentrantLock();
    private int balance;
    
    public BankAccount(int initialBalance) {
    	this.balance = initialBalance;
    }
    
    public boolean withDraw(int amount) {
    	lock.lock();
        
        try {
        	if (balance < amount) {
            	reutrn false;
            }
            
            balance -= amount;
        } finally {
        	lock.unlock();
        }
        
        return false;
    }
    
    public int getBalance() {
    	lock.lock();
        
        try {
        	return balance;
        } finally {
        	lock.unlock();
        }
    }
}

 

try-finally 구문이 들어가면서 가독성이 조금 떨어지긴 했는데, 작업이 끝난 후 lock 객체의 unlock 메서드를 통해서 락을 반납하는 것이 필수이다.

 

정리

자바 1.5 에서 등장한 Lock 인터페이스와 ReentrantLock 덕분에 synchronized의 단점인 무한 대기와 공정성 문제를 극복하고, 더욱 유연하고 세밀한 스레드 제어가 가능하게 되었다.