Spring으로부터 도메인 모듈을 보호하는 방법

Solomon Maeng
11 min readFeb 15, 2022

--

주의: 도메인 모델과 영속(JPA) 모델을 분리하는 내용에 대해서는 다루지 않으며, 글의 내용이 주관적일 수 있으므로 참고만 해주시면 좋겠습니다 :)

최근 들어, Clean Architecture와 Domain Driven Design에 대한 Java 개발자들의 관심도가 매우 높아졌습니다.

언급된 두 접근 방식의 본질은 복잡한 비즈니스 로직을 코드로 표현하고 급변하는 요구사항을 유연하게 대처하기 위함이라고 생각합니다.

어떻게 해야 복잡한 비즈니스 로직을 코드로 표현하고 급변하는 요구사항에유연하게 대처할 수 있을까요?

위와 같은 상황을 대처하는 첫 걸음은, 우리의 비즈니스 로직이 담긴 도메인 모듈, 혹은 코드 베이스를 변경에 취약한 외부 의존성으로부터 보호하는 것이 시작이라고 생각합니다.

이러한 맥락에서 오늘 저는 Spring을 효과적으로 사용하면서도 도메인 모듈을 보호할 수 있는 몇 가지 지침들에 대해 소개할까 합니다.

목차는 다음과 같습니다.

  • 도메인 모듈에서 JPARepository 상속을 하면 안됩니다.
  • 도메인 로직에 @Transactional 을 사용하지 마세요.
  • @Component 를 남용하지 말고, 규칙을 정해서 사용해주세요.

도메인 모듈에서 JPARepository 상속을 하면 안됩니다.

Domain Driven Design으로 인해 ORM의 활용도가 각광받는 이 시점에서, 많은 Java 개발자들이 Spring Data JPA를 사용하고 있지만 도메인 모듈을 보호하지 못한 채 테스트하기 어려운 코드를 만들어나가는 부분에 대해서 언급합니다.

(DDD로 인해 ORM의 활용도가 각광 받는다는 부분은 개인적 견해입니다. DDD가 ORM을 사용해야만 할 수 있는 것도 아니며, DDD와 ORM이 궁합이 잘 맞는 것도 아니라고 생각하지만 전술적 구현의 일환으로 JPA를 사용하는 분들을 종종 보았기에 해당 내용을 넣었습니다.)

먼저, 아래의 코드를 확인해보겠습니다.

이 개체는 클라이언트로부터 입력받은 id, email, password를 통해 몇가지 검증을 처리한 후 계정을 생성하고 등록하는 단순한 비즈니스 로직을 가지고 있습니다.

만약 AccountRepository가 JPARepository를 상속한 상황이라면 이 코드를 어떻게 단위 테스트해야 할까요?

(혹여나 단위 테스트 방법으로 @DataJpaTest 나 @SpringBootTest 를 먼저 생각했다면, 그것은 단위 테스트가 아니기 때문에 논의하지 않습니다)

저는 도메인 모듈 단위 테스트를 할 때 행위를 검증하는 것이 아닌 아래와 같이 상태를 검증하는 테스트를 선호하는 편입니다.

(Mock을 사용한 테스트를 선호하지 않는 이유는 코드 변경에 취약하다고 생각하기 때문입니다. 이와 관련한 내용은 최근에 출간된 “단위 테스트” 라는 책을 읽어보시길 권합니다.)

테스트 코드를 구성하고 AccountRepository의 Stub을 구현하려고 implement를 하였더니 다음 화면을 마주할 수 있었습니다.

저는 테스트에 필요한 existsByUserId(), existsByEmail(), save() 세 가지 메서드만 Stub으로 대체하려고 하였는데, JPARepository부터 시작해서 PagingAndSortRepository, CrudRepository에 정의된 모든 메서드를 구현해야했습니다.

단위 테스트하기 위해서 의존성을 구성하는 것부터가 이렇게 어려우니 테스트하기가 싫어지고 있습니다.

이상한 점은, 저는 분명 비즈니스 로직을 테스트하려고 단위 테스트를 작성 중인데, Spring Data JPA의 메서드를 구현하는 상황에 빠졌습니다.

다음은 SOLID 원칙 중 하나인 DIP(의존 역전 원칙)에 기술된 내용입니다.

고수준 모듈은 저수준 모듈의 구현에 의존해서는 안 된다. 저수준 모듈이 고수준 모듈에서 정의한 추상 타입에 의존해야 한다.

결과적으로 우리의 도메인 모듈이 저수준 모듈의 구현에 의존하여 변경에 취약하고 테스트하기 어렵다고 signal을 보내고 있습니다.

그렇다면 어떻게 해야 할까요? 답은 Repository 개체에서 JPARepository 의존성을 제거하는 것 뿐입니다.

의존성을 제거 후 아래와 같이 테스트를 작성할 수 있었습니다.

테스트는 잘 통과하며, 작성한 테스트는 변경에 취약하지 않습니다.

여기까지 보고나니, 이런 반문을 할 수도 있겠습니다.

JPARepository의 편리한 기능들을 사용하지 못하면 Spring Data JPA를 사용하는 것이 의미가 있을까요?

네, 없습니다. 그래서 저는 Spring Data JPA 의존성을 도메인 모듈에서 사용하지 않습니다.

대신, 도메인 모듈에서 Javax.Persistence API와 Hibernate-Core 의존성은 불가피하게 사용하고 있습니다.

(위에서 언급드렸듯이 도메인 모델과 영속 모델을 분리하는 부분에 대해서는 다루지 않습니다)

그렇다면 실무에서의 복잡한 조회 요구사항은 어떻게 처리하고 계시나요? 라고 물어볼 수 있겠습니다.

답은 의외로 간단합니다. CQRS를 공부해보세요.

도메인 로직에 @Transactional 을 사용하지 마세요.

스프링의 @Transactional 을 도메인 로직에 사용하면 어떠한 문제가 발생할 수 있을까요?

저는 아래와 같은 문제가 있다고 생각합니다.

  • CGLIB 프록시 사용으로 인한 도메인 클래스 선언부에 final을 제거해야하므로 도메인 코드에 불필요한 변경을 가한다.
  • CGLIB이건 JDK Dynamic 이건 Self Invocation 문제를 피해갈 수 없다.
  • 개발자가 유연하게 트랜잭션의 경계를 조절할 수 없다.

기본적으로 스프링 AOP는 CGLIB 프록시 기반이므로 @Transactional 애노테이션을 붙이게되면 클래스 선언 부에 final을 제거해야 합니다.

도메인 코드가 스프링을 사용하면서 변경 되어야 하는게 과연 올바른 상황일까요?

저는 올바르지 않다고 생각합니다.

혹여나 JDK Dynamic Proxy를 사용하면 final을 제거하지 않아도 된다고 얘기할 수 있겠습니다만, 아래에서 후술할 문제가 더욱 심각합니다.

많은 Java 개발자들이 Spring의 @Transactional 을 사용하면 반드시 꼭 알아야 하는 문제가 있습니다.

바로 스프링 AOP의 Self Invocation입니다.

이와 관련하여 저는 최근 스프링 AOP의 Self Invocation 문제에 대한 글을 하나 읽었습니다.

해당 글에서는 Self Invocation 문제가 왜 발생하는지 잘 설명되어 있었고 @Transactional 을 사용하면서 Self Invocation을 해결하는 방법에 대해 아래와 같이 소개하고 있었습니다.

  • Spring의 Proxy 기반 AOP가 아닌 AspectJ를 사용해라.
  • 클래스 내 로직을 AOP로 완전히 묶어라.
  • Self Invocation이 발생하지 않도록 최대한 분리해라.

과연 이것이 정말 Self Invocation을 해결할 수 있을까요? 다른 방법은 없는 것일까요?

Spring 팀은 이미 해당 문제를 손쉽게 해결하면서도 성능을 보장하고 개발자로 하여금 더욱 세밀한 트랜잭션의 경계를 조절할 수 있도록 하는 Programmatic한 방법을 제공하고 있습니다.

바로 TransactionTemplate을 사용하는 것입니다.

TransactionTemplate은 Spring에서 Programmatic하게 트랜잭션을 적용할 수 있는 방법 중 하나로써, 명령형(Imperative) 흐름에서는 TransactionTemplate이 권장되고 있고, 반응형(Reactive)에서는 TransactionalOperator가 권장되고 있습니다.

앞에서 언급한 Self Invocation 외에도 @Transactional 을 붙이게 되면 트랜잭션의 경계가 메서드 시작과 끝으로 고정됩니다.

개발자가 유연하게 트랜잭션의 경계를 조절할 수 없게 되는 것이죠.

과연 이것들만이 문제일까요?

스프링 AOP를 사용하면 기본적으로 프록시를 생성해야 하기 때문에 그것 역시도 성능에 영향을 줄 수 있다고 생각합니다.

결론적으로 TransactionTemplate을 사용하면 프록시를 만들지 않기 때문에, 코드에 변경을 가하지 않아도 되고 성능에 이점도 있으며, 개발자로 하여금 트랜잭션의 경계를 조절하고 Self Invocation과 같은 골치 아픈 문제를 경험할 이유가 전혀 없습니다.

여기까지 읽어보셨다면 이런 질문을 할 수는 있겠습니다.

TransactionTemplate을 만들면 오히려 침투적인 것 아닌가요? 선언적으로 사용하는 것보다 코드에 가시적으로 드러나지 않나요?

애초에 이 질문 자체가 잘못되었다고 저는 생각합니다.

이전에 제가 쓴 에 언급된 내용을 아래와 같이 가져와봤습니다.

도메인 주도 설계에서 응용 영역에 응용 서비스는 표현 영역과 도메인 영역을 연결하는 창구인 파사드(Facade)역할을 한다. 응용 서비스는 주로 도메인 개체 간 흐름 제어만을 위해 존재하므로 단순한 형태를 갖는다.

여기서 말하는 응용 서비스는 개체 간 흐름 제어를 위한 단순한 구조를 가져야 한다고 말합니다.

따라서 도메인 로직을 아래와 같이 도메인 서비스로 분리하고 응용 서비스에서 우리의 도메인 서비스를 호출하면 되는 것이 아닐까 생각합니다.

도메인 서비스로 분리해서 책임을 위임할 경우 다음과 같은 장점을 경험 했습니다.

  • 도메인 모듈 안에서 모든 비즈니스 로직과 관련된 테스트가 가능합니다.
  • 단일 책임을 가진 여러 개의 도메인 서비스가 만들어져서 테스트와 리팩토링에 용이합니다.

도메인 모듈 안에서 모든 비즈니스 로직과 관련된 테스트가 가능하다는 것이 무슨 의미일까요?

응용 서비스에서 하나의 비즈니스 로직에 대한 도메인 구성 요소를 조합하고 실행하게끔 한다면, 각 도메인 구성요소에 대한 테스트를 해야할 지, 아니면 응용 서비스 레이어에 대한 테스트를 해야할 지, 테스트를 중복으로 작성하게 되는 것은 아닌지 혼동되는 상황을 자주 겪었습니다.

이런 맥락에서 응용 서비스에서는 도메인 서비스를 호출하는 책임, 트랜잭션 경계를 지정하는 책임만 가지고 도메인 모듈 안에서 모든 비즈니스 로직을 관리하고 실행한다면 도메인 모듈이 완결성을 가질 수 있다고 생각합니다.

따라서 레이어 간 테스트 분리가 쉽고 자연스럽게 단일 책임을 준수하는 여러 개의 도메인 서비스를 만들도록 함으로써, 테스트에 용이하고 리팩토링하기 쉬운 설계를 유도하게 됩니다.

단일 책임을 준수하는 여러 개의 도메인 서비스를 만들도록 할 때, 스프링의 @Transactional 을 사용했다면 앞서 언급한 문제들로 인해 이러한 설계 자체가 시도될 수 없는 것임을 깨닫게 됩니다.

@Component 를 남용하지 말고, 규칙을 정해서 사용해주세요.

Spring은 대표적인 IoC 컨테이너를 제공하는 프레임워크로써, DI 원칙을 잘 사용할 수 있도록 설계가 되어 있습니다.

많은 사람들이 오해하는 것은, DI는 IoC 컨테이너 없이도 할 수 있지만 Spring을 사용하는 개발자들은 @Component 를 붙여야만 DI가 되는 것으로 오해하곤 합니다.

또한 Spring은 xml 구성 설정을 제공함으로써 비 침투적으로 애플리케이션 코드를 조립할 수 있도록 하고 있습니다.

하지만 Spring Boot의 등장 이후 이런 오해는 더욱 커져만 갔고 많은 도메인 코드에서 무분별하게 @Component 를 사용하는 모습을 볼 수 있었습니다.

저는 최소한 도메인 모듈에 존재하는 코드만이라도 Java Config를 활용한 외부 구성을 하는 것이 바람직한 Spring의 사용이라고 말씀드리고 싶습니다.

아래와 같이 말이죠.

이렇게 외부 구성 설정 파일을 만듦으로써, 얻을 수 있는 이점은 다음과 같습니다.

  • Spring Context를 활용한 테스트를 할 때 특정 구성 설정만 로드해서 테스트하도록 할 수 있습니다. 이를 통해 조금 더 빠르게 Spring Context를 띄울 수 있겠습니다.
  • 가장 중요한 이점입니다. 우리의 도메인 모듈 코드가 Spring이 아닌 Serverless 환경 및 그 어느 곳에서도 사용될 수 있는 적응력 있는 코드가 될 수 있다는 것입니다.

--

--