Java 8의 람다 함수 살펴보기

java.util.Map의 method API를 살펴보면 map에서 하나의 항목을 가져오기 위한 방법이 여러가지가 있음을 알 수 있다. 대표적으로 get, getOrDefault, compute(computeIfAbsent, computeIfPresent)가 있는데 이 문서에서는 어느 것을 사용하는 것이 가장 좋은가를 살펴보고자 한다.

전화번호부 프로그램 리팩토링

csv 파일에서 전화번호 목록을 가져와 전화번호부를 만드는 자바 코드를 생각해보자.

// phonenumbers.csv

홍길동,010-1234-5678
임꺽정,010-1111-2222
홍길동,010-5656-2323
...

위와 같이 특정 파일에서 전화번호를 불러와 list에 삽입하고, 그 list를 map에 넣는 방식으로 작성할 수 있다.

위의 코드 중

이 부분은 Java SE 8에 추가된 메소드 getOrDefault를 이용하여 다음과 같이 리팩토링 될 수 있다.

기존에 5줄로 작성하던 코드를 3줄로 줄이는 효과를 얻을 수 있다. 그러나 이 코드는 필요하지 않은 경우에도 항상 “new ArrayList()”를 실행한다는 문제점이 존재한다.

이 문제를 해결하기 위해 computeIfAbsent 메소드를 이용할 수 있다.

map.computeIfAbsent(values[0], k -> new ArrayList()).add(values[1]);

이렇게 하면 위에서 언급한 문제를 해결했을 뿐만 아니라 3줄의 코드를 단 1줄에 표현할 수 있게 되었다.

이로써 전화번호부 프로그램은 아무런 문제가 없는 아름다운 코드가 된 것일까?

최종 PhoneBook은 하나의 인터페이스를 작성하여 해당 구현만 변경하면 되도록 작성하였다.

완성된 PhoneBook 코드 보기


성능 비교

각 방식에 따른 성능 차이를 비교해보기 위해 다음과 같이 테스트 코드를 작성해보았다.

각 방식에 따른 성능을 측정하기 위해 테스트 반복 횟수를 100번, 1000번, 10000번 수행하면서 YourKit Java Profiler로 각 메소드의 실행 시간을 측정하였다. (추가: 테스트를 시행할 때는 테스트 하는 부분이 아닌 코드는 주석으로 처리했다.)

Comparison performance of methods (lower is better)

위의 결과에 미루어 보면 반복 횟수가 적을 때는 성능의 차이가 거의 없거나 GetOrDefault 의 성능이 좋다.

그러나 반복 횟수가 커지면 커질 수록 ComputeIfAbsent 의 성능이 가장 좋고 If, GetOrDefault 는 비슷한 성능을 보인다는 것을 알 수 있다.

(추가: 테스트 데이터는 키의 중복이 높은 데이터셋으로 준비하였다. 따라서 GetOrDefault의 성능이 비교적 좋지 않다.)

왜 이와 같은 결과가 나타난 것일까? 그 이유를 찾기위해 각 방식 별로 바이트 코드를 비교하여 분석해보자.

바이트 코드를 통한 비교 분석

바이트 코드 레벨에서 살펴보면 처음 두 개의 get, getOrDefault는 labmda 함수를 사용한 computeIfAbsent보다 bytecode instruction 길이가 길다. 그러나 이 때문에 lambda 함수가 다른 빠르다고 할 수는 없다. 이를 조금 더 정확히 파악하기 위해서는 lambda 함수에 대해 살펴볼 필요가 있다.

lambda 함수에는 숨은 instruction이 있다. lambda 함수를 실행하기 위해서는 일련의 준비 작업이 필요하다. 다음 그림을 보면서 그 작업 순서를 살펴보자.

Sequence of invoke lambda function — 출처: Invokedynamic in 45 Minutes (참고 문서에 표기)

위의 bytecode와 그림에서 보이는 invokedynamic은 동적 언어를 지원하기 위해 Java SE 1.7에서부터 지원하는 (special) instruction이다.

동적으로 작성된(람다) 함수는 invokedynamic -> bootstrap method -> method handles -> target method 의 순서에 따라 static 함수로 정의된다.

이후에는 bootstrap method -> method handles 작업은 필요없이 invokedynamic -> target method 로 바로 호출이 가능하다.

때문에 실행 횟수가 적을 때는 lambda 함수가 다른 방법(get, getOrDefault)과 비슷하거나 더 좋지 않은 성능을 보였던 반면 실행 횟수가 많아질 수록 더 좋은 성능을 보여주는 것을 확인할 수 있다. 즉, lambda는 초기 비용이 있지만 반복하여 사용하면 일정한 성능을 보인다.

따라서 “lambda 함수를 남용하는 것은 성능상에 문제를 가져올 수 있지만, 적당한 시기에 잘 사용하면 성능상에 득을 얻을 수 있다”는 결론을 얻을 수 있었다.

여기까지 get, getOrDefault, computeIfAbsent를 서로 비교해보면서 때와 상황에 따라 적절한 기능을 사용하는 것이 성능에 득이 됨을 확인해보았다.

마지막으로 다양한 invoke instruction의 종류, 역할과 순서를 비교한 그림과 함께 이 글을 마친다.

Difference between invoke instructions

참고 문서