본문 바로가기
Spring/DB Migrator

[DB Migrator] SpringBoot 동적으로 JpaRepository 생성 및 등록하기

by Taler 2022. 9. 20.

DB Migrator는 사용자의 편의를 최대한 봐주면서 Data Migration이 진행되는 것을 목표로 한다. 처음 인턴 했던 스타트업의 기술 블로그에도 작성했듯 목표로 했던 기능 중 첫 번째 편의성 기능은 Repository를 자동으로 생성하는 것이다. 그 이유는 생각보다 간단한데, 사용하는 Repository method가 기본적으로 findAll과 save 뿐이었다. 그래서 모든 Repository 코드가 아래와 같았다.

Legacy DB가 MongoDB이므로 MongoRepository를 사용한다. 하지만, 기본 제공 코드 이외의 다른 메소드를 사용하지 않는다.
JpaRepository 또한 마찬가지

원래 업무로 맡았던 DB의 Entity만 해도 60개가량 됐는데, 해당 코드들에 대해서 모두 이런 텅 빈 Repository 코드를 일일이 써주는 것은 정말 비효율적이라고 생각했다. 심지어 만약 내가 findAll이나 save가 아닌 다른 method를 통해서 데이터 읽기 및 저장을 수행하게 된다고 하면, 60 * 2 (Legacy, Migration)인 120개의 Repository 코드에 각각 직접 method를 추가해줘야 하는 것이다..!

 

사실 Entity의 경우는 각 Entity에 따라서 어떤 Entity와 매칭 할지도 달라질 수 있으며 convert 과정에서 데이터를 다른 방식으로 처리할 수도 있기 때문에, 즉 Entity의 특성에 따라서 각 코드가 달라질 수 있다. 이를 자동화하는 것은 오히려 사용성을 제한할 수 있었다.

 

반면 Repository 코드들은 Migrator 내부의 서비스에서만 접근해서 사용한다. 사용자가 직접 튜닝할 수도 있겠지만, 모든 Repository에 대해서 비슷한 튜닝이 진행될 것이며 Entity에 특화시킬 필요가 없다. 따라서, 해당 부분을 하나의 코드에서 자동으로 생성해주고 스프링 IoC에 등록하고자 했다... 만 사실 스프링 빈에 동적으로 무언가를 등록하는 것은 실제 서비스에서는 거의 필요하지 않고, 때문인지 소스 또한 거의 없었다.

 

이 역시 내가 직접 소스가 되어보자는 생각에 포스팅을 시작한다.

 

참고로 해당 DB Migrator 프로젝트에서는 MongoDB와 RDBMS 사이에서의 Data Migration을 지원하는 툴이다. 즉, MongoRepository와 JpaRepository 각각에 대해서 동적으로 생성해 IoC에 등록하는 예제를 보여준다. 이 역시 마음 단단히 먹고 읽어야 한다. 매우 복잡하다.

 

그리고 이번 글에서는 Dynamic JpaRepository Register In Runtime, 즉 애플리케이션 빌드 이후 동적으로 JpaRepository를 생성해 등록하는 과정을 다룬다.

 

Simple Example


이전 글에서 본 것처럼 매우 단순한 SpringBoot Application을 빌드할 것이며, MigrationExample이라는 Entity에 대해서 Repository를 생성할 것이다.

package com.dbmigrator.DBMigrator.domain.migration;

...

@NoArgsConstructor
@Getter
@Entity
@Table(name = "user", schema = "public")
public class MigrationExample extends BaseTimeEntity {

    @Id
    private Long userId;

    private String email;

    private String name;

    private String type;

    private int coin;

    @Builder
    public MigrationExample(Long userId, String email, String name, String type, int coin){
        super();

        this.userId = userId;
        this.email = email;
        this.name = name;
        this.type = type;
        this.coin = coin;
    }
}

Postgres를 위한 Entity이기 때문에 schema가 지정된 모습과 클래스 이름과 저장되는 Table의 이름이 다르기 때문에 table을 따로 지정해준 것이 눈에 띈다. 여기서 주의할 점은 해당 Entity는 반드시 @Entity annotation으로 지정해줘야 한다는 점이다. 추후 Repository를 Entity별로 생성해서 등록할 때 EntityManager를 사용해 각 Entity Class에 접근한다. 만약 @Entity annotation이 없다면 EntityManager가 이를 인식하지 못한다.

 

그리고 또한 우리가 생성하고자 하는 Repository 코드는 위에서 본 것처럼 아주 간단하다.

package com.dbmigrator.DBMigrator.domain.migration;

import org.springframework.data.jpa.repository.JpaRepository;

public interface MigrationExampleRepository extends JpaRepository<MigrationExample, Long> {
}

비어있는 Repository이다.

 

JpaRepository 코드를 살펴보면, 어떤 Entity를 대상으로 하는지, 그리고 그 Entity의 Id type은 무엇인지 JpaRepository Interface 생성 과정에 알린다. 이후 해당 Interface를 extends 하는 interface를 선언한다. 즉 Migrator는 JpaRepository를 상속하는 인터페이스를 생성해야 하며, 해당 interface의 선언문 즉, constructor parameter에 Entity Class, Id Class를 넘겨줘야 한다. 해당 사항들을 유념하고 있으면 아래에서 다룰 ByteBuddy파트가 더 쉽게 이해될 것이다.

 

Dynamically Create And Register JPA Repository


그래서 어떻게 만드냐? 앞서 잠깐 언급 했듯, ByteBuddy를 이용해 JpaRepository를 동적으로 생성한다. 이후 JpaRepositoryFactoryBean을 구성함으로써 가능해진다. SpringBoot를 사용할 때, JpaRepositoryFactoryBean을 명시적으로 특정하지 않으면, JpaRepositoriesAutoConfiguration은 자동으로 classpath에 있는 JpaRepository들을 탐색한다. 반면 우리가 JpaRepositoryFactoryBean을 직접 사용하면 JpaRepository를 직접 등록할 수 있다.

 

이 프로세스를 전체적인 그림으로 보면 아래와 같다.

 

LucidChart로 그린 전체 Dynamic Repository Register 프로세스

 

키워드가 필요한 이들을 위해 순서대로 요약하자면 아래와 같다.

 

1. ByteBuddy를 이용해 JpaRepository를 동적으로 생성한다.
2. 현재 ApplicationContext에 있는 ConfigurableListableBeanFactory를 들고 온다.
3. BeanDefinitionBuilder를 사용해 JpaRepositoryFactoryBean의 definition을 구성한다.
4. 가져온 BeanFactory에 해당 BeanDefinition을 등록한다.

 

처음부터 한 스탭씩 밟아보자.

 

 

1. ByteBuddy를 이용해 JpaRepository를 동적으로 생성하기


ByteBuddy란 Runtime에 동적으로 Java Class를 만들어낼 수 있는 라이브러리다. 상속, 인터페이스 구현, 추상 클래스, 인터페이스 자체를 동적으로 생성할 수 있는 것은 물론이고 해당 Class에 어떤 Method를 포함할지, 어떤 멤버 변수를 포함할지 모두 지정할 수 있다.

 

귀여운 ByteBuddy

 

 1) JpaRepository Type 생성하기

먼저 우리가 구현하고자 하는 Interface는 Interface를 구현하고자 할 때부터 이미 Constructor 변수를 요구한다. 따라서 해당 Interface를 먼저 생성해준다. TypeDescription을 build 함으로써 이를 구현할 수 있다.

 

일반화한 RepositoryTypeDescription builder. Repository class, EntityClass, idClass를 입력받아 조합해준다.

 

위 함수는 RepositoryClass와 EntityClass, Entity의 Id Class를 입력받아 이를 조합해 하나의 타입으로 반환한다. MongoRepository에 대해서도 사용하기 위해서 위와 같이 일반화했으며 코드도 깔끔하게 보기 좋게 변신한 모습이다. 위 함수에 JpaRepository, MigraitonEntity, Long을 넣었다고 가정하면, 반환되는 type은 JpaRepository<MigraitonEntity, Long>이라고 생각하면 된다.

 

이제 본격적으로 ByteBuddy를 이용해서 해당 Interface의 구현체를 Interface로 만드는 코드를 봐보자.

 

 2) ByteBuddy로 LegacyEntityRepository 생성하기

이제 해당 JpaRepository<LegacyEntity, Long>를 상속하는 interface를 구현해보자. 본격적으로 ByteBuddy를 이용할 것이다.

 

이 또한 일반화되어 MongoRepository를 등록하는 과정에서도 재사용된다.

 

ByteBuddy 코드를 살펴보면, interface를 만들고 있음을 명시하고, 어떤 타입의 Interface를 만들지 지정해준다. 여기서 genericType은 앞서 Builder로 생성한 JpaRepository<MigraitonEntity, Long> Interface를 담고 있는 TypeDescription이다. 이를 구현할 것이라고 알리는 것이며, 이렇게 동적으로 생성한 Class의 이름을 repositoryName으로 지정해준다. RepositoryName은 따로 앞에서 생성해서 넣어줬다.

 

그렇게 make까지 하면 ByteBuddy에서 Unloaded<?> type으로 클래스를 생성한다. 이 시점에서 만들고자 하는 클래스는 생성은 됐지만 JVM에 로드는 되지 않은, Binary form 형태로 존재한다.

 

때문에 이를 JVM에 로드해야만 우리가 Class<?> 타입으로 사용할 수 있게 된다. 이를 위해서 마지막 줄인 load가 실행된다. 또한, 필자는 Repository Class가 Disk에 저장되는 것을 원하지는 않았다. 그럼 Entity를 읽어서 일정한 Repository 코드를 생성해주는 것과 큰 차이가 없었으며, Disk 공간 낭비 등의 이유가 있었다. 따라서 클래스를 동적으로 생성한 다음 ClassLoader에 로드하고자 했다.

 

마지막 줄의 두 번째 parameter인 ClassLoadingStrategy에서 Injection은 기본적인 ClassLoading 전략으로 load 하려는 클래스 간의 어떤 연관관계도 존재하지 않아, 클래스를 load 하는데 순서가 중요하지 않는 경우 주로 사용된다.

 

어쨌든 이렇게 ByteBuddy를 활용해 JpaRepository를 동적으로 생성하는 방법까지는 알 수 있었다. 아래는 전체 코드이다.

 

입력받은 EntityName에 대해서 JpaRepository Class를 생성해 반환하는 코드

 

findTargetEntityClass는 입력받은 ClassName을 갖는 Entity를 미리 저장해둔 EntityType 목록에서 가져오는 간단한 로직으로 구현되어 있다.

 

이제 이렇게 생성한 JpaRepository를 Spring Bean으로 등록하는 과정을 알아보자.

 

 

2. JpaRepository Class를 사용해서 JpaRepositoryFactoryBean 등록하기 - 이론


뭘 하는지 제대로 이해가 안 될 수 있다. 때문에 먼저 읽으면서 '뭘 하기 위해서 이렇게 하고 있었지'하는 생각이 들 수 있게 어떤 과정을 거치는지 먼저 정리하고자 한다.

 

1. JpaRepository Class를 가지고 beanName을 먼저 생성한다.
2. JpaRepository Class를 이용해서 JpaRepositoryFactoryBean을 정의하는 BeanDefinitionBuilder를 만든다.
3. 해당 BeanDefinitionBuilder와 beanName로 ConfigurableApplicationContext의 BeanFactory에 등록한다.

 

키워드가 갑자기 너무 많이 나왔다. 그냥 단순히 코드를 복붙 하는 수준에서 벗어나 왜 이렇게 구현했는지 이해를 돕기 위해 하나씩 설명한다.

 

 1) ConfigurableApplicationContext?

스프링 Application에서는 오브젝트의 생성과 오브젝트 사이의 관계 설정, 사용 시 할당, 제거 등의 모든 작업을 Application 단의 코드가 아니라 독립된 하나의 컨테이너가 담당한다. 모든 오브젝트의 제어권을 담당하고 있는 컨테이너라고 해서 이를 IoC(Inversion of Control) 컨테이너라고 부른다. 원래라면 오브젝트의 선언, 사용, 제거 등의 작업은 application 코드에서 진행하는데, 이를 프레임워크에서 하기 때문에 제어의 역전이라는 이름이 붙었다. 또한 이 IoC를 담당하는 컨테이너를 Bean Factory 또는 Application Context라고 부른다.

 

원래라면 해당 Application Context는 Spring이 알아서 관리하는 컨테이너이지만, 우리는 지금 새로운 Bean을 등록하고 싶은 것이다. 그러므로 지금 동작하고 있는 Application의 ApplicationContext에 접근해서 우리가 정의한 Bean을 등록해야 한다. 이를 가능케 하는 것이 바로 ConfigurableApplicationContext이다.

 

ConfigurableApplicationContext은 ApplicationContext를 코드에 의해서 재구성할 수 있는 인터페이스이다. 우리는 이 ConfigurableApplicationContext를 이용해 우리가 사용하고자 하는 Bean을 등록하고자 한다.

 

Application이 준비되면 바로 ApplicationContext를 가져오는 코드

이렇게 ConfigurableApplicationContext를 가져온 후 해당 ConfigurableApplicationContext에서 beanFactory만 꺼내 주자. 이때 BeanFactory의 type 또한 ConfigurableListableBeanFactory임을 주의해야 한다.

EntityRepositoryFactoryPostProcessor 내부에 우리가 구성중인 DynamicJpaRepositoryRegister가 들어있다. mongoTemplate을 넣는 이유는 다음에..

 우리가 구성중인 EntityRepositoryFactoryPostProcessor에 주입해주자. 이렇게 하면 우리는 생성하고 로드한 JpaRepository 클래스를 현재 동작 중인 Application의 Context에 등록할 수 있게 된다.

 

 2) BeanFactoryPostProcessor?

기본적으로 스프링은 저장된 메타 정보를 통해서 BeanDefinition을 만들어둔 후, 이를 바탕으로 객체들을 생성한다. 이때 등록되어있는 BeanDefinition을 모두 불러와서 조작하는 것이 BeanFactoryPostProcessor이다. BeanFactoryPostProcessor에서는 EntityManager와 같은 Bean인스턴스들과 함께 작업을 진행할 수 있다. Entity List를 뽑아와 Repository를 생성하고, 해당 Repository를 다시 가져온 BeanFactory에 등록하고자 했던 이번 프로젝트에서는 아주 훌륭한 선택지가 됐다.

 

유용하기만 한 것은 아닌 게, BeanFactoryPostProcessor를 사용할 때 주의해야 할 점들이 있다.

 

  1. 사용하는 Bean들이 조기에 인스턴스화 된다. 이는 컨테이너 라이프사이클을 위반하는 행위이다.
  2. 한 컨테이너에서 정의되는 BeanFactoryPostProcessor는 해당 컨테이너 내부에 있는 Bean들에게만 적용된다.
  3. 다른 컨테이너에 있는 Bean 정의 들에 대해서는 작업할 수 없다.

 

각각에 대해서 선택한 이유를 말해보자.

  • 1: DB Migrator에서는 조기 인스턴스화에 대해서 걱정할 필요가 없었다.
    • 이번 프로젝트에서 사용한 BeanFactoryPostProcessor는 모든 Configuration이 끝난 이후인 Application 빌드 이후 Constructor를 사용해 생성되고 사용됐다. 따라서 모든 설정이 끝난 이후 등록이 진행된다.
  • 2, 3: 해당 Processor가 주입된 ApplicationContext에 대해서만 작업을 한다.
    • 애초에 다른 Container는 따로 사용하지 않았기에 2, 3번도 걱정할 필요가 없다고 판단했다.

따라서 해당 프로젝트에서는 이 BeanFactoryPostProcessor를 사용해서 생성한 JpaRepository를 Bean으로 등록했다.

 

 3) BeanDefinition? JpaRepositoryFactoryBean???

오브젝트를 Bean으로 등록하기 위해서는 Class가 아니라 BeanDefinition이 필요하다. 이때 BeanDefinition은 Bean을 구성하기 위해서 필요한 다양한 메타 데이터를 포함하고 있는 객체이다. Class나 name, scope나 dependency 등의 여러 정보를 포함하고 있는데, 스프링 IoC에서는 이를 활용해 Bean 객체를 생성한다. Class를 가지고 BeanDefinition을 만들 때는 BeanDefinitionBuilder를 사용할 수 있다. (사실 RootDefinition으로 그냥 선언해서 사용해도 무방하다.)

 

주의할 점이 하나 있다.

동적으로 JpaRepository를 등록하기 위해서 등록하는 것은 JpaRepositoryBean이 아니라 JpaRepositoryFactoryBean이다. 앞에서 만든 것은 Class라 언급하긴 했지만 Class가 아니라 Interface이다. Interface는 말 그대로 하나의 설계도에 불과하기 때문에 이를 바로 사용할 수 없다. 

 

그렇다면 어떻게 등록하느냐? 이는 Spring이 JpaRepository Interface를 토대로 어떻게 오브젝트를 생성하고, 그것을 어떻게 사용하는지 살펴보면 답을 찾을 수 있다.

 

Spring은 빌드 시에 JpaRepository Interface들에 대한 Proxy 구현체를 만들어서 사용한다. 이때 사용자가 작성했던 JpaRepository Interface 코드들에 대한 Proxy 구현체들은 JpaRepositoryFactoryBean(JpaRepositoryFactory)에 의해서 생성 및 등록된다. 따라서 앞에서 구성했던 JpaRepository Type의 Interface를 구성해줄 JpaRepositoryFactoryBean을 만들고, BeanFactory에 등록해주면 Spring IoC가 이를 확인해서 JpaRepository interface에 대한 proxy 객체를 생성해준다. 위의 과정이 있기 때문에 비로소 우리의 application code가 jpa repository를 사용할 수 있게 된다.

 

이해가 안 된다면 막힌 문장에 포함된 키워드들이 구체적으로 무엇인지 찾아보면 더 쉽게 이해할 수 있다. (이해하기 쉽게 쓴다고 풀어썼는데 너무 길어져서 오히려 이해하기 힘들어진 느낌..)

 

한 줄로 줄여보면, 동적으로 생성한 JpaRepository interface에 대한 구현체를 생성해서 사용하기 위해서는 Spring에게 맡겨야 하며, 이 과정을 위해 JpaRepositoryFactoryBean을 생성해서 ApplicationContext에 등록하는 것이다.

 

이해가 안 된다면 코드를 보자.

 

 

3. JpaRepositoryFactoryBean 생성 및 등록 실습


JpaRepository Interface와 BeanFactory를 입력받아 해당 BeanFactory에 BeanDefinition을 만들어서 등록해주는 코드

앞의 설명을 이해했다면 한눈에 들어올 코드이다. 사실 이 정도로 짧은 함수가 이해가 되지 않는 이유는 각 줄이 뭘 의미하는지 모르기 때문이다. 하지만, 위의 설명을 이해할 때까지 읽은 후 이 코드를 읽어보자.

 

첫 줄에서는 간단히 등록할 BeanName을 만든다. BeanFactory에 어떤 BeanDefinition을 등록하기 위해선 그 definition으로 만들 bean의 이름을 지정해줘야 한다.

 

이후 두 번째 줄에서는 JpaRepositoryFactoryBean을 기본 definition으로 하고, 그것의 생성자 변수에 받아온 JpaRepositoryClass를 사용한다는 것을 의미한다. JpaRepositoryFactoryBean은 그 생성자에 RepositoryInterface를 하나 받아와 저장한다.

 

JpaRepositoryFactoryBean Constructor

 

BeanDefinitionBuilder는 Builder 패턴을 사용해서 BeanDefinition을 만들 수 있는 클래스이며, 관련해서 많은 부가기능을 제공하고, 직관적으로 코드를 작성할 수 있기 때문에 BeanDefinitionBuilder를 사용했다. 위 코드는 간단히 아래와 같이 바꿀 수도 있다.

 

RootBeanDefinition을 하나 선언해서 사용하기. 생각보다 코드가 더 더러워진다.

저렇게 ConstructorArgument를 넣을 때나 PropertyValue를 넣을 때나 기타 등등의 상황에서 매번 관련 객체를 호출해 거기에 포함시켜줘야 한다. 복잡한 BeanDefinition을 만들어야 하기 때문에 사용하는 게 아니라면 굳이 쓸 필요가 없다.

 

이후 있는 print문을 건너뛰고 마지막 BeanFactory에 등록하는 과정을 보자.

 

받아온 beanFactory에 지금까지 만든 BeanName과 BeanDefinition을 사용해서 새로운 Bean을 등록해주는 로직이다. 이렇게 만든 Bean들은 이 BeanFactoryPostProcessor 외부에서 프린트를 찍어봐도 잘 등록된 것을 볼 수 있었다...

 

 

결론


살면서 한 번은 더 쓸까? 싶은 인터페이스들을 너무 많이 썼다. 하지만 이렇게 삽질의 과정을 끝내고 마지막에 자동으로 모든 Repository가 생성되며 Test Data에 대한 Migration도 정상적으로 진행됐을 때의 쾌감은 잊을 수가 없을 것 같다.

 

혹시 어떤 Interface를 알아보고자 검색해서 해당 글에 들어온 독자는 전문을 읽어보면 더 좋을 것 같다. Repository에 대한 스프링 라이프사이클이 대충 어떤 식으로 흘러가는지 제대로 파악할 수 있을 것이다.

 

또한, 매번 언급했듯 DB Migrator 프로젝트는 MongoDB to Postgres Migration에서 시작했다. 때문에 MongoRepository도 동적으로 선언해서 등록해줬는데, 해당 과정은 또 다른 삽질들을 요구했다. Mongo에 대한 소스가 거의 없는 것도 한 몫했다. 이에 대해서는 다음 글에서 다룰 계획이다.

 

 

Reference


ByteBuddy:

https://bytebuddy.net/#/tutorial

 

Byte Buddy - runtime code generation for the Java virtual machine

 

bytebuddy.net

BeanFactoryPostProcessor를 구성하며 참고한 소스:

https://glory-day.tistory.com/74

 

1.8.2. BeanFactoryPostProcessor로 설정 메타데이터 커스터마이징하기

우리가 다음으로 살펴볼 확장 가능한 부분은 org.springframework.beans.factory.config.BeanFactoryPostProcessor이다. 이 인터페이스의 의미는 BeanPostProcessor와 유사하지만 한 가지 다른 점이 있다. BeanFac..

glory-day.tistory.com

사실상 엄청 친절했던 외국 분의 소스:

https://palashray.com/creating-spring-jpa-entity-and-repository-dynamically/

 

Creating Spring JPA Entity And Repository Dynamically – The tech tales

Introduction At the outset, be warned that this is not for the fainthearted. Be prepared for a long and arduous read. Having said that, lets begin. Many a times, I have wondered, if I can ever create a JPA Entity and a JPA Repository class dynamically, on

palashray.com

 

댓글