본문으로 건너뛰기
2026. 6. 26
© WONKOOK LEE

Response는 모델을 닮아야 할까요?

하나의 묶음이 네 개의 평평한 칸으로 변환되는 그림

이 글은 직무 통합 과정에서 백엔드 실무를 맡게 된 프론트엔드 개발자가 실제 작업을 부딪히며 배운 것들을 주제 단위로 정리하는 시리즈입니다. 낯선 개념을 이미 아는 개념에 대응시키는 방식으로 씁니다.

  1. 도메인 모델은 왜 DB를 몰라야 할까요?
  2. DB 조회는 왜 아웃바운드일까요?
  3. 그 로직은 어디에 살아야 할까요?
  4. 이 API는 누가 부르나요?
  5. Response는 모델을 닮아야 할까요? — 이 글
  6. null은 비우기일까요, 건드리지 않기일까요?
  7. 컬럼일까요, JSON 컬럼일까요?
  8. DB 스키마에도 git이 필요할까요?
  9. 백엔드 도구들, FE로 치면 무엇일까요?

응답 스펙을 정하다가 손이 멈췄습니다. 모델에는 병역이 하나의 묶음(nested)으로 들어 있는데, 응답도 그 모양 그대로 내보내면 되는 걸까? 아니면 클라이언트가 쓰기 좋은 모양으로 따로 설계해야 하나? 어느 쪽이 정답인지 물었더니 이번에도 답은 "규칙이 아니라 선택"이었습니다. 다만 그 선택에는 값이 매겨져 있었습니다.

API 응답은 도메인 모델을 닮아야 할까요, 아니면 자기만의 모양을 가져야 할까요?


이 글에서 다루는 내용

지난 글에서 어떤 API를 낼지 정했다면 이번에는 그 API의 모양을 정할 차례입니다. 응답을 모델에 맞출 때 얻는 것과 내는 비용, 같은 데이터가 경계마다 옷을 갈아입는 DTO 레이어링, 그리고 모델이 바뀌면 모든 계층이 컴파일 에러로 함께 알게 되는 인터페이스 구현 강제를 다룹니다. 프론트엔드에서 익숙한 리소스 중심 API vs 뷰모델 논쟁이 그대로 이어집니다.





1. 규칙이 아니라 트레이드오프입니다

모델과 Response를 맞출 것인가. 이것은 아키텍처 규칙이 아니라 값이 매겨진 선택입니다.

전략얻는 것내는 비용
맞춘다 (모델 shape ≈ Response shape)매핑 비용 감소, 클라이언트가 모델의 생김새를 인식, 관리의 일관성계약이 모델에 결합 — 모델 리팩토링 = 계약 변경이 된다
분리한다 (Response는 독립 설계)모델과 계약이 각자의 속도로 진화매핑 레이어의 유지 비용

프론트엔드 개발자라면 이 논쟁을 이미 알고 있습니다. 리소스 기반 REST(모델을 노출)냐, 화면 종속 응답(BFF·뷰모델)이냐 — 그 논쟁의 서버판입니다. 어느 쪽도 절대 원칙이 아니라는 것까지 같습니다. 중요한 것은 결정하고 기록하는 것입니다. "이 API는 모델을 따른다" 혹은 "이 API는 화면을 따른다"가 어딘가에 적혀 있으면, 다음 사람은 논쟁 대신 일관성을 선택할 수 있습니다.

제가 겪은 사례에서는 맞추는 쪽이 자연스러웠습니다. 모델이 병역을 하나의 개념 묶음으로 갖게 됐으니 응답에서도 "프로필에 병역이 있구나"로 읽히는 것이 클라이언트에게 이득이었습니다. 모델과 화면의 요구가 같은 방향일 때는 맞추는 선택이 쌉니다. 두 요구가 갈라지는 순간이 분리를 고민할 때입니다.


Recap

모델과 Response를 맞추는 것은 규칙이 아니라 트레이드오프입니다. 맞추면 매핑이 싸지는 대신 모델 리팩토링이 곧 계약 변경이 되고, 분리하면 각자의 속도로 진화하는 대신 매핑 비용을 냅니다. 리소스 REST vs 뷰모델 논쟁의 서버판이며, 어느 쪽이든 결정하고 기록하는 것이 중요합니다.




2. 같은 데이터, 경계마다 다른 옷

"맞춘다"를 선택해도 모든 계층이 같은 모양을 쓴다는 뜻은 아닙니다. 실제로 병역 데이터는 계층마다 다른 옷을 입었습니다 — 도메인 안에서는 nested 값객체 하나로 다니고, 공개 API 계약에서는 flat한 4개 필드로 펼쳐집니다.

도메인 모델의 nested 형태와 공개 API의 flat 형태 사이의 변환

왜 굳이 다른 모양일까요. 도메인 안에서 묶는 이유는 3편에서 본 것처럼 정합성 규칙("복무 기간은 군필/복무중일 때만")이 이 묶음 단위로 작동하기 때문입니다. 반면 공개 계약이 flat한 이유는 클라이언트 폼과 응답 소비 코드가 낱개 필드 단위로 다루기 때문입니다. 각 경계가 자기 사정에 맞는 모양을 갖고, 경계를 건널 때 toResponse()·toDto() 같은 변환 함수가 옷을 갈아입힙니다.

프론트엔드로 번역하면 서버 응답 타입 ↔ 화면 뷰모델 ↔ 폼 상태를 각각 매핑하는 어댑터들과 같은 배치입니다. 1편에서 봤던 "필드 하나에 파일 열 개"의 정체가 한 겹 더 구체화된 셈입니다 — 그 파일들의 상당수가 바로 이 경계별 표현과 변환 함수들입니다.


Recap

같은 데이터라도 경계마다 옷이 다릅니다. 도메인은 정합성 규칙이 작동하는 nested 묶음으로, 공개 계약은 클라이언트가 다루기 좋은 flat 필드로 다니며, 경계의 변환 함수가 둘을 오갑니다. 서버 응답 타입·뷰모델·폼 상태를 각각 매핑하는 프론트엔드의 어댑터들과 같은 배치입니다.




3. 전파를 컴파일러에게 맡기는 선택

계층마다 표현이 따로 있으면 걱정이 하나 생깁니다. 모델에 필드를 추가했는데 DTO나 Response에서 빠뜨리면? 응답에서 조용히 필드가 누락된 채 배포되면?

여기에 대한 답으로 배운 패턴이 인터페이스 구현 강제입니다. 모델을 인터페이스로 선언하고 DTO·Entity·Response가 그것을 구현(implement)하게 하면, 모델에 필드를 추가하는 순간 모든 구현체가 컴파일 에러를 냅니다. 고치기 전에는 빌드가 안 됩니다.

interface UserProfile { military: MilitaryService }         // 모델에 필드 추가
class UserProfileDto implements UserProfile { /* ... */ } // → 즉시 컴파일 에러
interface UserProfile { val military: MilitaryService }

class UserProfileDto(
override val military: MilitaryService, // override가 강제된다 — 빠뜨리면 빌드 실패
/* ... */
) : UserProfile

TypeScript에서 매일 겪는 바로 그 메커니즘입니다. 여기에 Kotlin은 한 가지 장치가 더 있습니다 — else 없는 when은 enum의 모든 값을 다뤄야 합니다. 표현식으로 쓰는 when은 처음부터 그랬고, 문(statement)으로 쓰는 when도 Kotlin 1.7부터는 누락이 경고가 아니라 컴파일 에러입니다. 필드 목록 같은 것을 enum으로 두면, 값을 하나 추가하는 순간 그 enum을 분기하던 코드 전부가 컴파일 에러로 "나도 고쳐야 한다"고 손을 듭니다.

// enum에 MILITARY를 추가하는 순간, else 없는 when은 전부 컴파일 에러가 난다
when (column) {
ProfileColumn.NAME -> applyName(request)
ProfileColumn.BIRTH_DATE -> applyBirthDate(request)
// ProfileColumn.MILITARY 분기를 추가하기 전엔 빌드가 멈춘다
}

TypeScript의 discriminated union에 never 체크를 두어 분기 누락을 잡는 것과 같은 원리입니다.

이것 역시 공짜가 아니라 선택입니다. 얻는 것은 필드 누락이 컴파일 타임에 원천 차단된다는 것 — 런타임에 조용히 빠지는 일이 없습니다. 내는 비용은 변경이 원자적이 된다는 것입니다. 모델 변경 PR을 작게 쪼갤 수 없고 구현체 전파가 한 diff에 묶입니다. 필드 하나 추가한 제 PR이 커질 수밖에 없었던 구조적 이유가 이것이었습니다. 안전과 잘게 나누기 중 무엇을 살 것인가의 문제고, 이 구조는 안전을 산 것입니다.

계약의 모양이 정해졌으니 남은 것은 계약의 가장 미묘한 구석입니다 — 부분 수정에서 null이 도대체 무슨 뜻이냐는 문제. 다음 글에서 다루겠습니다.


Recap

모델을 인터페이스로 두고 각 계층 표현이 구현하게 하면 필드 추가가 모든 계층에 컴파일 에러로 전파됩니다 — 누락이 런타임까지 살아남지 못합니다. Kotlin의 exhaustive when도 같은 원리로 분기 누락을 잡습니다. 대가는 변경의 원자성입니다. 모델 PR이 커지는 것은 사고가 아니라 안전을 선택한 구조의 비용입니다.




FE ↔ BE 대응표

이 글에서 다룬 대응입니다.

BE 개념FE에서 가장 가까운 것대응의 핵심
모델 ↔ Response 정합리소스 REST vs 뷰모델 논쟁같은 축의 서버판 — 결정하고 기록한다
DTO 레이어링 (flat ↔ nested)응답 타입·뷰모델·폼 상태 어댑터경계마다 자기 표현, 변환은 경계에서
인터페이스 구현 강제implements + 컴파일 에러전파를 컴파일러에게 맡긴다
exhaustive whendiscriminated union + never 체크분기 누락을 타입 시스템이 잡는다



References

DTO와 계약

  1. Martin Fowler — Data Transfer Object (P of EAA)
  2. Ian Robinson — Consumer-Driven Contracts (martinfowler.com)

타입 시스템의 전파 강제

  1. Kotlin Docs — Sealed classes and when expressions
  2. TypeScript Handbook — Exhaustiveness checking


좋은 사람들과 재미있는 일을 하며 열정적이고 즐겁게 살고 싶은 개발자