흔히 Spring 위에서 Spring Data JPA 를 이용해 개발하다보면 아래와 같은 구조로 Service 가 Repository 를 의존하는 코드를 작성하곤 한다.
// service/MemberService.kt
@Service
class MemberService(
private val memberRepository: MemberRepository
) {
...
}
// repository/MemberRepository.kt
@Repository
class MemberRepository: JpaRepository<Member, Long> {
...
}
위와 같은 구조는 매우 흔하게 볼 수 있는 구조고 나도 처음 개발할 당시에 아무 생각없이 이런 구조를 사용하곤 했다.
하지만 위 구조에는 약간의 문제가 있다.
위 구조를 절대 사용하면 안된다는 것은 아니다. 하지만 적어도 어떤 문제가 있고 어떤 부분을 어떠한 이유로 Trade-Off 해 사용했는지 이해하고 있는게 중요하다고 생각해 이번 포스팅을 작성한다.
본격적인 내용에 앞서 클린 아키텍처와 헥사고날 아키텍처에 대해서 간단하게 소개보려고 한다.
TODO : 아키텍처 관련 내용 추가 예정
해당 아키텍처들에서 핵심적으로 말하는 것은 바로 의존성 규칙이다.
"소스코드 의존성은 반드시 안쪽으로, 고수준 정책을 향해야 한다" 는 것이다.
만약 실제 런타임 의존성의 방향, 즉 제어흐름이 반대로 향하게 하고 싶다면 의존성 역전을 통해 소스코드 의존성의 방향을 바꿔 위 의존성 규칙을 만족시켜야 한다.
// service/MemberService.kt
@Service
class MemberService(
private val memberRepository: MemberRepository
) {
...
}
// repository/MemberRepository.kt
@Repository
class MemberRepository: JpaRepository<Member, Long> {
...
}
그렇다면 위에서 언급된 구조는 클린 아키텍처와 헥사고날 아키텍처에서 이야기한 "도메인 계층으로 의존성 방향이 모여야 한다" 는 규칙을 잘 지키고 있을까?
단순히 생각한다면 '그렇다' 라고 대답할지도 모른다. (과거의 내가 그랬다)
MemberRepository
는 인터페이스이고 이에 대한 구현체는 Spring Data 가 만들어준다. 그리고MemberService
는 이렇게 만들어진 구현체가 아니라MemberRepository
라는 추상화 타입을 의존하고 있다.해당 추상화 타입은
MemberService
와 같은 모듈에 존재하기 때문에 의존성 역전이 생긴거고 의존성의 방향은 도메인 계층으로 모이고 있다고 생각할 수 있다.
나는 이 말에 일리가 없다고 생각하지는 않지만 맞는 말이라고 생각하지도 않는다.
위 말이 성립하려면 데이터베이스의 종류나 데이터베이스와 대화하기 위한 미들웨어(지금의 JPA)가 변경 됐을 때 MemberService
에 영향을 미치면 안된다.
하지만 위 구조에서는 어떨까?
기존에 MySQL 을 사용하고 있다가 MongoDB 로 변경되는 상황을 떠올려보자.
현재 JPA 는 MongoDB 를 지원하지 않는다. 그러면 우리는 계속해서 JpaRepository 인터페이스를 상속받은 MemberRepository
를 사용할 수 없게된다. 그러면 우리는 JpaRepository 를 상속받지 않은 형태(예를 들어 MongoRepository) 로 MemberRepository
를 변경하게 된다. 이때 이 변경이 MemberService
에 전파된다.
예를 들어 MemberService
에서 JpaRepository 에 정의되어있는 deleteAllByIdInBatch()
메소드를 사용하고 있다고 해보자. 더 이상 JPA 를 사용하지 못하는 상황에서 변경이 발생된다면 그 변경은 반드시 MemberService
에 영향을 미치게 된다. 즉, 인프라 계층의 변경이 도메인/서비스 계층으로 전파된 것이다.
변경은 의존성을 타고 전파되기 때문에 이렇게 변경이 전파됐다는 것은 의존성의 방향이 잘못됐다는 것과 같다.
우리는 의존성 역전을 통해 인프라 계층이 도메인/서비스 계층을 의존하도록 만들었다고 생각했지만 실제로는 도메인/서비스 계층이 인프라 계층을 의존하고 있었던 것이다.
그럼 정확한 의존성 방향을 통해 도메인 계층으로 변경이 전파되지 않는 구조로 만들려면 어떻게 해야할까?
헥사고날 아키텍처에서는 Out-Bound Adapter 를 통해 도메인 계층에서 사용하고자 하는 API 들을 정의하고, 해당 인터페이스를 구현하는 구현체를 외부(인프라 계층) 영역에 별도로 만들어야 한다고 말한다.
즉, Service → Port ← Adapter(DAO) → Repository ← 구현체
의 의존성 방향이 만들어진다.
조금 더 쉽게 이해할 수 있도록 코드로 한번 살펴보자.
// service/MemberService.kt
@Service
class MemberService(
private val memberPort: MemberPort
) {
fun retrieveMember(memberId: Long): Member {
return memberPort.find(memberId);
}
}
// port/MemberPort.kt
interface MemberPort {
fun find(memberId: Long): Member
}
// adapter/infra/MemberAdaptor.kt
@Component
class MemberAdaptor(
private val memberRepository: MemberRepository
): memberPort {
@Override
fun find(memberId: Long): Member {
val memberJpaEntity = memberRepository.findById(memberId)
return memberJpaEntity.toDomainEntity()
}
}
// adapter/infra/MemberRepository.kt
@Repository
interface MemberRepository: JpaRepository<Member, Long> {
}
헥사고날 아키텍처를 이용한 김에 JPA Entity 와 Domain Entity 도 분리해서 코드를 작성했다. 이 두개를 왜 분리했고 그 과정에서 어떤 것들을 고민했는지는 이번 포스팅에서는 다루지 않을 예정이다.
이외에도 JpaRepository 를 상속받지 않는 Spring Data Repository 를 사용하는 대안도 있다고 생각한다.
@Repository
class MemberRepository: CrudRepository<Member, Long> {
...
}
위 구조는 해당 Repository 클래스가 JPA 를 직접적으로 의존하고 있지 않다.
하지만 이 경우에도 JPA 에 대한 의존성은 끊어냈지만 Spring Data 라는 프로젝트를 의존하고 있기 때문에 완벽하게 의존성 문제를 해결했다고 할 수는 없다.
그래도 Spring Data 프로젝트는 대부분의 데이터베이스 확장성을 제공하고 있기 때문에 좋은 대안이 될 수 있다고 생각한다.
그런데 사실 한번 의사결정되어 운영되고 있는 데이터베이스가 변경되거나 JPA 를 다른 것으로 마이그레이션 하는 일은 흔치 않다. 이러한 희박한 변경 가능성 때문에 위와 같은 구조를 사용해야할까? 하는 의문이 들 수도 있다.
변경이 고수준 모듈인 도메인 영역으로 전파되지 않는 유연한 코드인 것은 분명하지만 그로인해 구조가 복잡해졌고 오히려 개발할 때 시간이 오래걸리고 코드를 파악하는데도 어려움이 가중됐다.
여기서 말하고 싶은 것은 설계에 정답이 없다는 것이다.
상황에 따라서 희박한 변경에 대한 유연성을 확보하는 것보다 개발의 편의성과 생산성을 높이는게 더 중요할 수 있다.
그렇기 때문에 설계는 주어진 환경에서 상황에 맞게 Trade-Off 하는게 중요하다고 생각한다.
하지만 분명한 것은 Trade-Off 해야한다는 것이다. 왜 그런 구조를 사용했는지 다른 사람을 설득할 수 없고 당연히 그래왔으니까 라고 생각하는 습관은 옳지 않다고 생각한다.
포스팅 처음에 언급했던 구조를 다시 보자.
// service/MemberService.kt
@Service
class MemberService(
private val memberRepository: MemberRepository
) {
...
}
// repository/MemberRepository.kt
@Repository
class MemberRepository: JpaRepository<Member, Long> {
...
}
똑같은 코드를 작성하더라도 '당연히 그래왔으니까' 혹은 '다른 포스팅에서 그렇게 해서' 라는 관성을 가지고 선택했다면 문제가 된다고 생각하고, '변경에 대한 유연성보다 생산성이 더 중요한 상황' 을 인지하고 Trade-Off 했다면 그건 본인에게 주어진 상황에 맞는 아키텍처를 선택한 훌륭한 사람이라고 생각한다.
이번 포스팅에서 소개한 케이스 외에 개발하면서 Trade-Off 를 고민해볼만한 키워드를 적으며 포스팅을 마무리 하려고 한다.
다시 한번 말하지만 설계에는 정답이 없다고 생각한다. 그저 본인이 개발하고 있는 상황에 맞게 최선의 선택을 하는 것이고 그 과정에서 지금보다 조금 더 많은 고민을 할 수 있었으면 좋겠다.