Page tree
Skip to end of metadata
Go to start of metadata

Introduction

MyBatis에서 제공하는 mybatis-spring 모듈을 이용하면, 다음과 같이 @Mapper 인터페이스를 스프링 빈으로 주입받아 사용할 수 있습니다.

Mybatis @Mapper interface
@Mapper
public interface CustomerMapper{
	@Select("SELECT * FROM CUSTOMER WHERE CUSTOMER_ID = #{customerId}")
	public Customer getCustomer(@Param("customerId") long customerId);
}
A Service uses Mybatis @Mapper Interface
@Service
public class CustomerServiceImpl implements CustomerService{
	@Autowired
	private CustomerMapper customerMapper;
 
	@Override
	public Customer getCustomer(long customerId){
		//스프링 프레임워크가 주입해 준 customerMapper를 이용하여 조회를 수행한다.
		return customerMapper.getCustomer(customerId);
	}
} 

 

Mybatis를 이용해 본 대부분의 개발자들은 크게 신경쓰지 않고 이 방법으로 데이터베이스 관련 처리를 수행합니다. 결과적으로 뻔하디 뻔한 Data Access Object 코드를 작성하지 않고 인터페이스의 선언만으로 데이터베이스에 접속할 수 있으니 여간 편한 것이 아닙니다.

그런데 어떻게 해서 이것이 가능할까요? 인터페이스만 작성했는데 무엇인가 스프링 빈으로 등록되고 @Autowired를 이용하여 주입(injection)될 수 있다니 말이죠. Mybatis와 스프링 프레임워크 내부적으로 무슨 일이 일어나는 것일까요?

 

이 글에서는 스프링이 제공하는 프로그램적인 빈 등록 방법에 대해 다루고, 동시에 인터페이스가 인스턴스로 주입될 수 있도록 하는 메커니즘에 대해서 최대한 단순히 설명할 예정입니다. 이를 위해 실제 Mybatis 코드 대신 이해하기 쉬운 간략한 코드를 이용할 것입니다.

Prerequisite

이 글을 잘 이해하기 위해서 다음 지식이 필요합니다.

  • 스프링 프레임워크 기본: 빈, 주입
  • 스프링 설정: XML Config, Java Config 기법
  • Java 동적 Proxy

 

1. BeanDefinition과 BeanFactoryPostProcessor (혹은 BeanDefinitionRegistryPostProcessor)

세부적인 코드에 대해 언급하기에 앞서, 먼저 알아야 할 내용이 있습니다. 바로, "애초에 스프링 빈은 어떻게 생성되고 주입될까?" 라는 것입니다. 이것만 이해하더라도 우리가 고민하는 많은 부분들이 해결될 수 있기 때문입니다. 먼저 그림 하나를 보겠습니다.

Bean loading process - overview

빈 생성 프로세스 (출처: https://jakubstas.com/spring-professional-study-notes/#.Wgj_tWh-qMo)

 

위 그림은 크게 두 가지 단계(Phase)로 구분되어 있습니다. 한 번만 일어나는(Happens only once) 처리 단계와 각각의 빈들에 대해 일어나는(Happens for each bean) 단계입니다. 그림의 왼쪽 부분과 오른쪽 부분이죠. 

다소 복잡해 보이지만 그림의 내용을 매우 간단히 설명하면, beans.xml과 같은 스프링 컨텍스트 파일을 로딩한 뒤, 몇몇 절차를 거쳐 "빈에 대한 최종 정의(Final Bean Definition)"를 생성하면, 그것을 이용하여 실제 스프링 빈을 생성하면서 의존성 주입(Dependency Injection)를 한다는 것입니다. 즉, 스프링 프레임워크를 잘 모르는 개발자들의 막연한 생각과는 달리, 빈에 대한 정의를 먼저 수립하고 그 뒤에 실제 빈 인스턴스를 생성하는 절차를 따르게 됩니다.

 

여기서 주목해야 할 점은, 빈에 대한 최종 정의를 만들 때, 설정 파일로부터 로딩된 빈 정의 목록에 BeanFactoryPostProcessor라는 인터페이스가 개입할 수 있다는 것입니다. 설정파일을 통해, 혹은 등록된 stereotype을 통해 이미 알고있는 빈에 대한 정의에 추가로 "개발자의 필요에 따라" 특정 빈들을 스프링이 로딩하도록 설정할 수 있다는 것인데, 다시 말해 "어떤 인터페이스가 @Mapper 어노테이션으로 지정되어 있다면 스프링 빈으로 등록하라" 라고 명령할 수 있다는 말입니다.

BeanFactoryPostProcessor 인터페이스는 Functional Interface로, postProcessBeanFactory(ConfiguratbleListableBeanFactory) 메소드 하나를 선언하고 있습니다. 이 인터페이스에 대한 구현체(클래스)를 이용하여 프로그램적으로 새로운 빈을 등록할 수 있습니다. 이에 대한 상세한 내용은 스프링 documentation을 참고하시길 바랍니다.

 

이미 눈치 채신 분들이 있겠지만, 이 글에서는 BeanFactoryPostProcessor를 상세히 다루지 않을 것인데, 그 이유는 BeanFactoryPostProcessor보다 더 유용한 대체자로 BeanDefinitionRegistryPostProcessor가 있기 때문입니다. 이 새로운 인터페이스를 이용하면 - 말 그대로 BeanDefinitionRegistry를 이용하여 - 좀더 쉽게 빈에 대한 정의를 등록할 수 있습니다. 

위 그림에는 표현되어 있지 않지만, 빈 정의(Bean Definition)들은 인덱싱(indexing) 과정을 통해 BeanDefinitionRegistry에 등록됩니다. 그리고 BeanDefinitionRegistry에 등록되어 있는 빈 정의들은 (singleton 타입인 경우) 이후 단계에서 실제 빈 인스턴스로 ApplicationContext에 등록됩니다. 아래 코드는 BeanDefinitionRegistryPostProcessor를 구현한 클래스입니다. 클래스가 스프링 로딩시에 등록되도록 @Configuration으로 지정되어 있는 것을 확인할 수 있습니다.

BeanDefinitionRegistryPostProcessor 구현체
@Configuration
public class CustomBeanDefinitionRegistryPostProcessor implements BeanDefinitionRegistryPostProcessor{
	@Override
	public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException{
		// BeanFactory를 이용하여 빈 정보를 등록하는 경우 이용
	}
 
	@Override
	public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) throws BeansException{
		// BeanDefinitionRegistry를 이용하여 빈 정보를 등록하는 경우 이용
        // TODO 이 메소드를 이용하여 @Mapper에 대한 Bean Definition을 등록한다.
	}
}

 

이제 이 클래스를 이용하여 @Mapper로 지정된 인터페이스가 스프링 빈으로 와이어될 수 있도록 차근차근 진행해 보겠습니다.


 

2. ClasspathBeanDefinitionScanner

이제 한 가지는 문제는 해결된 듯 합니다. 지금까지의 내용을 한 문장으로 축약하면, 클래스패스 내에 @Mapper라고 지정된 인터페이스가 스프링 빈에 와이어되도록 하려면 그 인터페이스에 대한 정의를 BeanDefinitionRegistry에 등록하면 된다는 것입니다. 그런데 뭔가 그 전에 해결되지 않은 문제가 있는 것 같습니다. 애초에 "클래스패스 내에 @Mapper라고 지정한 인터페이스"를 어떻게 찾아낼 수 있을까요? 뭐가 있는지 알아야 등록하든 말든 할건데 말이죠.

 

물론 눈치빠르신 분은 "ClassPathBeanDefinitionScanner를 이용하면 되겠구나" 라고 벌써 생각하셨을 것입니다. 스프링 프레임워크는 클래스패스(Class Path) 내에서 빈 정의(Bean Definition)를 찾아내는 녀석(Scanner)을 제공하고 있는데, 이 클래스가 이름 그대로 ClassPathBeanDefinitionScanner입니다. 

ClassPathBeanDefinitionScanner의 사용방법은 꽤나 단순한데, 다음 다섯 가지 절차를 따르면 됩니다.

  1. ClassPathBeanDefinitionScanner 클래스를 상속받는 클래스를 하나 정의한다.
  2. 찾고자 하는 대상(여기서는 @Mapper로 지정된 인터페이스)을 구분해 줄 필터(Filter)를 등록한다.
  3. 검색된 대상이 우리가 찾는 대상이 맞는지 확인해주는 isCandidateComponent 메소드를 재정의한다.
  4. super.doScan 메소드를 수행하여 BeanDefinitionHolder 목록을 얻는다.
  5. 얻어진 BeanDefinitionHolder 목록을 이용하여 BeanDefinition에 대한 후속조치를 한다.

뭔가 복잡해보이지만, 코드로 확인해보면 한결 쉬워 보일 것입니다.

CustomClassPathBeanDefinitionScanner
public class CustomClassPathBeanDefinitionScanner extends ClassPathBeanDefinitionScanner{
	public CustomClassPathBeanDefinitionScanner (BeanDefinitionRegistry registry){
		super(registry);
		initialize();
	}
 
	private void initialize(){
		// @Component 등의 stereo-type 어노테이션이 지정된 빈들을 스킵하기 위해 기본 필터를 사용하지 않도록 리셋한다.
		resetFilters(false);
		// @Mapper 어노테이션을 지정한 클래스 혹은 인터페이스를 찾도록 필터를 등록한다.
		addIncludeFilter(new AnnotationTypeFilter(Mapper.class));
	}
 
	@Override
	protected boolean isCandidateComponent(AnnotatedBeanDefinition beanDefinition){
		// 검색된 대상이 인터페이스이고 nested 클래스가 아니면 추가 대상이다.
 		return beanDefinition.getMetadata().isInterface() && beanDefinition.getMetadata().isIndependent();
	}
 
	/**
	 * super.doScan(...)을 수행하고 그 결과에 대해 후속조치를 수행하는 메소드를 정의한다.
	 */
	public void scanAndUpdateBeanDefinition(){
		// @Mapper 인터페이스들이 위치한 패키지가 com.x2commerce.backoffice.app.dao라고 가정한다.
		Set<BeanDefinitionHolder> definitionHolders = super.doScan("com.x2commerce.backoffice.app.dao");
 
		for(BeanDefinitionHolder definitionHolder : definitionHolders){
			// TODO definitionHolder.getDefinition()에 대해 후속조치를 수행한다.
		}
	}
}

후속조치에 대한 내용은 다음 장에서 다루기로 하고, 이제 작성된 CustomClassPathBeanDefinitionScanner를 이용하도록 CustomBeanDefinitionRegistryPostProcessor 코드를 수정하면 다음과 같이 됩니다.

수정된 CustomBeanDefinitionRegistryPostProcessor 의 postProcessBeanDefinitionRegistry 메소드
	//.... 생략
 
	@Override
	public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry){
		new CustomClassPathBeanDefinitionScanner(registry).scanAndUpdateBeanDefinition();
	}
 
	//.... 생략

위와 같이 설정하면 스프링이 최종 빈 정의를 취합할 때 CustomClassPathBeanDefinitionScanner가 검색한 @Mapper 인터페이스도 추가될 수 있습니다.


 

3. Java Proxy, Factory Bean 그리고 GenericBeanDefinition

이제 CustomClassPathBeanDefinitionScanner에 의해 검색된 BeanDefinition에 대해 "어떤 후속작업"을 거쳐야 스프링 빈으로 와이어될 수 있습니다. 그도 그럴것이, @Mapper 어노테이션으로 지정되어 검색된 BeanDefinition은 인터페이스에 대한 정보이며 인터페이스는 스프링 빈으로 인스턴스화 될 수 없기 때문입니다. 인스턴스화될 수 없는 빈 정의가 BeanDefinitionRegistry에 등록되어 있는 경우 스프링 프레임워크는 로딩 과정에서 오류를 출력하고 멈추게 됩니다.

 

결과적으로 이 "후속작업"를 위해 수행해야 할 몇 가지 작업을 나열하면 다음과 같습니다.

  1. 사전작업: @Mapper 인터페이스 구현체가 어떻게 동작할지 정의해야 한다. 
  2. 사전작업: 검색된 @Mapper 인터페이스에 대한 구현체를 동적으로 생성할 수 있는 무엇인가가 필요하다.
  3. 후속작업: 그 '무엇인가'에 대한 정보를 BeanDefinition에 등록해야 한다.

순서대로 차근차근 구현해 보겠습니다.

 

먼저 첫 번째 사전작업, @Mapper 인터페이스 구현체가 어떻게 동작할지 정의하는 문제입니다. 

@Mapper 인터페이스는 Mybatis의 SqlSessionTemplate을 이용하여 Sql-Mapping을 수행합니다. 즉, @Mapper 인터페이스가 어떤 메소드를 선언하고 있건 간에, 기본적인 동작 방식은 동일하다는 얘기입니다. 그래서 이 때 사용되는 것이 바로 Java Proxy입니다. Mybatis는 MapperProxy라는 InvocationHandler 구현체를 이용하여 @Mapper 인터페이스의 구현체를 동적으로 생성합니다. 

Java Proxy에 대한 상세한 설명은 Proxy 패턴, 그리고 Dynamic Proxy API 문서를 참고하시기 바랍니다.

상세한 구현 내용은 생략하고, MapperProxy의 코드를 살짝 살펴보면 다음과 같습니다.

Mybatis의 MapperProxy 클래스 코드 일부
public class MapperProxy<T> implements InvocationHandler, Serializable {
  private static final long serialVersionUID = -6424540398559729838L;
  private final SqlSession sqlSession;
  private final Class<T> mapperInterface;
  private final Map<Method, MapperMethod> methodCache;

  public MapperProxy(SqlSession sqlSession, Class<T> mapperInterface, Map<Method, MapperMethod> methodCache) {
    this.sqlSession = sqlSession;
    this.mapperInterface = mapperInterface;
    this.methodCache = methodCache;
  }

  @Override
  public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
    if (Object.class.equals(method.getDeclaringClass())) {
      try {
        return method.invoke(this, args);
      } catch (Throwable t) {
        throw ExceptionUtil.unwrapThrowable(t);
      }
    }
    final MapperMethod mapperMethod = cachedMapperMethod(method);
    return mapperMethod.execute(sqlSession, args);
  } 
  // 이하 생략
}

자세한 내용까지 알 필요는 없지만, MapperProxy가 InvocationHandler 인터페이스를 구현하고 있고, 그래서 invoke 메소드를 재정의하고 있다는 것을 알 수 있습니다. 결과적으로 @Mapper 인터페이스의 특정 메소드를 호출하면 이 invoke 메소드가 호출될 것임을 추측할 수 있고, 이렇게 첫 번째 사전 작업이 마무리되었습니다.

 

다음으로 이 MapperProxy를 동적으로 생성해 줄 객체를 생성하는 문제입니다. MapperProxy는 동적으로 생성된 @Mapper 인터페이스가 어떻게 동작할지만을 결정할 뿐, 실제 어떻게 생성될지에 대해서는 설명하고 있지 않습니다. 즉, MapperProxy를 동적으로 생성하는 주체가 필요하다는 얘기입니다.

스프링 프레임워크에서는 이를 위해, FactoryBean 인터페이스를 구현한 클래스를 이용하여, 빈 생성 시 해당 객체를 이용하도록 지정할 수 있습니다. FactoryBean 클래스의 구현 코드는 다음과 같습니다.

FactoryBean 인터페이스 구현체 - MapperProxyFactoryBean
public class MapperProxyFactoryBean<T> implements FactoryBean<T>{
	private Class<T> mapperInterfaceType;
 
	public MapperProxyFactoryBean(Class<T> mapperInterfaceType){
		this.mapperInterfaceType = mapperInterfaceType;
	}
 
	@Override
	public T getObject() throws Exception{
		//이해를 돕기 위해 최대한 생략함..
		return (T) Proxy.newProxyInstance(classLoader, new Class<?>{mapperInterfaceType}, new MapperProxy(sqlSession, mapperInterfaceType, methodCache));
	}
 
	@Override
	public Class<T> getObjectType(){
		return mapperInterfaceType;
	}
 
	@Override
	public boolean isSingleton(){
		return true;
	}
}

위 코드에서 알 수 있듯이, Java Proxy를 이용하여 mapperInterfaceType에 맞는 Proxy 인스턴스를 동적으로 생성하는 것을 알 수 있습니다. 그래서 @Mapper 인터페이스의 와이어링 대상으로 이 Proxy가 적용될 수 있는 것입니다.

 

이제 마지막으로 지금 생성한 MapperProxyFactoryBean 정보를 BeanDefinition에 등록할 차례입니다. 위에서 구현하다 멈췄던 CustomClassPathBeanDefinitionScanner의 scanAndUpdateBeanDefinition 메소드를 마무리하면 다음과 같습니다.

scanAndUpdateBeanDefinition 구현
public class CustomClassPathBeanDefinitionScanner extends ClassPathBeanDefinitionScanner{
    //.... 생략

	public void scanAndUpdateBeanDefinition(){
		Set<BeanDefinitionHolder> definitionHolders = super.doScan("com.x2commerce.backoffice.app.dao");

		for(BeanDefinitionHolder definitionHolder : definitionHolders){
			GenericBeanDefinition definition = (GenericBeanDefinition)(definitionHolder.getBeanDefinition());
			definition.getConstructorArgumentValues().addGenericArgumentValue(definition.getBeanClassName());
			//BeanDefinition의 생성 클래스로 MapperProxyFactoryBean을 지정한다.
			definition.setBeanClass(MapperProxyFactoryBean.class);
			definition.setAutowireMode(AbstractBeanDefinition.AUTOWIRE_BY_TYPE);
			definition.setLazyInit(true);
		}
	}
}

 

Conclusions

지금까지 Mybatis의 @Mapper 인터페이스가 스프링 빈으로 와이어링 될 수 있는 이유에 대해 살펴보았습니다. 다시 한 번 간략히 설명하면 다음과 같습니다.

  • @Mapper 인터페이스에 대해 BeanDefinitionRegistry에 BeanDefinition을 등록한다.
  • 그러기 위해 ClassPathBeanDefinitionScanner를 이용한다.
  • @Mapper 인터페이스에 대한 Proxy(MapperProxy)를 정의해둔다.
  • MapperProxy를 생성하는 MapperProxyFactoryBean을 정의해둔다.
  • BeanDefinition의 BeanClass로 MapperProxyFactoryBean.class를 지정한다.