티스토리 뷰

Intro. 왜 Java 8인가?

Java 8 (=Java 1.8) 은 2014년에 발표된 자바 버전이다.

 

내가 대학에서 자바를 배웠던 것은 Java 8이 발표되기 전이었다. 당연히 Java 8의 신기능에 대해서 대학 수업에서는 배우지 못했다 (조금 더 늦게 자바 수업을 들었다고 해도 배울 수 있었을지는 알 수 없지만).

학교 수업 이외에 개발 공부를 별도로 하고 있지 않았던 나로서는 대학을 졸업할 때까지 Java 8 버전을 쓰면서도 Java 8이라는 얘기를 딱히 들어본 적이 없었다.

 

졸업하고 한참 시간이 지나 부트캠프에서 개발을 다시 배우게 되었다. 커리큘럼 중에 Java 8의 추가기능에 대해 배우는 파트가 있었다. 이 포스팅에서 소개할 Lambda 식, Stream API, Functional Interface 개념 등을 이 때 배웠다. 하지만 그냥 '이런 게 있다' 정도로 넘어갔기 때문에 정말로 이게 중요한 것인지, 이것들이 어떤 연관성을 가지고 있는지까지는 알지 못했다. 그래서 프로젝트에서 쓰지도 않았다.

 

후에, 현업에서 일하고 있는 개발자와의 코드 리뷰를 경험해보고 나서야 이것들이 실무에서 중요하게 쓰이는 문법이라는 것을 뒤늦게 알게 되었다.

 

사실 Java 8에서 추가된 이 기능들에 대한 문서는 이미 웹상에 충분히 많이 있어서 이 포스팅을 굳이 쓸 필요가 있을지 고민도 잠깐 했었는데, Lambda, Stream과 Functional Interface의 관계를 한번에 묶어서 초보 개발자가 이해할 수 있도록 써놓은 문서는 보기 힘들었던 것 같다. 그래서 내 스타일로 한번 글을 써보기로 했다.


그래서 이 포스팅에서는 Java 8에서 추가된 주요 기능들인 Lambda 식, Stream API, 그리고 Functional Interface에 대한 소개를 할 것이고, 이것들의 관계를 이해하기 위해서는 함수형 프로그래밍에 대한 이해가 필요하기 때문에 그 얘기도 할 것이다.

 

그런데 한가지 드는 의문이 있다. Java 버전은 2020년 12월 현재 Java 15까지 나와있는 상태인데, 많고 많은 자바 버전들 중에서 왜 Java 8의 추가기능에 대해서만 따로 배우는 것일까?

 

1) Java 8에서 추가된 기능들이 그만큼 중요하기 때문이다.

 

아래 섹션에서 설명하겠지만 Java 8의 핵심은 함수형 프로그래밍이라는 최신 패러다임을 Java 개발에 접목하는 것이며, 함수형 프로그래밍이 요즘 대세인 이유는 당연히 여러가지 이점이 존재하기 때문이다.

 

2) 하위호환성도 고려하지 않을 수 없다.

 

물론 Java 8 이후의 버전에서도 유용하다면 유용한 기능이 많이 추가되었다. 하지만 JDK 버전을 올린다는 것은 호환성이라는 문제를 동반한다. Java 8 개발 환경에서 개발한 프로그램은 최신 Java 15가 설치된 환경에서 돌아가지만, 반대로 Java 15 개발 환경에서 개발한 프로그램은 Java 8이 설치된 환경에선 돌아가지 않는다.

 

즉 "JDK의 더 많은 기능을 사용할 것이냐 vs 더 많은 버전을 지원할 것이냐" 의 trade-off 관계가 있다는 것이다.

 

3) 위의 두가지 이유를 종합했을 때 현업에서 가장 널리 쓰이고 있는 버전이 Java 8이기 때문이다.

 

그 외에도 Java 8이 32비트 시스템을 지원하는 마지막 버전이라는 점, Java 9부터는 새 버전이 발표되는 텀이 매우 빨라졌다는 점 등도 Java 8이 시장에서 확고하게 자리를 잡는데 영향을 끼쳤을 것이다.

 

JRebel이라는 회사에서 발표한 2020 Java Technology Report에 따르면 자바 개발자를 대상으로 한 설문조사에서 약 58%의 개발자가 Java 8을 사용한다고 응답했다. 2위인 Java 11 (22%) 에 비해 2.5배가 넘는 압도적인 점유율이다. 한국에서의 상황도 크게 다르지 않은 것으로 알고 있다.


1. 함수형 프로그래밍 패러다임

Java 8의 핵심을 요약하면 "함수형 프로그래밍 패러다임의 지원" 이다.

 

Java를 배웠다면 누구나 가장 처음에 Java는 <객체지향 프로그래밍 언어> 라는 내용을 들었을 것이다. C언어로 대표되는 이전의 절차지향 프로그래밍과는 달리, 데이터와 그 데이터에 대한 행위를 객체라는 개념으로 묶고 객체들의 상호작용을 중심으로 프로그램을 만들어나가는 것이 객체지향 패러다임이라고 배웠다.

 

그렇다면 객체지향이 여전히 최신 프로그래밍 패러다임인가? 2010년대 이후로는 <함수형 프로그래밍> 이라는 패러다임이 떠오르고 있다. 물론 그 이전에도 함수형 프로그래밍 언어 (LISP, Scheme...) 라는 것이 존재하긴 했다지만, 일부 분야에서만 한정적으로 쓰이다가 2010년대 들어서 기존에 함수형으로 설계되지 않았던 언어들 (Java, Python...) 에도 함수형 패러다임의 기능이 탑재되고, 함수형 패러다임을 포함하고 있는 JavaScript가 대 Web시대 (?) 를 맞아 대세로 떠오르면서 급물살을 타고 있다.

 

널리 쓰이는 언어 중에서 함수형으로 분류되는 언어로는 JavaScript가 있다 (자바스크립트는 객체지향 언어이기도 하다. 그러니까 하나의 언어가 꼭 하나의 프로그래밍 패러다임과 대응되는 것은 아니다). 자바스크립트를 통해서 함수형 프로그래밍 언어의 대표적인 특징에 대해서 간단하게 알아보고 넘어가자.

 

1) 함수 = '1급 시민'

function add(a) {
    // 함수를 리턴하는 함수
    return function(b) {
        return a + b;
    }   
}

var func = add(5); // func라는 변수에 담긴 것은 function(b) 라는 함수임
console.log(func(10)); // print 15

 

C언어나 자바만 배웠고 자바스크립트를 처음 본다면 코드에 굉장히 신선하게 느껴지는 부분이 있을 것이다. 자바스크립트에서는 함수도, 숫자나 문자열같은 여느 자료형들과 마찬가지로

 

- 변수에 저장할 수 있으며

- 함수의 리턴값일 수 있고

- 다른 함수에 파라미터로 전달할 수도 있다.

 

프로그래밍 언어에서 이러한 3가지 특징을 가지는 것을 first-class citizen, 번역하자면 '1급 시민' 이라 부른다. 즉 자바스크립트에서 함수는 '1급 시민' 이며, 자바스크립트에서는 함수도 객체의 일종이므로 '1급 객체' (first-class object) 라 부른다.

 

함수가 '1급 시민' 의 특성을 가진다는 것이 함수형 프로그래밍 패러다임의 가장 큰 특징이며, 함수 안에 함수를 정의할 수 있으므로 고차함수를 손쉽게 구현할 수 있을 뿐 아니라 함수를 여기저기로 자유롭게 넘길 수 있다는 이점을 활용해 기존의 객체지향 디자인 패턴의 수많은 부분을 간소화하는 것이 가능하다 (고 한다).

 

자바는 어떤가? 자바에서는 숫자, 문자 같은 primitive type, 클래스를 통해 정의하는 reference type 객체 모두 '1급 시민' 이지만, 자바스크립트의 함수에 해당하는 메서드만큼은 '1급 시민' 이 아니다.

자바의 메서드는 리턴할 수 없으며 (return 옆에 재귀함수 호출하는 거랑 헷갈리지 말자: 그것은 메서드를 리턴하는게 아니라 메서드 호출의 결과값을 리턴하는 것이다) 다른 메서드의 파라미터로 메서드가 들어갈 수도 없다. 변수에 담아놨다가 쓸 수도 없고 오직 호출만 가능하다.

 

하지만 Java 8에서 추가된 '함수형 인터페이스' 라는 것을 이용하면 메서드, 즉 함수를 객체로서 다룰 수 있다. 자바에서 객체는 1급 시민이므로, 함수 역할을 하는 객체를 변수에 담고 파라미터로 넘기고 리턴하는 것이 가능하다는 뜻이다.

 

2) 클로저

function add(a) {
    // 함수를 리턴하는 함수
    return function(b) {
        return a + b;
    }   
}

var func = add(5); // func라는 변수에 담긴 것은 function(b) 라는 함수임
console.log(func(10)); // print 15

 

같은 예제를 다시 한번 보자. add() 함수는 파라미터로 a라는 값 하나를 받는다. C언어나 자바를 배운 입장에서, 함수의 파라미터는 기본적으로 지역 변수 (local variable) 다. 그러니까 파라미터로 받은 a의 값은 add() 함수 안에서만 접근할 수 있으며, add() 함수가 호출되고 리턴될 때까지만 유효한 것이다.

 

그런데 이 예제에서 add() 가 리턴하는 익명의 함수 내부에서 a라는 지역 변수에 접근하고 있다. 저 익명의 함수가 func라는 변수에 저장된 뒤, func() 를 호출하는 시점에서는 add() 함수의 호출은 이미 끝난 상태이다. 그러니까 기존의 지역 변수 개념대로라면 a의 값은 이미 소멸되고 없어야 정상이다. 하지만 func(10) 을 실행하면 a가 가지고 있었던 값이 그대로 반영되어 15라는 값이 출력된다.

 

자바스크립트에서는 이와 같이 외부 함수와 내부 함수가 있을 때, 내부 함수가 더 오래 유지되는 경우 내부 함수가 외부 함수의 지역 변수에 접근할 수 있다. 그 이유는 함수가 접근 가능한 변수의 scope가 "함수의 호출 시점" 이 아니라 "함수의 선언 시점" 에 따라 결정되기 때문이다.

이와 같이 함수형 프로그래밍에서 함수는, <자신이 선언된 시점에서의 환경 (어휘적 환경: lexical environment) 을 기억한다> 라는 특징이 있다. 이러한 개념을 클로저 (closure) 라고 부른다.

 

이런 개념이 있으면 뭐가 좋을까? 예를 들어 버튼을 클릭할 때마다 숫자가 1씩 올라가는 카운터 프로그램이 있다고 하자. 어딘가에는 현재 숫자를 저장하고 있어야 하는데, 만약 클로저가 없다면 상태를 기억하기 위해서는 전역 변수를 선언해야 할 것이다. 클로저가 없는 세상에서는 함수의 실행이 끝나면 그 함수 안에 있던 지역 변수들은 날아가기 때문이다.

하지만 전역 변수는 누구나 접근할 수 있어 의도치 않게 값이 변경될 수 있기 때문에 가급적 사용을 자제하는 것이 좋은데, 클로저를 사용하면 전역 변수 없이 "함수가" 상태를 관리하도록 만들 수 있다.

 

또, 자바스크립트에는 public, private 같은 접근 제어자가 없는데 클로저를 잘 이용하면 private을 구현할 수 있다. 즉 정보 은닉에도 클로저가 이용된다. 그 외에도 여러가지 활용 방법이 있다고 한다.

 

Java 8에서 클로저가 어떻게 사용되는지에 대해서는 마지막 6번 섹션에서 설명한다.


2. 함수형 인터페이스 (Functional Interface)

함수형 인터페이스란 1개의 추상 메서드만 가지는 인터페이스를 말한다.

 

함수형 인터페이스 얘기를 하기 전에 먼저 자바의 인터페이스 개념에 대해서 간단하게 짚고 넘어가자.

// Remocon.java
public interface Remocon {
	void turnOn();
	void turnOff();
	void changeChannel(int channelNo);
}

// SamsungRemocon.java
public class SamsungRemocon implements Remocon {
	@Override
	public void turnOn() {
		// 삼성 리모콘 TV 켜는 코드
	}

	@Override
	public void turnOff() {
		// 삼성 리모콘 TV 끄는 코드
	}

	@Override
	public void changeChannel(int channelNo) {
		// 삼성 리모콘 채널 바꾸는 코드
	}
}

// Person.java
public class Person {
	public static void main(String[] args) {
		Remocon remocon = new SamsungRemocon();
		remocon.turnOn();
	}
}

 

인터페이스는 클래스가 구현해야 할 기능에 대한 명세서이다.

 

위의 예시 코드는 리모콘 (Remocon) 의 기능을 정의한 인터페이스로 리모콘의 기능에는 TV를 켜는 것, 끄는 것, 그리고 채널을 바꾸는 것이 있다. 인터페이스는 기본적으로 어떤 기능이 있는지만 정의하며, 그 기능의 구현 (메서드의 body) 은 정의하지 않는다. 이렇게 정의만 해놓고 body를 작성하지 않은 메서드를 추상 메서드 (abstract method) 라 하며, 추상 메서드의 구현은 이 인터페이스를 구현 (implements) 하는 클래스의 역할이다.

 

이 세상에 리모콘의 종류가 하나 뿐이라면, 굳이 수고스럽게 인터페이스를 정의할 필요는 없을 수도 있다. 그냥 클래스에 TV 켜기, TV 끄기, 채널 바꾸기에 대한 메서드를 각각 만들면 된다. 하지만 삼성 리모콘, LG 리모콘, 등등 다양한 브랜드의 리모콘이 있다고 하자. 각 리모콘의 기능은 동일하지만, 브랜드마다 기기의 내부 설계가 다르기 때문에 다른 코드를 작성해야 한다.

 

그리고 리모콘을 사용하는 Person의 입장에서는 삼성 리모콘과 LG 리모콘의 각 기능이 내부적으로 어떻게 돌아가는지 알 필요가 없다. TV 켜기, 끄기, 채널 바꾸기라는 기능만 사용할 수 있으면 된다. 즉 Person은 실제로는 삼성 리모콘이나 LG 리모콘 등을 들고 있겠지만 (인스턴스), 기능을 사용할 때는 Remocon 인터페이스를 참조한다 (참조 변수).

 

예시에서 Remocon 인터페이스는 3개의 구현해야 할 기능, 즉 추상 메서드를 가진다. 그러므로 이것은 함수형 인터페이스는 아니다. 어거지지만 만약에 리모콘이라는 물건을 '전원 켜기' 라는 단 하나의 기능만을 위해 사용한다고 가정하면, turnOn() 이라는 하나의 추상 메서드만 만들면 함수형 인터페이스이긴 할 것이다.

 

그러면 진짜 함수형 인터페이스의 예시를 한번 보자. 함수형 인터페이스가 뭔지 몰라도 정렬 때문에 써보곤 했을 대표적인 함수형 인터페이스로 Comparator가 있다 (예전에 관련 포스팅을 한 적 있으니 써본 적 없다면 링크 참조).

지금 IDE 창이 띄워져 있다면 적당한 Comparator를 자동완성으로 만든 다음 F3 (이클립스 기준) 을 눌러보거나, 아니면 여기서도 전체 코드를 볼 수 있다.

@FunctionalInterface
public interface Comparator<T> {
    int compare(T o1, T o2);
    
    boolean equals(Object obj);
    
    default Comparator<T> reversed() {
        return Collections.reverseOrder(this);
    }
    // ...
}

- @FunctionalInterface: 이 인터페이스가 함수형 인터페이스라는 걸 알려주는 어노테이션이다. 인터페이스 위에 이걸 붙이고 2개 이상의 추상 메서드를 선언하려 하면 컴파일러가 에러를 띄워준다. 어노테이션은 컴파일러에게 정보를 주는 표식일 뿐, 이걸 붙이지 않았다고 해도 추상 메서드 1개인 인터페이스면 함수형 인터페이스다.

 

- compare(): 인터페이스 안에 선언한 메서드고 body가 없으므로 이것은 추상 메서드이며, '이 인터페이스는 이 작업을 위해 존재하는 것입니다, body는 여러분이 작성하세요' 라는 뜻이다.

 

- equals(): 함수형 인터페이스는 추상 메서드가 1개라고 했는데, 어째 추상 메서드가 하나 더 있는 것 같다. 하지만 사실 equals() 라는 메서드는 자바에서 모든 객체의 최상위에 존재하는 (명시적으로 extends하지 않더라도!) Object 클래스에 정의되어 있는 그것으로, 모든 클래스에 존재할 수밖에 없는 것이므로 추상 메서드 카운트에 포함되지 않는다. 사실 모든 클래스에 존재하는 걸 왜 굳이 또 써놨는지까지는 자세히 모르겠다. 찾아보니 어떤 특수한 경우에는 재정의하라는 것 같은데...

 

- 그 아래: 메서드가 몇개 더 있는데 default라는 키워드가 다 앞에 붙어있다. Java 8 이전까지 인터페이스는 추상 메서드만 가져야 했으나, Java 8에서는 default라는 키워드를 붙이면 인터페이스 내의 메서드도 body를 가질 수 있게 된다. 이를 디폴트 메서드라 하며, 디폴트 메서드는 추상 메서드가 아니므로 함수형 인터페이스의 정의와는 관계가 없다.

Collections.sort(students, new Comparator<Student>() { 
    @Override 
    public int compare(Student o1, Student o2) {
        return o2.score - o1.score;
    }
});

Comparator가 함수형 인터페이스라는 걸 알았다. 그럼 익숙한 코드로 돌아와보자.

 

Comparator를 익명으로 (Anonymous Inner Type) 만들어서 사용하는 대표적인 예제 코드이다. Collections.sort() 는 두번째 인자로 Comparator를 받아서 그 Comparator에서 정의하고 있는 내용대로 첫번째 인자로 받은 것 (students) 을 정렬한다. 그러니까 예제에서는 Student가 가지고 있는 score에 따라서 내림차순으로 정렬한다.

 

근데 Comparator가 compare() 메서드를 재정의 (override) 하고 있는 부분을 보자. 이 Comparator라는 게 함수형 인터페이스라는 사실을 알고 나면 이런 생각을 해볼 수가 있다.

 

"이게 함수형 인터페이스면 재정의해줘야 할 건 어차피 compare() 하나 뿐인데, 굳이 compare() 라고 써줘야 하나??"


3. 람다 표현식 (Lambda Expression)

옛날에 알론소 머시기... 라는 수학자가 함수를 수학적으로 표기하는 방법이라며 '람다 대수' 라는 걸 고안했다고 한다. 함수형 프로그래밍의 이론은 람다 대수에 근간을 두고 있으며, 이 람다 표현식 (람다식) 이라는 것도 거기서 유래했다... 는데 더 자세한 건 나도 모른다.

 

여튼, 자바에서의 람다식이란 함수형 인터페이스를 간단하게 축약해서 표현할 수 있는 방법이다.

Collections.sort(students, (o1, o2) -> { 
        return o2.score - o1.score;
});

이 코드는 바로 위에서 봤던 compare() 가 있는 코드랑 완전히 똑같은 역할을 하는 코드이며 람다식을 이용해 표현만 바꾼 것이다.

 

- new Comparator<Student>: 어차피 Collections.sort() 메서드는 두번째 인자로 Comparator가 온다는 것을 알고 있으며, 첫번째 인자로 들어간 students의 타입에 따라 제네릭 타입 (<Student>) 도 정해지므로 생략 가능하다.

 

- compare() 메서드의 이름과 리턴타입, 접근제어자: 함수형 인터페이스이므로 정의할 내용이 compare() 의 구현이라는 것이 보장되므로, 써줄 필요 없다.

 

- 파라미터: 타입은 compare() 의 선언에 써 있으므로 생략 가능하지만, 파라미터 이름은 구현 내용에서 사용될 것이므로 써줘야 한다.

 

즉, 람다식의 기본 형태는 (parameter_list) -> { 추상메서드의 구현내용 } 이며,

이 식의 어디에서도 함수형 인터페이스라던지 Comparator 같은 단어를 찾아볼 수 없기 때문에 직관적으로 와닿지 않을 수 있지만 저 화살표가 쓰인 람다식 자체가 함수형 인터페이스 객체이다!

Collections.sort() 의 두번째 인자로 Comparator가 들어가야 하는데 그 대신 저 화살표가 들어간 람다식이 자리를 차지하고 있는 모양새니까, 람다식 = Comparator = 함수형 인터페이스라는 걸 알 수 있다.

 

달리 말하면, 람다식은 함수형 인터페이스를 표현할 때만 쓸 수 있다.

 

추가적으로 파라미터가 1개라면 (parameter_list) 를 감싸고 있는 소괄호도 생략할 수 있으며,

구현내용이 1줄이라면 중괄호와 return도 생략할 수 있다.

 

즉 위의 코드는 다음과 같이 더 줄일 수 있다.

Collections.sort(records, (o1, o2) -> o2.score - o1.score);

첫 콤마 이후로 나오는, 그러니까 Collections.sort() 의 두번째 인자로 들어가있는 문장 (람다식) 자체가 Comparator 객체와 같은 것이라는 사실을 명심하자. 


4. 다양한 함수형 인터페이스

Comparator로 예시를 자꾸 들어서 함수형 인터페이스는 Comparator처럼 사전 정의된 것들 뿐인가? 라고 생각할 수도 있겠지만 그렇지 않다. 정의 그대로, 추상 메서드가 1개인 인터페이스이기만 하면 함수형 인터페이스이므로 위의 함수형 인터페이스를 위의 Remocon 같이 내가 직접 설계할 수도 있다.

 

하지만 사전 정의된 유용한 함수형 인터페이스들이 더 있다. Java 8에서는 함수형 프로그래밍 패러다임을 지원하기 위해 자주 쓰일 법한 함수형 인터페이스들을 java.util.function이라는 패키지에 정의해 놓았다. 특히 이것들이 중요한 이유는 아래에서 설명할 Stream API가 이것들과 함께 쓰이기 때문이다.

 

1) Predicate<T>

 

boolean test(T) 라는 추상메서드 1개를 가지는 함수형 인터페이스이다. 즉 입력은 T 타입의 인자 1개, 출력은 true 또는 false이다.

boolean을 리턴하는 만큼 조건 체크 등 기존의 if문이 하던 일들을 대체하기 위해 많이 쓰인다.

 

예시를 보면서 차근차근 이해해보자.

Predicate<Student> predicate = new Predicate<Student>() {
    @Override
    public boolean test(Student s) {
    	return s.score >= 70;
    }
};

boolean isPassed = predicate.test(student1);

Predicate라는 타입의 객체를 만들려고 한다. Predicate는 함수형 인터페이스이므로, test() 라는 추상메서드를 반드시 재정의해야 한다. 그래서 정의해줬다. test() 는 입력으로 받은 Student의 score가 70점 이상이면 true, 그렇지 않다면 false를 리턴할 것이다.

 

람다식을 배웠으니까 아래와 같이 줄여서 쓸 수 있다.

Predicate<Student> predicate = s -> s.score >= 70;
boolean isPassed = predicate.test(student1);

그리고, 아래에서는 만든 predicate 객체를 이용해 student1 이라는 Student 객체가 가지고 있는 점수가 70점 이상인지를 test한다. isPassed 변수에는 그 결과가 들어갈 것이다.

 

이쯤에서 이런 생각이 들 수 있다.

 

"이 Predicate라는 거... 입력은 T타입 인자 하나, 출력은 boolean인 함수네? 메서드로도 만들 수 있잖아?"

 

맞다. 위의 코드나, 함수형 인터페이스를 쓰지 않은 아래의 코드나 하는 일은 똑같다.

private boolean studentTest(Student s) {
	return s.score >= 70;
}

boolean isPassed = studentTest(student1);

하지만 studentTest() 는 메서드다. 자바에서 메서드는 '1급 시민' 이 아니라고 했다. 그러므로 변수에 저장하거나, 다른 메서드의 파라미터로 넘기거나, 리턴할 수 없다.

 

위에서 만들어준 Predicate는 메서드처럼 일하지만, 함수형 인터페이스 타입의 '객체' 다. 그래서 predicate라는 이름의 변수에 넣어줄 수 있었으며, 이 predicate를 다른 메서드의 파라미터로 넘기거나 메서드의 리턴값으로 써먹을 수 있다.

 

초반에 언급했던 '함수 역할을 하는 객체' 라는 것이 바로 이런 의미이며 함수형 인터페이스의 의의이다. 함수형 프로그래밍의 관점에서 이야기하면, 함수형 인터페이스가 곧 함수이며 (Java라는 언어가 원래 함수형으로 설계되지 않았기 때문에 인터페이스라는 형태를 빌렸을 뿐) 람다식은 함수를 표현하는 방법인 것이다.

 

예시에서는 다른 메서드랑 연결되어 쓰이지 않기 때문에 predicate.test() 를 직접 호출한 거지만, 다른 메서드에 predicate가 정의하고 있는 일을 시킬 거라면, predicate 객체만 주면 될 것이다. 얘가 하는 일은 어차피 유일한 추상메서드인 test() 를 동작시키는 것이라는 게 확실하기 때문이다. 다음 섹션에서 Stream API 사용 예제를 보면 알 수 있다.

 

유사한 일을 하지만 입력을 2개 받는 함수형 인터페이스로 BiPredicate라는 것도 있다.

 

2) Consumer<T>

 

void accept(T) 라는 추상메서드 1개를 가지는 함수형 인터페이스이다. 즉 입력은 T 타입의 인자 1개, 리턴값은 없다.

받은 T 타입 인자를 가지고 뭔가 내부에서 작업을 수행할 수 있다. 인자를 받아서 할 건 있는데 리턴값은 필요없을 때 쓴다. 즉 리턴 타입이 void인 메서드의 역할을 한다.

 

3) Supplier

 

T get() 이라는 추상메서드 1개를 가지는 함수형 인터페이스이다. 즉 입력은 없고, 출력은 T 타입 하나다.

입력이 없기 때문에 내부에서 랜덤 같은걸 돌리지 않는다면 항상 같은 값을 가져올 것이다. 공급자 (supplier) 라는 이름에 걸맞는 친구다.

 

4) Function<T, R>

 

R apply(T) 라는 추상메서드 1개를 가지는 함수형 인터페이스이다. 입력은 T 타입의 인자 1개, 출력은 R 타입 하나다.

이름 그대로 입력을 받아 그에 따른 출력을 뱉는다는 함수의 원래 정의에 가장 어울리는 함수형 인터페이스라 할 수 있다. Consumer처럼 인자를 받아서 일을 하지만, 리턴값이 필요할 때 쓴다.


5. 스트림 (Stream)

Java 8에서 추가된 Stream은 Array나 Collection같은 데이터를 연속적으로 가공해서 처리할 수 있게 도와주는 클래스이다. 기존에 for문으로 처리했던 많은 일들을 보다 간결한 코드로 작성할 수 있게 해준다.

 

예를 들면, students라는 ArrayList가 있다. 이 학생들 중에서 성적이 80점 이상인 학생이 몇 명인지 확인하고 싶다. Stream을 모른다면 아래와 같이 for, if문을 이용해서 코드를 작성했을 것이다.

int passedStudents = 0;
for (Student s: students) {
	if (s.score >= 80)
    	passedStudents++;
}

 

Stream을 사용하면 한 줄로 끝낼 수 있다. 여기서 filter() 는 위에서 설명했던 Predicate 객체를 인자로 받는다.

int passedStudents = (int) students.stream().filter(s -> s.score >= 80).count();

위의 Comparator 예시 때와 마찬가지로, 어디에도 Predicate 객체라고 써있지 않지만 저 람다식 자체가 '익명의' Predicate 객체라는 사실에 신경쓰자. 물론 앞에다가 Predicate 객체를 따로 만들고 저 자리에 그 이름을 넣는 것도 가능하다.

 

이처럼 메서드의 인자로 기능 (function) 을 넣어주는 것이 바로 함수형 인터페이스이기 때문에 가능한 것이다.

 

Stream을 사용할 수 있게 해주는 관련 메서드들을 Stream API라 하며, Stream API를 사용하기 위해서는 Stream API에는 3가지 종류가 있다는 것을 알아야 한다.

 

1) 시작연산 (initial operation / source)

 

Stream 객체가 아닌 다른 source로부터 Stream 객체를 얻는 연산.

 

일반적으로 배열이나 컬렉션으로 스트림을 열 수 있으며, 배열의 경우 Arrays.stream(arr), 컬렉션의 경우 위의 예시와 같이 컬렉션 객체의 stream() 메서드를 통해 Stream 객체를 얻을 수 있다. 그 외에도 파일이나 문자열 등도 스트림으로 변환할 수 있다.

 

2) 중간연산 (intermediate operation)

 

Stream을 가공하기 위한 연산으로, 중요한 특징은 다음의 두 가지다.

 

- Stream을 리턴하므로, 중간연산 바로 다음에 다른 중간연산이나 최종연산을 이어붙일 수 있다 (chaining). 그러니까 xxx.stream().filter().filter().filter()... 같은 식으로 쓸 수 있다는 뜻이다.

- 대부분 함수형 인터페이스를 인자로 받는다. 따라서 람다식을 이용할 수 있다.

 

대표적인 중간연산이 위의 예시에 쓰인 filter() 메서드로, 인자로 함수형 인터페이스 Predicate를 받는다.

즉 스트림의 모든 구성요소들이 s에 들어가 Predicate가 가지는 추상메서드인 test() 의 인자로 한번씩 들어가게 되는데, 그 중 리턴값이 true인 경우만 filtering하는 것이 filter() 메서드이다.

 

filter() 말고도 자주 쓰이는 중간연산으로는 map(), sorted() 등이 있다.

Stream<Integer> temp = students.stream().map(s -> s.score).sorted();

- map(): 함수형 인터페이스 Function을 받아, 그 출력들을 가지고 새로운 Stream을 만든다. Function<T, R> 은 입력의 타입과 출력의 타입이 다를 수 있으므로, Stream의 타입이 변환될 수 있다. 예시에서는 각 student들에 대해 score를 뽑아냈고, 그 score들을 가지고 새 스트림을 만들었다. 그 결과 Stream<Student> 가 Stream<Integer> 로 바뀌었다.

 

- mapToXX(): mapToInt, mapToDouble 등이 있는데 Stream을 IntStream, DoubleStream 등 특정 타입을 처리하는 데 특화된 스트림으로 변환할 수 있다. IntStream은 Stream<Integer> 랑 개념적으로 차이는 없지만, 정수 연산에 특화된 몇 가지 편리한 메서드 (ex: sum) 를 가지고 있다는 장점이 있다.

 

- sorted(): Comparator 함수형 인터페이스를 받아서, 그에 따라 Stream의 구성요소들을 정렬할 수 있다. 위 예시처럼 인자를 안 받을 수도 있는데, Collections.sort() 등과 마찬가지로 구성요소의 타입 (예시의 경우 Integer) 이 Comparable을 구현하고 있다면 굳이 Comparator가 없어도 되기 때문이다.

 

3) 최종연산 (terminal operation)

 

스트림 연산을 끝내고 최종 결과를 얻어내는 연산으로 Stream 객체가 아닌 다른 타입을 리턴한다.

long count = students.stream().count();

students.stream().forEach(s -> System.out.println(s.score));

List<Student> list = students.stream().collect(Collectors.toList());

boolean existFail = students.stream().anyMatch(s -> s.score < 50);

- count(): 스트림 구성요소의 개수를 세어 리턴하는 메서드. 리턴타입은 long이다.

 

- forEach(): 각 스트림 구성요소에 대해서 어떤 작업을 반복적으로 수행하게 한다. Consumer를 인자로 받으며 리턴타입은 void이다.

한편 예시의 코드는 아래와 같이 바꿔쓸 수도 있는데, Java 8에서 추가된 method reference 또는 double colon operator (::) 라고 불리는 것으로 클래스 이름을 앞에, 메서드 이름을 뒤에 쓰면 된다.

students.stream().map(s -> s.score).forEach(System.out::println);

 

- collect(): 스트림을 끝내고 그 스트림의 구성요소들을 List나 Map같은 컬렉션으로 만들 때 주로 사용한다. Collector라는 타입을 인자로 받는데, 이건 함수형 인터페이스는 아니고 Collectors라는 클래스에 정의되어 있는 static 메서드들을 통해 얻을 수 있다. 그 중 List로 만들어주는 것이 예시에서 볼 수 있는 Collectors.toList() 이다.

 

- anyMatch(), allMatch(): 각 스트림 구성요소들에 대하여 특정한 조건 만족 여부를 검사하여 boolean을 리턴한다. anyMatch() 는 적어도 하나의 구성요소가 조건을 충족하면 true, allMatch() 는 모두 조건을 충족해야 true다. true/false 조건 체크를 한다는 점에서 짐작할 수 있듯 Predicate 함수형 인터페이스를 인자로 받는다.

 

한편, 스트림을 사용할 때 다음과 같은 주의사항이 있다.

 

- 스트림은 원본 데이터를 바꾸지 않는다: 즉 students.stream().filter()... 를 하더라도 students라는 ArrayList는 filter 조건에 해당하는 학생만 추출한 것으로 바뀌는게 아니라 저 스트림 연산을 실행하기 전과 같은 상태이다.

 

- 스트림이 무조건 for-loop보다 좋은건 아니다: 스트림을 쓰면 for-loop를 쓰는 것보다 코드 라인수는 많은 경우에 줄어들지만, 항상 가독성이 좋기만 한 것은 아니며, 성능도 for-loop보다 떨어질 수 있다. 컴파일러가 for-loop는 옛날부터 쓰던 거라서 최적화를 잘 하는데 스트림은 잘 최적화를 못한다고 한다.

 

- 스트림은 재사용할 수 없다: 최종연산은 물론이고 중간연산으로 새로운 스트림을 만들고 나서도, 이전의 스트림에 대고 뭘 또 호출하려고 하면 에러가 난다. 예를 들면 아래와 같은 코드는 에러를 발생시킨다.

Stream<Student> stream1 = students.stream().filter(s -> s.score >= 80);

stream1.forEach(System.out::println); // stream1 사용 끝

List<Student> result = stream1.collect(Collectors.toList()); // stream1 재사용 -> error

6. 활용 예시

좀 더 현실적인 예시를 가지고 Java 8의 추가기능을 이용해서 코드를 어떻게 리팩토링할 수 있는지, 그리고 어떤 이점이 있는지 살펴보자.

 

예시는 게임에서 아이템을 합성하는 기능이라고 하자. 요구사항은 다음과 같다 (어디까지나 Java 8을 설명하기 위한 예시일 뿐이므로 디테일은 신경쓰지 말자...).

 

- 아이템 5개를 넣고 돌리면 새로운 아이템 1개를 얻는다.

- 아이템의 속성은 아이템의 고유번호, 이름, 등급이 있다.

- 합성재료로 고유번호가 같은 아이템을 2개 이상 넣을 수 없다 (=서로 다른 아이템 5개를 넣어야 한다).

- 아이템의 등급에는 레전드, 레어, 커먼 등급이 있다.

- 결과 아이템의 등급별 등장확률은 레전드 10%, 레어 30%, 커먼 60%이며 등급이 먼저 정해지고 각 등급 내에서의 아이템별 등장확률은 모두 같다.

- 단, 합성재료로 레전드 아이템 5개를 넣었을 경우에는 결과 아이템으로 100% 레전드가 나온다.

 

아래는 Java 8의 추가기능을 쓰지 않고 짜본, old-style 코드이다.

 

	class Item {
		int itemNo;
		String name;
		Grade grade;
	}
	
	enum Grade {
		LEGEND, RARE, COMMON
	}
	
	public Item combine (List<Integer> inputItemIds) throws Exception {
		if (inputItemIds.size() != 5)
			throw new Exception ("아이템 5개를 넣어야 합성이 가능합니다.");
		
		// 입력 아이템 중복 체크
		Set<Integer> itemIds = new HashSet<>();
		for (Integer id: inputItemIds)
			itemIds.add(id);
		
		if (itemIds.size() < 5)
			throw new Exception ("중복 아이템을 합성재료로 사용할 수 없습니다.");
		
		// 데이터베이스에서 모든 아이템들의 정보를 갖고온다
		List<Item> allItems = itemDao.getAll();
		
		// 입력 아이템들의 등급이 all 레전드인지 체크
		int inputLegends = 0;
		
		for (Item item: allItems) {
			if (inputItemIds.contains(item.itemNo)) {
				if (item.grade == Grade.LEGEND)
					inputLegends++;
			}
		}
		
		// 입력이 all 레전드인 경우 결과 아이템 등급은 레전드로 고정, 그렇지 않다면 랜덤으로 등급 추첨
		Grade resultGrade = null;
		if (inputLegends == 5)
			resultGrade = Grade.LEGEND;
		else
			resultGrade = getRandomGrade((int) (Math.random() * 100));
		
		// 전체 아이템 중 resultGrade 등급에 해당하는 아이템들만 추출
		List<Item> candidateItems = new ArrayList<>();
		for (Item item: allItems) {
			if (item.grade == resultGrade)
				candidateItems.add(item);
		}
		
		// 추첨된 등급의 아이템 중에서 랜덤으로 하나 뽑기
		Collections.shuffle(candidateItems);
		return candidateItems.get(0);
	}

	private Grade getRandomGrade(int num) {
		if (num < 10)
			return Grade.LEGEND;
		else if (num < 40)
			return Grade.RARE;
		else
			return Grade.COMMON;
	}

Java 8에 익숙하거나 경험이 많은 개발자라면 이 코드를 보고 입에 고구마를 잔뜩 쑤셔넣은 기분이 들 수도 있다. 하지만 그렇지 않다면 그냥 생각나는 대로 코드를 짰을 때 이 정도가 보통이지 않을까... 그렇게 생각한다. 아님 말구...

 

코드를 읽다보면 for문이랑 if문이 같이 쓰이면서 코드가 길어지는 부분이 한 세 군데 정도 보이는 것 같다. 저것들을 Stream API랑 람다식을 이용해서 간단하게 바꿔볼 것이다.

 

// 입력 아이템 중복 체크
Set<Integer> itemIds = new HashSet<>();
for (Integer id: inputItemIds)
	itemIds.add(id);

if (itemIds.size() < 5)
	throw new Exception ("중복 아이템을 합성재료로 사용할 수 없습니다.");
// 입력 아이템 중복 체크
if (inputItemIds.stream().distinct().count() != 5)
    throw new Exception ("중복 아이템을 합성재료로 사용할 수 없습니다.");

먼저 입력받은 아이템들의 id에 중복이 있는지 체크하는 부분이다. Stream에는 distinct() 라는 메서드가 있는데 이걸 쓰면 구성요소들 중 중복이 있으면 제거를 해준다. SQL을 안다면, select distinct... 랑 같은 역할이라 보면 된다. 만약 중복이 있다면 개수가 변할 것이므로, 그 경우 Exception을 띄우는 코드이다.

 

for문을 한줄로 줄였을 뿐 아니라 HashSet을 사용할 필요도 없게 되었다.

int inputLegends = 0;

for (Item item: allItems) {
	if (inputItemIds.contains(item.itemNo)) {
		if (item.grade == Grade.LEGEND)
			inputLegends++;
	}
}
boolean isAllLegend = allItems.stream().filter(i -> inputItemIds.contains(i.itemNo))
						.allMatch(i -> i.grade == Grade.LEGEND);

다음으로 입력이 전부 레전드 등급인지 체크하는 부분이다. 전체 아이템들 중에서 입력받은 id에 해당하는 아이템들만 filter() 로 추려낸 후, 그것들이 전부 레전드 등급인지를 allMatch() 로 확인하는 것이다. 둘 다 Predicate로 조건을 받고 있다.

 

전부 레전드인지, 그렇지 않은지의 여부만 알면 되므로 int 타입의 변수 (inputLegends) 는 쓸모가 없었는데 for문을 지우면서 같이 없어졌다. 또 allMatch() 메서드는 성능 면에서도 이점이 있는데, 각 구성요소들에 대해 조건체크를 하다가 하나라도 false가 뜨면 결과는 무조건 false이므로 그 시점에서 내부적으로 반복 체크를 종료한다. if 조건문에 || 연산자로 조건 여러개를 걸어놨을 때 맨 앞의 조건이 true면 그 뒤는 체크하지 않는 것과 같은 맥락이다 (이런 걸 short-circuit operation이라 한다).

Grade resultGrade = null;
if (inputLegends == 5)
	resultGrade = Grade.LEGEND;
else
	resultGrade = getRandomGrade((int) (Math.random() * 100));

List<Item> candidateItems = new ArrayList<>();
for (Item item: allItems) {
	if (item.grade == resultGrade)
		candidateItems.add(item);
}
Grade resultGrade = null;
if (isAllLegend)
	resultGrade = Grade.LEGEND;
else
	resultGrade = getRandomGrade((int) (Math.random() * 100));

List<Item> candidateItems = allItems.stream().filter(i -> i.grade == resultGrade)
							.collect(Collectors.toList());

Stream API 쓴 것 자체는 이전 예시와 크게 다를 것이 없는데, 이 코드는 사실 에러가 나는 코드이다. 왜 그런지 설명하려면 여기서 클로저 얘기를 해야 한다.

 

처음에 함수형 프로그래밍의 특징을 얘기하면서 클로저라는 게 있다고 했었다. 이제 그 클로저가 이 코드의 어디에 있는지 살펴보자. filter() 메서드 안에 predicate가 들어가 있고, 람다식의 오른쪽은 predicate의 유일한 추상 메서드 test() 의 구현내용을 써놓은 것이다. 그런데 그 안에서 바깥에 있는 지역 변수인 resultGrade를 사용하고 있다 (파라미터로 넘겨주지도 않았는데). 즉 내부 함수에서 외부 함수의 지역 변수를 참조하고 있으며 이는 개념적으로 클로저이다.

 

예시에서의 resultGrade와 같이 클로저에서 참조되는 외부 지역 변수를 자유 변수 (free variable) 라고 부른다. 그런데 자바에서는 자유 변수는 반드시 final이어야 한다는 제약을 두었다. 왜냐하면 람다식 (익명 함수) 이 생성될 때 그 시점에서의 외부 환경 (lexical environment) 을 복사하는데, 이런 상황에서 자유 변수의 값이 변경되면 의도하지 않은 결과를 초래할 수 있기 때문이다 (멀티스레드 환경과 밀접하게 연관이 있다고 한다).

 

Java 8에서는 명시적으로 final이라 선언하지 않더라도, 자유 변수가 final일 수 있는 상황이라면 컴파일러는 알아서 final을 붙여서 람다식으로 넘겨준다. 이를 유사 파이널 (effectively final) 이라 하며, 유사 파이널일 경우 에러가 뜨지 않는다. 하지만 위 예시의 경우 if-else문 때문에 resultGrade를 선언과 동시에 초기화해줄 수가 없어서 final이 될 수 없다. 그래서 아래와 같은 에러가 뜬다.

 

Local variable resultGrade defined in an enclosing scope must be final or effectively final

그러면 어떻게 고칠까? 예시의 경우 if-else문 대신 삼항 연산자를 사용하면 선언과 동시에 초기화를 시켜줄 수 있다. 이러면 resultGrade가 처음 선언된 이후로 변경되지 않으므로 final을 붙이지 않았지만 "유사 파이널" 이다.

Grade resultGrade = isAllLegend ? Grade.LEGEND : getRandomGrade((int) (Math.random() * 100));		
List<Item> candidateItems = allItems.stream().filter(i -> i.grade == resultGrade).collect(Collectors.toList());

이런 경우 말고 더 머리아픈 경우도 많이 있는데, 일반적으로 이 에러를 우회하는 방법은 primitive type 대신 reference type (객체나 배열 등) 을 사용하는 것이다. 객체를 만든 다음 객체의 메서드를 이용해서 내부 값을 변경하는 것은 허용된다. reference type의 경우 굳이 상태 복사를 하지 않아도 람다식에서 따라갈 수 있기 때문이다. 하지만 에러가 안 뜬다는 것일 뿐 그렇게 짜면 멀티스레드 환경에서의 위험성은 여전히 존재하므로 스레드 동기화를 잘 해줘야 한다... 고 한다.

 

public Item combine2 (List<Integer> inputItemIds) throws Exception {
	if (inputItemIds.size() != 5)
		throw new Exception ("아이템 5개를 넣어야 합성이 가능합니다.");

	if (inputItemIds.stream().distinct().count() != 5)
		throw new Exception ("중복 아이템을 합성재료로 사용할 수 없습니다.");

	List<Item> allItems = itemDao.getAll();

	boolean isAllLegend = allItems.stream().filter(i -> inputItemIds.contains(i.itemNo)).allMatch(IS_LEGEND);

	Predicate<Item> predicate = isAllLegend ? IS_LEGEND : getGrade((int) (Math.random() * 100));
	List<Item> candidateItems = allItems.stream().filter(predicate).collect(Collectors.toList());

	Collections.shuffle(candidateItems);
	return candidateItems.get(0);
}

private static final Predicate<Item> IS_LEGEND = i -> i.grade == Grade.LEGEND;
private static final Predicate<Item> IS_RARE = i -> i.grade == Grade.RARE;
private static final Predicate<Item> IS_COMMON = i -> i.grade == Grade.COMMON;

private Predicate<Item> getGrade (int num){
	if (num < 10)
		return IS_LEGEND;
	else if (num < 40)
		return IS_RARE;
	else
		return IS_COMMON;
}

(*이 코드가 모범적인 코드라는 것은 아니다: Java 8과 딱히 관련없는 부분은 더 리팩토링하지 않았다)

 

위의 내용을 모두 반영하고 추가적으로 Predicate를 static final 변수로 빼 보았다. 함수를 변수에 저장할 수 있다는 특성을 활용해서, 저런 식으로 빼두면 똑같은 람다식이 계속 반복적으로 쓰이는 경우 유용하게 써먹을 수 있다. Predicate.or() 같은 메서드를 활용해서 "레전드 또는 레어 등급" 을 IS_LEGEND.or(IS_RARE) 와 같은 식으로 쓸 수도 있다.

 

getGrade() 메서드도 Grade 대신 Predicate를 리턴하도록 살짝 바꿔봤다. 예시의 경우 그냥 Predicate를 리턴할 수 있다는 걸 보여주는 의미 정도지만, 좀 더 프로그램이 복잡해졌을 때 함수를 리턴하는 함수를 잘 활용하면 코드가 훨씬 깔끔해질 수 있다. 그런 예시까지 있으면 더 좋을 것 같긴 한데... 피곤해서 이정도만 쓸래...


글 전체 내용을 간단하게 정리하는 것으로 글을 마친다.

 

- Java 8 추가기능의 핵심은 함수형 프로그래밍 패러다임을 자바에 도입한 것이다.

- 자바에서 원래 함수 역할을 하는 메서드는 '1급 시민' 이 아니기 때문에 함수형 인터페이스라는 것이 도입되었다. 함수형 인터페이스는 추상 메서드가 1개인 인터페이스를 말하며, 함수 역할을 하지만 객체이므로 '1급 시민' 이다.

- 자주 사용되는 함수형 인터페이스로 java.util.function 패키지의 Predicate, Supplier, Consumer, Function 등이 있고 그 외에도 Comparator와 같은 함수형 인터페이스들이 존재한다. 

- Lambda 식이란 함수형 인터페이스를 축약해서 표현할 수 있는 방식으로 화살표 (->) 를 사용한다.

- Stream이란 데이터를 연속적으로 가공 및 처리하는 것을 도와주는 API로 함수형 인터페이스, 람다식과 함께 사용되며 기존의 for, if문으로 했던 일들을 많은 부분 대체할 수 있다.

- 함수형 인터페이스 내부에서 외부의 지역변수에 접근할 경우 (클로저) 그 지역변수는 final과 같이 선언 후 변경되어서는 안된다.

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
TAG
more
«   2024/04   »
1 2 3 4 5 6
7 8 9 10 11 12 13
14 15 16 17 18 19 20
21 22 23 24 25 26 27
28 29 30
글 보관함