티스토리로 마이그레이션 후 첫번째로 다룰 내용은, 코드 작성에 있어 계층화는 왜 필요한 것일까? 에 대해서 다뤄보고자합니다.
우선 제가 학교를 다니면서 만들어보았던 한 프로젝트를 소개해드리겠습니다.
github: Fantasia
대충 마을의 용사가 마을에 등장한 몬스터를 잡으며, NPC에게 퀘스트를 받으며 성장하면서 레벨 30이 되면 마왕을 때려잡아 마을을 구원하는 내용의 게임을 2주에 걸쳐서 만들어본 적이 있습니다.
하지만 위의 프로젝트를 구성하면서 중간중간에 변경 사항들이 정말 많았습니다. 하지만 저걸 만들 당시에는 계층화 라는 것에 대해서 모르던 시절이었기 때문에 모든 스킬 발동, 혹은 몬스터의 공격 행동들을 전부 스레드 하나에 때려박고, 스레드를 돌리다보니 코드 변경 사항이 발생하면 최소 6곳의 클래스에서 빨간 줄이 떴습니다.
솔직히, 저 프로젝트를 유지보수하는데만 2주 가운데 4일을 녹여버린 것 같습니다.
이 때를 추억삼아서, 아키텍처의 계층화에 관해서 이야기 해보고자합니다.
1. 아키텍처의 중요성
[마틴파울러] 소프트웨어 아키텍처의 중요성 과 클린아키텍처 를 참고해보시면 소프트웨어가 제공하는 가치는 아래의 두가지로 크게 요약이 됩니다.
- 소프트웨어가 제공하는 기능
- 소프트웨어의 품질, 즉 소프트웨어의 구조
마틴파울러는 장기적인 생명을 가지는 소프트웨어 개발에 대해서 이 두 가지의 가치 중에서 후자인 소프트웨어의 품질에 집중하라고 말을합니다. 그 이유는 유지보수에 있어서 가속도를 붙여주는 요소가 소프트웨어의 품질에 달려있기 때문이라고 하는데요, 그러면 소프트웨어의 유지보수가 무엇인지에 대해서 언급을 해봐야할 것 같습니다.
소프트웨어 유지보수란, 컴퓨터에게 원하는 일을 더 정확히, 더 빠르게, 그리고 더 많이 시키기 위해서 코드를 읽고, 이해하고, 코드를 수정/추가 해나가는 모든 일련의 과정을 의미한다 - 클린아키텍처 애매한 부분 정해드립니다, NHN Forward 2022
따라서 유지보수를 위해서는 코드를 읽고 이해하는 과정 또한 필요하다는 의미로도 해석이 가능하기 때문에 소프트웨어의 품질은 장기적인 관점에서 매우 중요한 부분이고, 또한 이를 위해서 많은 소프트웨어 개발자들은 아키텍처 라는 개념을 만들어서 해당 구조를 준수하도록 강요하고 있습니다.
2. 계층화된 아키텍처
아래의 사진은 계층화 아키텍처-위키북스 에서 가져온 사진입니다.
해당 사진을 기반으로 계층화 아키텍처의 구성요소에 대해서 설명을 해볼까합니다.
도메인 레이어
도메인 레이어는 해당 프로젝트가 해결하고자 하는 도메인의 지식과 정보, 그리고 비지니스의 규칙을 정해주는 역할을 수행합니다. 해당 특징으로 인해서 저는 개인적으로 도메인 레이어에는 아래의 구성요소를 놓습니다.
- Entity : 해당 프로젝트의 비지니스 해결의 결과로 Database에 저장되는 스키마를 정의하는 요소
@Entity
@Table(name = "board")
@DynamicInsert
@DynamicUpdate
class Board(
@Id
@GeneratedValue(strategy = GenerateStrategy.IDENTITY)
@Column(name = "board_id", nullable = false)
var id: Long = 0L,
@Column(name = "title", nullable = false)
var title: String = "",
@Column(name = "content", nullable = false)
var content: String = ""
)
- Repository : 해당 프로젝트의 응용계층이 Database와 통신하는 규칙을 정하여 정의하는 요소입니다. 저는 개인적으로 interface 형태로 정의하여 규칙만을 정의합니다.
@Repository
interface BoardRepository : JpaRepository<Board, Long>, BoardCustomRepository {
fun findByTitle(title: String): List<Board>
}
인프라스트럭쳐 레이어
인프라스트럭쳐 레이어는 도메인 레이어의 Repository에서 정의한 규칙을 실제로 구현하는 역할을 수행하며, Domain과 Database간의 통신을 위임받아 처리하는 계층입니다. 저는 보통 아래의 요소를 인프라스트럭쳐 레이어에 선언합니다.
- DAO(RepositoryImpl) : Domain layer에 정의한 repository들을 구현하는 요소
어플리케이션(응용) 레이어
도메인에 정의한 Repository를 이용하여 실제 비지니스 규칙을 수행하는 요소들의 집합입니다. 주로 상위의 사용자 인터페이스(저는 여기서 표현 계층이라고 부르겠습니다) 에 들어온 검증된 입력을 받아서 프로젝트에 위임된 비지니스 문제를 해결하는 역할을 수행합니다.
저는 어플리케이션 레이어에는 아래의 두 가지 요소를 넣어두는 편입니다.
- DTO : Data Transfer Object, 계층 간의 데이터 교환만을 목적으로 정의하는 객체 집합입니다.
- Service : Repository와 결합을 맺고 프로젝트에 위임된 비지니스 문제를 해결하는 요소들의 집합입니다.
@Service
class BoardService(
private val boardRepository: BoardRepository
) {
fun postBoard(request: BoardCommand.CreateRequest): BoardCommand.CreateResponse {
val createdBoard = boardRepository.save(request.toEntity())
return createdBoard.toCreatedResponse()
}
}
표현 계층 (사용자 인터페이스)
외부의 사용자로부터 입력값을 검증하고, 검증된 입력값을 어플리케이션으로 내려서 비지니스 문제를 위임하는 역할을 수행하는 계층입니다. 이와 같은 특성 때문에 저는 아래의 두 가지 요소를 표현 계층에 정의하는 편입니다.
- Controller(최근에 저는 Handler라는 명칭을 더 선호하긴합니다) : 외부의 사용자로부터 입력값을 받아서 검증하고 응용 계층에 입력값을 넘기는 역할을 수행합니다. 이와 같은 특징 때문에 Controller class의 코드는 아래의 형태를 띄는 경우가 많습니다.
@RestController
@RequestMapping("/api/v1/board")
class BoardHandler(
private val boardService: BoardService,
private val boardValidator: BoardValidator
) {
@PostMapping("")
fun postBoard(@RequestBody request: BoardCommand.CreateRequest): ResponseEntity<BoardCommand.CreateResponse> {
boardValidator.validateCreatable(request); // 입력을 검증하고
val postResult = boardService.postBoard(request); // 비지니스 문제를 해결하고
// 결과를 반환한다
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_JSON)
.body(postResult);
}
}
- validator : 입력값으로 들어온 내용물을 검증하는 요소입니다.
3. 계층화의 원칙
제가 생각하는 계층화의 원칙은 크게 보자면 아래의 네 가지를 지켜야한다고 생각합니다.
- 단방향으로 흐르는 의존성
- 단계를 건너뛰는 의존성은 특별한 케이스를 제외하곤 최대한 지양한다
- 계층간에는 최대한 결합도가 낮아야한다
- 응집도가 높아야한다
단방향으로 흐르는 의존성
말 그대로, 구조화된 소프트웨어는 상위 계층은 하위 계층을 알더라도 하위 계층은 상위 계층을 몰라야한다는 뜻입니다. 예를 들어서, 응용 계층이 하위 계층인 도메인 계층을 참조할 수는 있어도, 도메인 게층은 절대로 상위 계층인 응용계층을 참조해서는 안된다는 것입니다.
단계를 건너뛰는 의존성
예를 들어서, 표현 계층이 도메인 계층을 직접 참조하는 경우를 단계를 건너뛰는 의존성이라 표현합니다. 이러한 의존성은 전체 소프트웨어의 의존의 일관성을 해치기 때문에 추후 유지보수의 난이도를 높이는 존재로 작용합니다. 따라서 단계를 건너뛰는 의존성은 최대한 피하는게 좋습니다.
계층간에는 최대한 결합도가 낮아야한다
우선 결합도의 정의에 대해서 짚고 넘어가겠습니다.
- 강한 결합 (Associate)
하나의 클래스가 다른 하나의 객체를 직접 필드로 참조를 하고있는 형태가 대표적인 강한 결합의 형태입니다. 주로 아래의 형태를 띄고있다면 강한 결합이라 보시면 되겠습니다.
class A {
private val b: B = B()
fun execute() {
b.print()
}
}
- 약한 결합 (Dependent)
하나의 클래스가 다른 하나의 클래스를 메소드의 필드로 받고있거나, 혹은 리턴 타입으로 가진다면 약한 결합을 띈다고 이야기 할 수 있습니다. 주로 아래의 형태를 띄면 약한 결합이라고 보시면 됩니다.
class A(private val b: B) {
fun execute() {
b.print()
}
}
강한 결합은 참조되는 클래스에 변화가 생기면 직접적으로 자신을 참조하는 클래스에 변화가 전파되는 방식이기 때문에 유자보수의 난이도를 높이는 존재로 작용할 가능성이 매우 높습니다.
따라서 계층 간에는 직접적으로 필드로써 하위 계층을 참조하기 보다는, 파라미터의 주입 혹은 리턴 타입으로 가져가는 것을 매우 추천합니다.
Spring을 해보신 분들이라면 흔히들 들어봤을, 필드 주입방식의 DI 보다는 생성자 파라미터를 통한 주입을 해야한다는 말이 이러한 이유에 기인합니다.
응집도가 높아야한다
응집도가 높아야한다는 뜻은 곧 코드 블럭의 역할과 책임이 명확해야한다는 뜻입니다. 예를 들어서, controller가 사용자 입력을 받고 비지니스 영역으로 입력값을 보내는 것 뿐만 아니라 입력을 직접적으로 검증하는 역할까지 수행한다면 아래의 코드 예시가 등장할겁니다.
@RestController
@RequestMapping("/api/v1/board")
class BoardHandler(
private val boardService: BoardService
) {
@PostMapping("")
fun postBoard(@RequestBody request: BoardCommand.CreateRequest): ResponseEntity<BoardCommand.CreateResponse> {
// 검증
if (isAlreadyExistTitle(request)) {
throw RuntimeException()
}
if (!isValidContentLength(request)) {
throw RuntimeException()
}
val postResult = boardService.postBoard(request); // 비지니스 문제를 해결하고
// 결과를 반환한다
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_JSON)
.body(postResult);
}
private fun isAlreadyExistTitle(request: BoardCommand.CreateRequest): Boolean {
...
}
private fun isValidContentLength(request: BoardCommand.CreateRequest): Boolean {
return request.content.length > 0 && request.content.legnth <= 200
}
}
이런 경우 handler는 입력값을 받아서 비지니스 영역으로 보내는데 집중을 하지 못하고, 오히려 검증 로직을 처리하느라 바쁠겁니다. 본연의 임무에 비해서 배보다 배꼽이 커지는 상황이지요.
따라서, 위의 검증 로직을 handler로부터 분리하여 validator를 만들어내는 과정이 일종의 코드 응집성을 높이는 과정이라고 보시면 되겠습니다.
코드의 응집성이 낮은지 판단하는 제일 보편적인 방법은, 클래스의 import문을 보는 것입니다. 해당 import 문이 생각보다 길다는 생각이 든다면, 해당 코드가 응집성이 낮은가를 의심해볼 필요가 있습니다
아래의 세미나 영상을 참조하시면 해당 본문을 이해하는데 도움이 되시리라 믿습니다.
지속 성장 가능한 코드를 만들어가는 방법 - Toss slash 22
4. DIP
DIP란 Dependency Inversion Principle(의존성 역전 원리)라는 SOLID의 D에 해당하는 원칙입니다.
저는 해당 원칙을 클래스 추상화의 원칙이라고 개인적으로 부르고는 하는데요, 이렇게 부르는 이유는 DIP의 핵심 골자가 의존성을 역전시켜서 클래스 간의 결합도를 낮춤으로써 변화 가능성을 줄인다 에 있기 때문입니다.
코드 예시를 들어보도록 하겠습니다. 아래는 Kotlin + Spring 기준으로 짜여진 예제입니다.
// Domain layer
interface UserRepository {
fun findById(id: String): User?
}
// Infrastructure layer
@Repository
class UserRepositoryImpl(
private val userEnhancedClient: DatabaseTemplate
} {
...
override fun findById(id: Long): User? {
return userEnhancedClient.get(id)
}
}
// Appliaction layer
@Service
class UserService(
private val userRepository: UserRepository
) {
fun findUserById(id: Long): User? {
return userRepository.findById(id)
}
}
위의 예제를 보시면, UserRepositoryImpl이 UserRepository의 실제 내용을 구현하고 있음에도 Service layer에서는 domain 계층에 존재하는 UserRepository를 참조하여 사용하고 있는 모습을 확인할 수 있습니다. 하지만 UserService는 그럼에도 불구하고 userRepositoryImpl에 정의된 기능의 혜택을 누릴 수가 있는데요, 이러한 방식의 장점이 도대체 뭘까요?
제가 생각하는 위 방식의 장점은 아래와 같습니다.
- service layer에서 인터페이스 타입의 repository를 참조하기 때문에 UserRepositoryImpl의 변화에 대하여 직접적 영향이 아닌 간접적인 영향을 받게된다. 즉, 변화에 닫힌 형태의 구현이 된다
- 그리고 infrastructure layer가 아닌 domain layer를 직접 service layer가 참조하는 형태를 띄기 때문에 2단계 아래 참조 현상을 막을 수 있다. 즉, 아키텍처의 일관성을 보존해줄 수 있다
5. 계층화의 장점
제가 생각하는 계층화의 장점은 아래의 사항들이라고 생각합니다.
- 구조화된 구조이기 때문에 어디를 고쳐야할지가 한 눈에 보인다. 즉, 유지보수의 편의를 증대한다
- 모듈의 대체성을 높인다. 즉, 필요에따라 전략적으로 모듈을 갈아끼울 수 있어지게된다.
- 테스트가 용이해진다. 역할과 책임이 명확해지기 때문에 테스트 코드가 짧아지고, 행위 중심의 테스트 설계또한 매우 간단해진다
위의 장점과 별개로 제가 따로 생각하는 장점이 있다면, 계층화 아키텍처는 매우 보편적이다 라는 것도 있습니다. 따라서 이 글을 읽는 여러분들도 계층화 아키텍처에 대해 깊은 이해도를 가진다면 아주 유용한 점이 많을것입니다.
'Architecture' 카테고리의 다른 글
Clean Architecture, for sustainable software development (0) | 2023.01.13 |
---|---|
객체지향에서의 추상화 (0) | 2023.01.07 |