흔히 훌륭한 아키텍처는 개발자와 소프트웨어 품질에 많은 도움을 준다고 한다. 대체 아키텍처란 뭘까? 많은 개발자가 아키텍처란 그저 개발자가 일을 하는 방법이라고 말하고 필자 또한 이 말에 동의한다. 다시 한 번 일을 하는 방법이란 말에 대해 생각해보자. 일은 그냥 하면 되는데 왜 방법을 만드는 걸까?

여러가지 답이 있을 수 있겠지만 필자가 생각하는 가장 큰 이유는 목표를 효과적으로 이루기 위해서라고 생각한다. 우리가 무언가 일을 할 때는 목표를 정하고 그에 맞춰 일하는 방법을 정한다. 소프트웨어도 마찬가지다. 회사의 규모, 고객의 요구사항, 환경 등에 따라 목표가 달라지고 그에 맞춰 아키텍처는 달라진다. 즉, 우리는 해야하는 업무에 적합한 아키텍처를 선택할 수 있고 그에 따라 생산성이 달라질 수 있다.

일을 하는 방법에는 효율을 위한 규칙과 제약이 포함되어 있다. 따라서, 아키텍처를 풀어서 말해보자면 효율적인 코드 작성을 위한 규칙과 제약이라고 할 수 있다. 이 규칙과 제약은 어떤 제품을 만드냐에 따라 크게 달라질 수 있다.

이 글에서는 확장성과 모듈 독립화를 목표로 하는 아키텍처를 설명하고자 한다. 아키텍처를 설명하기 위해 Spring Boot를 기반으로 하고 Gradle을 이용하여 멀티 모듈을 구성한다.

도메인과 모듈

우리가 만드는 소프트웨어는 비즈니스와 밀접한 연관이 있다. 이때 도메인은 비즈니스에서 소프트웨어로 문제를 해결하기 위한 영역을 말한다. 개발자는 도메인과 유스케이스를 논리적인 코드로 작성하고 사용자에게 제품으로 제공한다. 도메인을 이해하고 이에 따라 코드를 작성하는 것이 중요하기 때문에 도메인 주도 설계(Domain-Driven Design) 방법론을 이용하는 팀이 많다. 필자가 재직하고 있는 회사 또한 도메인에 기반하여 제품에 대해 논의하고 개발한다.

따라서 개발팀은 도메인의 역할과 제약을 기반으로 논리를 만들어내기 때문에 도메인은 아키텍처를 구성하는데 중요한 역할을 한다. 개발팀이 협업을 잘하기 위해선 도메인 영역을 나누고 도메인 모델이 서로 어떤 관계를 가지고 있는지 파악하는 것이 중요하다. 그래서 보통 도메인 영역을 별도의 모듈로 분리하여 관리하는 경우가 많다. 흔히 모듈을 평가할 때 결합도와 응집도를 보게되는데 결합도는 모듈 사이에서 서로 의존하는 정도를 말하고 응집도는 모듈 내에 기능이 서로 연관이 있는지를 말한다. 보통 결합도는 낮을 수록 좋고 응집도는 높을 수록 좋다.

소프트웨어 공학을 공부하면 자주 보는 그림

인터페이스

추가로 모듈에서 중요한 것은 인터페이스다. 해당 모듈이 무엇을 제공하는지를 인터페이스로 정의하고 외부에 노출한다. 이렇게 하면 외부에서 해당 모듈을 사용할 때 인터페이스를 통해 사용할 수 있기 때문에 모듈 내부의 구현이 변경되어도 외부에 영향을 주지 않는다. 이를 통해 모듈의 독립성을 높일 수 있다. 또한, 개발자는 인터페이스를 보고 어떤 기능이 제공되고 필요한지 알 수 있기 때문에 협업에도 도움이 된다.

인터페이스는 어떤 기능을 제공하는지 나타내는 문서라고도 볼 수 있다

이 글에서 다루는 Spring에선 개발자가 다른 모듈의 기능이 필요한 경우 해당 인터페이스를 주입하여 사용할 수 있다. 이를 인터페이스 주입(Interface Injection)이라고 하는데 뒤에서 설명할 Spring의 의존성 주입(Dependency Injection)을 통해 가능하다. 따라서 개발자는 인터페이스의 구현체를 몰라도 해당 기능을 사용할 수 있다.

순환 의존 문제

이상적으로 도메인에 따라 모듈을 나누기만 하면 깔끔한 아키텍처를 설계할 수 있을 것 같지만 실제 상황에선 항상 문제가 생기기 마련이다. 예를 들어, A 모듈이 B 모듈을 의존하고 B 모듈이 A 모듈을 의존하는 경우가 있다. 이런 경우를 순환 의존 관계(Circular Dependency)라고 한다. 이런 경우 Message Queue 등을 사용해 비동기 통신을 하는 것이 아니라면 어쩔 수 없이 모듈을 합치거나 별도 다른 모듈로 기능을 분리하는 경우가 많다.

왜 이런 일이 발생하는지 알기 위해 한 가지 예시를 들어보자. 만약 우리가 게시판 서비스를 만들어야 한다고 가정해보자.

  • 게시물 작성
  • 게시물 리스트 조회
  • 게시물 상세 조회
    • 작성된 댓글도 포함됨
  • 댓글 작성
  • ...

게시판 서비스를 만든다면 대략 위와 같은 기능들이 필요할 것이다. 이런 상황에서 Article 모듈과 Comment 모듈을 나누는 것은 좋은 방법이다. Article과 Comment의 책임을 분리하고 각 도메인에 따라 로직을 구현할 수 있으며 모듈 간의 의존 관계도 명확하다. 의존 관계의 예를 들면 게시물 상세 조회 기능을 구현하기 위해선 Article 모듈에서 Comment 모듈에 포함된 기능을 사용할 수 있다.

그런데 만약 댓글을 남기기 전에 정말 Article이 있는지 검사할 필요가 있다고 가정해보자. 그러면 Article에 대한 정보가 필요하기 때문에 Comment 모듈에서 Article 모듈을 의존해야한다. 이런 경우 순환 의존 관계가 발생한다. 의존 관계에 순환이 발생하면 빌드가 되지 않는다.

순환 의존 관계

이런 경우 어떻게 해야할까? 앞서 말한 것 처럼 순환 의존 관계를 해결하기 위해 Article 모듈과 Comment 모듈을 하나로 합치거나 Article 모듈과 Comment 모듈을 둘 다 의존하는 별도의 모듈을 만드는 방법을 이용할 수도 있다. 하지만, 이런 경우 특정 기능만을 위한 모듈이나 도메인 정책에 맞지 않는 모듈이 생겨날 가능성이 높다. 모듈이 목적성을 잃게되면 의사소통에 혼란이 생기고 관련된 모듈의 응집도가 낮아진다.

혹은 앞서 말한 것 처럼 Message Queue와 같은 비동기 통신을 이용할 수도 있다. 하지만 동기 통신이 필요한 경우 이 방법은 사용할 수 없다.

이를 해결하기 위한 방법 중 하나로 IoC와 DI를 이용한 인터페이스 주입을 통해 해결할 수 있다.

IoC와 DI를 이용한 아키텍처

IoC와 DI를 이용한 아키텍처 패턴은 이미 리팩토링이라는 책으로 유명한 마틴 파울러가 Inversion of Control Container and the Dependency Injection pattern라는 아티클로 잘 정리한 자료가 있다. 해당 아키텍처에 대해 설명하기 전에 먼저 DI와 IoC에 대해 알아보자.

의존성 주입(DI)이란?

DI는 Dependency Injection의 약자로 의존성 주입이라고 한다. 의존성 주입은 필요한 객체를 직접 생성하는 것이 아닌 외부에서 주입 받아오는 것을 말한다. 예를 들어, Article 클래스에서 Comment 클래스를 사용한다고 가정해보자. 이런 경우 Article 클래스는 Comment 클래스를 사용하기 위해 Comment 인스턴스가 필요하다. 이때 Article 클래스는 Comment 인스턴스를 클래스 내부에서 직접 생성하는 것이 아니라 외부에서 생성한 인스턴스를 주입받아 사용할 수 있다. 이렇게 외부에서 생성한 인스턴스를 주입받는 것을 의존성 주입이라고 한다.

거창한 이름에 비해 많이 간단하다. 의존성 주입 중 생성자 주입을 이용한 코드는 다음과 같다.

// Comment 인스턴스를 직접 생성
class Article {
  private val comment: Comment = Comment()
  /* ... */
}

// 외부에서 Comment 인스턴스를 주입받음
class Article(private val comment: Comment) {
  /* ... */
}

보다시피 코드가 아주 간단하다. 간단하지만 효과는 꽤 큰데 외부에서 생성한 인스턴스를 주입받기 때문에 개발자가 클래스를 수정하지 않고 외부에서 제어할 수 있다는 점이 다르다. 그리고 Comment 클래스를 상속받은 클래스를 주입받을 수도 있다. 이렇게 하면 Article 클래스는 Comment 클래스의 구현체에 대해 몰라도 된다. 이를 통해 모듈의 독립성을 높일 수 있다.

참고로 꼭 생성자가 아니더라도 주입을 할 수 있다면 의존성 주입이라고 할 수 있다. 별도 Setter 메서드를 통해 주입받거나 어노테이션을 이용한 필드 주입을 이용할 수도 있다. 보통은 생성자 주입 방식이 권장된다.

제어의 역전(IoC)이란?

IoC는 Inversion of Control의 약자로 제어의 역전이라고 한다. 위에 설명한 DI를 이용하려면 개발자가 직접 외부에서 인스턴스를 생성하고 주입을 해줘야 한다. 사실 개발자가 흐름을 제어하는 것은 당연한 일이다. 하지만 만약 이러한 제어를 개발자가 아닌 프레임워크가 하면 어떨까? 이것이 바로 IoC의 개념이다. 즉, 제어의 역전이란 개발자가 프로그램의 흐름을 제어하는 것이 아닌 프레임워크가 프로그램의 흐름을 제어하는 것을 의미한다.

Spring에서는 IoC 컨테이너가 프로그램의 흐름을 제어한다. IoC 컨테이너는 Bean이라는 객체를 관리하고 주입해준다. Bean은 IoC 컨테이너가 관리하는 객체를 말한다. Bean은 IoC 컨테이너에 의해 생성되고 주입되기 때문에 개발자는 Bean을 직접 생성하거나 주입할 필요가 없다. 이렇게 Bean을 관리하고 주입해주는 IoC 컨테이너를 Bean Factory라고 한다. 다음 예제 코드를 살펴보자.

@Repository
class ArticleRepository {
  fun findById(id: Long): Article {
    /* ... */
  }
}

@Service
class ArticleService(private val articleRepository: ArticleRepository) {
  fun getArticle(id: Long): Article =
    articleRepository.findById(id)
}

위 코드는 Spring에서 사용하는 Bean 어노테이션을 이용한 코드이다. @Repository@Service 어노테이션은 @Component 어노테이션을 상속받은 어노테이션이다. @Component 어노테이션은 해당 클래스가 Bean으로서 IoC 컨테이너에게 관리된다는 것을 의미한다. @Repository@Service는 역할을 구분하기 위해 사용된다. @Repository 어노테이션은 해당 클래스가 데이터베이스와 통신하는 클래스라는 것을 나타내고 @Service 어노테이션은 해당 클래스가 비즈니스 로직을 처리하는 클래스라는 것을 나타낸다. @Repository 어노테이션을 이용한 ArticleRepository 클래스는 IoC 컨테이너에 의해 알아서 생성되고 @Service 어노테이션을 이용한 ArticleService 클래스가 IoC 컨테이너에 의해 생성될 때 주입된다. 이렇게 IoC 컨테이너가 Bean을 생성하고 주입해주는 것을 제어의 역전이라고 한다.

인터페이스 주입

앞서 설명한 DI와 IoC의 개념을 이용하면 인터페이스 주입이라는 방법을 이용할 수 있다. 인터페이스 주입은 인터페이스를 주입받아 사용하는 것을 말한다. 이렇게 하면 외부에서 인터페이스의 구현체를 주입받아 사용할 수 있기 때문에 모듈의 독립성을 높일 수 있다. 또한, 인터페이스를 주입받기 때문에 해당 인터페이스의 구현체가 변경되어도 외부에 영향을 주지 않는다. 다음 예제 코드를 살펴보자.

/**
 * Article Module
 */
@Repository
class ArticleRepository {
  fun findById(id: Long): Article {
    /* ... */
  }
}

interface ArticleProvided {
  fun getArticle(id: Long): Article
}

@Service
class ArticleService(private val articleRepository: ArticleRepository): ArticleProvided {
  override fun getArticle(id: Long): Article =
    articleRepository.findById(id)
}

/**
 * Comment Module
 */
@Repository
class CommentRepository {
  fun findAllByArticleId(articleId: Long): List<Comment> {
    /* ... */
  }
}

@Service
class CommentService(
  private val articleProvided: ArticleProvided,
  private val commentRepository: CommentRepository
) {
  fun getComments(articleId: Long): List<Comment> =
    articleProvided
      .getArticle(articleId)
      .map { commentRepository.findAllByArticleId(it.id) }
}

위 코드에서 CommentServiceArticleProvided를 주입받아 사용한다. 이때 ArticleServiceArticleProvided 인터페이스를 구현체기 때문에 ArticleService 클래스를 주입받아 사용할 수 있다. 이렇게 하면 구현체를 몰라도 인터페이스를 통해 주입받을 수 있기 때문에 구현체 로직이 변경되어도 주입 받는 클래스에 영향을 주지 않는다. IoC 컨테이너가 해당 인터페이스를 Bean으로 관리하고 주입하기 때문에 CommentServiceArticleProvided 인터페이스를 주입받을 때 ArticleService의 구현체가 주입된다. 구현체가 주입되더라도 인터페이스에 의해 열려있는 메서드만 사용하기 때문에 명세를 쉽게 파악할 수 있다.

컨셉 아키텍처

실제 구현 코드를 다루기 전에 IoC와 DI를 이용한 아키텍처에 대한 컨셉을 알아보자. 이 아키텍처는 인터페이스가 담기는 모듈과 실제 구현체가 포함되는 모듈로 구성된다. 인터페이스가 담기는 모듈을 컨셉 모듈이라고 하고 실제 구현체가 포함되는 모듈을 구현 모듈이라고 하자. 구현 모듈은 컨셉 모듈을 의존하지만 컨셉 모듈은 구현 모듈을 의존하지 않는다. 이렇게 하면 구현 모듈은 컨셉 모듈의 의존성을 알지 못하기 때문에 순환 의존 문제가 발생하지 않는다. 또한, 컨셉 모듈은 인터페이스만을 포함하기 때문에 구현 모듈에 영향을 받지 않는다. 따라서, 컨셉 모듈은 독립적으로 관리할 수 있다.

컨셉 모듈과 구현 모듈로 구성된 아키텍처

이 아키텍처는 구현 모듈이 컨셉 모듈에 의존하기 때문에 구현 모듈이 컨셉 모듈을 사용할 수 있다. 이때 구현 모듈은 컨셉 모듈의 인터페이스를 주입받아 사용한다. 여기서 앞서 설명한 IoC와 DI가 사용된다. 구현 모듈에 필요한 의존성은 IoC 컨테이너에 의해 관리되기 때문에 인터페이스의 구현체가 알아서 주입된다. 그리고 구현 모듈은 컨셉 모듈의 인터페이스를 주입받기 때문에 컨셉 모듈의 구현체가 변경되어도 영향을 받지 않는다. 이는 모듈의 독립성을 높이는데 도움이 된다.

그럼 앞서 이야기했던 순환 의존 문제를 어떻게 해결할 수 있는지 알아보자. 다시 Article과 Comment 예시로 돌아와서 Comment 모듈이 Article 모듈을 의존하면 순환 의존 문제가 생긴다고 했었다. 하지만 IoC와 DI를 이용한 아키텍처를 이용하면 Comment 서브시스템의 구현 모듈이 Article 서브시스템의 컨셉 모듈을 의존하기 때문에 모듈 간의 순환 의존 문제에서 빠져나올 수 있다.

Cycle에서 빠져나올 수 있다

결국 Comment의 구현 모듈은 Article 서브시스템의 컨셉 모듈만 의존해도 IoC에 의해 Article 서브시스템의 구현 모듈 내에 있는 구현체를 주입받을 수 있다.

Spring 멀티 모듈 아키텍처

이제부터 실제로 Spring에서 IoC와 DI를 이용한 아키텍처를 구현하는 방법을 알아보자. Spring에서 IoC와 DI를 이용한 아키텍처를 구현하기 위해선 모듈 시스템이 필요한데 여기선 Gradle 멀티 모듈을 이용하겠다.

Gradle 멀티 모듈 구성

여기선 Gradle 설정 파일을 Kotlin DSL을 이용하여 작성하겠다. Gradle 멀티 모듈을 구성하기 위해선 프로젝트의 루트 디렉토리에 settings.gradle.kts 파일을 생성하고 include를 이용하여 모듈을 추가할 수 있다. 다음과 같이 작성할 수 있다.

rootProject.name = "spring-multi-module-architecture"

include(":a-module-name")
include(":b-module-name")
// ...

위 코드에서 a-module-nameb-module-name은 모듈의 이름이다. 모듈의 이름은 모듈 디렉토리의 이름과 일치해야 한다. 만약 디렉토리가 중첩 구조라면 :를 이용하여 구분해야 한다. 예를 들어, a-module-namemodule 디렉토리의 하위 디렉토리라면 :module:a-module-name과 같이 작성해야 한다.

모듈에 이름을 붙이기 위해선 project를 이용하여 모듈을 참조할 수 있다. 예를 들어, a-module-name 모듈에 이름을 붙이기 위해선 다음과 같이 작성할 수 있다.

rootProject.name = "spring-multi-module-architecture"

include(":a-module-name")
include(":b-module-name")
project(":a-module-name").name = "a-module"
// ...

다음으로 컨셉 아키텍처에서 다룬 것처럼 모듈을 구성해보자. 그러면 subsystem 하위에 각 서브시스템들이 존재하고 다시 그 하위엔 컨셉 모듈과 구현 모듈이 존재하는 구조가 된다. 이때 컨셉 모듈은 concept 디렉토리에 구현 모듈은 implementation 디렉토리에 위치시키자. 그리고 각 서브시스템을 통합하여 실행하는 app 모듈이 필요하다. 만약 앞서 다룬 게시판 시스템을 만든다면 다음과 같은 구조가 된다.

├── build.gradle.kts
├── settings.gradle.kts
├── app
│   ├── build.gradle.kts
│   └── src/main/kotlin/...
├── subsystem
│   ├── article
│   │   ├── concept
│   │   │   ├── src/main/kotlin/...
│   │   │   └── build.gradle.kts
│   │   ├── implementation
│   │   │   ├── src/main/kotlin/...
│   │   │   └── build.gradle.kts
│   ├── comment
│   │   ├── concept
│   │   │   ├── src/main/kotlin/...
│   │   │   └── build.gradle.kts
│   │   ├── implementation
│   │   │   ├── src/main/kotlin/...
│   │   │   └── build.gradle.kts

일일히 서브시스템을 추가하는 것은 번거로울 수 있기 때문에 settings.gradle.kts 파일의 내용을 다음과 같이 수정해보자.

import java.io.File

rootProject.name = "..."

include(":app")

val projectPath: String = File(System.getProperty("user.dir")).absolutePath
val subsystemPath = "$projectPath/subsystem"
val buildDirectory = listOf("build", "out", "bin")

val subsystems = File(subsystemPath).listFiles()
  ?.filter { it.isDirectory && !buildDirectory.contains(it.name) && !it.name.startsWith(".") }
  ?.map { it.name }

subsystems?.forEach { subsystem ->
  println("Loaded $subsystem subsystem.")

  include(":subsystem:$subsystem:conept")
  project(":subsystem:$subsystem:conept").name = "$subsystem-conept"
  include(":subsystem:$subsystem:implementation")
  project(":subsystem:$subsystem:implementation").name = "$subsystem-implementation"
}

위와 같이 작성하면 디렉토리를 순회하며 알아서 추가해준다.

모든 서브시스템을 불러와서 사용해야하는 app 모듈도 추가해보자. app 모듈은 각 서브시스템의 구현 모듈을 의존하기 때문에 app 모듈의 build.gradle.kts 파일에 다음과 같이 작성하자.

import java.io.File

val projectPath: String = File(System.getProperty("user.dir")).absolutePath
val subsystemPath = "$projectPath/subsystem"
val buildDirectory = listOf("build", "out", "bin")

val subsystems = File(subsystemPath).listFiles()
  ?.filter { it.isDirectory && !buildDirectory.contains(it.name) && !it.name.startsWith(".") }
  ?.map { it.name }

dependencies {
  subsystems?.forEach { subsystem ->
    this.implementation(project(":subsystem:$subsystem:$subsystem-implementation")) // 구현 모듈만 불러온다.
  }
}

이제 각 서브시스템의 컨셉 모듈과 구현 모듈을 추가해보자. 각 서브시스템의 컨셉 모듈은 concept 디렉토리에 위치시키고 구현 모듈은 implementation 디렉토리에 위치시키자. 그리고 각 서브시스템의 컨셉 모듈과 구현 모듈의 build.gradle.kts 파일에 다음과 같이 작성하자.

// subsystem/article/concept/build.gradle.kts
dependencies {
  // 아무것도 의존하지 않는다.
  // 이 경우 build.gradle.kts에 아무 내용이 없기 때문에 파일을 제거해도 무방하다.
}

// subsystem/article/implementation/build.gradle.kts
dependencies {
  // Comment, Article 서브시스템의 컨셉 모듈을 불러온다.
  implementation(project(":subsystem:article:article-concept"))
  implementation(project(":subsystem:comment:comment-concept"))
}

// subsystem/comment/concept/build.gradle.kts
dependencies {
  // 아무것도 의존하지 않는다.
  // 이 경우 build.gradle.kts에 아무 내용이 없기 때문에 파일을 제거해도 무방하다.
}

// subsystem/comment/implementation/build.gradle.kts
dependencies {
  // Comment, Article 서브시스템의 컨셉 모듈을 불러온다.
  implementation(project(":subsystem:comment:comment-concept"))
  implementation(project(":subsystem:article:article-concept"))
}
이미지와 똑같이 의존성을 구성했다

위 작업을 마지막으로 Gradle 멀티 모듈 구성이 끝났다. 구체적인 설정 코드를 보고싶다면 따로 만들어둔 GitHub 저장소를 통해 참고하면 된다.

실제로 구현해보기

이제 실제 코드를 작성해보자. 먼저 각 서브시스템의 컨셉 모듈과 구현 모듈에 대한 코드를 작성할 수 있다. 각 서브시스템의 컨셉 모듈은 다음과 같은 파일이 들어간다.

  • Model
  • DTO
  • Provided

즉, 구현체가 아닌 외부에 제공해줘야 하는 데이터 혹은 인터페이스가 포함된다. 반면, 각 서브시스템의 구현 모듈은 다음과 같은 파일이 들어간다.

  • Configuration
  • Repository
  • Service
  • Controller
  • ...

다음으로 각 서브시스템의 컨셉 모듈과 구현 모듈에 대한 코드를 작성해보자. 여기선 앞서 계속 이야기한 Article과 Comment 서브시스템을 예시로 들어보자.

Article 서브시스템 구성

먼저 Article이라는 모델을 만들어보자.

// subsystem/article/concept/src/main/kotlin/so/kciter/board/article/concept/model/Article.kt

data class Article(
  val id: Int,
  val title: String,
  val body: String
)

이어서 Article 서브시스템이 제공해줘야 할 기능 명세를 인터페이스로 작성하자. 여기서는 Article 하나를 제공하는 기능이 필요하다고 가정한다.

// subsystem/article/concept/src/main/kotlin/so/kciter/board/article/concept/ArticleProvided.kt

interface ArticleProvided {
  fun findById(id: Int): Mono<Article>
}

이제 Article 서브시스템의 구현 모듈을 작성해보자. 먼저 ArticleProvided 인터페이스를 구현한 ArticleService 클래스를 작성하자. 여기서 Repository와 Controller 구현은 생략한다.

// subsystem/article/implementation/src/main/kotlin/so/kciter/board/article/ArticleService.kt

@Service
class ArticleService(
  private val articleRepository: ArticleRepository
) : ArticleProvided {
  fun findAll(): Flux<Article> =
    this.articleRepository.findAll()

  override fun findById(id: Int): Mono<Article> {
    return articleRepository.findById(id)
  }
}

그럼 이제 Comment 서브시스템에서 Article 서브시스템의 기능을 이용할 수 있다. 그럼 이어서 Comment 서브시스템의 컨셉 모듈과 구현 모듈을 작성해보자.

Comment 서브시스템 구성

마찬가지로 먼저 Comment라는 모델을 만들어보자.

// subsystem/comment/concept/src/main/kotlin/so/kciter/board/comment/concept/model/Comment.kt

data class Comment(
  val id: Int,
  val articleId: Int,
  val body: String
)

이어서 Article 서브시스템을 구성할 때 처럼 Comment 서브시스템이 제공해줘야 할 기능 명세를 인터페이스로 작성하자. 여기서는 Article 하나에 대한 Comment 목록을 제공하는 기능이 필요하다고 가정한다.

// subsystem/comment/concept/src/main/kotlin/so/kciter/board/comment/concept/CommentProvided.kt

interface CommentProvided {
  fun findAllByArticleId(articleId: Int): Flux<Comment>
}

이제 Comment 서브시스템의 구현 모듈을 작성해보자. 먼저 CommentProvided 인터페이스를 구현한 CommentService 클래스를 작성하자. 여기서도 Repository와 Controller 구현은 생략한다.

// subsystem/comment/implementation/src/main/kotlin/so/kciter/board/comment/CommentService.kt

@Service
internal class CommentService(
  private val commentRepository: CommentRepository,
  private val articleProvided: ArticleProvided
) {
  fun findAll(): Flux<Comment> =
    this.commentRepository.findAll()

  override fun findAllByArticleId(articleId: Int): Flux<Comment> =
    this.commentRepository.findAllByArticleId(articleId)
}

그럼 이제 Article 서브시스템에서 Comment 서브시스템의 기능을 이용할 수 있다.

클래스 순환 의존 문제 해결

이어서 서로 구현한 기능을 이용하도록 코드를 수정해보자. 먼저 ArticleService 클래스에서 CommentService 클래스를 이용하도록 코드를 수정해보자.

// subsystem/article/implementation/src/main/kotlin/so/kciter/board/article/ArticleService.kt

@Service
internal class ArticleService(
  private val articleRepository: ArticleRepository,
  private val commentProvided: CommentProvided // CommentProvided 인터페이스를 주입받는다.
): ArticleProvided {
  fun findAll(): Flux<Article> =
    this.articleRepository.findAll()

  override fun findById(id: Int): Mono<Article> =
    this.articleRepository
      .findById(id)
      .switchIfEmpty(Mono.error(ResponseStatusException(HttpStatus.NOT_FOUND)))

  fun findAllCommentByArticleId(articleId: Int): Flux<Comment> =
    this.commentProvided.findAllByArticleId(articleId) // CommentProvided의 구현체인 CommentService의 메서드가 실행된다
}

그럼 이제 Comment 서브시스템에서 Article 서브시스템의 기능을 이용하도록 코드를 수정해보자.

// subsystem/comment/implementation/src/main/kotlin/so/kciter/board/comment/CommentService.kt

@Service
internal class CommentService(
  private val commentRepository: CommentRepository,
  private val articleProvided: ArticleProvided
): CommentProvided {
  fun findAll(): Flux<Comment> =
    this.commentRepository.findAll()

  override fun findAllByArticleId(articleId: Int): Flux<Comment> =
    this.commentRepository.findAllByArticleId(articleId)

  fun findById(id: Int): Mono<Comment> =
    this.commentRepository
      .findById(id)
      .switchIfEmpty(Mono.error(ResponseStatusException(HttpStatus.NOT_FOUND)))
      .flatMap { comment ->
        this.articleProvided
          .findById(comment.articleId)
          .switchIfEmpty(Mono.error(ResponseStatusException(HttpStatus.NOT_FOUND)))
          .map { comment }
      }
}

이렇게 작성한 후 실행해보자. 큰 문제 없어보이지만 실행하면 에러가 발생한다.

***************************
APPLICATION FAILED TO START
***************************

Description:

The dependencies of some of the beans in the application context form a cycle:

   commentController defined in file [/Users/kciter/project/spring-ioc-di-architecture/subsystem/comment/implementation/build/classes/kotlin/main/so/kciter/board/comment/CommentController.class]
┌─────┐
|  commentService defined in file [/Users/kciter/project/spring-ioc-di-architecture/subsystem/comment/implementation/build/classes/kotlin/main/so/kciter/board/comment/CommentService.class]
↑     ↓
|  articleService defined in file [/Users/kciter/project/spring-ioc-di-architecture/subsystem/article/implementation/build/classes/kotlin/main/so/kciter/board/article/ArticleService.class]
└─────┘

이는 클래스간 순환 참조가 발생해서 그렇다. 모듈 간의 순환 참조 문제는 해결되었지만 클래스간 순환 참조 문제를 해결하기 위해선 두 가지 방법 중 하나를 택할 수 있다.

  1. @Lazy 어노테이션 사용
  2. Provided를 상속받는 다른 클래스 구현

@Lazy 어노테이션을 사용하는 것은 성능이나 메모리 문제가 있을 수 있기 때문에 Spring에서 권장하지 않는 방법이다. 따라서 두 번째 방법을 사용해보자. 그럼 CommentService 클래스에서 상속을 받을 수 없기 때문에 별도로 CommentProvideService 클래스를 만들어보자.

// subsystem/comment/implementation/src/main/kotlin/so/kciter/board/comment/CommentService.kt

@Service
internal class CommentService(
  private val commentRepository: CommentRepository,
  private val articleProvided: ArticleProvided
) { // 상속 제거
  fun findAll(): Flux<Comment> =
    this.commentRepository.findAll()

  fun findById(id: Int): Mono<Comment> =
    this.commentRepository
      .findById(id)
      .switchIfEmpty(Mono.error(ResponseStatusException(HttpStatus.NOT_FOUND)))
      .flatMap { comment ->
        this.articleProvided
          .findById(comment.articleId)
          .switchIfEmpty(Mono.error(ResponseStatusException(HttpStatus.NOT_FOUND)))
          .map { comment }
      }
}

// subsystem/comment/implementation/src/main/kotlin/so/kciter/board/comment/CommentProvideService.kt

@Service
internal class CommentProvideService(
  private val commentRepository: CommentRepository
): CommentProvided {
  override fun findAllByArticleId(articleId: Int): Flux<Comment> =
    this.commentRepository.findAllByArticleId(articleId)
}

위와 같이 수정하면 클래스 순환 참조 문제가 해결되어 문제없이 실행되는 것을 확인할 수 있다.

잘 돌아간다

지금까지의 예제에 대한 전체 코드는 GitHub 저장소에 있다. 참고로 예제는 Spring Webflux 환경으로 작성되었다.

마치며

필자가 재직 중인 회사 코발트에서는 백엔드 도메인과 로직을 이 글에서 설명한 DI와 IoC를 이용하여 관리하고 있다. 모듈이 제공한 인터페이스를 통해서만 통신이 가능하고 모듈간 의존을 엄격히 관리하기 때문에 불편한 점도 있지만 모듈의 목적성을 잃지 않고 독립적이고 응집도가 높은 모듈을 만들 수 있다는 장점이 있다. 또 다른 장점으로는 모듈 단위로 분리가 쉽기 때문에 새로운 서버 애플리케이션을 쉽게 만들 수 있다는 점이다. 참고로 이 글에서 설명한 아키텍처는 Spring 서버 뿐만 아닌 iOS, Android 등의 앱에서도 DI와 IoC 시스템을 구축하면 사용 가능하다. 추후에 기회가 된다면 다른 플랫폼에서 사용하는 방법도 설명하고자 한다.

참고로 여기서 설명한 아키텍처는 모듈 레벨의 아키텍처 설계라 볼 수 있다. 그렇기 때문에 코드 레벨의 규칙은 따로 정해야한다. 그리고 현재 잘 관리하고 있지는 않지만 예전에 해당 아키텍처의 형태로 Spring 애플리케이션을 생성할 수 있는 오픈 소스를 만들어두었다. 만약 이 아키텍처에 관심이 있다면 해당 프로젝트를 참고하면 좋을 것 같다.