728x90


자바를 어느정도 사용하다보면 드디어 도달하게 되는 분야가 있다.

바로 클래스 자체를 건드리는 방법이다.

여기서 말하는 클래스는 .class파일을 의미한다.

여러분도 모두 알다시피 모든 .java파일은 .class로 번역된다.

그래서 .class를 jvm이 읽어들이는 방식으로 자바는 동작한다.




자바는 객체지향이고 대부분의 데이터는 객체로 이루어져있다.

String도 객체고 Object도 객체고 System도 객체이다.

그런데 여러분은 "이미 만든 객체에 내 코드를 삽입하고 싶다."는 욕망이 꿈틀댄 적이 있을 것이다.

필자도 그런적이 있었다. 가령 Position클래스를 남이 만들어 뒀다고 가정하자.

클래스의 생성자를 호출할 때마다 나는 "Position 생성자 호출"이라는 글자를 화면에 출력하고 싶다고 가정해보자.

그러면 여러분들은 어떻게 해야하는가? 당장 떠오르는 방법은 이 파일의 소스파일을 찾아서 재컴파일을 한후 기존 파일을 대체해서 사용하는 것이다.

하지만 이 방식에는 아주아주아주 치명적인 문제점이 있다.



그러면 원본은 그대로 두면서 우리가 만든 코드를 삽입할 수 있는 기술이 필요하게 된다.

다른 대안이 과연 없는 것일까?

여기서 우리는 힌트를 얻을 수 있다.

만약 이미 있는 자바 파일을 변경한다면 두가지 방식을 취할 수 있다.


1.실행 시간 중 .java파일을 읽어서 변경한다.

2.실행 시간 중 .class파일을 읽어서 변경한다.

1번 방식은 사용하기 까다롭다. 그 이유는 사람마다 만드는 코드가 다르기 때문이다.

즉 컴파일러 수준의 작성이 필요하다. 그래서 현실적으로 2번 방식을 택하게 된다.

그래서 우리는 이 .class파일을 읽어서 변경하게 된다면 기존의 클래스에 코드를 삽입하지 않고 변경할 수 있게 된다. 




여기서 변경한다는 말을 하였지만 사실 원본을 변경하는건 아니고(물론 그렇게도 할 순있지만)

원본에 덧붙힌 새로운 파일을 만들어 내게 된다.

이 방식은 여러가지 장점을 만들어 낸다.


1.원본을 수정하지 않으므로 유연한코딩이 가능하다.

2.원본이 오픈소스건 아니건 상관없이 코드를 삽입 할 수 있다.

3.내가 원하는 .class에 일괄 삽입이 가능하다.


이러한 기술의 이름을 BCI(Byte Code Injection)이라고 부른다. 한국말로 번역하면 바이트코드 삽입이 되겠다.

이는 AspectJ나 Spring의 AOP에서 등장하는 개념이다. 즉 Spring의 AOP는 사실 BCI로 만든 것이다.


이러한 BCI를 지원하는 라이브러리는 여러 종류가 있지만 여기서는 asm만 논하겠다.

다른 라이브러리가 필요하다면 알아서 조사해서 사용하면 될것이다.


http://asm.ow2.org


asm을 사용하기 위해서는 일단은 두가지의 라이브러리가 필요하다 해당 레포지터리로 가서 두가지의 jar를 받자.


사진속의 jar파일중 asm-all과 asm을 받으면된다.



이제 예제를 위해서 우리의 목표를 가정해 보자.

여러분이 특정 클래스를 생성했을 때 그 클래스의 toString을 호출해서 생성을 확인하는 디버그를 자주할 것이다.

그러한 상황을 재현하기 위해서 문자열의 생성자가 호출될 때마다 어떤 문자열을 생성했는지 출력하자.


목표 : String클래스의 생성자와 toString을 변경하자.


일단 예제를 하기에 앞서서 asm의 사용할 클래스들 중 가장 중요한 녀석들 4가지를 미리 보고 넘어가겠다.


ClassReader - 외부의 .class를 읽어오는 클래스이다.


ClassVisitor - .class를 방문하는 녀석으로 ClassAdapter와 ClassWriter를 상속하는 interface 였다. 추후 버전에서 Adapter와 합쳐졌다. .class의 정보를 가진 객체라고 생각하면 타당하다.(엄밀히말하면 .class에 침입해서 정보를 빼오는 거지만)


ClassAdapter - 우리가 .class파일을 변경한다. 여기서 adapter(class)는 visitor(interface)를 상속받아서 만드는데 원래도 깡으로 visitor를 쓰는 일은 거의 없었고 adapter를 반드시 상속받아서 썼었다. 그래서 그런지 3.3.1버전 이후로는 Adapter가 Visitor에 포함됬다. 그래서 4버전 이후로의 visitor는 abstract class이다.


ClassWriter - .class를 쓴다. 바로 파일로 쓰는 메소드가 없고 바이트 배열로 변형시켜주는데 이를 이용해서 추후에 FileOutputStream일 이용해서 쓰게된다.


이정도 알았으면 사실 거의 다 알았다고 보면 된다.

이제 예제를 보도록합시다.


StringTransformer

package com.company;

import org.objectweb.asm.ClassAdapter;
import org.objectweb.asm.ClassReader;
import org.objectweb.asm.ClassWriter;
import org.objectweb.asm.Opcodes;

import java.io.FileOutputStream;
import java.io.IOException;

public class StringTransformer implements Opcodes {
public void transform(String className) throws IOException {
ClassReader reader = new ClassReader("java.lang." + className);
ClassWriter writer = new ClassWriter(ClassWriter.COMPUTE_MAXS);
ClassAdapter adapter = new StringClassAdapter(writer);
reader.accept(adapter, ClassReader.SKIP_FRAMES);

byte[] b = writer.toByteArray();
FileOutputStream fos = new FileOutputStream(className + ".class");
fos.write(b);
fos.flush();
}

public static void main(String[] args) {
try {
String className = "String";
StringTransformer fit = new StringTransformer();
fit.transform(className);
} catch (Exception ex) {
ex.printStackTrace();
}
}
}

먼저 StringTransformer이다. 굳이 이름은 String이라고 할 필요는 없다.

다만 이번에는 String클래스를 변형해볼거기 때문에 이름에 String을 넣은 것이다.

부분 부분을 잘라서 한번 보도록 하자.


public void transform(String className) throws IOException {
ClassReader reader = new ClassReader("java.lang." + className);
ClassWriter writer = new ClassWriter(ClassWriter.COMPUTE_MAXS);
ClassAdapter adapter = new StringClassAdapter(writer);
reader.accept(adapter, ClassReader.SKIP_FRAMES);

byte[] b = writer.toByteArray();
FileOutputStream fos = new FileOutputStream(className + ".class");
fos.write(b);
fos.flush();
}

아까도 말했지만 실제로는 여러분이 단 3가지만 알면된다.


ClassReader로 클래스를 읽는다.

여기서 생성자로 호출하는데 인자로 여러분이 변경시킬 클래스의 이름(패키지명포함, .class는 적지 않는다.)을 적는다.

ClassWriter로 .class를 쓰는 클래스를 만든다.

인자로는 COMPUTE_MAXS를 써준다.

사실 인자는 두종류가 있다. COMPUTE_MAXS와 COMPUTE_FRAMES가 있다.

뭐 간단히 MAXS와 FRAMES의 차이는 스택프레임을 계산하느냐 안하느냐의 차이인데 여러분이 크게 고려할 인자는 아니다. 그냥 MAXS를 쓰자.


Adapter는 조금 다르다. 이미 있는걸 쓰지 않고 여러분은 상속받아서 클래스를 따로만들어줘야한다.

그래서 위의 StringClassAdapter는 기존에 제공하는게 아니고 여러분이 만들어야한다.


/*구버전*/
ClassAdapter adapter = new StringClassAdapter(writer);
/*신버전*/
ClassVisitor adapter = new StringClassAdapter(writer);

그리고 Adapter를 선언하는 방식은 구버전과 신버전이 다르다.

누누히 말하지만 신버전에서는 adapter가 visitor에 흡수됬고 adapter라는 개념 자체가 없다.


이제 설명할 부분은 다 설명한 것 같다.


StringClassAdapter


package com.company;

import org.objectweb.asm.ClassAdapter;
import org.objectweb.asm.ClassVisitor;
import org.objectweb.asm.MethodVisitor;

public class StringClassAdapter extends ClassAdapter {
public StringClassAdapter(ClassVisitor visitor) {
super(visitor);
}

public MethodVisitor visitMethod(int access, String name, String desc, String sig, String[] exes) {
MethodVisitor mv = super.visitMethod(access, name, desc, sig, exes);

System.out.println("StringClassAdapter.visitMethod");
System.out.println("access = [" + access + "], name = [" + name + "], desc = [" + desc + "]," +
"sig = [" + sig + "], exes = [" + exes + "]");

return mv;

}
}

그 다음 만들 것은 StringClassAdapter이다. 위에서 언급했듯이 String이 된건 여러분이 String클래스를 수정할거기 때문에 그런것이다.

그러므로 다른 클래스를 수정한다면 굳이 이 이름을 할 필요는 없다.


visitMethod는 이미 정의된 메소드이므로 오버라이딩이다.

이 메소드는 여러분이 가지고 있는 여러분이 대상으로 하는 .class의 메소드 하나하나를 대상으로 모두 실행되게 된다.



public class StringClassAdapter extends ClassVisitor implements Opcodes {
private static int opcode = Opcodes.ASM7;

public StringClassAdapter(ClassVisitor visitor) {
super(opcode, visitor);

}

public MethodVisitor visitMethod(int access, String name, String desc, String sig, String[] exes) {
MethodVisitor mv = super.visitMethod(access, name, desc, sig, exes);

System.out.println("StringClassAdapter.visitMethod");
System.out.println("access = [" + access + "], name = [" + name + "], desc = [" + desc + "]," +
" sig = [" + sig + "], exes = [" + exes + "]");

return mv;

}
}

만약 신버전이라면 Adapter가 없기 때문에 Visitor를 상속받아야한다.

또한 인자가 하나늘어나는데 api버전을 추가로 받는다. 

만약 여러분이 api버전 7을 사용하고싶다면 인자에 ASM7을 넘겨주면된다.


위의 코드를 실행해보자.



해당 클래스를 읽어올때 visitor가 방문해서 각각의 메소드마다 visitMethod를 실행하는걸 확인할 수 있다.

여기서 나오는 인자의 뜻을 궁금해 할 수 있다. 인자의 뜻에 대해서 알려드리겠다.


access - 접근자 및 기타 정보를 의미한다. 단순히 접근자의 의미로만 쓰이는것은 아니다.


docs에 가보면 access에 대해서 사용하고 있는 정보들를 볼 수 있다.

보면 해당 정보가 final인지, native인지, deprecated됬는지에 대한 정보를 플래그로 담고 있다.

가령위의 String클래스에서 access가 9인 녀석들이 많다. 이 뜻은 public(1) static(8)이라는 뜻이다.


name - 당연하지만 메소드 이름을 의미한다. 생성자 및 초기화 블럭도 메소드 취급하는데 <init>으로 이름이 주어진다. 해당 루틴이 static이라면 <clinit>이 된다.

이 이름은 바이트코드에 기재된 규칙을 따르는 것이다.


desc - 출력된 정보를 보면 왠지 인자같은데 암호처럼 적혀있어서 당황스러울 것이다. 놀랄것 없다. 규칙이 있다.

L을 제외한 알파벳은 각각의 매개변수를 의미한다. 즉 B라고 적혀있으면 byte이다. BII라고 되있다면 byte,int,int를 매개변수로 같는다.

L은 클래스를 의미한다. L뒤에는 반드시 패키지를포함한 클래스명이 따라온다. V는 보이드를 의미한다.

여기서 boolean은 Z로 나타낸다. 한글자로 하면 byte와 겹치기 때문이다.

[는 배열을 의미한다. ()바로 뒤에는 반드시 알파벳이 하나 적혀있는데 이는 반환타입을 의미한다.

ex)[([BLjava/nio/charset/Charset;)V] : 반환형은 void이고 매개변수는 byte배열,Charset이라는 뜻


보면 알다시피 새로운 String클래스가 생겼다.

그러나 아직은 이 String클래스는 기존의 클래스와 똑같다. 왜냐하면 아직은 여러분이 코드를 삽입하지 않았기 때문이다.

생각보다 내용이 길어졌는데 바이트코드를 변경하는 것은 다음 장에서 설명하도록 하겠다.

+ Recent posts