[내일배움캠프 Spring_3기] 달리기반 2회차 - 익명 클래스와 람다

jiiim_ni·2026년 1월 19일

1일차에서는 객체끼리 대화(메시징)하는 법을 배웠다면, 오늘은 조금 더 세련된 대화법 "나중에 결정하기"에 대해 배웠다.


Step1: 전략 패턴과 클래스의 홍수

전략 패턴의 구조(Structure)

  • 전략 패턴의 핵심은 무엇을 하는가(인터페이스)와 어떻게 하는가(구현체)를 완전히 분리하는 것
    1) Strategy(전략 인터페이스): 모든 이동 수단이 공통으로 가져야 할 메서드를 정의
    2) Concrete Strategy(구체적인 전략): 실제로 경로를 계산하는 다양한 방법들
    3) Context(네비게이션): 전략을 사용하는 주체. 어떤 전략인지 몰라도 인터페이스만 보고 실행함

왜 전략 패턴을 쓸까?

1) OCP(Open-Closed Principle) 준수 - 새로운 이동 수단이 추가되어도 기존 코드는 수정할 필요 없음. 새로운 클래스 하나만 더 만들면 끝
2) 런타임 교체 가능 - 프로그램 실행 중에 따라 알고리즘을 유연하게 바꿀 수 있음
3) 단위 테스트 용이 - 각 전략이 독립된 클래스이므로, 특정 알고리즘만 따로 테스트하기 매우 편함

🍔우리는 햄버거 프랜차이즈 사장! 주방에 만능 조리 기계를 들였는데, 이 기계는 빵이랑 패티는 기본으로 깔아주지만 중간에 들어갈 핵심 비법이 뭔지는 모름
-> 그래서 BurgerRecipe라는 약속(인터페이스)을 만듬

// "뭐가 될진 모르겠지만, 아무튼 '조리(cook)' 할 수 있는 레시피"
public interface BurgerRecipe {
    void cook(); 
}

만능 조리 기계

public class HamburgerChef {
    private BurgerRecipe recipe;

    // 어떤 레시피든 '장착(set)' 하면 해당 메뉴로 변신!
    public void setRecipe(BurgerRecipe recipe) {
        this.recipe = recipe;
    }

    public void makeBurger() {
        System.out.println("--- 🍔 조리 시작 ---");
        System.out.println("# 번(빵)을 따뜻하게 굽습니다.");
        System.out.println("# 육즙 가득한 패티를 올립니다.");
        
        if (recipe != null) {
            // "중간에 뭘 넣을진 모르겠지만, 아무튼 너의 레시피대로 해!" (다형성)
            recipe.cook(); 
        }

        System.out.println("# 빵을 덮고 포장합니다.");
        System.out.println("--- ✅ 조리 완료 ---\n");
    }
}
  • 이것이 바로 디자인 패턴 중 하나인 전략 패턴(Strategy Pattern)
  • 빵-패티-포장 이라는 고정된 템플릿 안에 조리법이라는 변하는 전략을 갈아 끼울 수 있게 만든 것

치즈버거 레시피

public class CheeseBurgerRecipe implements BurgerRecipe {
    @Override
    public void cook() {
        System.out.println("# 체다 치즈를 듬뿍 올리고 녹입니다.");
    }
}

불고기버거 레시피

public class BulgogiBurgerRecipe implements BurgerRecipe {
    @Override
    public void cook() {
        System.out.println("# 단짠단짠 불고기 소스를 바릅니다.");
        System.out.println("# 양상추를 듬뿍 올립니다.");
    }
}

Main

HamburgerChef machine = new HamburgerChef();

// 치즈버거 레시피 장착 & 조리
machine.setRecipe(new CheeseBurgerRecipe());
machine.makeBurger();

// 불고기버거 레시피로 교체 & 조리
machine.setRecipe(new BulgogiBurgerRecipe());
machine.makeBurger();

만약 신메뉴가 100개 나오면 100개의 클래스 파일(.java)을 일일이 만들어야 할까?
-> 익명 클래스와 람다로 해결


Step2: 익명 클래스

마치 우리가 배달 음식을 먹을 때 굳이 그릇을 사지 않고 일회용 용기를 쓰고 버리는 것처럼 일회용 레시피 -> 익명 클래스

public class Main {
    public static void main(String[] args) {
        HamburgerChef machine = new HamburgerChef();

        // 💥 별도의 .java 파일 없이, 여기서 바로 만든다! (일회용)
        BurgerRecipe shrimpRecipe = new BurgerRecipe() {
            @Override
            public void cook() {
                System.out.println("# 통통한 새우 패티에 타르타르 소스를 듬뿍!");
            }
        };
        
        machine.setRecipe(shrimpRecipe);
        machine.makeBurger();
    }
}

-> Main 클래스 안에서 BurgerRecipe 인터페이스를 즉석에서 구현하는 모습

// 파라미터 자리에서 바로 new 하는 패기!
machine.setRecipe(new BurgerRecipe() {
    @Override
    public void cook() {
        System.out.println("# 부드러운 아보카도를 슬라이스해서 올립니다.");
    }
});
machine.makeBurger();
  • shrimpRecipe라는 변수 이름 삭제

장점: 파일이 줄었다 -> 클래스 폭발 문제가 해결됨
단점: 너무 길다 -> 우리가 진짜 하고 싶은 핵심 로직은 딱 한줄
-> 이걸 해결할 수 있는 게 람다(Lambda)


Step3: 람다 표현식

  • 람다의 핵심은 추론(Inference)
    1) new BurgerRecipe() -> 어차피 setRecipe 파라미터가 BurgerRecipe 타입인거 다 알기 때문에 생략
    2) public void cook() -> 인터페이스에 메서드가 cook 하나뿐인데 뻔함 생략
    3) {}랑 return -> 코드가 한 줄이면 괄호도 필요 없음 생략
// Before: 익명 클래스 (5줄)
BurgerRecipe avocado = new BurgerRecipe() {
    @Override
    public void cook() {
        System.out.println("# 아보카도 추가");
    }
};

// After: 람다 표현식 (1줄) ⚡️
BurgerRecipe avocado = () -> System.out.println("# 아보카도 추가");
  • 5줄짜리 코드가 1줄로 줄었음
  • 레시피 객체를 만드는 게 아니라 마치 조리 동작 그 자체를 던지는 것 같은 기분

안전장치: @FunctionalInterface

  • 람다는 오직 하나의 추상 메서드만 가진 인터페이스여야만 사용할 수 있음(그래야 컴파일러가 아 이 메서드구나 하고 추측 가능)
  • 혹시라도 나중에 동료가 실수로 메서드를 추가해서 내 람다 코드가 깨지는 걸 막기 위해 인터페이스 위에 안전장치를 걸어둠
// "이 인터페이스는 람다 전용이니까 절대 건드리지 마!"
@FunctionalInterface
public interface BurgerRecipe {
    void cook();
    // void eat(); // ❌ 이거 주석 풀면 컴파일 에러 발생! 우리를 지켜줍니다. 🛡️
}

Main(람다로 완성된 만능 조리 기계)

HamburgerChef machine = new HamburgerChef();

// 1. 치즈버거 람다로 조리
machine.setRecipe(() -> System.out.println("# 체다 치즈 투하!"));
machine.makeBurger();

// 2. 베이컨버거 람다로 조리
machine.setRecipe(() -> System.out.println("# 바삭한 베이컨 추가!"));
machine.makeBurger();
  • 조리법을 마치 데이터처럼 가볍게 주고받을 수 있게 되었음
  • 함수형 프로그래밍(Functional Programming)의 시작

💡 요약

  • String 파라미터는 주방장한테 포스트잇 한 장 써주는 것 -> 치즈라고 써있으면 주방장은 그냥 치즈만 갖다 놓음
  • 람다는 주방장한테 조리법 동영상을 보여주는 것 -> 복잡한 시나리오를 통째로 입력할 수 있게 됨
  • 단순히 이름을 바꾸고 싶은 게 아니라 상황에 따라 변하는 복잡한 행동을 통째로 조립하고 싶은 것

Step4: 동작 파라미터화

  • 사과 농장 주인이 녹색 사과, 빨간 사과, 무거운 사과, 빨간색 + 무거운 사과 골라달라고 함 -> 조건(검사하는 방법)자체를 파라미터로 받으면 됨

이것이 동작 파라미터화

// "사과를 검사하는 방법"을 추상화
public interface ApplePredicate {
    boolean test(Apple apple);
}

// 메인 메서드는 이제 '검사 방법(p)'을 받아서 실행만 합니다.
public static List<Apple> filterApples(List<Apple> inventory, ApplePredicate p) {
    ...
    if (p.test(apple)) { // "이 사과 합격인가요?"라고 물어봄
        result.add(apple);
    }
    ...
}
  • 이제 농장 주인이 무슨 요구를 하든 메서드를 고칠 필요 없음
    그때그때 새로운 전략(동작)을 만들어서 던져주면 됨
// "무거운 사과 골라줘" -> OK, 무거운 사과 전략(람다) 투입!
filterApples(inventory, a -> a.getWeight() > 150);

제네릭

// T: 무엇이든 들어올 수 있다!
public static <T> List<T> filter(List<T> list, GenericPredicate<T> p) { ... }

// 숫자 중 짝수만 골라줘!
filter(numbers, (Integer i) -> i % 2 == 0);

💡 결론
1) 하드코딩: 변화에 대응 불가능

  • 녹색 사과만 골라줘!
public static List<Apple> filterGreenApples(List<Apple> inventory) {
    List<Apple> result = new ArrayList<>();
    for (Apple apple : inventory) {
        if (Color.GREEN.equals(apple.getColor())) { // 녹색만 딱 집어서!
            result.add(apple);
        }
    }
    return result;
}

2) 값 파라미터화: 조건이 바뀌면 대처 불가능

  • 빨간 사과도 골라줘!
// 색깔(Color)을 파라미터로 받아서 유연해졌다! (값 파라미터화)
public static List<Apple> filterApplesByColor(List<Apple> inventory, Color color) {
    ...
    if (apple.getColor().equals(color)) { ... }
    ...
}

3) 동작 파라미터화: 어떤 조건이든 대응 가능
4) 제네릭: 어떤 객체든 적용 가능

  • 변하지 않는 로직(필터링)과 자주 변하는 로직(조건)을 분리하는 것

Bonus Step:메서드 참조

  • 파라미터 받고 그걸 다시 그대로 넘겨주는 것조차 귀찮다 -> 아예 파라미터(->)조차 생략 가능. 이것이 메서드 참조, 즉 이중 콜론(::)

예시1 (스태틱 메서드를 쓸 때(Static Method))

// Lambda: x를 받아서 Math.abs에 넣어라
Function<Integer, Integer> f = x -> Math.abs(x);

// Method Reference: Math의 abs를 써라 (x는 알아서 들어간다)
Function<Integer, Integer> f = Math::abs;

예시 2(이미 있는 객체의 메서드를 쓸 때(Specific Object))

// Lambda: 파라미터 없이, tv 객체의 turnOn을 실행해라
Command cmd = () -> tv.turnOn();

// Method Reference: tv의 turnOn을 실행해라
Command cmd = tv::turnOn;

예시 3(특정 타입의 임의의 객체 메서드를 쓸 때(Arbitrary Object))

// Lambda: idol 한 명을 받아서(i), 그 사람의 이름(getName)을 꺼내라
Function<Idol, String> f = i -> i.getName();

// Method Reference: Idol 클래스의 getName을 써라 (주어인 i가 생략됨)
Function<Idol, String> f = Idol::getName;

람다 VS 메서드 참조


2회차 강의를 들으면서 확실히 난이도가 있다고 느꼈다.
하지만 튜터님의 찰떡 비유 덕분에 이해가 잘 되었다.
람다와 메서드 참조 부분이 잘 이해가 가지 않아서 다시 복습 해야겠다고 느꼈다.

0개의 댓글