본문 바로가기

지앤선 이야기

[한글화 프로젝트]5.자바에서 람다 식이 필요한 이유 - 1



 

 

번역가오현석

 

월애플베이직으로 코딩을 시작했으며, 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);


다음글(한글 번역은 다음주 등록 예정)서는 람다 식을 사용해 자바 코드가 더 간결하고 읽기 쉬워지는 경우를 몇 가지 보일 것이다.