본문 바로가기

지앤선 이야기

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



 

 

번역가 : 오현석

 

월애플베이직으로 코딩을 시작했으며, 10여년간 한국에서 개발자/팀장으로 재직 후, 현재는 호주 Griffith University에서 웹 개발자로 일하고 있다. 웹이나 모바일 등의 분야에서 값 중심의 프로그래밍을 통해 좀 더 오류 발생 가능성이 적고 유지보수가 편한 프로그램을 작성하는 방법과 이를 지원하는 여러 도구를 만드는 일에 관심이 많다.

 

원문링크




 

 


 

자바에서 람다 식이 필요한 이유  2



 

Why We Need Lambda Expressions in Java  Part 2

 


번째 (원문링크)에서는 가능한 평이한 예제를 통해 람다를 도입하면 자바가 더 간결하고 읽기 쉽고 강력한 언어, 한마디로 더 현대적인 언어가 될 수 있다는 점을 설명했다. 이번에는 더 실용적인 예를 두가지 보여줌으로써 자바에 함수언어적 특성을 가미하는 것이 시급함을 자바 개발자, 그 중에서도 특히 경험이 많은 사람들에게 납득시키고자 한다.


 

지연 연산(laziness)을 통한 효율성

 

컬렉션의 내부 이터레이션을 사용하거나 더 일반적으로 함수 프로그래밍을 사용할 시 얻을 수 있는 잇점중 하나는 지연 연산을 사용할 수 있다는 점이다. 이를 첫번째 글에서 이미 썼던 아래의 정수 리스트를 가지고 보여줄 것이다.

 

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6);

 

이번에는 조금 더 복잡하게, 리스트에 있는 짝수 원소 중에서 두 배 했을 때 5보다 더 커지는 첫 번째 것을 출력하고 싶다고 하자.

 

for (int number : numbers) {

    if (number % 2 == 0) {

        int n2 = number * 2;

        if (n2 > 5) {

            System.out.println(n2);

            break;

        }

    }

}

 

위 프로그램이 좋지 않은 이유는 명확하다. 내포 깊이가 깊은 단일 루프 안에서 너무 많은 작업을 수행하고 있어서 코드를 읽고 유지보수하기가 까다롭다. 이를 해결하기 위해 루프 내에서 수행하는 연산을 한줄짜리 짧은 메소드로 나누도록 하자. 각 메소드에는 잘 정의된 책임을 하나씩 부여할 수 있다.

 

public boolean isEven(int number) {

    return number % 2 == 0;

}

 

public int doubleIt(int number) {

    return number * 2;

}

 

public boolean isGreaterThan5(int number) {

    return number > 5;

}

 

그리고 이 세 메소드를 사용하도록 앞의 루프를 다음과 같이 변경할 수 있다.


List<Integer> l1 = new ArrayList<Integer>();

for (int n : numbers) {

    if (isEven(n)) l1.add(n);

}

 

List<Integer> l2 = new ArrayList<Integer>();

for (int n : l1) {

    l2.add(doubleIt(n));

}

 

List<Integer> l3 = new ArrayList<Integer>();

for (int n : l2) {

    if (isGreaterThan5(n)) l3.add(n);

}

 

System.out.println(l3.get(0));

 

실제로 내가 만든 것은 단계별로 위의 3가지 메소드 중 하나를 각 원소에 적용하는 리스트 변환 파이프라인이다. 하지만, 정말 이 코드가 앞의 코드보다 더 나은가? 아마도 아닐 것이다. 두 가지 다른 이유가 있다. 가장 명확한 첫 번째 이유는 너무 장황하다는 점이다. 두 번째 이유는 결과를 얻기 위해 불필요한 연산을 수행하기 때문에 비 효율적이라는 점이다. 이 두 번째 문제점을 더 명확히 보기 위해 isEven,  doubleIt,  isGreaterThan5   메소드에 프린트문을 추가하면 다음과 같은 결과를 볼 수 있다.

 

isEven: 1

isEven: 2

isEven: 3

isEven: 4

isEven: 5

isEven: 6

doubleIt: 2

doubleIt: 4

doubleIt: 6

isGreaterThan5: 4

isGreaterThan5: 8

isGreaterThan5: 12

8

 

앞의 루프 방식을 사용하면 4번째 원소가 조건을 충족하기 때문에 첫 네 원소에 대해서만 루프를 돌지만, 파이프라인 방식에서는 여섯 원소에 대해 연산을 함을 알 수 있다.

 

이런 문제를 해결하는 데 아주 유용한 것이 스트림(Stream)이다. 컬렉션의 steram()메소드를 호출해서 스트림을 만들 수 있다. 그렇지만 스트림과 컬렉션은 다른 점이 몇 가지 있다.

  •    저장 공간이 없다 : 스트림은 값을 저장하지 않는다스트림은 연산 파이프라인을 거치는 데이터 구조를    통해 값을 나르기만 한다.

  •    함수적 특성을 가진다 : 스트림에 연산을 적용해 결과를 얻어와도 하부에 있는 데이터 소스에는 변화가    없다스트림의 데이터 소스로 컬렉션을 사용할 수 있다.

  •    지연 연산을 추구한다 : 필터링맵핑정렬중복제거 등의 대부분의 스트림 연산은 지연연산으로 구현      될 수 있다지연연산을 사용하면 원하는 답을 얻기 위해 필요한 만큼만 스트림에서 원소를 얻어 검사할    수 있다.

  •    한계를 원하는데로 변화시킬 수 있다 : 문제를 풀 때 무한 스트림으로 표현하는 것이 더 합리적인 경우      가 많이 있다클라이언트쪽에서 필요한 만큼만 값을 가져가면 된다컬렉션은 이런식으로 동작할 수 없    지만 스트림은 가능하다.


스트림을 사용하면 앞에서 본 문제를 다음과 같이 물 흐르듯 기술할 수 있다.

 

System.out.println(

    numbers.stream()

            .filter(Lazy::isEven)

            .map(Lazy::doubleIt)

            .filter(Lazy::isGreaterThan5)

            .findFirst()

);

 

위 프로그램의 출력은 다음과 같다.

 

isEven: 1

isEven: 2

doubleIt: 2

isGreaterThan5: 4

isEven: 3

isEven: 4

doubleIt: 4

isGreaterThan5: 8

IntOptional[8]

 

여기서 알아두어야 할 것이 두 가지 있다. 우선 지연연산을 사용함에 따라 CPU를 허비하지 않고 리스트의 첫 네 수만을 검사할 수 있었다는 점을 들 수 있다. 실제로 맨 뒤의 filter()가 호출된 다음  findFirst()가 호출되기 전까지 스트림은 어떤 연산도 수행하지 않는다. 왜냐하면 결과를 요청하지 않았기 때문이다. 두 번째로 findFirst() 메소드가 반환하는 값은 8이 아니라 IntOptional[8]라는 점이다. 옵션(Optional)은 존재하지 않을 수도 있는 값을 둘러싸기 위한 래퍼이다. 함수 프로그래밍에서는 옵션을 자주 사용한다(스칼라의 Option이나 하스켈의 Maybe가 자바 Optional과 동일하다). 전통적인 객체 지향 자바에서 null 참조를 사용했을법한 경우에 옵션을 사용해 어떤 값이 없는 경우와 있는 경우를 함께 다룰 수 있다. 내가 쓴 ' 이상 null 허용하지 말자'라는 글에서 옵션이 어떻게 동작하는지와 현재의 자바Optional 구현의 한계를 설명하고 대안을 제시했다. 바로 위의 예에서는 결과값이 나올것이 확실하기 때문에 getAsInt()를 호출해 옵션을 벗겨내고 결과값 8을 얻어도 안전하다.


 

빌려쓰기(loan) 패턴

 

자바에 함수형 프로그래밍을 적용해서 더 잘 캡슐화할 수 있고 반복을 피할수 있다는 사실을 마지막 예를 통해 보일 것이다. 어떤 자원 Resource가 있다고 하자.

 

public class Resource {

 

    public Resource() {

        System.out.println("Opening resource");

    }

 

    public void operate() {

        System.out.println("Operating on resource");

    }

 

    public void dispose() {

        System.out.println("Disposing resource");

    }

}

 

우리는 자원을 생성해 작업을 수행한 다음에 사용을 끝낸 자원을 누수(메모리, 파일 식별자 등등)를 방지하기 위해 해제할 수 있다.

 

Rsource resource = new Resource();

resource.operate();

resource.dispose();

 

위 코드에서 잘못된 것이 무엇일까? 물론 자원에 대해 작업을 수행하다가 런타임 예외가 발생할 수 있다. 따라서 dispose() 메소드가 확실히 호출되게 만들려면 finally 블럭에 이를 넣어야 한다.

 

Resource resource = new Resource();

try {

    resource.operate();

} finally {

    resource.dispose();

}

 

문제는 Resource를 사용할 때마다 try/finally 블럭을 매번 넣어야 한다(이는 반복금지(DRY)원칙에 위배된다). 또한 일부 try/finally 블럭을 넣는 것을 잊어버리는 경우 누수가 발생할 수도 있다. 이 문제를 해결하기 위해서 내가 제안하는 방법은 Resource 클래스의 정적 메소드에 try/finally 블럭을 캡슐화하고 Resource의 생성자를 전용(private)으로 만들어서 자원을 사용하는 쪽에서는 정적 메소드를 통해서만 사용할 수 있게 만드는 것이다.

 

public static void withResource(Consumer<Resource> consumer) {

    Resource resource = new Resource();

    try {

        consumer.accept(resource);

    } finally {

        resource.dispose();

    }

}

 

이 메소드에 전달되는 인자는 Consumer 펑셔널 인터페이스의 인스턴스이다. 이 인터페이스는 자원을 소비하려는 쪽, 즉 자원에 대해 작업을 수행해야 하는 쪽에서 구현해야 한다. 이 메소드에 람다 식을 넘기면 자원에 대한 작업을 수행할 수 있다.

 

withResource(resource -> resource.operate());

 

이렇게 하면 코드의 반복 없이도 자원을 항상 제대로 해제할 수 있다. 또한 위 코드를 보면 이 패턴의 이름이 왜 빌려쓰기(loan)인지 잘 알 수 있다. '빌리는 쪽'(자원을 액세스하는 람다 식)에서 자원을 다 사용하고 나면, '빌려주는 쪽'(자원을 보유한 코드)에서 자원을 관리하게 되기 때문이다.