본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
31장 : 함수형 파이프라인

상태 있는 람다와 수명

외부 가변 상태를 캡처한 조건식이 호출 순서와 재사용에 미치는 영향을 확인하고, 익명 클래스의 독립 범위와 람다의 어휘 범위, 실행별 상태 팩터리를 구분합니다.

람다가 짧다고 무상태한 것은 아닙니다. 본문이 필드, 컬렉션, 카운터를 읽거나 쓰면 결과가 현재 입력 외 상태에 의존합니다. Stream 계약은 동작 매개변수가 비간섭하고 대체로 무상태하기를 기대합니다. 원본을 수정하거나 이전 원소 처리 결과를 공유하면 순차 실행에서는 우연히 동작해도 재사용과 병렬 실행에서 깨질 수 있습니다.

상태가 실제 요구인 경우도 있습니다. 중복 제거는 이미 본 키 집합이 필요하고, 누적 합계는 누적값이 필요합니다. 이때 상태를 숨은 캡처로 둘지, 전용 상태 유지 연산이나 수집기가 소유할지 선택합니다. 핵심은 상태 수명을 파이프라인 실행과 맞추는 것입니다.

상태 있는 Predicate 재사용

다음 조건식은 필드 Set에 본 키를 저장합니다. 첫 스트림이 끝난 뒤 Set이 비워지지 않으므로 두 번째 스트림은 이미 본 모든 값을 거절합니다. 인스턴스가 여러 스레드에서 공유되면 HashSet 데이터 경합도 생깁니다.

bad/ReusableStatefulPredicate.java
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.function.Predicate;

public final class ReusableStatefulPredicate<T> implements Predicate<T> {
    private final Set<T> seen = new HashSet<>();

    @Override
    public boolean test(T value) {
        return seen.add(value);
    }

    public static void main(String[] args) {
        ReusableStatefulPredicate<String> distinct = new ReusableStatefulPredicate<>();
        System.out.println(List.of("a", "a", "b").stream().filter(distinct).toList());
        System.out.println(List.of("a", "b", "c").stream().filter(distinct).toList());
    }
}

JDK Stream에는 상태 수명을 최종 실행 안에 관리하는 distinct()가 있습니다. 사용자 정의 키 distinct가 필요하다면 조건식 팩터리가 실행마다 새 인스턴스를 만들게 하거나 수집기가 키 집합과 산출물을 함께 소유하게 합니다. “Set을 동시 컬렉션으로 바꾸면 해결”되는 문제가 아닙니다. 스레드 안전성과 실행 간 상태 초기화는 별도 요구입니다.

외부 카운터를 캡처해 map 인덱스를 만드는 패턴도 처리 순서와 병렬 스케줄링에 의존합니다. 순차 인덱스가 의미라면 인덱스 기반 알고리즘을 명시적으로 구현하고, 병렬 환경에서는 원소 위치를 원본 구조에서 계산할 수 있는지 검토합니다.

람다와 익명 클래스의 this

람다는 새로운 this 바인딩을 만들지 않습니다. 인스턴스 메서드 안에서 람다의 this.name은 둘러싼 소유자 필드를 읽습니다. 익명 클래스 본문의 this는 익명 객체입니다. 이 차이는 콜백이 어떤 소유자를 캡처하고 수명을 연장하는지에도 영향을 줍니다.

src/LambdaAnonymousPipelineScope.java
import java.util.List;
import java.util.function.Function;

public final class LambdaAnonymousPipelineScope {
    private final String name;

    LambdaAnonymousPipelineScope(String name) {
        this.name = name;
    }

    Function<String, String> lambdaFormatter() {
        return value -> this.name + ":" + value;
    }

    Function<String, String> anonymousFormatter() {
        return new Function<>() {
            private final String name = "anonymous";

            @Override
            public String apply(String value) {
                return this.name + ":" + LambdaAnonymousPipelineScope.this.name + ":" + value;
            }
        };
    }

    public static void main(String[] args) {
        LambdaAnonymousPipelineScope owner = new LambdaAnonymousPipelineScope("pipeline");
        System.out.println(List.of("a", "b").stream().map(owner.lambdaFormatter()).toList());
        System.out.println(List.of("a", "b").stream().map(owner.anonymousFormatter()).toList());
    }
}

람다 캡처는 사실상 final 지역 변수도 포함합니다. 참조 재대입을 금지할 뿐 참조 대상 객체의 변경을 막지 않습니다. List<String> log를 캡처한 Consumer는 로그 내부를 계속 바꿀 수 있습니다. 메서드가 반환된 뒤에도 람다가 살아 있으면 지역 객체도 힙에서 함께 살아 있습니다.

메서드 참조 this::format 역시 소유자를 캡처한 결합된 참조입니다. 무상태 static 메서드 참조는 소유자 캡처가 없으므로 장기 파이프라인 레지스트리에 더 안전할 수 있지만, 의미상 인스턴스 정책이 필요하다면 억지로 static으로 만들지 않습니다.

원본 간섭과 결과 상태

스트림 순회 중 원본 컬렉션을 수정하면 빠른 실패 반복자가 ConcurrentModificationException을 낼 수 있고, 그렇지 않더라도 어떤 원소를 볼지 불명확합니다. 결과 수집 대상을 수정하는 Consumer는 일반적인 최종 부수 효과지만 원본과 동일한 컬렉션이면 간섭이 됩니다.

다음 예제는 원본 스냅샷을 먼저 만든 뒤 결과를 별도 목록에 수집합니다. 변경이 필요한 경계와 읽기 원본을 분리하면 순회 의미가 안정적입니다.

src/NonInterferingPipeline.java
import java.util.ArrayList;
import java.util.List;
import java.util.function.Function;
import java.util.function.Predicate;

public final class NonInterferingPipeline {
    record Pipeline<T, R>(Predicate<? super T> filter, Function<? super T, ? extends R> mapper) {
        List<R> execute(List<? extends T> source) {
            List<T> snapshot = List.copyOf(source);
            List<R> result = new ArrayList<>();
            for (T value : snapshot) {
                if (filter.test(value)) {
                    result.add(mapper.apply(value));
                }
            }
            return List.copyOf(result);
        }
    }

    public static void main(String[] args) {
        List<Integer> source = new ArrayList<>(List.of(1, 2, 3, 4));
        Pipeline<Integer, String> pipeline = new Pipeline<>(
                value -> value % 2 == 0,
                value -> "N" + value);
        List<String> first = pipeline.execute(source);
        source.add(6);
        List<String> second = pipeline.execute(source);
        System.out.println(first);
        System.out.println(second);
        System.out.println(source);
    }
}

스냅샷은 실행 시작 시점의 원본 구성원을 고정하지만 원소 객체 자체를 깊은 copy하지 않습니다. 가변 원소 필드가 동시에 바뀌면 파이프라인은 변경을 관찰할 수 있습니다. 완전한 격리가 필요하면 불변 도메인 값이나 동기화, 버전이 지정된 스냅샷이 필요합니다.

실행별 상태 팩터리

중복 키 필터처럼 상태 유지 동작이 필요하면 Supplier<Predicate<T>>가 매 실행 새 조건식을 만들게 할 수 있습니다. 파이프라인 정의는 팩터리를 불변으로 보관하고 execute 시작 때 하나를 얻습니다. 순차 처리 순서를 전제로 한다는 제한도 API에 적습니다.

병렬 키 기준 중복 제거는 동시 Set만으로 처리 순서의 “첫 원소” 의미를 보장하기 어렵습니다. 순서 있는 스트림과 병렬 병합 규칙을 정의하거나 순차 연산으로 제한합니다. 정확성을 유지할 수 없는 최적화는 제공하지 않습니다.

연습 문제

DistinctPipeline<T,K>가 키 추출기를 받고 execute(List<T>)마다 새 Set을 만들게 합니다. 같은 파이프라인 인스턴스를 두 번 실행해도 각 입력 안에서만 중복을 제거해야 합니다. 결과 순서는 첫 등장 순서를 유지합니다.

해답 보기
src/PerExecutionDistinctSolution.java
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.function.Function;

public final class PerExecutionDistinctSolution<T, K> {
    private final Function<? super T, ? extends K> keyExtractor;

    PerExecutionDistinctSolution(Function<? super T, ? extends K> keyExtractor) {
        this.keyExtractor = keyExtractor;
    }

    List<T> execute(List<? extends T> source) {
        Set<K> seen = new HashSet<>();
        List<T> result = new ArrayList<>();
        for (T value : source) {
            if (seen.add(keyExtractor.apply(value))) {
                result.add(value);
            }
        }
        return List.copyOf(result);
    }

    record User(String id, String name) {
    }

    public static void main(String[] args) {
        PerExecutionDistinctSolution<User, String> pipeline =
                new PerExecutionDistinctSolution<>(User::id);
        System.out.println(pipeline.execute(List.of(
                new User("1", "A"),
                new User("1", "A2"),
                new User("2", "B"))));
        System.out.println(pipeline.execute(List.of(
                new User("1", "Again"),
                new User("3", "C"))));
    }
}

해답은 가변 Set을 메서드 지역에 두어 실행 수명과 일치시킵니다. 키가 null일 때 HashSet은 하나의 null을 허용합니다. null 키를 거절해야 한다면 추출기 직후 명시적으로 검증합니다.