계층별 검증 표준Request의 선택 필드에는 @Nullable, Command·Query·Domain의 nullable 필드에는 orNull을 쓰기로 했다. 그런데 요청이 반대 방향으로 돌아올 때 만나는 SnapshotResponse는 비어 있었다.

둘 다 값을 읽는 DTO지만 계약 상대가 다르다. Snapshot은 다른 도메인의 Java 코드가 읽고, Response는 클라이언트가 JSON으로 읽는다. 같은 null이라도 어디까지 이름이 따라가는지가 다르다.


먼저: null을 허용하기 전에 부재를 다른 값으로 표현할 수 있는지 확인한다

null 표기 규칙은 null을 쉽게 허용하자는 규칙이 아니다. 값의 부재가 실제 상태일 때만 쓴다.

String trackingNumberOrNull;  // 배송장 발급 전에는 값이 없다
Instant paidAtOrNull;         // 결제 전에는 시점이 없다

컬렉션은 부재가 아니라 원소가 0개인 상태로 표현할 수 있으므로 null을 쓰지 않는다.

List<OrderItemSnapshot> items;  // 없으면 List.of()

상태 자체가 업무 개념이면 boolean이나 enum이 답일 수도 있다. null은 단일 값이 아직 존재하지 않는다는 사실을 다른 값으로 정확히 표현할 수 없을 때만 남긴다.


원칙 1. Snapshot의 nullable 필드는 orNull이 호출부까지 따라간다

정해야 했던 건 이거였다 — 다른 도메인이 읽는 Snapshot의 null 가능성을 어디에 표시할 것인가.

안 A — 아무것도 표시하지 않는다.

private final String trackingNumber;

코드가 가장 짧다. 하지만 필드가 null 가능한지 선언부에서도 호출부에서도 알 수 없다. 소비 도메인이 null 체크를 빠뜨리면 런타임에 깨진다.

안 B — @Nullable만 붙인다.

@Nullable
private final String trackingNumber;

IDE와 정적 분석기가 어노테이션을 추적하면 경고를 받을 수 있다. 대신 호출부의 이름은 getTrackingNumber()라서 코드를 멀리 전달하거나 어노테이션 분석이 꺼진 환경에서는 위험이 사라져 보인다.

안 C — 필드 이름에 orNull을 붙인다.

public final class OrderSnapshot {

    private final Long orderId;
    private final String trackingNumberOrNull;

    public String getTrackingNumberOrNull() {
        return trackingNumberOrNull;
    }
}

호출하는 다른 도메인에서도 null 가능성이 이름에 남는다.

String trackingNumberOrNull = order.getTrackingNumberOrNull();
if (trackingNumberOrNull != null) {
    shipment.trace(trackingNumberOrNull);
}

골랐다 — 안 C. 안 A가 숨긴 부재 가능성을 호출부까지 운반하고, 안 B처럼 IDE 설정에만 기대지 않는다. Snapshot은 JSON 계약이 아니므로 Java 이름을 바꿔도 외부 API 키가 깨지지 않는다. 대신 getter와 지역 변수 이름이 길어진다. 그 길이가 다른 도메인에 넘기는 계약의 위험을 드러내는 비용이다.

Optional을 Snapshot 필드로 두지 않는다. Optional은 반환값의 부재를 표현할 때 쓰고, DTO 필드에 넣으면 생성자와 매핑 코드가 컨테이너까지 계속 운반해야 한다.


원칙 2. Response의 nullable 필드는 JSON 키를 지키기 위해 @Nullable로 표시한다

정해야 했던 건 이거였다 — Response에도 orNull을 적용할 것인가.

안 A — Response에도 orNull을 붙인다.

private final String trackingNumberOrNull;

Java 코드에서는 null 가능성이 잘 보인다. 하지만 기본 직렬화 결과도 Java 이름을 따라간다.

{
  "trackingNumberOrNull": null
}

클라이언트의 API 계약에 Java의 null 처리 방식이 노출된다. 나중에 null 표현 규칙을 바꾸면 업무 이름과 무관한 이유로 JSON 키까지 바뀐다.

안 B — @JsonProperty로 Java 이름과 JSON 키를 분리한다.

@JsonProperty("trackingNumber")
private final String trackingNumberOrNull;

Java와 JSON 양쪽의 요구를 모두 만족한다. 대신 한 필드에 이름이 둘 생긴다. 생성자, Swagger, 직렬화 설정에서 어느 이름을 기준으로 삼는지 계속 맞춰야 하고 리팩터링 검색도 두 이름을 따라가야 한다.

안 C — JSON 계약의 이름은 유지하고 @Nullable로 표시한다.

@Nullable
@Schema(description = "배송장 발급 전에는 null", nullable = true)
private final String trackingNumber;

골랐다 — 안 C. 안 A가 오염시키는 JSON 키와 안 B가 만드는 이중 이름을 피한다. Response는 Java 호출부보다 API 스키마와 직렬화 결과가 주된 계약이므로 그 표면을 먼저 지킨다. 대신 Java 호출부의 이름만으로는 null 가능성이 드러나지 않으므로 @Nullable 정적 분석과 API 문서가 반드시 함께 있어야 한다.

같은 이유로 RequestResponse@Nullable, Command·Query·Domain·SnapshotorNull을 쓴다. 기준은 입력과 출력의 방향이 아니라 그 이름이 JSON 키가 되는가다.


원칙 3. 공개한 Response 필드는 값이 없어도 JSON에 null로 남긴다

정해야 했던 건 이거였다 — nullable 값을 null로 내려줄지, 필드 자체를 생략할지.

안 A — null 필드를 응답에서 생략한다.

{
  "orderId": 10
}

응답 크기가 조금 줄고 화면에서 없는 필드를 자연스럽게 다룰 수 있다. 하지만 trackingNumber가 계약에 없는지, 아직 값이 없는지, 권한 때문에 숨긴 것인지, 서버가 누락했는지 구분할 수 없다.

안 B — 필드를 항상 포함하고 값이 없으면 null을 내려준다.

{
  "orderId": 10,
  "trackingNumber": null
}

클라이언트 타입은 두 상태만 가진다.

type OrderResponse = {
  orderId: number;
  trackingNumber: string | null;
};

필드를 생략하면 trackingNumber?: string | null이 되어 문자열, null, undefined 세 상태를 처리해야 한다. 업무적으로 세 상태가 필요하지 않은데 전송 방식 때문에 하나가 늘어난다.

골랐다 — 안 B. 안 A가 섞어버리는 “계약에 존재하는가”와 “현재 값이 존재하는가”를 분리한다. 대신 null 필드도 전송하므로 응답이 조금 커진다. 안정된 응답 형태와 명시적인 부재가 그 비용보다 중요하다.

전역·클래스·필드에 JsonInclude.Include.NON_NULL을 적용하지 않는다. 권한에 따라 필드의 존재 자체를 숨겨야 한다면 같은 Response를 조건부로 직렬화하지 않고 PublicUserResponse, AdminUserResponse처럼 계약을 나눈다.


원칙 4. 컬렉션은 빈 배열로 내리고 nullable 단일 값은 부재의 뜻을 문서화한다

클라이언트가 null을 처리해야 하는 자리를 최소화하려면 단일 값과 컬렉션을 구분해야 한다.

{
  "trackingNumber": null,
  "items": []
}

trackingNumber: null은 “배송장 발급 전”이라는 업무 상태다. items: []는 주문 항목이 0개라는 값이다. items: null은 컬렉션 자체가 초기화되지 않았는지, 조회하지 않았는지 의미가 불분명하므로 사용하지 않는다.

nullable 단일 필드는 @Nullable만 붙이고 끝내지 않는다. 왜 없는지 API 문서에 적는다.

@Nullable
@Schema(description = "배송장 발급 전에는 null", nullable = true)
private final String trackingNumber;

OpenAPI에서도 이 필드는 키는 필수이고 값은 nullable이어야 한다.

required:
  - trackingNumber
properties:
  trackingNumber:
    type: string
    nullable: true
    description: 배송장 발급 전에는 null

required는 문자열 값이 반드시 있다는 뜻이 아니라 JSON 객체에 키가 존재한다는 뜻이다. 어노테이션만 믿지 않고 생성된 OpenAPI 문서에서 required와 nullable이 함께 나오는지 확인한다.


원칙 5. null 계약을 넓히는 변경은 깨지는 변경으로 본다

non-null 필드를 nullable로 바꾸면 서버는 더 많은 상태를 표현할 수 있지만 기존 클라이언트는 null 체크를 하지 않았을 수 있다.

// 기존 계약
trackingNumber: string;

// 변경된 계약
trackingNumber: string | null;

서버 코드에서 타입 하나를 느슨하게 만든 것처럼 보여도 클라이언트에는 깨지는 변경이다. 반대로 nullable 필드를 non-null로 좁힐 때도 문서와 생성 타입을 함께 갱신한다.

새 nullable 필드를 추가하는 것은 클라이언트가 알 수 없는 필드를 무시한다는 계약 아래에서만 호환된다. null 여부는 구현 상세가 아니라 API와 도메인 간 계약의 일부다.


판단 기준 정리

타입 nullable 단일 값 컬렉션 이름이 닿는 계약
XxxRequest @Nullable 입력 계약에 따라 검증 JSON 입력
XxxResponse @Nullable, 키는 항상 포함 빈 배열 JSON 출력
XxxCommand·XxxQuery orNull 빈 컬렉션 Service 호출부
XxxDomain orNull 빈 컬렉션 Domain 호출부
XxxSnapshot orNull 빈 컬렉션 다른 도메인 호출부

Response에 공개한 nullable 필드는 OpenAPI에서 required와 nullable을 함께 표시하고, 설명에 null의 업무 의미를 적는다. Optional 필드와 NON_NULL 직렬화로 부재를 숨기지 않는다.


이 표준을 정하기까지 — 같은 null을 두 계약에 같은 방식으로 적을 수 없었다

시작은 계층별 검증 표준의 빈칸이었다. Request는 JSON 키를 지키려고 @Nullable, Command·Query·Domain은 호출부에 위험을 남기려고 orNull을 쓰기로 했지만 요청이 돌아오는 쪽의 Snapshot과 Response는 정하지 않았다.

Snapshot은 기존 내부 타입과 같은 이유로 빠르게 정해졌다. 다른 도메인의 여러 파일에서 getter를 호출하므로 orNull이 이름을 따라가야 했다. JSON 계약이 없어 이름을 바꾸는 대가도 작았다.

Response에서 같은 규칙을 쓰자 JSON 키가 깨졌다. trackingNumberOrNull은 Java에서는 정직하지만 클라이언트에게 null 구현 방식을 노출했다. @JsonProperty로 이름을 둘로 나누는 안도 얻는 것은 있었지만 생성자와 문서까지 두 이름을 계속 맞춰야 했다. 그래서 Request와 같은 @Nullable을 골랐다.

그다음 질문은 null을 내려줄지 필드를 뺄지였다. 처음에는 둘 다 부재 표현처럼 보였지만 클라이언트 타입으로 옮기자 차이가 드러났다. 생략은 undefined라는 세 번째 상태를 만들고 계약 누락과 값 부재를 구분하지 못하게 했다. 공개한 필드는 null로 항상 남기기로 했다.

마지막으로 null을 허용하지 않을 자리를 분리했다. 컬렉션은 빈 배열이 이미 정확한 표현이고, 권한별 필드 차이는 nullable이 아니라 서로 다른 Response 계약의 문제였다. null은 아직 존재하지 않는 단일 값에만 남았다.


정리

  • Snapshot은 nullable 필드에 orNull을 붙여 다른 도메인 호출부까지 위험을 남긴다.
  • Response는 JSON 키를 지키기 위해 @Nullable을 쓰고 null의 업무 의미를 문서화한다.
  • 공개한 Response 필드는 값이 없어도 키를 생략하지 않고 null로 내려준다.
  • 컬렉션은 null이 아니라 빈 컬렉션으로 만든다.
  • OpenAPI에서는 키가 required이면서 값은 nullable임을 함께 표현한다.
  • non-null을 nullable로 넓히는 변경은 클라이언트를 깨뜨릴 수 있다.

AI 코드 어시스턴트에 바로 적용하기

Claude Code — SKILL.md

---
name: boundary-dto-null-standard
description: Request, Response, Command, Query, Domain, Snapshot의 nullable 표기와 JSON null 출력 규칙
---

- `XxxRequest``XxxResponse`의 nullable 단일 필드는 JSON 키를 지키기 위해 이름을 바꾸지 않고 `@Nullable`을 붙인다.
- `XxxCommand`, `XxxQuery`, `XxxDomain`, `XxxSnapshot`의 nullable 단일 필드는 `orNull` 접미사를 붙인다.
- `XxxSnapshot` getter와 그 값을 받는 지역 변수에도 `orNull` 이름을 유지한다.
- `Optional`을 DTO 필드 타입으로 사용하지 않는다.
- 컬렉션 필드는 null로 만들지 않는다. 값이 없으면 빈 컬렉션을 사용하고 Response에서는 빈 배열로 직렬화한다.
- Response에 공개하기로 한 필드는 값이 없어도 JSON 키를 항상 포함하고 null을 명시한다.
- 전역, 클래스, 필드에 `JsonInclude.Include.NON_NULL`을 적용하지 않는다.
- 권한에 따라 필드의 존재를 숨겨야 하면 조건부 직렬화 대신 `PublicXxxResponse`, `AdminXxxResponse`처럼 Response 타입을 분리한다.
- Response의 nullable 필드는 API 문서에 null이 되는 업무 상태를 적는다.
- OpenAPI에서 nullable Response 필드는 required property이면서 nullable value가 되게 하고 생성된 스키마를 확인한다.
- non-null Response 필드를 nullable로 바꾸는 변경을 깨지는 API 변경으로 다룬다.

GitHub Copilot — instructions.md

---
description: 경계 DTO의 nullable 표기와 JSON null 출력 규칙
applyTo: "**/*.java"
---

- `XxxRequest``XxxResponse`의 nullable 단일 필드는 JSON 키를 지키기 위해 이름을 바꾸지 않고 `@Nullable`을 붙인다.
- `XxxCommand`, `XxxQuery`, `XxxDomain`, `XxxSnapshot`의 nullable 단일 필드는 `orNull` 접미사를 붙인다.
- `XxxSnapshot` getter와 그 값을 받는 지역 변수에도 `orNull` 이름을 유지한다.
- `Optional`을 DTO 필드 타입으로 사용하지 않는다.
- 컬렉션 필드는 null로 만들지 않는다. 값이 없으면 빈 컬렉션을 사용하고 Response에서는 빈 배열로 직렬화한다.
- Response에 공개하기로 한 필드는 값이 없어도 JSON 키를 항상 포함하고 null을 명시한다.
- 전역, 클래스, 필드에 `JsonInclude.Include.NON_NULL`을 적용하지 않는다.
- 권한에 따라 필드의 존재를 숨겨야 하면 조건부 직렬화 대신 `PublicXxxResponse`, `AdminXxxResponse`처럼 Response 타입을 분리한다.
- Response의 nullable 필드는 API 문서에 null이 되는 업무 상태를 적는다.
- OpenAPI에서 nullable Response 필드는 required property이면서 nullable value가 되게 하고 생성된 스키마를 확인한다.
- non-null Response 필드를 nullable로 바꾸는 변경을 깨지는 API 변경으로 다룬다.

자신만의 철학을 만들어가는 중입니다.
최상단으로 이동했습니다!
확대 이미지

댓글남기기