프로젝트에 새로운 아키텍처 적용하기

김승택
NAVER Pay Dev Blog
Published in
16 min readJun 8, 2023

안녕하세요. 저는 네이버파이낸셜 주문형페이개발팀의 김승택입니다.

네이버페이는 외부 쇼핑몰이 주문형페이의 시스템을 이용해서 결제 및 주문을 진행할 수 있는 프로세스를 가지고 있고, 이 프로세스의 개발 검증을 위한 테스트 외부 쇼핑몰이 별도의 애플리케이션으로 존재하고 있습니다.

해당 애플리케이션을 팀원분들과 새로운 프로젝트로 구축하면서 어플리케이션의 아키텍처에 대해 고민하고, 새로운 아키텍처를 도입하며 개발하는 과정에서 소프트웨어의 아키텍처에 대해 고민하고 느낀점을 공유하고자 합니다.

레이어드 아키텍처

우리가 웹 애플리케이션을 구축한다면 가장 먼저 떠오르는 아키텍처는 다음과 같은 레이어드 아키텍처일 것입니다.

레이어드 아키텍처는 주요 로직을 계층 별로 역할을 나누기 때문에 웹 애플리케이션의 개발에 적용하기 쉬운 아키텍처입니다. 각 계층과 컴포넌트를 부르는 이름은 조금씩 다를 수 있지만 주로 다음과 같은 3계층으로 나뉘게 됩니다.

  • Presentation 계층, Controller는 사용자의 요청/응답을 처리(주로 웹 API )
  • Application 계층, Service는 비즈니스 로직을 처리
  • Data Access계층, Repository는 데이터베이스와 데이터를 송/수신

위 그림에서 화살표의 방향은 계층간 의존성의 방향을 나타내고 있는데, Persistence 계층의 Repository가 최상위 의존성을 가지고 있는 것을 확인할 수 있습니다.

애플리케이션의 설계 및 개발 순서는 자연스럽게 비즈니스 로직에 대한 검증과 구현보다는 데이터베이스와 데이터 구조 등의 Data Access 계층의 설계가 가장 먼저 이루어지게 되고, 해당 계층의 변화는 전체 애플리케이션에 영향을 주게 됩니다.

그렇기 때문에 비즈니스 로직과 무관환 변화로부터 애플리케이션의 비즈니스 로직을 분리시키는 방법이 필요하게 되었고, 계층간의 결합도를 낮춰 변화에 유연하게 대응할 수 있는 클린 아키텍처에 대한 고민이 이루어지기 시작했습니다.

클린 아키텍처란?

클린 아키텍처

어플리케이션을 개발할때 가장 중요한 것은 주요 비즈니스 로직을 이해하고 이를 구현 및 검증하는 것입니다.

그러기 위해서는 도메인 모델을 정의하고 핵심 로직에 대한 명세를 작성하는 것부터 개발을 시작해야 합니다. 그 외에 REST API 요청 명세나, 데이터베이스 종류/스키마 등은 비즈니스 로직과 분리되어 구현되어야 합니다.

클린 아키텍처의 주요 개념을 정리하면 다음과 같습니다.

  • 비즈니스 로직을 구현한 계층은 어떤 의존성도 가지지 않는 최상위 계층이어야 하며, 외부 계층의 변경에 영향을 받지 않아야 한다.
  • 해당 계층으로의 입출력을 추상화 계층으로 감싸면, 모든 외부 의존성의 방향을 도메인 계층을 향하도록 만들 수 있다.
  • 그로 인해 비즈니스 로직을 구현할때 외부 계층에 대한 결정(REST API, DB 등)을 미루거나 쉽게 변경할 수 있고, 비즈니스 로직 구현에 우선적으로 집중할 수 있다.
  • 외부 계층의 상세 구현에 영향을 받지 않고 비즈니스 로직의 테스트가 가능하며, 모든 계층의 컴포넌트들은 독립적으로 각 계층의 역할에 맞는 테스트를 할 수 있다.

헥사고날 아키텍처

헥사고날 아키텍처

헥사고날 아키텍처는 추상적인 개념의 클린 아키텍처를 구현하는 방법 중 하나로, 그 구현 방식 때문에 포트 앤 어댑터(Port & Adapter) 아키텍처라고도 불립니다.

헥사고날 아키텍처의 특징은 다음과 같습니다.

  • 도메인 계층이 최상위 의존성으로 존재하면서, 도메인 계층으로 들어오거나 나가는 계층간의 요청/응답은 Port라는 인터페이스를 통해 정의한다.
  • 도메인 계층 외부에서 특정 Port의 메서드를 호출하거나, Port의 인터페이스를 구현한 구현체를 Adapter라고 부른다.
  • Input Port를 이용하는 대표적인 Adapter로는 REST API 요청을 처리하는 역할을 하는 웹 어댑터가 있고, 해당 계층은 User Interface 계층이라고 하며 비즈니스 로직을 직접 호출하기 때문에 주도하는(driving) 어댑터라고 부른다.
  • Output Port를 구현하는 대표적인 Adapter로는 데이터베이스에 데이터를 저장하고 불러오는 역할을 하는 영속성 어댑터가 있고, 해당 계층은 Infrastructure 계층이라고 하며 비즈니스 로직에 의해 호출되기 때문에 주도되는(driven) 어댑터라고 부른다.

헥사고날 아키텍처는 Port와 Adapter라는 추상화와 구현체를 통해 Infrastructure 계층이 최상위 의존성으로 존재하는 레이어드 아키텍처와 다르게 비즈니스 로직을 최상위 계층으로 두는 아키텍처입니다.

위의 그림에서 확인할 수 있듯이 헥사고날 아키텍처는 input 포트와 output 포트 명세가 있다면, 해당 명세에 맞게 구현된 어댑터의 형태가 어떻든 동일한 비즈니스 로직을 수행할 수 있다는 것이 가장 큰 장점입니다.

헥사고날 아키텍처 웹 애플리케이션

헥사고날 아키텍처로 구현한 웹 어플리케이션

일반적인 웹 애플리케이션을 헥사고날 아키텍쳐로 구현한다면, 각 계층의 컴포넌트가 가져야할 책임과 역할은 다음과 같이 정의할 수 있습니다.

웹 어댑터

  • HTTP 입력 유효성 검증
  • 비즈니스 로직을 수행하는 유스케이스 호출
  • 결과를 HTTP 응답 형태로 반환

어플리케이션

  • 입력을 받아서 비즈니스 로직 수행
  • (필요하다면) 영속성 어댑터를 통해 수행 결과 저장
  • 수행 결과를 반환

도메인

  • 도메인 로직이 구현되어있는 애그리거트 루트, 엔티티 도메인 모델

영속성 어댑터

  • 데이터베이스 연결을 담당하는 구현체
  • 어플리케이션 계층으로부터의 입력을 데이터베이스에 전달할 수 있는 형태로 변환한 후 전달
  • 데이터베이스의 출력을 어플리케이션 계층에서 정의한 형태로 변환한 후 반환

프로젝트에 적용

기존에 새로운 서비스를 구축할때의 기억을 떠볼려보면 가장 먼저 했던 일은 데이터베이스를 선택하고 스키마를 작성하는 일이었습니다. 또는 REST API 명세를 작성하거나 어떤 프레임워크와 ORM을 사용할지부터 고민하였습니다.

하지만 헥사고날 아키텍처를 적용하면서 가장 먼저 우리의 비즈니스 로직을 어떻게 구현할 것인지에 대한 고민부터 하게 되었고, 어플리케이션의 입출력 상세에 대한 고민은 나중으로 미룰 수 있다는 것을 알게 되었습니다.

상품 등록 요청을 받으면 상품 정보를 검증하고, 상품을 저장한 뒤 결과를 응답으로 전달하는 유스케이스를 간단한 코드 예제와 함께 헥사고날 아키텍처를 구현하는 방법을 알아보겠습니다.

가장 먼저 도메인 모델을 구현합니다. 상품 도메인 모델은 다음과 같은 요구사항을 구현해야 합니다.

  • 상품은 id/상품명/가격/재고수량 값을 가지며, 상품명/가격/재고수량은 변경될 수 있고 유효한 값의 기준이 있다.
class Product(
val id: Long? = null,
var name: String,
var price: Long,
var stockQuantity: Int
) {
fun validate() {
if (name.isBlank()) {
throw IllegalArgumentException("상품명은 공백을 제외하고 한글자 이상이어야 합니다.")
}
if (price < 1) {
throw IllegalArgumentException("가격은 1 이상이어야 합니다.")
}
if (stockQuantity < 0) {
throw IllegalArgumentException("재고수량은 0 이상이어야 합니다.")
}
}
}

위 도메인 모델을 확인하는 것만으로 상품 도메인 모델이 가지고 있어야 할 데이터들과 각 데이터에 관한 핵심 도메인 로직을 확인할 수 있습니다.

이후에 이 상품 모델을 사용하는 비즈니스 로직을 정의할 수 있습니다. 상품정보를 생성하는 유스케이스의 핵심 로직은 다음과 같습니다.

  • 상품 생성 요청이 들어오면, 상품 모델을 생성한다.
  • 상품 모델을 검증한다.
  • 상품을 저장하고 생성된 상품 모델을 반환한다.

두번째로 구현할 것은 이 비즈니스 로직을 처리하기 위한 어플리케이션 계층의 입출력 경계에 위치할 인터페이스입니다.

User Interface 계층으로부터의 입력 요청에 대한 명세는 ProductUseCase 인터페이스에 정의하고, Infrastructure 계층으로 나가는 출력 요청에 대한 명세는 ProductOutPort 인터페이스에 정의합니다.

// ProductUseCase.kt
interface ProductUseCase {
fun create(productCreateDto: ProductCreateDto): Product
}
// ProductOutPort.kt
interface ProductOutPort {
fun save(product: Product): Product
}

어플리케이션 계층으로의 입출력 명세를 작성하고 나면 해당 명세를 따르는 상세 비즈니스 로직을 구현할 수 있습니다.

// ProductService.kt
class ProductService(
private val productOutPort: ProductOutPort
) : ProductUseCase {

override fun create(productCreateDto: ProductCreateDto): Product {
// 상품 모델 생성
val product = productCreateDto.toProduct()

// 상품 모델 검증
product.validate()

// 상품 저장 후 반환
return productOutPort.save(product)
}
}

여기까지 구현이 되었다면 User Interface, Infrastructure 계층에 대한 구현 없이 Application & Domain 계층만 구현한 상태에서 비즈니스 로직을 검증할 수 있습니다.

ProductOutPort는 인터페이스이기 때문에 쉽게 모킹이 가능해서 테스트 코드를 간단하게 작성할 수 있기 때문입니다.

비즈니스 로직을 검증하는데는 User Interface 계층으로부터의 입력이 웹 REST API 요청일지, CLI 로부터의 입력일지는 중요하지 않습니다. 또한, Infrastructure 계층으로의 출력이 RDBMS 데이터베이스로 가는지, 다른 마이크로서비스의 API를 호출하는 것일지 또한 중요하지 않습니다.

현재까지 구현된 어플리케이션은 어떤 프레임워크나 라이브러리, 웹/데이터베이스 환경에 대한 의존성이나 고려 없이 순수 코틀린 코드만을 사용했기 때문입니다.

만약 Infrastructure 계층으로의 출력이 RDBMS 데이터베이스로 정해졌다면 다음과 같이 ProductOutPort 명세를 따르는 Adapter를 구현할 수 있고, 어떤 Database 혹은 ORM을 이용할지에 대한 추가적인 고민이 이어지게 됩니다.

class ProductPersistAdapter(
private val productRepository: ProductRepository
) : ProductOutPort {

override fun save(product: Product) {
return productRepository.save(product)
}
}

그리고 마지막으로 사용자로부터 요청을 받아서 유스케이스를 호출할 Input Adapter에 대한 구현과 어플리케이션을 구성하는 프레임워크나 라이브러리에 대한 고민이 이어질 것입니다.

완성된 상품 등록 서비스 애플리케이션의 클래스 의존성 다이어그램은 다음과 같습니다.

위 그림에서 점선은 계층간의 경계를 나타내는데, 애플리케이션 & 도메인 계층으로 향하는 의존성만 존재하고 밖으로 나가는 의존성이 없는 것을 확인할 수 있습니다.

패키지 구조는 다음과 같이 구성하였습니다.

product
action
api
ㄴ ProductApiController.java
application
port
input
ㄴ ProductUseCase.java
output
ㄴ ProductPersistPort.java
service
ㄴ ProductService.java
domain
ㄴ Product.java
infrastructure
persistence
ㄴ ProductPersistAdapter.java
ㄴ ProductRepository.java

도메인을 나타내는 패키지를 최상위 패키지로 두고, action(User Interface) / application / domain / infrastructure 패키지를 하위 패키지로 구성하였습니다.

모듈 교체

해당 프로젝트를 진행하면서 헥사고날 아키텍처를 적용한 것의 이점을 톡톡히 본 것이 있었는데, 바로 영속성 어댑터를 교체했던 작업입니다.

프로젝트 초기에 팀원분들과 최근에 주목받고 있는 ORM을 적용해보고자 Spring Data JDBCSpring Data R2DBC 중 어떤 ORM을 사용할지 고민을 하였습니다.

두 ORM 모두 사용해본 경험이 없었고, 어떤 장단점이 있는지 몰랐기 때문에 두 ORM을 모두 사용해본서 장단점을 느껴보고 한 ORM으로 정해보자라는 결론이 나왔습니다.

애플리케이션 계층은 Out Port 인터페이스에 의존하고 있었기 때문에 실제 Out Port를 구현한 영속성 어댑터가 어떤 ORM을 사용하는지, 어떤 데이터베이스와 연결되는지는 어플리케이션 계층의 구현과 관련이 없었습니다.

그렇기 때문에 영속성 어댑터의 구현체를 서로 다른 ORM으로 개발하고 구현체를 바꿔보면서 서비스를 실행도 해보고, 각 ORM을 개발하면서 느낀 장단점을 통해 영속성 어댑터를 결정할 수 있었습니다.

상위 계층의 포트에 맞는 하위 계층의 어댑터를 변경하는 일은 상위 계층에 영향을 주지 않기 때문에, 어댑터를 변경함에 있어서 상위 의존성인 어플리케이션 & 도메인 계층의 코드 수정은 불필요하였습니다.

이후에 서비스를 운영하다가 Infrastructure 계층의 스펙이 변경되어 상품 정보의 저장을 데이터베이스에 연결해서 처리하는 것이 아닌, 다른 마이크로서비스의 REST API를 호출하는 방식으로 변경될 수 있습니다.

이럴 경우 역시 기존 Out Port 명세를 따르는 새로운 REST API 어댑터를 구현하기만 한다면, 애플리케이션 & 도메인 계층의 소스코드 수정이나 영향을 주지 않고 쉽게 교체가 가능할 것입니다.

은총알은 없다

위와 같은 헥사고날 아키텍처의 이점을 얻기 위해서는 그만큼 레이어드 아키텍처로 프로젝트를 구현할 때보다 추가로 요구되는 작업이 있었는데, 하나의 도메인 모델에 대해서 계층별로 해당 계층에서 사용할 모델을 구현하는 것입니다.

이런 작업이 필요한 이유는, 특정 계층의 요구사항 변화로 인한 모델의 변경으로 인해 다른 계층의 모델이 영향을 받지 않아야 하기 때문입니다.

예를 들어 상품 정보를 조회하는 REST API의 응답 필드나 데이터베이스 스키마에 도메인 로직과 무관한 변경이 있더라도, 도메인 계층의 상품 모델에는 변경이 있어서는 안됩니다.

그렇기 때문에 계층별로 상품 도메인 모델에 대해 다음과 같은 형태의 서로 다른 모델을 가지고 있습니다.

  • 웹 계층: ProductResponse
  • 어플리케이션 계층: Product
  • 영속성 계층: ProductRow

모델이 계층을 넘나들때는 반드시 해당 계층에 맞는 모델로의 변환 로직을 통해 변환되거나 데이터 전송 오브젝트(DTO) 형태로 전달되어야 합니다.

하지만 만약 세 계층에서 사용하는 모델의 구조가 크게 다르지 않다면 모델에 중복되는 필드가 많고, 변환 로직에도 중복이 많아지게 됩니다.

3개의 모델을 관리하는 것 때문에 애플리케이션 개발이 더뎌진다는 생각이 들고 하나의 모델을 계층간에 공유하는 코드를 작성하고 싶은 욕심이 들기도 했습니다.

특히 비즈니스 로직이 복잡하지 않고 단순한 CRUD 작업만을 수행하는 애플리케이션인 경우 하나의 모델을 계층간에 공유해도 되지 않을까라는 생각을 하기도 했습니다.

물론 그렇게 된다면 도메인 모델에 @Table, @Column, @JsonIgnore 등 특정 프레임워크/라이브러리에 의존적인 어노테이션이 추가될 것이고 이것은 외부 계층의 수정이나 변화에 도메인 계층에 수정사항이 필요한 것을 의미합니다.

마무리

애플리케이션을 설계하는데 영향을 미치는 수많은 설계 의사결정(Architectural Design Decision)들이 모여서 최종적으로 완성된 구조를 소프트웨어 아키텍처라고 할 수 있습니다.

소프트웨어 설계 의사결정에는 크게는 Microservices/Monolithic 구조부터 단일 프로젝트의 싱글/멀티모듈 구조 등이 포함될 수 있습니다.

새로운 프로젝트를 진행하면서 팀원분들과 꾸준히 여러 의사결정에 대해 다같이 논의를 하였고, 앞으로도 이런 의사결정에 대한 논의는 계속될 것입니다.

그 과정에서 프로젝트를 멀티 모듈에서 싱글 모듈로 변경하기도 하며, 레이어드 아키텍처에서 헥사고날 아키텍처로 변경을 하며 최종적으로 현재 프로젝트의 구조를 가지게 되었습니다.

하지만 결국 어떤 아키텍처를 선택해서 소프트웨어를 개발하더라도, 그것을 선택하게된 핵심 요인인 재사용성/변경용이성/확장개발성과 같은 개발 관점의 품질 속성 (Quality Attribute)들이 가장 중요하다는 것을 알게 되었습니다.

그리고 항상 이런 사항들을 고민하며 소프트웨어를 위한 의사결정을 위해 팀원분들과 토론하고 논의했던 시간들이 정말 값진 시간이었습니다.

출처

--

--