목록 API에 조회 조건 하나를 추가하는 PR이었다.
컨트롤러에는 요청 파라미터가 이미 있었고, 데이터를 읽는 outbound port도 있었다. 둘을 바로 연결하면 코드가 가장 짧았다.
@RestController
class StudentController(
private val studentQueryPort: StudentQueryPort,
) {
@GetMapping("/students")
fun search(request: StudentSearchRequest): List<StudentResponse> {
return studentQueryPort
.search(request.grade, request.status)
.map(StudentResponse::from)
}
}기능은 동작했다. 테스트도 통과했다.
그런데 import 하나가 구조에서 약속한 경로를 건너뛰고 있었다.
adapter.inbound.web → application.port.outbound컨트롤러가 데이터를 읽는 것 자체가 문제는 아니다. 문제는 조회 조건을 해석하는 정책까지 HTTP 코드 쪽으로 이동하기 시작한다는 점이다. 권한에 따라 결과를 제한하거나, 같은 조회를 배치에서도 사용해야 하는 순간에는 유스케이스의 경계를 다시 찾아야 한다.
이 문제는 코드 리뷰에서 한 번 지적한다고 끝나지 않았다. 비슷한 import가 다른 기능에서 다시 들어왔다.
문서에 적힌 원칙을 사람이 매번 기억해야 하는 구조였다.
패키지 이름은 의도를 말하지만 의존을 막지는 않는다
프로젝트는 도메인마다 다음과 비슷한 패키지 구조를 사용했다.
domains/{domain}/
├── domain
├── application
│ └── port
│ ├── inbound
│ └── outbound
└── adapter
├── inbound
└── outbounddomain은 업무 규칙을 담는다. application은 유스케이스를 조율하고, adapter는 HTTP, 데이터베이스, 외부 API를 연결한다.
이 이름을 보면 코드를 어디에 둘지 짐작할 수 있다. 하지만 컴파일러는 패키지의 뜻을 모른다.
- web controller가 outbound port를 직접 호출해도 컴파일된다.
- application service가 Slack client 구현체를 가져와도 컴파일된다.
- domain model에 Jackson이나 Spring 타입을 붙여도 컴파일된다.
폴더 구조는 표지판이다. 가드레일은 아니다.
그렇다면 모든 아키텍처 원칙을 테스트로 만들면 될까? 여기서 먼저 선을 그어야 했다.
테스트로 옮길 수 있는 규칙은 생각보다 적다
“도메인 모델은 풍부해야 한다”는 원칙은 중요하다. 그러나 class dependency만 읽어서 풍부함을 판정할 수는 없다.
“포트의 크기는 적당해야 한다”도 마찬가지다. 메서드가 세 개면 적당하고 네 개면 과하다는 규칙은 없다.
반면 다음 규칙은 import 방향으로 드러난다.
컨트롤러는 outbound port를 직접 호출하지 않는다.
application은 adapter 구현체를 참조하지 않는다.
domain은 application과 adapter를 참조하지 않는다.ArchUnit이 잘하는 일은 이쪽이다. 컴파일된 class를 읽고, 어느 패키지의 class가 어느 패키지를 참조하는지 확인한다.
설계를 대신 판단하게 하지 않고, 기계가 확실히 확인할 수 있는 의존 방향만 맡기기로 했다.
컨트롤러는 유스케이스를 호출한다
처음 예제로 돌아가 보자.
컨트롤러가 outbound port를 바로 부르면 HTTP 입력과 조회 정책 사이에 application 경계가 없다. 그래서 inbound port에 유스케이스를 드러냈다.
interface SearchStudentsUseCase {
fun search(query: SearchStudentsQuery): SearchStudentsResult
}
@RestController
class StudentController(
private val searchStudentsUseCase: SearchStudentsUseCase,
) {
@GetMapping("/students")
fun search(request: StudentSearchRequest): List<StudentResponse> {
val result = searchStudentsUseCase.search(request.toQuery())
return result.students.map(StudentResponse::from)
}
}파일은 하나 더 생겼다. 대신 조회 조건과 권한 정책은 application 안에서 찾을 수 있게 됐다. 같은 유스케이스를 다른 adapter에서 호출해도 기준이 달라지지 않는다.
이 경계를 ArchUnit 규칙으로 표현하면 다음과 같다.
noClasses()
.that()
.resideInAPackage("..adapter.inbound..")
.should()
.dependOnClassesThat()
.resideInAPackage("..application.port.outbound..")
.because("inbound adapter는 유스케이스를 통해 application에 진입합니다.")
.check(importedClasses)테스트는 StudentController라는 이름을 모른다. 어느 도메인에 새 컨트롤러가 추가돼도 같은 방향을 검사한다.
application은 필요한 행위만 안다
외부 알림을 붙일 때도 비슷한 일이 생긴다.
@Service
class ConfirmReservationService(
private val reservationRepository: ReservationRepository,
private val slackClient: SlackClient,
) {
fun confirm(command: ConfirmReservationCommand) {
val reservation = reservationRepository.getById(command.reservationId)
reservation.confirm()
reservationRepository.save(reservation)
slackClient.send("예약이 확정되었습니다: ${reservation.id}")
}
}이 코드는 “예약 확정 사실을 알린다”는 유스케이스와 “Slack으로 메시지를 보낸다”는 구현을 동시에 안다.
application에는 필요한 행위만 남긴다.
interface ReservationNotificationPort {
fun notifyReservationConfirmed(reservation: Reservation)
}
@Service
class ConfirmReservationService(
private val reservationRepository: ReservationRepository,
private val notificationPort: ReservationNotificationPort,
) {
fun confirm(command: ConfirmReservationCommand) {
val reservation = reservationRepository.getById(command.reservationId)
reservation.confirm()
reservationRepository.save(reservation)
notificationPort.notifyReservationConfirmed(reservation)
}
}Slack, email, 메시지 큐 중 무엇을 사용할지는 outbound adapter가 결정한다.
noClasses()
.that()
.resideInAPackage("..application..")
.should()
.dependOnClassesThat()
.resideInAPackage("..adapter..")
.because("application은 adapter 구현체를 알지 않습니다.")
.check(importedClasses)이 테스트가 알림 설계를 좋은 구조로 만들어 주지는 않는다. 다만 SlackClient를 application으로 바로 가져오는 우회는 막아준다.
domain에서는 표현 방식이 빠져나간다
도메인 경계는 더 작은 import에서 무너지기도 한다.
class Student(
val id: StudentId,
val name: String,
@JsonIgnore
val guardianPhoneNumber: String,
) {
fun toResponse(): StudentResponse = StudentResponse(
id = id.value,
name = name,
)
}@JsonIgnore에는 개인정보를 보호하려는 좋은 의도가 있다. toResponse()도 중복을 줄여준다. 그래도 domain이 JSON 직렬화와 HTTP 응답 타입을 알기 시작했다는 사실은 달라지지 않는다.
직렬화는 web adapter가 맡고, persistence mapping은 persistence adapter가 맡는다. domain에는 상태와 행동을 남긴다.
noClasses()
.that()
.resideInAPackage("..domain..")
.should()
.dependOnClassesThat()
.resideInAnyPackage("..application..", "..adapter..")
.because("domain은 바깥 레이어의 표현 방식을 알지 않습니다.")
.check(importedClasses)프레임워크 의존까지 막고 싶다면 Spring, Jackson, JPA 패키지를 대상으로 별도 규칙을 추가할 수 있다. 처음부터 모든 라이브러리를 금지하기보다 실제로 반복해서 들어온 의존부터 규칙으로 옮기는 편이 관리하기 쉬웠다.
큰 방향과 자주 생기는 우회를 나눠 검사했다
ArchUnit의 onionArchitecture()는 레이어의 큰 방향을 표현하는 데 편하다.
onionArchitecture()
.domainModels("..domain.model..", "..domain.event..")
.domainServices("..domain.service..")
.applicationServices("..application..")
.adapter("web", "..adapter.inbound.web..")
.adapter("persistence", "..adapter.outbound.persistence..")
.adapter("external", "..adapter.outbound.external..")
.withOptionalLayers(true)
.check(importedClasses)하지만 프로젝트에서 자주 생기는 우회까지 모두 설명해 주지는 않는다.
그래서 규칙을 두 층으로 나눴다.
onionArchitecture()로 큰 레이어 방향을 확인한다.noClasses()로 컨트롤러의 outbound port 직접 호출처럼 반복되는 우회를 막는다.
도메인 사이의 관계도 업무 방향이 분명할 때만 개별 규칙으로 적었다.
noClasses()
.that()
.resideInAPackage("..student..")
.should()
.dependOnClassesThat()
.resideInAPackage("..consulting..")
.because("consulting이 student를 참조하는 단방향만 허용합니다.")
.check(importedClasses)모든 도메인을 서로 고립시키지는 않았다. 여러 도메인에서 같은 의미로 쓰는 값까지 복제하면 DTO만 늘어난다. 어느 쪽이 기준 정보를 제공하는지 설명할 수 있는 관계만 테스트에 넣었다.
예외는 숨기지 않고 코드에 남겼다
인증 컨텍스트, clock, 공통 오류처럼 여러 레이어에서 함께 쓰는 코드가 있다. 이런 패키지는 검사에서 제외할 수 있다.
.ignoreDependency(
DescribedPredicate.describe("shared or bootstrap package") { clazz ->
clazz.packageName.startsWith("com.example.app.shared") ||
clazz.packageName == "com.example.app"
},
DescribedPredicate.alwaysTrue(),
)예외가 있다는 사실보다 예외가 보이지 않는 상태가 더 위험했다.
shared를 무제한으로 허용하면 어디에 둘지 애매한 코드가 전부 그쪽으로 이동한다. 그래서 제외 범위는 테스트에 명시하고, 범위가 커지는 변경을 PR에서 확인했다.
예외 목록은 규칙의 실패가 아니라 구조의 부채를 보여주는 목록으로 다뤘다.
다음 변경의 범위를 넓히는 의존만 막았다
아키텍처 테스트를 추가할지 판단할 때 사용한 질문은 하나였다.
이 import가 들어오면 다음 변경에서 함께 읽고 고쳐야 할 코드가 늘어나는가?
컨트롤러가 outbound port를 직접 호출하면 조회 정책을 찾을 범위가 넓어진다. application이 adapter 구현체를 알면 외부 연동을 바꿀 때 유스케이스도 수정한다. domain이 표현 타입을 알면 API 응답 변경이 도메인까지 전파된다.
이런 의존은 테스트에 넣었다.
반대로 단순히 패키지 이름이 마음에 들지 않거나, 포트의 크기가 적당한지 판단해야 하는 문제는 코드 리뷰에 남겼다. ArchUnit이 답할 수 없는 질문까지 맡기면 예외만 늘어난다.
테스트가 통과해도 설계는 끝나지 않는다. 빈약한 도메인 모델, 지나치게 잘게 나뉜 포트, 교체할 이유가 없는 인터페이스는 여전히 사람이 판단해야 한다.
달라진 것은 리뷰에서 쓰는 시간이었다.
이제 리뷰에서는 잘못된 import를 눈으로 다시 찾기보다, 그 의존을 허용해야 하는 이유가 있는지 논의한다. 허용한다면 테스트와 예외 목록이 함께 바뀐다. 허용하지 않는다면 빌드가 코드 리뷰보다 먼저 알려준다.
문서에 있던 원칙 가운데 컴파일된 class로 확인할 수 있는 부분만 테스트로 옮겼다. 아키텍처를 자동으로 설계하려는 시도는 아니었다.
잘못된 import 한 줄을 사람이 매번 찾아내는 일을 줄이려는 시도였다.