잡동사니

자바 추천 강좌, 더 자바, 코드를 조작하는 다양한 방법 정리 및 후기 본문

IT/Java

자바 추천 강좌, 더 자바, 코드를 조작하는 다양한 방법 정리 및 후기

yeTi 2020. 1. 17. 16:51

인프런더 자바, 코드를 조작하는 다양한 방법 - 백기선을 수강하면서 내용을 정리하고 간단한 후기를 남깁니다.

JVM 이해하기

JVM, JRE, JDK 그리고 자바

JVM은 자바 가상 머신(Java Virtual Machine)의 약자로 자바 바이트 코드(.class 파일)를 OS에 특화된 코드로 변환(인터프리터와 JIT 컴파일러)하여 실행한다. 때문에 특정 플랫폼에 종속적이다.

JVM자체는 바이트 코드를 실행하는 표준이자 특정 벤더가 구현한 구현체(JVM 벤더 : 오라클, 아마존, Azul 등)이다.

JRE는 자바 실행 환경(Java Runtime Environment)의 약자로 자바 어플리케이션을 실행할 수 있도록 구성된 배포판이다.

JVM과 핵심 라이브러리 및 프로퍼티, 리소스를 가지고 있다. (JVM + 라이브러리)

JDK는 자바 개발 환경(Java Development Kit)의 약자로 JRE에 개발에 필요한 툴이 추가된 배포판이다. 오라클은 자바 11부터는 JDK만 제공하고 JRE는 따로 제공하지 않는다.

자바프로그래밍 언어로 JDK에 들어있는 자바 컴파일러(javac)를 사용하여 바이트코드로 컴파일할 수 있다.

JVM은 OS에 특화된 코드로 변환하기 때문에 플랫폼에 종속적인데 반하여 자바는 바이트코드로 변환되는 대상이기 때문에 플랫폼에 독립적이다.

자바의 유료화에 대해서는 오라클에서 만든 Oracle JDK 11 버전부터 상용으로 사용할 때 유료라는 의미이다.

그 밖에 클로저, 그루비, JRuby, Jython, Kotlin, Scala 등은 바이트 코드로 변환하여 JVM 기반으로 실행할 수 있다.

JVM

JVM은 5가지 컴포넌트로 구성되어 있다.

  • 클래스 로더 시스템
  • 메모리
  • 실행 엔진
  • 네이티브 메소드 인터페이스(JNI)
  • 네이티브 메소드 라이브러리

해당 컴포넌트들은 클래스나 객체를 읽어서 메모리에 올리고 실행하는 과정을 수행한다.

클래스 로더 시스템은 .class파일에서 바이트코드를 읽어 메모리에 저장하는 시스템으로 아래 3가지 과정을 수행한다.

  • 로딩(Loading) : 클래스를 읽어오는 과정
  • 링크(Linking) : 레퍼런스를 연결하는 과정
  • 초기화(Initialization) : static 값들을 초기화하고 변수를 할당하는 과정

메모리를 사용하는 영역은 6가지로 구분되는데, 스택, PC, 네이티브 메소드 스택은 쓰레드별로 사용하는 영역이고 힙과 메소드 영역, Run-time Constant Pool은 공유 자원이다.

  • 스택(Stack) : 쓰레드마다 런타임 스택을 만들고 그 안에 스택 프레임이라고 부르는 메소드 호출을 블럭으로 쌓는다. 쓰레드를 종료하면 런타임 스택도 사라진다.
  • PC(Program counter registers) : 쓰레드마다 쓰레드 내 현재 실행할 스택 프레임을 가리키는 포인터가 생성된다.
  • 네이티브 메소드 스택(Native method stack) : Native method에 대한 스택을 저장한다.
  • 힙(Heap) : Instance를 저장하는 공유자원이다.
  • 메소드(Method) : 클래스 수준의 정보(클래스 이름, 부모 클래스 이름, 메소드, 변수)를 저장하는 영역고 공유자원이다. static으로 선언된 것들도 이 영역에 저장된다.
  • Runtime Constant Pool : 클래스나 인터페이스가 만들어질 때 메소드 영역에 생기며 각 클래스나 인터페이스마다 Constant pool table을 가지고 있다.

실행 엔진은 아래의 3가지 컴포넌트로 구성된다.

  • 인터프리터(Interpreter) : 바이트코드를 한줄씩 실행한다.
  • JIT 컴파일러 : 인터프리터의 효율을 높이기 위해 인터프리터가 반복되는 코드를 발견하면 JIT 컴파일러로 네이티브 코드로 바꾼 후 컴파일된 네이티브 코드를 사용한다.
  • GC(Garbage Collector) : 더이상 참조하지 않는 객체를 모아서 정리한다. Stop-the-world CollectorThroughput Collector가 있다.

네이티브 메소드 인터페이스(JNI)는 자바 애플리케이션에서 C, C++, 어셈블리로 작성된 함수를 사용하도록 제공하는 인터페이스고 코드상에서 native 키워드를 사용한 메소드를 호출한다.

네이티브 메소드 라이브러리는 C, C++로 작성 된 라이브러리를 말한다.

클래스 로더

클래스 로더는 .class파일에서 바이트코드를 읽어 메모리에 저장하는 시스템으로 로딩(Loading), 링크(Linking), 초기화(Initialization) 과정을 수행한다.

로딩은 .class파일을 읽어 적절한 바이너리 데이터를 만들어 메모리의 메소드 영역에 저장하는 과정인데, 3가지 클래스 로더를 제공한다.

  • Bootstrap class loader : JAVA_HOME/lib에 있는 코어 자바 API를 제공한다. 최상위 우선순위를 가지는 클래스 로더이다.
  • Extension(Platform) class loader : JAVA_HOME/lib/ext 폴더 또는 java.ext.dirs 시스템 변수에 해당하는 위치에 있는 클래스를 읽는다.
  • Application class loader : 어플리케이션 클래스 경로(어플리케이션 실행시 주는 -classpath 옵션 또는 java.class.path 환경변수의 값에 해당하는 위치)에서 클래스는 읽는다.

클래스를 로드할 때 Bootstrap class loader부터 로드의 역할을 수행하는데 Application class loader까지도 클래스 정보를 찾지 못하면 ClassNotFoundException이 발생한다.

메소드 영역에 저장하는 데이터는 다음과 같다.

  • FQCN(Fully Qualified Class Name, 클래스가 속한 패키지명을 모두 포함한 이름)
  • 클래스, 인터페이스, 이넘
  • 메소드, 변수

로딩이 끝나면 Class<User>와 같은 클래스 객체를 생성하여 힙 영역에 저장한다.

링크는 레퍼런스를 연결하는 과정으로 다음 3가지 절차를 수행한다.

  • Verify : .class파일의 형식이 유효한지 확인한다.
  • Prepare : 클래스 변수(static 변수)와 기본값에 필요한 메모리 영역을 준비한다.
  • Reoslve : Optional로 동작하는 부분이고, 심볼릭 메모리 레퍼런스를 메소드 영역에 있는 실제 레퍼런스로 교체한다.

초기화는 static변수의 값을 할당하는 과정이고 static 블럭이 수행된다.

바이트코드 조작

소스코드의 커버리지를 바이트코드의 조작으로 측정 가능하다. (JaCoCo와 같은 라이브러리 활용)

다음 라이브러리들을 사용하여 바이트코드의 조작을 할 수 있다.

바이트코드를 조작하는 방식은 다양하게 할 수 있다.

  1. .class 파일의 내용을 변경하여 조작할 수 있다. 이렇게 할 경우에는 Instance를 로드할 때 이미 기존 바이트코드의 정보가 메모리에 올라가므로 .class 파일의 바이트코드를 변경 후 동작하는 소스를 실행도록 해야하는 불편함이 있다.
  2. 클래스를 로딩할 때 바이트코드를 변경하여 메모리에 올리는 방식으로도 조작할 수 있다. 이 방식은 코드의 순서에 종속되기 때문에 다른 클래스에서 이미 해당 인스턴스를 메모리에 올렸을 경우에는 바이트코드를 변경하지 않고 올라갈 수 있는 문제점이 있다.
  3. javaagent를 활용하여 클래스 로드시 바이트코드를 변경하여 메모리에 올리도록 할 수 있다.

바이트코드의 조작으로 활용할 수 있는 예시는 다음과 같다.

  • 프로그램 분석
    • 코드에서 버그를 찾는 툴
    • 코드 복잡도 개선
  • 클래스 파일 생성
    • 프록시
    • 특정 API 호출 접근 제한
    • 스칼라 같은 언어의 컴파일러
  • 자바 소스코드를 건드리지 않고 코드 변경이 필요한 경우
    • 프로파일러
    • 최적화
    • 로깅

스프링의 경우에는 컴포넌트 스캔시 바이트코드를 활용한다.

  • 컴포넌트 스캔으로 빈으로 등록할 후보의 클래스 정보를 찾는데 활용
  • ClassPathScanningCandidateComponentProvider -> SimpleMetadataReader
  • ClassReader와 Visitor를 사용해서 클래스에 있는 메타정보를 읽어온다.

리플렉션

리플렉션이란?
Java의 Reflection 가이드

Java에서는 리플렉션을 Class<?> 인스턴스로 접근 가능하다.

Class<?> 인스턴스를 사용할 수 있는 방법은 아래와 같다.

Class<Book> bookClass = Book.class;
Book book = new Book();
Class<? extends Book> aClass = book.getClass();
Class<?> bClass = Class.forName("me.whiteship.Book");

어노테이션도 리플렉션으로 조회가 가능하다.

다음과 같이 어노테이션을 정의할 수 있는데

@Rentention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.FIELD})
@Inherited
public @interface MyAnnotaion {
}

각각의 의미는 다음과 같다.

  • @Rentention(RetentionPolicy.RUNTIME) : .class 파일에 어노테이션 정보가 들어가고 메모리에도 추가된다.
  • @Target({ElementType.TYPE, ElementType.FIELD}) : 어노테이션을 사용할 수 있는 곳은 클래스와 필드이다.
  • @Inherited : 클래스의 상속관계에서 어노테이션도 상속한다.

리플렉션하여 인스턴스를 생성하거나 클래스 정보를 수정, 실행할 수 있다. 이를 응용하여 DI(Dependency Injection)을 구현해 볼 수 있다.

DI 샘플 코드

public class IocContainerService {
    public static <T> T getObject(Class<T> classType) {
        T instance = createInstance(classType);
        Arrays.stream(classType.getDeclaredFiends()).forEach(
            if(f.getAnnotation(Inject.class) != null) {
                Object fieldInstance = createInstance(f.getType());
                f.setAccessible(true);
                try {
                    f.set(instance, fieldInstance);
                } catch(IllegalAccessException e) {
                    throw new RuntimeException(e);
                }
            }
        );

        return instance;
    }

    private static <T> T createInstance(Class<T> classType) {
        try {
            return classType.getConstructor(null).newInstance();
        } catch (InstantiationException | IllegalAccessException | InvocationTargetException | NoSuchMethodException) {
            throw new RuntimeException(e);
        }
    }
}

리플렉션할 때 다음과 같은 사항을 유의해야 한다.

  • 지나친 사용은 성능 이슈를 야기할 수 있다. 반드시 필요한 경우에만 사용하자.
  • 컴파일 타임에는 확인되지 않고 런타임 시에만 발생하는 문제를 만들 가능성이 있다.
  • 접근 지시자를 무시할 수 있다.

리플렉션을 활용하는 경우는 다음과 같다.

  • 스프링
    • 의존성 주입
    • MVC 뷰에서 넘어온 데이터를 객체에 바인딩 할 때
  • 하이버네이트
    • @Entity 클래스에 Setter가 없으면 리플렉션을 활용하여 값을 설정한다.
  • JUnit

다이나믹 프록시

스프링 데이터 JPA는 어떻게 동작하는가?

Repository를 생성할 때 interface로 정의하고 어노테이션도 달아주지 않는데도 instance가 생성되어 사용할 수 있다.

스프링 데이터 JPA는 Spring AOP를 기반으로 동작하며 RepositoryFactorySupport에서 프록시를 생성한다.

프록시 패턴

프록시 패턴이란? 실제 인스턴스의 코드에는 변경을 가하지 않으면서 동작을 변경할 수 있는 패턴이다.

이는 실제 인스턴스와 인터페이스를 공유하는 프록시 인스턴스를 생성하여 추가적인 동작 및 실제 동작을 위임할 수 있다.

클라이언트 입장에서도 코드가 변경되지 않으면서 기능의 변경이 가능하다.

다이나믹 프록시

다이나믹 프록시란? 런타임에 특정 인터페이스들을 구현하는 클래스 또는 인스턴스를 만드는 기술이다.

Java의 Dynamic Proxy 가이드

프록시 패턴을 직접 구현하는 경우에 프록시 클래스를 직접 구현해야하고 코드량이 많아지는 문제점이 있다.

이에 자바에서 Proxy 클래스를 제공하고 있고 이를 사용하면 런타임시에 동적으로 기능을 추가할 수 있다.

BookService bookService = (BookService) Proxy.newProxyInstance(BookService.class.getClassLoader(), new Class[]{BookService.class}, 
    new InvocationHandler() {
        BookService bookService = new DefaultBookService();
        @Override
        public Object invoke(Object proxy, Method, method, Object[] args) throws Throwable {
            if (method.getName().equals("rent")) {
                System.out.println("aaaa");
                Object invoke = method.invoke(bookService, args);
                System.out.println("bbbb");
                return invoke;
            }

            return method.invoke(bookService, args);
        }
    });

해당 방식의 제약 사항은 인터페이스를 통해서만 Proxy를 생성할 수 있는 것이다.

클래스의 프록시가 필요하다면?

클래스의 프록시가 필요한 경우에는 이전 프록시 패턴에서 언급했던 거와 같이 서브 클래스를 구현함으로써 사용할 수 있고 사용할 수 있는 라이브러리는 아래와 같이 두가지가 있다.

  • CGlib 라이브러리를 활용하는 방법
  • ByteBuddy를 활용하는 방법

CGlib

스프링과 하이버네이트가 사용하는 라이브러리이다.

MethodInterceptor handler = new MethodInterceptor() {
    BookService bookService = new DefaultBookService();
    @Override
    public Object intercept(Object interceptor, Method, method, Object[] args, MethodProxy methodProxy) throws Throwable {
            if (method.getName().equals("rent")) {
                System.out.println("aaaa");
                Object invoke = method.invoke(bookService, args);
                System.out.println("bbbb");
                return invoke;
            }

            return method.invoke(bookService, args);
        }
    });

BookService bookService = (BookService) Enhancer.create(BookService.class, handler);

ByteBuddy

바이트 코드의 조작 뿐만이 아니라 런타임(다이나믹) 프록시를 만들 때도 사용할 수 있다.

Class<? extends BookService> proxyClass = new ByteByddy().subclass(BookService.class)
    .method(named("rent")).intercept(IncovationHandlerAdapter.of(new InvocationHandler() {
        BookService bookService = new BookService();
        @Override
        public Object invoke(Object proxy, Method, method, Object[] args) throws Throwable {
            System.out.println("aaaa");
            Object invoke = method.invoke(bookService, args);
            System.out.println("bbbb");
            return invoke;
        }
    }))
    .make().load(BookService.class.getclassLoader()).getLoaded();

BookService bookService = proxyClass.getConstructor(null).newInstance();

서브 클래스를 만드는 방법의 단점

상속을 사용하지 못하는 경우 프록시를 만들 수 없다.

  • Private 생성자만 있는 경우
  • Final 클래스인 경우

인터페이스가 있을 때는 인터페이스의 프록시를 만들어서 사용하는것을 추천한다.

애노테이션 프로세서

롬복(Project Lombok)은 어떻게 동작할까?

컴파일 시점에 어노테이션 프로세서를 사용하여 소스코드의 AST(Abstract Symtax Tree)를 조작한다.

Java의 Processor Interface 가이드

어노테이션 프로세서

어노테이션 프로세싱은 컴파일 시간에 어노테이션들을 스캐닝하고 프로세싱하는 javac로 작성된 툴이다.

어노테이션 프로세서란? 어노테이션 프로세싱을 하기 위한 주체이다. 따라서 어노테이션 프로세서를 구현함으로써 컴파일 타임에 추가적인 동작을 정의할 수 있다.

AbstractProcessor를 상속받아서 어노테이션 프로세서를 구현할 수 있다.

public class MagicMojaProcessor extends AbstractProcessor {
    @Override
    public boolean process(Set<? extends TypeElement> annotaions, RoundEnvironment roundEnv) {
        return true;
    }
}

구현한 어노테이션 프로세서를 등록하기 위해서 먼저 .class파일을 생성 후 해야하는 불편함이 있는데 이는 AutoService를 사용하여 해소할 수 있다.

@AutoService(Processor.class)
public class MagicMojaProcessor extends AbstractProcessor {
}

AutoService가 제공하는 기술은 서비스 프로바이더와 관련된 내용이다.

어노테이션 프로세서에서 클래서 파일 생성하기

Javapoet을 활용하면 자바 파일을 쉽게 생성할 수 있다.

public class MagicMojaProcessor extends AbstractProcessor {
    @Override
    public boolean process(Set<? extends TypeElement> annotaions, RoundEnvironment roundEnv) {

        TypeElement typeElement = (TypeElement)element;
        ClassName className = ClassName.get(typeElement);

        MethodSpec pullOut = MethodSpec.methodBuilder("pullOut")
            .addModifiers(Modifier.PUBLIC)
            .returns(String.class)
            .addStatement("return $S", "Rabbit!")
            .build();
        a
        TypeSpec magicMoja = TypeSpec.classBuilder("MagicMoja")
            .addModifiers(Modifier.PUBLIC)
            .addSuperinterface(className)
            .addMethod(pullOut)
            .build();

        Filer filer = processingEnv.getFiler();
        try {
            JavaFile.builder(className.packageName(), magicMoja)
                .build()
                .writeTo(filer);
        } catch (IOException e) {
            processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR, "FATAL ERROR: " + e);
        }

        return true;
    }
}

후기

강의를 수강한 배경은 자바의 다이나믹 프록시에 대해서 이해하고자 수강하게 됐다.

하지만 다이나믹 프록시와 강의의 제목인 코드를 조작한다는 의미가 뭘까 하는 의구심이 들었는데 강의를 듣고 나니 기존 코드에 변경없이 기능을 추가하는 방법에 대해 말하고자 했다는 것을 알 수 있었다.

Spring이나 JPA에서 말하는 객체를 프록시 한다는 의미에 대해 배울 수 있는 좋은 기회였던거 같고

자바나 Spring을 한층 더 이해하고 싶으신 분들에게 추천하고 싶은 강의입니다.

Comments