스레드에서 Runnable 인터페이스를 사용하는 이유는 유연성, 코드 재사용성, 그리고 객체 지향적 설계와 관련이 있습니다. 스레드를 생성할 때, Runnable 인터페이스를 구현하는 것이 일반적인 방법 중 하나인데, Thread 클래스 자체를 상속받는 방식과 비교했을 때 여러 가지 이점이 있습니다.
자바는 다중 상속을 지원하지 않기 때문에 한 클래스가 두 개 이상의 클래스를 상속받을 수 없습니다. 하지만 인터페이스는 여러 개를 구현할 수 있기 때문에 특정 클래스가 다른 클래스를 상속받으면서 동시에 스레드를 실행할 수 있는 방법이 필요합니다.
예시:
class MyClass extends AnotherClass implements Runnable {
@Override
public void run() {
// 스레드로 실행할 코드
}
}
MyClass myClass = new MyClass();
Thread thread = new Thread(myClass);
thread.start();
위의 코드에서 MyClass는 이미 AnotherClass를 상속받고 있기 때문에, Thread를 상속받을 수 없습니다. 하지만 Runnable 인터페이스를 구현하면 Thread 객체로 실행할 수 있어 다중 상속의 제약을 해결할 수 있습니다.
Runnable 인터페이스를 사용하면 스레드 실행 코드와 비즈니스 로직을 분리할 수 있습니다. 이렇게 하면 스레드 관련 로직과 비즈니스 로직을 별도로 관리할 수 있어 더 깔끔한 코드 구조를 유지할 수 있습니다.
이 방식은 유지보수성과 확장성을 높이는 데 유리합니다.
Runnable 인터페이스를 사용하면 하나의 Runnable 객체를 여러 Thread에서 재사용할 수 있습니다. 즉, 동일한 작업을 여러 스레드에서 실행하고 싶을 때 유용합니다. 반면 Thread 클래스 자체를 상속받는 경우에는 Thread 인스턴스 하나가 생성될 때마다 새로운 스레드가 만들어집니다.
예시:
Runnable task = new MyRunnableTask();
Thread thread1 = new Thread(task);
Thread thread2 = new Thread(task);
thread1.start();
thread2.start();
위 예시에서는 MyRunnableTask 객체가 두 개의 스레드에서 재사용되고 있습니다. 이는 동일한 작업을 여러 스레드에서 수행할 때 유리합니다.
객체 지향 설계 원칙에 부합하기 때문에 Runnable을 사용하는 것이 권장됩니다. Runnable을 사용하면 작업을 스레드로부터 독립된 객체로 다룰 수 있으며, 이는 객체 지향적 설계에 더 적합한 방법입니다.
Runnable 인터페이스를 사용하면 스레드를 생성하는 방식이 더욱 간결해집니다. 특히, 람다 표현식과 함께 사용하면 매우 직관적이고 짧은 코드로 스레드를 생성할 수 있습니다.
예시 (람다 표현식 사용):
Thread thread = new Thread(() -> {
// 스레드로 실행할 코드
});
thread.start();
Runnable은 하나의 추상 메서드 run()만 있기 때문에, 자바 8 이상의 버전에서는 람다 표현식을 사용해 더욱 간결하게 스레드를 생성할 수 있습니다.
Thread 클래스를 상속받아 스레드를 만드는 방식에는 몇 가지 단점이 있습니다:
다중 상속의 제한: 자바는 다중 상속을 지원하지 않기 때문에, 이미 다른 클래스를 상속받고 있는 클래스는 Thread 클래스를 상속받을 수 없습니다. 이로 인해, 스레드 기능과 다른 기능을 동시에 구현해야 할 때 유연성이 떨어집니다.
비즈니스 로직과 스레드의 결합: Thread 클래스를 상속받으면, 스레드 실행 코드와 비즈니스 로직이 결합되어 코드가 분리되지 않고 혼재될 수 있습니다. 이는 코드의 유지보수성을 떨어뜨리고, 스레드와 로직의 책임 분리가 어렵게 만듭니다.
재사용성 부족: Thread 클래스는 한 번만 실행될 수 있기 때문에 같은 스레드 객체를 여러 번 재사용할 수 없습니다. 반면 Runnable은 여러 스레드에서 동시에 재사용할 수 있습니다.
상속의 오버헤드: 자바에서 상속은 클래스 계층 구조에 영향을 미치고 불필요한 오버헤드를 발생시킬 수 있습니다. Runnable은 인터페이스이기 때문에 더 가볍고, 코드가 간결해집니다.
따라서, Thread 클래스를 상속받는 것보다는 Runnable 인터페이스를 사용하는 것이 더 유연하고 재사용성이 높습니다.
Runnable 인터페이스를 사용하는 경우에도, 여러 스레드가 공유 자원에 접근할 때 동기화 문제가 발생할 수 있습니다. 이를 해결하기 위해 몇 가지 기법을 사용할 수 있습니다:
class Counter implements Runnable {
private int count = 0;
public synchronized void increment() {
count++;
}
@Override
public void run() {
for (int i = 0; i < 1000; i++) {
increment();
}
}
public int getCount() {
return count;
}
}
class CounterWithLock implements Runnable {
private int count = 0;
private final Lock lock = new ReentrantLock();
public void increment() {
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
}
@Override
public void run() {
for (int i = 0; i < 1000; i++) {
increment();
}
}
public int getCount() {
return count;
}
}
class AtomicCounter implements Runnable {
private AtomicInteger count = new AtomicInteger(0);
@Override
public void run() {
for (int i = 0; i < 1000; i++) {
count.incrementAndGet();
}
}
public int getCount() {
return count.get();
}
}
이와 같은 방법을 사용하면 Runnable 인터페이스로 구현한 스레드에서도 안전하게 동기화를 처리할 수 있습니다.
Callable 인터페이스는 Runnable과 비슷하지만, 반환값을 가질 수 있으며 예외를 던질 수 있는 인터페이스입니다. Runnable은 반환값이 없고 예외 처리가 제한적이지만, Callable은 작업의 결과를 반환할 수 있어 다음과 같은 경우에 유용합니다:
Callable<Integer> task = () -> {
return 42; // 작업의 결과
};
ExecutorService executor = Executors.newFixedThreadPool(1);
Future<Integer> future = executor.submit(task);
try {
Integer result = future.get(); // 결과를 얻음
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
}
Callable<Integer> task = () -> {
if (someCondition) {
throw new Exception("예외 발생");
}
return 42;
};
따라서 결과를 반환하거나 예외 처리가 필요한 작업에서는 Runnable 대신 Callable을 사용하는 것이 적합합니다.