본문 바로가기
Java

[Java] synchronized를 활용한 멀티스레드 동기화 처리

by teamnova 2025. 3. 13.
728x90

안녕하세요.

오늘은 자바의 동기화(Synchronization) 개념을 활용해서 멀티스레드 환경에서 발생할 수 있는 데이터 충돌 문제와 이를 해결하는 방법을 살펴보겠습니다.

동기화(Synchronization)란?

동기화는 프로세스 또는 스레드들이 수행되는 시점을 조절하여 서로가 알고 있는 정보를 일치시키는 것을 말합니다. 여러 스레드가 동시에 하나의 공유 자원(예: 변수, 객체 등)에 접근할 때, 데이터 충돌(Data Race)이나 불일치와 같은 문제가 생길 수 있습니다.

 

은행 계좌를 예로 들어보겠습니다. 계좌에는 잔액(balance)이 있고, 여러 스레드가 동시에 입금과 출금을 처리한다고 가정해 봅시다. 동기화를 하지 않을 경우, 아래와 같은 상황이 발생할 수 있습니다.

  1. 데이터 손실: 두 스레드가 동시에 잔액을 수정하면, 한 스레드의 변경 내용이 덮어씌워질 수 있습니다.
  2. 비일관성: 잔액이 음수가 되는 등, 논리적으로 불가능한 상태가 발생할 수 있습니다.

아래는 동기화를 하지 않고 구현한 코드입니다.

// 동기화가 없는 은행 계좌 클래스
class UnsafeBankAccount {
    private int balance;
    private String accountNumber;

    public UnsafeBankAccount(String accountNumber, int initialBalance) {
        this.accountNumber = accountNumber;
        this.balance = initialBalance;
    }

    // 동기화되지 않은 입금 메서드
    public void deposit(int amount) {
        int currentBalance = balance;
        // 실제 업무처리 시간을 시뮬레이션하기 위한 지연
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        balance = currentBalance + amount;
        System.out.println(Thread.currentThread().getName() + 
            " - 입금: " + amount + "원, 잔액: " + balance + "원");
    }

    // 동기화되지 않은 출금 메서드
    public boolean withdraw(int amount) {
        int currentBalance = balance;
        if (currentBalance < amount) {
            System.out.println(Thread.currentThread().getName() + 
                " - 잔액 부족으로 출금 실패 (요청: " + amount + "원, 잔액: " + balance + "원)");
            return false;
        }
        
        // 실제 업무처리 시간을 시뮬레이션하기 위한 지연
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        
        balance = currentBalance - amount;
        System.out.println(Thread.currentThread().getName() + 
            " - 출금: " + amount + "원, 잔액: " + balance + "원");
        return true;
    }

    public int getBalance() {
        return balance;
    }
}

 

위 코드는 동기화를 적용하지 않았기 때문에, 여러 스레드가 동시에 balance를 수정하려고 하면 데이터가 꼬일 가능성이 있습니다.

예를 들어, 스레드 A가 잔액을 읽어 1000원에서 10원을 더하려는 순간, 스레드 B가 잔액을 읽고 10원을 빼는 작업을 동시에 수행할 수 있습니다. 결과적으로 최종 잔액이 예상과 다르게 계산될 수 있습니다.

 

시뮬레이션

아래 코드를 통해서, 두 개의 스레드가 동시에 계좌에 접근하여 입금과 출금을 처리하는 상황을 시뮬레이션해 보겠습니다

public class Main {
    public static void main(String[] args) {
        UnsafeBankAccount account = new UnsafeBankAccount("123-456", 1000);

        // 스레드 1: 입금 작업
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 5; i++) {
                account.deposit(100); // 100원 입금
            }
        }, "스레드-1");

        // 스레드 2: 출금 작업
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 5; i++) {
                account.withdraw(50); // 50원 출금
            }
        }, "스레드-2");

        // 스레드 시작
        t1.start();
        t2.start();

        // 스레드 종료 대기
        try {
            t1.join();
            t2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        // 최종 잔액 출력
        System.out.println("최종 잔액: " + account.getBalance() + "원");
    }
}

 

실행 결과 예시

스레드-1 - 입금: 100원, 잔액: 1100원
스레드-2 - 출금: 50원, 잔액: 1050원
스레드-1 - 입금: 100원, 잔액: 1150원
스레드-2 - 출금: 50원, 잔액: 1050원
스레드-1 - 입금: 100원, 잔액: 1150원
스레드-2 - 출금: 50원, 잔액: 1100원
스레드-1 - 입금: 100원, 잔액: 1200원
스레드-2 - 출금: 50원, 잔액: 1150원
스레드-1 - 입금: 100원, 잔액: 1250원
스레드-2 - 출금: 50원, 잔액: 1200원
최종 잔액: 1200원

 

결과는 실행할 때마다 달라질 수 있습니다.

  1. 데이터 손실
    스레드-1이 입금 작업을 수행하고 스레드-2가 출금 작업을 수행하는 과정에서, 잔액이 덮어씌워지며 중간 계산이 손실되었습니다. 예를 들어, 스레드-1이 잔액을 1150원으로 업데이트했지만, 스레드-2가 이와 동시에 작업을 수행하면서 잔액이 1100원으로 잘못 기록되었습니다.
  2. 비일관성
    입금과 출금이 정상적으로 이루어졌음에도 불구하고, 최종 잔액은 계산상 올바르지 않은 값(1200원)으로 나타났습니다. 동기화가 없기 때문에 스레드 간의 작업 순서가 뒤섞이며 이러한 문제가 발생합니다.

synchronized를 활용한 동기화 처리

위 문제를 해결하기 위해 자바의 synchronized 키워드를 사용해 동기화를 적용해 보겠습니다.

synchronized는 특정 코드 블록이나 메서드에 대해 한 번에 하나의 스레드만 접근할 수 있도록 보장합니다.

synchronized 키워드를 사용하여 입금과 출금 메서드에 동기화를 적용하면, 한 번에 하나의 스레드만 해당 메서드에 접근할 수 있도록 제한할 수 있습니다.

 

// 동기화된 은행 계좌 클래스
class SafeBankAccount {
    private int balance;
    private String accountNumber;

    public SafeBankAccount(String accountNumber, int initialBalance) {
        this.accountNumber = accountNumber;
        this.balance = initialBalance;
    }

    // 동기화된 입금 메서드
    public synchronized void deposit(int amount) {
        int currentBalance = balance;
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        balance = currentBalance + amount;
        System.out.println(Thread.currentThread().getName() + 
            " - 입금: " + amount + "원, 잔액: " + balance + "원");
    }

    // 동기화된 출금 메서드
    public synchronized boolean withdraw(int amount) {
        int currentBalance = balance;
        if (currentBalance < amount) {
            System.out.println(Thread.currentThread().getName() + 
                " - 잔액 부족으로 출금 실패 (요청: " + amount + "원, 잔액: " + balance + "원)");
            return false;
        }
        
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        
        balance = currentBalance - amount;
        System.out.println(Thread.currentThread().getName() + 
            " - 출금: " + amount + "원, 잔액: " + balance + "원");
        return true;
    }

    public synchronized int getBalance() {
        return balance;
    }
}

 

이제 동기화된 은행 계좌 클래스를 테스트해 보겠습니다.

public class Main {
    public static void main(String[] args) {
        SafeBankAccount account = new SafeBankAccount("123-456", 1000);

        // 스레드 1: 입금 작업
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 5; i++) {
                account.deposit(100); // 100원 입금
            }
        }, "스레드-1");

        // 스레드 2: 출금 작업
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 5; i++) {
                account.withdraw(50); // 50원 출금
            }
        }, "스레드-2");

        // 스레드 시작
        t1.start();
        t2.start();

        // 스레드 종료 대기
        try {
            t1.join();
            t2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        // 최종 잔액 출력
        System.out.println("최종 잔액: " + account.getBalance() + "원");
    }
}

 

실행 결과 예시

스레드-1 - 입금: 100원, 잔액: 1100원
스레드-2 - 출금: 50원, 잔액: 1050원
스레드-1 - 입금: 100원, 잔액: 1150원
스레드-2 - 출금: 50원, 잔액: 1100원
스레드-1 - 입금: 100원, 잔액: 1200원
스레드-2 - 출금: 50원, 잔액: 1150원
스레드-1 - 입금: 100원, 잔액: 1250원
스레드-2 - 출금: 50원, 잔액: 1200원
스레드-1 - 입금: 100원, 잔액: 1300원
스레드-2 - 출금: 50원, 잔액: 1250원
최종 잔액: 1250원