Well-structured REST API

CH.dev·2025년 8월 5일
post-thumbnail

📄 요약

잘 설계된 REST API 응답은 단순한 데이터 전송을 넘어, API의 명세(Contract)이자 클라이언트와의 약속임. 이는 애플리케이션의 보안, 성능, 개발 생산성에 직접적인 영향을 미침. 핵심 원칙은 '정보 최소화''목적의 명확성'임. 클라이언트에 필요한 최소한의 정보만 선택적으로 제공함으로써 민감 데이터 노출을 원천 차단하고, 불필요한 네트워크 트래픽을 줄여야 함.

🙆‍♂️ 무엇을 포함할 것인가

1. 요청된 핵심 데이터 (Resource)

API의 존재 이유. 클라이언트가 요청한 리소스의 핵심 정보를 반드시 포함해야 함. 예를 들어, 사용자 정보 조회 API라면 사용자의 ID, 이름, 이메일 등이 해당됨. 이는 API의 가장 기본적인 책임.

2. 일관된 응답 래퍼 (Consistent Response Wrapper)

모든 응답(성공/실패)을 일관된 구조로 감싸주면 클라이언트가 파싱하기 매우 용이해짐. 상태, 메시지, 실제 데이터를 담는 표준화된 구조를 사용하는 것이 좋음.

{
  "status": "success", // or "error"
  "message": "사용자 정보가 성공적으로 조회되었습니다.", // Optional: 디버깅이나 UI 표시에 유용한 메시지
  "data": {
    "id": 1,
    "username": "john_doe",
    "email": "john.doe@example.com"
  }
}

3. 페이징 처리를 위한 메타데이터 (Pagination Metadata)

목록 조회 시, 모든 데이터를 한 번에 반환하는 것은 심각한 성능 저하를 유발함. 응답 데이터와 함께 페이징 정보를 제공하는 것이 필수적임.

  • 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. 민감한 사용자 데이터 (Sensitive User Data)

보안의 제1원칙. 사용자의 비밀번호 해시, 주민등록번호, 개인 연락처, 신용카드 정보 등은 어떤 경우에도 API 응답에 포함해서는 안 됨. 이는 데이터베이스에서 조회했더라도 반드시 응답 객체로 변환하는 과정(DTO)에서 제외해야 함.

2. 내부 시스템 정보 (Internal System Information)

공격자에게 시스템 내부 구조의 힌트를 제공하는 모든 정보. 이는 마치 "우리 집 설계도를 대문 앞에 붙여놓는 것"과 같음.

  • 데이터베이스의 Auto-increment PK 값: 시스템의 전체 사용자 수나 데이터 규모를 추측할 수 있게 함. 대신 외부 노출용으로는 UUID와 같은 예측 불가능한 ID를 사용하는 것이 좋음.
  • 서버의 내부 파일 경로: 공격자가 시스템의 파일 구조를 파악하는 데 사용될 수 있음.
  • 상세한 에러 스택 트레이스 (Stack Trace): 개발 환경에서는 유용하지만, 운영 환경에서는 사용된 프레임워크, 라이브러리 버전 등 심각한 보안 정보를 노출함. 운영 환경에서는 {"status": "error", "message": "내부 서버 오류가 발생했습니다."}와 같이 일반적인 메시지만 반환해야 함.

3. 클라이언트가 사용하지 않는 불필요한 정보 (Unnecessary Data)

"나중에 쓸 수도 있으니 일단 넣자"는 생각은 금물. 당장 클라이언트 화면에 표시되거나 로직에 사용되지 않는 데이터는 페이로드(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));
    }
}

🔍 더 깊이 찾아보기

  • DTO (Data Transfer Object): 계층 간 데이터 전송을 위한 객체. 내부 로직에서 사용하는 도메인 모델과 API 스펙을 분리하여 시스템의 유연성과 보안을 높이는 핵심 패턴.
  • 응답 래퍼 (Response Wrapper) 패턴: 모든 API 응답을 일관된 구조로 감싸는 디자인 패턴. 클라이언트 측에서 상태에 따른 분기 처리를 매우 간단하게 만들어주고, API의 예측 가능성을 높임.
  • HATEOAS (Hypermedia as the Engine of Application State): 응답에 다음에 수행할 수 있는 작업에 대한 링크(URI)를 포함시키는 REST 아키텍처의 제약 조건. API가 스스로를 설명하게 만들어 클라이언트와 서버 간의 결합도를 낮춤.
  • API Gateway: API 서버 앞단에서 인증/인가, 로깅, 속도 제한, 응답 데이터 변환 등의 공통 기능을 처리하는 프록시 서버. 게이트웨이 레벨에서 특정 필드를 일괄적으로 제거하는 정책을 적용하여 보안을 강화할 수도 있음.
profile
더 이상 미룰 수 없다 나의 공부 나의 성장

0개의 댓글