문서 읽는 데 56분 · day25

Day 25 — 람다와 함수형 인터페이스: 동작을 인자로 넘기다

목차 20
전체 33강 중 25강 · 자바 기초
난이도 · 입문

Phase 4 "모던 자바" 의 첫날이에요. 지난 시간에 우리는 인스타그램의 두뇌, 서비스 계층을 순수 자바만으로 통째로 조립해냈죠. 회원가입·게시물 CRUD·팔로우까지요. 그 과정에서 한 가지가 자꾸 눈에 거슬렸어요. "전체를 훑으면서 조건에 맞는 것만 골라내는" for 반복이 여기저기 똑같이 반복됐거든요.

오늘부터 그 반복을 확 줄이는 도구를 배워요. "조건에 맞는 것만 걸러줘", "이걸 저것으로 바꿔줘" 같은 주문을 한 줄로 표현하는 방법이에요. 지금까지 우리는 "어떻게 하나하나 처리할지" 를 길게 적었어요. 앞으로는 "무엇을 원하는지" 만 짧게 선언하게 돼요.

그 첫 조각이 바로 람다(lambda) 와 함수형 인터페이스(functional interface) 예요. 이름이 좀 낯설죠? 괜찮아요. 오늘 우리가 할 일은 사실 단순해요. "코드 한 토막"을 마치 값처럼 메서드에 넘기는 법을 배우는 거예요. 숫자나 문자열을 인자로 넘겼듯이, 이제 "동작" 자체를 인자로 넘겨봐요. 시작해볼게요!

🎯 학습 목표

  • 익명 클래스(anonymous class, 이름 없이 그 자리에서 만드는 일회용 클래스)가 왜 번거로운지 알고, 그걸 람다로 줄일 수 있어요.
  • 람다가 "이름 없는 함수 한 토막" 이라는 걸 이해하고, (매개변수) -> 결과 형태로 쓸 수 있어요.
  • 함수형 인터페이스(functional interface, 추상 메서드가 딱 하나인 인터페이스)가 무엇인지 알고 @FunctionalInterface 로 표시할 수 있어요.
  • 자바가 미리 만들어둔 4대 도구 Predicate·Function·Consumer·Supplier 를 상황에 맞게 골라 쓸 수 있어요.
  • 메서드 참조(method reference, ::)로 람다를 더 짧게 줄일 수 있어요.
  • 동작(코드)을 메서드의 인자로 넘기는 법을 익혀, 지난 시간 반복됐던 for 거르기를 짧게 줄일 수 있어요.

오늘의 로드맵

  • Step 1 — 규칙 하나 때문에 클래스 한 파일? 람다가 필요한 이유를 짚어요.
  • Step 2 — 익명 클래스: 파일은 안 만들어도 되지만, 여전히 길어요.
  • Step 3 — 람다로 압축: 군더더기를 걷어내면 화살표 한 줄만 남아요.
  • Step 4 — 함수형 인터페이스란? @FunctionalInterface 의 정체를 밝혀요.
  • Step 5Predicate: 조건을 담는 그릇이에요.
  • Step 6Function·Consumer·Supplier: 변환·소비·공급 3종 세트예요.
  • Step 7 — 메서드 참조 ::: 람다를 한 번 더 줄여요.
  • Step 8 — 종합: 조건을 인자로 받는 filter 로 지난 시간 for 반복을 줄여요.

Step 1: 규칙 하나 때문에 클래스 한 파일?

지난 시간보다 더 거슬러 올라가 볼게요. Day 18 에서 회원 명단을 "팔로워 많은 순" 으로 정렬하려고 했던 거 기억나세요? 그때 우리는 정렬 기준을 담은 클래스를 하나 따로 만들었어요.

Java
// com/instagram/javabasic/collection/FollowerComparator.java
public class FollowerComparator implements Comparator<SortableMember> {

    @Override
    public int compare(SortableMember a, SortableMember b) {
        return b.getFollowers() - a.getFollowers();
    }
}

이 클래스에서 진짜 알맹이는 단 한 줄이에요.

Java
return b.getFollowers() - a.getFollowers();

나머지는 전부 그 한 줄을 감싸는 포장이에요. public class ... implements Comparator, @Override, public int compare(...) — 이게 다 "정렬 기준 한 줄" 을 넣기 위한 껍데기였던 거죠. 게다가 "이름 순" 으로도 정렬하고 싶으면? UsernameComparator 라는 클래스를 또 하나 만들어야 했어요.

텍스트
  내가 진짜 넘기고 싶은 것:   b.getFollowers() - a.getFollowers()   ← 한 줄
  실제로 써야 했던 것:
        ┌──────────────────────────────────────────────┐
        │ public class FollowerComparator              │  ← 포장
        │       implements Comparator<SortableMember> {│  ← 포장
        │   @Override                                  │  ← 포장
        │   public int compare(...) {                  │  ← 포장
        │       return b.getFollowers() - ...;         │  ← 알맹이(이게 전부!)
        │   }                                          │  ← 포장
        │ }                                            │  ← 포장
        └──────────────────────────────────────────────┘

우리가 정말 넘기고 싶은 건 "비교하는 동작" 한 줄인데, 그걸 전달하려고 클래스 파일을 통째로 만들어야 했어요. 숫자를 넘길 땐 3 한 글자면 되는데, "동작" 을 넘기려니 이렇게 거창해지는 거예요.

오늘의 목표가 여기서 나와요. 이 포장을 한 겹씩 벗겨내서, 결국 알맹이 한 줄만 넘기는 것. 그게 람다예요. 지금부터 이 FollowerComparator 를 단계별로 줄여볼게요.

💡 튜터의 한마디

람다는 새로운 마법이 아니에요. "정렬 기준 같은 동작 한 토막을, 클래스라는 포장 없이 그대로 넘기자" 는 아주 실용적인 아이디어예요. 왜 필요한지만 잡고 가면 문법은 저절로 따라와요.


Step 2: 익명 클래스 — 파일은 안 만들어도 되지만, 여전히 길어요

포장을 벗기는 첫걸음이에요. 사실 정렬 기준처럼 그 자리에서 딱 한 번만 쓸 클래스라면, 굳이 파일을 따로 만들 필요가 없어요. 자바에는 필요한 그 자리에서, 이름도 안 붙이고, 클래스를 즉석으로 만드는 문법이 있거든요. 이걸 익명 클래스(anonymous class, 이름 없는 클래스) 라고 해요.

우리 Member 명단을 팔로워 많은 순으로 정렬해볼게요. (MembergetFollowers() 를 갖고 있으니, Day 18 의 비교 한 줄이 그대로 통해요.)

Java
// com/instagram/javabasic/modern/LambdaIntro.java
public static List<Member> sortByFollowersAnonymous(List<Member> members) {
    List<Member> copy = new ArrayList<>(members);
    copy.sort(new Comparator<Member>() {
        @Override
        public int compare(Member a, Member b) {
            return b.getFollowers() - a.getFollowers();
        }
    });
    return copy;
}

copy.sort(...) 의 괄호 안을 보세요. new Comparator<Member>() { ... } — 클래스 선언과 그 내용물이 통째로 sort 의 인자 자리에 들어가 있죠. FollowerComparator.java 파일이 사라진 거예요. 파일을 찾아 열 필요 없이, 정렬이 필요한 바로 그 자리에 비교 기준을 적어 넣었어요.

참고로 List 에는 sort(Comparator) 라는 메서드가 있어요. Collections.sort(list, comparator) 와 같은 일을 하는, 리스트가 직접 가진 정렬 메서드예요. 둘 중 편한 걸 쓰면 돼요.

한 걸음 나아갔어요. 그런데 여전히 뭔가 길죠? 포장이 절반쯤 남아 있어요.

텍스트
  Day 18 방식 (별도 파일):  FollowerComparator.java 파일 통째로
  익명 클래스 (지금):       new Comparator<Member>() {
                              @Override
                              public int compare(Member a, Member b) {
                                  return b.getFollowers() - a.getFollowers();
                              }
                          }   ← 파일은 없앴지만 포장은 아직 남음

new Comparator<Member>(), @Override, public int compare(...) — 이 포장이 아직 그대로예요. 알맹이는 여전히 return b.getFollowers() - a.getFollowers(); 한 줄인데 말이죠. 다음 단계에서 이 남은 포장마저 걷어내요.


Step 3: 람다로 압축 — 화살표 한 줄만 남기다

이제 핵심이에요. 익명 클래스에서 "컴파일러가 이미 아는 정보" 를 하나씩 지워볼게요. 자바가 스스로 알아낼 수 있는 건 안 적어도 되거든요.

생각해보면, copy.sort(...) 에 넘기는 게 Comparator 라는 건 자바도 이미 알아요(sort 메서드가 Comparator 를 받게 되어 있으니까요). 그리고 Comparator 에는 채워야 할 메서드가 compare 단 하나뿐이에요. 그러니 이런 것들은 군더더기예요.

텍스트
  ① new Comparator<Member>()   →  자바가 이미 앎 (sort 가 Comparator 를 받으니까)   → 지움
  ② @Override / public int compare  →  채울 메서드가 하나뿐이라 뻔함            → 지움
  ③ (Member a, Member b)       →  타입도 자바가 추론함                        → (a, b)
  ④ { return ...; }            →  한 줄이면 중괄호·return 생략 가능            → -> ...

네 단계를 다 적용하면, 거짓말처럼 이렇게 줄어들어요.

Java
// com/instagram/javabasic/modern/LambdaIntro.java
public static List<Member> sortByFollowersLambda(List<Member> members) {
    List<Member> copy = new ArrayList<>(members);
    copy.sort((a, b) -> b.getFollowers() - a.getFollowers());
    return copy;
}

copy.sort((a, b) -> b.getFollowers() - a.getFollowers()); — 이 한 줄이 Step 2 의 익명 클래스 다섯 줄과 완전히 똑같이 동작해요. 정렬 결과도 같고요. 포장을 다 벗기고 알맹이만 남긴 거예요.

(a, b) -> b.getFollowers() - a.getFollowers() 가 바로 람다예요. 모양을 뜯어볼게요.

텍스트
   (a, b)      ->      b.getFollowers() - a.getFollowers()
   └──┬──┘      │       └──────────────┬───────────────┘
   매개변수    화살표          돌려줄 결과(또는 실행할 일)
  (받는 값)   "이걸 받아서   "이렇게 처리해줘"
              →이렇게 해"

람다는 한마디로 "이름 없는 함수 한 토막" 이에요. 메서드처럼 입력을 받아서 결과를 내놓는데, 이름도 없고 클래스도 없이 그 자리에 딱 적는 거죠. 왼쪽 (a, b) 가 받는 값, 화살표 -> 오른쪽이 할 일이에요.

🙋 학생 질문 — "튜터님, 화살표 오른쪽에 여러 줄을 쓰고 싶으면요?"

좋은 질문이에요! 한 줄로 안 끝나는 동작이면 중괄호를 다시 쓰면 돼요.

Java
copy.sort((a, b) -> {
    int diff = b.getFollowers() - a.getFollowers();
    System.out.println(a.getUsername() + " vs " + b.getUsername());
    return diff;
});

중괄호 { } 를 열면 그 안엔 평소처럼 여러 줄을 쓸 수 있고, 결과를 돌려줄 땐 return 도 다시 써줘야 해요. 반대로 한 줄짜리면 중괄호도 return 도 생략하는 거고요. 즉 "한 줄이면 짧게, 여러 줄이면 중괄호" 이렇게 기억하면 편해요.

💡 튜터의 결론

별도 클래스 → 익명 클래스 → 람다. 셋 다 하는 일은 똑같아요. 단지 포장을 점점 벗긴 것뿐이에요. 람다를 보면 머릿속으로 "아, 이건 메서드 하나짜리 클래스를 줄인 거구나" 하고 되돌려 읽을 수 있으면 충분해요.


Step 4: 함수형 인터페이스란? @FunctionalInterface 의 정체

여기서 잠깐 멈춰서 질문 하나 던질게요. 왜 Comparator 자리에는 람다가 되는 걸까요? 아무 인터페이스나 람다로 줄일 수 있는 걸까요?

비밀은 Comparator채워야 할 메서드가 딱 하나 라는 데 있어요. compare 하나뿐이죠. 메서드가 하나뿐이니, 람다 한 토막이 "바로 그 메서드의 몸통" 이라고 딱 정해지는 거예요. 만약 메서드가 두 개라면, 람다를 적어도 자바가 "이게 둘 중 어느 메서드야?" 하고 헷갈리겠죠.

이렇게 추상 메서드(아직 몸통이 없는, 채워야 할 메서드)가 정확히 하나인 인터페이스 를 함수형 인터페이스(functional interface) 라고 해요. 람다는 함수형 인터페이스의 그 하나뿐인 메서드를 채우는 거예요.

직접 하나 만들어볼게요. "회원이 어떤 조건에 맞나?" 를 판단하는 그릇이에요.

Java
// com/instagram/javabasic/modern/MemberFilter.java
@FunctionalInterface
public interface MemberFilter {

    // 회원이 조건에 맞으면 true, 아니면 false 를 돌려줘요.
    boolean isMatch(Member member);
}

메서드가 isMatch 하나뿐이죠. 그래서 이 인터페이스는 람다로 바로 만들 수 있어요. 위에 붙은 @FunctionalInterface 가 보이나요? 이건 자바에게 "이 인터페이스는 메서드 하나짜리로 쓸 거야" 라고 알리는 표시예요. 실수로 메서드를 하나 더 넣으면 곧바로 컴파일 에러를 내서 막아줘요. 일종의 안전장치죠. 안 붙여도 동작은 하지만, 의도를 분명히 하고 실수를 막아주니 붙이는 게 좋아요.

이제 이 그릇에 람다를 담아볼게요. 놀라운 건, 람다를 변수에 담을 수 있다 는 거예요.

Java
MemberFilter popular = m -> m.getFollowers() >= 1000;

Member minji = new Member("minji", 8500, 150, 5, 365);
System.out.println(popular.isMatch(minji));   // true — 팔로워 8500 ≥ 1000

popular 라는 변수에 "팔로워 1000 이상인지 판단하는 동작" 을 통째로 담았어요. 그리고 popular.isMatch(minji) 로 그 동작을 실행했죠. 동작에 이름표를 붙여 변수로 들고 다니다가, 필요할 때 꺼내 쓰는 거예요. 같은 그릇에 다른 람다를 담으면 다른 규칙이 되고요.

Java
MemberFilter active = m -> m.getDaysActive() >= 100;   // 같은 그릇, 다른 규칙
텍스트
   MemberFilter (그릇)              담기는 람다 (동작)
   ┌─────────────────┐
   │ boolean isMatch │  ◀── popular:  m -> m.getFollowers() >= 1000
   │     (Member)    │  ◀── active:   m -> m.getDaysActive() >= 100
   └─────────────────┘       메서드가 하나뿐이라, 람다 하나로 채워져요

💡 튜터의 결론

함수형 인터페이스 = 메서드 하나짜리 인터페이스 = 람다를 담는 그릇. 람다는 공중에 떠다니는 게 아니라 늘 이런 그릇에 담겨요. 그릇의 메서드가 하나뿐이니까, 람다 한 토막으로 깔끔하게 채워지는 거예요.


Step 5: Predicate — 조건을 담는 그릇

방금 우리가 만든 MemberFilter 를 다시 보세요. "무언가를 받아서 true/false 로 답한다" — 이 모양이에요. 그런데 가만 생각해보면, 이렇게 "받아서 참/거짓 판단" 하는 그릇은 프로그래밍에서 어마어마하게 자주 필요해요. 인기 회원인가? 공개 글인가? 성인인가? 전부 같은 모양이죠.

그래서 자바가 아예 미리 만들어놨어요. 그게 Predicate<T>(프레디킷, "판단·조건" 이라는 뜻) 예요. 우리가 만든 MemberFilter 의 표준판이라고 보면 돼요. 메서드 이름만 isMatch 대신 test 예요.

Java
// com/instagram/javabasic/modern/PredicateDemo.java
// 인기 회원인가? — 팔로워 1000 이상이면 true
public static final Predicate<Member> POPULAR = m -> m.getFollowers() >= 1000;

// 활동적인가? — 활동 일수 100 이상이면 true
public static final Predicate<Member> ACTIVE = m -> m.getDaysActive() >= 100;

Predicate<Member> 는 "Member 를 받아 true/false 를 판단하는 그릇" 이에요. 꺾쇠 <Member> 안에 어떤 타입을 받을지 적어요(지난 시간에 배운 제네릭이죠). 쓸 때는 test(...) 를 불러요.

Java
Member minji = new Member("minji", 8500, 150, 5, 365);
Member newbie = new Member("newbie", 50, 2, 10, 5);

System.out.println(POPULAR.test(minji));    // true
System.out.println(POPULAR.test(newbie));   // false

이제 우리는 MemberFilter 같은 그릇을 매번 직접 만들 필요가 없어요. 조건을 판단하고 싶으면 자바가 준비해둔 Predicate 를 꺼내 람다만 담으면 되거든요.

그리고 Predicate 에는 조건을 합치거나 뒤집는 편의 기능이 같이 들어 있어요.

Java
// and — 두 조건이 모두 true 일 때만 true ("인기이면서 활동적인")
Predicate<Member> popularAndActive = POPULAR.and(ACTIVE);
System.out.println(popularAndActive.test(minji));    // true

// negate — 조건을 뒤집어요 ("인기가 아닌")
System.out.println(POPULAR.negate().test(newbie));   // true — newbie 는 인기 아님

and(그리고)·or(또는)·negate(부정) 로 작은 조건들을 레고처럼 조립할 수 있어요. "인기이면서 활동적인 회원" 같은 복잡한 조건을, 작은 조건 둘을 and 로 붙여 만든 거죠. 조건 하나하나를 따로 정의해두고 필요할 때 조합하면, 읽기도 쉽고 재사용도 쉬워요.

💡 튜터의 결론

Predicate<T> = test(T) 로 true/false 를 답하는 표준 그릇. "거를까 말까", "맞나 틀리나" 를 판단하는 모든 곳에 등장해요. 우리가 Step 4 에서 직접 만든 MemberFilter 의 정식 버전이라고 기억하면 돼요.


Step 6: Function · Consumer · Supplier — 변환·소비·공급 3종 세트

Predicate 는 "받아서 판단(true/false)" 하는 그릇이었어요. 동작에는 이것 말고도 자주 나오는 모양이 몇 개 더 있어요. 받아서 다른 걸로 바꾸거나, 받아서 쓰기만 하거나, 아무것도 안 받고 만들어주거나. 이 셋도 자바가 그릇으로 미리 만들어놨어요.

먼저 Function<T, R>(펑션, 함수) 예요. T 를 받아서 R 로 바꿔서 돌려줘요. 메서드는 apply 고요.

Java
// com/instagram/javabasic/modern/FunctionConsumerSupplier.java
// Function — 회원을 받아 화면에 띄울 이름표(@minji) 문자열로 바꿔요
public static final Function<Member, String> TO_TAG = m -> "@" + m.getUsername();
Java
Member minji = new Member("minji", 8500, 150, 5, 365);
String tag = TO_TAG.apply(minji);
System.out.println(tag);   // @minji

Function<Member, String> 은 "Member 를 받아 String 으로 바꾼다" 는 뜻이에요. 꺾쇠 안 첫 번째가 받는 타입, 두 번째가 돌려줄 타입이에요. 회원 객체를 화면용 이름표 문자열로 변환한 거죠.

다음은 Consumer<T>(컨슈머, 소비자) 예요. T 를 받아서 쓰기만 하고, 돌려주는 값은 없어요. 메서드는 accept 예요.

Java
// Consumer — 받은 걸 쓰기만 하고, 돌려주는 값은 없어요
Consumer<String> printer = line -> System.out.println("알림: " + line);
printer.accept(tag + " 님이 새 글을 올렸어요");   // 알림: @minji 님이 새 글을 올렸어요

화면에 출력하거나, 어딘가에 기록하는 것처럼 "받아서 처리하고 끝" 인 동작에 어울려요. 결과를 돌려받을 필요가 없는 일이죠.

마지막은 Supplier<T>(서플라이어, 공급자) 예요. 아무것도 안 받고 T 를 하나 만들어 줘요. 메서드는 get 이에요.

Java
// Supplier — 부를 때마다 갓 만든 게스트 회원을 하나 만들어 줘요
public static final Supplier<Member> GUEST = () -> new Member("guest", "guest@instagram.com");
Java
Member guest = GUEST.get();
System.out.println(guest.getUsername());   // guest

() -> 처럼 화살표 왼쪽 괄호가 비어 있죠? 받는 게 없다는 뜻이에요. 부를 때마다 새 객체를 만들어 주는 "공장" 같은 그릇이에요.

이제 네 그릇을 한 표로 정리해볼게요. 입력과 출력이 있느냐 없느냐로 나뉘어요.

그릇 받는 것 돌려주는 것 메서드 한 줄 요약
Predicate<T> T boolean test 받아서 판단
Function<T,R> T R apply 받아서 변환
Consumer<T> T 없음 accept 받아서 소비
Supplier<T> 없음 T get 안 받고 공급

💡 튜터의 결론

네 그릇의 차이는 딱 하나예요. "입력이 있나/없나, 출력이 있나/없나." 판단이면 Predicate, 변환이면 Function, 받아 쓰기만 하면 Consumer, 만들어내면 Supplier. 이름을 외우기보다 "이 동작은 받아서 뭘 하지?" 를 떠올리면 어느 그릇인지 보여요.


Step 7: 메서드 참조 :: — 람다를 한 번 더 줄이다

람다를 쓰다 보면 이런 경우가 자주 생겨요. 람다가 하는 일이 이미 있는 메서드 하나를 그대로 부르는 것뿐 일 때요.

Java
Function<Member, String> toName = m -> m.getUsername();

이 람다는 받은 mgetUsername() 을 부르기만 해요. 받은 값을 그대로 어떤 메서드에 넘겨 부르기만 할 거라면, 자바는 이걸 더 짧게 줄이는 문법을 줘요. 바로 메서드 참조(method reference) 예요. ::(콜론 두 개) 를 써요.

Java
// com/instagram/javabasic/modern/MethodReferenceDemo.java
Function<Member, String> toName = Member::getUsername;   // m -> m.getUsername() 과 똑같아요
System.out.println(toName.apply(minji));   // minji

Member::getUsername 은 "Member 의 getUsername 메서드를 가리킨다" 는 뜻이에요. m -> 도 없고 괄호도 없죠. 람다보다 짧고, "무슨 메서드를 쓰는지" 가 이름으로 바로 드러나요.

메서드 참조에는 네 가지 형태가 있어요. 모양은 비슷하니 가볍게 훑어볼게요.

Java
// (1) 타입의 인스턴스 메서드 —  m -> m.getUsername()
Function<Member, String> toName = Member::getUsername;

// (2) 정적 메서드 —  s -> Integer.parseInt(s)
Function<String, Integer> toInt = Integer::parseInt;

// (3) 생성자 —  () -> new StringBuilder()
Supplier<StringBuilder> makeBuilder = StringBuilder::new;

// (4) 특정 객체의 인스턴스 메서드 —  x -> System.out.println(x)
Consumer<String> print = System.out::println;

네 형태 모두 공통점이 하나예요. "람다가 메서드 딱 하나를 부르기만 한다" 는 거죠. 그럴 때 대상::메서드 로 줄여 쓰는 거예요. 생성자는 메서드 이름 대신 new 를 써서 StringBuilder::new 처럼 적고요.

⚠️ 이런 건 메서드 참조로 못 줄여요

m -> m.getFollowers() >= 1000 처럼 람다 안에서 비교·계산을 하거나, m -> "@" + m.getUsername() 처럼 결과를 한 번 더 가공하면 메서드 참조로 못 바꿔요. "메서드 하나를 그대로 부르기만" 할 때만 줄일 수 있거든요. 안 줄여진다고 당황하지 말고, 그냥 람다로 두면 돼요.

💡 튜터의 결론

메서드 참조는 람다의 축약형이에요. m -> m.getUsername()Member::getUsername 으로 줄듯이, "받은 걸 그대로 메서드 하나에 넘기는" 람다만 :: 로 바꿀 수 있어요. 못 줄이는 경우가 더 많으니, 줄일 수 있을 때만 쓰면 돼요.


Step 8: 종합 — 조건을 인자로 받는 filterfor 반복을 줄이다

드디어 오늘의 결실이에요. 지난 시간 우리를 거슬리게 했던 그 for 반복으로 돌아가요. 게시물 저장소에서 "특정 작성자의 글만 모으는" 코드, 기억나죠?

Java
// com/instagram/javabasic/repository/PostRepository.java (지난 시간 코드)
public List<Post> findByAuthor(Member author) {
    List<Post> result = new ArrayList<>();
    for (Post post : store.values()) {
        if (author != null && author.equals(post.getAuthor())) {
            result.add(post);
        }
    }
    return result;
}

"빈 리스트를 만들고 → 전체를 훑으며 → 조건에 맞으면 담고 → 돌려준다." 이 뼈대 자체는 아주 흔해요. 문제는, "좋아요 많은 글만" 모으고 싶으면 findPopular 라는 메서드를 또 만들고, "공개된 글만" 이면 findPublic 을 또 만들어야 한다는 거예요. 뼈대는 똑같은데 if 안의 조건 한 줄만 다른 메서드를 계속 복제하게 되죠.

이제 우리에겐 도구가 있어요. 그 다른 조건 한 줄을 Predicate인자로 받으면 되잖아요?

Java
// com/instagram/javabasic/modern/PostFilter.java
public static List<Post> filter(List<Post> posts, Predicate<Post> condition) {
    List<Post> result = new ArrayList<>();
    for (Post post : posts) {
        if (condition.test(post)) {
            result.add(post);
        }
    }
    return result;
}

findByAuthor 와 거의 똑같죠? 딱 한 군데만 달라요. if 안이 구체적인 조건이 아니라 condition.test(post) 예요. "무슨 조건으로 거를지" 를 메서드 안에 박아두지 않고, 부르는 쪽이 람다로 정하게 넘긴 거예요.

그러니 이제 메서드 하나로 무엇이든 걸러낼 수 있어요.

Java
Member minji = new Member("minji", 8500, 150, 5, 365);

// 좋아요 100 이상인 글만
List<Post> popular = PostFilter.filter(posts, p -> p.getLikeCount() >= 100);

// minji 가 쓴 글만
List<Post> mine = PostFilter.filter(posts, p -> minji.equals(p.getAuthor()));

// 공개된 글만
List<Post> shared = PostFilter.filter(posts, p -> p.canBeShared());

findByAuthor·findPopular·findPublic 세 메서드가 filter 하나로 합쳐졌어요. 새 조건이 필요하면 람다 한 줄만 더 넘기면 되고요. 이게 바로 "동작을 인자로 넘긴다" 의 힘이에요. 오늘 배운 모든 게 이 한 장면을 위한 거였어요.

텍스트
  지난 시간 (조건마다 메서드 복제)        오늘 (조건을 인자로)
  ┌──────────────────┐                  ┌───────────────────────────────┐
  │ findByAuthor()   │                  │ filter(posts, 조건)           │
  │ findPopular()    │     ───▶         │   ↑ 뼈대 하나                 │
  │ findPublic()     │                  │   조건은 부를 때 람다로 넘김  │
  │ ... (계속 복제)  │                  │   p -> p.getLikeCount() >= 100│
  └──────────────────┘                  └───────────────────────────────┘
   뼈대 중복, if 한 줄만 다름            뼈대 하나 + 람다만 갈아끼움

💡 튜터의 결론

거르는 뼈대(for + 빈 리스트 + add)는 한곳에 두고, 바뀌는 조건만 람다로 바깥에서 넘긴다 — 이게 오늘의 핵심 한 장면이에요. 같은 생각을 변환(map 비슷한 것)·정렬에도 그대로 쓸 수 있어요.

다음 시간엔 — 자바가 미리 만들어둔 filter

방금 우리가 만든 PostFilter.filter, 꽤 쓸모 있죠? 그런데 재밌는 사실이 있어요. 자바를 만든 사람들도 똑같은 생각을 했어요. "거르고·바꾸고·모으는 뼈대를 매번 만들지 말고, 아예 모든 컬렉션에 기본으로 넣어두자."

그래서 자바에는 List·Set 같은 컬렉션을 흐르는 데이터의 줄기처럼 다루는 도구가 들어 있어요. 우리가 만든 filter 와 똑같은 일을 하는 .filter() 가 이미 거기 들어 있고, 변환하는 .map(), 모으는 도구까지 한 줄로 이어 붙일 수 있어요. 이걸 스트림(Stream) 이라고 불러요.

다음 시간엔 이 스트림으로 "데이터 파이프라인" 을 만들어요. 오늘 우리가 손으로 만든 filter 의 정식 버전을, 그리고 그 뒤에 .map() 까지 이어 붙여 데이터를 물 흐르듯 가공하는 법을요. 오늘 "조건을 람다로 넘긴다" 를 손에 익혔다면, 다음 시간은 그 위에 올라타기만 하면 돼요.


마무리

  • Step 1: 정렬 기준 같은 "동작" 한 줄을 넘기려고 클래스 파일을 통째로 만드는 건 과해요. 알맹이만 넘기고 싶어요.
  • Step 2: 익명 클래스로 파일은 없앴지만, new Comparator<>() { @Override ... } 포장이 아직 남아요.
  • Step 3: 컴파일러가 아는 정보를 다 지우면 람다 (a, b) -> ... 한 줄만 남아요. 람다 = 이름 없는 함수 한 토막.
  • Step 4: 람다가 담기는 그릇이 함수형 인터페이스예요. 추상 메서드가 하나뿐인 인터페이스죠. @FunctionalInterface 로 안전장치를 켜요.
  • Step 5: Predicate<T>test(T) 로 true/false 를 판단하는 표준 그릇. and·or·negate 로 조건을 조립해요.
  • Step 6: Function(변환)·Consumer(소비)·Supplier(공급) — 입출력 유무로 나뉘는 3종 그릇이에요.
  • Step 7: 메서드 참조 :: 는 "메서드 하나만 그대로 부르는" 람다의 축약형. Member::getUsername.
  • Step 8: 거르는 뼈대는 한곳에 두고 조건만 Predicate 로 인자로 받으면, 메서드 하나로 무엇이든 걸러내요.

오늘로 우리는 "동작을 값처럼 다루는" 새로운 사고방식을 손에 넣었어요. 지금까지 변수에 담던 건 숫자·문자열 같은 데이터뿐이었는데, 이제 "동작" 까지 변수에 담고, 인자로 넘기고, 돌려받을 수 있게 됐죠. 이건 단순히 코드가 짧아지는 걸 넘어서, 앞으로 배울 거의 모든 모던 자바 문법의 토대예요. 다음 시간 스트림에서 바로 이 힘을 펼쳐볼게요. 정말 수고 많으셨어요!


과제

오늘 배운 람다·함수형 인터페이스·메서드 참조를 직접 손에 익히는 과제예요. 셋 다 modern 패키지의 예제를 옆에 두고 풀면 편해요. 스트림(.stream())이나 다음에 배울 문법은 아직 쓰지 않아요. 오늘까지 배운 람다·Predicate·Function·Consumer·메서드 참조, 그리고 지난 시간까지의 컬렉션·제네릭 범위에서 풀면 돼요.

과제 1: [기초] Predicate 로 회원 골라내기

해야 할 일

게시물을 걸렀던 PostFilter 처럼, 이번엔 회원을 거르는 도구를 만들어보세요. 조건을 Predicate<Member> 로 인자로 받는 게 핵심이에요.

요구사항

  • 새 클래스 MemberSelector 를 만들고, static List<Member> select(List<Member> members, Predicate<Member> condition) 메서드를 둬요.
  • 안에서는 빈 리스트를 만들고 → membersfor 로 훑으며 → condition.test(m)true 인 회원만 새 리스트에 담아 → 돌려줘요. (PostFilter.filter 를 회원용으로 옮기는 거예요.)
  • main 에서 회원 넷쯤 만들어 리스트에 담고, select세 번 불러요.
    • 팔로워 1000 이상인 회원만 (m -> m.getFollowers() >= 1000)
    • 활동 일수 100 미만인 신규 회원만 (m -> m.getDaysActive() < 100)
    • 추천 점수가 일정 이상인 회원만 (m -> m.calculateRecommendScore() >= 50 처럼)
  • 각각 거른 결과의 개수를 출력해, 조건마다 다른 회원이 걸러지는 걸 확인해요.

힌트

  • 같은 select 메서드에 람다만 갈아 끼우면 다른 조건이 돼요. 메서드를 세 개 만들 필요가 없어요.
  • 회원의 점수는 calculateRecommendScore() 로, 활동 일수는 getDaysActive() 로 꺼내요.

과제 2: [응용] FunctionConsumer 로 알림 문구 만들기

해야 할 일

게시물 하나를 "알림 문구" 로 바꿔 출력하는 흐름을, Function(변환)과 Consumer(소비)로 나눠 만들어보세요. "바꾸는 일" 과 "출력하는 일" 을 각각 다른 그릇에 담는 게 핵심이에요.

요구사항

  • 새 클래스 NotificationFormatter 를 만들어요.
  • Function<Post, String> 타입의 필드(또는 지역 변수) toMessage 를 만들어, 게시물을 받아 "[알림] @작성자 님의 새 글: 내용 (좋아요 N)" 같은 문구로 바꿔요. (작성자 이름은 getAuthorName(), 내용은 getContent(), 좋아요는 getLikeCount() 로 꺼내요.)
  • Consumer<String> 타입의 printer 를 만들어, 받은 문구를 화면에 출력해요.
  • main 에서 게시물 세 개를 리스트에 담고, for 로 하나씩 돌면서 toMessage.apply(post) 로 문구를 만든 뒤 printer.accept(...) 로 출력해요.

힌트

  • Functionapply, Consumeraccept 로 불러요.
  • "변환" 과 "출력" 을 굳이 둘로 나눈 이유를 생각해보세요. 나중에 출력 대신 "파일에 저장" 으로 바꾸고 싶으면 printer 만 갈아 끼우면 돼요. 변환 규칙(toMessage)은 그대로 두고요.

과제 3: [심화] 어떤 타입이든 거르는 제네릭 filter

해야 할 일

PostFilterPost 전용, 과제 1 의 MemberSelectorMember 전용이었어요. 둘을 합쳐, 어떤 타입이든 거르는 제네릭 filter 하나로 일반화해보세요. 지난 시간 배운 제네릭과 오늘 배운 Predicate 가 만나는 지점이에요.

요구사항

  • 새 클래스 Filters 를 만들고, 제네릭 메서드 static <T> List<T> filter(List<T> items, Predicate<T> condition) 를 둬요. (<T> 가 "아무 타입이나" 를 뜻해요. Day 20 의 제네릭 메서드를 떠올려요.)
  • 이 메서드 하나로 회원 리스트도, 게시물 리스트도 똑같이 거를 수 있어야 해요.
  • main 에서 두 가지를 모두 보여줘요.
    • 회원 리스트를 Filters.filter(members, m -> m.getFollowers() >= 1000) 로 걸러 개수 출력
    • 게시물 리스트를 Filters.filter(posts, p -> p.getLikeCount() >= 100) 로 걸러 개수 출력
  • 마지막으로, 거른 회원들의 이름을 출력할 때 메서드 참조를 한 번 써보세요. 예를 들어 Function<Member, String> toName = Member::getUsername; 을 만들어 이름을 뽑아 출력해요.

힌트

  • 제네릭 메서드의 <T> 는 반환 타입 앞에 적어요: static <T> List<T> filter(...).
  • PostFilter.filter 의 본문을 거의 그대로 가져오되, PostT 로 바꾸면 돼요. 신기하게도 안쪽 코드는 똑같아요 — 타입만 "아무거나" 로 열어준 거죠.
  • 같은 filter 가 회원에도 게시물에도 통하는 걸 보면, "동작을 인자로" 와 "타입을 인자로(제네릭)" 가 어떻게 함께 힘을 내는지 느껴질 거예요.

생각해볼 주제

오늘 람다와 함수형 인터페이스를 다루며 자연스럽게 떠오르는 질문들이에요. 정답을 외우기보다, 왜 그런지 곰곰이 따져보면 문법 너머의 설계 감각이 자라요.

1. 함수형 인터페이스는 왜 메서드가 "하나" 여야 할까?

오늘 우리는 추상 메서드가 딱 하나인 인터페이스에만 람다를 담을 수 있다고 배웠어요. @FunctionalInterface 는 메서드가 둘이 되면 아예 컴파일 에러로 막아주고요.

그런데 왜 하필 "하나" 일까요? 만약 메서드가 두 개인 인터페이스에 람다 m -> m.getFollowers() >= 1000 를 적었다고 상상해보세요. 자바 입장에서 이 람다는 둘 중 어느 메서드의 몸통일까요? 이 "모호함" 의 관점에서, 함수형 인터페이스의 "하나" 라는 제약이 왜 자연스러운 약속인지 생각해보세요.

2. 같은 for 반복인데, 왜 filter 로 묶는 게 나을까?

Step 8 에서 우리는 findByAuthor·findPopular 같은 메서드를 따로 만드는 대신, 조건을 인자로 받는 filter 하나로 합쳤어요. 사실 결과만 보면 둘 다 똑같이 동작해요. 거르는 일을 하는 건 마찬가지죠.

그렇다면 굳이 filter 로 합치면 뭐가 좋아질까요? 새로운 조건(예: "댓글 5개 이상인 글")이 필요해졌을 때 두 방식이 각각 어떻게 달라지는지 떠올려보세요. 그리고 반대로, 조건을 인자로 받는 게 항상 더 좋기만 할까요? 코드를 처음 보는 사람 입장에서 findByAuthor(member)filter(posts, p -> ...) 중 어느 쪽이 의도를 빨리 알아챌 수 있을지도 함께 생각해보세요.

3. 람다와 익명 클래스, 언제 무엇을 쓸까?

오늘 우리는 익명 클래스를 람다로 줄이는 흐름을 봤어요. 그러면 익명 클래스는 이제 쓸 일이 없는, 그저 람다의 옛날 버전일 뿐일까요?

꼭 그렇진 않아요. 익명 클래스로만 할 수 있는 일이 아직 남아 있거든요. 예를 들어 채워야 할 메서드가 두 개 이상이거나, 그 자리에서 상태(필드)를 들고 있어야 한다면 람다로는 표현할 수 없어요. 어떤 상황에서 람다가 분명히 낫고, 어떤 상황에서는 익명 클래스(혹은 아예 이름 있는 클래스)가 더 어울릴지, "메서드가 몇 개인가" 와 "읽는 사람이 이해하기 쉬운가" 를 기준으로 따져보세요.

✅ 예시 답안정답 보기

오늘 과제는 "조건을 Predicate 로 인자로 받아 거른다", "변환과 소비를 Function·Consumer 로 나눈다", "제네릭으로 어떤 타입이든 거르는 filter 를 만든다" 세 가지가 핵심이에요. 모두 오늘 배운 "동작을 인자로 넘긴다" 의 연습이라, modern 패키지의 예제를 옆에 두고 풀면 편해요. 스트림(.stream())이나 다음에 배울 문법은 아직 쓰지 않아요 — 오늘까지 배운 람다·Predicate·Function·Consumer·메서드 참조, 그리고 지난 시간까지의 컬렉션·제네릭 범위에서 풀면 돼요.

세 과제 모두 정답이 하나는 아니에요. 아래 예시와 다르게 풀었더라도 요구사항을 만족하고 람다를 제대로 활용했다면 훌륭한 답이에요.


과제 예시답안

과제 1 예시답안 — Predicate 로 회원 골라내기

핵심 접근

이 과제의 주제는 "거르는 뼈대는 한곳에 두고, 조건만 람다로 바깥에서 넘긴다" 예요. 교안 Step 8 의 PostFilter 를 회원용으로 옮기는 거죠. 안쪽 for 반복은 똑같고, if 안만 condition.test(m) 으로 열어두면 돼요.

핵심은 "조건마다 메서드를 따로 만들지 않는다" 는 거예요. selectPopular·selectNew 처럼 메서드를 세 개 만드는 대신, select 하나에 람다만 갈아 끼우면 세 가지 조건이 다 처리돼요.

예시 구현

Java
// com/instagram/javabasic/modern/solution/day25/MemberSelector.java
public class MemberSelector {

    // 조건에 맞는 회원만 골라 새 리스트로 돌려줘요. 원본은 건드리지 않아요.
    public static List<Member> select(List<Member> members, Predicate<Member> condition) {
        List<Member> result = new ArrayList<>();
        for (Member m : members) {
            if (condition.test(m)) {
                result.add(m);
            }
        }
        return result;
    }
}

main 에서는 같은 select 에 람다만 바꿔 세 번 불러요.

Java
public static void main(String[] args) {
    List<Member> members = new ArrayList<>();
    members.add(new Member("minji", 8500, 150, 5, 365));
    members.add(new Member("jaehoon", 1240, 42, 3, 200));
    members.add(new Member("newbie", 50, 2, 10, 5));
    members.add(new Member("seungwoo", 320, 12, 1, 90));

    // 같은 select 에 람다만 갈아 끼워 세 가지 조건으로 걸러요
    System.out.println("인기 회원: " + select(members, m -> m.getFollowers() >= 1000).size());
    System.out.println("신규 회원: " + select(members, m -> m.getDaysActive() < 100).size());
    System.out.println("점수 50+: " + select(members, m -> m.calculateRecommendScore() >= 50).size());
}

실행하면 이렇게 나와요.

텍스트
인기 회원: 2
신규 회원: 2
점수 50+: 3

같은 select 인데 넘기는 람다에 따라 다른 회원이 걸러졌죠. 메서드를 세 개 만들지 않고 한 개로 끝낸 게 핵심이에요.

채점 포인트

항목 배점 기준 가중
조건을 인자로 selectPredicate<Member> 를 매개변수로 받는가
거르는 뼈대 빈 리스트 → for 순회 → test 통과만 add → 반환 구조인가
람다만 교체 main 에서 메서드를 늘리지 않고 람다 세 개로 처리했는가
원본 보존 새 리스트를 만들어 담고, 입력 리스트를 바꾸지 않는가
조건 다양성 팔로워·활동일수·점수 등 서로 다른 조건을 보였는가

흔한 실수

  • 조건마다 메서드를 따로 만들기selectPopular·selectNew 를 각각 만들면 오늘 배운 게 무의미해져요. 뼈대는 하나, 조건은 람다로 — 이걸 연습하는 과제예요.
  • Predicate 대신 boolean 결과를 미리 계산해 넘기기select(members, true) 같은 건 동작을 넘기는 게 아니라 결과를 넘기는 거예요. 우리가 넘기려는 건 "판단하는 동작" 자체예요.
  • m -> { return m.getFollowers() >= 1000; } → 틀린 건 아니지만, 한 줄이면 중괄호와 return 을 생략해 m -> m.getFollowers() >= 1000 으로 쓰는 게 깔끔해요.

과제 2 예시답안 — FunctionConsumer 로 알림 문구 만들기

핵심 접근

이 과제의 주제는 "바꾸는 일과 쓰는 일을 다른 그릇에 나눠 담는다" 예요. 게시물을 알림 문구로 바꾸는Function(변환), 그 문구를 화면에 출력하는Consumer(소비) 가 맡아요.

왜 굳이 둘로 나눌까요? 변환 규칙(toMessage)은 그대로 두고, 나중에 "출력" 대신 "어딘가에 기록" 으로 바꾸고 싶을 때 printer 만 갈아 끼우면 되거든요. 두 일이 한 덩어리로 엉켜 있으면 한쪽만 바꾸기 어려워져요.

예시 구현

Java
// com/instagram/javabasic/modern/solution/day25/NotificationFormatter.java
public class NotificationFormatter {

    // Function — 게시물을 받아 알림 문구 문자열로 바꿔요
    public static final Function<Post, String> TO_MESSAGE =
            p -> "[알림] @" + p.getAuthorName() + " 님의 새 글: " + p.getContent()
                    + " (좋아요 " + p.getLikeCount() + ")";
}

main 에서는 변환 그릇과 소비 그릇을 따로 두고, for 로 게시물을 하나씩 돌려요.

Java
public static void main(String[] args) {
    Member minji = new Member("minji", 8500, 150, 5, 365);
    List<Post> posts = new ArrayList<>();
    posts.add(new Post("첫 글이에요", minji, 30));
    posts.add(new Post("오늘의 인기 글", minji, 250));
    posts.add(new Post("소소한 일상", minji, 12));

    Function<Post, String> toMessage = TO_MESSAGE;                 // 변환 그릇
    Consumer<String> printer = line -> System.out.println(line);  // 소비 그릇

    for (Post post : posts) {
        String message = toMessage.apply(post);   // 변환
        printer.accept(message);                  // 소비(출력)
    }
}

실행하면 이렇게 나와요.

텍스트
[알림] @minji 님의 새 글: 첫 글이에요 (좋아요 30)
[알림] @minji 님의 새 글: 오늘의 인기 글 (좋아요 250)
[알림] @minji 님의 새 글: 소소한 일상 (좋아요 12)

toMessage.apply(post) 로 게시물을 문구로 바꾸고, printer.accept(message) 로 그 문구를 출력했죠. 두 동작이 또렷이 분리돼 있어요.

채점 포인트

항목 배점 기준 가중
변환은 Function Function<Post, String> 로 게시물 → 문구 변환을 담았는가
소비는 Consumer Consumer<String> 로 출력을 담았는가
메서드 호출 변환은 apply, 소비는 accept 로 정확히 불렀는가
역할 분리 변환과 출력을 한 람다에 뭉치지 않고 둘로 나눴는가
main 시연 게시물 여러 개를 for 로 돌려 결과를 보였는가

흔한 실수

  • 변환과 출력을 한 람다에 뭉치기Consumer<Post> c = p -> System.out.println("[알림] " + ...) 처럼 한 그릇에 다 넣으면, 나중에 "출력만 바꾸기" 가 어려워져요. 과제의 의도는 둘을 나누는 거예요.
  • applyaccept 를 헷갈리기Function 은 결과를 돌려주니 apply, Consumer 는 받아 쓰기만 하니 accept. 메서드 이름이 그릇의 성격을 말해줘요.
  • Function 인데 출력까지 하기Function 은 "바꿔서 돌려주는" 그릇이에요. 변환 람다 안에서 System.out.println 까지 하면 역할이 섞여요. 변환은 문자열만 만들고, 출력은 Consumer 에게 맡겨요.

과제 3 예시답안 — 어떤 타입이든 거르는 제네릭 filter

핵심 접근

이 과제의 주제는 "동작을 인자로(Predicate)" 와 "타입을 인자로(제네릭 <T>)" 가 만나는 지점이에요. PostFilterPost 전용, 과제 1 의 MemberSelectorMember 전용이었죠. 둘의 본문이 사실 글자까지 똑같았어요 — 타입만 달랐을 뿐이에요. 그러니 타입을 T 로 열면 하나로 합쳐져요.

지난 시간 배운 제네릭 메서드를 떠올려요. 반환 타입 앞에 <T> 를 적으면 "이 메서드는 아무 타입이나 받는다" 는 뜻이 돼요.

예시 구현

Java
// com/instagram/javabasic/modern/solution/day25/Filters.java
public class Filters {

    // 어떤 타입이든 조건에 맞는 것만 골라 새 리스트로 돌려줘요.
    public static <T> List<T> filter(List<T> items, Predicate<T> condition) {
        List<T> result = new ArrayList<>();
        for (T item : items) {
            if (condition.test(item)) {
                result.add(item);
            }
        }
        return result;
    }
}

PostFilter.filter 와 비교해보면 신기해요. Post 가 있던 자리에 T 가 들어갔을 뿐, 안쪽 코드는 완전히 똑같아요. 그래서 이 하나로 회원도 게시물도 거를 수 있어요.

Java
public static void main(String[] args) {
    List<Member> members = new ArrayList<>();
    members.add(new Member("minji", 8500, 150, 5, 365));
    members.add(new Member("newbie", 50, 2, 10, 5));

    // 같은 filter 로 회원을 걸러요
    List<Member> popular = filter(members, m -> m.getFollowers() >= 1000);
    System.out.println("인기 회원 수: " + popular.size());

    // 같은 filter 로 게시물도 걸러요
    Member author = members.get(0);
    List<Post> posts = new ArrayList<>();
    posts.add(new Post("a", author, 30));
    posts.add(new Post("b", author, 250));
    List<Post> hot = filter(posts, p -> p.getLikeCount() >= 100);
    System.out.println("인기 글 수: " + hot.size());

    // 메서드 참조로 이름만 뽑아 출력해요
    Function<Member, String> toName = Member::getUsername;
    for (Member m : popular) {
        System.out.println(toName.apply(m));
    }
}

실행하면 이렇게 나와요.

텍스트
인기 회원 수: 1
인기 글 수: 1
minji

회원 리스트와 게시물 리스트를 같은 filter 메서드로 걸렀어요. 타입을 T 로 열어둔 덕분이죠. 마지막엔 Member::getUsername 메서드 참조로 이름만 뽑아 출력했고요.

채점 포인트

항목 배점 기준 가중
제네릭 메서드 static <T> List<T> filter(...) 형태로 타입을 열었는가
조건은 Predicate 조건을 Predicate<T> 로 받아 test 로 판단하는가
두 타입 시연 회원·게시물 둘 다 같은 filter 로 걸렀는가
메서드 참조 Member::getUsername 같은 메서드 참조를 한 번 썼는가
본문 동일성 PostFilter 와 안쪽 코드가 같다는 걸 이해했는가

흔한 실수

  • <T> 를 빠뜨리기static List<T> filter(...) 처럼 반환 타입 앞에 <T> 를 안 적으면 "T 가 뭐냐" 며 컴파일 에러가 나요. 제네릭 메서드는 반환 타입 앞에 <T> 를 선언하는 게 약속이에요.
  • Object 로 받기List<Object> 로 받으면 거른 뒤 원래 타입으로 다시 못 써요. <T> 로 열면 회원을 넣으면 회원이, 게시물을 넣으면 게시물이 그대로 나와요.
  • 메서드 참조를 억지로 끼우기m -> m.getFollowers() >= 1000 같은 비교 람다는 메서드 참조로 못 바꿔요. "메서드 하나만 그대로 부르는" Member::getUsername 같은 곳에만 쓰면 돼요.

생각해볼 주제 예시답안

생각해볼 주제 1 예시답안 — 함수형 인터페이스는 왜 메서드가 "하나" 여야 할까?

[문제 상황 요약]

람다는 추상 메서드가 딱 하나인 인터페이스(함수형 인터페이스)에만 담을 수 있어요. @FunctionalInterface 는 메서드가 둘이 되면 아예 컴파일 에러로 막고요. 왜 하필 "하나" 일까요?

[튜터의 가이드 및 해설]

핵심은 "모호함이 없어야 한다" 예요. 람다 m -> m.getFollowers() >= 1000 를 적었다고 해볼게요. 이 람다는 "회원을 받아 true/false 를 돌려주는 동작" 이에요. 그런데 만약 인터페이스에 메서드가 isMatchisValid 둘 있다면? 자바는 이 람다가 둘 중 어느 메서드의 몸통인지 알 수 없어요.

람다에는 이름이 없어요. 메서드 이름을 안 적고 화살표만 적죠. 그래서 "이 동작이 정확히 어느 메서드냐" 가 인터페이스에 메서드가 하나뿐 일 때만 깔끔하게 정해져요. 메서드가 하나면 고를 필요 없이 그게 그거니까요.

그래서 함수형 인터페이스의 "하나" 제약은 불편한 규칙이 아니라, 람다가 성립하기 위한 자연스러운 조건이에요. Comparatorcompare, Predicatetest, Runnablerun — 람다로 쓰는 인터페이스는 전부 메서드가 하나예요. (참고로 equals 같은, 모든 객체가 이미 갖춘 메서드는 세지 않아요. "새로 채워야 할" 추상 메서드만 하나면 돼요.)

💡 이렇게 기억하면 편해요: 람다는 이름표 없는 택배예요. 받는 곳에 문이 하나뿐이어야 어디로 넣을지 헷갈리지 않죠. 문이 둘이면 이름표 없는 택배는 갈 곳을 못 정해요.

🎯 면접관을 홀리는 핵심 멘트

"함수형 인터페이스가 메서드 하나로 제한되는 건 람다에 이름이 없기 때문입니다. 람다는 '어느 메서드를 구현하는지' 를 이름이 아니라 인터페이스의 유일한 추상 메서드로 정합니다. 그래서 추상 메서드가 둘이면 대상이 모호해져 컴파일러가 람다를 받아주지 못하고, @FunctionalInterface 는 그 제약을 컴파일 단계에서 강제해 실수를 막아줍니다."


생각해볼 주제 2 예시답안 — 같은 for 반복인데, 왜 filter 로 묶는 게 나을까?

[문제 상황 요약]

findByAuthor·findPopular 처럼 조건마다 메서드를 따로 만드는 방식과, 조건을 인자로 받는 filter 하나로 합치는 방식 — 결과는 똑같아요. 그렇다면 왜 filter 로 합치는 게 나을까요? 그리고 항상 합치는 게 정답일까요?

[튜터의 가이드 및 해설]

합쳤을 때의 이득은 "새 조건이 생겼을 때" 또렷해져요. 메서드를 따로 만드는 방식이라면 "댓글 5개 이상인 글" 이 필요해질 때마다 findManyComments 같은 메서드를 새로 만들어야 해요. 거르는 뼈대(for + 빈 리스트 + add)를 매번 복사하면서요. 같은 뼈대가 메서드 수만큼 복제되는 거죠. filter 로 합쳐두면 람다 한 줄만 넘기면 끝이에요. 뼈대는 한 곳에만 있으니, 거기서 버그를 고치면 모든 거르기가 한 번에 고쳐지고요.

그런데 항상 합치는 게 정답은 아니에요. 코드를 처음 보는 사람 입장에서 findByAuthor(member) 는 이름만 봐도 "작성자로 찾는구나" 가 바로 와닿아요. 반면 filter(posts, p -> member.equals(p.getAuthor())) 는 한 박자 읽어야 의도가 보이죠. 그래서 특정 조건이 도메인에서 아주 중요하고 자주 쓰인다면, 이름 있는 메서드로 남겨두는 게 더 친절할 수 있어요. 실무에서는 흔히 둘을 같이 써요 — 범용 filter 는 만들어두되, 자주 쓰는 핵심 조건은 이름 있는 메서드로도 노출하는 식으로요.

💡 이렇게 기억하면 편해요: filter 는 "도구" 이고, findByAuthor 는 "이름표 붙은 단축키" 예요. 도구가 있으면 무엇이든 만들 수 있고, 자주 쓰는 건 단축키로 빼두면 읽기 좋아요.

🎯 면접관을 홀리는 핵심 멘트

"조건을 인자로 받는 방식의 핵심 이득은 '변하는 것과 변하지 않는 것의 분리' 입니다. 순회·수집이라는 뼈대는 고정하고, 바뀌는 조건만 람다로 주입해 중복을 없애죠. 다만 가독성 측면에선 이름 있는 메서드가 의도를 더 빨리 드러내므로, 저는 범용 filter 를 토대로 두되 도메인에서 중요한 조건은 이름 있는 메서드로 함께 노출하는 절충을 택합니다."


생각해볼 주제 3 예시답안 — 람다와 익명 클래스, 언제 무엇을 쓸까?

[문제 상황 요약]

오늘 우리는 익명 클래스를 람다로 줄였어요. 그러면 익명 클래스는 이제 람다의 옛날 버전일 뿐, 쓸 일이 없는 걸까요?

[튜터의 가이드 및 해설]

대부분의 경우엔 람다가 더 짧고 읽기 좋아요. 메서드 하나짜리 인터페이스(Comparator·Predicate 등)를 채울 때는 람다가 거의 항상 낫죠. 그래서 정렬 기준이나 거르기 조건 같은 곳엔 람다를 쓰면 돼요.

하지만 익명 클래스로만 할 수 있는 일이 남아 있어요. 두 가지예요. 첫째, 채워야 할 메서드가 두 개 이상일 때예요. 람다는 메서드 하나만 채울 수 있으니, 메서드가 여럿인 인터페이스는 익명 클래스(또는 이름 있는 클래스)로 만들어야 해요. 둘째, 그 자리에서 상태(필드)를 들고 있어야 할 때예요. 익명 클래스는 안에 필드를 둘 수 있지만 람다는 그럴 수 없어요.

판단 기준은 두 가지로 잡으면 편해요. "채울 메서드가 하나인가?" 와 "읽는 사람이 이해하기 쉬운가?" 예요. 메서드가 하나고 동작이 짧으면 람다, 메서드가 여럿이거나 필드가 필요하면 익명 클래스, 그리고 같은 동작을 여러 곳에서 재사용하거나 이름으로 의도를 드러내고 싶으면 아예 이름 있는 클래스 — 이렇게 상황에 맞춰 고르면 돼요.

💡 이렇게 기억하면 편해요: 람다는 포스트잇, 익명 클래스는 메모지, 이름 있는 클래스는 정식 문서예요. 한 줄이면 포스트잇, 좀 더 담아야 하면 메모지, 두고두고 쓸 거면 문서로 남기는 거죠.

🎯 면접관을 홀리는 핵심 멘트

"람다는 추상 메서드가 하나인 함수형 인터페이스를 간결하게 구현할 때 최선입니다. 다만 구현할 메서드가 둘 이상이거나 인스턴스 상태(필드)가 필요하면 람다로는 표현할 수 없어 익명 클래스가 필요합니다. 저는 '메서드가 하나인가, 상태가 필요한가, 재사용·가독성을 위해 이름이 필요한가' 를 기준으로 람다·익명 클래스·이름 있는 클래스 중에서 고릅니다."

전체 목록 자바 기초

더 배우려면

실무 프로젝트까지 가고 싶다면

팀스파르타 백엔드 부트캠프에서 인스타그램 클론을 풀스택으로 완성합니다.