Lambda는 동작부터 다르다!

Android(또는 Java)에서 Lambda의 컴파일 차이에 대해서 알아보아요!

작성일 2022년 10월 04일

안녕하세요, IMQA 개발팀 노성현입니다.

이번 시간에는 IMQA를 개발하면서 느낀 Android(또는 Java)에서 lambda의 컴파일 차이에 대해서 알아보았습니다.

(기본) Lambda란 무엇일까요?

기본적으로 익명 함수를 더욱 쉽게 화살표 함수로 사용할 수 있는 것을 Lambda라고 부릅니다.

아래 코드를 Lambda 식으로 바꾸면,

new Thread(new Runnable(){
    @Override
    public void run() {
        System.out.println("Thread Run");
    }
}).start();

다음과 같이 간결한 코드로 바뀌겠죠.

new Thread(() -> {
    System.out.println("Thread Run");
}).start();

Lambda는 Java8부터 지원하기 시작했지만 Android의 경우, Java7을 지원할 때에는 외부 Lambda 지원 라이브러리를 이용했는데요.  Android SDK가 Java8을 지원하면서부터 Android 개발자가 이제는 Lambda를 보편적으로 사용합니다.

특히 다음과 같이 OnClickListener와 같은 간단한 이벤트를 구현할 때 많이 사용하죠.

findViewById(R.id.crashBtn).setOnClickListener(view -> {
    startActivity(new Intent(MpmActivity.this, CrashActivity.class));
});

(중급) Lambda와 익명 클래스를 쓸 때 어떤 차이점이 있나요?

당연히 있습니다!

Lambda와 Inner Class의 차이점을 단순히 문법을 지원하는 차이로만 알고 있는 분들이 많이 계시는데요. 아마도 비즈니스를 구현하는 데에 있어 큰 차이점을 못 느끼는 개발자분들이 대다수인 것 같습니다.

하지만 lambda와 inner class의 차이점이 분명 존재한다면, 여러분은 무엇을 의심해 보실 건가요? 저는 this 가 뭔가 다르지 않을까 의심해 봤습니다.

public class Run {
    public void run() {
        new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("Inner" + this.getClass());
            }
        }).start();

        new Thread(() -> {
            System.out.println("Lambda" + this.getClass());
        }).start();
    }
}

다음과 같이 Thread의 Runnable을 익명 클래스와 Lambda로 구현하여 this의 class를 찍어봤습니다. 그리고 다음과 같은 값이 나오는 것을 확인했습니다.

Lambdaclass Run
Innerclass Run$1
<실행결과>

this.getClass로 찍은 값을 보니 Inner에서 찍은 값은 익명 클래스 자신을 가리키지만, Lambda는 선언된 Class를 가리키는 것을 알 수 있었습니다. Shadow Variable이나 다른 차이점도 존재하나 이번 포스트에서는 위의 이슈에 집중하여 다뤄보려 합니다.


(고급) 왜 차이가 나는 것일까요?

차이점이 있다는 것은 실제 동작하는 원리가 다르다는 것이겠죠?
두 개의 코드를 컴파일 하여 비교해 보겠습니다.

public class Run {
    public void run() {
        new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("Inner" + this.getClass());
            }
        }).start();
}

해당 코드를 컴파일 하고 javap로 돌려보면 다음과 같은 바이트 코드가 생성됨을 확인할 수 있습니다.

// class version 61.0 (61)
// access flags 0x21
public class Run {

  // compiled from: Run.java
  NESTMEMBER Run$1
  // access flags 0x0
  INNERCLASS Run$1 null null

  // access flags 0x1
  public <init>()V
   L0
    LINENUMBER 1 L0
    ALOAD 0
    INVOKESPECIAL java/lang/Object.<init> ()V
    RETURN
   L1
    LOCALVARIABLE this LRun; L0 L1 0
    MAXSTACK = 1
    MAXLOCALS = 1

  // access flags 0x1
  public run()V
   L0
    LINENUMBER 3 L0
    NEW java/lang/Thread
    DUP
    NEW Run$1
    DUP
    ALOAD 0
    INVOKESPECIAL Run$1.<init> (LRun;)V
    INVOKESPECIAL java/lang/Thread.<init> (Ljava/lang/Runnable;)V
   L1
    LINENUMBER 8 L1
    INVOKEVIRTUAL java/lang/Thread.start ()V
   L2
    LINENUMBER 13 L2
    RETURN
   L3
    LOCALVARIABLE this LRun; L0 L3 0
    MAXSTACK = 5
    MAXLOCALS = 1
}

Android나 Java 개발을 오랫동안 했어도 컴파일된 class 파일을 이렇게 바이트코드로 볼 경험이 많지 않기 때문에 생소할 수 있습니다. 보이는 것만 우선 잘 찾아보겠습니다.

코드 중간에

public run()V

이 있는 걸 보니 이곳이 우리가 선언한 run이라는 메소드인거 같은데요.

 INVOKESPECIAL java/lang/Thread.<init> (Ljava/lang/Runnable;)V

또, 중간에 이 코드가 있는 것을 보니 우리가 Thread에 Runnable을 선언하는 부분인 것 같습니다.

우리가 호출한 System.out.println(”inner” + Class.getName()) 코드는 어디로 갔을까요? 중간에 Run$1로 되어 있는 것을 보니 익명 클래스는 $가 붙으면서 새로운 class 파일로 빌드 되어 다른 파일에 있나 봅니다.

자, 그럼 Lambda로 된 코드는 어떨까요?

public class Run {
    public void run() {
        new Thread(() -> {
            System.out.println("Lambda" + this.getClass());
        }).start();
    }
}

해당 파일을 컴파일해서 javap를 하면 어떻게 될까요?

// class version 61.0 (61)
// access flags 0x21
public class Run {

  // compiled from: Run.java
  // access flags 0x19
  public final static INNERCLASS java/lang/invoke/MethodHandles$Lookup java/lang/invoke/MethodHandles Lookup

  // access flags 0x1
  public <init>()V
   L0
    LINENUMBER 1 L0
    ALOAD 0
    INVOKESPECIAL java/lang/Object.<init> ()V
    RETURN
   L1
    LOCALVARIABLE this LRun; L0 L1 0
    MAXSTACK = 1
    MAXLOCALS = 1

  // access flags 0x1
  public run()V
   L0
    LINENUMBER 10 L0
    NEW java/lang/Thread
    DUP
    ALOAD 0
    INVOKEDYNAMIC run(LRun;)Ljava/lang/Runnable; [
      // handle kind 0x6 : INVOKESTATIC
      java/lang/invoke/LambdaMetafactory.metafactory(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;
      // arguments:
      ()V, 
      // handle kind 0x5 : INVOKEVIRTUAL
      Run.lambda$run$0()V, 
      ()V
    ]
    INVOKESPECIAL java/lang/Thread.<init> (Ljava/lang/Runnable;)V
   L1
    LINENUMBER 12 L1
    INVOKEVIRTUAL java/lang/Thread.start ()V
   L2
    LINENUMBER 13 L2
    RETURN
   L3
    LOCALVARIABLE this LRun; L0 L3 0
    MAXSTACK = 3
    MAXLOCALS = 1

  // access flags 0x1002
  private synthetic lambda$run$0()V
   L0
    LINENUMBER 11 L0
    GETSTATIC java/lang/System.out : Ljava/io/PrintStream;
    ALOAD 0
    INVOKEVIRTUAL java/lang/Object.getClass ()Ljava/lang/Class;
    INVOKEDYNAMIC makeConcatWithConstants(Ljava/lang/Class;)Ljava/lang/String; [
      // handle kind 0x6 : INVOKESTATIC
      java/lang/invoke/StringConcatFactory.makeConcatWithConstants(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/String;[Ljava/lang/Object;)Ljava/lang/invoke/CallSite;
      // arguments:
      "Lambda\\u0001"
    ]
    INVOKEVIRTUAL java/io/PrintStream.println (Ljava/lang/String;)V
   L1
    LINENUMBER 12 L1
    RETURN
   L2
    LOCALVARIABLE this LRun; L0 L2 0
    MAXSTACK = 2
    MAXLOCALS = 1
}

뭔가 많이 바뀌었죠. 우선 눈에 띄는 몇 가지부터 살펴보죠.

private synthetic lambda$run$0()V 라는 코드가 보이네요.

그 코드를 쫓아가 보니  GETSTATIC java/lang/System.out : PrintStream도 보이고

// arguments:
"Lambda\\u0001"

라는 String 값도 보입니다.
바로 아래는 INVOKEVIRTUAL java/io/PrintStream.println (Ljava/lang/String;)V 라는 코드도 보이죠?

위에 3가지 정보를 조합해 보면,

System.out.println("Lambda\\u0001"

정도의 코드를 실행시키는 코드로 보입니다.

이렇게 정의 되어 있는 lambda$run$0를 사용하는 곳을 확인하니 다음과 같이 기존에 익명 클래스로 했을 때와 다른 호출 코드가 컴파일되어 있던 것을 볼 수 있습니다.

INVOKEDYNAMIC run(LRun;)Ljava/lang/Runnable; [
      // handle kind 0x6 : INVOKESTATIC
      java/lang/invoke/LambdaMetafactory.metafactory(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;
      // arguments:
      ()V, 
      // handle kind 0x5 : INVOKEVIRTUAL
      Run.lambda$run$0()V, 
      ()V
    ]
    INVOKESPECIAL java/lang/Thread.<init> (Ljava/lang/Runnable;)V

이러한 내용을 보았을 때 Lambda와 익명 클래스가 컴파일된 내용이 엄연히 다르다는 것을 알 수 있는데요. Lambda의 동작원리는 다른 포스트에서 더욱 자세히 알아보도록 하겠습니다.


IMQA에서 겪은 Lambda 이슈

IMQA Android SDK는 BCI(Byte Code instrumentation) 기술을 사용하여 gradle의 컴파일 시점에 관여하여 코드를 조작하고 자동으로 프로파일링 코드를 삽입하는데요. 개발자가 쉽게 SDK 연동을 할 수 있도록 IMQA Injector라는 gradle plugin을 제공하고 있습니다.

이때 IMQA SDK팀은 위와 같은 lambda 형태의 가벼운 EventListener 개발 시에 Injector가 적용되지 않는다는 것을 확인하였고, ASM을 이용한 Lambda 지원을 하는 Injector 버전을 신규 패치하였습니다. IMQA에서 사용하는 바이트코드 조작 기술에 대한 내용도 곧 소개하겠습니다.

저는 Java로 서버 개발과  Android 개발을 주로 하는데요. 그렇기 때문에 Java에 대한 트렌드를 열심히 쫓아가고 있습니다. 또 서버 개발에서의 Stream과 Lambda를 활용한 함수형 프로그래밍에 푹 빠져있죠. 거기에 Spring WebFlux와 같은 개발을 하다 보니 더욱더 많은 Lambda 코드를 생산하고 있는것 같습니다.

여러분들은 Android 개발에서 Lambda를 얼마나 활용하고 계시나요? 간단한 UITread나 EventListener에는 Lambda를 사용하고 있지 않으신가요?
그럼, Lambda 코드를 쓰게 되면 동작이 바뀌는 것도 모두 알고 계셨나요?

오늘은 Lambda가 컴파일된 class 파일을 가지고 차이점을 확인해 보았습니다.
컴파일된 class 파일을 리버싱하여 Java 코드로 보시는 건 해도,  class 파일을 직접 보는 건 많이 생소해서 어려울 수 있는데요. 결론적으로 익명 클래스와  Lambda 코드는 동작이 다르다는 것만 알고 가시면 좋을 것 같습니다.

여러분들의 build 폴더에는 여러분들의 소스코드가 컴파일되어 있으니 bytecode 한번 구경해 보시길 바랍니다.

참고자료

Share on

Tags

IMQA 뉴스레터 구독하기

국내외 다양한 기술 소식을 선별하여 매월 전달해드립니다. IMQA 뉴스레터를 통해 기술 이야기를 함께해보세요.

구독하기