파란하늘의 지식창고
반응형

spring-jdbc 소개

spring-jdbc 는 jdbc 관련 기능을 제공하는 라이브러리이다.

DB를 연결하여 사용하기 위해 해야 하는 작업 중 상당 부분을 스프링이 처리해 준다.

https://docs.spring.io/spring-framework/reference/data-access/jdbc.html

Action Spring You
Define connection parameters.   X
Open the connection. X  
Specify the SQL statement.   X
Declare parameters and provide parameter values   X
Prepare and run the statement. X  
Set up the loop to iterate through the results (if any). X  
Do the work for each iteration.   X
Process any exception. X  
Handle transactions. X  
Close the connection, the statement, and the resultset. X  

AbstractRoutingDataSource 구현하기

DataSource 는 물리적 데이터 소스에 대한 connection을 위한 factory이다.

Spring JDBC는 이 DataSource 를 확장한 몇 가지를 제공한다.

AbstractRoutingDataSource 는 lookup key를 기반으로 다양한 대상 DataSource 중 하나로 getConnection() 호출을 routing 하는 abstract DataSource class이다.

동일한 스키마를 가진 master / slave DB에 대한 분기처리를 한다거나 replication DB처럼 여러 DB에 대해 그때그때 조건에 따라 호출을 분기하고자 하는 등의 경우 AbstractRoutingDataSource 를 사용한다.

일반적으로 thread-bound transaction context를 통해 분기를 결정한다.

AbstractRoutingDataSource 를 extends 한 class를 생성하면 determineCurrentLookupKey method를 override 해야 한다.

대략 다음과 같이 구현하였다.

public class RoutingDataSource extends AbstractRoutingDataSource {

    @Override
    protected Object determineCurrentLookupKey() {
        return RoutingDataSourceContextHolder.getContext().getLookupKey();
    }

}

lookupKey를 contextHolder에서 가져오는 형태의 구현이다.

RoutingDataSourceContext, RoutingDataSourceContextHolder 구현하기

따라서 RoutingDataSourceContextRoutingDataSourceContextHolder 도 구현해야 한다.

RoutingDataSourceContext 는 다음과 같다.

@FunctionalInterface
public interface RoutingDataSourceContext {

    String getLookupKey();

}

RoutingDataSourceContextHolder 는 다음과 같다.

@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class RoutingDataSourceContextHolder {

    private static final ThreadLocal<RoutingDataSourceContext> contextHolder = new ThreadLocal<>();

    public static void clearContext() {
        contextHolder.remove();
    }

    public static RoutingDataSourceContext getContext() {
        var context = contextHolder.get();
        if (context == null) {
            context = () -> null;
            contextHolder.set(context);
        }
        return context;
    }

    public static void setContext(RoutingDataSourceContext context) {
        Assert.notNull(context, "Only non-null RoutingDataSourceContext instances are permitted");
        contextHolder.set(context);
    }

}

Data JPA를 사용한 간단한 사용 예

이제 여러 개의 DataSource 를 targetDataSources로 가지고 있는 RoutingDataSource bean을 생성한다.

따로 별도의 bean으로 DataSource를 생성했다고 가정한 예제이다.

@Bean
@Primary
<T extends DataSource> DataSource routingDataSource(Map<String, T> dataSourceMap) {
    var routingDataSource = new RoutingDataSource();
    routingDataSource.setTargetDataSources(dataSourceMap);

    // 예제에선 defaultDataSource를 임의 지정
    routingDataSource.setDefaultTargetDataSource(dataSourceMap.values().stream().findAny().get());
    return routingDataSource;
}

data jpa를 사용하는 경우 entityManagerFactory 생성 시 대상 dataSource를 RoutingDataSource 로 지정하도록 한다.

@Configuration
@EnableJpaRepositories(basePackages = "net.luversof.api.board.**.repository", entityManagerFactoryRef = "boardEntityManagerFactory", transactionManagerRef = "boardTransactionManager")
public class BoardDataJpaConfig {

    @Bean
    LocalContainerEntityManagerFactoryBean boardEntityManagerFactory(
            EntityManagerFactoryBuilder builder, 
            @Qualifier("routingDataSource") DataSource routingDataSource) {
        return builder
                .dataSource(routingDataSource)
                .persistenceUnit("boardPersistenceUnit")
                .packages("net.luversof.api.board.**.domain").build();
    }

    @Bean
    PlatformTransactionManager boardTransactionManager(@Qualifier("boardEntityManagerFactory") LocalContainerEntityManagerFactoryBean boardEntityManagerFactory) {
        return new JpaTransactionManager(boardEntityManagerFactory.getObject());
    }
}

서비스에서 repository를 조회할 때 RoutingDataSourceContextHolder 를 지정하여 호출한다.

@Service
public class BoardService {

    public Board findByAlias(String alias) {
        RoutingDataSourceContextHolder.setContext(() -> "board");
        return boardRepository.findByAlias(alias).orElseThrow(() -> new BlueskyException(BoardErrorCode.NOT_EXIST_BOARD));
    }

}

여기까지 작업을 하였으면 기본적인 방법으로 RoutingDataSource 와 lookupKey 조회를 구현한 것이다.

좀 더 사용하기 편하도록 몇 가지 편의 기능을 추가해 보았다.

RoutingDataSourceLookupKeyResolver 만들기

RoutingDataSourceLookupKeyResolver 는 어떤 lookupkey를 사용할지 결정하는 interface이다.

위에서 특정 service의 method에서 RoutingDataSourceContextHolder 로 직접 지정해서 DB를 선택하였는데 공통으로 관리하기 위해 추가하였다. 다음과 같다.

/**
 * routingDataSource 분기 기준 키 설정
 * @author bluesky
 *
 */
@FunctionalInterface
public interface RoutingDataSourceLookupKeyResolver {

    String getLookupKey();

}

단순히 설정할 lookupKey를 지정하는 interface이다.

이 resolver를 전역으로 사용할 경우도 있고 각 서비스에서 개별로 지정할 경우도 있어서 전역으로 사용할 경우는 이를 상속한 CommonRoutingDataSourceLookupKeyResolver 를 만들었다.

/**
 * 전체 공통으로 사용하는 resovler를 만들 경우 이 interface를 상속함
 * filter에 등록하여 사용함
 * @author bluesky
 *
 */
public interface CommonRoutingDataSourceLookupKeyResolver extends RoutingDataSourceLookupKeyResolver {

}

RoutingDataSourceContextHolderFilter 구현하기

이제 이 CommonRoutingDataSourceLookupKeyResolver 가 전체 적용이 되도록 filter를 구현한다.

@Order(-103)
public class RoutingDataSourceContextHolderFilter extends OncePerRequestFilter {

    private RoutingDataSourceLookupKeyResolver routingDataSourceLookupKeyResolver;

    public RoutingDataSourceContextHolderFilter(@Nullable RoutingDataSourceLookupKeyResolver routingDataSourceLookupKeyResolver) {
        this.routingDataSourceLookupKeyResolver = routingDataSourceLookupKeyResolver;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        try {
            if (routingDataSourceLookupKeyResolver != null) {
                RoutingDataSourceContextHolder.setContext(() -> routingDataSourceLookupKeyResolver.getLookupKey());
            }
            filterChain.doFilter(request, response);
        } finally {
            RoutingDataSourceContextHolder.clearContext();
        }
    }

}

Filter bean을 등록할 때 CommonRoutingDataSourceLookupKeyResolver bean이 있으면 해당 bean을 추가하여 모든 서비스가 공통으로 바라보는 dataSource의 lookupKey를 지정할 수 있도록 해준다.

@Bean
RoutingDataSourceContextHolderFilter routingDataSourceContextHolderFilter(@Nullable CommonRoutingDataSourceLookupKeyResolver routingDataSourceLookupKeyResolver) {
    return new RoutingDataSourceContextHolderFilter(routingDataSourceLookupKeyResolver);
}

@RoutingDataSource annotation과 AOP 만들기

위에서 method 내 직접 지정하는 방법과 전역으로 지정하는 방식을 구현하였다.

하지만 각 서비스 별로 대상을 지정하는 경우가 더 많을 것이다.

이를 위해 대상을 지정할 수 있는 @RoutingDataSource annotation과 해당 annotation에 대한 AOP를 구현해 본다.

@RoutingDataSource annotation은 다음과 같다.

@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
public @interface RoutingDataSource {

    /**
     * lookupKey를 직접 지정하여 사용할 경우 설정
     * @return
     */
    String value() default "";

    /**
     * resolver를 사용할 경우 등록된 resolver beanName을 설정
     * @return
     */
    String resolver() default "";
}

대상 DB를 직접 지정하는 value()와 위에서 정의했던 RoutingDataSourceLookupKeyResolver 를 지정할 수 있는 항목이 있다.

이 annotation에 대한 RoutingDataSourceAspect aop는 다음과 같다.

@Aspect
public class RoutingDataSourceAspect {

    private ApplicationContext applicationContext;

    public RoutingDataSourceAspect(ApplicationContext applicationContext) {
        this.applicationContext = applicationContext;
    }

    @Around("@within(routingDataSource)")
    public Object classAround(ProceedingJoinPoint proceedingJoinPoint, RoutingDataSource routingDataSource) throws Throwable {
        return execute(proceedingJoinPoint, routingDataSource);
    }

    @Around("@annotation(routingDataSource)")
    public Object methodAround(ProceedingJoinPoint proceedingJoinPoint, RoutingDataSource routingDataSource) throws Throwable {
        return execute(proceedingJoinPoint, routingDataSource);
    }

    private Object execute(ProceedingJoinPoint proceedingJoinPoint, RoutingDataSource routingDataSource) throws Throwable {
        if (StringUtils.hasText(routingDataSource.resolver())) {
            var resolver = applicationContext.getBean(routingDataSource.resolver(), RoutingDataSourceLookupKeyResolver.class);
            RoutingDataSourceContextHolder.setContext(resolver::getLookupKey);
        } else if (StringUtils.hasText(routingDataSource.value())) {
            RoutingDataSourceContextHolder.setContext(routingDataSource::value);
        }
        return proceedingJoinPoint.proceed();
    }

}

이제 기존에 method 내에서 직접 지정하던 부분을 다음과 같이 사용할 수 있게 된다.

@Service
@RoutingDataSource("board")
public class BoardService {

    public Board findByAlias(String alias) {
        return boardRepository.findByAlias(alias).orElseThrow(() -> new BlueskyException(BoardErrorCode.NOT_EXIST_BOARD));
    }

}

class가 아닌 각 method 별로 지정해서 사용도 가능하다.

@Service
public class BoardService {

    @RoutingDataSource("board")
    public Board findByAlias(String alias) {
        return boardRepository.findByAlias(alias).orElseThrow(() -> new BlueskyException(BoardErrorCode.NOT_EXIST_BOARD));
    }

}

또한 특정 resolver를 구성한 후 해당 resolver를 등록해서 쓸 수도 있다.

@Service
@RoutingDataSource(resolver="someResolverBeanName")
public class BoardService {

    public Board findByAlias(String alias) {
        return boardRepository.findByAlias(alias).orElseThrow(() -> new BlueskyException(BoardErrorCode.NOT_EXIST_BOARD));
    }

}

다만 이렇게 AOP로 사용할 경우 @Transactional annotation과 같이 사용할 때 올바르게 동작하지 않는 문제가 있다.

@Transactional 은 interceptor로 동작하고 내가 등록한 aop는 interceptor가 동작한 이후 동작한다.
그로 인해 내가 지정한 db를 사용하지 않고 defaultTarget을 사용한다.

이를 방지하기 위해서는 RoutingDataSource 를 등록할 때 LazyConnectionDataSourceProxy 을 감싸서 statement 구문을 실행하는 시점에 begin tran을 하도록 트랜잭션이 동작 시점을 늦춰주는 설정을 해주어야 한다.

RoutingDataSource bean을 다음과 같이 등록해 주면 된다.
(RoutingDataSource bean과 LazyConnectionDataSourceProxy bean을 각각 등록하지 않는 경우 RoutingDataSource를 LazyConnectionDataSourceProxy에 넣기 전에 afterPropertiesSet() method 호출을 해야 함)

@Bean
@Primary
<T extends DataSource> DataSource routingDataSource(Map<String, T> dataSourceMap) {
    var routingDataSource = new RoutingDataSource();
    routingDataSource.setTargetDataSources(dataSourceMap);

    // 예제에선 defaultDataSource를 임의 지정
    routingDataSource.setDefaultTargetDataSource(dataSourceMap.values().stream().findAny().get());

    routingDataSource.afterPropertiesSet();
    return new LazyConnectionDataSourceProxy(routingDataSource);
}

테스트해 보면서 몇 가지 신경 써야 하는 부분이 있었다.

OSIV 관련 RoutingDataSourceContextHolder set 여러 번 지정 시 두 번째 이후 connection은 무시됨

테스트 코드에선 RoutingDataSourceContextHolder 로 targetDataSource의 변경을 여러 번 하면서 호출이 정상 동작하였지만

웹 요청의 경우 @Transactional 선언을 하지 않더라도 OpenEntityManagerInViewFilterOpenEntityManagerInViewInterceptor 가 동작하여 thymeleaf나 json objectMapper의 parsing 시 entity 호출을 위해 암시적으로 transaction 완료 처리를 지연하려고 한다.
이로 인해 RoutingDataSourceContextHolder 의 set을 통해 target dataSource 변경을 하여도 처음 설정한 target DataSource만 보고 더 이상 변경하지 않았다.

관련해서 해결 방법으로 openEntityManagerInView 처리를 비활성화하거나 혹은 DataSource가 connection을 캐싱하는 부분을 statement 실행 시마다 해제하도록 설정하는 방법이 있다.

spring.jpa.open-in-view=false

# 또는

spring.jpa.properties.hibernate.connection.handling_mode=DELAYED_ACQUISITION_AND_RELEASE_AFTER_STATEMENT

두 방법의 장단점을 각각 찾아보고 설정할지 여부를 고려해보아야 할 것이다.

(open in view 처리는 OSIV 관련하여 검색해보면 될 듯하고 connection handling mode는 검색해도 자료가 그리 많지 않았다...)

@Transactional 선언과 RoutingDataSourceContextHolder set 복수 동작 문제

RoutingDataSourceContextHolder 의 set을 통해 target datasource 지정을 여러 번 변경하려는 로직은 명시적인 @Transactional 선언 내에서 하지 말아야 한다.

웹 요청의 경우(OSIV 동작의 경우) 여러 DB 조회 시 동일 domain의 동일 id를 가진 결과에 대해 처음 호출 entity 사용

웹 요청 시 여러 DB에서 findByXXX 같은 method를 통해 가져온 동일 domain을 사용하는 두 entity가 같은 id를 가지고 있는 경우 처음 호출한 값을 사용하였다.
(id 기준 조회가 아닌 두 entity가 동일 id를 가진 경우에도 처음 조회한 entity로 처리)

하지만 test code 실행 시(OSIV가 적용되지 않은 경우)엔 개별 결과가 의도된 대로 반환되었다.

그렇게 사용할 경우가 있을 것 같지 않지만 이런 케이스도 발견되어 기록을 남겨둔다.

반응형
profile

파란하늘의 지식창고

@Bluesky_

내용이 유익했다면 광고 배너를 클릭 해주세요