- 멀티모듈로 헥사고날 구현[1] - 모듈 구성
- 멀티모듈로 헥사고날 구현[2] - application layer를 pojo로 구성하기
비즈니스 로직을 포함하는 application layer를 프레임워크나 라이브러리 의존 없이 pojo로 구현하게 되면 외부 환경변화로 부터 비즈니스 로직을 지킬 수 있는 큰 장점을 얻을 수 있다.
프레임워크, 라이브러리, 연동 소프트웨어가 바뀌더라도 비즈니스 로직은 수정할 필요가 없어진다.
하지만 프레임워크나 라이브러리에서 지원하는 편리한 기능을 사용하지 못한다는 것은 단점이다.
개발을 하며 반드시 필요로하다고 생각했던 기능은 DI이다.
스프링의 DI를 활용하면 의존관계를 new 연산자와 같은 코드를 통해 직접 설정하지 않고 어노테이션 기반으로 설정할 수 있다.
의존관계는 변경사항이 발생할 가능성이 굉장히 높기에 이를 코드로 모두 표현하게 되면 유지보수해야할 영역이 굉장히 늘어난다고 생각하여 DI는 제공해야할 기능이라고 생각했다.
또한 개인적으로 비즈니스 로직과 의존관계 설정 코드가 복잡하게 뒤엉켜 있는 구조는 좋지 않다고 생각했기에, 의존관계 설정은 분리시켜야할 필요가 있다고 생각했다.
DI 뿐만 아니라 트랜잭션 기능 구현을 하였는데, 함께 설명하겠다.
소개
어떻게 구성하고 동작되는지 간단하게 설명하겠다.
먼저 application 모듈에 커스텀 어노테이션을 작성한다.
- UseCase : 스프링의 Component 역할
- Priority : 스프링의 Primary 역할
- Aliases : 스프링의 Qualifier 역할
- TXExcute : 트랜잭션 기능 지원
위 어노테이션은 모두 application 모듈에서 순수 pojo 코드로 작성된 것이다.
DI를 제공하는 모듈에서 위 어노테이션이 부착된 클래스를 스캔하여 스프링 빈으로 등록하면 pojo로 구현된 모듈에서도 DI를 제공받을 수 있다.
아래와 같이 pojo 어노테이션을 스프링 어노테이션처럼 동작하도록 할 수 있다.
어노테이션 기반이므로 비즈니스 로직을 표현한 프로그래밍 코드에는 전혀 영향을 주지 않는다.
@UseCase
class MemberProfileWriteService(
private val memberProfileReadOrmPort: MemberProfileReadCase,
private val memberProfileWriteOrmPort: MemberProfileWriteOrmPort,
private val sysGarbageFileWriteOrmPort: SysGarbageFileWriteOrmPort,
) : MemberProfileWriteCase {
@TXExecute
override fun updateProfileTypeByMemberId(memberId: UUID, profileType: ProfileType) {
memberProfileWriteOrmPort.updateProfileTypeByMemberId(memberId, profileType)
}
}
DI와 트랜잭션을 제공하는 모듈을 아래와 같이 system-construction이라는 모듈로 정의하였다.
system-construction이 application 모듈을 의존하여 application 패키지를 스캔하여 기능을 제공한다.
dependencies {
implementation(project(":project:app-service"))
implementation(project(":project:app-domain"))
}
참고로, 리플렉션을 사용하면 의존성을 완전히 제거 시킬 수 있다.
하지만 리플렉션 사용시 코드도 지저분해질 뿐만 아니라 변경사항에 너무 예민하여 관리하기 어렵다.
리플렉션의 문자열 인자값을 사용하는 방식은 타입체크와 같은 컴파일러를 활용하지 못하기에 큰 단점이다.
나또한 의존 구조에서 리플렉션으로 전환했다가 관리하기 어려워 다시 의존하는 구조를 갖추게 하였다.
DI 기능 구현
1. Config 설정
스캔 대상인 application 모듈의 최상위 루트와 DI를 적용할 커스텀 어노테이션을 상수로 선언한 Config 파일이다.
(상수 파일을 따로 만들어 코드 중복을 막기 위한 목적이다.)
object CustomDIAnnotationBeanConstConfig {
// 사용자 정의 빈 등록 대상 어노테이션
val CUSTOM_BEAN_ANNOTATION = UseCase::class
// 스캔 대상이 될 기본 패키지 경로들. 컴마(,) 로 구분하여 복수개 패키지 경로 설정 가능
const val BASE_PACKAGES = "com.kamcci.numberbox.app"
// 빈 스코프 정의
const val BEAN_SCOPE = "singleton"
// 상위 타입의 하위 구현체 중 최우선 순위를 주기 위해 사용할 어노테이션(like @Primary)
val CUSTOM_PRIMARY_ANNOTATION = Priority::class
// 상위 타입의 하위 구현체들에게 각각 별칭을 주기 위해 사용할 어노테이션(like @Qualifier)
val CUSTOM_QUALIFIER_ANNOTATION = Aliases::class
}
2. BeanFactoryPostProcessor의 구현
BeanFactoryPostProcessor는 스프링 빈 인스턴스화 이전에 실행되며 BeanDeifnition 생성 및 변경의 책임을 갖는다.
스프링 빈은 BeanDefinitioin 기반으로 생성된다.
(BeanDefinition은 클래스 타입, Primary, scope, Qualifier, lazy와 같은 스프링 빈 관리 속성이다.)
따라서 우리는 빈 등록 대상의 BeanDefinition만 생성하여 등록하면 이후 인스턴스화 및 관리는 스프링의 기존 프로세스를 그대로 사용할 수 있다.
@Component
class AnnotationBeanFactoryPostProcessor(
private val annotationCapableBeanRegistrar: AnnotationCapableBeanRegistrar = AnnotationCapableInstanceFactory.getAnnotationCapableBeanFactory(),
private val qualifierAnnotationRegistrar: QualifierAnnotationRegistrar = AnnotationCapableInstanceFactory.getDICapableAnnotationResolver(),
) : BeanFactoryPostProcessor {
override fun postProcessBeanFactory(beanFactory: ConfigurableListableBeanFactory) {
// 커스텀 어노테이션 붙은 클래스 beanDefinition으로 생성
val registry = beanFactory as BeanDefinitionRegistry
annotationCapableBeanRegistrar.registerOnlyWith(CUSTOM_BEAN_ANNOTATION, BASE_PACKAGES, registry)
// 의존(참조속성)관계 설정에 @Qualifier처럼 사용될 어노테이션 타입 지정
qualifierAnnotationRegistrar.add(CUSTOM_QUALIFIER_ANNOTATION, beanFactory as DefaultListableBeanFactory)
}
}
3. BeanDefinition 등록
AnnotationCapableBeanRegistrar라는 인터페이스는 내가 정의한 인터페이스이다.
BeanFactoryPostProcessor에 모든 것을 구현하면 코드가 비대해질까봐 BeanDefinition 속성을 추출하고 등록하는 로직을 분리하였다.
class CustomAnnotationCapableBeanRegistrar(
private val beanDefinitionPropertyProcessor: BeanDefinitionPropertyProcessor,
) : AnnotationCapableBeanRegistrar {
@Throws(BeansException::class)
override fun registerOnlyWith(
customBeanAnnotation: KClass<out Annotation>,
basePackages: String,
registry: BeanDefinitionRegistry,
) {
// annotation filter 등록
val componentScanner = ClassPathScanningCandidateComponentProvider(false)
val annotationFilter = AnnotationTypeFilter(customBeanAnnotation.java)
componentScanner.addIncludeFilter(annotationFilter)
// basePackage에 대하여 빈 후보군 beanDefinition 추출
val basePackageArr = basePackages.split(",").map { it.trim() }
val beanDefOfCandidates = basePackageArr.flatMap { basePackage ->
componentScanner.findCandidateComponents(basePackage)
}.toSet()
// 빈 후보들에 대하여 빈 정의 설정 및 등록
for (beanDef in beanDefOfCandidates) {
val beanClass = Class.forName(beanDef.beanClassName)
val beanName = beanClass.simpleName.apply { this[0].lowercase() }
val beanDefBuilder = BeanDefinitionBuilder.genericBeanDefinition(beanClass)
// manager에게 beanDefinition 조작 위임
beanDefinitionPropertyProcessor.modify(beanClass, beanDefBuilder)
// 빈 등록
registry.registerBeanDefinition(beanName, beanDefBuilder.beanDefinition)
}
}
}
스캐너가 application 모듈을 스캔하여 DI 어노테이션 부착여부를 판단하고
어노테이션이 부착되었다면 BeanDefinition을 생성한다.
이때, Primary나 Qualifier와 같은 역할을 담당하는 어노테이션의 존재여부도 판단하여 해당 속성도 beanDefintion 인스턴스에 정의한다.
마지막으로 beanDefinitionRegistry에 등록하면 끝이다.
beanDefinitionRegistry에 beanDefinition이 등록되면 스프링은 registry에 저장된 beanDefintion을 기반으로 스프링 빈을 인스턴스화하고 singletonRegistry에 등록하는 절차를 수행한다.
따라서 스프링 빈으로 등록되어 DI 기능을 적용할 수 있느 ㄴ것이다.
4. Custom Qualifier 타입 설정
Qualifier 처럼 사용하기 위한 커스텀 어노테이션을 설정하는 구현체이다.
beanFactory의 autowireCandidateResolver 속성을 가져와 커스텀 어노테이션을 추가하면 된다.
class CustomQualifierAnnotationRegistrar : QualifierAnnotationRegistrar {
// 의존주입시 customAnnot도 qualifier 처럼 사용될 수 있게 Qualifier 타입에 CUSTOM_QUALIFIER_ANNOTATION 타입 추가
override fun add(customAnnot: KClass<out Annotation>, beanFactory: DefaultListableBeanFactory) {
val qualifierResolver = beanFactory.autowireCandidateResolver
if (qualifierResolver is QualifierAnnotationAutowireCandidateResolver) {
qualifierResolver.addQualifierType(Qualifier::class.java)
qualifierResolver.addQualifierType(customAnnot.java)
}
}
}
[참고]
BeanFactoryPostProcessr를 스프링 빈 생성 이전에 수행되므로 스프링 빈을 의존 주입 받을 수 없다.
따라서 나는 아래와 같이 별도의 Factory 클래스를 두어 BeanFactoryPostProcessr의 의존관계를 설정하였다.
object AnnotationCapableInstanceFactory {
fun getAnnotationCapableBeanFactory(): AnnotationCapableBeanRegistrar {
val beanDefinitionModifyByAnnotManager =
AnnotationBeanDefinitionPropertyProcessor()
return CustomAnnotationCapableBeanRegistrar(beanDefinitionModifyByAnnotManager)
}
fun getDICapableAnnotationResolver(): QualifierAnnotationRegistrar {
return CustomQualifierAnnotationRegistrar()
}
}
트랜잭션 기능 구현
우선 나의 커스텀 트랜잭션은 아래와 같이 이루어져있다.
@Target(ElementType.TYPE, ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
annotation class TXExecute(
val readOnly: Boolean = false,
val propagation: Propagation = Propagation.REQUIRED,
val isolation: Isolation = Isolation.DEFAULT
)
/**
* 트랜잭션 전파 수준
*/
enum class Propagation(val id: Int) {
/**
* 부모 트랜잭션 존재한다면 합류, 그렇지 않으면 새로운 트랜잭션 생성
* 부모나 자식 예외시 둘다 롤백
*/
REQUIRED(0),
// 트랜잭션을 필요로 하지 않지만 진행 중인 트랜잭션이 존재하면 트랜잭션 사용(트랜잭션 미 존재시에도 메소드 정상 동작)
SUPPORTS(1),
// 부모 트랜잭션에 합류(부모 트랜잭션 미존재시 예외 발생)
MANDATORY(2),
// 무조건 새로운 트랜잭션 생성(nested한 방식이라도 롤백은 각각 이루어짐)
REQUIRES_NEW(3),
// 트랜잭션 중단(예외 발생은 안함)
NOT_SUPPORTED(4),
// 트랜잭션 사용 안함(진행 중 트랜잭션 존재시 예외 발생)
NEVER(5),
/**
* REQUIRED 처럼 동작
* But, 자식 예외 부모에 영향 안줌(부모 예외시에만 자식까지 롤백)
*/
NESTED(6);
}
/**
* 데이터베이스 트랜잭션 고립성 수준
*/
enum class Isolation(val id: Int) {
// DB 디폴트 고립성 수준 그대로 사용
DEFAULT(-1),
// 아직 커밋 되지 않은 다른 트랜잭션의 변경사항 보임
READ_UNCOMMITTED(1),
// 커밋된 다른 트랜잭션의 변경사항만 보임, Oracle defualt
READ_COMMITTED(2),
// 다른 트랜잭션이 커밋을 해도 변경 데이터가 보이지 않음(나의 트랜잭션이 시작할 시점의 DB 스냅샷 시점), Mysql default
REPEATABLE_READ(4),
// 트랜잭션 작업 직렬화(동시 진행 트랜잭션 없음)
SERIALIZABLE(8)
}
1. 트랜잭션 속성 추출 및 적용
트랜잭션 기능은 스프링의 PlatformTransactionManager를 통해 제공하므로
트랜잭션 속성을 추출하여 PlatformTransactionManager에 전달하여 주면 된다.
class SystemConstructionTXAdvice(
private val transactionManager: PlatformTransactionManager,
) : MethodInterceptor {
override fun invoke(invocation: MethodInvocation): Any? {
// 타킷 메서드에 적용된 어노테이션 추출
val method = invocation.method
val customTXAnnotation = method.getAnnotation(CUSTOM_TX_ANNOTATION.java)
// 타킷 클래스에 적용된 어노테이션 추출
val targetClass = method.declaringClass
val classAnnotation = targetClass.getAnnotation(CUSTOM_TX_ANNOTATION.java)
// 어노테이션은 메서드 -> 클래스 순으로 우선순위
val txAnnotation =
when {
customTXAnnotation != null -> customTXAnnotation
classAnnotation != null -> classAnnotation
else -> null
}
// 어노테이션에 적용된 트랜잭션 속성 객체 생성
val txDefinition =
if (txAnnotation != null) {
val txDefinition = DefaultTransactionDefinition()
txDefinition.isolationLevel = customTXAnnotation.isolation.id
txDefinition.propagationBehavior = customTXAnnotation.propagation.id
txDefinition.isReadOnly = customTXAnnotation.readOnly
transactionManager.getTransaction(txDefinition)
} else {
// 어노테이션 미존재시 디폴트 값 선언
transactionManager.getTransaction(DefaultTransactionDefinition())
}
try {
val returnVal = invocation.proceed()
transactionManager.commit(txDefinition)
return returnVal
} catch (e: RuntimeException) {
transactionManager.rollback(txDefinition)
throw e
} catch (e: Exception) {
transactionManager.rollback(txDefinition)
throw e
} catch (e: Throwable) {
transactionManager.rollback(txDefinition)
throw e
}
}
}
2. Config 설정
Custom 트랜잭션 어노테이션을 포인트컷으로 지정하고 트랜잭션 처리가 구현된 클래스를 advice로 지정하여 advisor 객체로 등록하면 트랜잭션 기능을 지원 받을 수 있다.
@Configuration
class SystemConstructionTxConfig(
// txMananger의 구현체는 모듈 사용처의 구현체가 주입됨
private val txManager: PlatformTransactionManager,
) {
@Bean
@Qualifier("txMethod")
fun txAnnotationPointcut(): Pointcut {
return AnnotationMatchingPointcut(null, CUSTOM_TX_ANNOTATION.java, true)
}
@Qualifier("txClass")
@Bean
fun customTransactionAdvice(): MethodInterceptor {
val customTxAdvice = SystemConstructionTXAdvice(txManager)
return customTxAdvice
}
@Bean
fun customTransactionAdvisor(): Advisor {
return DefaultPointcutAdvisor(txAnnotationPointcut(), customTransactionAdvice())
}
}
설명은 간략하게 하였다.
주석과 소스 코드를 읽어보면 충분히 이해할 수 있을 것이라고 생각한다.
정리
외부 모듈에서 완벽하게 DI와 트랜잭션 처리를 구현하여 application 모듈에서 아무런 의존성 없이 순수 pojo 코드로 기능을 적용 받게 하였다.
위와 같은 구조를 갖추며 겪었던 경험을 공유하겠다.
system-construction 모듈을 만든 이유 : 구동과 동작의 관심사 분리
application 모듈에서 프레임워크나 라이브러리 의존성을 제거하기 위해 system-construction 모듈을 만들긴하였지만,
또다른 이유가 하나 더 있다.
과거 클린코드라는 책에서 프로그램의 구동 목적 코드와 동작 목적 코드는 분리 되어야 한다는 내용을 읽은적이 있다.
구동 영역과 동작 영역을 분리하게되면 개발자는 동작(비즈니스 행위)에 더욱 초점을 맞춘 개발이 가능하다는 것이다.
그 예시로 든 것이 DI 였다.
해당 내용 대해 굉장히 새롭고 깊은 인상을 받아 나또한 system-construction라는 별도 모듈을 만들어 DI 기능을 지원하게 했다.
이를 통해 application 모듈에 비즈니스 로직을 표현한 코드만 남기게 되었다.
system-construction 모듈 구조의 장점 : OCP
만약 DI나 트랜잭션 처리가 지금 보다 좋은 방법이 나타나 변경을 필요로 하면 어떻게 되나?
새로운 모듈을 만들어 기존 system-construction 모듈을 비활성화하여 제공하면 된다.
모듈 단위의 OCP 구조를 갖출 수 있다.
의존성의 방향이
application layer -> system-construction이 아니라 system-construction -> application layer 이므로
그 어떤 외부의 변경사항도 application 모듈에 영향을 주지 않고, 심지어 외부 모듈의 변경사항도 기존 모듈에 영향을 주지 않고 확장시켜 나갈 수 있다.
'Design pattern' 카테고리의 다른 글
멀티모듈로 헥사고날 구현[1] - 모듈 구성 (0) | 2024.03.10 |
---|---|
템플릿 메서드 패턴, 전략 패턴, 템플릿/콜백 패턴 비교 분석 (1) | 2024.02.17 |
Hexagonal 아키텍쳐 (2) | 2023.12.18 |