Lambda와 관련해서는 학습을 진행하며 작성한 글이기 때문에 잘못된 내용이 있을 수도 있습니다. 틀린 내용이 있다면 채널톡 혹은 댓글을 통해 피드백해주시길 바랍니다. ㅎㅎ
Self Invocation
Self Invocation은 Dynamic Proxy 기반의 기능들을 사용할 때 사소한 실수로 인하여 자주 발생하는 문제입니다. 쉽게 설명하자면, 객체 외부에서 보내는 메시지(요청)에 대해서만 반응하도록 설계되어 있기에 내부의 요청에 대해서는 반응하지 못하기 때문입니다.
JVM 생태계에서 많은 사랑을 받는 Spring Framework는 다양한 기능들을 Dynamic Proxy 방식으로 제공하고 있습니다.
@Transcational, @Async, @Cacheable, AOP(Before, Around) 등의 Aspect 기능들이 속합니다.
AopContext의 currentProxy() 메서드를 통해 해당 객체를 감싸고 있는 Proxy 객체를 반환
((Type) AopContext.currentProxy()). someMethod();
상태 변수를 통한 자기 참조 (@Autowired나 ApplicationContext.getBean() 활용)
객체 외부에서 호출하는 메서드에 Dynamic Proxy가 반응하도록 설정하기
등이 있지만, 이것들을 적용하기에는 과한 상황이거나 Spring Container에 종속되는 좋지 않은 코드를 작성하게 될 수 있습니다.
Lambda를 이용하여 Self-Invocation 회피
Lambda로 어떻게 Self-Invocation을 회피할 수 있을까요?
결론만 이야기하자면 Lambda를 통해 실행되는 메서드를 접근하기 위해서 현재 호출 객체 외부로 메시지가 나가고, 최종적으로 호출해야 되는 메서드를 찾아 요청하였을 때, 외부에서 전달되기 때문에 감싸고 있는 Proxy가 해당 요청을 인지할 수 있기 때문입니다.
Java Lambda는 Reflection API, MethodHandle, LambdaMetaFactory 인터페이스를 이용하여 기능을 제공합니다.
Lambda Method를 호출하는 흐름
Reflection API를 통해 실행 대상이 되는 메서드 정보를 가져옵니다.
MethodHandle Lookup API에 정의된 Factory 메서드를 통해 Lookup 객체를 가져옵니다.
1번에서 가져온 정보를 Lookup.unreflect() 메서드에 전달함으로써 해당 메서드의 구현, 수행 정보를 알고 있는 MethodHandle 객체를 가져옵니다. (실제 메서드를 바라보고 있는 일종의 포인터)
LambdaMetafactory.metafactory() 메서드에 필요한 인자를 넘겨 CallSite 객체를 반환받습니다. 해당 객체는 Functional Interface를 객체로 다룰 수 있으며, 매개 변수를 설정하고 응답을 반환합니다. 인자 목록은 밑에 나열하였습니다.
접근 권한을 가지고 있는 Lookup 객체
구현할 메서드 이름(Supplier Interface를 사용했을 경우 get이라는 문자열을 넘긴다.)
메서드의 매개 변수와 응답 값의 Class 정보. methodType(Supplier.class, {Type}. class)
함수 객체(Lambda)에 의해 반환될 응답 값의 유형. methodType(Object.class)
메서드의 구현 및 수행 방식을 알고 있는 MethodHandle 객체
호출 시 동적으로 적용되어야 할 응답 값의 세부 정보. methodType({Type}. class)
callSite.getTarget()을 통해 호출할 메서드 정보를 가져오고 bindTo({Type}. class)를 통해 메서드에 넘길 인자 값을 지정한 뒤 Invoke를 통해 메서드를 호출합니다.
(사실상 그 호출하는 형태는 Dynamic Proxy와 유사한 것 같습니다)
꽤 복잡하지만, 결론적으로는 Lambda를 통해 호출되는 인터페이스를 인스턴스 화 하고, 메서드를 호출하기 때문에 객체 외부 요청으로 다시 돌아오는 것입니다.
이 흐름은 Bytecode의 invokedynamic이 호출된 경우에 수행 흐름을 나타냅니다. Lambda에서 Function Interface를 사용하지 않고 단순한 로직을 사용하는 경우 static 메서드를 생성하여 활용하기도 합니다.
invokedynamic은 Bootstrap 메서드라는 특정한 메서드를 호출하고, 이를 통해 위의 호출 프로세스를 초기화 및 실행하여 CallSite 객체를 반환받습니다. (InnerClassLambdaMetafactory를 활용하여 내부 클래스 생성 후 반환)
한번 Bootstrap 메서드가 실행된다면 이후에는 기존의 CallSite와 MethodHandle를 재사용하여 요청을 처리합니다.
구현 예시
Self-Invocation을 회피하기 위해 구현한 TransactionalHandler입니다.
@Service
public class TransactionHandler {
@Transactional(propagation = Propagation.REQUIRED)
public <T> T runInTransaction(Supplier<T> supplier) {
return supplier.get();
}
}
Sample Service.
@Service
@RequiredArgsConstructor
public class SampleService {
private final SampleRepository someRepository;
private final TransactionHandler transactionHandler;
// 특정 객체에서 호출하는 Method
public void addNewSamples(List<Sample> samples) {
return samples.forEach(sample ->
transactionHandler.runInTransaction(() -> addSample(sample.toEntity()))
)
}
// 외부에서 호출되는 Method
@Transcational
public SomeEntity addSample(SampleEntity newSample) {
return someRepository.insertSample(newSample);
}
}
단순한 예시여서 실제 효용성과 조금 동떨어진 감은 있지만, 실제 업무 중 활용할 수 있을만한 부분을 특정하실 수 있을 것이라고 생각합니다. ㅎㅎ 이 글은 여기까지입니다. 감사합니다.
static <T> UnaryOperator<T> identity() {
return t -> t;
}
BinaryOperator extends BiFunction <T, T, T>
public static <T> BinaryOperator<T> minBy(Comparator<? super T> comparator) {
Objects.requireNonNull(comparator);
return (a, b) -> comparator.compare(a, b) <= 0 ? a : b;
}
public static <T> BinaryOperator<T> maxBy(Comparator<? super T> comparator) {
Objects.requireNonNull(comparator);
return (a, b) -> comparator.compare(a, b) >= 0 ? a : b;
}
DoubleBinaryOperator
double 타입의 두 인자를 받고 해당 타입으로 값을 반환하는 함수형 인터페이스이다.
double applyAsDouble(double left, double right);
DoubleUnaryOperator
인자와 반환 타입이 double인 메서드를 제공하는 함수형 인터페이스이다.
double applyAsDouble(double operand);
IntBinaryOperator
int 타입의 두 인자를 받고 해당 타입으로 값을 반환하는 함수형 인터페이스이다.
int applyAsInt(int left, int right);
IntUnaryOperator
인자와 반환 타입이 int인 메서드를 제공하는 함수형 인터페이스이다.
int applyAsInt(int operand);
LongBinaryOperator
long 타입의 두 인자를 받고 해당 타입으로 값을 반환하는 함수형 인터페이스이다.
long applyAsLong(long left, long right);
LongUnaryOperator
인자와 반환 타입이 long인 메서드를 제공하는 함수형 인터페이스이다.
long applyAsLong(long operand);
번외 : Runnable
해당 인터페이스는 JDK 1.0 부터 존재해왔던 오래된 인터페이스이지만, 자바의 함수형 인터페이스 제약을 지키고 있기에 추가하였다.
인자 타입과 반환 타입이 존재하지 않는 메서드를 제공하는 (논리적인) 함수형 인터페이스이다.
해당 인터페이스는 JDK 1.2 부터 존재해왔던 인터페이스로 역시 자바의 함수형 인터페이스 제약을 지키고 있다.
@FunctionalInterface도 타입 래벨에 정의되어 있음을 알 수 있다.
T라는 타입을 가지는 두 인자를 받아 int 값을 반환하는 메서드를 제공하는 함수형 인터페이스이다.
객체, 값 객체 간의 우선 순위 즉 정렬을 위한 값을 얻어내는데 주로 사용된다.
int compare(T o1, T o2);
Arrays.sort(split, (o1, o2) -> o2.compareTo(o1));
Variable Capture (Lambda Capturing)
Lambda의 body에서 인자로 넘어온 것 이외의 변수를 접근하는 것을 Variable Capture라고 한다.
Lambda는 인스턴스, 정적 변수와 final로 선언된 혹은 final처럼 사용되고 있는 지역 변수를 참조할 수 있다.
지역변수를 사용할 때에는 해당 변수에게 값의 재할당이 일어나서는 안된다.
// final int value = 100;
// 값을 통한 초기화 이후에 value의 값은 변경되어서는 안된다.
int value = 100;
// 순수 함수 형태가 아닌 사용 방식.
Lob lob = () -> new Lob(value);
--------------------
예제
@FunctionalInterface
interface Lob {
public abstract void print();
}
//private final int a = 10; 도 동일하게 재정의가 가능하다.
private int a = 10;
public void hello() {
final int b = 20;
int c = 30;
int d = 40;
final Lob lobA = () -> System.out.println(a);
lobA.print();
// a 재정의
a = 20;
final Lob lobB = () -> System.out.println(a);
lobB.print();
final Lob lobC = () -> System.out.println(b);
lobC.print();
final Lob lobD = () -> System.out.println(c);
lobD.print();
final Lob lobE = () -> System.out.println(d);
lobE.print();
}
public static void main(String[] args) {
Lambda lambda = new Lambda();
lambda.hello();
}
왜 지역 변수를 재정의해서 사용할 수 없는가?
이는 지역 변수가 스택 영역에 존재하기에 발생하는 문제점인데, 해당 변수를 초기화하는 스레드가 사라져 변수 할당이 해제된 경우에, Lambda를 실행하고 있는 별도의 스레드가 해당 변수 정보에 접근하려는 경우가 발생할 수 있다.
지역변수는 스레드 간에 공유가 되지 않는다.
자바에서는 람다를 실행하는 스레드의 스택에 지역 변수 할당 시 생성한 변수의 복사본을 저장하여 동작시키게 되는데, 이 값이 변경되었을 경우를 예측할 수 없기에 final 혹은 재할당 방지 제약조건을 걸어둔 것이다.
인스턴스 변수와 정적 변수는 왜 이런 제약조건을 걸지 않았는가?
이는 앞서 말했던 스레드 간의 가시성 문제의 연장선인데, 인스턴스 변수와 정적 변수는 모든 스레드에서 접근 가능한 값이기 때문에, 값의 변경이 이루어져도 직접적으로 접근할 수 있다.
메서드, 생성자 레퍼런스
메소드 참조?
JDK 8에 추가된 기능으로 함수를 메서드의 인자로 전달하는 것을 메서드 참조라고 한다.
해당 방식을 사용함으로써 해당 메서드 시그니처를 여러 곳에서 재사용할 수 있고, 기본적인 제공 메서드와 커스텀한 메서드 모두를 사용할 수 있다는 장점이 있다.
추가적으로 메서드 참조는 람다 표현식을 한번 더 축약적으로 표현할 수 있으며, 그를 통해 가독성을 향상할 수 있다.
람다 표현식을 대체하기보다는 상호 보완적인 관계를 형성한다.
// example
public static void printList(List<String> list, Consumer<String> consumer) {
for (String item : list) {
consumer.accept(item);
}
}
public static void main(String[] args) {
List<String> list = new ArrayList<>();
list.add("1");
list.add("2");
list.add("3");
list.add("4");
// 람다 표현식
printList(list, (item) -> System.out.println(item));
// 메서드 참조
// 한 단계 더 축약된 것을 알 수 있다.
printList(list, System.out::println);
}