Ayden's journal

자바에서의 함수형 프로그래밍

자바스크립트로 프로그래밍을 처음 배웠을 때, 함수는 단순한 실행 단위가 아니라 변수에 담고, 다른 함수에 인자로 넘기고, 반환할 수도 있는 1급 객체였다. 이러한 개념은 자연스럽게 받아들여졌고, 다양한 고차 함수를 조합해 로직을 구성하는 방식에 익숙해졌다.

반면 자바는 객체지향 언어로서 함수보다는 클래스와 메서드를 중심으로 코드를 구성한다. 하지만 다행히도 자바 8부터는 함수형 프로그래밍 요소들이 도입되면서 표현력이 한층 풍부해졌다. 특히 람다 표현식, 함수형 인터페이스, 메서드 참조는 코드를 더욱 간결하고 유연하게 만들어준다. 여기에 더해 동작 파라미터화나 실행 어라운드 패턴 같은 구조적인 접근법은 실전 코드에서도 높은 재사용성과 응집력을 확보할 수 있게 해준다.

 

함수형 인터페이스

함수형 인터페이스(Functional Interface)는 단 하나의 추상 메서드만을 갖는 인터페이스를 말한다. 이 인터페이스는 람다 표현식 또는 메서드 참조로 구현될 수 있다. 자바 8에서 도입된 @FunctionalInterface 어노테이션을 사용하면, 컴파일 타임에 함수형 인터페이스 조건을 강제할 수 있다.

@FunctionalInterface
interface MyFunction {
void run();
}

 

자바에서는 이미 다양한 기본 함수형 인터페이스를 제공한다. 대표적으로는 다음과 같다:

  • Runnable: () -> void — 아무 매개변수도 없이 실행
  • Function<T, R>: (T) -> R — 입력값 T를 받아서 R을 반환
  • Predicate<T>: (T) -> boolean — 조건 검사를 위한 인터페이스
  • Consumer<T>: (T) -> void — 입력값을 소비하고 아무것도 반환하지 않음
  • Supplier<T>: () -> T — 값을 공급함

이처럼 함수형 인터페이스는 람다 표현식이 "어디에 넣을 수 있는지"를 결정하는 중요한 기준이다.

 

람다 표현식

람다 표현식(Lambda Expression)은 익명 함수(anonymous function)를 작성하는 방법이다. 클래스나 메서드를 따로 정의하지 않고도, 필요한 동작을 바로 코드 블록으로 전달할 수 있게 해준다. 기본 문법은 다음과 같다:

(매개변수) -> { 실행문 }

가령, Runnable 인터페이스를 구현한다고 했을 때, 익명 클래스를 사용하는 방식은 다음과 같다:

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

하지만 람다를 사용하면 다음과 같이 간단하게 표현할 수 있다:

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

람다 표현식은 함수형 인터페이스의 함수 디스크립터(Function Descriptor)와 일치하는 시그니처를 가져야 한다. 그래서 어떤 인터페이스에 람다를 넣을 수 있는지는 그 인터페이스의 추상 메서드 시그니처를 보면 알 수 있다.

 

함수 디스크립터는 함수형 인터페이스가 표현하는 "함수의 형태", 즉 매개변수 타입과 반환 타입으로 구성된 시그니처(signature)를 의미한다. 이것은 람다 표현식이 해당 인터페이스에 사용할 수 있는지를 판단하는 기준이 된다.

@FunctionalInterface
public interface Converter<F, T> {
T convert(F from);
}

예를 들어 위와 같은 함수형 인터페이스가 있을 때, 이 인터페이스의 함수 디스크립터는 (F) -> T이다. 따라서 아래와 같이 (String) -> Integer 형태의 람다를 넣을 수 있다.

Converter<String, Integer> converter = s -> Integer.parseInt(s);

 

이처럼 함수 디스크립터는 람다 표현식의 계약(contract) 역할을 하며, 어떤 인자와 어떤 반환값을 갖는지를 명확히 해야 한다.

 

메서드 참조

메서드 참조(Method Reference)는 이미 존재하는 메서드를 람다처럼 전달하는 문법이다. 람다 표현식이 단순히 어떤 메서드 하나만 호출하는 경우, 이를 메서드 참조로 대체할 수 있다. 메서드 참조는 가독성을 높이고, 람다 표현식을 더 직관적으로 표현할 수 있도록 도와준다. 문법은 다음과 같다:

[클래스이름 or 객체참조]::메서드이름

 

정적 메서드 참조

Consumer<String> c = System.out::println;
// 람다로 쓰면: s -> System.out.println(s)

 

특정 객체의 인스턴스 메서드 참조

String prefix = "Hello, ";
Function<String, String> f = prefix::concat;
// 람다로 쓰면: s -> prefix.concat(s)

 

클래스의 인스턴스 메서드 참조

BiPredicate<String, String> p = String::equalsIgnoreCase;
// 람다로 쓰면: (a, b) -> a.equalsIgnoreCase(b)

 

생성자 참조

Supplier<List<String>> s = ArrayList::new;
// 람다로 쓰면: () -> new ArrayList<String>()

 

동작을 추상화하는 함수형 접근법

현대 소프트웨어 개발에서는 코드의 재사용성과 유연성을 높이기 위해 반복되는 로직을 일반화하고, 변하는 부분을 함수(또는 동작)로 분리하는 방법이 중요하다. 이때 동작을 추상화하여 함수나 객체로 전달하는 방식은 불필요한 중복을 줄이고, 다양한 상황에 맞게 코드를 쉽게 확장할 수 있는 기반이 된다.

자바 8부터 도입된 람다 표현식과 함수형 인터페이스는 이런 함수형 접근법을 자바 환경에서도 자연스럽게 구현할 수 있도록 돕는다. 특히, 동작 파라미터화(Behavior Parameterization)와 실행 어라운드 패턴(Execute Around Pattern)은 각각 다른 목적과 상황에서 이 ‘동작 추상화’ 개념을 효과적으로 활용하는 대표적인 사례다.

 

동작 파라미터화

동작 파라미터화(Behavior Parameterization)는 동작(behavior)을 인자로 전달하여, 코드의 로직 일부를 유연하게 변경하는 방식이다. 즉, 특정한 조건, 필터링, 정렬 등의 동작을 함수형 인터페이스를 통해 외부에서 주입하는 방식이다. 예를 들어 사과를 필터링하는 메서드를 생각해보자.

public interface ApplePredicate {
boolean test(Apple apple);
}
public List<Apple> filterApples(List<Apple> inventory, ApplePredicate p) {
List<Apple> result = new ArrayList<>();
for (Apple apple : inventory) {
if (p.test(apple)) {
result.add(apple);
}
}
return result;
}

 

이제 람다로 다양한 조건을 전달할 수 있다:

List<Apple> greenApples = filterApples(inventory, a -> "green".equals(a.getColor()));
List<Apple> heavyApples = filterApples(inventory, a -> a.getWeight() > 150);

 

이처럼 "무엇을 할 것인가"라는 동작을 메서드에 넘길 수 있게 되면, 코드 재사용성과 유연성이 매우 높아진다.

 

실행 어라운드 패턴

실행 어라운드 패턴(Execute Around Pattern)은 어떤 작업을 수행하기 위해 반복되는 준비 및 마무리 작업은 고정하고, 실제 수행할 동작만 외부에서 주입받는 구조를 말한다. 즉, 실행 어라운드 패턴을 사용하면 외부 리소스 관리 코드와 핵심 로직을 깔끔하게 분리할 수 있다. 특히 리소스를 열고 닫는 작업이 반복되는 I/O 처리, DB 연결, 트랜잭션 처리 등에서 유용하게 쓰인다.

 

예를 들어, 파일에서 한 줄을 읽는 작업을 보자:

public String processFile() throws IOException {
try (BufferedReader br = new BufferedReader(new FileReader("data.txt"))) {
return br.readLine();
}
}

 

여기서 파일을 열고 닫는 로직은 항상 동일하고, 실제로 읽는 로직만 다르다. 이 구조를 일반화해보자.

@FunctionalInterface
public interface BufferedReaderProcessor {
String process(BufferedReader br) throws IOException;
}
public String processFile(BufferedReaderProcessor processor) throws IOException {
try (BufferedReader br = new BufferedReader(new FileReader("data.txt"))) {
return processor.process(br);
}
}

 

이제 원하는 동작을 람다로 전달하여 실행할 수 있다:

String oneLine = processFile(br -> br.readLine());
String twoLines = processFile(br -> br.readLine() + br.readLine());

 

블로그의 프로필 사진

블로그의 정보

Ayden's journal

Beard Weard Ayden

활동하기