Runnable 을 쓰는이유

서버란·2024년 9월 19일

자바 궁금증

목록 보기
21/35

스레드에서 Runnable 인터페이스를 사용하는 이유는 유연성, 코드 재사용성, 그리고 객체 지향적 설계와 관련이 있습니다. 스레드를 생성할 때, Runnable 인터페이스를 구현하는 것이 일반적인 방법 중 하나인데, Thread 클래스 자체를 상속받는 방식과 비교했을 때 여러 가지 이점이 있습니다.

1. 자바의 다중 상속 제한 극복

자바는 다중 상속을 지원하지 않기 때문에 한 클래스가 두 개 이상의 클래스를 상속받을 수 없습니다. 하지만 인터페이스는 여러 개를 구현할 수 있기 때문에 특정 클래스가 다른 클래스를 상속받으면서 동시에 스레드를 실행할 수 있는 방법이 필요합니다.

예시:

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 객체로 실행할 수 있어 다중 상속의 제약을 해결할 수 있습니다.

2. 스레드와 비즈니스 로직의 분리

Runnable 인터페이스를 사용하면 스레드 실행 코드와 비즈니스 로직을 분리할 수 있습니다. 이렇게 하면 스레드 관련 로직과 비즈니스 로직을 별도로 관리할 수 있어 더 깔끔한 코드 구조를 유지할 수 있습니다.

  • Thread 상속 방식: 스레드와 로직이 혼합되어 관리됩니다.
  • Runnable 인터페이스: 로직을 담고 있는 클래스는 스레드 실행에만 필요한 코드를 Runnable로 구현하고, 실행은 Thread 객체가 담당합니다.

이 방식은 유지보수성과 확장성을 높이는 데 유리합니다.

3. 코드 재사용성

Runnable 인터페이스를 사용하면 하나의 Runnable 객체를 여러 Thread에서 재사용할 수 있습니다. 즉, 동일한 작업을 여러 스레드에서 실행하고 싶을 때 유용합니다. 반면 Thread 클래스 자체를 상속받는 경우에는 Thread 인스턴스 하나가 생성될 때마다 새로운 스레드가 만들어집니다.

예시:

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

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

위 예시에서는 MyRunnableTask 객체가 두 개의 스레드에서 재사용되고 있습니다. 이는 동일한 작업을 여러 스레드에서 수행할 때 유리합니다.

4. 객체 지향적 설계

객체 지향 설계 원칙에 부합하기 때문에 Runnable을 사용하는 것이 권장됩니다. Runnable을 사용하면 작업을 스레드로부터 독립된 객체로 다룰 수 있으며, 이는 객체 지향적 설계에 더 적합한 방법입니다.

5. 간결한 구현

Runnable 인터페이스를 사용하면 스레드를 생성하는 방식이 더욱 간결해집니다. 특히, 람다 표현식과 함께 사용하면 매우 직관적이고 짧은 코드로 스레드를 생성할 수 있습니다.

예시 (람다 표현식 사용):

Thread thread = new Thread(() -> {
    // 스레드로 실행할 코드
});
thread.start();

Runnable은 하나의 추상 메서드 run()만 있기 때문에, 자바 8 이상의 버전에서는 람다 표현식을 사용해 더욱 간결하게 스레드를 생성할 수 있습니다.

결론: 언제 Runnable을 사용해야 할까?

  • 다른 클래스를 상속받고 있는 경우: 자바의 다중 상속 제한 때문에 Thread 클래스를 상속받을 수 없을 때 Runnable을 사용합니다.
  • 작업과 스레드를 분리하고 싶을 때: 비즈니스 로직과 스레드 관리 로직을 명확하게 분리하여 코드의 유지보수성을 높이기 위해 사용합니다.
  • 여러 스레드에서 동일한 작업을 재사용할 때: 동일한 Runnable 인스턴스를 여러 스레드에서 재사용해야 할 경우 적합합니다.
  • 간결한 코드 구현: 람다 표현식을 활용하여 간결한 스레드 생성이 가능하기 때문에, 코드를 단순화하고 싶을 때 Runnable을 사용합니다.

Q1. Thread 클래스를 상속받아서 스레드를 만들면 어떤 단점이 있을까요?

Thread 클래스를 상속받아 스레드를 만드는 방식에는 몇 가지 단점이 있습니다:

  1. 다중 상속의 제한: 자바는 다중 상속을 지원하지 않기 때문에, 이미 다른 클래스를 상속받고 있는 클래스는 Thread 클래스를 상속받을 수 없습니다. 이로 인해, 스레드 기능과 다른 기능을 동시에 구현해야 할 때 유연성이 떨어집니다.

  2. 비즈니스 로직과 스레드의 결합: Thread 클래스를 상속받으면, 스레드 실행 코드와 비즈니스 로직이 결합되어 코드가 분리되지 않고 혼재될 수 있습니다. 이는 코드의 유지보수성을 떨어뜨리고, 스레드와 로직의 책임 분리가 어렵게 만듭니다.

  3. 재사용성 부족: Thread 클래스는 한 번만 실행될 수 있기 때문에 같은 스레드 객체를 여러 번 재사용할 수 없습니다. 반면 Runnable은 여러 스레드에서 동시에 재사용할 수 있습니다.

  4. 상속의 오버헤드: 자바에서 상속은 클래스 계층 구조에 영향을 미치고 불필요한 오버헤드를 발생시킬 수 있습니다. Runnable은 인터페이스이기 때문에 더 가볍고, 코드가 간결해집니다.

따라서, Thread 클래스를 상속받는 것보다는 Runnable 인터페이스를 사용하는 것이 더 유연하고 재사용성이 높습니다.

Q2. Runnable 인터페이스를 사용할 때, 스레드 간 동기화 문제를 어떻게 해결할 수 있을까요?

Runnable 인터페이스를 사용하는 경우에도, 여러 스레드가 공유 자원에 접근할 때 동기화 문제가 발생할 수 있습니다. 이를 해결하기 위해 몇 가지 기법을 사용할 수 있습니다:

  1. synchronized 키워드 사용: 특정 메서드나 코드 블록을 동기화하여 하나의 스레드가 해당 자원을 사용하는 동안 다른 스레드가 접근하지 못하도록 할 수 있습니다.
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;
    }
}
  1. Lock 인터페이스 사용: 자바의 java.util.concurrent.locks 패키지에서 제공하는 Lock을 사용하면 더 세밀하게 동기화 제어가 가능합니다. Lock 인터페이스는 tryLock() 같은 메서드를 통해 비블로킹 방식으로 동기화할 수 있습니다.
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;
    }
}
  1. Atomic 클래스 사용: AtomicInteger, AtomicLong과 같은 Atomic 클래스를 사용하면 동기화가 필요 없이 안전하게 동시성 문제를 해결할 수 있습니다. 이는 내부적으로 CAS(Compare-And-Swap) 알고리즘을 사용하여 성능이 뛰어납니다.
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 인터페이스로 구현한 스레드에서도 안전하게 동기화를 처리할 수 있습니다.

Q3. Runnable 대신 Callable 인터페이스를 사용해야 하는 경우는 언제인가요?

Callable 인터페이스는 Runnable과 비슷하지만, 반환값을 가질 수 있으며 예외를 던질 수 있는 인터페이스입니다. Runnable은 반환값이 없고 예외 처리가 제한적이지만, Callable은 작업의 결과를 반환할 수 있어 다음과 같은 경우에 유용합니다:

  1. 작업의 결과를 반환해야 할 때: Callable은 작업을 실행하고 그 결과를 반환할 수 있는 구조입니다. Future 객체와 함께 사용하면 작업이 끝난 후 그 결과를 비동기적으로 받아올 수 있습니다.
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();
}
  1. 예외 처리가 필요한 경우: Callable은 작업 중 체크 예외를 던질 수 있기 때문에, 예외 처리를 유연하게 할 수 있습니다. 반면 Runnable은 체크 예외를 처리할 수 없습니다.
Callable<Integer> task = () -> {
    if (someCondition) {
        throw new Exception("예외 발생");
    }
    return 42;
};
  1. 복잡한 비동기 작업: Callable은 Runnable보다 더 복잡한 비동기 작업에 적합합니다. 특히, 다중 작업을 처리하고 그 결과를 받아서 후속 작업을 실행해야 할 때 Callable과 Future를 함께 사용하면 효율적입니다.

따라서 결과를 반환하거나 예외 처리가 필요한 작업에서는 Runnable 대신 Callable을 사용하는 것이 적합합니다.

profile
백엔드에서 서버엔지니어가 된 사람

0개의 댓글