잘 설계된 REST API 응답은 단순한 데이터 전송을 넘어, API의 명세(Contract)이자 클라이언트와의 약속임. 이는 애플리케이션의 보안, 성능, 개발 생산성에 직접적인 영향을 미침. 핵심 원칙은 '정보 최소화'와 '목적의 명확성'임. 클라이언트에 필요한 최소한의 정보만 선택적으로 제공함으로써 민감 데이터 노출을 원천 차단하고, 불필요한 네트워크 트래픽을 줄여야 함.
API의 존재 이유. 클라이언트가 요청한 리소스의 핵심 정보를 반드시 포함해야 함. 예를 들어, 사용자 정보 조회 API라면 사용자의 ID, 이름, 이메일 등이 해당됨. 이는 API의 가장 기본적인 책임.
모든 응답(성공/실패)을 일관된 구조로 감싸주면 클라이언트가 파싱하기 매우 용이해짐. 상태, 메시지, 실제 데이터를 담는 표준화된 구조를 사용하는 것이 좋음.
{
"status": "success", // or "error"
"message": "사용자 정보가 성공적으로 조회되었습니다.", // Optional: 디버깅이나 UI 표시에 유용한 메시지
"data": {
"id": 1,
"username": "john_doe",
"email": "john.doe@example.com"
}
}
목록 조회 시, 모든 데이터를 한 번에 반환하는 것은 심각한 성능 저하를 유발함. 응답 데이터와 함께 페이징 정보를 제공하는 것이 필수적임.
total: 전체 아이템 개수page: 현재 페이지 번호size: 페이지 당 아이템 개수total_pages: 전체 페이지 수_links (HATEOAS): 다음/이전/첫/마지막 페이지로 이동할 수 있는 URI. 클라이언트가 URL 구조를 하드코딩하지 않아도 되게 함.{
"status": "success",
"data": {
"items": [
{ "id": 101, "title": "첫 번째 게시글" },
{ "id": 102, "title": "두 번째 게시글" }
],
"total": 523,
"page": 1,
"size": 10,
"total_pages": 53,
"_links": {
"self": "/api/v1/posts?page=1&size=10",
"next": "/api/v1/posts?page=2&size=10",
"last": "/api/v1/posts?page=53&size=10"
}
}
}
보안의 제1원칙. 사용자의 비밀번호 해시, 주민등록번호, 개인 연락처, 신용카드 정보 등은 어떤 경우에도 API 응답에 포함해서는 안 됨. 이는 데이터베이스에서 조회했더라도 반드시 응답 객체로 변환하는 과정(DTO)에서 제외해야 함.
공격자에게 시스템 내부 구조의 힌트를 제공하는 모든 정보. 이는 마치 "우리 집 설계도를 대문 앞에 붙여놓는 것"과 같음.
UUID와 같은 예측 불가능한 ID를 사용하는 것이 좋음.{"status": "error", "message": "내부 서버 오류가 발생했습니다."}와 같이 일반적인 메시지만 반환해야 함."나중에 쓸 수도 있으니 일단 넣자"는 생각은 금물. 당장 클라이언트 화면에 표시되거나 로직에 사용되지 않는 데이터는 페이로드(Payload) 크기만 증가시켜 네트워크 지연, 모바일 데이터 낭비, 서버 리소스 낭비를 초래함. 정보 최소화 원칙을 철저히 지킬 것.
DTO 패턴과 응답 래퍼를 활용하여 안전하고 구조화된 API 응답을 만드는 향상된 예시.
// UserInDB.java
@Data
@AllArgsConstructor
@NoArgsConstructor
public class UserInDB {
private int id;
private String username;
private String email;
private String passwordHash; // 민감 정보 - 절대 외부에 노출하지 않음
private String personalId; // 민감 정보
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
// UserResponse.java
@Data
@AllArgsConstructor
@NoArgsConstructor
public class UserResponse {
private int id;
private String username;
private String email;
private LocalDateTime createdAt;
}
// APIResponse.java
@Data
@AllArgsConstructor
@NoArgsConstructor
public class APIResponse<T> {
private String status; // 예: "success", "error"
private String message; // 예외 메시지 또는 설명 (nullable)
private T data; // 실제 응답 데이터
}
// PageResponse.java
@Data
@AllArgsConstructor
@NoArgsConstructor
public class PageResponse<T> {
private List<T> items; // 현재 페이지의 데이터
private int total; // 전체 항목 수
private int page; // 현재 페이지
private int size; // 페이지 크기
private int totalPages; // 전체 페이지 수
private Map<String, String> links = new HashMap<>(); // self, next, prev 링크
}
// UserController.java
@RestController
@RequestMapping("/users")
public class UserController {
// --- 가상 DB 생성 ---
private static final Map<Integer, UserInDB> fakeUsers = IntStream.rangeClosed(1, 100)
.boxed()
.collect(Collectors.toMap(
i -> i,
i -> new UserInDB(
i,
"user" + i,
"user" + i + "@example.com",
"hash_for_" + i,
"personal_id_" + i,
LocalDateTime.now(),
LocalDateTime.now()
)
));
// --- Entity → DTO 변환 ---
private UserResponse toResponse(UserInDB user) {
return new UserResponse(
user.getId(),
user.getUsername(),
user.getEmail(),
user.getCreatedAt()
);
}
// --- 단일 사용자 조회 API ---
@GetMapping("/{userId}")
public ResponseEntity<APIResponse<UserResponse>> getUser(@PathVariable int userId) {
UserInDB user = fakeUsers.get(userId);
if (user == null) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new APIResponse<>("error", "User not found", null));
}
return ResponseEntity.ok(new APIResponse<>("success", null, toResponse(user)));
}
// --- 사용자 목록 조회 + 페이징 ---
@GetMapping
public ResponseEntity<APIResponse<PageResponse<UserResponse>>> getUsers(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size
) {
List<UserInDB> allUsers = new ArrayList<>(fakeUsers.values());
int total = allUsers.size();
int totalPages = (int) Math.ceil((double) total / size);
int from = Math.min((page - 1) * size, total);
int to = Math.min(from + size, total);
List<UserResponse> paginated = allUsers.subList(from, to).stream()
.map(this::toResponse)
.toList();
Map<String, String> links = new HashMap<>();
links.put("self", "/users?page=" + page + "&size=" + size);
if (page < totalPages) links.put("next", "/users?page=" + (page + 1) + "&size=" + size);
if (page > 1) links.put("prev", "/users?page=" + (page - 1) + "&size=" + size);
PageResponse<UserResponse> pageData = new PageResponse<>(
paginated, total, page, size, totalPages, links
);
return ResponseEntity.ok(new APIResponse<>("success", null, pageData));
}
}