Contents
see List왜 API 응답 모델을 명확히 나눠야 하는가
Java 서버에서 요청과 응답 객체를 단순한 JavaBean으로만 만들면 필드가 늘어날수록 생성 경로와 상태를 추적하기 어려워진다. 특히 성공, 입력 오류, 인증 오류, 처리 중 오류처럼 형태가 다른 응답을 하나의 DTO에 억지로 담으면 nullable 필드가 증가하고, 호출하는 쪽은 어떤 필드를 읽어도 되는지 알기 어렵다. Java record와 sealed interface를 함께 사용하면 값 객체는 불변으로 표현하고, 가능한 응답 종류는 컴파일 단계에서 제한할 수 있다.
이 방식은 Spring MVC뿐 아니라 HTTP 클라이언트, 배치 작업, 메시지 소비자처럼 결과를 여러 계층에 전달하는 코드에도 적용할 수 있다. 핵심은 데이터의 모양과 가능한 상태를 타입으로 드러내는 것이다. API 문서, 테스트, 예외 처리의 기준도 자연스럽게 정리된다.
record를 DTO의 기본 선택으로 두기
record는 구성 요소를 선언하면 private final 필드, 접근자, 생성자, equals, hashCode, toString을 자동으로 제공한다. 전달 전용 DTO에 필요한 보일러플레이트를 줄이면서 변경 불가능한 상태를 기본값으로 만든다. 접근자 이름은 getId가 아니라 id()이므로 JSON 직렬화 라이브러리와 프레임워크의 버전을 프로젝트 표준으로 고정하고, 실제 요청·응답 직렬화 테스트를 추가하는 것이 좋다.
import java.time.Instant;
public record UserResponse(
long id,
String email,
String displayName,
Instant createdAt
) {
public UserResponse {
if (id <= 0) throw new IllegalArgumentException("id must be positive");
if (email == null || email.isBlank()) {
throw new IllegalArgumentException("email is required");
}
if (displayName == null || displayName.isBlank()) {
throw new IllegalArgumentException("displayName is required");
}
}
}compact constructor는 검증이나 방어적 복사에 적합하다. 다만 DB 조회 결과를 그대로 담는 내부 모델과 외부 API DTO를 같은 record로 재사용하지 않는 편이 안전하다. 공개 응답은 노출 가능한 필드만 가지게 하고, 비밀번호 해시·권한 내부 코드·운영 메모 같은 값은 별도의 내부 타입에 남긴다. 컬렉션을 record 구성 요소로 받을 때는 수정 가능한 List가 외부에서 바뀌지 않도록 List.copyOf로 복사한다.
sealed interface로 결과의 경우의 수 제한하기
성공과 실패의 구조가 다르면 상속 가능한 타입을 아무나 추가할 수 없게 막는 것이 유용하다. sealed interface는 permits 목록에 선언한 타입만 구현하도록 제한한다. 응답의 종류가 명시되므로 서비스 계층에서 예상하지 못한 결과를 반환하는 실수를 줄일 수 있다. 오류 코드와 사용자 노출 메시지는 함께 정하지만, 예외 상세 정보나 SQL 오류 문구는 외부로 전달하지 않는다.
public sealed interface FindUserResult
permits FindUserResult.Found, FindUserResult.NotFound, FindUserResult.Forbidden {
record Found(UserResponse user) implements FindUserResult { }
record NotFound(long userId) implements FindUserResult { }
record Forbidden(String reason) implements FindUserResult { }
}
public String toLogMessage(FindUserResult result) {
return switch (result) {
case FindUserResult.Found found -> "found user=" + found.user().id();
case FindUserResult.NotFound missing -> "not found user=" + missing.userId();
case FindUserResult.Forbidden denied -> "forbidden reason=" + denied.reason();
};
}pattern matching switch는 모든 하위 타입을 처리하면 default 없이도 완전성을 검사한다. 이후 새로운 결과 타입을 추가하면 switch를 사용하는 지점에서 컴파일 오류가 나므로, 변환 로직과 테스트를 빠뜨릴 가능성이 낮아진다. 단, 이 문법을 사용하려면 프로젝트의 Java 릴리스와 빌드 도구 설정이 일치해야 한다. 운영 JDK, CI JDK, IDE 언어 레벨을 같은 LTS 버전으로 맞추고, 컴파일 옵션도 명시적으로 관리한다.
Spring Controller에서 HTTP 응답으로 변환하기
서비스는 HTTP 상태코드 대신 도메인 결과를 반환하고, Controller 또는 전용 mapper가 HTTP 표현으로 바꾸면 계층 책임이 분명해진다. 이렇게 하면 같은 서비스를 REST API와 배치 작업에서 재사용할 수 있다. Controller가 모든 비즈니스 예외를 직접 해석하지 않도록 하고, 예외 처리 정책은 @RestControllerAdvice에 모은다.
@GetMapping("/users/{id}")
public ResponseEntity<?> findUser(@PathVariable long id) {
return switch (userService.findById(id)) {
case FindUserResult.Found found ->
ResponseEntity.ok(found.user());
case FindUserResult.NotFound missing ->
ResponseEntity.status(404).body(new ErrorResponse(
"USER_NOT_FOUND", "사용자를 찾을 수 없습니다."));
case FindUserResult.Forbidden denied ->
ResponseEntity.status(403).body(new ErrorResponse(
"ACCESS_DENIED", "접근 권한이 없습니다."));
};
}
public record ErrorResponse(String code, String message) { }ResponseEntity의 와일드카드는 성공 DTO와 오류 DTO의 형태가 다른 경우에만 제한적으로 사용한다. API 계약을 더 엄격하게 만들고 싶다면 공통 envelope를 만들되, 의미 없는 data와 error 필드를 동시에 두지 않도록 주의한다. 오류는 code를 안정적인 계약으로 삼고, message는 사용자 언어와 상황에 맞게 교체 가능한 표시 값으로 취급한다.
테스트와 버전 관리에서 확인할 항목
record와 sealed type은 컴파일 안전성을 높이지만 API 호환성을 자동으로 보장하지는 않는다. JSON 필드 이름 변경, enum 값 변경, null 허용 여부 변경은 클라이언트에 영향을 줄 수 있다. Controller 통합 테스트에서 성공·미존재·권한 없음의 상태코드와 JSON 구조를 각각 검증하고, 공개 API는 예시 응답을 문서화한다. DTO의 필드를 제거하거나 의미를 바꿀 때는 새 버전 경로 또는 명확한 전환 기간을 검토한다.
적용 체크리스트
- 외부 요청과 응답 DTO를 record로 선언하고 생성 시점의 필수값을 검증한다.
- 성공과 실패 형태가 다른 서비스 결과는 sealed interface와 하위 record로 모델링한다.
- switch에서 모든 결과 타입을 명시적으로 처리해 누락을 컴파일 단계에서 찾는다.
- 도메인 결과를 HTTP 상태와 오류 코드로 변환하는 책임을 Controller 또는 mapper에 둔다.
- 운영 JDK·CI·IDE의 Java 버전과 JSON 직렬화 테스트를 함께 관리한다.
불변 DTO와 제한된 결과 타입은 코드 양을 줄이는 장식이 아니라, 변경이 잦은 API의 계약을 읽기 쉽게 유지하는 방법이다. 작은 조회 API부터 적용해 응답 형태와 오류 정책을 고정한 뒤, 반복되는 패턴을 공통 규칙으로 확장하면 된다.
java
| No | 작성일 | Title |
|---|---|---|
| 3356 | 2026. 08. 14. | Java record와 sealed interface로 안전한 API 응답 모델 만들기 |
| 3324 | 2026. 08. 05. | Java 가상 스레드 실전 적용 가이드: I/O 중심 API의 동시성 구조 바꾸기 |
| 3292 | 2026. 07. 28. | Java 메모리 사용량이 계속 늘어날 때: Heap Dump와 JFR로 누수 원인 찾기 |
| 3261 | 2026. 07. 20. | Java record로 API DTO를 단순하게 만드는 방법: 검증·변환·직렬화 실전 적용 |
| 3235 | 2026. 07. 12. | Java API 동시 호출 폭주 대응: Thread Pool, timeout, 장애 격리로 안정성 올리기 |
| 3178 | 2026. 07. 03. | Java 메모리 누수 진단 가이드: jcmd와 JFR로 원인 좁히기 |
| 3130 | 2026. 06. 25. | Java 가상 스레드 적용 전 점검: 블로킹 I/O와 동시성 제한 운영 가이드 |
| 3074 | 2026. 06. 17. | Java 스레드 작업 취소 설계: interrupt, Future, ExecutorService 종료를 제대로 연결하기 |
| 2985 | 2026. 06. 09. | Java 컨테이너 메모리 운영 가이드: 힙, 직접 메모리, GC 로그를 함께 점검하기 |
| 2928 | 2026. 06. 01. | Java Virtual Threads 운영 전환 가이드: 블로킹 API, 커넥션 풀, 관측 지표 점검법 |