Java 람다와 클로져

클로져 (Closure)

아마 자바만 다루셨던 분이라면 클로져가 조금은 생소한 개념일 수 있습니다. 주로 자바스크립트와 같이 함수를 일급객체로 다루는 언어에서 중요하게 다루는 개념인데 간단하게 짚고 넘어가보도록 하겠습니다.

// ClosureDemo.js

function add10(num) {
  var innerFunc = function () { return num + 10 }
  return innerFunc
}

var call = add10(10)
console.log(call()) // 20

위 코드에서 주목해야할 점은 var innerFunc = function () { return a + 10; } 부분입니다. innerFunc함수에서 외부함수인 add10 함수의 파라미터인 num을 사용하고 있습니다.

같은 함수내에서 사용되기 때문에 문제될 것이 없는것처럼 보이지만, var call = add10(10) 를 호출하는 시점에서 call 변수가 갖게되는것은 innerFunc 함수일 것 입니다.
그렇다면 해당 함수를 호출하는 call()을 수행하게되면 add10의 함수는 이미 종료되었기때문에 num 파라미터를 어떻게 사용 할 수 있는지에 대해서 혼동이 오실 수 있습니다.

일반적인 프로그래밍 개념에서 보자면, 실행이 종료된 함수(완료된 스택)의 변수는 소멸되는것이 맞지만, 자바스크립트의 함수는 자신이 선언되었을때의 환경(Lexical environment)를 기억해 외부에서 함수가 호출 되더라도 처음 함수가 생성됬을때의 환경을 참조하여 불러 올 수 있습니다. 이런 동작을 하는 함수를 클로져 함수라고 칭하기도 합니다.


자바의 클로져

위의 자바스크립트에서 클로져에 대해 알아보았는데, 이번에는 자바에서 어떻게 사용되는지 알아보도록 하겠습니다. 우선, 자바 자체는 함수를 일급객체로 다루지 않았지만 Java8부터 함수형 인터페이스람다 표현식이 나오면서 유사하게 사용이 가능해졌습니다.

위 자바스크립트 예제와 동일하게 사용된 자바 예제를 통해 알아보도록 하겠습니다.

// ClosureDemo.java

public class ClosureDemo {
    public static void main(String[] args) {
        Closure closure = new Closure();
        var call = closure.add10(10);
        System.out.println(call.get()); // 20
    }

    public static class Closure{
        private Supplier<Integer> add10(int num){
            return () -> num+10;
        }
    }
}

자바스크립트에서는 Lexical environment라는 개념을 통해 이 처리가 가능했지만 자바에서는 이 개념이 존재하지 않습니다. 자바에서는 자바의 argument 전달 방식인 Call by Value와 같이 클로져 함수에서 사용되는 변수들을 복제해두고 사용하기 때문에 외부에서 함수가 호출 되더라도 초기에 함수가 생성됬을때 넘겨받은 변수값을 사용 할 수 있습니다.


자바 람다와 클로져

위와같은 코드는 주로 자바에서 람다함수를 사용할때 사용되게 되는데 알아보았다시피 일반적이지 않게 동작하기 때문에 내부적으로 어떻게 동작하는지 잘 인지하고 코드를 작성해주셔야 합니다.

자바에서 내부함수에서 사용되는 외부 변수들은 복사해서 사용된다는 특성때문에 몇가지 룰이 존재합니다.

우선 외부변수가 내부함수에서 변경 될 수 없습니다. 변경을 시도한다면 다음과같은 컴파일 에러가 발생합니다.

// SampleClass.java

 private void doSomething(){
    String st = "origin";
    Supplier<String> supplier = () -> {
        st += "_changed";
        return st;
    };
}

java: local variables referenced from a lambda expression must be final or effectively final

컴파일 오류메세지처럼 내부함수에서 사용되는 변수들은 final를 직접 지정해주거나, 키워드가 없더라도 final처럼 취급되어야 합니다. (Java7 까지는 익명함수에서 반드시 final 키워드가 필요했습니다) 만약 꼭 변경이 필요한경우 atomic 타입으로 변경하여 사용 될 수 있습니다.

이와같은 룰이 있는 이유는, 위에서 알아보았다시피 내부함수에서는 외부변수의 복제본을 사용하기도 하고 해당 함수가 어떤 쓰레드에서 동작할지 알 수 없어 쓰레드간 경쟁에 노출될 수 있기 때문입니다.

‘복제본을 쓰는데 쓰레드간 경쟁이 왜 일어나지?’ 라고 생각하시는 분들이 있으실텐데 다음 예제를 보여드리도록 하겠습니다.

// SampleClass.java

public static class Sample{
    private void doSomething(){
        SampleObj obj = new SampleObj();

        Supplier<SampleObj> supplier = () -> {
            obj.count++;
            return obj;
        };
    }
}

public static class SampleObj{
    public int count = 0;
    public boolean flag = true;
}

자바에서 객체 파라미터가 복제될때에는 참조값을 담은 레퍼런스를 복제하기때문에 람다식에서 사용되는 객체는 공유될 수 있습니다. (참고 링크 : 자바는 Call by Value 로 동작한다)

실제로 위 코드는 obj 객체자체가 변경되는것은 아니기때문에 람다식에서 obj.count값이 변경 될 수 있고 async로 동작하게 된다면 다른 쓰레드에의해 변경이 일어나 쓰레드경쟁이 발생 할 수 있습니다.


자바 람다를 사용할때

최종으로 정리해보자면 위에서 알아본 이유들로 인해 람다식에서 사용되는 객체의경우 가능하다면 다음과같은 조건하에 사용하는것이 좋습니다.

  • final 선언하여 사용하기
  • 불변객체를 사용하기
// SampleClass.java

public static class Sample{
    private void doSomething(){
        final SampleObj obj = new SampleObj(0, true);

        Supplier<SampleObj> supplier = () -> {
            int count = obj.getCount + 1;
            boolean flag = count == 100 ? false, true;
            return new SampleObj(count, flag);
        };
    }
}

public static class SampleObj{ // immutable object
    private final int count = 0;
    private final boolean flag = true;

    public SampleObj(int count, boolean flag){
        this.count = count;
        this.flag = flag;
    }

    public getCount(){
        return count;
    }

    public getFlag(){
        return flag;
    }
}

물론 위와같은점들을 지키지 않아도 문제가 생길수 있는 지점을 인지하고 개발한다면 컴파일 에러 없이 사용이 가능하지만, 팀에서 함께 사용되는 코드에서는 다른 팀원혹은 추후에 코드를 보게될 분들을 위해 위같은 룰을 지켜서 코드를 작성하는것이 좋을것 입니다.