Zaki is realm magic header

Realm의 마법을 이루는 원리, Realm Java의 바이트 코드 조작

소개

저는 Realm Java 팀의 Makoto Yamazaki로, Realm Java의 세부 구현 사항에 대해 말씀드리겠습니다. Realm은 훌륭한 기능을 지니고 있으며, 이번 내용은 그 기본 내용에 초점을 맞출 생각입니다.

단순함의 마법

아래 코드는 객체를 만든 다음 필드에 값을 할당하는 것처럼 보이지만 실제로는 데이터베이스 파일에 데이터를 전달합니다. 또한, 단지 필드를 읽는 것만으로도 데이터베이스 파일에서 데이터를 읽을 수 있습니다.


Realm realm = Realm.getDefaultInstance();

realm.executeTransaction(r -> {
    final Person person = r.createObject(Person.class);
    person.name = "Zaki";
});

Person person = realm.where(PErson.class).findFirst();
Log.d("realm", person.name); // Zaki

realm.close()

필드를 정의하고 RealmObject를 확장하는 간단한 정의만으로 모델을 정의할 수 있습니다. 평범해 보이는 이 코드 뒤에 많은 마법이 숨어 있습니다.


public class Person extends RealmObject {
    public String name;
}

@RealmClass
public class Person implements RealmModel {
    public String name;
}

Realm에는 지연 로딩(lazy loading) 기능이 있습니다. 즉, Realm은 사용자가 실제로 필요하지 않은 경우 어떤 데이터도 불러오지 않죠. 예를 들어 아래 코드는 쿼리를 만들어서 하나의 조건을 추가하는데, 이 시점에서 Realm은 단지 쿼리 조건을 추가할 뿐 거의 아무것도 하지 않습니다. 다음으로 findAll()이 실제 쿼리를 실행하는데, 이때도 역시 실제 데이터를 로딩하지 않고 단지 테이블에서 행 인덱스만을 생성합니다. person 객체가 생성되는 부분에서도 실제 데이터를 여전히 불러오지 않고, 단지 프락시 객체만 만듭니다. 마지막 줄에 이르러서야 실제 값, name 값을 데이터베이스 파일에서 읽어 옵니다. 이것이 지연 로딩이죠.


RealmQuery<Person> query = realm.where(Person.class);
                           .beginsWith("name","Z");

RealmResults<Person> results = query.findAll();

Person person - results.first();

String name = person.name;

이전 Realm Java 구현 방식

이전 구현에는 제한 사항들이 있었습니다. 다음과 같이 사용자가 몇 가지 규칙을 따라 모델을 정의해야 했습니다.

이런 개발 뉴스를 더 만나보세요

  1. 필드는 private이어야 했습니다.
  2. 사용자가 반드시 게터/세터를 정의해야 했습니다.

public class Person extends RealmObject {
  private String  name;


  public String getName() { return name; }

  public void setName(String name) { this.name = name; }
}

해당 모델 정의에 대해 어노테이션 프로세서를 사용해서 프락시 클래스를 만듭니다.

생성된 프락시는 모델 클래스의 하위 클래스로, 우리 예시에서는 person이고, 행 객체를 포함합니다. 행은 내부 데이터베이스를 가리키는 포인터이죠.


public class PersonRealmProxy extends Person {
  private Row row =...;
  private PersonColumnInfo columnInfo = ...;

  @Override
  public String getName() {
    return row.get(columnInfo.columnNameIndex);
  }

  @Override
  public void setName(String value) {
    row.set(columnInfo.columnNameIndex, value);
  }
}

단순히 어노테이션 프로세서로 하위 클래스를 프락시로 생성하고 게터와 세터를 오버라이드하는 간단한 구현입니다. 그러면 Realm이 데이터베이스에 바인딩 된 객체인 “관리된 객체”를 반환합니다. 이 프락시 객체는 속성에 대한 접근를 가로채서 대신 데이터베이스를 씁니다.

기존 방식의 한계점

이것이 기본 전략이며, 현재 구현도 거의 같지만 다음과 같은 제한 사항이 있었기 때문에 몇 가지 한계점을 보완하였습니다.

  1. 필드가 private이어야 했습니다. public이어도 괜찮지만 이 경우 많은 사용자가 실수를 발생하기 때문에 public 필드를 금지했었습니다.
  2. 게터/세터를 오버라이드하므로 이들이 반드시 있어야 했었습니다.
  3. 게터/세터에 로직이 없어야 하며 단지 필드를 읽거나 쓰는 기능만 해야 했습니다.
  4. 예를 들어 커스텀 메서드를 사용해서 필드에 접근하는 것과 같은 사용자의 실수를 막기 위해 게터/세터 이외의 커스텀 메서드를 금지했었습니다.
  5. 사용자가 반드시 RealmObject 클래스를 확장해야 했습니다.

바이트 코드 조작으로 한계점 극복

바이트 코드 조작을 사용해서 앞서 말한 한계점을 개선했습니다. 바이트 코드 조작이란 컴파일 타임이나 로딩 시간에 바이트 코드를 수정하는 것으로, 저희는 현재 컴파일 타임에 수정하고 있습니다.

현재 구현 방식

현재 구현 방식에서는 사용자가 이전처럼 모델을 정의할 수 있습니다. 현재 private 필드와 게터/세터 제한을 없앴지만, 같은 정의를 사용합니다.

그다음 인터페이스와 클래스, 두 개의 프락시를 생성합니다. 인터페이스가 필드를 위한 게터/세터를 가지는데, realmGet$name, realmSet$name이라는 특별한 이름을 사용합니다. 하위 클래스에서 프락시 클래스 내의 이 특별한 게터/세터를 구현합니다.


public interface PersonRealmProxyInterface {
  public String realmGet$name();
  public void realmSet$name(String value);
}
public class PersonRealmProxy extends Person implements PersonRealmProxyInterface {
  private Row row = ...;
  private PersonColumnInfo columnInfo = ...;

  public String realmGet$name() {
    return row.get(columnInfo.columnNameIndex);
  }
  public void realmSet$name(String value) {
    row.set(columnInfo.columnNameIndex, value);
  }
}

컴파일 이후 바이트 코드 조작을 적용하게 됩니다. 먼저 우리 구현에서 직접 속성에 접근하기 위해 모델 클래스에 인터페이스를 추가합니다. 이렇게 하면 모델 클래스를 인터페이스로 캐스트하고 우리가 정의한 게터/세터를 직접 호출할 수 있으며, 게터/세터를 구현할 수 있습니다. 이런 게터/세터는 단순히 필드를 직접 읽거나 씁니다.


public class Person extends RealmObject implements PersonRealmProxyInterface {
  private String  name;
  public String   getName() { return realmGet$name(); }
  public void     setName(Stringname) {realmSet$name(name); }

  @Override
  public String realmGet$name() { return name; }

  @Override
  public void realmSet$name(String value) { this.name = name; }
}

중요한 것은 name 읽기나 쓰기와 같은 모델 필드에 대한 모든 접근을 게터나 세터 호출로 바꾸는 것입니다. 이렇게 하면 필드에 대한 모든 접근이 게터/세터를 사용하게 되죠. 이제 다음과 같은 정의가 가능해집니다.


public class Person extends RealmObject {
  public String  name;
}

사용자가 직접 name에 접근하는 경우, 저희는 프락시 인터페이스나 프락시 클래스에 정의된 게터/세터를 통해 해당 접근을 가로챌 수 있습니다.


RealmResults <Person> results = realm.where(Person.class).findAll();
Person person = results.first();

Log.d("realm", person.name);

위와 같이 name에 접근하는 코드라면 바이트 코드 조작으로 필드의 접근을 게터 호출로 변경합니다.


Log.d("realm", person.realmGet$name());

동작 방법

실제로 어떻게 동작할까요? 두 가지 기술을 사용했는데, 첫 번째는 구글과 안드로이드 Gradle 플러그인인 Transform API이고 다른 하나는 바이트 코드를 수정하는 라이브러리인 Javassist입니다.

Transform API

Realm을 적용한 앱을 개발할 때 :app:transformClassesWithRealmTransformerForDebug를 보신 적이 있으신가요? 이는 트랜스포머가 앱에 적용된다는 뜻입니다.

저희는 project.android.registerTransform(new RealmTransformer(project))를 실행해서 트랜스포머를 적용하며, Gradle 플러그인에는 트랜스포머를 등록하는 API가 있습니다. 트랜스포머가 등록되면 컴파일 타임에 트랜스포머가 호출되고, 그 후 변환을 위한 정보를 받을 수 있습니다.

트랜스포머는 인풋 클래스, 참조된 라이브러리, 아웃풋 디렉터리 등의 컨텍스트 정보를 제공할 수 있습니다. 트랜스포머 내에 클래스에 접근자와 프락시 인터페이스를 추가합니다. 각 클래스의 이름은 애플리케이션 코드를 사용하는 모든 클래스를 포함합니다. 그다음 모든 클래스 내에서 메서드를 사용하는 모든 필드 접근을 수정합니다.


@Override
void transform(...) throws IOException, ... {

  // Create and populate the Javassist class pool
  ClassPool classPool = createClassPool(inputs, referencedInputs)

  inputModelClasses.each {
    BytecodeModifier.addRealmAccessors(it)
    BytecodeModifier.addRealmProxyInterface(it,classPool)
  }

  inputClassNames.each {
    def ctClass = classPool.getCtClass(it)
    BytecodeModifier.useRealmAccessors(ctClass, allManagedFields, allModelClasses)
    ctClass.writeFile(getOutputFile(outputProvider).canonicalPath)
  }
}

바이트 코드 조작 라이브러리는 여러 가지로 일부는 저수준, 일부는 고수준입니다. 고수준의 라이브러리는 특정 목적을 위해 만들어지며 사용하기 쉽습니다. 유명한 고수준 바이트 코드 조작 라이브러리로는 Retrolambda와 AspectJ가 있습니다. 저수준 라이브러리는 강력하며 Java 가상 머신에서 할 수 있는 대부분의 것을 할 수 있지만 사용하기 어렵고 명령어와 바이트 코드, 가상 머신에 대한 이해도 높아야 합니다.

Realm은 중수준의 Javassist를 사용합니다. 바이트 코드의 모든 세부 내용을 알지 못해도 일반적인 용도로 사용할 수 있습니다. 예를 들어 Javassist는 클래스와 필드를 추가하거나 쉽게 정의할 수 있죠. 간단히 Ctfield 인스턴스를 만들고 수정자를 설정해서 필드를 정의할 수 있습니다. 클래스를 정의하려면 makeClass를 호출합니다. addField를 사용해서 클래스에 쉽게 필드를 추가할 수도 있습니다. 즉, 바이트 코드 자체가 아니라 Java 언어에 대한 이해만으로 Javassist를 사용할 수 있습니다.

코드 확인

이제 Realm Java의 실제 코드를 확인해 볼까요?


public static void addRealmAccessors(CtClass clazz) {
  def methods = clazz.getDeclaredMethods()*.name
    clazz.declaredFields.each { CtField field ->
        if (isModelField(field)) {
            if (!methods.contains("realmGet\$${field.name}".toString())) {
                clazz.addMethod(CtNewMethod.getter("realmGet\$${field.name}", field))
            }
            if (!methods.contains("realmSet\$${field.name}".toString())) {
                clazz.addMethod(CtNewMethod.setter("realmSet\$${field.name}", field))
            }
        }
    }
}

트랜스포머에서 addRealmAccessors를 호출했고, 다음 줄에서 선언된 메서드의 모든 이름을 수집합니다. 다음으로 클래스에 선언된 필드를 받아서 modelfield라면 해당 클래스에 특별한 게터/세터를 추가합니다.

접근자를 추가하는 코드는 다음과 같습니다.


public static void useRealmAccessors (CtClass clazz, List<CtField> managedFields) {
    clazz.getDeclaredBehaviors().each { behavior ->
        if (
              (
                  behavior instanceof CtMethod &&
                  !behavior.name.startsWith('realmGet$') &&
                  !behavior.name.startsWith('realmSet$')
              ) || (
                  behavior instanceof CtConstructor
              )
        )  {
              behavior.instrument(
                  new FieldAccessToAccessorConverter(managedFields, clazz, behavior))
        }
    }
}

선언된 행동을 볼까요? 메서드이지만 이름이 realmGet이나 realmSet으로 시작되지 않으면, 생성자가 필드에 대한 이들의 접근을 교체합니다.

실제 변환 코드는 다음과 같습니다.


private static class FieldAccessToAccessorConverter extends ExprEditor {
  @Override
  void edit(FieldAccess fieldAccess) throws CannotCompileException {
    def isRealmFieldAccess = managedFields.find {
        fieldAccess.className.equals(it.declaringClass.name) && fieldAccess.fieldName.equals(it.name)
    }
    if (isRealmFieldAccess != null) {
      def fieldName = fieldAccess.fieldName
      if (fieldAccess.is.Reader()) {
        fieldAccess.replace('$_=$0.realmGet$' + fieldName + '();')
      } else if (fieldAccess.isWriter()) {
        fieldAccess.replace('$0.realmSet$' + fieldName + '($1);')
      }
    }
  }
}

모델에 읽기로 접근하는 경우 이 읽기 접근을 realmGet$ + fieldName 메서드 호출로 바꿨습니다. 또한, 필드에 쓰기를 하는 경우 세터 호출로 변경했습니다.

결론

이 모든 내용을 저희 저장소에서 볼 수 있습니다. 바로 Realm이 오픈 소스인 덕분이죠! 제가 설명한 모든 내용이 공개되어 있으므로 직접 코드를 읽어볼 수 있습니다.

다음: Realm Java의 새로운 기능을 만나 보세요!

General link arrow white

컨텐츠에 대하여

이 컨텐츠는 저자의 허가 하에 이곳에서 공유합니다.

Makoto Yamazaki

Makoto Yamazaki is a software engineer with extensive experience in Java and Android. He is a member of Realm Java team and also CTO of uPhyca Inc.

4 design patterns for a RESTless mobile integration »

close