일단 익숙한 자바로
함수형 언어와 람다식 배워보자
1
람다함수
약간, 익명 클래스같은 느낌인데
이름없고
1. 람다의 기본 구조
(매개변수 목록) -> { 실행할 코드 }
2. 람다 표현 방식 (생략 규칙 마스터하기)
Java 컴파일러는 똑똑해서, 굳이 다 안 써줘도 문맥을 보고 알아서 추측해 줍니다. 그래서 코드를 점점 더 짧게 줄일 수 있습니다.
레벨 1: 완전한 형태 (The Full Form)
가장 정석적인 형태입니다. 매개변수 타입과 중괄호 {}를 모두 씁니다.
// 두 개의 int를 받아서 더한 결과를 int로 반환하는 람다
(int a, int b) -> { return a + b; }
레벨 2: 매개변수 타입 생략
컴파일러가 이 람다식이 사용될 곳(예: (int, int)를 받는 인터페이스)을 보고 타입을 이미 알고 있다면, 타입을 생략할 수 있습니다.
// 어차피 int인 거 아니까 그냥 이름만 쓸게!
(a, b) -> { return a + b; }
레벨 3: 중괄호 {}와 return 생략
실행할 코드가 단 한 줄이고, 그 한 줄이 결과를 반환하는 문장이라면, {}와 return 키워드를 동시에 생략할 수 있습니다. 컴파일러가 "아, 이 한 줄의 결과값을 그냥 리턴하라는 뜻이구나" 라고 알아듣습니다.
Generated java
// 실행문이 return a + b; 한 줄 뿐이네? 그럼 다 빼버리자!
(a, b) -> a + b
이 형태가 가장 많이 쓰이는 간결한 형태 중 하나입니다.
레벨 4: 매개변수가 하나일 때 소괄호 () 생략
매개변수가 오직 하나일 경우, 매개변수를 감싸는 소괄호 () 마저 생략할 수 있습니다. (매개변수가 없거나 두 개 이상일 때는 생략 불가)
// 문자열 하나를 받아서 길이를 반환하는 람다
// 레벨 1: (String s) -> { return s.length(); }
// 레벨 2: (s) -> { return s.length(); }
// 레벨 3: (s) -> s.length()
// 레벨 4: s -> s.length() // 괄호까지 생략!
레벨 5: 매개변수가 없을 때
매개변수가 없을 때는 소괄호 ()를 비워두어야 합니다. 생략할 수 없습니다.
// 매개변수 없이 그냥 "Hello" 문자열을 반환하는 람다
() -> "Hello"
2
메서드 참조 (Method Reference)
메서드 참조가 가능한 조건
핵심 조건은 이것입니다. "람다식이 하는 일이 기존 메서드 하나를 호출하는 것 외에 아무것도 없을 때"
다시 말해, 입력(매개변수)을 받아서, 그 입력을 그대로 다른 메서드에 전달만 할 때 메서드 참조로 바꿀 수 있습니다.
| 람다식 | 입력(Input) | 출력(Output) | 메서드 참조로 변환 |
| n -> Math.abs(n) | n | Math.abs(n) | 가능 (Math::abs) |
| s -> System.out.println(s) | s | System.out.println(s) | 가능 (System.out::println) |
| () -> new ArrayList<>() | 없음 | new ArrayList<>() | 가능 (ArrayList::new) |
| (s1, s2) -> s1.concat(s2) | s1, s2 | s1.concat(s2) | 가능 (String::concat) |
| n -> n * 2 | n | n * 2 | 불가능! (* 2는 기존 메서드가 아님) |
| s -> s.toUpperCase() + "!" | s | s.toUpperCase() + "!" | 불가능! (+ "!" 라는 추가 작업이 있음) |
3
근데 왜 Math.abs()안하지 , 아 그러면 매개변수를 넣어야해서 ? 직접 ?아닌데, 변수로 하면 되는데
비유: "요리하기" vs "요리법 알려주기"
상황: 제가 친구에게 "김치찌개"를 대접하고 싶습니다.
1. Math.abs(-10): 지금 당장 요리해서 대접하기
- 코드의 의미: Math.abs(-10)을 쓰는 순간, 컴퓨터는 즉시 abs 메서드를 실행하고 그 결과인 10을 그 자리에 돌려줍니다.
- 비유: 제가 친구를 만나자마자 그 자리에서 바로 김치찌개를 끓여서 주는 행위입니다. 친구는 이미 만들어진 '김치찌개(결과물 10)'를 받습니다.
Generated java
int result = Math.abs(-10); // 이 코드가 실행되는 순간, result에는 '10'이 저장됨.
// '요리법'은 사라지고 '완성된 요리'만 남음.
2. Math::abs: 나중에 요리할 수 있는 "요리법"을 건네주기
- 코드의 의미: Math::abs를 쓰는 순간, 컴퓨터는 abs 메서드를 실행하지 않습니다. 대신 **"나중에 Math.abs를 실행할 수 있는 능력" 또는 "기능 그 자체"**를 하나의 객체(값)처럼 만듭니다.
- 비유: 제가 친구에게 김치찌개 '레시피(요리법)'가 적힌 쪽지를 건네주는 행위입니다. 친구는 당장 김치찌개를 먹는 게 아니라, 나중에 원할 때 그 레시피를 보고 직접 끓여 먹을 수 있는 '능력'을 받은 것입니다.
Generated java
// Function<Integer, Integer>는 "정수를 받아 정수를 반환하는 요리법"이라는 뜻의 '레시피북' 타입.
// 여기에 Math::abs 라는 '김치찌개 레시피'를 저장함. (아직 요리 안 함!)
Function<Integer, Integer> recipe = Math::abs;
// 나중에, 친구가 정말 배고파졌을 때 레시피를 사용함.
// "이 레시피(recipe)를 써서 -10 이라는 재료로 요리해줘(.apply(-10))"
int result = recipe.apply(-10); // 이 시점에 비로소 Math.abs(-10)이 실행됨.
왜 "요리법"을 전달해야 할까요?
Java의 스트림(Stream)이나 이벤트 리스너 같은 기능들은 **"나중에 실행할 코드"**를 파라미터로 요구하기 때문입니다.
map이라는 기계(메서드)를 예로 들어보죠.
Generated java
List<Integer> numbers = List.of(-1, -2, 3);
// map 기계는 "완성된 요리"가 아니라 "요리법"을 달라고 요구함.
// 왜? 각 숫자(-1, -2, 3)가 컨베이어 벨트를 지나갈 때마다
// 그 "요리법"을 재료에 적용해야 하니까.
List<Integer> positiveNumbers = numbers.stream()
.map(Math::abs) // 여기에 "절대값 주스 레시피"를 전달
.collect(Collectors.toList());
만약 여기에 map(Math.abs(-1)) 처럼 완성된 요리를 넣으려고 하면 어떻게 될까요?
Math.abs(-1)은 즉시 1로 계산되어 버립니다. map(1)이 되는 거죠.
map 기계는 "모든 숫자를 1로 바꿔라" 라는 이상한 명령으로 알아듣고 [1, 1, 1] 이라는 엉뚱한 결과를 내놓게 됩니다.
우리가 원한 것은 -1이 지나갈 땐 Math.abs(-1)을, -2가 지나갈 땐 Math.abs(-2)를, 3이 지나갈 땐 Math.abs(3)을 **그때그때 실행하는 능력(요리법)**이었죠.
4
Java의 스트림(Stream)이나 이벤트 리스너 같은 기능들은 **"나중에 실행할 코드"**를 파라미터로 요구하기 때문입니다.<왜지
1. 제어의 역전 (Inversion of Control, IoC)
2. 지연 실행 (Lazy Evaluation)
1. 제어의 역전 (IoC): "네가 하지 말고, 나에게 맡겨"
이 개념을 **"인테리어 전문가에게 집 꾸미기 맡기기"**에 비유해 보겠습니다.
전통적인 방식 (내가 모든 것을 제어):
내가 직접 인테리어의 모든 과정을 하나하나 지시하고 실행합니다.
- 나: "페인트 가게에 가서 흰색 페인트를 사 와야지." (직접 실행)
- 나: "이제 벽지를 뜯어내고..." (직접 실행)
- 나: "가구를 옮기고, 바닥에 신문지를 깔고..." (직접 실행)
- 나: "이제 페인트를 칠하자." (직접 실행)
이것이 for 루프를 사용하는 전통적인 방식입니다. 언제, 무엇을, 어떻게 할지 모든 제어권(Control)을 내가(개발자) 가집니다.
Generated java
for (Apple apple : appleBox) { // 내가 직접 상자를 열고
if (apple.isRed()) { // 내가 직접 색깔을 확인하고
redApples.add(apple); // 내가 직접 바구니에 담는다.
}
}
스트림 방식 (제어의 역전):
나는 **'인테리어 전문가(Stream)'**를 고용합니다. 전문가는 전체적인 공사 과정(반복, 요소 꺼내기 등)을 다 알고 있습니다. 나는 그 전문가에게 **"내가 원하는 핵심적인 일"**만 알려주면 됩니다.
- 나 -> 전문가에게: "전체적인 공사는 알아서 해주세요. 대신, 페인트 칠하는 작업은 꼭 **'이 페인트 장인'**을 써주세요."
- 나 -> 전문가에게: "그리고 조명 설치는 꼭 **'저 조명 기술자'**를 써주시고요."
여기서 **'페인트 장인'**과 **'조명 기술자'**가 바로 "나중에 실행될 코드(람다, 메서드 참조)"입니다.
- 언제 페인트를 칠할지 (벽지 제거 후? 가구 옮긴 후?)
- 어떻게 칠할지 (롤러로? 붓으로?)
이런 세부적인 제어는 모두 **'인테리어 전문가(Stream)'**에게 넘어갔습니다. 나는 "페인트 칠하기"라는 핵심 기능만 제공했을 뿐입니다. 이것이 바로 제어의 역전입니다. 개발자가 반복문의 제어권을 갖는 게 아니라, 프레임워크(Stream API)에 제어권을 넘기고 우리는 그 안에서 실행될 로직만 주입하는 것입니다.
Generated java
appleBox.stream() // "사과 상자 처리 전문가(Stream)님,"
.filter(apple -> apple.isRed()) // "사과를 고르는 일은 '이 전문가(람다)'에게 맡기세요."
.collect(Collectors.toList());
왜 이렇게 할까요?
전문가(Stream)는 최적의 공사 순서를 알고 있습니다. 병렬 처리(parallelStream) 같은 고급 기술을 써서 일을 훨씬 더 효율적으로 처리할 수도 있습니다. 우리가 직접 하는 것보다 훨씬 낫죠.
2. 지연 실행 (Lazy Evaluation): "최대한 미루다가, 꼭 필요할 때 한 방에!"
이 개념은 **"마트에서 장보기"**에 비유할 수 있습니다.
전통적인 방식 (즉시 실행, Eager Evaluation):
필요한 물건 목록을 보고, 하나씩 카트에 담는 즉시 계산대로 달려가서 계산합니다.
- 두부를 카트에 담는다. -> 바로 계산.
- 우유를 카트에 담는다. -> 또 계산.
- 계란을 카트에 담는다. -> 또 계산.
매우 비효율적입니다.
스트림 방식 (지연 실행, Lazy Evaluation):
스트림은 이렇게 동작합니다.
- 장바구니에 담기 (filter, map 등 중간 연산):
- filter(사과만): "일단 사과만 담을 거야" 라고 계획만 세웁니다. (아직 안 담음)
- map(껍질깎기): "그리고 껍질을 깎을 거야" 라고 계획만 세웁니다. (아직 안 깎음)
- 계산하기 (collect, forEach 등 최종 연산):
- collect(봉투에 담기): "이제 최종적으로 봉투에 담아줘!" 라는 최종 명령이 내려오는 순간, 스트림은 그동안 쌓아둔 모든 계획을 한 번에 최적화해서 실행합니다.
- 이제서야 비로소 사과 상자를 열고, 빨간 사과 하나를 꺼내서, 바로 껍질을 깎아 봉투에 넣습니다. 다음 빨간 사과를 꺼내서, 껍질을 깎아 봉투에 넣습니다. 이 과정을 반복합니다. 불필요하게 중간 결과를 저장하는 과정(빨간 사과만 모아둔 임시 바구니)이 없습니다.
왜 이렇게 할까요?
최적화 때문입니다. 모든 계획을 다 들은 뒤에 일을 시작하면, 훨씬 더 효율적인 작업 순서를 찾을 수 있습니다. 예를 들어, 10억 개의 데이터 중에서 5개만 뽑는 작업을 할 때, 10억 개를 모두 가공할 필요 없이 5개를 찾는 순간 작업을 중단할 수 있습니다. 이런 최적화는 "나중에 실행할 코드"를 미리 받아두었기 때문에 가능합니다.
결론
Java의 스트림이나 이벤트 리스너가 **"나중에 실행할 코드"**를 요구하는 이유는 다음과 같습니다.
- 제어의 역전(IoC): 반복과 같은 귀찮고 복잡한 제어는 강력한 프레임워크(Stream)에게 맡기고, 개발자는 **"핵심 비즈니스 로직"**에만 집중하기 위해.
- 지연 실행(Lazy Evaluation): 최종 결과가 필요해지기 전까지 실행을 최대한 미루어, 전체 작업 계획을 보고 최적화된 방식으로 한 번에 처리하기 위해.
5
람다식을 쓰려면 **"함수형 인터페이스"**가 필요함
🔹 함수형 인터페이스란?
메서드가 딱 1개만 있는 인터페이스
interface MyFunction {
int apply(int x);
}
람다식은 이 인터페이스의 메서드를 한 줄로 구현
System.out.println(f.apply(5)); // 출력: 25
6
"람다식은 메서드 여러 개가 될 수 없나?"
네, 맞습니다. 람다식 하나는 오직 하나의 추상 메서드만 대체할 수 있습니다.
7
람다식까지는 이해가되
파이썬에도 있엇으니깐
근데 갑자기 함수형 언어?는 왜 언급된거지
AAA 사이즈 건전지를 AA 소켓에 넣을 수 없듯이, 아무 람다식이나 아무 곳에나 쓸 수는 없습니다. 람다식은 반드시 자신의 모양과 딱 맞는 '소켓(함수형 인터페이스)'에만 들어갈 수 있습니다.
Java는 정적 타입(Static Type) 언어입니다. 이게 무슨 뜻이냐면, 모든 변수는 반드시 **명확한 '타입(형태)'**을 가져야 한다는 뜻입니다. int, String, Person 처럼요.
그런데 람다식 (x, y) -> x + y 는 그 자체로는 타입이 애매합니다. 그냥 '기능 덩어리'일 뿐이죠. Java 세상에서 이 '기능 덩어리'를 변수에 담거나 메서드에 전달하려면, **"이 기능 덩어리의 타입이 무엇인지"**를 명확하게 정의해줘야 합니다.
>그 '타입' 역할을 해주는 것이 바로 '함수형 인터페이스'입니다.
// 타입이 뭘까? ? what_type = (x, y) -> x + y; // 그냥 이렇게는 못 씀
// "IntBinaryOperator" 라는 '타입(소켓)'을 지정해줘야 함.
// IntBinaryOperator는 "int 두 개 받아서 int 하나를 리턴하는 기능의 타입" 이라는 규격임.
IntBinaryOperator calculator = (x, y) -> x + y;
//IntBinaryOperator 인터페이스의 내부
@FunctionalInterface
// "이건 함수형 인터페이스야!" 라고 컴파일러에게 알려주는 표시
public interface IntBinaryOperator {
int applyAsInt(int left, int right); // 메서드가 딱 하나!
}
8
함수형 언어 예제
List<String> names = List.of("철수", "영희", "민수");
names.stream()
.filter(name -> name.startsWith("영"))
.map(name -> name + "짱")
.forEach(System.out::println);
// 출력: 영희짱
//해석이 안됨
9
근데 .stream() .forEach 뭐지?
여기서 startswith()는 파이썬에도 있는 함수같은데
근데 함수가 .(마치 메소드처럼 연결되네 뭐지)>>> 메서드 체이닝(Method Chaining) 또는 플루언트 인터페이스(Fluent Interface)
어떻게 함수(메서드)가 .으로 계속 연결될 수 있을까?"
답: 앞의 메서드가 "다음 메서드를 호출할 수 있는 객체"를 반환(return)하기 때문입니다.
10
스트림에 대해
10-1.
.stream()이 왜 필요한가? 그냥 리스트로는 안 되나?"
결론: 그냥 리스트(Collection) 상태로는 "함수형 조립 라인" 방식의 작업을 할 수 없기 때문입니다.
<아무튼 안된다고 하니깐..이 아니라 왜 안되는데 ㅋㅋ
10-2.
애초에 스트림이 뭐지?
보안시간에, 스트림형태가 아니라, 무슨 일정한 형태로 묶는다..그런거 배운거 같은데( 블록 암호 (Block Cipher))
답: 끊임없이 이어지는 데이터의 흐름<이게 뭔소린데 그래서 ㅋㅋ
10-3.
함수형 언어에서 스트림을 자주 사용하나?ㅇㅇ
10-4.
스트림을 사용하는 이유
전통 방식 (List, for 루프): "미리 다 사서 카트에 담아두기"
>근데 결국, 스트림도 사과봉지 3개짜리의 저장소는 필요한거 아닌가?
- (전통적인 방법:작업 후, 찬장에는 원래 라면 20개가 있고, 내 앞에는 신라면 5개가 담긴 바구니가 놓여있음)
10-5.
애초에 전통적인 방법은, 그 바구니라는게 왜 필요한건데
임무: 찬장의 모든 라면 중, "신라면"이면서 "유통기한이 지난 것"은 버리고, "유통기한이 남은 신라면"은 "봉지를 뜯어서" "선반 위에 올려두세요."
처리 단계가 3가지입니다.
- 필터링 1: "신라면"인가?
- 필터링 2: "유통기한이 지났는가?"
- 가공(Map): "봉지를 뜯는다."
전통 방식 (for 루프)으로 이 모든 걸 한 번에 하려고 할 때
> 이 코드는 if문이 계속 중첩되면서 깊어지고, 코드를 읽기가 점점 어려워집니다. 이걸 가독성이 떨어진다고 합니다. 만약 처리 단계가 4개, 5개가 되면 if문 지옥이 펼쳐지겠죠.
> 복잡한 작업을 한 번에 처리하는 for 루프를 만드는 대신, 각 단계를 단순한 for 루프로 나누기 위해서입니다.
각 for 루프는 하나의 책임만 가지므로 코드를 이해하고 테스트하기가 훨씬 쉬워집니다.
10-6.
이걸 스트림은,
List<PeeledRamen> shelf = cupboard.stream() // 1. 찬장에서 재료를 흘려보내
.filter(ramen -> ramen.isShinRamen()) // 2. 신라면만 통과시켜
.filter(ramen -> ramen.isNotExpired()) // 3. 유통기한 남은 것만 통과시켜
.map(ramen -> ramen.peel()) // 4. 봉지를 뜯어서 변신시켜
.collect(Collectors.toList()); // 5. 최종 결과물을 모아 선반에 둬
아직 빨강부분은 이해가 안됨
실제 ㅋ코드부분이나, 메모리 측면을 살펴보면 좀 이해되려나
좀더 예제 코드를 봐야겟는데
'개발 > 유니티' 카테고리의 다른 글
| [유니티] LINQ (8) | 2025.07.18 |
|---|---|
| [유니티] 델리게이트와 이벤트 (12) | 2025.07.18 |
| [유니티]c#문법5 :스트림 이해 및 예제 (15) | 2025.07.15 |
| [유니티] c#문법3: 궁금햇던 것들에 대하여 (8) | 2025.07.15 |
| [유니티] c#문법1: 자바와의 차이점을 중심으로 (13) | 2025.07.15 |