본문 바로가기

기술스택/ASM - [Bytecode]

[ASM 바이트코드 작성] if문과 예외를 포함한 메서드 추가 예제(중요)

목차

    예제 코드

    package org.agent.util.asm.testcode.chap03;
    
    import org.objectweb.asm.*;
    
    import static org.objectweb.asm.Opcodes.*;
    
    public class SetF extends ClassVisitor {
        public SetF(ClassVisitor classVisitor) {
            super(Opcodes.ASM9, classVisitor);
        }
    
        @Override
        public MethodVisitor visitMethod(
                final int access,
                final String name,
                final String descriptor,
                final String signature,
                final String[] exceptions) {
            if (cv != null) { //유효성 검사용.
                return cv.visitMethod(access, name, descriptor, signature, exceptions);
            }
    
            return cv.visitMethod(access, name, descriptor, signature, exceptions);
        }
    
        @Override
        public FieldVisitor visitField(int access, String name, String descriptor, String signature, Object value) {
            return cv.visitField(access, name, descriptor, signature, value);
        }
    
        @Override
        public void visitEnd() {
            //1. 필드 추가 ('public int f' 가 있다면 visitField 수행, 아니라면 새로 생성)
            FieldVisitor fv = cv.visitField(ACC_PUBLIC, "f", "I", null, null);
            if (fv != null) {
                fv.visitEnd();
            }
    
            //2. setF 메서드 추가
            MethodVisitor mv = cv.visitMethod(ACC_PUBLIC, "setF", "(I)V", null, null);
            if (mv != null) { //MethodVisitor 객체의 유효성을 검사
                mv.visitCode();             //메서드의 바이트코드 방문을 시작
    
                //if문 체크
                mv.visitVarInsn(ILOAD, 1); // 로컬 변수에서 첫번째 값을 로드 (0은 this)
                Label label = new Label();  // 점프할 레이블 설정
                mv.visitJumpInsn(IFLT, label); //IFLT 체크 (음수일 경우 label로 점프)
    
                //양수일 경우
                mv.visitVarInsn(ALOAD, 0); // 'this' 참조를 로드(자신)
                mv.visitVarInsn(ILOAD, 1); // 다시 첫 번째 파라미터 값을 로드 (입력 파라미터)
                mv.visitFieldInsn(PUTFIELD, "com/dummy/jdbcserver/Chap03", "f", "I"); // 첫 번째 파라미터의 값을 'f' 필드에 저장
                Label end = new Label(); // 메서드의 종료 부분으로 점프할 레이블을 생성 (사실상 else이거나, if문 바깥)
                mv.visitJumpInsn(GOTO, end); // 'end' 레이블로 무조건 점프
    
                //음수일 경우
                mv.visitLabel(label); // 'label' 레이블 위치.
                mv.visitFrame(F_SAME, 0, null, 0, null); // 현재 스택과 로컬 변수의 상태를 JVM에 알림.
                mv.visitTypeInsn(NEW, "java/lang/IllegalArgumentException"); // `IllegalArgumentException` 인스턴스를 생성한다.
                mv.visitInsn(DUP); // 생성자 호출을 위해 인스턴스의 복사본을 스택에 둔다. -> INVOKESPECIAL에서 사용
                mv.visitMethodInsn(INVOKESPECIAL, "java/lang/IllegalArgumentException", "<init>", "()V"); // 예외 객체의 생성자 호출
                mv.visitInsn(ATHROW); // throw Exception.
    
                //End Label
                mv.visitLabel(end); // 'end' 레이블 위치
                mv.visitFrame(F_SAME, 0, null, 0, null); // 현재 스택과 로컬 변수의 상태를 JVM에 알림
                mv.visitInsn(RETURN); // 메서드를 종료
                mv.visitMaxs(2, 2); //스택의 최대 깊이와 변수 최대 수를 설정 (이 코드를 실제 JVM이 읽을 때, 프레임 크기를 몇으로 해야하는지 설정)
                mv.visitEnd();
            }
    
            super.visitEnd();
        }
    }

     

    이 코드를 바이트코드 조작을 통한 결과값으로 본다면 다음과 같다.

    //
    // Source code recreated from a .class file by IntelliJ IDEA
    // (powered by FernFlower decompiler)
    //
    
    package com.dummy.jdbcserver.example_asm;
    
    import org.springframework.context.annotation.Configuration;
    
    @Configuration
    public class Chap03 {
        static int legacyInt;
        public int f; //이 변수가 추가되었음
    
        public Chap03() {
        }
    
        //이 메서드가 바이트코드 조작으로 생성되었음.
        public void setF(int var1) {
            if (var1 >= 0) {
                super.f = var1;
            } else {
                throw new IllegalArgumentException();
            }
        }
    }

     

    원 바이트코드가 어떻게 동작하는지 로직을 하나하나 알아보자.

     

    1. IF문 생성 및 체크

    //if문 체크
    mv.visitVarInsn(ILOAD, 1); // 로컬 변수에서 첫번째 값을 로드 (0은 this)
    Label label = new Label();  // 점프할 레이블 설정
    mv.visitJumpInsn(IFLT, label); //IFLT 체크 (음수일 경우 label로 점프)

     

    1. ILOAD를 통해 로컬 지역 변수에서 첫번째 값(메서드의 첫번째 입력 파라미터)을 스택 메모리에 저장한다.

     

     

    2. IF문 분기를 위해 필요한 레이블을 생성한다. (이 레이블은 이후 '3. 음수일 경우'로 이동하게 하는데 쓰인다.)

    이는 MetaData에 저장된다.

     

    3. IFLT 조건에 따라 일치할 경우 메서드 흐름을 그대로 따라가고, 아닐 경우 2에서 생성한 label의 위치로 이동시킨다.

     

     

    2. 양수일 경우

    //양수일 경우
    mv.visitVarInsn(ALOAD, 0); // 'this' 참조를 로드(자신)
    mv.visitVarInsn(ILOAD, 1); // 다시 첫 번째 파라미터 값을 로드 (입력 파라미터)
    mv.visitFieldInsn(PUTFIELD, "com/dummy/jdbcserver/Chap03", "f", "I"); // 첫 번째 파라미터의 값을 'f' 필드에 저장
    Label end = new Label(); // 메서드의 종료 부분으로 점프할 레이블을 생성 (사실상 else이거나, if문 바깥)
    mv.visitJumpInsn(GOTO, end); // 'end' 레이블로 무조건 점프

     

    1. 먼저 지역 변수 배열에서 0번째 인덱스를 스택 메모리에 저장한다. (ALOAD) 이는 객체형이며, 일반적으로 0번째는 자기 자신(this)의 인스턴스이다.

     

     

     

    2. 이후 ILOAD를 통해 지역 배열에서 1번째 인덱스, Int형 변수를 가져온다. 이는 일반적으로 첫 번째 입력 파라미터( 메서드의 첫 번째 매개변수)를 의미한다. 이 값을 스택 메모리에 넣는다.

     

     

    3. 스택 메모리에서 값을 꺼내서, 'PUFIELD' 연산을 통해 값을 저장한다. 이는 "com/dummy/jdbcserver/Chap03" 클래스 내부의 "f" 라는 이름, "I" 기본형(Int)의 지역 배열이다. 'visitFieldInsn'

     

     

    4. 레이블을 생성한다. 이는 조건 분기용으로 사용한다.

    5. 음수인 로직은 방문하면 안 되기에, GOTO를 통해 end 레이블로 점프한다. 이로 인해 메서드 아래 구문(음수 부분)은 스킵한다.

     

    음수일 경우

    //음수일 경우
    mv.visitLabel(label); // 'label' 레이블 위치.
    mv.visitFrame(F_SAME, 0, null, 0, null); // 현재 스택과 로컬 변수의 상태를 JVM에 알림.
    mv.visitTypeInsn(NEW, "java/lang/IllegalArgumentException"); // `IllegalArgumentException` 인스턴스를 생성한다.
    mv.visitInsn(DUP); // 생성자 호출을 위해 인스턴스의 복사본을 스택에 둔다. -> INVOKESPECIAL에서 사용
    mv.visitMethodInsn(INVOKESPECIAL, "java/lang/IllegalArgumentException", "<init>", "()V"); // 예외 객체의 생성자 호출
    mv.visitInsn(ATHROW); // throw Exception.

     

    1. IFLT 조건에 따라 음수일 경우 이 레이블의 위치로 바로 이동하게 된다.

    2. visitFrame을 통해 현 스택과 로컬 변수 사항을 알린다. (F_SAME의 경우 이전 프레임과 동일하다는 뜻이다.)

     

    상태를 알리는 이유?(VisitFrame)

    이 명령은 실행 흐름에서 중요한 변화가 있을 때(예를 들어 조건부 분기나 예외 처리 블록의 시작 등) JVM에 현재의 실행 프레임 상태를 알리는 데 사용된다.  

    지금의 경우는 label을 통해 jump를 뛰었을 가능성이 있기에, 현재 상태를 JVM에게 알려주는 것이다. (갱신이라고 생각하면 편하다.)

     

    실제 스택 프레임 상태와 JVM이 알고 있는 스택 프레임 상태가 다를 수도 있다는 점을 유의하자.

     

    메서드의 실행 흐름이 복잡하거나 여러 분기를 포함하는 경우, visitFrame 호출은 스택 맵 프레임을 정확하게 유지하는 데 중요하다. 이를 갱신하여 JVM이 에러를 내지 않도록 하는 과정인 것이다. (오류 방지)

     

     

    3. visitTypeInsn을 통해 'NEW' 새로운 객체를 생성하여 피연산자 스택에 저장한다.

    4. visitInsn을 통해 가장 최상단의 스택값(3번에서 생성한 new 예외)을 복사(DUP)한다.

    5. INVOKESPECIAL을 통해 메서드를 호출한다(invokespecial: 생성자, private 메서드, 슈퍼 클래스의 메서드 호출)

    . 이 부분은 아래 포스팅을 참조하면 유용하다.

    https://d2.naver.com/helloworld/1230

    6. 5번에서 호출한 예외를 throw한다.

     

    (자세한 설명)DUP을 통해 왜 스택에 값을 복사하나요? : 객체 참조의 소모 때문!

    이 객체의 복사 및 예외 처리 과정을 다시 명확하게 풀이해보겠다.

     
    1. "mv.visitTypeInsn(NEW, "java/lang/IllegalArgumentException"); 

    `IllegalArgumentException` 인스턴스가 생성된다. 인스턴스는 Heap 메모리에 보관되며, 피연산자 스택 메모리에 이 객체의 참조값이 보관된다. (편의상 A라고 하겠다.)

     

     

    NEW 명령어는 실제 객체를 Heap 메모리에 "만드는" 단계에 해당한다.

    이 시점에서 객체는 메모리에 할당되었지만, 아직 초기화되지 않은 상태이다. 객체의 참조는 생성 직후 피연산자 스택에 푸시된다.


    2. mv.visitInsn(DUP);

    DUP으로 인해 피연산자 스택 메모리의 'A' 참조값이 복사된다(A*라고 하자.)
    이제 스택 메모리의 최상단에는 A*, A가 존재하며, 이 두 참조값은 전부 `IllegalArgumentException` 인스턴스(Heap 메모리에 있는 만들어진 인스턴스)를 바라보고 있다.

     



    3. mv.visitMethodInsn(INVOKESPECIAL, "java/lang/IllegalArgumentException", "<init>", "()V");

    INVOKESPECIAL 을 통해 예외 객체의 생성자를 호출했다. 생성자를 만들어야 객체를 만들 수 있기 때문이다.
    이 과정에서 A* 참조 객체가 피연산자 스택에서 소모된다. A* 참조 객체를 소모하여 Heap에 있는 인스턴스를 통해 새로운 생성자를 만들었다.

     

     

    생성자 메서드는 객체를 초기화하는 과정에 해당한다.

    이 명령어는 피연산자 스택에서 객체 참조를 pop하여, 해당 참조가 가리키는 객체에 대해 생성자 메서드를 실행한다. 이 과정에서 객체의 필드가 초기화되고, 객체가 완전히 사용 가능한 상태가 된다.


    이는 실제 개발 코드로 본다면 'new IllegalArgumentException();' 에 속한다.

    4. mv.visitInsn(ATHROW); 
    3번에서 만들어진 예외 생성자를 기반으로 throw를 실행한다. 이 과정에서 A 참조 객체가 사용된다.
    이제 피연산자 스택 메모리에서 모든 참조 객체가 사용되었다.

     


    5. 이후 추가 사항 
    이 상태로 피연산자 스택이 유지된다면, Heap 메모리에서 만들어진 Exception 인스턴스는 GC에 의해서 정리될 것이다. (연관 참조가 없기 때문)