본문 바로가기
[ JAVA ]/JAVA

[ Java ] Runnable 인터페이스 개념 및 Thread 단일 연산

by 환이s 2024. 9. 23.


Intro

 

안녕하세요. 환이s입니다👋

이 전 포스팅에서 동시성을 위한 3가지 키워드에 대해 알아봤는데요.

실무에서 여러 프로젝트를 경험하면서 기초 부분을 더욱더 탄탄하게 만들어야겠다는 생각을 갖고

복습하는 일상을 보내고 있습니다🙂

 

자바 병렬 프로그래밍

 

저는 자바 병렬 프로그래밍이라는 책을 참고해서 해당 포스팅을 작성하려고 합니다.오늘은 Thread 단일 연산부터 등장했던 Runnable 인터페이스에 대해 알아보겠습니다.


Runnable 이란?

 

Runnable 인터페이스란, Java에서 간단한 Thread를 생성하기 위한 인터페이스로서,

메서드인 run()을 오버라이딩하여 사용할 수 있습니다.

 

이 인터페이스를 구현하여 만든 Thread는 Thread 클래스를 상속하지 않아도 되며,

전통적인 방법보다 더 쉽게 Thread를 생성할 수 있게 합니다.

 

이해도를 높이기 위해 예제 코드로 알아보겠습니다.

 

[ 전통적인 방법(Thread 클래스를 상속) ]

class TestThread extends Thread {
    @Override
    public void run() {
        for (int i = 0; i < 5; i++) {
            System.out.println("Thread: " + i);
            try {
                Thread.sleep(500); // 0.5초 대기
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

public class TraditionalThreadExample {
    public static void main(String[] args) {
        MyThread thread = new MyThread();
        thread.start(); // 스레드 시작
    }
}

 

전통적인 방법은 Thread 클래스를 상속하여 Thread를 생성하는 방식입니다.

이 방법에서는 run() 메서드를 오버라이드하여 Thread에서 실행할 작업을 정의합니다.

 

[ Runnable 인터페이스 사용 예시 ]

class TestRunnable implements Runnable {
    @Override
    public void run() {
        for (int i = 0; i < 5; i++) {
            System.out.println("Runnable: " + i);
            try {
                Thread.sleep(500); // 0.5초 대기
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

public class RunnableExample {
    public static void main(String[] args) {
        MyRunnable myRunnable = new MyRunnable();
        Thread thread = new Thread(myRunnable); // Runnable을 Thread에 전달
        thread.start(); // 스레드 시작
    }
}

 

Thread: 0
Runnable: 0
Thread: 1
Runnable: 1
Thread: 2
Runnable: 2
Thread: 3
Runnable: 3
Thread: 4
Runnable: 4

 

출력 결과를 확인해 보면

각각의 Thread가 0에서 4까지의 숫자를 출력하고, 각 숫자 사이에 0.5초의 지연되는 결과와 모두 동일한 값이 출력되는 걸 확인할 수 있습니다.

 

Runnable 인터페이스를 구현한 클래스는 Thread 클래스를 상속받지 않기 때문에 다른 클래스에서도 재사용할 수 있는데, 즉, 여러 스레드에서 동일한 작업을 수행할 수 있습니다.

 

또한 다른 클래스와의 결합도가 낮아져,  Runnable 객체를 다른 스레드에 전달할 수 있는 코드의 유연성을 향상시키고, 여러 Thread가 동일한 작업을 수행할 수 있어, 자원을 효율적으로 공유할 수 있습니다.

 

정리하자면

기존의 Thread를 상속받아 Thread 인스턴스를 생성하는 방법보다 더 쉽고 편리하게 Thread를 생성할 수 있습니다.


Thread - 단일 연산

 

마지막으로 Runnable 인터페이스를 사용해서 단일 연산 식을 사용해 보겠습니다.

 

단일 연산 식을 사용할 때 안전한 상태의 Thread를 작동하려면 어떻게 해야 하는지 실패/성공 예제 코드로 알아보겠습니다.

 

먼저 단일 연산 실패 예제 코드입니다.

/*
*   Thread - 단일 연산 실패 예제 코드
*
* */
public class UnsafeStatelessService {

    private int state; // 상태 추가 -> 초기값 = 0으로 시작

    public UnsafeStatelessService() {
        this.state = 0; // 초기 상태
    }

    // 상태를 변경하는 메서드 (synchronized 없이)
    // 이 메서드는 synchronized로 선언되지 않았다. 여러 스레드가 동시에 이 메서드를 호출할 수 있습니다.
    public void incrementState() {
        state++;
    }

    // 현재 상태를 가져오는 메서드
    public int getState() {
        return state;
    }

    public static void main(String[] args) {
        UnsafeStatelessService service = new UnsafeStatelessService();

        // 여러 스레드에서 상태를 변경하는 예제
        Runnable task = () -> {
            for (int i = 0; i < 1000; i++) {
                service.incrementState();
            }
        };
        Thread thread1 = new Thread(task);
        Thread thread2 = new Thread(task);

        thread1.start();
        thread2.start();

        try {
            // 두 스레드가 동시에 시작되며, 각 스레드는 1000번씩 incrementState() 메서드를 호출한다.
            // 그러나 동기화가 없으므로, 두 스레드가 동시에 state를 증가시키면서 데이터 경쟁이 발생할 수 있다.
            thread1.join();
            thread2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        // 두 스레드가 각각 1000번씩 메서드를 호출하지만, 동기화가 없으므로 최종적으로 state 변수의 값은 예상과 다를 수 있다.
        System.out.println("최종 상태 :" + service.getState());
    }
}

 

위 코드를 실행하면 state의 최종 값이 2000이 아닌, 다른 값이 나올 가능성이 높습니다.

예를 들어, 1998,1999 또는 2000이 아닐 수 있는데, 이는 두 스레드가 동시에 state++를 수행하기 때문입니다.

 

이 경우, 하나의 스레드가 state 값을 읽고 증가시키는 동안 다른 스레드가 같은 값을 읽고 증가시킬 수 있어,

최종적으로 예상치 못한 결과가 발생할 수 있습니다.

 

위 코드는 동기화가 없을 경우의 위험성을 보여주는 예제입니다.

 

3차 테스트를 진행해서 나온 결과는 다음과 같습니다.

단일 연산 실패 예제 코드 결과

 

그렇다면 반대로 성공 예제 코드를 알아보겠습니다.

/*
*   Thread - 단일 연산 예제 코드
*
* */
public class StatelessService {

    private int state; // 상태 추가 -> 초기값 = 0으로 시작

    public StatelessService() {
        this.state = 0; // 초기 상태
    }

    // 상태를 변경하는 메서드
    // 이 메서드는 synchronized로 선언되어 있어, 여러 스레드가 동시에 이 메서드를 호출할 수 없다. 
    //즉, 한 스레드가 이 메서드를 실행할 때 다른 스레드는 기다려야 한다.
    public synchronized void incrementState() {
        state++;
    }

    // 현재 상태를 가져오는 메서드
    public int getState() {
        return state;
    }

    public static void main(String[] args) {
        StatelessService service = new StatelessService();

        // 여러 스레드에서 상태를 변경하는 예제
        // Runnable task는 for 루프를 통해 incrementState() 메서드를 1000번 호출한다. 
        // 두 개의 스레드(thread1과 thread2)가 이 작업을 수행한다.
        Runnable task = () -> {
            for (int i = 0; i < 1000; i++) {
                service.incrementState();
            }
        };

        Thread thread1 = new Thread(task);
        Thread thread2 = new Thread(task);

        thread1.start();
        thread2.start();

        try {
            //Thread start =  두 스레드가 동시에 시작되며, 
            //각 스레드는 1000번씩 incrementState() 메서드를 호출한다.. 
            //따라서 총 2000번의 호출이 이루어진다.
            thread1.join();
            thread2.join(); // 내려가지 말고 기다려라.
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        // 두 스레드가 각각 1000번씩 메서드를 호출하기 때문에, 
        // 최종적으로 state 변수의 값은 2000이 된다.
        System.out.println("최종 상태 :" + service.getState());
        
    }
}

 

위 코드에서 incrementState()synchronized로 선언해서 코드를 작성해 보았습니다.

synchronized로 선언되어 있기 때문에 두 스레드가 동시에 state를 증가시키는 일이 없어서

데이터 경쟁(Data Race) 문제는 발생하지 않습니다.

 

스레드 1과 스레드 2가 각각 안전하게 state를 증가시키고, 최종적으로 state에 2000 값이 될 때까지 각 스레드는 메서드가 실행되는 동안 다른 스레드의 접근을 막아줍니다.

 

결론적으로, 스레드가 안전하게 상태를 변경하도록 설계된 덕분에 예상한 대로 결과를 출력할 수 있습니다.

단일 연산 성공 예제 코드 결과

 

 

데이터 경쟁(Data Race)란?

 

데이터 경쟁(Data Race)은 멀티스레딩 프로그래밍에서 발생할 수 있는 문제로, 두 개 이상의 스레드가 동시에 공유 데이터에 접근하고, 그중 적어도 하나의 스레드가 데이터를 수정하는 경우를 뜻합니다.

 

이로 인해 예기치 않은 결과나 버그가 발생할 수 있습니다.

 

[ 데이터 경쟁이 발생하는 경우 ]

  • 동시 접근 : 두 개 이상의 스레드가 동일한 변수 또는 데이터 구조에 동시에 접근하는 경우
  • 수정 중인 데이터 : 그 중 적어도 하나의 스레드가 해당 데이터를 수정하는 경우
  • 정의되지 않은 행동 : 데이터 경쟁이 발생하면, 프로그램의 실행 결과가 예측할 수 없게 됩니다. 이는 특정 실행에서는 의도한 결과를 얻을 수 있지만, 다른 실행에서는 전혀 다른 결과가 나올 수 있습니다.

[ 간단한 예제 코드 ]

public class DataRaceExample{
    private static int counter = 0;

    public static void main(String[] args){
        Runnable incrementTask = () -> {
            for (int i = 0; i < 1000; i++) {
                counter++; // 데이터 레이스 발생 가능
            }
        };

        Thread thread1 = new Thread(incrementTask);
        Thread thread2 = new Thread(incrementTask);

        thread1.start();
        thread2.start();

        try {
            thread1.join();
            thread2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("Final counter value: " + counter);
    }
}

 

위 코드를 테스트해 봤을 때, 아래와 동일한 결과가 출력되었습니다.

Final counter value: 2000
Final counter value: 1999
Final counter value: 1985

 

정상적으로 실행되었을 경우, counter의 최종 값은 2000이 되어야 합니다.

(각 스레드가 1000번씩 증가시키므로)

 

그러나 데이터 경쟁이 발생하면 최종 값은 2000이 아닐 수 있으며, 위 결과처럼 1999,1985 또는 더 낮은 값이 나올 수도 있습니다.

 

[ 데이터 경쟁을 방지하려면? ]

 

찾아보면 방지하는 방법은 여러 있지만, 3가지 정도만 정리해 보겠습니다.

 

  1. 동기화(Synchronization) : synchronized키워드나 다른 동기화 메커니즘을 사용하여 스레드가 공유 데이터에 접근할 때 적절히 제어합니다.
  2. Lock 사용 : Java의 ReentrantLock과 같은 Lock 객체를 사용하여 더 세밀한 제어를 할 수 있습니다.
  3. 불변 객체(Immutable Objects) : 가능한 데이터가 변경되지 않도록 불변 객체를 사용하여 데이터 경쟁을 방지합니다.

마치며

 

오늘은 Thread 단일 연산 식 예제를 Runnable 인터페이스를 응용해서 알아봤습니다.

 

멀티스레드 환경에서 데이터를 안전하게 처리하기 위해서는 반드시 동기화 메커니즘을 적용해야 하고,

그렇지 않을 경우 예측할 수 없는 결과가 발생할 수 있다는 점을 명심해야 할 거 같습니다.

 

동시성 문제는 시각화가 어렵다 보니 관심을 갖고 공부하는데,

확실히 기초 부분에서 놓치고 있는 부분이 많다는 걸 인지할 수 있었습니다..😅

 

언젠가는 자연스럽게 코드를 짜내가는 시니어 개발자가 되기를.. 바라며

다음 포스팅으로 찾아뵙겠습니다👋

728x90