자바의 람다식

Huisu·2025년 6월 25일

ETC

목록 보기
11/12

람다 표현식

람다 표현식 (lambda expression)이란 함수형 프로그래밍을 구성하기 위한 함수식이며, 간단히 말해 자바의 메소드를 간결한 함수식으로 표현한 것이다.

지금까지 자바에서는 메서드를 하나 표현하려면 클래스를 정의해야 했다. 하지만 람다식으로 표현하면 메서드의 이름과 반환값을 생략할 수 있고, 이를 변수에 넣어 자바 코드가 매우 간결해지는 장점이 있다.

아래 그림에서 보듯이 int add(int a, int b) 메소드 표현식을 메서드 타입, 메서드 이름, 매개변수 타입, 중괗로, return문을 생략하고 화살표 기호를 넣음으로써 코드를 혁명적으로 함축했음을 볼 수 있다. 이러한 특징으로 람다식을 이름이 없는 함수인 익명 함수(anonymous function)이라고도 한다.

int add(int x, int y) {
   return x + y;
}


// 위의 메서드를 람다 표현식을 이용해 아래와 같이 단축
(int x, int y) -> return (x + y);

// 매개변수 타입도 생략
(x, y) -> return (x + y);

// 함수에 리턴문 한 줄만 있을 경우 더욱 생략
(x, y) -> (x + y);

타입을 생략해도 컴파일러가 에러를 띄우지 않는 이유는 컴파일러 나름대로 생략된 타입 위치를 추론하여 동작하게 해 주기 때문이다.

람다식이 처음에는 생소하게 느껴져 오히려 거부감이 들 수 있다. 하지만 문법이 혁신적으로 간결해지는 것만큼, 람다식에 익숙해지면 가독성 면에서 큰 장점을 얻게 된다. 특히 Collection의 요소를 필터링하거나 매핑하여 원하는 결과를 쉽게 얻을 수 있다.

람다식과 자바스크립트 익명 화살표 함수

만약 자바를 배우기 전에 자바스크립트를 먼저 배웠다면 이 표현이 더욱 쉽게 느껴질 수도 있다. 자바스크립트의 익명 화살표 함수 자체가 람다 함수의 일종이기 때문이다. 괄호를 생략하는 등 람다의 기본 문법 체계도 자바스크립트와 비슷하다. 단지 화살표 모양이 => 에서 ->로 다를 뿐이다.

람다식을 자바스크립트 문법과 비교하자면 다음과 같다.

const MyFunction = {
   print: function() {}
}

MyFunction.print = (str) => console.log(str);
let myfunc = MyFunction;
myfunc.print("Hello World");
interface MyFunction {
   void print(String str);
}

public class Main {
   public static void main(String[] args) {
      MyFunction myfunc = (str) -> System.out.println(str);
      myfunc.print("Hello World");
   }
}

변수에 함수를 담을 때, 자바스크립트는 약타입 언어라 타입에 관계없이 자유롭게 받을 수 있지만, 자바는 강타입 언어라 반드시 함수에 대한 타입을 선언해야 한다. 하지만 자바에는 함수 데이터를 담을 만한 적절한 자료형이 없다. 따라서 인터페이스를 익명 구현 객체 타입으로써, 함수를 해당 인터페이스 타입으로 받을 수 있게 설계했다.

람다식과 함수형 인터페이스

람다식의 형태를 보면 마치 자바 메소드를 변수로 선언하는 것처럼 보이지만, 사실 자바는 메소드를 단독으로 선언할 수 없다. 형태만 그렇게 보일 뿐이지, 코드를 보면 람다 함수식을 변수에 대입하고 변수에서 메서드를 호출해 사용하는 것이 마치 객체와 다름이 없다.

사실 람다식도 결국은 객체이다. 정확히 말하면 인터페이스를 익명 클래스로 구현한 익명 구현 객체를 짧게 표현한 것이다.

객체 지향 방식 VS 람다 표현 방식

이에 대해서 기존의 자바에서 표현했던 객체 지향 방식과 람다 표현 방식을 비교하며 이해해 보자.

다음 IAdd라는 인터페이스가 있고 add() 추상 메서드가 있다. 우리는 이 인터페이스를 구현하여 메서드를 정의해 덧셈 기능을 이용할 예정이다.

기존에는 인터페이스를 클래스에 implements하고 오버라이딩하여 사용해 왔다.

interface IAdd {
   int add(int x, int y);
}

class Add implemets IAdd {
   public int add(int x, int y) {
      return x + y;
   }
}

public class Main {
   public static void main(String[] args) {
      // 생 클래스로 메소드 사용하기
      Add a = new Add();

      int result1 = a.add(1, 2);
      System.out.println(result);
   }
}

더 나아가선 한번만 사용하고 버려질 클래스라면 굳이 번거롭게 클래스를 선언하지 않고 익명 클래스로 오버라이딩해 사용하기도 하였다.

interface IAdd {
   int add(int x, int y);
}

public class Main {
   public static void main(String[] args) {
      // 익명 클래스로 정의해 사용하기 (일회용)
      IAdd a = new IAdd() {
         public int add(int x, int y) {
            return x + y;
         }
      };

      int result2 = a.add(1, 2);
      System.out.println(result2);
   }
}

그리고 람다는 이 익명클래스 코드 부분을 짧게 표현한 것이다.

interface IAdd {
   int add(int x, int y);
}

public class Main {
   public static void main(String[] args) {
      // 람다 표현식으로 함축
      IAdd a = lambda = (x, y) -> { return x + y; };

      int result3 = lambda.add(1, 2);
      System.out.println(result3);
   }
}

함수형 인터페이스란?

함수형 인터페이스란 딱 하나의 추상 메서드가 선언된 인터페이스를 말한다. 위의 IAdd 인터페이스 예제 코드가 바로 함수형 인터페이스이다. 그리고 람다식은 함수형 인터페이스 안에 정의된 하나의 추상 메서드 선언을 짧게 표현한 것이다.

생각해 보면 람다식 자체가 하나의 메서드를 한 줄로 정의하는 표현식이기 때문에, 인터페이스에 두 개 이상의 추상 메서드가 들어가 있으면 이를 코드로 겹쳐 표현할 방법이 달리 없기 때문에, 오로지 추상 메소드 한 개만 가진 인터페이스가 람다식의 타겟 타입이 될 수 있는 것이다.

단 Java8 버전부터 이용이 가능한 인터페이스의 final 상수나 default, static, private 메서드는 추상 메서드가 아니기 때문에, 이들 여러 개가 인터페이스에 들어 있어도 오로지 추상 메서드가 한 개면 함수형 인터페이스로 취급된다.

// 함수형 인터페이스가 될 수 있다
interface IAdd {
   int add(int x, int y);
}

// 함수형 인터페이스가 될 수 없다
interface ICalculate {
   int add(int x, int y);
   int min(int x, int y);
}


// 구성요소가 많아도 결국 추상 메서드는 한 개이기 때문에 함수형 인터페이스다.
interface IAdd {
   int add(int x, int y);
   final boolean isNumber = true;
   default void print() {};
   static void print2() {};
}

@FunctionalInterface

나만의 함수적 인터페이스를 만들 때 두 개 이상의 추상 메서드가 선언되지 않도록 컴파일러가 checking해 주는 기능이 있다. 인터페이스 선언 시에 @FunctionalInterface 어노테이션을 붙여 주게 된다면 두 개 이상의 메서드 선언 시 컴파일 오류를 발생시킨다. 이는 개발자의 실수를 줄여 주는 역할을 한다.

@FunctionalInterface
public interface MyFunctional {
   public void method();
   public void otherMethod(); // 컴파일 오류
}

람다식의 타입 추론

람다식으로 코드를 혁신적으로 줄일 수 있다는 건 알았다. 그런데 리턴 타입도 파라미터 타입도 없는 람다식을 컴파일러가 어떤 타입을 쓰는 함수인지 알고 컴파일해 주는 걸까?

사실 컴파일러 스스로 람다 함수식을 보고 추론하여 타입을 유추하기 때문에 가능한 것이다. 무슨 AI처럼 추론할 정도는 아니고 사람이 미리 정의해 놓은 정의문을 보고 추론해 주는 것이다.

대부분의 함수형 인터페이스를 사용하게 되면 제네릭을 쓰게 되는데, 컴파일러가 타입을 추론하는 데 타입 정보 대부분을 제네릭에서 판별하여 얻는다고 보면 된다.

다음 코드로 설명해 보겠다. List 자료형을 만들고 List의 타입 파라미터를 String으로 지정하였다. 그리고 Collections 클래스의 sort 메소드를 불러와 첫 번째 매개변수로는 리스트 객체를 두 번째 매개변수로는 람다 함수를 전달하였다.

public class Main {
   public static void main(String[] args) {
      List<String> words = Arrays.asList("aa", "bbb", "ccc", "ddd");
      Collections.sort(word, (s1, s2) -> Integer.compare(s1.length(), s2.length()));
   }
}

참고로 아래의 Collections 클래스의 sort 메서드 정의문을 보면, 람다함수의 함수형 인터페이스 타입은 java.util.Comparator로 지정되어 있으며, 람다의 매개변수 타입은 Comparator 인터페이스의 제네릭 T 타입으로 지정되어 있다.

이러한 형태에서 컴파일러가 타입을 유추하는 순서는 다음과 같이 된다.

  • sort 메서드의 첫 번째 매개변수로 List 형태의 객체가 들어온다.

  • 첫 번째 매개변수의 타입 지정에 의해 sort 메서드의 제네릭 타입 매개변수는 모두 String으로 지정하게 된다.

  • 따라서 Comparator 인터페이스 제네릭 타입도 String으로 지정되며, 추상 메서드의 매개변수 타입도 String으로 지정된다.

  • 최종적으로 람다 함수의 타입 구성은 int형 메소드 반환 타입과 String형 매개변수 타입 두 개로 추론되게 된다.

단 개발자 입장에서 코드를 복기할 때 람다식의 타입을 이러한 과정으로 유추하는 것은 시간이 걸리기 때문에, 상황에 따라 명시적으로 람다식 파라미터에 타입을 기재하기도 한다. 무엇이 좋은지는 없고 상황에 따라 결정하면 된다.

람다 표현식 활용하기

다양한 람다식의 할당

람다식의 가장 큰 특징은 바로 변수에 정수를 할당하듯이 함수를 할당할 수 있다는 것이다.

사실 함수도 일반 데이터처럼 메모리 주소가 할당되어 있다. 지금까지 클래스로 메서드를 사용해 왔기에 메서드의 메모리 주소를 변수에 할당하는 일은 없었지만, 람다식을 이용하면 함수의 주소를 사용하여 C언어, 파이썬 같은 함수 스타일의 프로그래밍을 작성할 수 있게 된 것이다.

람다식 변수 할당

interface IAdd {
   int add(int x, int y);
}

public class Main {
   public static void main(String[] args) {
      IAdd lambda = (x, y) -> x + y;
      lambda.add(1, 2);
   }
}

람다식 매개변수 할당

특히 람다식은 메서드의 매개변수에 바로 입력값으로 넣는 방식으로 자주 사용된다. 이를 함수를 메서드의 매개변수로 넘겨 준다고 표현한다.

interface IAdd {
   int add(int x, int y);
}

public class Main {
   public static void main(String[] args) {
      int n = result((x, y) -> x + y);
   }

   public static int result(IAdd lambda) {
      return lambda.add(1, 2);
   }
}

기존의 자바 메서드 같은 경우 변수에 할당하거나 매개변수로 넣거나 리턴값으로 사용하는 행위는 꿈도 못 꿨을 일이다. 하지만 람다식이 이런 식으로 응용이 가능한 이유는 람다는 익명 함수이며, 익명 함수는 모두 일급 객체로 취급되기 때문이다.

함수를 매개변수로 전달해서 사용하는 것을 무슨 신박한 기능처럼 소개했지만, 사실 클래스 참조 객체를 넘겨 준 것이랑 마찬가지다. 왜냐하면 람다식은 익명 구현 객체를 심플화한 것이고, 익명 클래스도 결국 클래스 참조 객체니 우리가 메서드에 참조 객체를 넘겨 메서드 내에서 객체의 메서드를 사용한 것과 똑같다.

람다식 반환값 할당

일급 객체의 또 다른 특징이라고 말할 수 있는 것 중 하나는, 메서드의 반환값을 람다 함수 자체를 리턴하도록 지정해 줄 수 있다. 즉, 메서드의 리턴값이 메서드인 것이다.

interface IAdd {
   int add(int x, int y);
}

public class Main {
   public static void main(String[] args) {
      IAdd func = makeFunction();
      int result = func.add(1, 2);
   }

   public static IAdd makeFunction() {
      return (x, y) -> x + y;
   }
}

람다식 실전 예제

Thread 호출

자바의 쓰레드를 먼저 배웠다면 다음과 같은 코드 문법 구성에 대해 익숙할 것이다.

이는 신박한 문법은 아니고 결국 자세히 보면 new Thread()안에 매개변수로 람다식을 넣은 것뿐이다.

Thread thread = new Thread( () -> {
   for(int i = 0; i < 10; i++) {
      System.out.println(i);
   }
});

enum을 깔끔하게

enum 객체의 확장을 조금 더 간결하고 깔끔하게 만들 수도 있다.

enum Operation {
   PLUS("+", (x, y) -> x + y),
   MINUS("-", (x, y) -> x - y),
   TIMES("*", (x, y) -> x * y),
   DIVIDE("/", (x, y) -> x / y);

   private final String symbol;
   private final DoubleBinaryOperator op;

   Operation(String symbol, DoubleBinaryOperator op) {
      this.symbol = symbol;
      this.op = op;
   }

   public double apply(double x, double y) {
      return op.applyAsDouble(x, y);
   }
}

public class Main {
   public static void main(String[] args) {

      // 사용은 아래와 같이
      Operation.PLUS.apply(2, 3);

   }
}

람다식의 형변환

사실 람다식은 익명 객체이고 타입이 없다. 정확하게 말하면 함수형 인터페이스로 람다식을 참조할 수 있을 뿐이다. 그래서 사실 인터페이스 타입의 변수에 람다식을 할당하는 행위는 캐스팅 연산자가 생략되어 있다.

IAdd func = (() -> {});
IAdd func = (IAdd) (() -> {});

이러한 특징을 이용해 람다식을 모든 클래스의 최상위 클래스인 Object 클래스로 형변환이 가능하다. 이러한 특징이 있다는 것 정도만 알면 된다.

interface IAdd {
   int add(int x, int y);
}

public class Main {
   public static void main(String[] args) {
      IAdd lambda = (x, y) -> x + y;
      System.out.println(lambda);

      // Object 타입으로 업캐스팅하기 위해선 두 번 캐스팅해 줘야 한다
      Object lambda_obj = (Object) (IAdd) ((x, y) -> x * y);
      System.out.println(lambda_obj);
   }
}

람다 표현식의 한계

이처럼 람다표현식은 안 그래도 길다란 자바 코드를 말끔히 줄이는 데 상당한 일조를 하지만 단점이나 사용하기에 적절치 못한 경우가 존재한다.

람다는 문서화를 할 수 없다

람다 자체는 이름이 없는 함수이기 때문에 메서드나 클래스와 다르게 문서화를 할 수 없다. 그래서 코드 자체로 동작이 명확하게 설명되지 않거나 람다가 길거나 읽기 어렵다면, 쓰지 않는 방향으로 리팩토링하는 것을 고려해야 한다.

람다는 디버깅이 다소 까다롭다

람다식은 기본적으로 익명 구현 객체 기반이기 때문에, 익명 객체 특성상 디버깅할 때 콜 스택 추적이 매우 어려운 단점을 가지고 있다.

이는 람다가 내부적으로 수행하는 작업이 더 많기 때문에 발생하는 현상이라, 코드가 복잡해질수록 어디에서 문제가 발생했는지 확인이 어려워진다. 그리고 이는 곧 성능과 연결되기도 한다.

stream에서 람다를 사용할 시 for문보다 성능이 떨어진다

어플리케이션 성능에 매우 민감한 사람이라면 치명적인 단점이 될 수도 있다.

람다를 남발하면 코드가 지저분해질 수 있다

기존에는 동작 행위를 미리 클래스의 메서드로 정의해 놓고 실행부에서 갖다 쓰는 것이었지만, 람다는 동작 행위를 실행부에서 지정하는 식이다. 그래서인지 람다식을 남발하다 보면 비슷하게 생긴 함수를 계속 중복해서 생성하고 있는 자신을 발견할 수 있다.

interface OperationStrategy {
   // (int x, int y) -> int
   int calculate(int x, int y);
}

// Template
claass OperationTemplate {
   int calculate(int x, int y, OperationStrategy cal) {
      int result = cal.calculate(x, y);
      return result;
   }
}
public static void main(String[] args) {
   int x = 100;
   int y = 30;

   OperationContext cxt = new OperationContext();
   int result = cxt.calculate(x, y, (x1, y1) -> x1 + y1);
}

비스무리한 람다 함수를 메서드 아규먼트로 지정하고 있다. 위의 에제는 아주 간단한 예시라 와닿지 않을 수 있지만 람다식 로직이 두 줄 세 줄 넘어간다면 실행부의 코드가 지저분해질 수도 있다.

재귀로 만들 경우에는 다소 부적합하다

람다식을 통해 재귀 함수를 구축하면 실행조차 안 되는 컴파일 에러가 일어난다.

public static void main(String[] args) {
   UnaryOperator<Long> factorial = (x) -> {
      x == 0 ? 1 : x * factorial.apply(x - 1);
   };
   factorial(1);
}

4개의 댓글

comment-user-thumbnail
2025년 6월 29일

자바스크립트와 비교하니 확 와닿는 느낌이네요!
enum에서 람다식을 사용할 수 있다는 점을 이번 글 읽으면서 처음 알게 되었어요 ^__^
역시나 오늘도 유익한 글 감사합니다 ^~^

1개의 답글
comment-user-thumbnail
2025년 8월 8일

Java랑 JS가 서로 좋은 기능들을 비슷하게나마 업데이트 해나가는게 재밌네요!@!?!!!
잘 읽었습니다

근데 최신글 업뎃은 언제되는거에요

1개의 답글