4주차. 자바의 스레드(Thread) (2)

박서영·2025년 9월 26일

들어가기 전에: 앞의 스레드 내용 복습

  • 멀티스레드 구현 전 몇 개의 작업을 병렬적으로 실행할 지 결정해야한다.

  • 자바에서는 다중상속이 불가능하기에, 이를 생각하면 Runnable 인터페이스를 사용하는 방식으로 스레드를 생성하는 것이 좋다.

  • 주의할 부분:

    • run() 메소드 종료 시 스레드는 종료된다.
    • 스레드가 계속 살아있게 하기위해서는 무한루프를 run() 메소드 내에 작성하면된다.
    • 한 번 종료한 스레드는 다시 시작할 수 없기에, 다시 스레드 객체를 생성하고 start()를 호출해야한다.


스레드(Thread)의 상태

스레드의 상태

스레드의 상태로는 생성, 준비, 실행, 대기, 종료가 있다.

  • 생성 상태:

    • 스레드 클래스를 이용해 새로운 스레드 객체가 생성된다.
    • start() 메소드를 사용해 생성된 스레드를 시작한다.
  • 실행 가능 상태:스레드가 스케줄링 큐에 넣어지고 스케줄러에 의해 우선순위에 따라서 실행된다.

  • 실행 가능 상태에서 아래와 같은 이벤트가 발생하면, 실행중지 상태가 된다.

    • 스레드가 wait(), sleep()을 호출하는 경우
    • 스레드가 입출력 작업을 위해 대기하는 경우

스레드 상태 제어

실행중인 스레드의 상태를 변경하는 것을 말한다.

상태 열거상수 설명
객체 생성 NEW 스레드 객체가 생성되었지만, 아직 start()가 호출되지 않음
실행 준비 RUNNABLE start()가 호출되어 실행 가능한 상태. 실제 실행 중일 수도 있고, CPU를 기다리는 중일 수도 있음
두 칸 병합 BLOCKED 다른 스레드가 가진 락(lock)을 기다리는 중이라 실행되지 못하는 상태
WAITING wait()를 호출한 상태로 스레드 동기화를 위해 사용한다. 다른 스레드의 명시적인 신호(notify 등)를 기다리는 상태로 시간 지정 없이 무한정 대기.
TIMED_WAITING 지정된 시간 동안 대기하는 상태. 예) Thread.sleep()
종료 TERMINATED 스레드의 실행이 종료된 상태.


스레드 동기화(Thread synchronization)

스레드 동기화는 공유 데이터에 동시에 접근하는 문제를 해결하기 위한 방법이다. 멀티스레드를 사용하다보면, 공유 데이터에 다수의 스레드가 접근하게되는데, 이 접근이 배타적으로 이루어지도록하기 위해 스레드 동기화를 한다.

공유 데이터를 접근하는 모든 스레드를 한 줄로 세우고, 하나의 스레드가 공유 데이터에 대한 작업을 끝낼 때까지 다른 스레드가 대기하도록하여 배타적인 접근을 보장한다.

자바에서는 스레드 동기화를 위해 아래와 같은 두 가지 방법을 쓸 수 있다.
1. synchronized를 통해 동기화 블록 지정
2. wait()-notify() 메소드를 통해 스레드 실행 순서를 제어함

스레드 동기화를 위해서는 synchronized 키워드를 사용한다. 이 키워드는 하나의 스레드가 독점 실행해야하는 부분 또는 코드를 표시하는 키워드로 임계영역을 지정한다. 메소드 전체 또는 코드 블록에 지정해 한 번에 하나의 스레드만이 공유 데이터에 접근할 수 있도록 제어하게된다.


실습5: MyBank

은행 계좌에 입출금하는 시나리오로 스레드를 2개 만들어, 각각의 스레드에서 입금과 출금을 반복한다.

class BankAccount {

    int balance;

    public synchronized void deposit (int amount) {
        System.out.println("+" + amount);
        this.balance += amount;
    }

    public synchronized void withdraw(int amount) {
        System.out.println("-"+ amount);
        this.balance -= amount;
    }

    public synchronized int getBalane() {
        System.out.println("잔고 : "+this.balance);
        return this.balance;
    }
}

BankAccount 클래스의 코드이다. 입금과 출금을 할 수 있는 메소드와 잔고를 확인할 수 있는 메소드가 정의되어있다. 각 메소드에 synchronized라는 키워드를 붙여서 여러 개의 스레드가 실행 중이어도, 한 번에 하나의 스레드만 메소드에 접근할 수 있도록 한다.

class User implements Runnable {
    BankAccount account;

    public User(BankAccount account) {
        this.account = account;
    }

    @Override
    public void run() {
        for (int i=0; i<30; i++) {
            account.deposit(10000);
            try {
                Thread.sleep(20);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            account.withdraw(10000);
            if (account.getBalane() < 0) {
                System.out.println("오류발생!");
            }
        }
    }
}

public class MyBank {
    static BankAccount account = new BankAccount();

    public static void main(String[] args) {
        Thread one = new Thread(new User(account));
        Thread two = new Thread(new User(account));

        one.start();
        two.start();
    }
}

메인 메소드와 User 클래스 코드이다. 만약 synchronized 키워드 없이 실행한다면, 스레드 2개가 실행되고 있으니, 두 스레드가 동시에 공유 데이터인 BankAccount에 접근해 입출금을 시도할 수 있을 것이다.


✚ 보충
synchronized키워드가 없으면, 두 스레드가 동시에 BankAccount에 접근할 수 있다는 것까지는 알겠는데, 이러면 어떤 문제가 발생할 수 있는지 궁금해서 제미나이한테 물어봤다.

두 스레드가 하나의 공유 자원에 동시에 접근하면 발생하는 핵심적인 문제는 경쟁 상태(Race Condition) 때문에 데이터의 일관성이 깨지는 것이다. 즉, 위와 같은 예에서는 계좌 잔액(balane)이 예상과 다르게 비정상적인 값을 갖게될 수 있다.

경쟁 상태(Race Condition)이란 둘 이상의 스레드가 공유 데이터에 동시에 접근해 조작하려 할 때, 접근 순서에 따라 결과가 달라지는 상황을 말한다.

예로, this.balane += amount;라는 코드가 한 줄 처럼 보이지만, 컴퓨터 내부에서 여러 단계로 실행된다는 점에서 이런 동시 접근 시 문제가 발생한다.

1. 메모리에서 balane 값 읽어오기
2. 읽어온 값에 amount 더하기
3. 계산된 최종값을 다시 메모리의 balane에 쓰기

실제로 컴퓨터 내부에서는 위와 같은 단계로 코드를 수행한다. 하지만 만약 synchronized 키워드가 없다면, 저 3단계 중간에 다른 스레드가 끼어들 수 있게되는 것이다.

join()

join() 메소드는 해당 스레드가 완료될 때까지 다른 스레드가 기다리게하는 메소드이다. 계산 작업을 하는 스레드가 모든 계산 작업을 마쳤을 때, 결과값을 받아 이용해야하는 경우 주로 사용한다.

public static void main(String[] args) {
	Thread t1 = new Thread();
    Thread t2 = new Thread(new Runnable() {
    	public void run() {
        	System.out.println("스레드2 실행");
        }
    });
    
    t1.start();
    t2.start();
    
    
    try {
            t2.join();
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }

	System.out.println("메인 스레드");
}

위와 같은 코드에서 t2.join() 코드가 있을 때는 메인 스레드가 t2의 작업이 종료될 때까지 대기하게된다. 즉, 항상 "스레드2 실행"이라는 출력문이 출력되고, 그 후에 "메인 스레드"라는 출력문이 출력되게된다.

실습7: 아기돼지삼형제 - join()

아기돼지 삼형제는 자유롭게 놀다가도 집에 들어올 때는 서로를 기다렸다가 함께 돌아온다는 시나리오의 실습이다.

각자 노는 것을 우선 각각의 다른 스레드로 실행한다. 그리고 "서로를 기다렸다가 돌아온다"의 조건을 만족시키기 위해서, 각 스레드의 종료까지 대기하게하는 join() 메소드를 사용해 구현한다.

public class Pig implements Runnable {
    private int time;
    private String name;

    public Pig(String name) {
        this.time = (int)(Math.random()*100)*30;
        this.name = name;
    }

    @Override
    //time만큼 쉬기
    public void run() {

        System.out.println(this.name+"돼지 놀기 시작 "+time+"초");

        try {
            Thread.sleep(time);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }

        System.out.println(this.name+"돼지 놀기 끝 "+time+"초");
    }
}

Runnable 인터페이스를 구현하는 아기돼지(Pig) 클래스이다. time 만큼 Thread.sleep()을 통해 일시정지하게되기 때문에, 해당 시간만큼 기다렸다가 스레드가 종료되게될 것이다.

public class Pig implements Runnable {
    private int time;
    private String name;

    public Pig(String name) {
        this.time = (int)(Math.random()*10+1)*1000;
        this.name = name;
    }

    @Override
    //time만큼 쉬기
    public void run() {

        System.out.println(this.name+"돼지 놀기 시작 "+time/1000+"초");

        try {
            Thread.sleep(time);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }

        System.out.println(this.name+"돼지 놀기 끝 "+time/1000+"초");
    }
}

실행을 위한 메인 메소드 및 메인 스레드이다. 아기돼지 세 마리를 각각 스레드로 생성해주고, start() 메소드를 통해 실행한다.

시나리오의 조건에 따라 마지막 출력문이 세 돼지가 모두 노는 것을 끝내고, 즉 세 개의 스레드의 실행이 종료된 후에 출력되어야한다. 따라서 메인 스레드가 세 개 스레드의 종료 전까지 중지될 수 있게 join() 메소드를 사용한다.

단, join() 메소드를 사용하기 위해서는 예외 처리가 필수적이다. 여기서는 try-catch문으로 감쌌다.

실행 결과는 아래와 같다. 각 스레드가 모두 끝난 후에, 마지막 출력문이 출력되는 것을 확인할 수 있다.

소감

생각보다 정리하다보니 헷갈리거나 제대로 이해하지 못한 부분이 많았다. 사실 지금 정리한 내용에도 잘못된 내용이 포함되어 있을 수 있지 않을까하는 생각이다. 또, 익명클래스로 스레드 생성하는 것도 꽤 유용한 것 같았다.. 아, 스레드 관련 내용이 많아서 2개로 나눠서 쓰게되었다..ㅎ 정리 못한 실습도 몇 개 있는데...나중에 시간 나면 추가적으로 정리해봐야할 것..같다...ㅎ

profile
이불 밖은 위험해.

0개의 댓글