본문 바로가기

Runnable 표현과 람다 캡처

정적 중첩 클래스, 익명 클래스, 람다를 동일 작업에 적용하고 상태·재사용·캡처 기준으로 구분합니다.

Runnable은 추상 메서드가 하나인 함수형 인터페이스라 여러 문법으로 만들 수 있습니다.

표현이 짧다는 이유만으로 람다를 고르면 캡처된 상태와 재사용 범위가 감춰질 수 있습니다.

작업의 이름, 상태, 생성 횟수, 테스트 범위를 보고 적절한 표현을 선택합니다.


람다가 변경되는 지역 변수를 캡처

람다가 바깥 지역 변수를 캡처하려면 그 변수는 final이거나 사실상 final이어야 합니다.

아래 코드는 캡처한 지역 변수 index를 뒤에서 index++로 변경하므로 컴파일 오류가 발생합니다.

index가 사실상 final이 아니라는 컴파일 규칙 위반이며, 스레드를 시작하기 전 단계에서 거부됩니다.

lab/MutableLocalCaptureFailure.java
public final class MutableLocalCaptureFailure {
    public static void main(String[] args) {
        int index = 0;
        Runnable task = () -> System.out.println(index);
        index++;
        task.run();
    }
}

오류를 가변 배열로 우회하면 컴파일은 되지만 모든 작업이 같은 box를 공유해 의도하지 않은 최종값을 볼 수 있습니다.

반복 안에서 int taskIndex=index;처럼 각 반복의 값을 고정하거나 필요한 값을 생성자 필드로 명시합니다.

캡처 규칙은 스레드 안전성을 보장하지 않고 지역 변수 재할당만 제한합니다.

예시

src/CaptureReferenceBoundary.java
public final class CaptureReferenceBoundary {
    public static void main(String[] args) throws InterruptedException {
        final int[] box = {7};
        Thread worker = new Thread(() -> System.out.println("captured=" + box[0]));
        box[0] = 9;
        worker.start();
        worker.join();
    }
}

출력은 captured=9입니다. box라는 지역 참조는 다시 대입하지 않았지만 참조 대상 배열의 0번 칸은 바뀝니다. 변경을 start 전에 마쳐 두었으므로 이 예제에는 실행 중 쓰기 경쟁이 없습니다. 같은 배열 칸을 start 뒤 여러 스레드가 바꾸는 안전성까지 증명한 것은 아닙니다. final 참조를 불변 객체나 깊은 복사와 같은 뜻으로 읽지 마세요.


정적 중첩 클래스의 상태 규칙

반복 사용되고 생성자 매개변수가 있으며 독립 테스트가 필요한 작업은 이름 있는 class가 읽기 쉽습니다.

static 중첩 클래스로 만들면 바깥 인스턴스를 암묵적으로 캡처하지 않습니다.

src/NestedClassRunnable.java
public final class NestedClassRunnable {
    private static final class RepeatMessage implements Runnable {
        private final String message;
        private final int count;

        RepeatMessage(String message, int count) {
            if (count < 1) throw new IllegalArgumentException("count");
            this.message = message;
            this.count = count;
        }

        @Override
        public void run() {
            for (int i = 1; i <= count; i++)
                System.out.println(Thread.currentThread().getName() + " " + i + ":" + message);
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Thread worker = new Thread(new RepeatMessage("review", 3), "nested-task");
        worker.start();
        worker.join();
    }
}

클래스 이름이 스택 추적과 검색 결과에 남고 필드가 공유 범위를 보여 줍니다.

비정적 내부 클래스였다면 외부 객체 참조까지 가지므로 수명이 늘고 스레드 사이에 예상치 못한 상태가 공유될 수 있습니다.


익명 클래스의 다중 메서드

Runnable처럼 메서드 하나인 경우에도 익명 class는 필드와 도우미 메서드를 둘 수 있습니다.

생성 위치에서만 쓰이고 로직이 짧지만 람다보다 객체 구조를 명시하고 싶을 때 적절합니다.

이름 없는 타입이라 다른 위치에서 직접 재사용하기 어렵습니다.

익명 클래스와 람다의 this는 다릅니다. 인스턴스 메서드 안에서 만든 익명 Runnable의 this는 그 익명 객체입니다. 같은 위치의 람다에 있는 this는 바깥 메서드의 객체를 그대로 가리킵니다. JLS의 람다 본문 규칙에 따라, 익명 클래스의 필드·메서드를 참조하는 코드를 람다로 바꿀 때 수신 객체까지 같은지 확인하세요. 정적 메서드에는 바깥 this가 없으므로 람다도 이를 만들어 주지 않습니다.

src/AnonymousRunnableState.java
public final class AnonymousRunnableState {
    public static void main(String[] args) throws InterruptedException {
        Runnable task =
                new Runnable() {
                    private int completed;

                    @Override
                    public void run() {
                        for (String stage : new String[] {"parse", "verify", "store"})
                            record(stage);
                    }

                    private void record(String stage) {
                        completed++;
                        System.out.println(
                                Thread.currentThread().getName() + " " + stage + " #" + completed);
                    }
                };
        Thread worker = new Thread(task, "anonymous-pipeline");
        worker.start();
        worker.join();
    }
}

같은 작업 인스턴스를 두 스레드에 동시에 전달하면 completed를 함께 바꾸므로 경합이 됩니다.

상태가 필요 없으면 가변 상태를 두지 않고, 실행마다 독립 상태가 필요하면 Runnable을 매번 새로 만듭니다. 각 인스턴스가 가리키는 객체까지 공유되는지도 따로 확인합니다.

익명 객체 변수를 따로 두지 않고 new Thread(new Runnable(){...}, name).start()로 즉시 전달할 수도 있습니다.

다만 생성, 처리기 설정, start가 한 표현에 몰리면 읽기 어렵고, new Thread(...).start()만 호출하면 나중에 명시적으로 join할 참조를 남기지 않습니다.

생명 주기 제어가 필요하면 지역 변수에 보관합니다.


람다와 작은 무상태 동작

람다는 매개변수가 없는 run() 본문만 표현합니다.

한두 단계의 작업, 불변 캡처, 주변 문맥만으로 목적이 분명한 경우에 가장 간결합니다.

긴 분기와 여러 자원 정리까지 람다에 넣으면 이름과 책임이 사라집니다.

src/LambdaTaskBatch.java
import java.util.ArrayList;
import java.util.List;

public final class LambdaTaskBatch {
    public static void main(String[] args) throws InterruptedException {
        List<String> actions = List.of("publish", "index", "notify");
        List<Thread> workers = new ArrayList<>();
        for (int index = 0; index < actions.size(); index++) {
            String action = actions.get(index);
            int number = index + 1;
            Runnable task =
                    () ->
                            System.out.println(
                                    Thread.currentThread().getName()
                                            + " action="
                                            + action
                                            + ", number="
                                            + number);
            workers.add(new Thread(task, "lambda-" + number));
        }
        for (Thread worker : workers) {
            worker.start();
        }
        for (Thread worker : workers) {
            worker.join();
        }
    }
}
변경한 지역 변수는 거부되고 각 회차의 고정 바인딩은 유지된다

실패 예제의 index는 뒤의 증가 연산 때문에 캡처할 수 없습니다. LambdaTaskBatch는 각 회차의 action과 number를 고정해 publish 1, index 2, notify 3의 짝을 출력합니다.

람다 캡처 · 고정된 바인딩

변경한 지역 변수는 거부되고 각 회차의 고정 바인딩은 유지된다

실패 예제의 index++는 컴파일 전에 거부됩니다. 성공한 반복은 action·number의 짝을 회차별로 고정합니다.

고정된 바인딩이 작업의 짝을 보존 변경되는index캡처는 컴파일실패에서 끝나고 세회차action·number는 publish1/index2/notify3의 짝을 고정합니다. 출력순서는 가변이며 객체를 깊게복사하지 않습니다. 변경되는 지역 변수 index=0 뒤 index++ 사실상 final 아님 캡처는 컴파일 거부 스레드 출력 없음 회차별 고정된 바인딩 지역 action·number publish1 / index2 notify3 각 람다에 해당 값 전달 1→publish / 2→index 3→notify 세 작업자의 출력 짝은 고정 / 순서는 가변 실선: 바인딩과 컴파일·실행 관계 참조 대상 객체를 복제하지 않음
고정된 바인딩이 작업의 짝을 보존 변경되는index캡처는 컴파일실패에서 끝나고 세회차action·number는 publish1/index2/notify3의 짝을 고정합니다. 출력순서는 가변이며 객체를 깊게복사하지 않습니다. 변경되는 지역 변수 index=0 뒤 index++ 사실상 final 아님 캡처는 컴파일 거부 스레드 출력 없음 회차별 고정된 바인딩 지역 action·number publish1 / index2 notify3 각 람다에 해당 값 전달 1→publish / 2→index 3→notify 세 작업자의 출력 짝은 고정 / 순서는 가변 실선: 바인딩과 컴파일·실행 관계 참조 대상 객체를 복제하지 않음

실패 예제의 index

지역 변수의 값·변경
index=0
뒤에서 index++
컴파일 또는 출력 결과
사실상 final이 아니어서 컴파일 오류

작업 목록 · 1회차

지역 변수의 값·변경
action=publish
number=1
컴파일 또는 출력 결과
lambda-1 action=publish, number=1

작업 목록 · 2회차

지역 변수의 값·변경
action=index
number=2
컴파일 또는 출력 결과
lambda-2 action=index, number=2

작업 목록 · 3회차

지역 변수의 값·변경
action=notify
number=3
컴파일 또는 출력 결과
lambda-3 action=notify, number=3

실패 예제에는 반복문이 없습니다. 성공한 세 회차는 LambdaTaskBatch의 서로 다른 지역 바인딩이며 출력 순서가 아닙니다. 캡처한 참조의 대상 객체가 복제되는 것도 아닙니다.

action과 number는 반복마다 새로 만들어지며 사실상 final인 지역 변수입니다.

목록 자체가 이후 변경될 가능성이 있다면 List.copyOf로 스냅샷을 만들고 그 스냅샷에서 값을 꺼냅니다.

목록 복사와 원소 복사를 구분하세요. List.copyOf는 원소의 참조를 담은 수정 불가 목록입니다. 원래 목록에 원소를 추가·삭제해도 복사한 목록의 구성은 바뀌지 않지만, 가변 원소 객체까지 새로 복제하지는 않습니다. 이 예제의 String은 불변이라 값 공유가 단순합니다. 가변 게시글 객체 목록이라면 원소의 변경·소유권 규칙을 따로 정해야 합니다.

람다 인스턴스가 자동으로 스레드마다 복제된다고 가정하지 않습니다.


작업 객체의 공유 여부

정적 중첩 class, 익명 class, 람다는 모두 결국 Runnable 인스턴스를 Thread 생성자에 전달합니다.

각 Thread에 새 인스턴스를 주는지, 하나를 공유하는지에 따라 필드 경쟁이 결정됩니다.

문법이 다르다고 Java Memory Model이 달라지지 않습니다.

상태 없는 작업은 하나를 공유해도 괜찮습니다.

읽기 전용 불변 구성도 안전하게 공유할 수 있습니다.

가변 카운터, 버퍼, 반복자가 있다면 상태를 한 스레드에만 두는 방식, 락, 원자 타입, 메시지 전달 가운데 규칙에 맞는 방법을 택합니다.


세 Runnable 표현을 같은 결과 형식으로 비교하기

아래 예제는 이름 있는 Runnable 클래스, 익명 클래스, 람다로 작업자 이름과 입력값을 같은 형식으로 출력합니다.

차이는 코드 위치와 상태 표현입니다.

세 표현을 비교하고 제품 코드에서는 팀이 추적하기 쉬운 최소 표현을 고릅니다.

app/RunnableStyleCatalog.java
public final class RunnableStyleCatalog {
    private static final class NamedTask implements Runnable {
        private final String value;

        NamedTask(String value) {
            this.value = value;
        }

        public void run() {
            print(value);
        }
    }

    private static void print(String value) {
        System.out.println(Thread.currentThread().getName() + "=" + value);
    }

    public static void main(String[] args) throws InterruptedException {
        Thread named = new Thread(new NamedTask("class"), "style-1");
        Thread anonymous =
                new Thread(
                        new Runnable() {
                            public void run() {
                                print("anonymous");
                            }
                        },
                        "style-2");
        Thread lambda = new Thread(() -> print("lambda"), "style-3");
        Thread[] all = {named, anonymous, lambda};
        for (Thread worker : all) {
            worker.start();
        }
        for (Thread worker : all) {
            worker.join();
        }
    }
}
세 Runnable 표현은 같은 출력 형식을 만든다

RunnableStyleCatalog의 NamedTask는 value 필드를 쓰고 익명 클래스와 람다는 각각 짧은 print 본문을 전달합니다. 세 이름과 값의 짝은 고정이며 출력 순서는 보장하지 않습니다.

RUNNABLE · 세 표현

세 Runnable 표현은 같은 출력 형식을 만든다

클래스·익명 클래스·람다는 같은 이름=값 형식으로 출력합니다. 표현을 바꿔도 공유 상태 규칙은 같습니다.

세 정의 방식과 각 작업의 출력 이름클래스·익명클래스·람다 각각을해당출력style-1=class/style-2=anonymous/style-3=lambda로 연결합니다. 세작업을 고정출력순서나 공유안전의 보장으로 해석하지 않습니다. 이름 있는 클래스 NamedTask.value run에서 print(value) style-1의 출력 style-1=class 익명 클래스 run 본문에 값 print(anonymous) style-2의 출력 style-2=anonymous 람다 짧은 실행 본문 print(lambda) style-3의 출력 style-3=lambda 실선: 각 정의와 출력의 관계 세 작업자의 출력 순서는 가변 문법만으로 공유 안전 보장 안 함
세 정의 방식과 각 작업의 출력 이름클래스·익명클래스·람다 각각을해당출력style-1=class/style-2=anonymous/style-3=lambda로 연결합니다. 세작업을 고정출력순서나 공유안전의 보장으로 해석하지 않습니다. 이름 있는 클래스 NamedTask.value run에서 print(value) style-1의 출력 style-1=class 익명 클래스 run 본문에 값 print(anonymous) style-2의 출력 style-2=anonymous 람다 짧은 실행 본문 print(lambda) style-3의 출력 style-3=lambda 실선: 각 정의와 출력의 관계 세 작업자의 출력 순서는 가변 문법만으로 공유 안전 보장 안 함

이름 있는 클래스

실제 작업 본문의 구성
NamedTask의 value 필드
run()이 print(value) 호출
이름과 값의 출력
style-1=class

익명 클래스

실제 작업 본문의 구성
run()에서 print("anonymous") 호출
이름과 값의 출력
style-2=anonymous

람다

실제 작업 본문의 구성
() -> print("lambda")
이름과 값의 출력
style-3=lambda

세 줄의 순서는 고정하지 않습니다. 차이는 작업을 정의하는 위치와 상태 표현이며, 어느 문법도 가변 객체의 안전한 공유를 자동으로 보장하지 않습니다.

세 출력의 순서는 보장하지 않지만 이름과 값의 짝은 고정입니다.

반복 실행, 예외 수집, 최대 동시성 제어가 필요해지면 표현을 더 늘리기보다 실행기로 실행 정책을 올립니다.


연습 문제

문자열 alpha, beta, gamma, delta를 각각 별도 스레드에서 출력하세요.

index와 값이 맞아야 하며 main은 모든 스레드를 join해야 합니다.

정답과 캡처 해설

각 반복에서 slot과 value를 새 지역 변수에 복사합니다.

배열에는 작업자 참조를 저장해 종료를 회수합니다.

exercise/SafeLoopCaptureSolution.java
public final class SafeLoopCaptureSolution {
    public static void main(String[] args) throws InterruptedException {
        String[] values = {"alpha", "beta", "gamma", "delta"};
        Thread[] workers = new Thread[values.length];
        for (int i = 0; i < values.length; i++) {
            int slot = i;
            String value = values[i];
            workers[i] =
                    new Thread(() -> System.out.println(slot + ":" + value), "capture-" + slot);
            workers[i].start();
        }
        for (Thread worker : workers) {
            worker.join();
        }
        System.out.println("captured=" + workers.length);
    }
}

앞의 네 줄은 순서가 달라도 각 index-값 조합이 한 번씩 나타납니다.

마지막 줄은 join 뒤라 항상 나중입니다.

따라 하기를 마쳤다면 새 입력 one·two로 목록만 바꾸고 (0, one), (1, two)의 두 짝이 남는지 따로 확인하세요. 한 작업의 출력 순서가 아니라 각 회차에서 복사한 slot·value의 연결이 규칙입니다. 원 네 입력의 결과와 새 두 입력의 결과를 한 실행 로그처럼 섞지 않습니다.

선택 기준을 요약하면 반복 사용과 명시적 상태는 이름 있는 class, 한 지점의 객체 구조는 익명 class, 짧고 무상태한 동작은 람다입니다.

어느 문법이든 캡처와 Runnable 인스턴스 공유 범위를 먼저 검토해야 합니다.


캡처한 값과 공유한 객체를 추적하기

각 상황은 독립적입니다. 첫 상황에서는 final int[] box = {0} 한 개를 공유하고 세 Runnable 모두 box[0]만 읽습니다. 작업을 만드는 동안 값을 차례로 1, 2, 3으로 바꾸되, 모든 쓰기는 끝낸 뒤 세 스레드를 시작하고 모두 join합니다. 시작 후에는 아무도 배열을 바꾸지 않습니다.

  1. 세 작업은 각각 무엇을 출력하나요? 만들 때의 1·2·3을 보존하려면 어떤 지역 값을 캡처해야 하나요?
  2. 위 상황을 바꿔 시작 후 여러 작업이 box[0]++까지 수행합니다. final 참조가 있으니 정확히 세 번 증가한다고 보장할 수 있나요? 확인해야 할 공유 경계는 무엇인가요?
  3. 어떤 객체의 인스턴스 메서드 안에서 익명 Runnable과 람다가 각각 this를 사용합니다. 두 this는 같은 객체인가요? 실행마다 독립 카운터가 필요하다면 문법 선택 외에 무엇을 분리해야 하나요?
캡처와 객체 공유의 해설
  1. 같은 배열 참조의 현재 칸을 읽으므로 모두 3입니다. 1·2·3을 보존하려면 각 회차에 int captured = box[0]처럼 새 지역 값을 두고 그 값을 캡처합니다. 모든 쓰기를 start 전에 끝냈다는 조건과 실행 중 배열을 바꾸지 않는다는 조건도 중요합니다.
  2. 보장할 수 없습니다. final은 참조 재대입을 막을 뿐 배열 칸의 읽기·증가·쓰기를 원자적 연산으로 만들지 않습니다. 여러 작업이 같은 배열 객체에 쓰므로 락·원자 타입 등 일관된 동기화나 작업별 객체 분리가 필요합니다.
  3. 익명 Runnable의 this는 익명 객체이고 람다의 this는 바깥 객체입니다. 작업별 상태를 원한다면 각 작업 인스턴스와 그 인스턴스가 가리키는 가변 객체까지 분리해야 합니다. 람다로 바꾸는 것만으로 공유가 사라지지 않습니다.