이번 시간에 다룰 주제는 클린아키텍처입니다. 최근에 JPA + Clean Architecture에 대한 예제를 작성한 바 있는데요, 그 예제의 README 만으로는 설명이 되지않을 것 같은 부분들이 많아 글을 쓰게 되었습니다.
0. 소프트웨어 아키텍처의 목표
마틴 파울러의 영상, 혹은 글을 보는 사람들이라면 분명히 아래의 문구를 한번쯤은 봤을겁니다.
소프트웨어 아키텍처의 목표란, 구조를 좋게 만들어서 수정하기 쉽게 만듦으로써, 수정하는데 비용을 적게 만드는 것에 목표를 두어야한다라고 생각한다. - 엉클 밥 (마틴 파울러)
소프트웨어의 유지보수란, 간단히 말해서 컴퓨터에게 더 많은 일, 그리고 더 정확하고 더 빠르게 만들기 위해서 코드를 읽고 수정하고 추가하는 행위로 정의됩니다. 그리고 코드를 읽는 행위는 코드의 구조가 좋아야만 가능한 일입니다.
따라서, 지속 성장 가능한 소프트웨어의 개발을 위해서라면 소프트웨어 아키텍처를 잘 구성해야함은 그 누구도 반박할 수는 없을겁니다.
하지만, 소프트웨어 아키텍처를 너무 잡으려는 나머지, 확장 가능성에만 너무 몰두하게 되면 개발만 늦어지는 현상또한 발생하고 마는데요, 저는 이 글에서 두 가지의 논점에 집중해서 글을 서술해보려합니다.
- 클린아키텍처를 구현하는 방법 (내지는 원칙)
- 클린아키텍처를 구현함에 있어 예외를 두는 방법 (흔히 지름길이라 합니다)
1. 좋은 아키텍처란?
레이어드 아키텍처에서 벗어나, 좋은 아키텍처가 무엇인지에 대해 논해볼 시간인 것 같습니다. 제가 생각하는 좋은 아키텍처란, 의존성의 방향이 매우 명확하며 (단방향으로만 의존성이 흐르는 형태), 적당히 지식이 공유가 된 상태인 팀에서 해당 코드를 읽으면 직관적으로라도 바로 이해가 가능한 아키텍처를 저는 좋은 아키텍처라고 생각합니다.
그리고 좋은 아키텍처를 구성하기 위한 잘 알려진 아키텍처 패턴들이 있는데요, 제가 듣고 겪어본 아키텍처 패턴은 대표적으로 아래의 3가지 패턴이 있습니다.
- 레이어드 아키텍처
- Hexagonal Architecture
- Clean Architecture
이러한 아키텍처는 마치 백종원 레시피와도 같습니다. 이미 잘 알려진 모범사례이기도 하겠지만, 따라하는 것만으로도 좋은 아키텍처가 갖춰야할 조건들 (단방향의 의존성, 단계를 뛰어넘지 않는 의존성, 역할과 책임이 명확하게 분리되어있는 코드)을 갖출 수 있고, 이러한 원칙들을 지키면서 코딩하는 것을 쉽게 만들어줍니다. 참고(계층화의 필요성)
2. 어떠한 아키텍처를 써야할까요?
저는 세 가지의 아키텍처에 대해서 설명을 드리겠습니다.
레이어드 아키텍처
레이어드 아키텍처는 구조가 매우 단순하고, 백엔드 아키텍처를 처음 접해본다면 매우 직관적이고 쉽습니다. 게다가 레이어드 아키텍처를 안다뤄본 백엔드 엔지니어는 거의 없기 때문에 매우 보편적이기도 합니다. 따라서 도입하기가 매우 쉽습니다.
하지만 단점도 존재하는 아키텍처 패턴인데요, 저희는 일반적으로 레이어드 아키텍처를 설계할 때 DB 중심적으로 설계하려는 경향이 존재합니다. 실제로도 저희는 일반적으로 레이어드 아키텍처를 구성할 때는 먼저 도메인을 정의한 다음, 상위의 DAO, Application, Presentaion layer를 구성하려는 경향을 가지게됩니다.
이렇게 되면, 저희는 해당 프로젝트 내부에 업무 도메인을 거의 설명하게 되지 않고, 이는 소프트웨어가 복잡해지면 복잡해질수록 조직화에 도움을 주지 못할지도 모릅니다.
하지만 이를 극복하고자 하는 개발자분들을 몇 분 보았는데요, 한 개발자분은 Layered Architecture를 설계하면서 독특하게 설계를 하시기에 한번 언급해보고자 합니다.
Layered Architecture을 설계할 때 상향식 설계가 아닌 하향식 설계를 한번 해보세요. 그러면 Controller부터 먼저 설계해서 하향식으로 내려가기 때문에 DB 주도적인 설계에서 어느 정도는 회피할 수 있게 되더라구요.
Clean Architecture
다음으로 소개시켜드릴 아키텍처는 Clean Architecture입니다. 자세한 설명은 다음 단락에서 진행하고, Clean Architecture에 대한 간략한 설명만 진행하고 넘어갈까합니다.
Clean Architecture는 Layered Architecture와 비교해보면, Domain은 중심에 위치하게 되며, 의존성은 무조건 상위 레이어에서 하위 레이어로 흐르게 됩니다.
또한 Clean Architecture는 매우 규칙이 단순합니다. 그저 의존성이 외부에서 내부로만 흘러야하고 관심사의 분리가 핵심이 되어야합니다. 그리고 도메인이 절대로 세부사항에 의존하지 않기 때문에 (유스케이스 계층에 의해서 도메인은 충분히 추상화되기 때문이죠) DDD를 구현하기에 어쩌면 최적인 아키텍처 패턴이 아닐까싶은 생각도 있습니다. (DDD가 특정 기술에 의존해야만하는 것은 아니긴하지만, 그래도 Layered Architecture에 비해선 낫다, 이런 의미입니다)
하지만 클린아키텍처는 애매 그 자체입니다. 아키텍처 패턴임에도 지켜야할 규칙이 너무 단순하기 때문에 클린 아키텍처를 처음 접하게 되면 Best Practice를 못내는 경우가 많습니다. 따라서 Clean Architecture 자체가 처음인 개발자라면, 저는 아래의 아키텍처 패턴부터 적용해본 다음에, 아래의 아키텍처에서 뗄건 떼면서 Clean Architecture의 Best Practice로 접근하는걸 추천합니다.
Hexagonal Architecture
위에서 말씀드렸다시피, Clean Architecture는 애매 그 잡채인 아키텍처 패턴입니다. 그에 반해 Hexagonal Architecture은 명확하게 지켜야할 원칙들이 정해져있는 패턴입니다. 즉, 백종원 레시피처럼 준수해야할 사항들이 정해진 아키텍처 패턴이기에, Clean Architecture 직전에 적용해보면 좋을 것 같은 패턴이라고 생각합니다.
그림 참조: 헥사고날 아키텍처 in Mesh Korea
Hexagonal Architecture의 다른 말은 Port and Adapter Architecture 입니다. 즉, 중심에는 도메인이 위치하는 거는 Clean Architecture와 동일하나, Port와 Adapter 계층을 준수해야한다는 점에서 제약사항이 조금 존재하는 Clean Architecture라고 저는 개인적으로 생각합니다.
여기서 Port and Adapter를 준수하기 위해 Port와 Adapter에 둬야하는 것들이 무엇인지 설명을 드리도록 하겠습니다.
1) Port: 상위의 Adapter에서 사용할 로직들을 추상화하는 계층입니다. Adapter는 Port에 있는 인터페이스를 의존해서 사용하는게 Best Practice라 할수있습니다.
Port에는 대표적으로 Repository, Service (저는 개인적으로 Service는 인터페이스로, ServiceImpl을 구현하여 상위 레이어에는 DIP를 이용해서 Service를 넘기는걸 선호합니다), 그리고 MQ를 사용하는 환경이라면 Messaging 인터페이스까지 넣어두면 되겠습니다.
2) Adapter: Adapter는 실세계와 통신하는 역할을 하는 로직들을 모아두는 계층이라고 보셔도됩니다. 따라서 Controller (내지는 Handler), DAO, Producer, Consumer 같이 외부의 미들웨어, 혹은 외부 브라우저, 모바일과 맞닿아있는 요소들을 위치시키고, Port에 있는 인터페이스에 의존하는 형태로 구성하면됩니다.
이 때, 동일 계층에 있는 요소들을 의존해야한다면, 단방향으로만 의존해야한다는 것을 팀 내부에서 충분히 협의하여 사용하시면 되겠습니다.
다시 정리하자면, 클린아키텍처를 잘 모르겠다면 헥사고날 아키텍처로 시작하는걸 추천합니다. 개인적인 생각으로는 규칙이 더 엄격한 클린 아키텍처가 곧 헥사고날 아키텍처이기에, 헥사고날 아키텍처를 더 간략화시켜 만들어가는 방식으로 클린 아키텍처를 구현하는 방향이 저는 더 옳다라고 생각합니다.
3. Clean Architecture
다시, Clean Architecture에 대해서 다루겠습니다. 이번에는 제가 작성한 예제를 기반으로 알려드리겠습니다. 코틀린 기반의 SpringBoot + JPA 프로젝트입니다.
https://github.com/doccilabs/jpa-clean-architecture
코드에 대해서 일일이 설명하기엔 전달력이 떨어질 것 같기 때문에, 핵심적인 부분과 그리고 제가 채택한 클린아키텍처의 규칙을 살짝 어긴 부분 위주로 설명을 드려볼까합니다.
저는 해당 프로젝트를 클린아키텍처라고 소개를 드렸지만, 실상은 헥사고날 아키텍처와 매우 닮아있는 형태를 볼 수 있습니다. 실제로도 저는 domain 바로 상위 레이어의 이름을 port라고 지었고, port 상위의 레이어는 adapter라고 지었습니다. (제 작명 센스가 부족해서 그럴수도 있습니다)
repository와 dao
JPA는 Java 자체의 리플렉션 기능을 이용해서 repository를 JpaRepository를 상속하기만 하고 Repository 어노테이션을 달아주면 모든 코드가 주입되는 특징이 있습니다.
따라서 JPA를 클린아키텍처 내지는 헥사고날 아키텍처로 구현하려다보면 저 Repository를 어디다 둬야하는가에 대해서 논쟁이 많은데요, 이 부분은 제가 어떻게 했는지 설명드리겠습니다.
결론은, 저는 Repository는 Port layer에 놓고, RepositoryImpl을 Adapter에 놓고, QueryDSL을 사용하는 경우 customRepository는 port layer에, customRepositoryImpl은 adapter에 놓습니다.
물론 이 부분에는 갈등의 여지가 많긴하지만, 제가 이렇게 두는 이유는 차차 설명드리겠습니다.
usecase와 interactor
저는 repository에 사용할 부분만 사용하기 위해서, 그리고 handler에서 사용할 로직을 제한하기 위해서 interface 타입의 유스케이스를 port layer에 정의합니다. 이 때 usecase는 같은 port layer에 존재하는 dto 에 의존해서 구현합니다.
package team.me.usecases.user
import team.me.domain.entity.User
import team.me.dto.user.UserInbound
interface UserInboundUsecase {
fun register(createRequest: UserInbound.CreateRequest): User
}
그리고 usecase의 구현체는 interactor인데요, 이는 adapter 계층에 위치시켜서 아래의 port layer에 위치한 dto, repository에 의존하는 채로 usecase를 구현하는 방식으로 코드를 작성합니다.
package team.me.interactor.user
import org.springframework.stereotype.Service
import team.me.domain.entity.User
import team.me.dto.user.UserInbound
import team.me.dto.user.UserOutbound
import team.me.repository.user.UserRepository
import team.me.usecases.user.UserInboundUsecase
@Service
class UserInboundInteractor(
private val userRepository: UserRepository
) : UserInboundUsecase {
override fun register(createRequest: UserInbound.CreateRequest): User {
val user = createRequest.toEntity()
val createdUser = userRepository.save(user)
return createdUser
}
}
따라서, usecase는 repository의 기능을 추상화하고 비지니스 로직을 담당하게 만듦으로써 개발자는 업무 도메인을 코드에 잘 녹여낼 수 있다라는 특징을 가지게됩니다.
내가 클린아키텍처 정신에서 위배하고 지름길을 탄 부분
저는 완전히 클린아키텍처를 지킨 상태로 코드를 구현하지 않았습니다. 딱 한부분만 단계를 건너뛰는 의존성 을 만들었는데요, 그 부분은 Adapter layer의 usecase 내부에서 구현한 extensions 코틀린 파일입니다.
클린아키텍처 정신을 완벽하게 지키기 위해서는 port layer 안에서 gateway 라는 클래스를 만들어서 domain entity와 dto의 교환 방식을 정의해야만 맞지만, kotlin의 경우 extension 이라는 기능이 존재하기 때문에 gateway를 만들어서 한번 더 추상화하는 방식을 채택하기 보다는 간단하게 dto와 entity의 교환 방식을 정의하기로 결심하였습니다.
사실 이렇게 하여도 큰 문제는 되지 않을 수 있습니다. 그러나, 이러한 지름길을 타게된다면 지켜야할 사항은 몇 가지가 존재합니다.
- 클린아키텍처 정신에 살짝 위배가 되기 때문에 팀 간의 합의가 필요하다.
- 주석을 잘 달아두자. 나중에 후임자가 헷갈려하면 안되기 때문이다.
4. Clean Architecture, 이럴때는 비추천합니다
첫째로, 프로젝트가 소규모일 때입니다. 프로젝트가 소규모일 때 클린아키텍처는 코드 복잡도만 늘리는 결과만 낳고 개발 기간만 늘리는 단점만을 낳을 수도 있습니다. 오히려 프로젝트가 소규모라면 레이어드 아키텍처 패턴을 더욱 추천합니다.
둘째로, 팀원 모두가 클린아키텍처를 이해한 상태가 아닐 때입니다. 클린 아키텍처는 단순하면서도 지키기 어려운 아키텍처 패턴이기 때문에 팀원의 전체적인 이해, 그리고 합의가 존재하지 않는다면 오히려 레이어드 아키텍처가 나은 경우도 있을 수 있습니다.
클린 아키텍처는 분명히 장기적인 소프트웨어 개발에 있어서 지속 성장 가능성을 부여함에는 분명합니다. 따라서 클린 아키텍처를 도입하고자 하는 분들에게 이 글이 도움이 되었으면 좋겠습니다.
'Architecture' 카테고리의 다른 글
객체지향에서의 추상화 (0) | 2023.01.07 |
---|---|
계층화의 필요성 (0) | 2023.01.03 |