개발/유니티

[유니티] c#문법4: 람다함수와 함수형 언어(java)

kimchangmin02 2025. 7. 15. 20:58

일단 익숙한 자바로

함수형 언어와 람다식 배워보자

 

 

 

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'이 저장됨.
                           // '요리법'은 사라지고 '완성된 요리'만 남음.
 
Use code with caution.Java

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)이 실행됨.
 
Use code with caution.Java

왜 "요리법"을 전달해야 할까요?

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());
 
Use code with caution.Java

만약 여기에 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): "네가 하지 말고, 나에게 맡겨"

이 개념을 **"인테리어 전문가에게 집 꾸미기 맡기기"**에 비유해 보겠습니다.

전통적인 방식 (내가 모든 것을 제어):

내가 직접 인테리어의 모든 과정을 하나하나 지시하고 실행합니다.

  1. 나: "페인트 가게에 가서 흰색 페인트를 사 와야지." (직접 실행)
  2. 나: "이제 벽지를 뜯어내고..." (직접 실행)
  3. 나: "가구를 옮기고, 바닥에 신문지를 깔고..." (직접 실행)
  4. 나: "이제 페인트를 칠하자." (직접 실행)

이것이 for 루프를 사용하는 전통적인 방식입니다. 언제, 무엇을, 어떻게 할지 모든 제어권(Control)을 내가(개발자) 가집니다.

Generated java

for (Apple apple : appleBox) { // 내가 직접 상자를 열고
    if (apple.isRed()) {       // 내가 직접 색깔을 확인하고
        redApples.add(apple);  // 내가 직접 바구니에 담는다.
    }
}
 
Use code with caution.Java

스트림 방식 (제어의 역전):

나는 **'인테리어 전문가(Stream)'**를 고용합니다. 전문가는 전체적인 공사 과정(반복, 요소 꺼내기 등)을 다 알고 있습니다. 나는 그 전문가에게 **"내가 원하는 핵심적인 일"**만 알려주면 됩니다.

  1. 나 -> 전문가에게: "전체적인 공사는 알아서 해주세요. 대신, 페인트 칠하는 작업은 꼭 **'이 페인트 장인'**을 써주세요."
  2. 나 -> 전문가에게: "그리고 조명 설치는 꼭 **'저 조명 기술자'**를 써주시고요."

여기서 **'페인트 장인'**과 **'조명 기술자'**가 바로 "나중에 실행될 코드(람다, 메서드 참조)"입니다.

  • 언제 페인트를 칠할지 (벽지 제거 후? 가구 옮긴 후?)
  • 어떻게 칠할지 (롤러로? 붓으로?)

이런 세부적인 제어는 모두 **'인테리어 전문가(Stream)'**에게 넘어갔습니다. 나는 "페인트 칠하기"라는 핵심 기능만 제공했을 뿐입니다. 이것이 바로 제어의 역전입니다. 개발자가 반복문의 제어권을 갖는 게 아니라, 프레임워크(Stream API)에 제어권을 넘기고 우리는 그 안에서 실행될 로직만 주입하는 것입니다.

Generated java

appleBox.stream() // "사과 상자 처리 전문가(Stream)님,"
        .filter(apple -> apple.isRed()) // "사과를 고르는 일은 '이 전문가(람다)'에게 맡기세요."
        .collect(Collectors.toList());
 
Use code with caution.Java

왜 이렇게 할까요?
전문가(Stream)는 최적의 공사 순서를 알고 있습니다. 병렬 처리(parallelStream) 같은 고급 기술을 써서 일을 훨씬 더 효율적으로 처리할 수도 있습니다. 우리가 직접 하는 것보다 훨씬 낫죠.


2. 지연 실행 (Lazy Evaluation): "최대한 미루다가, 꼭 필요할 때 한 방에!"

이 개념은 **"마트에서 장보기"**에 비유할 수 있습니다.

전통적인 방식 (즉시 실행, Eager Evaluation):

필요한 물건 목록을 보고, 하나씩 카트에 담는 즉시 계산대로 달려가서 계산합니다.

  1. 두부를 카트에 담는다. -> 바로 계산.
  2. 우유를 카트에 담는다. -> 또 계산.
  3. 계란을 카트에 담는다. -> 또 계산.

매우 비효율적입니다.

스트림 방식 (지연 실행, Lazy Evaluation):

스트림은 이렇게 동작합니다.

  1. 장바구니에 담기 (filter, map 등 중간 연산):
    • filter(사과만): "일단 사과만 담을 거야" 라고 계획만 세웁니다. (아직 안 담음)
    • map(껍질깎기): "그리고 껍질을 깎을 거야" 라고 계획만 세웁니다. (아직 안 깎음)
    이 단계에서는 실제 어떤 일도 일어나지 않습니다. 그저 "무엇을 할지"에 대한 **실행 계획(레시피)**만 차곡차곡 쌓아둡니다.
  2. 계산하기 (collect, forEach 등 최종 연산):
    • collect(봉투에 담기): "이제 최종적으로 봉투에 담아줘!" 라는 최종 명령이 내려오는 순간, 스트림은 그동안 쌓아둔 모든 계획을 한 번에 최적화해서 실행합니다.
    "아, 결국엔 '빨간 사과'의 '껍질을 깎아서' '봉투에 담는' 거구나!"
  3. 이제서야 비로소 사과 상자를 열고, 빨간 사과 하나를 꺼내서, 바로 껍질을 깎아 봉투에 넣습니다. 다음 빨간 사과를 꺼내서, 껍질을 깎아 봉투에 넣습니다. 이 과정을 반복합니다. 불필요하게 중간 결과를 저장하는 과정(빨간 사과만 모아둔 임시 바구니)이 없습니다.

왜 이렇게 할까요?
최적화 때문입니다. 모든 계획을 다 들은 뒤에 일을 시작하면, 훨씬 더 효율적인 작업 순서를 찾을 수 있습니다. 예를 들어, 10억 개의 데이터 중에서 5개만 뽑는 작업을 할 때, 10억 개를 모두 가공할 필요 없이 5개를 찾는 순간 작업을 중단할 수 있습니다. 이런 최적화는 "나중에 실행할 코드"를 미리 받아두었기 때문에 가능합니다.

결론

Java의 스트림이나 이벤트 리스너가 **"나중에 실행할 코드"**를 요구하는 이유는 다음과 같습니다.

  1. 제어의 역전(IoC): 반복과 같은 귀찮고 복잡한 제어는 강력한 프레임워크(Stream)에게 맡기고, 개발자는 **"핵심 비즈니스 로직"**에만 집중하기 위해.
  2. 지연 실행(Lazy Evaluation): 최종 결과가 필요해지기 전까지 실행을 최대한 미루어, 전체 작업 계획을 보고 최적화된 방식으로 한 번에 처리하기 위해.

 

 

 

 

 

5

람다식을 쓰려면 **"함수형 인터페이스"**가 필요함

🔹 함수형 인터페이스란?

메서드가 딱 1개만 있는 인터페이스

 
@FunctionalInterface
interface MyFunction {
    int apply(int x);
}

람다식은 이 인터페이스의 메서드를 한 줄로 구현

 
MyFunction f = x -> x * 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. 필터링 1: "신라면"인가?
    2. 필터링 2: "유통기한이 지났는가?"
    3. 가공(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. 최종 결과물을 모아 선반에 둬

 

 

 

 

 

아직 빨강부분은 이해가 안됨

실제 ㅋ코드부분이나, 메모리 측면을 살펴보면 좀 이해되려나

 

 

좀더 예제 코드를 봐야겟는데