CodeGym /행동 /JAVA 25 SELF /함수형 프로그래밍에서의 오류 분석

함수형 프로그래밍에서의 오류 분석

JAVA 25 SELF
레벨 49 , 레슨 4
사용 가능

1. 람다 표현식에서의 실수: 변수 캡처

Java의 람다 표현식은 외부 컨텍스트의 변수를 사용할 수 있습니다. 하지만 제약이 있습니다. 그런 변수는 final이거나 “사실상 final”(effectively final)이어야 하며, 초기화 이후 변경되면 안 됩니다.

실수 예시

int sum = 0;
List<Integer> list = List.of(1, 2, 3, 4, 5);
list.forEach(n -> sum += n); // 컴파일 오류!

왜 그런가요?
컴파일러는 람다에서 변수를 사용하면 그 변수가 final 또는 effectively final이어야 한다고 판단합니다. 그런데 sum은 람다 내부에서 변경되고 있습니다.

어떻게 피하나요?

  • 외부 변수를 필요로 하지 않는 스트림의 터미널 연산을 사용하세요: mapToInt + sum().
  • 부득이한 경우 — AtomicInteger 같은 컨테이너나 길이 1의 배열을 사용하세요(하지만 해킹에 가까운 우회책입니다).
int sum = list.stream().mapToInt(Integer::intValue).sum();

비유
람다는 “시간 여행자”와 같습니다. 생성 시점의 변수 값을 “기억”할 뿐, 그 이후의 변화를 관찰할 수 없습니다. 변경하려는 시도는 “할아버지 역설”과 같아서, 컴파일러가 컴파일을 허용하지 않습니다.

2. 스코프와 this 관련 실수

람다 표현식에서 키워드 this는 익명 클래스의 인스턴스가 아니라 외부 객체를 가리킵니다(익명 클래스를 사용할 때와 다릅니다).

예시

public class Example {
    int value = 42;

    void foo() {
        Runnable r = () -> {
            System.out.println(this.value); // this는 Runnable이 아니라 Example입니다!
        };
        r.run();
    }
}

중요: 익명 클래스를 람다로 바꿀 때 this의 동작이 달라지므로, 이를 고려하지 않으면 의도치 않은 결과를 낳을 수 있습니다.

3. 변경 가능한 상태(부작용) 문제

함수형 접근법은 부작용의 부재를 권장합니다. 즉, 함수는 자신 밖의 상태를 변경하거나 외부 컬렉션/변수를 변이시키지 않습니다.

List<String> names = new ArrayList<>(List.of("안나", "보리스", "비카"));
List<String> newNames = new ArrayList<>();

names.forEach(name -> {
    if (name.startsWith("아")) {
        newNames.add(name); // 부작용!
    }
});

코드는 “동작”하지만 예측 가능성이 떨어지고, parallelStream()을 사용할 때는 경쟁 상태나 예외 발생 위험이 있습니다. 테스트와 유지보수도 어려워집니다.

올바른 방법: 외부 상태를 변경하지 않고 새로운 결과를 명시적으로 만드는 연산을 사용하세요.

List<String> newNames = names.stream()
    .filter(name -> name.startsWith("아"))
    .collect(Collectors.toList());

4. 타입과 generics 관련 실수

Java는 강한 정적 타입 언어입니다. 때로는 람다나 체인이 너무 복잡해 컴파일러가 타입을 추론하지 못할 수 있습니다.

예시

List<Object> objects = List.of(1, "문자열", 3.14);
List<String> strings = objects.stream()
    .filter(obj -> obj instanceof String)
    .map(obj -> (String) obj)
    .collect(Collectors.toList());

겉보기에는 타당해 보이지만, 사소한 오타나 잘못된 캐스팅은 컴파일 오류를 야기하거나 더 나쁘게는 실행 시 ClassCastException을 발생시킬 수 있습니다.

어떻게 피하나요?

  • 타입 추론이 흔들릴 때는 명시적 타입을 추가하세요.
  • <String>을 쓰거나 람다에 타입을 명시하는 것을 두려워하지 마세요: (String s) -> ....
  • 변환 시 타입 호환성을 점검하세요.

다음과 같은 Optional 사례

Optional<String> opt = Optional.of("hello");
opt.map(s -> s.length()); // 결과 — Optional<Integer>

Optional<String>을 기대했는데 Optional<Integer>를 얻었다면, 여러분의 함수가 무엇을 반환하는지 확인하세요.

5. 람다의 부작용과 병렬 처리

병렬 스트림(parallelStream())과 부작용의 조합은 위험합니다.

예시

List<Integer> numbers = IntStream.range(0, 1000).boxed().collect(Collectors.toList());
List<Integer> results = new ArrayList<>();

numbers.parallelStream().forEach(n -> results.add(n)); // 위험!

무엇이 일어날 수 있나요?

  • 데이터 손실 또는 중복.
  • ConcurrentModificationException 또는 원인 불명의 버그.

올바른 방법?

  • 스레드-세이프 컬렉션을 사용하세요: ConcurrentLinkedQueue, CopyOnWriteArrayList.
  • 더 좋은 방법은 부작용을 완전히 피하고 collect(...)로 결과를 수집하는 것입니다.
List<Integer> results = numbers.parallelStream()
    .map(n -> n)
    .collect(Collectors.toList());

6. 가독성 저하: ‘스트림 스파게티’와 긴 체인

함수형 스타일은 훌륭하지만, 체인이 “대형마트 영수증”처럼 길어지면 문제가 됩니다.

List<String> result = list.stream()
    .filter(s -> s.length() > 2)
    .map(String::trim)
    .map(s -> s.toUpperCase())
    .filter(s -> s.contains("JAVA"))
    .sorted()
    .distinct()
    .collect(Collectors.toList());

팁:

  • 체인을 논리적 블록으로 나누세요.
  • 복잡한 람다는 의미 있는 이름의 별도 메서드로 추출하세요.
  • 필요하다면 주석을 추가하세요 — Stream 코드에도 예외는 없습니다.

7. 좋지 않은 변수/함수 이름

지나치게 짧은 이름(x, y, z)은 이해를 어렵게 합니다.

list.stream()
    .map(x -> x.trim())
    .filter(y -> y.length() > 3)
    .map(z -> z.toUpperCase())
    .forEach(System.out::println);

특히 람다가 여러 줄이거나 비자명한 로직을 담고 있다면 의미 있는 이름을 사용하세요.

8. nullOptional 관련 실수

Stream API와 함수형 인터페이스는 null을 좋아하지 않습니다. 람다나 스트림에 null이 섞이면 NullPointerException의 흔한 원인이 됩니다.

List<String> list = Arrays.asList("a", null, "b");
list.stream()
    .map(String::toUpperCase) // 펑! 두 번째 요소에서 NPE
    .forEach(System.out::println);

올바른 방법?

  • null은 미리 필터링하세요: .filter(Objects::nonNull).
  • 값의 부재를 명시적으로 표현하고 싶다면 Optional을 사용하세요.

9. 합성 함수의 반환 타입 문제

composeandThen을 사용할 때는 함수 적용 순서와 기대 타입을 혼동하기 쉽습니다.

Function<String, Integer> parse = Integer::parseInt;
Function<Integer, Integer> square = x -> x * x;

Function<String, Integer> parseAndSquare = parse.andThen(square);
// 동작함: 먼저 parse, 그다음 square

Function<String, Integer> squareThenParse = parse.compose(square);
// 오류! square는 Integer를 받고, parse는 String을 기대합니다

교훈: 항상 적용 순서와 타입 호환성을 확인하세요.

10. 람다에서의 체크 예외 문제

java.util.function 패키지의 함수형 인터페이스는 체크 예외(예: IOException)의 던지기를 허용하지 않습니다. 람다 내부에서 체크 예외를 던지는 코드가 필요하다면 직접 예외를 처리해야 합니다.

Function<String, String> readFile = path -> {
    try {
        return Files.readString(Path.of(path));
    } catch (IOException e) {
        throw new RuntimeException(e); // 다른 방식으로 처리할 수도 있습니다
    }
};

그렇지 않으면 컴파일러가 해당 함수를 스트림이나 컬렉션에서 사용하도록 허용하지 않습니다.

1
설문조사/퀴즈
함수형 프로그래밍, 레벨 49, 레슨 4
사용 불가능
함수형 프로그래밍
함수형 프로그래밍
코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION