JAVA

Java 람다식 — 콜백 지옥 탈출 (함수형 인터페이스부터 메서드 레퍼런스까지)

IT Lab 2026. 2. 21. 20:00

Java 17 기준으로 함수형 인터페이스, 람다 문법, 메서드 레퍼런스를 실무 관점에서 정리하고 콜백 지옥을 깔끔하게 줄이는 패턴을 예제로 보여드립니다.

 

비동기 처리나 이벤트 기반 코드를 작성하다 보면 “콜백 안에 콜백”이 계속 중첩되어 흐름을 따라가기 어려워질 때가 많습니다. 특히 익명 클래스까지 섞이면 코드가 길어지고, 예외 처리나 로깅을 끼워 넣는 순간 더 복잡해지죠. 이럴 때 Java 람다식은 “행동(로직)을 값처럼 전달”해서 코드를 짧고 읽기 쉽게 만드는 강력한 도구가 됩니다.

핵심 개념: Java 람다식이 콜백 지옥을 줄이는 이유 (함수형 인터페이스)

익명 클래스 기반 콜백 중첩이 람다와 메서드 레퍼런스로 단순화되는 흐름도

람다식은 결국 **“함수형 인터페이스(Functional Interface)의 인스턴스를 간단히 만드는 문법 설탕”**입니다. 핵심은 클래스/익명 클래스로 “전략”을 만들던 방식을, 람다로 “행동”만 전달하게 바꾼다는 점이에요. 비유하자면, 매번 도구 전체를 들고 다니는 대신 “필요한 비트(팁)만 교체”하는 느낌입니다.

함수형 인터페이스란?

  • 추상 메서드가 정확히 1개인 인터페이스입니다.
  • @FunctionalInterface는 필수는 아니지만, 실수로 추상 메서드를 추가해 깨지는 걸 막아줘서 실무에서 권장됩니다.
  • 대표적으로 Runnable, Callable, Consumer, Function, Predicate, Supplier 등이 있습니다.

람다 문법에서 자주 쓰는 포인트

  • 매개변수 타입은 대부분 타입 추론으로 생략합니다.
  • 매개변수가 1개면 괄호 생략 가능: x -> x + 1
  • 본문이 한 줄이면 {}return 생략 가능
  • “캡처링” 규칙: 람다에서 바깥 지역 변수를 쓰려면 effectively final이어야 합니다(값을 재할당하면 컴파일 오류).

메서드 레퍼런스는 언제 쓰나요?

람다가 “그 메서드 그대로 호출” 형태라면 메서드 레퍼런스로 더 읽기 좋아집니다.

표현 예시 의미 주로 쓰는 상황
정적 메서드 Integer::parseInt s -> Integer.parseInt(s) 파싱/유틸 호출
인스턴스(특정 객체) logger::info msg -> logger.info(msg) 로깅/콜백 전달
인스턴스(임의 객체) String::toUpperCase s -> s.toUpperCase() Stream 변환
생성자 ArrayList::new () -> new ArrayList<>() 팩토리/컬렉션 생성

아래 흐름처럼 “비즈니스 로직”과 “콜백(후처리)”를 분리하면, 중첩이 줄고 테스트도 쉬워집니다.

flowchart TD
  A[""Call site""] --> B[""Service method""] 
  B --> C[""Do work""] 
  C --> D[""Callback (lambda)""] 
  D --> E[""Next step""] 

한 번의 작업 흐름에서 “후처리”를 람다로 주입해 중첩을 줄이는 구조입니다.

코드 예제: Java 함수형 인터페이스 + 람다 + 메서드 레퍼런스로 콜백 지옥 정리하기

아래 코드는 Java 17에서 그대로 실행 가능합니다. “작업 실행 + 공통 전/후 처리(타이밍, 로깅, 예외 변환)”를 템플릿으로 만들고, 실제 동작만 람다로 전달합니다. 또한 메서드 레퍼런스로 더 읽기 좋게 바꿀 수 있는 포인트도 함께 보여드립니다.

import java.time.Duration;
import java.time.Instant;
import java.util.Objects;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Supplier;

public class LambdaCallbackDemo {

    public static void main(String[] args) {
        // 1) Supplier<T>: 값을 "만드는" 작업을 람다로 전달
        String token = Timed.run("issueToken",
                () -> Auth.issueToken("alice"),
                System.out::println,                 // 성공 시 출력 (메서드 레퍼런스)
                ex -> System.err.println(ex.getMessage())); // 실패 시 출력

        // 2) Function<T, R>: 입력을 받아 변환하는 작업
        Integer userId = Timed.run("parseUserId",
                () -> token,                         // 앞 단계 결과를 공급
                s -> Integer.parseInt(s.substring(6)),// "token:" 이후 숫자 파싱
                id -> System.out.println("userId=" + id),
                ex -> System.err.println("parse failed: " + ex.getMessage()));

        // 3) Consumer<T>: 부수효과(저장/전송 등) 작업
        Timed.accept("saveUser",
                userId,
                Repo::save,                          // 메서드 레퍼런스
                msg -> System.out.println("OK: " + msg),
                ex -> System.err.println("save failed: " + ex.getMessage()));
    }

    // 실무에서 자주 쓰는 패턴: 공통 관심사(시간 측정/로깅/예외 처리)를 감싸는 템플릿
    static class Timed {

        public static <T> T run(
                String name,
                Supplier<T> supplier,
                Consumer<T> onSuccess,
                Consumer<Exception> onError
        ) {
            Objects.requireNonNull(supplier);
            Objects.requireNonNull(onSuccess);
            Objects.requireNonNull(onError);

            Instant start = Instant.now();
            try {
                T result = supplier.get();
                onSuccess.accept(result);
                return result;
            } catch (Exception e) {
                onError.accept(e);
                throw e; // 필요 시 도메인 예외로 변환해서 던지세요.
            } finally {
                long ms = Duration.between(start, Instant.now()).toMillis();
                System.out.println(name + " took " + ms + "ms");
            }
        }

        // 입력값이 있고 반환이 없는 케이스(Consumer)도 별도 유틸로 두면 깔끔합니다.
        public static <T> void accept(
                String name,
                T input,
                Consumer<T> action,
                Consumer<String> onSuccess,
                Consumer<Exception> onError
        ) {
            run(name,
                    () -> {
                        action.accept(input);
                        return "done";
                    },
                    onSuccess,
                    onError);
        }

        // "공급 -> 변환"을 한 번에 묶으면 콜백 중첩을 더 줄일 수 있습니다.
        public static <T, R> R run(
                String name,
                Supplier<T> supplier,
                Function<T, R> mapper,
                Consumer<R> onSuccess,
                Consumer<Exception> onError
        ) {
            return run(name, () -> mapper.apply(supplier.get()), onSuccess, onError);
        }
    }

    // 데모용 도메인 로직
    static class Auth {
        static String issueToken(String username) {
            if (username == null || username.isBlank()) {
                throw new IllegalArgumentException("username is blank");
            }
            // 예: "token:123"
            return "token:" + (username.length() * 41);
        }
    }

    static class Repo {
        static void save(Integer userId) {
            if (userId == null || userId < 0) {
                throw new IllegalArgumentException("invalid userId");
            }
            // 저장 로직이 있다고 가정
            System.out.println("saved userId=" + userId);
        }
    }
}

실무 팁

💡 실무에서는: “람다로 다 해결”하려고 하기보다, 표준 함수형 인터페이스를 먼저 찾으세요

  • 새 인터페이스를 만들기 전에 Supplier/Function/Consumer/Predicate로 표현 가능한지 확인해 보세요.
  • 팀 내 공통 유틸(예: Timed.run)은 표준 타입을 기반으로 만들어야 재사용성이 좋아지고, 다른 라이브러리와도 잘 맞습니다.

💡 실무에서는: 람다에서 예외 처리 전략을 명확히 정해두는 게 중요합니다

  • Function/Supplier는 checked exception을 던질 수 없어서, 파일/네트워크처럼 checked 예외가 많은 영역에서는 래핑 전략이 필요합니다.
  • 선택지는 보통 2가지입니다:
    1. 람다 내부에서 도메인 예외(대개 unchecked)로 변환해서 던지기
    2. ThrowingFunction 같은 별도 함수형 인터페이스를 만들되, 남용하지 않기
  • 어느 쪽이든 “어디서 변환하고 어디서 로깅하는지”를 팀 규칙으로 고정해두면 운영 이슈가 줄어듭니다.

핵심 요약

  • 람다식은 함수형 인터페이스 인스턴스를 간단히 만들어 콜백 중첩을 줄여줍니다.
  • 메서드 레퍼런스는 “그 메서드 그대로 호출” 패턴에서 가독성을 크게 올립니다.
  • 공통 관심사는 템플릿 유틸로 감싸고, 실제 동작만 람다로 주입해 보세요.

다음 글: [#20 Stream API 기초]