본문 바로가기

Dev Log

“Swagger 사용해보셨나요?”라는 질문에 제대로 답하기2

728x90

지난 글에서는 “Swagger는 서버에서 어떻게 동작하는가?”라는 질문에서 출발해,
Spring Boot 환경에서 Controller와 어노테이션을 기준으로
API 문서가 어떤 흐름으로 생성되는지를 정리해보았다.

 

이번 글에서는 관점을 조금 바꿔,

Swagger Request 객체를 실제로 만들면서 내가 어떤 고민을 했는지를 정리해보려 한다.

특히,

  • Request 객체를 어떤 단위로 나눠야 하는지
  • DO / VO / DTO를 어디까지 분리해야 하는지
  • DTO를 정리하다가 왜 오히려 더 찝찝해졌는지

와 같은 고민들을
실제 코드와 함께 되짚어보면서,
Swagger를 “어떻게 쓰는 게 맞는지”보다는
Swagger를 쓰면서 어떤 설계 고민을 하게 되는지에 초점을 맞춰보려고 한다.

 

Swagger의 Request 객체들은 어떤 방식으로 Java DTO에 매핑되는가?

Swagger(OpenAPI)는 컨트롤러 시그니처를 기준으로 Request 구조를 추론한다. 즉 “Swagger가 DTO를 매핑” 기보다는 Spring MVC의 바인딩 규칙을 그대로 문서화한다고 보는 게 정확하다.

주요 매핑 방식 Spring 어노테이션 Swagger 표현

@RequestBody requestBody schema
@RequestParam query parameter
@PathVariable path parameter
@RequestHeader header
@ModelAttribute query + form parameter 묶음
@GetMapping("/companies")
public List<CompanyResponse> getCompanies(
    @RequestParam Long companySeq,
    @RequestParam String companyName
)

→ Swagger에서는 companySeq, companyName이 각각 독립적인 query parameter로 노출된다.

@GetMapping("/companies")
public List<CompanyResponse> getCompanies(CompanyRequest request)

→ CompanyRequest 내부 필드가 query parameter로 자동 펼쳐져서 문서화된다.

 

API Request 용 객체를 어떻게 관리하는가?

이 질문에 대한 답을 정리하면서, 먼저 하나의 중요한 사실을 깨달았다.

바로 DO / VO / DTO 용어를 스스로 혼용하고 있었다는 점이다.

현재 프로젝트에는 DTO를 따로 만들지 않고 DO를 그대로 API Request로 사용하는 코드가 있었고,그로 인해 Swagger 문서의 Request Parameter가 명확하게 정리되지 않고 있었다.

 

결론부터 말하자면, 나는 DO = DTO라고 잘못 이해하고 있었고, 이 오해가 Request 객체 설계와 Swagger 문서 품질 모두에 영향을 주고 있었다.

 

[DO / VO / DTO 개념 정리]

API Request 객체를 어떻게 관리할지 이야기하기 전에, 먼저 각 용어의 역할을 명확히 정리할 필요가 있다.

DO: Domain Object / Data Object

  • 도메인의 상태와 행위를 표현하는 객체
  • 비즈니스 개념 그 자체를 담는다
  • 외부 API Request / Response로 직접 사용하지 않는 것이 원칙
public class Company {

    private Long companySeq;
    private String companyName;

    public boolean isValid() {
        return companyName != null;
    }
}

VO: Value Object

  • 값 자체에 의미 있는 객체
  • 식별자보다 값의 동일성이 중요
  • 비즈니스 개념의 일부를 표현
  • 도메인 규칙을 안전하게 표현하기 위한 객체
  • 사용 위치: 도메인 규칙을 캡슐화할 때
  • 마찬가지로 API Request 객체로 사용하지 않음
public class CompanyName {

    private final String value;

    public CompanyName(String value) {
        if (value == null || value.isBlank()) {
            throw new IllegalArgumentException("회사명은 필수입니다.");
        }
        this.value = value;
    }
}

DTO: Data Transfer Object

  • 레이어 간 데이터 전달을 위한 객체
  • 구조는 단순하고 행위는 없음
  • Getter, Setter 중심, API 계약 표현에 적합
  • 사용 위치: controller ↔ client, controller ↔ service
  • Swagger 문서화의 대상은 항상 DTO

문제가 되었던 코드는 다음과 같았다.

회사라는 DO를 그대로 API Request 객체로 사용하고 있었고,

그로 인해 여러 문제가 발생했다.

@Schema(description = "회사")
@Setter
@Getter
@ToString
publicclassCompanyimplementsSerializable {

@Schema(description = "회사 순번", example = "1")
private Long companySeq;

@Schema(description = "회사명", example = "A회사")
private String companyName;
}

이 설계로 인해 발생한 문제

  • API마다 요구하는 parameter가 달라질수록
  • → DO의 몸집이 계속 커짐
  • Swagger 상에서는
  • → 클라이언트가 어떤 query parameter를 보내야 하는지 명확하지 않음
  • 결과적으로 Swagger를 보고도
  • → API 사용 방법을 직관적으로 이해하기 어려움

그래서 나는 한동안, Controller 단에서 필요한 파라미터를 다시 조합해 Service로 넘기는 방식을 사용하고 있었다. 🥲

@Operation(summary = API_TAG, description = "회사 상세 조회")
@GetMapping(value = "/company/detail", produces = "application/json")
public DataViewWrapper<CompanyDetailResponse>getCompanyDetail(
@Parameter(description = "회사 순번", example = "1")
@RequestParam Long companySeq,

@Parameter(hidden = true)
@AuthUser MemberDetail member
) {
CompanyDetailRequestrequest=newCompanyDetailRequest();
// controller 단에서 필요한 값만 재구성
}

 

하지만 이 방식의 근본적인 문제는, 애초에 API Request DTO를 분리하지 않았다는 점이었다.

그러면 내가 근본적으로 물어봐야 할 질문은 이것이었다.

DTO를 어떤 식으로 관리해야 하는가? GET 메서드마다 무조건 DTO를 새로 만들어야 하는가?

 

결론부터 말하면 DTO는 의미(조회 목적)을 기준으로 나눠야하는게 맞을 것 같다.

DTO를 재사용해도 되는 경우

  • 같은 조회 목적
  • 일부 필드만 optional 차이
// 조회 대상은 결국 항상 회사 목록이기 때문에 DTO 재사용시 문제가 없다 
/companies?companyName=A회사
/companies?industryType=IT
/companies?companyName=A회사&industryType=IT
public class CompanySearchRequest {

    private String companyName;   // 회사명 검색
    private String industryType;  // 업종
}
@GetMapping("/companies")
public List<CompanyResponse> searchCompanies(CompanySearchRequest request) {
    ...
}

 

DTO를 이렇게 정리하면 모든 게 해결될 줄 알았다.

하지만 DTO를 분리하고 나니 찝찝한 기분을 지울 수 없었는데, “비즈니스 레이어에서 필요한 필드는 결국 DO에 다 있는데, 그럼 Service에는 DO를 넘겨야 하나?” “조회 결과를 위해 companySeq 같은 값만 필요한데 그걸 위해 DTO 파일을 하나 더 만드는 게 맞는가?”라는 질문들이 자연스럽게 생겼기 때문이다.

 

이 지점은 아직도 현재 프로젝트에서 계속 고민하고 있는 부분이다. 실제 기능을 구현하고 구조를 정리해 나가면서, DTO 설계 문제를 넘어서서 레이어 간 역할과 책임을 어떻게 나눌 것인가 에 대한 문제라는 생각이 점점 더 들고 있다. 그래서 실무를 하면서 더 깊이 이해해가야 할 영역이라고 느끼고 있다.

728x90