|
|
번역가 : 오현석
월애플베이직으로 코딩을 시작했으며, 10여년간 한국에서 개발자/팀장으로 재직 후, 현재는 호주 Griffith University에서 웹 개발자로 일하고 있다. 웹이나 모바일 등의 분야에서 값 중심의 프로그래밍을 통해 좀 더 오류 발생 가능성이 적고 유지보수가 편한 프로그램을 작성하는 방법과 이를 지원하는 여러 도구를 만드는 일에 관심이 많다.
|
자바에서 람다 식이 필요한 이유 – 1
Why We Need Lambda Expressions in Java – Part 1
람다식은 자바 8에 포함될 것이다. 하지만, 아직도 저항에 직면하고 있으며, 모든 자바 개발자가 유용성을 인정하고 있지도 않다. 특히 이들은 자바가 지닌 강력한 객체지향과 절차적 언어로서의 특성을 깨는 것이 아닌가 하는 두려움 때문에, 자바에 함수 언어의 특징을 추가하는 것이 실수가 될 것이라고 주장하곤 한다. 이 글의 목적은 바라건데 이런 의심을 없애고, 평이하면서 실용적인 예제를 통해 현대적 프로그래밍 언어라면 람다 식을 지원하지 않을 수 없음을 보여주는 데 있다.
외부 이터레이션과 내부 이터레이션
우선 아주 단순한 정수 리스트로부터 시작해 보자.
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6);
이제 이 리스트의 모든 원소를 이터레이션하면서 출력하는 for 루프를 만들 수 있다.
for (int number : numbers) {
System.out.println(number);
}
위와 같은 문장은 아주 단순하고 흔한 것이다. 자바 개발자로 10년간 일하면서 하루도 이런 류의 코드를 작성하지 않은 날이 없다. 또 이 문장은 너무너무 단순한데.... 아니다. 그렇지 않다. 위와 같은 구문을 보면 내 2살바기 딸 소피아에게 놀고 나서 장난감을 치우게 하는 일이 떠오른다. 보통 다음과 같은 일이 벌어진다.
나: "소피아, 장난감 정리하자. 땅에 어떤 장난감이 있지?"
소피아: "어. 공이 있네"
나: "그래. 공을 박스에 넣자. 땅에 또 뭐가 있지?"
소피아: "어. 인형이 있지"
나: "그래. 인형을 박스에 넣자. 땅에 또 뭐가 있지?"
소피아: "어. 책이 있네"
나: "그래. 책을 박스에 넣자. 땅에 또 뭐가 있지?"
소피아: "아니. 아무것도 없어"
나: "잘했네. 이제 다 했다"
바로 이런 일을 우리가 매일 자바 컬렉션을 가지고 하고 있다. 하지만, 우리 대부분은 2살바기 꼬마가 아니다. 우리는 컬렉션을 외부에서 이터레이션하면서, 원소를 명시적으로 하나씩 꺼내서 처리하고 있다. 만약 소피아에게 단지 "소피아. 땅에 있는 모든 장난감을 박스에 넣으렴"이라 말할 수 있다면 훨씬 좋을 것이다. 이런 내부 이터레이션이 더 좋은 이유가 두가지 있다. 첫째로 소피아는 한손에 공, 다른손에 인형을 한꺼번에 나르기로 결정할 수도 있다. 두번째로, 박스에 가장 가까운 물건들을 먼저 나르고, 그 다음에 나머지를 옮기기로 할 수도 있다. 마찬가지로 내부 이터레이션을 사용하면 JIT 컴파일러가 원소 처리를 최적화 해서 병렬로 하거나 다른 순서로 하게 할 수도 있다. 자바나 다른 절차적 언어에서 하듯 외부에서 컬렉션을 이터레이션 하면 이런 방식의 최적화는 불가능하다.
그렇다면, 왜 내부 이터레이션을 사용하지 않을까? 내 생각은 이렇다. 자바 8 이전 버전에서는 이런 패턴에 대한 지원이 부족했다. 따라서 내부 이터레이션을 사용하려면 번잡하게(이름 없는 내부 클래스를 만들어야 한다) 기술해야 했다. 이로 인해 단순히 습관이 나쁘게 든 것뿐이다. 다음이 그 한 예이다.
numbers.forEach(new Consumer<Integer>() {
public void accept(Integer value) {
System.out.println(value);
}
});
실제로는 forEach 메소드와 Consumer 인터페이스도 자바 8에 추가된 것이다. 하지만, 자바 5+에서도guava나 lambdaj와 같은 라이브러리를 사용해 이와 비슷한 일을 할 수 있다. 하지만 자바 8의 람다 식을 사용하면 다음과 같이 같은 작업을 더 간략하고 읽기 쉬운 방법으로 해낼 수 있다.
numbers.forEach((Integer value) -> System.out.println(value));
위 람다 식은 두 부분으로 이루어져 있다. 화살표(->)의 왼쪽은 매개변수 목록이고, 오른쪽은 몸체이다. 이 경우 자바 컴파일러가 람다식을 분석해 Consumer 인터페이스의 미 구현된 메소드(즉, accept 메소드)와 동일한 타입(시그니쳐)인지 검사하고, 람다식을 마치 Consumer 인터페이스를 구현한 클래스의 인스턴스인 것처럼 사용한다. 물론 만들어진 바이트코드는 다를 수 있다. 람다 식의 인수의 타입 선언은 대부분의 경우 컴파일러가 추론할 수 있고, 다음과 같이 생략 가능하다.
numbers.forEach(value -> System.out.println(value)); // 식(1)
하지만, 이 문장도 자바 8에 포함될 메소드 참조(method reference)를 사용하면 더 간단하게 할 수 있다. 자세히 말하자면, 자바 8에 새로 도입될 :: 연산자를 사용하면 다음과 같이 정적 메소드나 인스턴스 메소드를 참조할 수 있다.
numbers.forEach(System.out::println); // 식(2)
이렇게 하면, 함수 언어에서 에타(eta, 그리스어 η) 확장이라 알려진 과정에 의해, 컴파일러가 해당 메소드를 자동으로 "확장"해서 Consumer 함수형 인터페이스에 있는 유일한 추상 메소드와 같은 시그니처를 만들어주고, 그 결과 Consumer 인스턴스로 사용될 수 있게 된다.
(역주: 에타 확장(또는 더 일반적으로 에타 변환)이란 어떤 함수 f가 있을 때, (자바 람다식으로 표현하자면) (x) -> f(x)나 f가 같다는 것입니다. 매개변수를 붙이면 에타 확장, 매개변수를 때어내면 에타 축소(reduction)이라 합니다. 다만, f안에서 x가 자유변수(free variable)여야 합니다.
에타 확장이 실제 위의 forEach에서 어떤 일을 하냐 하면, 바로 그 앞의 식과 같은 일을 합니다. 즉, 위 식 (2)의
numbers.forEach(System.out::println);
에서 System.out::println를 에타 확장하면, (x) -> System.out::println(x)입니다. 따라서, 이는
numbers.forEach((x) -> System.out::println(x)) // 식(3)
이 됩니다. x를 value로 바꾸면 위 식(3)은 식(1)과 완전히 동일한 식입니다.)
값만 전달하는 대신 작동 방식도 전달하기
앞의 예에서 본 것이 바로 람다 식이 유용한 가장 큰 이유이며, 어쩌면 유일한 이유이기도 하다. 람다식을 다른 함수에 전달할 수 있다면 값 뿐 아니라 작동 방식까지도 전달할 수 있다. 그렇게 되면 추상화의 수준을 극적으로 끌어 올릴 수 있으며, 더욱 일반적이며 유연하고 재사용 가능한 API를 만들 수 있다. 더 심화된 예를 살펴보면서 이런 주장을 입증할 것이다. 우선 일반적인 정수의 리스트로부터 시작해 보자.
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6);
리스트 안의 모든 정수의 합계를 내주는 메소드를 작성해 달라는 요청을 받으면, 다음과 같이 할 수 있다.
public int sumAll(List<Integer> numbers) {
int total = 0;
for (int number : numbers) {
total += number;
}
return total;
}
다음날, 관리자가 우리 섹션으로 와서 사업부에서 리스트 안에 있는 짝수들만의 합도 요청했다고 말한다. 이런 요구를 가장 빠르게 처리할 방법은 무엇일까? 간단하다. 앞의 메소드를 복사해 붙여넣기 하고, 적당한 조건문으로 원소를 걸러내도록 하면 된다.
public int sumAllEven(List<Integer> numbers) {
int total = 0;
for (int number : numbers) {
if (number % 2 == 0) {
total += number;
}
}
return total;
}
다른 어느 날, 다른 요구 사항이 또 도착한다. 이번에는 리스트의 원소를 더하되, 3보다 큰 수만을 더해야 한다. 이제는 어떻게 해야 할까? 물론, 다시 앞의 메소드를 복사해서 조건문의 식을 바꿀 수도 있다. 하지만, 그렇게 하는 것은 너무 지저분한 것 같다. 그렇지 않은가? 이제, "우선 작성하고, 그 다음에는 복사하고, 세번째로는 리팩토링하자First Write, Second Copy, Third Refactor" 원칙에 따라 더 똑똑하게 일반적인 방식으로 처리할 수 없는지 고민해볼 때이다. 이 경우, 리스트와 함께 원소 합계를 구하기 전에 원소를 걸러내는 조건을 지정하는 Predicate(자바 8에서 추가된 또 다른 함수형 인터페이스)를 받는 고차(high order) 함수를 만들면 된다.
public int sumAll(List<Integer> numbers, Predicate<Integer> p) {
int total = 0;
for (int number : numbers) {
if (p.test(number)) {
total += number;
}
}
return total;
}
다시 말하면, 데이터(정수의 리스트) 뿐 아니라, 그 데이터를 어떻게 사용할지에 대한 작동 방식(조건을 판단하기 위한 객체 Predicate)도 함께 메소드에 전달한다는 것이다. 이렇게 하면 앞의 세가지 요구 사항을 더 일반적인 재사용 가능한 메소드를 하나만 사용해서 충족시킬 수 있다.
sumAll(numbers, n -> true);
sumAll(numbers, n -> n % 2 == 0);
sumAll(numbers, n -> n > 3);
다음글(한글 번역은 다음주 등록 예정)에서는 람다 식을 사용해 자바 코드가 더 간결하고 읽기 쉬워지는 경우를 몇 가지 보일 것이다.
'지앤선 이야기' 카테고리의 다른 글
소통하는 출판사 - 독자의 피드백 (0) | 2014.01.21 |
---|---|
[한글화 프로젝트] 6.자바에서 람다식이 필요한 이유 - 2 (1) | 2014.01.16 |
[멘토에게 묻다] 프로그래머(개발자)란??? (0) | 2013.12.09 |
지앤선을 알려드립니다. (0) | 2013.11.28 |
[한글화 프로젝트] 4. 설계 지구력 가설 (0) | 2013.11.20 |