← 모든 글
2026-05-13/Architecture

아키텍처 경계를 테스트로 지키기

헥사고날 구조에서 레이어 의존 방향을 검증한 이유

아키텍처는 코드를 예쁘게 나누기 위한 이름이 아니다.

내가 생각하는 아키텍처의 역할은 변경이 들어왔을 때 코드가 어느 방향으로 흔들릴지 미리 정해두는 데 가깝다. 화면 요구사항이 바뀌었을 때 도메인 규칙까지 같이 흔들리지 않게 하고, 저장 방식이 바뀌었을 때 유스케이스가 덜 흔들리게 하는 것. 결국 변경의 파급 범위를 줄이기 위한 약속이다.

처음 구조를 잡을 때는 누구나 신중하다. 패키지를 나누고, 책임을 구분하고, 의존 방향을 정한다. 그림으로 그리면 꽤 그럴듯하다. 하지만 기능이 늘어나고 일정이 밀리면 구조는 조금씩 현실과 부딪힌다.

새로운 조회 조건이 붙고, 외부 연동이 하나 더 생기고, 급하게 화면에 내려줄 값이 필요해진다. 이때 기존 흐름을 조금만 우회하면 일이 빨리 끝날 것처럼 보인다. 한 번은 별일 아닌 것처럼 보이고, 실제로 대부분 별일 없이 동작한다.

아키텍처가 어려운 지점은 여기서부터다. 규칙을 어겨도 컴파일은 되고, 화면도 뜨고, 기능 테스트도 통과할 수 있다. 대신 시간이 지나면 변경 비용으로 돌아온다. 한쪽 변경이 다른 쪽으로 새고, 테스트하려던 코드가 필요 이상으로 많은 설정을 끌고 온다.

나는 여러 아키텍처 스타일 중에서 헥사고날 아키텍처를 선호한다. 도메인을 안쪽에 두고, 웹이나 DB나 외부 API 같은 세부사항을 바깥쪽으로 밀어내는 방식이 변경의 방향을 설명하기 좋기 때문이다. 물론 헥사고날 아키텍처가 항상 정답이라는 뜻은 아니다. 작은 CRUD 기능에는 과할 수 있고, 포트와 어댑터가 오히려 읽는 흐름을 멀게 만들 수도 있다. 그래도 변경이 잦고 외부 연동이 많은 코드에서는 “안쪽은 바깥쪽을 모른다”는 규칙이 꽤 실용적인 기준이 된다.

그래서 이 글에서는 아키텍처라는 큰 이야기에서 출발하되, 내가 선호하는 헥사고날 구조를 기준으로 이야기를 좁혀보려 한다. 특히 그 구조를 문서나 기억에만 맡기지 않고, ArchUnit 테스트로 어떻게 지킬 수 있는지 정리해보려 한다.

먼저 용어를 맞추기

헥사고날 아키텍처를 말할 때 자주 나오는 단어가 있다. 포트, 어댑터, 유스케이스, inbound, outbound 같은 말이다. 익숙하면 편하지만, 설명 없이 지나가면 글이 괜히 멀어진다.

내가 이해하는 구조는 단순하다. 안쪽에는 도메인이 있다. 도메인은 비즈니스 규칙을 담고, 가능하면 웹이나 DB 같은 바깥 사정을 모른다. 그 바깥에는 애플리케이션 레이어가 있다. 여기에는 유스케이스가 놓인다. 학생을 조회한다, 예약을 확정한다, 결제를 승인한다 같은 흐름을 조율하는 코드다.

가장 바깥에는 어댑터가 있다. HTTP 요청을 받는 컨트롤러, Kafka 메시지를 받는 consumer, DB에 접근하는 persistence adapter, Slack이나 외부 API를 호출하는 client adapter가 여기에 놓인다. 바깥에서 안쪽으로 들어오는 요청을 다루는 쪽을 inbound adapter라고 부르고, 안쪽에서 바깥 시스템으로 나갈 때 사용하는 쪽을 outbound adapter라고 부른다.

포트는 이 경계에 놓인 인터페이스다. inbound port는 바깥 요청이 호출할 유스케이스의 입구가 되고, outbound port는 애플리케이션이 바깥 시스템에 기대는 동작을 추상화한다. 이름은 조금 거창하지만, 결국 “안쪽 코드는 바깥 구현체를 직접 보지 않는다”는 약속을 코드로 표현한 것이다.

패키지 구조는 규칙을 지켜주지 않는다

헥사고날 아키텍처를 적용하면 보통 이런 구조를 만든다.

domains/{domain}/
├── domain
├── application
│   └── port
│       ├── inbound
│       └── outbound
└── adapter
    ├── inbound
    └── outbound

이 구조는 좋은 출발점이다. 도메인 모델은 바깥 세상을 몰라도 된다. 애플리케이션 서비스는 유스케이스를 조율한다. 어댑터는 웹 요청이나 DB 같은 외부 세계를 연결한다. 이 방향이 유지되면 비즈니스 로직은 인프라 세부사항에서 떨어져 있을 수 있다.

하지만 디렉터리 이름은 규칙을 강제하지 않는다. adapter.inbound.web 아래에 있는 컨트롤러가 application.port.inbound의 유스케이스를 호출하지 않고 application.port.outbound의 조회 포트를 직접 호출해도 컴파일은 된다. application.service가 편하다는 이유로 adapter.outbound.external의 클라이언트 구현체를 바로 가져와도 컴파일은 된다. 도메인 모델 안에 Spring 타입이나 Jackson annotation이 들어와도, 당장은 기능이 돌아간다.

오히려 이 점이 더 문제다. 아키텍처 위반은 보통 런타임 에러로 나타나지 않는다. 화면도 정상적으로 보이고, 기능 테스트도 통과할 수 있다. 대신 나중에 변경 비용으로 돌아온다. 한 레이어의 변경이 다른 레이어로 새고, 도메인 로직을 테스트하려는데 인프라 설정이 필요해지고, 어댑터를 교체하려는데 애플리케이션 코드가 같이 흔들린다.

패키지 구조는 시작일 뿐이다. 시간이 지나도 같은 방향을 유지하게 만드는 장치가 따로 필요했다.

경계는 보통 작은 작업에서 흐려진다

막고 싶었던 것은 대단한 설계 실수가 아니었다. 실제로는 작은 작업을 빨리 끝내고 싶을 때 생기는 지름길이 더 위험했다.

예를 들어 목록 화면에 필터 조건이 하나 더 붙는 상황을 생각해볼 수 있다. 이미 조회용 Repository 포트가 있고, 컨트롤러에서는 요청 파라미터도 바로 보인다. 상태를 바꾸는 기능도 아니고 단순 조회처럼 보인다. 그러면 유스케이스를 하나 더 만들기보다 컨트롤러에서 조회 포트를 직접 호출하고 싶어진다.

@RestController
class StudentController(
    private val studentQueryPort: StudentQueryPort,
) {
    @GetMapping("/students")
    fun search(request: StudentSearchRequest): List<StudentResponse> {
        val students = studentQueryPort.search(
            grade = request.grade,
            status = request.status,
        )

        return students.map { StudentResponse.from(it) }
    }
}

코드만 보면 나빠 보이지 않는다. 요청을 받고, 조회하고, 응답으로 바꾼다. 단순 조회 API라면 이 정도가 가장 빠른 길처럼 보인다. 문제는 컨트롤러가 outbound port를 직접 알게 되면서 inbound adapter가 애플리케이션의 흐름을 우회한다는 점이다.

adapter.inbound.web → application.port.outbound

조회 조건을 조립하는 기준, 권한에 따라 보여줄 수 있는 범위, 결과를 내려주기 전에 적용해야 하는 정책이 컨트롤러 주변으로 흩어진다. 나중에 같은 조회를 배치나 다른 API에서 재사용하려고 하면, 어디까지가 유스케이스이고 어디부터가 웹 요청 처리인지 다시 찾아야 한다.

그래서 컨트롤러는 조회 방식이 아니라 유스케이스를 호출하게 두는 편이 낫다.

@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(it) }
    }
}

이 코드는 조금 더 돌아가는 것처럼 보인다. 대신 웹 요청을 어떻게 해석할지, 어떤 조회 정책을 적용할지, 어떤 결과를 유스케이스의 결과로 볼지를 애플리케이션 안에 둘 수 있다. 컨트롤러는 여전히 HTTP adapter로 남는다.

외부 연동을 붙일 때도 비슷하다. 애플리케이션 서비스에서 필요한 것은 “알림을 보낸다”는 동작이다. 그런데 이미 만들어둔 SlackClientEmailSender 구현체가 눈앞에 있다. 포트 인터페이스를 하나 더 만들지 않고 그 구현체를 바로 주입하면 당장은 코드가 줄어든다.

@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 → adapter.outbound.external
application → adapter.outbound.persistence

테스트에서는 대체 구현을 끼우기 어려워지고, 연동 방식이 바뀔 때 유스케이스 코드까지 같이 흔들린다. 포트는 경계를 만들기 위한 장치인데, 구현체를 직접 바라보면 그 경계가 힘을 잃는다.

이럴 때 애플리케이션 쪽에는 필요한 행위만 남긴다.

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인지, 나중에 메시지 큐로 바뀔지는 애플리케이션 서비스의 관심사가 아니다. 서비스는 예약 확정이라는 유스케이스와, 확정 사실을 알린다는 필요만 안다.

도메인도 한 번에 무너지지 않는다. 처음부터 도메인 객체가 어댑터를 직접 참조하는 일은 흔하지 않다. 오히려 더 작은 형태로 들어온다. 화면 응답에 맞추려고 toResponse()가 생기거나, JSON 순환 참조를 피하려고 @JsonIgnore가 붙거나, 생성 시각을 편하게 넣으려고 LocalDateTime.now()를 도메인 안에서 직접 호출하는 식이다.

class Student(
    val id: StudentId,
    val name: String,
    @JsonIgnore
    val guardianPhoneNumber: String,
    val status: StudentStatus,
) {
    private val registeredAt: LocalDateTime = LocalDateTime.now()

    fun toResponse(): StudentResponse = StudentResponse(
        id = id.value,
        name = "${name}(${status.label})",
        registeredAt = registeredAt.format(DateTimeFormatter.ISO_DATE),
    )
}

이런 코드는 실제 작업 중에 충분히 생길 수 있다. 화면에 내려줄 값이 필요하고, JSON으로 노출하면 안 되는 필드가 있고, 생성 시각도 당장 넣어야 한다. 하나씩 보면 모두 그럴듯하다. 하지만 도메인 모델이 표현 방식, 직렬화 방식, 시간 생성 방식을 같이 알기 시작하면 비즈니스 규칙을 테스트할 때도 바깥 사정이 따라온다.

도메인 모델에는 도메인 판단만 남기는 쪽이 더 오래 버틴다.

class Student(
    val id: StudentId,
    val name: StudentName,
    val guardian: Guardian,
    val status: StudentStatus,
    val registeredAt: LocalDateTime,
) {
    fun canApplyForConsulting(): Boolean = status == StudentStatus.ACTIVE
}

응답 변환은 web adapter에서, 저장 방식은 persistence adapter에서, 현재 시각이 필요하면 애플리케이션 레이어가 Clock 같은 포트를 통해 결정한다. 도메인은 학생이 어떤 상태이고 어떤 규칙을 만족하는지만 말한다.

domain → application
domain → adapter

이런 의존은 한 줄씩 들어올 때는 크게 거슬리지 않는다. 하지만 쌓이면 도메인을 순수하게 테스트하기 어려워진다. 비즈니스 규칙을 보려는데 프레임워크 설정이나 표현 방식의 사정을 같이 떠올려야 한다. 안쪽 경계가 흐려졌다는 신호다.

도메인 간 의존에서도 비슷한 일이 생긴다. 처음에는 다른 도메인의 모델 하나를 가져다 쓰는 정도로 시작한다. 이미 필요한 값이 거기에 있고, 새 모델을 만들자니 중복처럼 보인다. 하지만 어느 쪽이 더 기본이 되는 도메인인지, 어떤 도메인이 어떤 도메인을 참조해도 되는지 정하지 않으면 시간이 지나면서 순환 의존이 생긴다. 한 기능을 고치는데 여러 도메인을 같이 읽어야 하고, 작은 변경에도 생각해야 할 범위가 넓어진다.

그래서 규칙을 크게 두 층으로 나눴다. 하나는 레이어 의존 방향이고, 다른 하나는 도메인 간 의존 방향이다.

adapter.inbound  → application.port.inbound → domain
application      → domain
adapter.outbound → application.port.outbound → domain

하위 입력 도메인 → 상위 분석 도메인  (금지)
상위 도메인 → 하위 도메인의 공개 모델  (필요한 범위에서 허용)
서로 다른 기능 도메인 간 직접 의존      (기본적으로 금지)

후자는 프로젝트마다 달라진다. 모든 도메인을 완전히 고립시킬 수는 없다. 그래도 어떤 방향은 허용하고, 어떤 방향은 금지할지 명시해야 한다.

규칙을 문서가 아니라 테스트로 옮기기

문서에 규칙을 적는 일은 필요하다. 나도 README나 작업 문서에 레이어 의존 방향을 적어두었다. 새 도메인을 만들 때 어떤 패키지 구조를 따를지, inbound adapter는 어떤 포트를 호출해야 하는지, 외부 API 호출은 어디에 둘지 적어두면 도움이 된다.

하지만 문서는 변경을 막지 못한다. 문서는 읽어야 작동한다. 바쁘면 안 읽고, 익숙해지면 덜 읽고, 시간이 지나면 어디에 적었는지도 잊는다. 특히 혼자 개발하거나 작은 팀에서 일할수록 리뷰어가 항상 옆에서 경계를 봐주지 않는다.

중요한 규칙일수록 사람의 기억 바깥에 있어야 했다. 그래서 ArchUnit 테스트를 추가했다. 프로덕션 클래스를 import한 뒤, 패키지 의존 방향을 규칙으로 선언한다.

private val importedClasses =
    ClassFileImporter()
        .withImportOption(ImportOption.DoNotIncludeTests())
        .importPackages("com.example.app")

@Test
fun `hexagonal layer rules`() {
    noClasses()
        .that()
        .resideInAPackage("..domain..")
        .should()
        .dependOnClassesThat()
        .resideInAnyPackage("..application..", "..adapter..")
        .because("domain은 application, adapter 레이어에 의존하면 안 됩니다.")
        .check(importedClasses)

    noClasses()
        .that()
        .resideInAPackage("..application..")
        .should()
        .dependOnClassesThat()
        .resideInAPackage("..adapter..")
        .because("application 레이어는 adapter 구현체에 의존하면 안 됩니다.")
        .check(importedClasses)

    noClasses()
        .that()
        .resideInAPackage("..adapter.inbound..")
        .should()
        .dependOnClassesThat()
        .resideInAPackage("..application.port.outbound..")
        .because("inbound adapter는 UseCase(inbound port)만 의존해야 합니다.")
        .check(importedClasses)
}

이 테스트는 특별히 멋진 코드가 아니다. 특정 패키지에 있는 클래스가 특정 패키지의 클래스를 의존하지 못하게 할 뿐이다. 그 단순함이 오히려 좋았다. 지키고 싶은 규칙도 결국 단순했기 때문이다.

안쪽은 바깥쪽을 모른다.

onionArchitecture 위에 프로젝트 규칙을 더하기

ArchUnit에는 onionArchitecture() 같은 고수준 API도 있다. 도메인, 애플리케이션, 어댑터 레이어를 선언하면 큰 방향의 위반을 잡아준다.

onionArchitecture()
    .domainModels("..domain.model..", "..domain.event..", "..domain.exception..")
    .domainServices("..domain.service..")
    .applicationServices("..application..")
    .adapter("web", "..adapter.inbound.web..")
    .adapter("persistence", "..adapter.outbound.persistence..")
    .adapter("external", "..adapter.outbound.external..")
    .withOptionalLayers(true)
    .check(importedClasses)

이것만으로도 큰 방향은 잡을 수 있다. 다만 실제 프로젝트에는 고수준 규칙만으로 표현하기 어려운 결정들이 있다. inbound adapter가 outbound port를 직접 의존하는 문제는 별도 규칙으로 더 명확히 막고 싶었다. 도메인 간 의존 방향도 프로젝트마다 다르기 때문에 직접 적어야 했다.

어떤 도메인은 독립적이어야 한다. 어떤 도메인은 다른 도메인의 공개 모델만 참조할 수 있다. 어떤 값 객체는 shared kernel로 허용하지만, aggregate root나 internal service는 금지해야 하는 경우도 있다.

그래서 구조는 대략 이렇게 갔다.

1. onionArchitecture()로 큰 레이어 방향을 검증한다.
2. noClasses() 규칙으로 자주 생기는 우회를 막는다.
3. 도메인 간 의존 방향은 프로젝트의 업무 규칙에 맞게 별도로 적는다.

이때 중요한 것은 규칙을 너무 많이 만들지 않는 것이었다. 모든 것을 금지하려고 하면 테스트가 개발을 돕는 게 아니라 방해한다. 반대로 너무 느슨하면 의미가 없다. 그래서 기준은 하나였다.

이 의존이 생겼을 때 나중에 변경 비용이 커지는가?

커진다면 테스트로 막았다. 단순히 보기 싫은 의존이라는 이유만으로 막지는 않으려 했다.

예외도 규칙의 일부다

아키텍처 테스트를 만들다 보면 예외가 필요해진다. 예를 들어 shared 패키지는 cross-cutting concern을 담는다. 인증 컨텍스트, 공통 설정, 공통 에러 응답, clock, lock 같은 것들이 여기에 들어갈 수 있다. 이런 패키지를 모든 레이어 규칙에 그대로 넣으면 테스트가 너무 쉽게 깨진다.

그래서 shared나 애플리케이션 부트스트랩 패키지는 예외로 둘 수 있다.

.ignoreDependency(
    DescribedPredicate.describe("shared or root bootstrap package") { clazz ->
        clazz.packageName.startsWith("com.example.app.shared") ||
            clazz.packageName == "com.example.app"
    },
    DescribedPredicate.alwaysTrue(),
)

다만 예외를 만들 때는 조심해야 한다. 예외는 편하다. 편하기 때문에 금방 커진다. shared라는 이름은 특히 위험하다. 어디에 둬야 할지 애매한 코드가 전부 shared로 들어가기 시작하면, shared는 공통 모듈이 아니라 우회로가 된다.

그래서 예외도 규칙의 일부로 다뤄야 했다. shared는 아무거나 넣는 곳이 아니다. 여러 레이어에서 필요하지만 특정 도메인 규칙을 담지 않는 cross-cutting concern을 위한 곳이어야 한다. 도메인 간 의존을 피하려고 모든 모델을 shared로 내리는 것도 좋은 해결책이 아니다. 그건 의존을 없앤 게 아니라 이름만 바꾼 것에 가깝다.

아키텍처 테스트가 이런 판단까지 자동으로 해주지는 못한다. 하지만 적어도 예외를 코드에 명시하게 만든다. 그리고 명시된 예외는 나중에 다시 검토할 수 있다.

테스트가 바꾼 개발 흐름

아키텍처 테스트를 넣고 가장 크게 달라진 것은 피드백의 시점이었다.

예전에는 구조가 흐려진 뒤에야 알았다. 기능을 다 만들고 나서 코드를 읽다가 이상한 의존을 발견하거나, 다음 기능을 붙이려 할 때 무언가 지나치게 엮여 있다는 걸 깨달았다. 테스트를 넣은 뒤에는 더 빨리 알 수 있었다.

잘못된 import 하나를 추가하면 테스트가 실패한다. 컨트롤러에서 outbound port를 직접 가져오면 실패한다. 도메인 모델이 바깥 레이어를 참조하면 실패한다. 도메인 간 금지된 방향의 의존을 만들면 실패한다.

이 실패는 꽤 좋은 실패다. 컴파일 에러처럼 빠르고, 테스트 실패처럼 명확하다. 리뷰에서 누군가가 “이 방향은 좀 이상하지 않나요?”라고 말하기 전에 빌드가 먼저 알려준다. 혼자 개발할 때는 이 점이 특히 좋았다. 미래의 내가 현재의 나에게 남겨둔 리뷰어 같은 역할을 한다.

새 기능을 붙일 때도 도움이 됐다. 기존 흐름을 따라 inbound port를 만들고, 필요한 outbound port를 정의하고, adapter를 붙인다. 중간에 “이번 건 단순하니까 바로 호출해도 되지 않을까?” 싶은 순간이 오면 테스트가 막는다. 조회 조건 하나 때문에 컨트롤러가 persistence 쪽을 알게 되거나, 알림 하나 때문에 application service가 외부 API client 구현체를 직접 알게 되는 식의 변경을 초기에 끊어준다.

테스트가 정답을 알려주지는 않는다. 하지만 적어도 “그 방향은 아니다”라고 말해준다.

도메인 간 의존은 더 조심스럽다

레이어 의존은 비교적 단순하다. 안쪽은 바깥쪽을 모른다. domain은 application을 모르고, application은 adapter를 모른다. 이 규칙은 대부분의 프로젝트에 큰 차이 없이 적용할 수 있다.

도메인 간 의존은 그렇지 않다. 어떤 도메인은 다른 도메인의 입력이 필요하다. 어떤 도메인은 다른 도메인의 결과를 읽어야 한다. 어떤 값 객체는 여러 도메인에서 같은 의미로 사용된다. 모든 도메인을 완전히 독립시키려고 하면 오히려 중복이 늘거나, 의미 없는 DTO가 너무 많이 생긴다.

그래서 도메인 간 의존은 “무조건 금지”가 아니라 “방향과 범위 제한”으로 봤다. 예를 들어 이런 질문을 먼저 던졌다.

  • 이 도메인은 다른 도메인의 내부 상태를 알아야 하는가?
  • aggregate root를 직접 참조해야 하는가, 아니면 read model이면 충분한가?
  • 값 객체는 shared kernel로 볼 수 있는가?
  • 이벤트로 전달할 수 있는 관계인가?
  • 지금 허용한 의존이 나중에 양방향 의존으로 자랄 가능성은 없는가?

이 질문의 답에 따라 테스트도 달라졌다.

@Test
fun `student 도메인은 consulting 도메인에 의존하지 않는다`() {
    noClasses()
        .that()
        .resideInAPackage("..student..")
        .should()
        .dependOnClassesThat()
        .resideInAPackage("..consulting..")
        .because("consulting → student 단방향 의존만 허용됩니다. 역방향은 금지입니다.")
        .check(importedClasses)
}

이런 테스트는 단순한 패키지 규칙처럼 보인다. 하지만 그 안에는 도메인 관계에 대한 결정이 들어 있다. 어느 쪽이 더 기본이 되는 도메인인지, 어느 쪽이 어느 쪽을 참조해도 되는지, 어떤 관계를 직접 의존이 아니라 이벤트나 포트로 풀어야 하는지에 대한 결정이다.

그래서 도메인 간 의존 테스트는 아키텍처 문서의 일부이기도 하다. 차이점은 그 문서가 빌드 중에 실행된다는 점이다.

그래도 테스트가 설계를 대신해주지는 않는다

아키텍처 테스트를 넣었다고 좋은 설계가 자동으로 만들어지는 것은 아니다.

패키지 의존 방향을 지켜도 도메인 모델이 빈약할 수 있다. 포트가 너무 잘게 쪼개져서 오히려 읽기 어려울 수도 있다. 모든 유스케이스에 인터페이스를 만들었지만 실제로는 아무런 교체 가능성을 주지 못할 수도 있다. shared 패키지를 허용했더니 중요한 비즈니스 개념이 shared로 흘러갈 수도 있다.

테스트는 이런 문제를 대신 판단해주지 않는다. 그리고 헥사고날 아키텍처 자체도 항상 정답은 아니다. 단순 CRUD가 대부분인 작은 기능에 모든 포트와 어댑터를 엄격하게 나누면 보일러플레이트가 더 커질 수 있다. 파일 수가 늘고, 처음 읽는 사람에게는 흐름이 멀리 돌아가는 것처럼 보일 수 있다.

그래서 테스트는 설계를 증명하는 장치가 아니다. 최소한의 가드레일에 가깝다. 좋은 설계를 보장하기보다는, 명백히 나쁜 방향으로 흐르는 변경을 빨리 알아차리게 해주는 장치다.

이 차이를 구분해야 했다. 아키텍처 테스트가 통과했다고 해서 코드를 자랑할 수 있는 것은 아니다. 다만 내가 중요하게 여긴 경계가 깨지지 않았다는 신호는 된다. 그 위에서 여전히 이름을 고민해야 하고, 포트의 크기를 조정해야 하고, 도메인 관계를 다시 봐야 한다.

테스트는 생각을 대체하지 않는다. 생각해야 할 시점을 앞당길 뿐이다.

기억보다 피드백

아키텍처는 처음 그릴 때보다, 시간이 지나도 같은 방향을 유지하게 만드는 일이 더 어렵다.

처음에는 누구나 깔끔하게 시작한다. 하지만 기능이 늘어나고 일정이 밀리고 예외가 쌓이면, 경계는 조금씩 흐려진다. 그리고 그 흐려짐은 대개 너무 조용해서 늦게 알아차린다.

그래서 중요한 규칙일수록 기억에 맡기지 않으려 했다.

컨트롤러는 유스케이스만 호출한다.
애플리케이션은 어댑터 구현체를 모른다.
도메인은 바깥 레이어를 모른다.
도메인 간 의존은 정해진 방향으로만 흐른다.

이런 문장은 문서에 남길 수도 있다. 하지만 테스트로 만들면 매번 실행된다. 그리고 실패할 수 있다. 실패할 수 있다는 점이 중요하다.

아키텍처 경계를 테스트로 지킨다는 것은 좋은 설계를 증명하는 일이 아니었다. 자주 잊을 수 있는 경계를 빌드가 대신 기억하게 만드는 일이었다. 시간이 지나도 같은 방향으로 고칠 수 있도록, 설계를 기억이 아니라 피드백으로 유지하려는 시도였다.