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가지 정도만 정리해 보겠습니다.
- 동기화(Synchronization) : synchronized키워드나 다른 동기화 메커니즘을 사용하여 스레드가 공유 데이터에 접근할 때 적절히 제어합니다.
- Lock 사용 : Java의 ReentrantLock과 같은 Lock 객체를 사용하여 더 세밀한 제어를 할 수 있습니다.
- 불변 객체(Immutable Objects) : 가능한 데이터가 변경되지 않도록 불변 객체를 사용하여 데이터 경쟁을 방지합니다.
마치며
오늘은 Thread 단일 연산 식 예제를 Runnable 인터페이스를 응용해서 알아봤습니다.
멀티스레드 환경에서 데이터를 안전하게 처리하기 위해서는 반드시 동기화 메커니즘을 적용해야 하고,
그렇지 않을 경우 예측할 수 없는 결과가 발생할 수 있다는 점을 명심해야 할 거 같습니다.
동시성 문제는 시각화가 어렵다 보니 관심을 갖고 공부하는데,
확실히 기초 부분에서 놓치고 있는 부분이 많다는 걸 인지할 수 있었습니다..😅
언젠가는 자연스럽게 코드를 짜내가는 시니어 개발자가 되기를.. 바라며
다음 포스팅으로 찾아뵙겠습니다👋
'[ JAVA ] > JAVA' 카테고리의 다른 글
[ Java ] 동시성 제어를 위한 세 가지 키워드 / CAS 알고리즘 개념 알아가기 (0) | 2024.08.21 |
---|---|
[ Java ] Optional 개념 및 올바른 사용법 알아가기 (1) | 2024.06.10 |
[ Java ] java.util.stream.IntStream 주요 메서드 정리 (1) | 2024.05.23 |
[ JAVA ] Iterator 개념 및 예제 (0) | 2023.07.21 |
[ Java ] Lambda (0) | 2023.01.17 |