Java

람다와 함수형 프로그래밍의 원리와 활용

변위니 2025. 1. 7. 03:31

이펙티브 자바를 읽고 람다에 대해 다시 한 번 정리하고자 글을 적게 되었다.

 

 

 

 

람다를 들어가기 앞서 함수형 프로그래밍에 대해서 먼저 알아보자.

함수형 프로그래밍은 순수함수를 기반으로 상태 변경과 부작용을 최소하하여 코드를 작성하는 프로그래밍 패러다임이다.

이 패러다임은 데이터를 변형하는 대신 함수 조합을 통해 작업을 수행하며, 함수가 일급 객체로 취급된다. 즉, 함수를 다른 함수에 인자로 전달하거나 반환값으로 사용할 수 있고, 이를 통해 더 직관적이고 유지보수가 쉬운 코드를 작성할 수 있다.

이와 같은 함수형 프로그래밍의 접근 방식을 자바와 같은 객체지향 언어에서 구현하기 위해 등장한 것이 바로 람다 표현식이다.

 

 

 

함수형 프로그래밍 특징

1. 순수함수

동일한 입력이 주어지면 항상 동일한 출력을 반환하는 함수이다. 함수의 외부 상태나 변수에 의존하지 않고, 함수 내부에서 외부 상태를 변경하지 않는다.

Function<Integer, Integer> square = x -> x * x;​

 

2. 불변성

상태와 데이터를 변경하지 않고, 새로운 상태를 반환한다.
객체나 변수를 수정하는 대신, 새로운 객체나 변수를 생성한다.

 

3. 고차함수

함수 자체가 다른 함수와 함께 작동하는 방식으로, 함수를 값으로 취급할 수 있도록 해준다.

함수가 다른 함수를 매개변수로 받거나, 함수를 반환하는 함수이다.

 
Function<Integer, Function<Integer, Integer>> adder = x -> y -> x + y;

 

4. 일급 객체

일급 객체란, 변수에 할당하거나 다른 함수에 인자로 전달하거나, 함수의 반환값으로 사용할 수 있는 객체이다.

즉, 일급 객체는 값으로 취급되어 변수나 데이터 구조 안에 저장되고, 다른 함수로 전달되며, 함수에서 반환될 수 있다.

1. 변수나 데이터 구조 안에 저장할 수 있다.
2. 함수의 인자로 전달할 수 있다.
3. 함수의 반환값으로 사용할 수 있다.

이를 바탕으로, 함수가 일급 객체로 취급되면, 함수를 변수처럼 저장하고, 다른 함수에 전달하며, 반환할 수 있는 프로그래밍이 가능해진다.

 

5. 지연계산

필요할 때까지 계산을 미루는 방식으로 효율적이다.

 

6. 선언형 프로그래밍

무엇을 하는지에 초점을 두고, 어떻게 수행할지 추상화한다.







람다란?

메서드를 하나의 식으로 표현한 것으로, 메서드의 이름과 반환 값이 없어서 익명 함수라고도 한다.

자바 8에서 도입된 기능으로 함수형 프로그래밍 스타일을 지원하고, 코드의 간결성과 가독성을 높인다.

람다가 이렇게 간결한 코드로 표현되는 이유는, 자바 컴파일러가 람다 표현식을 인스턴스화된 함수형 인터페이스로 처리하기 때문이다.

람다 표현식이 실제로 어떻게 동작하는지 이해하기 위해, 그 내부 동작을 알아보자.




람다의 내부 동작

자바 컴파일러는 람다 표현식을 인스턴스화된 함수형 인터페이스로 처리한다. 컴파일 과정에서 invokedynamic 바이트 코드 명령을 생성하여 런타임에 적절한 메서드를 찾아 연결한다.

이 과정에서 LambdaMetafactory라는 유틸리티가 사용된다.
이 유틸리티는 람다 표현식을 실행 가능한 코드로 변환하는 데 피리요한 메타데이터를 처리하고, 실제 람다 표현식을 실행 가능한 코드로 변환하는 데 필요한 메타데이ㅣ터를 처리하고, 실제 람다 표현식을 인스턴스화된 함수형 인터페이스로 변환하는 역할을 한다.

이렇게 변환된 람다는 런타임에 적절한 메서드와 연결되어 실행된다.

1. 람다 표현식 작성

Runnable runnable = () -> System.out.println("Hello, Lambda!");​

2. 컴파일러 처리

  • 컴파일러는 이 코드를 분석해 Runnable 인터페이스의 run() 과 연결한다.
  • 이후, invokedynamic 명령을 생성해 런타임에서 run() 메서드와 람다 표현식을 동적으로 연결한다.

3. 런타임 처리

  • invokedynamic 명령은 JVM 런타임에서 실행되며, 람다의 실제 구현체를 생성한다.
  • LambdaMetafactory가 이 과정을 담당하며, 필요한 경우 클래스 파일 없이도 효율적으로 함수형 인터페이스의 인스턴스를 생성한다.

4. 최종 실행

생성된 인스턴스는 인터페이스의 메서드를 호출해 람다의 내용을 실행한다.

 




자바 8이전에는 함수형 프로그래밍을 구현하기 위해 익명 클래스를 사용했다.
함수형 인터페이스 구현을 위해 사용되었고, 람다는 이를 더욱 간결하고 효율적으로 대체하기 위해 도입되었다.

람다와 익명 클래스는 비슷한 목적을 가지고 있지만 구현 방식과 성능에서 차이가 있다.

그 차이를 알아보자.



 

 


람다와 익명 클래스

익명 클래스는 이름 없는 클래스로 클래스를 정의하면서 객체를 즉시 생성할 수 있다.
이를 통해 상속없이 기능을 확장할 수 있고 메서드를 오버라이드할 때 유용하다.

주로 추상 클래스나 인터페이스를 구현하거나, 이미 정의된 클래스에서 일부 기능을 구현하거나 오버라이드해야 할 때 사용된다.

Runnable r = new Runnable() {
    @Override
    public void run() {
        System.out.println("Hello, World!");
    }
};

 

익명 클래스는 클래스를 동적으로 생성하고, 별도의 클래스 파일이 생성되어 추가적인 메모리와 성능 오버헤드가 발생한다.

반면 람다 표현식은 별도의 클래스를 생성하지 않고, 내부적으로 바이트 코드를 생성하여 메모리 사용이 적고, 경량화된 구현을 제공한다.

Runnable r = () -> System.out.println("Hello, World!");



 

 


람다와 클로저

람다는 기본적으로 내부 정의나 파라미터만 사용한다. 람다식이 외부 변수를 참조하면, 이 외부 변수를 캡처하고, 이로써 클로저 처럼 동작할 수 있다.

Closure

람다식 내에서 자유변수를 사용하는 것을 클로저라 한다. 내부 컨텍스트에서 접근한 외부 컨텍스트의 값은 외부 함수가 종료되어도 그 값이 유지된다.

int a = 5;
(i) -> i + a;

이렇게 클로저에서 사용된 변수를 자유 변수라고 한다.

자유변수

자유 변수는 코드 블록 내부에서 정의되거나, 매개변수가 아닌데 사용되는 변수를 말한다. 자유 변수는 변경할 수 없어야 한다. 따라서 final 키워드가 필요하다. 변수의 값을 변경하려고 하면 아래의 에러가 발생한다.

local variables referenced from a lambda expression must be final or effectively final

만약 외부 local 변수가 final이 아니라면, final 처럼(effectively final) 동작해야 한다.

Variable capture

람다식에서 사용한 데이터를 자유 변수에 저장하는 것을 Variable capture이라 한다. 그리고 람다식에서 자유 변수를 참조하는 행위를 Lambda Capturing이라 한다. 캡처하는 방식으로 변수를 사용하는 경우, 변수의 값은 힙에 저장되며, 메서드 종료 후에도 계속 참조할 수 있다.




 


함수형 인터페이스

  • 하나의 추상 메서드를 갖는 인터페이스
  • 인터페이스에 선언된 메서드는 abstract를 명시하지 않아도 자동으로 추상 메서드로 간주된다.
  • 자바 8부터는 default 또는 static 메서드를 통해 구현을 포함할 수 있다.
  • 함수형 인터페이스라는 의미로 @FunctionalInterface 를 명시하자.

주요 함수형 인터페이스
람다 표현식과 함수형 인터페이스

 

 

 

Object 클래스에서 제공되는 메서드(equals, hashCode, toString 등)는 이미 구현된 메서드로, 추상 메서드로 간주되지 않는다.

예시) Comparator 인터페이스

@FunctionalInterface
public interface Comparator<T> {

    int compare(T o1, T o2);
    
    boolean equals(Object obj);
     
    // 생략
}

 

Comparator 인터페이스의 추상 메서드는 compare, equals 총 2개 인데, 왜 함수형 인터페이스가 가능할까?

equals메서드는 이미 Object 클래스에서 구현된 메서드이기 때문.
자바의 모든 클래스는 암묵적으로 Object를 상속 받는다. 따라서, Comparator 인터페이스에서 equals 가 선언되어 있어도 이미 구현된 메서드이기 때문에 다시 구현하지 않아도 된다.

 

 

왜 Comparator에 equals가 있는지?

인터페이스에서 재정의 가능한 메서드임을 보여주기 위해서.
but, 구현하지 않으면 기본적으로 Object의 구현을 사용.



default method

인터페이스에서 구현을 포함할 수 있는 메서드이다. 인터페이스를 구현하는 클래스에서 이 메서드를 재정의하지 않으면, 기본 구현이 그대로 사용된다. 인터페이스를 구현한 클래스들이 공통적으로 사용할 수 있는 기본 동작을 정의한다.

static method

인스턴스화 없이 호출가능한 메서드로 주로 유틸리티 메서드를 제공하는 용돋로 사용된다. 특정 로직을 재사용할 수 있도록 하며, 객체의 상태와 무관하게 동작한다.

주의

여러 인터페이스에서 동일한 디폴트 메서드를 상속받는 경우 충돌이 발생할 수 있기 때문에, 이런 경우 재정의가 필요하다. 정적 메서드의 경우 클래스나 인터페이스 이름으로 호출되기 때문에 다형성과 무관하며, 재정의할 수 없다.

 



'Java' 카테고리의 다른 글

상속과 다형성  (0) 2025.01.07
Thread 구현 동기화와 Lock  (0) 2025.01.07
람다 표현식과 함수형 인터페이스  (0) 2025.01.07
@Transactional과 Proxy  (0) 2025.01.07
Transaction  (0) 2025.01.07