Skip to content
HyoSang edited this page Apr 10, 2018 · 1 revision

모던 Java

클로저와 람다 표현식을 둘러싼 논란

클로저는 어휘적(lexical) 클로저 또는 함수(function) 클로저를 간단하게 부르는 말이다. 단순하게 말하면 자신을 감싼 영역에 있는 외부 변수에 접근하는 함수다. 클로저에서 접근하는 함수 밖의 변수를 자유 변수(free variable)라 한다. 이 정의에 따르면 람다 표현식으로 정의한 익명 함수 가운데 일부는 클로저고 일부는 클로저가 아니다.

클래식 Java에서도 익명 클래스로 클로저의 개념을 구현할 수 있다. 그러나 자유 변수를 final로 선언해야 하는 제약이 있어 Java의 익명 클래스를 클로저로 인정할지는 논란이 있다.

우리가 Java에 클로저를 더한다면 그것은 이미 지원되고 있는 문법의 테두리 안에서 조심스럽게 이뤄져야 할 것입니다. 즉, 클로저가 내부에 하나의 메서드만 가지고 있는 인터페이스를 구현하는 형태를 가져야 한다는 뜻입니다. Runnable 같은 인터페이스나 TimerTask 같은 클래스처럼 말입니다. 이미 존재하는 익명 클래스 문법에 약간의 수정을 가할 필요도 있습니다. final 관련된 요구 사항도 조금 현실적으로 변할 필요가 있습니다.

이미 익명 클래스로 할 수 있는 일을 더 쉽게 하고, 불필요하게 장황해지지 않게 하는 것이 가장 중요하다고 생각합니다. 람다 표현식에서 변하는(mutable) 변수에 접근해 값을 덮어 쓸 수 있는 것은 좋기도 하고 나쁘기도 한 것이 아니라 더 나쁜 것이라고 봅니다.

람다 표현식과 Stream 인터페이스의 도입

클래식 Java에서 구현한 필터링, 정렬, 변환을 Java 8로 구현하면 다음과 같다.

public List<String> findGuestNamesByCompany(String company) {  
    List<Guest> guests = repository.findAll();
    return guests.stream()
        .filter(g -> company.equals(g.getCompany()))
        .sorted(Comparator.comparing(g -> g.getGrade()))
        .map(g -> g.getName())
        .collect(Collectors.toList());
}

filter(), sorted(), map() 메서드의 파라미터로 핵심 행위를 다른 선언 없이 바로 파라미터로 전달한다.

  • 필터링: g -> company.equals(g.getCompany())
  • 정렬: Comparator.comparing(g -> g.getGrade())
  • 변환: g -> g.getName()

-> 기호로 함수를 정의한 부분이 Java 8의 람다 표현식이다. Groovy나 Kotlin과 동일하다. 아직은 it 키워드처럼 파라미터가 한 개일 때 참조할 수 있는 예약어는 없다.

변환의 g -> g.getName()처럼 파라미터를 실행할 메서드만 전달하면 되는 경우에는 :: 기호로 메서드 레퍼런스를 직접 전달할 수 있다. 정렬과 변환에 사용한 방법을 :: 기호로 다시 쓰면 다음과 같다.

.filter(g -> company.equals(g.getCompany()))
.sorted(Comparator.comparing(Guest::getGrade))
.map(Guest::getName)

정렬 로직에서는 Comparator.comparing() 메서드의 파라미터로 Guest.getGrade() 메서드를 전달하고 함수 역할을 하는 java.util.Comparator 타입으로 결과를 반환받았다. 고차 함수의 개념이 쓰인 것이다.

Stream 인터페이스

Java 8에서는 함수를 적용할 수 있는 새로운 인터페이스로 java.util.stream.Stream을 도입했다. java.util.List.stream() 메서드를 호출해 List 타입의 객체로부터 Stream 타입의 객체를 얻었다.

java.util.Collection이나 Iterable 등 기존의 인터페이스에 새로운 메서드를 추가하는 대신 새로운 역할을 하는 인터페이스를 분리했다. Collection.stream(), Iterable.forEach() 메서드 등 몇 가지를 기본 메서드로 추가했다. 이를 통해 인터페이스에 새로운 메서드를 추가해도 이전 버전의 구현체가 규약을 어기지 않게 지원할 수 있다.

이러한 설계는 이전 버전의 인터페이스에 의존하는 코드를 보호해 JDK를 안정적으로 업그레이드할 수 있게 고려한 것이다. 기본 메서드가 아닌 새로운 메서드가 추가됐다면 Java 8로는 이전 버전의 인터페이스를 구현한 소스를 컴파일할 수 없다. 이를 사용하는 애플리케이션에서도 이전 구현체 클래스의 인스턴스를 새로운 버전의 인터페이스로 참조한다면 추가된 메서드를 호출하는 순간 오류가 발생한다.

Stream 인터페이스는 TotallyLazy 라이브러리처럼 지연 연산도 지원한다. Stream 인터페이스의 연산을 중간 단계를 반환하는 것과 최종 값을 반환하는 것으로 구분해서 중간 단계를 처리할 때는 지연된 연산을 적용한다.

Stream 인터페이스는 병렬 처리에도 유리한 구조를 제공한다. 예제 22의 guests.stream() 메서드 부분만 guests.parallelStream() 메서드로 바꾸면 이 Stream 타입의 객체는 내부적으로 병렬로 처리된다. 병렬 처리를 했을 때 효율적일지 여부는 작업의 성격에 따라 다르지만 추상화된 틀이 있어 코드를 조금만 수정해 기존의 작업을 병렬로 처리할 수 있다는 점은 큰 장점이다.

클로저와 final

final이 아닌 변수도 람다 표현식 안에서 참조할 수 있다는 점도 주목할 만하다. 그렇다고 해서 자유 변수인 company의 값을 클로저 안에서 마음대로 바꿀 수 있는 것은 아니다.

public List<String> findGuestNamesByCompany(String company) {  
    List<Guest> guests = repository.findAll();
    return guests.stream()
        .filter(g -> {
            if (company == null) {
                company = ""; // compile error
            }
            return company.equals(g.getCompany());
        })
    // 생략
}

company를 재할당하려 하면 Local variable company defined in an enclosing scope must be final or effectively final이라는 오류가 나오면서 컴파일 오류가 발생한다.

심지어 클로저 밖에서 company를 재할당하려 해도 클로저 안에서 처음으로 company에 접근하는 줄에서 컴파일 오류가 발생한다. 즉 final 키워드를 붙이지 않았을 뿐 사실상 final과 같이 취급한 변수만 클로저 안에서 접근할 수 있는 것이다.

함수 인터페이스

public List<String> findGuestNamesByCompany(String company) {  
    List<Guest> all = repository.findAll();

    Stream<Guest> stream = all.stream();

    // filtering
    Predicate<Guest> filterFunc = g -> company.equals(g.getCompany());
    Stream<Guest> filtered = stream.filter(filterFunc);

    // sorting
    Comparator<Guest> sortFunc = Comparator.comparing(Guest::getGrade);
    Stream<Guest> sorted = filtered.sorted(sortFunc);

    // mapping
    Function<Guest, String> mapFunc = Guest::getName;
    Stream<String> mapped = sorted.map(mapFunc);
    Collector<String, ?, List<String>> collector = Collectors.toList();
    return mapped.collect(collector);
}

보편적인 함수 인터페이스가 정의돼 중복된 코드를 만들 필요가 없다.

public interface Supplier<T> {  
    T get();
}
public interface Predicate<T> {  
    boolean test(T t);
}
public interface Function<T, R> {  
    R apply(T t);
}
public interface BiFunction<T, U, R> {  
    R apply(T t, U u);
}
public interface Consumer<T> {  
    void accept(T t);
}
public interface BiConsumer<T, U> {  
    void accept(T t, U u);
}

Java 8에서는 논란 끝에 함수를 위한 새로운 타입 시스템을 도입하지 않고 인터페이스로 표현했다.

int를 받아서 int를 반환하는 함수가 있다면 아래처럼 IntUnaryOperator 인터페이스를 이용한다.

public static void main(String[] args) {  
    FunctionParameterExam printer = new FunctionParameterExam();
    int base = 7;
    printer.printWeighted(weight -> base * weight, 10);
}
public void printWeighted(IntUnaryOperator calc, int weight) {  
    System.out.print(calc.applyAsInt(weight));
}

Java 8에서 추가된 인터페이스가 아니더라도 조건만 맞다면 람다 표현식으로 정의할 수 있다. 메서드가 한 개인 인터페이스가 있고 파라미터와 반환 타입만 맞다면 인터페이스의 타입이나 메서드 이름과는 상관없이 람다 표현식으로 인스턴스를 할당할 수 있다.

@FunctionalInterface
public static interface StringAction {  
    void execute(String str);
}
...
Consumer<String> f1 = System.out::println;  
StringAction f2 = System.out::println;  
StringAction f3 = s -> System.out.println ("!!" + s);  

람다 표현식의 내부 구현

Java에 새로운 함수 타입 체계를 도입하지 않은 이유는 내부 구현을 효율적으로 하기 위함도 있다. Java 언어의 아키텍트인 Brian Goetz에 따르면 JVM 차원의 표현 방식과 언어 차원의 표현에 거리가 생길수록 감당해야 하는 구현의 복잡함이 커지기 때문에 이를 피하려고 했다고 한다. ‘int 한 개를 파라미터로 받아 int를 반환’과 같은 함수 타입의 표현은 기존의 메서드 시그니처와 같은 방식으로는 할 수 없고, JVM에서도 현재 바이트코드 수준에서는 함수 시그니처를 표현할 수 있는 명세가 없다.

익명 클래스 선언 문법을 단순히 대체한 것처럼 보이지만 람다는 다른 JVM 언어처럼 컴파일 시점에 익명 클래스를 생성하지 않는다. 컴파일된 소스 폴더나 역컴파일을 해도 익명 클래스의 흔적은 없다. 람다 표현식은 익명 클래스 문법과는 다른 바이트코드를 생성한다.

람다 표현식이 기존의 익명 클래스 문법과 다르기 떄문에 언어를 쓰는 사용자에게 우선 드러나는 차이는 this 키워드의 의미다.

public class ThisDifference {  
    public static void main(String[] args) {
        new ThisDifference().print();
    }
    public void print() {
        Runnable anonClass = new Runnable(){
            @Override
            public void run() {
                verifyRunnable(this);
            }
        };

        anonClass.run();

        Runnable lambda = () -> verifyRunnable(this);
        lambda.run();
    }

    private void verifyRunnable(Object obj) {
        System.out.println(obj instanceof Runnable);
    }
}

익명 클래스 내부에서 전달한 this는 Runnable을 구현한 익명 클래스 그 자체인데 반해 람다 표현식을 썼을 때는 익명 클래스가 아닌 것이다. 람다 표현식 안에서 선언한 this의 타입은 이를 생성한 클래스인 ThisDifference다. 익명 클래스 안에서 이를 생성한 객체를 전달하려면 ThisDifference.this처럼 직접 타입을 지정하면 된다.

결과적으로 Java 8에서 람다 표현식으로 객체를 생성하는 코드는 invokedynamic이라는 바이트코드로 변환된다.

public class SimpleLambda {  
    public static void main(String[] args) {
        Runnable lambda= () -> System.out.println(1);
        lambda.run();
    }
}
Compiled from "SimpleLambda.java"  
public class com.naver.helloworld.resort.SimpleLambda {  
    public com.naver.helloworld.resort.SimpleLambda();
        Code:
            0: aload_0
            1: invokespecial    #8        // Method java/lang/Object."<init>":()V
            4: return
    public static void main(java.lang.String[]);
        Code:
            0: invokedynamic    #19, 0    // InvokeDynamic #0:run:()Ljava/lang/Runnable;
            5: astore_1
            6: aload_1
            7: invokeinterface  #20, 1    // InterfaceMethod java/lang/Runnable.run:()V
            12: return

    private static void lambda$0();
        Code:
            0: getstatic        #29       // Field java/lang/System.out:Ljava/io/PrintStream;
            3: iconst_1
            4: invokevirtual    #35       // Method java/io/PrintStream.println:(I)V
            7: return
}

람다 표현식으로 객체를 생성하는 코드는 invokedynamic으로 변환됐다. invokedynamic은 람다 표현식으로 반환될 인터페이스를 구현한 클래스를 동적으로 정의하고 인스턴스를 생성해서 반환한다. 생성된 객체를 실행하는 lambda.run() 메서드는 invokeinterface로 치환됐다.

원래 invokedynamic은 Java 언어가 아닌 JRuby, Jython, Groovy와 같은 동적 타입 언어를 위한 명세였다. 동적 타입 언어는 컴파일 시점에 타입이 확정되지 않은 메서드를 런타임에 호출할 수 있는데 이를 효율적으로 지원하기 위해 Java 7부터 invokedynamic 명세가 포함됐다.

invokedynamic 호출은 Bootstrap 메서드, 정적 파라미터 목록, 동적 파라미터 목록 등 세 가지 정보를 필요로 한다. Bootstrap 메서드는 호출 대상을 찾아서 연결하고 invokedynamic을 쓰는 메서드가 처음 호출될 때만 실행된다. 정적 파라미터는 상수풀(constant pool)에 저장된 정보다. 동적 파라미터는 메서드의 런타임에서 참조할 수 있는 변수인데 람다 표현식으로 치면 클로저로 쓰였을 때의 자유 변수가 이에 해당한다.

public class SimpleLambda {  
    public static void main(String[] args) {
        Runnable lambda= invokedynamic(
            bootstrap=LambdaMetafactory,
            staticargs=[Runnable, lambda$0],
            dynargs=[]);
        lambda.run();
    }
    private static void lambda$0() {
        System.out.println(1);
    }
}

invokedynamic을 이용한 람다 표현식은 성능과 자원 사용면에서 효율적이다. 해당 코드 블럭이 처음 호출되기 전까지는 초기화를 하지 않는다. 따라서 익명 함수가 생성됐어도 실 제로 호출되지 않았다면 힙 메모리를 사용하지 않는다. 외부 변수를 참조하지 않는, 상태가 없는 익명 함수는 인스턴스를 하나만 생성해 다시 반환한다. 결과적으로 상태 없는 익명 함수를 생성하는 실험 케이스에서는 익명 클래스를 쓸 때에 비해 1/67의 인스턴스 생성 비용(capturing cost)이 들었다고 한다.

애플리케이션 코드의 개선

Runnable 구현체를 파라미터로 받아서 별도의 작업 스레드에서 실행한다. 람다 표현식으로 바꿀 수 있다.

public void doGet(final HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {  
    final AsyncContext asyncContext = request.startAsync();
    asyncContext.start(new Runnable(){
        public void run(){
            // 오래 걸리는 작업 실행
            asyncContext.dispatch("/threadNames.jsp");
        }
    });
}
public void doGet(final HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {  
    AsyncContext asyncContext = request.startAsync();
    asyncContext.start(() -> {
        // 오래 걸리는 작업 실행
        asyncContext.dispatch("/threadNames.jsp");
    });
}
public List<Guest> findAll() {  
    return jdbc.query(SELECT_ALL, new RowMapper<Guest>(){
        @Override
        public Guest mapRow(ResultSet rs, int rowNum) throws SQLException {
            return  new Guest (
                    rs.getInt("id"),
                    rs.getString("name"),
                    rs.getString("company"),
                    rs.getInt("grade")
                    );
        }
        });
}
public List<Guest> findAll() {  
    return jdbc.query(SELECT_ALL, (rs, rowNum) -> new Guest (
                rs.getInt("id"),
                rs.getString("name"),
                rs.getString("company"),
                rs.getInt("grade")
        )
    );
}
Button calcButton = (Button) view.findViewById(R.id.button1);  
Button sendButton = (Button) view.findViewById(R.id.button2);

calcButton.setOnClickListener(new OnClickListener() {  
    public void onClick(View v) {
        calculate();
    }
});
sendButton.setOnClickListener(new OnClickListener() {  
    public void onClick(View v) {
        send();
    }
});

Android에서는 아직 Java 8을 쓸 수 없지만 Retrolambda 프로젝트를 이용하면 Android에서도 람다 표현식을 사용할 수 있다.

Button calcButton = (Button) view.findViewById(R.id.calcBtn);  
Button sendButton = (Button) view.findViewById(R.id.sendBtn);

calcButton.setOnClickListener(v -> calculate());  
sendButton.setOnClickListener(v -> send());  

람다 표현식을 활용한 프레임워크

Lambda Behave

describe("ResortService with modern Java", it -> {
        it.isSetupWith(() -> {
            repository.save(
                    new Guest(1, "jsh", "Naver", 15),
                    new Guest(2, "hny", "Line", 10),
                    new Guest(3, "chy", "Naver", 5)
                );

        });
         it.should("find names of guests by company ", expect -> {
            List<String> names = service.findGuestNamesByCompany("Naver");
            expect.that(names).isEqualTo(Arrays.asList("chy","jsh"));
        });

describe() 메서드와 should() 메서드에 전달된 문자열은 마치 JUnit의 클래스와 메서드 이름처럼 인식돼서 테스트가 성공하거나 실패했을 때 출력되는 메시지에 포함된다. 클래스 이름과 메서드 이름을 써야 해서 공백 문자를 쓸 수 없던 한계를 극복할 수 있다.

Jinq

public List<String> findGuestNamesByCompany(String company) {  
    return stream(Guest.class)
        .where(g -> g.getCompany().equals(company))
        .sortedBy(Guest::getGrade)
        .select(Guest::getName)
        .toList();
}
Hibernate: select guest0_.id as id1_0_, guest0_.company as company2_0_, guest0_.grade as grade3_0_, guest0_.name as name4_0_ from guest guest0_ where guest0_.company=? order by guest0_.grade ASC limit ?  

Spark

import static spark.Spark.*;

public class SparkServer {  
    public static void main(String[] args) {
        get("/guests/:company", (request, response) -> {
            String company = request.params(":company");
            return "No guests from " + company;
        });
    }
}
Clone this wiki locally