티스토리 뷰

728x90

멈춘 코드

브랜드 도메인을 만들고 있었다. 요구사항은 단순했다

"어드민이 브랜드를 등록할 때, 동일한 이름의 soft-deleted 행이 있으면 새 INSERT 대신 그 행을 복구(restore)하라."

설계 문서는 이 로직을 BrandAdminFacade.create 에 두라고 했다. 그대로 옮기다가 손이 멈췄다.

 

// BrandAdminFacade.create()
fun create(name: String): BrandInfo {
    val existing = brandRepository.findByName(name)        // ← 왜 Facade 가 이걸 알지?
    return when {
        existing == null -> save(BrandModel(name))
        existing.deletedAt != null -> existing.restore()   // ← 이게 진짜 "어드민 흐름" 인가?
        else -> throw CONFLICT
    }
}

 

처음엔 그냥 컨벤션이 어긋난 느낌 정도라고 넘기려 했다. 설계 문서를 따랐으니 일단 진행하면 되겠지, 라고.

그런데 손은 계속 멈춰 있었다. 어색함의 정체가 단순한 스타일 문제가 아닌 것 같았다. 책임 위치 자체가 어긋났다는 직감에 가까웠다. 그래서 한 단계 깊이 들어가 봤다.

Service 에 둘까, Facade 에 둘까. 답을 찾으려면 한 단계 위의 질문을 먼저 풀어야 했다.

이건 도메인 규칙인가, 유스케이스인가?

 

두 단어의 경계

 

그런데 실제 케이스에 대보면 한 가지 룰을 두고 둘 다 말이 되어 보일 때가 있다. auto-restore 가 정확히 그런 경우였다.

도메인이라 보면: "Brand 의 이름은 영구 식별자다" 는 Brand 개념의 본질.
유스케이스라 보면: "어드민이 POST 로 등록 요청 시, 충돌하면 restore 한다" 는 어드민의 행동 양식.


둘 다 말이 됐다. 표 옆에 두 옵션을 놓고 한참을 갈팡질팡했다. 표가 답을 줄 거라 기대했는데, 표는 정의일 뿐 판별 기준은 아니었다.

 

다른 기준들도 거쳐봤다

 

"진입점 불변성"이라는 기준에 도달하기 전에 몇 가지 다른 잣대를 시도했었다.

"상태가 있는가?" — auto-restore 는 상태 없는 연산이고, Service 도 Facade 도 stateless 라 변별이 안 됐다.
"테스트하기 쉬운가?" — 양쪽 모두 단위/통합으로 테스트 가능. 차이가 거의 없었다.
"재사용성이 높은가?" — 다른 도메인이 부를 일이 적었다. 약한 신호.


그러다 "다른 진입점이 들어와도 같은 정책이어야 하나?" 라는 질문을 던졌더니 답이 또렷해졌다.

 

진입점 불변성이라는 한 줄 테스트

"이 규칙은 진입점이 바뀌어도 같아야 하나?"


여기서 진입점이란 코드를 부르는 시작점 — REST API, 배치 잡, 시드 스크립트, CLI, 혹은 미래에 생길지 모를 어떤 호출자.

같은 정책을 다른 진입점에서 호출했을 때 일관성이 유지되어야 하는지 묻는 거다.

 

만약 정책이 진입점마다 달라야 한다면 — 예를 들어 어드민 화면에선 auto-restore, 배치는 무조건 CONFLICT — 이 규칙은 유스케이스의 일부다.

반대로, 진입점이 늘어도 정책이 같아야 한다면 그 규칙은 진입점 위쪽 어딘가에 살아야 한다. 거기가 도메인이다.

이 기준이 실용적인 이유는, 미래에 진입점이 추가될 때 일관성이 자동으로 보장되기 때문이다. 반대 상황을 그려보면 차이가 분명해진다.

 

 

유스케이스 레이어에 도메인 규칙을 두면, 진입점이 늘 때마다 룰이 분산된다. 누군가는 복붙하고, 누군가는 빠뜨린다. 시간이 지나면 어느 쪽이 정답인지도 모호해진다.

 

auto-restore 에 대입하기

Q. "Brand 의 auto-restore 가 진입점 무관해야 하나?"
A. YES — 누가 등록하든 "같은 이름 = 같은 브랜드" 라는 정책은 유지돼야 한다.

 

// BrandService.register() — 도메인 계층
@Transactional
fun register(name: String): BrandModel {
    val existing = brandRepository.findByNameIncludingDeleted(name)
    return when {
        existing == null -> brandRepository.save(BrandModel(name))
        existing.deletedAt != null -> existing.also { it.restore() }
        else -> throw CoreException(ErrorType.CONFLICT, "이미 사용 중인 이름입니다.")
    }
}

// BrandAdminFacade.create() — 응용 계층
fun create(name: String): BrandInfo =
    BrandInfo.from(brandService.register(name))

 

이걸 보고 솔직히 한 번 더 의심이 들었다. "Facade 가 한 줄 위임밖에 안 하면, 발제에서 까는 *Impl 안티패턴이랑 본질적으로 같은 거 아닌가?"

그래서 한 번 더 자문했다 — "이 Facade 가 미래에도 비어 있을 게 확실한가?" 답은 아니었다. 어드민 흐름엔 슬랙 알림이나 변경 이력 같은 부수효과가 들어올 자리가 필요하다. 도메인은 진입점 무관, Facade 는 진입점 종속. 지금 비어 있어도 그 자리는 의미가 있다.

비어있는 자리도 책임의 표현이라는 것. 결정 이후에 얻은 작은 깨달음이었다.

 

결정 이후에도 흔들리는 것들

 

여기까지가 글의 답이고, 여기서부터는 정직한 자기 점검이다. 결정한 다음에도 머릿속에 남는 의심들이 있다.

1. YAGNI 위반일 수 있다.
"미래에 다른 진입점이 추가될 가능성" 자체가 가설이고, 그 가설을 근거로 추상화 비용을 지금 지불한 거다. 진입점이 영영 REST 하나뿐이라면 이 결정은 과한 결정이 된다. 다만 그 추상화 비용이 한 줄짜리 위임 메서드 한 개라 부담이 작다고 봤다.

2. 반대 결정도 충분히 옳을 수 있다.
"지금은 어드민만 등록한다. 다른 진입점이 생길 때 옮겨도 늦지 않다. 그때까지 Facade 안에 명시적으로 두는 게 가독성이 좋다" 라는 논리도 일리가 있다. 내 결정이 맞다 가 아니라 내 기준에서 정합적이다 가 더 정확한 표현일 거다.

 

 

마지막으로...

이 기준이 만능은 아니다. 위에 적은 흔들림들이 그 증거다.

그럼에도 "진입점이 늘어날 때 규칙이 따라가야 하는가" 라는 질문은 "복잡도를 어디서 흡수할 것인가" 라는 더 큰 질문의 작은 버전이라고 생각한다. 도메인 위쪽에 둔 규칙은 진입점이 늘면 자동으로 적용되고, 유스케이스 쪽에 둔 규칙은 진입점이 늘면 복제된다. 시간이 갈수록 이 차이가 코드의 결을 결정한다.

다음에 같은 결정 앞에 서면, 나는 다시 같은 자문을 할 것 같다. 그리고 또 잠깐은 흔들릴 것 같다.

"이 규칙은, 진입점이 바뀌어도 같은가?"

728x90