티스토리 뷰
최근 백엔드 리팩토링을 두 건 진행했다. 하나는 컨트롤러에 인라인으로 박혀있던 Swagger 애너테이션을 별도 인터페이스로 분리한 작업이고, 다른 하나는 도메인별 dto 패키지를 request/response로 나눈 작업이다. 둘 다 기능 변화는 없는 순수 구조 개선이지만, 결정할 게 생각보다 많았어서 정리해둔다.
✏️ 1. Swagger 스펙을 ControllerDocs 인터페이스로 분리하기
문제상황
프로젝트의 컨트롤러들은 메서드마다 @Operation, @ApiResponses, @Parameter(description = ...) 같은 Swagger 애너테이션이 인라인으로 붙어 있었다. 예를 들어 리뷰 컨트롤러 하나만 봐도 이랬다. 실제 위임 로직은 두 줄인데 문서 메타데이터가 메서드의 80~90%를 차지한다. 메서드가 12개인 컨트롤러는 파일 하나가 200줄을 훌쩍 넘어갔고, "이 API가 실제로 뭘 하는지"를 보려면 Swagger 텍스트 사이를 헤집고 다녀야 했다.
@Operation(summary = "리뷰 수정(글 수정)", description = """
리뷰 내용을 수정하는 API 입니다.<br><br>
글 수정만 가능하며 사진 수정은 지원하지 않습니다.
""")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "리뷰 수정 성공"),
@ApiResponse(responseCode = "403", description = "리뷰에 대한 권한이 없음",
content = @Content(schema = @Schema(implementation = BaseResponse.class))),
@ApiResponse(responseCode = "404", description = "존재하지 않는 리뷰",
content = @Content(schema = @Schema(implementation = BaseResponse.class))),
})
@PatchMapping("/{reviewId}")
public BaseResponse<?> updateReview(@Parameter(description = "reviewId")
@PathVariable("reviewId") Long reviewId,
@RequestBody UpdateMealReviewRequest request,
@AuthenticationPrincipal CustomUserDetails customUserDetails) {
reviewServiceV2.updateReview(customUserDetails, reviewId, request);
return BaseResponse.success();
}
분리 방식 결정
인터페이스로 분리하는 방식에도 갈래가 있었다.
- 매핑까지 통째로 위임(Full-split): 인터페이스가 @GetMapping 같은 매핑 애너테이션과 파라미터 바인딩까지 다 가지고, 컨트롤러는 @Override로 위임만 한다. springdoc 공식 예제에서 자주 보이는 패턴이지만, 컨트롤러만 봐서는 실제 URL과 HTTP verb를 알 수 없다는 게 걸렸다.
- Swagger 전용 분리: 인터페이스에는 @Operation/@ApiResponses/@Parameter(description=...) 같은 순수 문서 애너테이션만 두고, @GetMapping/@PathVariable/@RequestParam 등 실제 스프링 매핑·바인딩은 컨트롤러에 그대로 남긴다.
두 번째 방식을 골랐다. 컨트롤러만 열어봐도 "이 메서드가 어떤 엔드포인트고 뭘 받는지" 바로 보이는 게 디버깅/코드리뷰 관점에서 더 중요하다고 판단했기 때문이다. 대신 메서드 시그니처(파라미터 목록)는 인터페이스와 구현체 양쪽에 유지해야 한다는 단점은 감수했다.
결과
각 도메인의 presentation/docs(혹은 controller/docs) 하위 패키지에 [ControllerName]Docs 인터페이스를 두는 구조로, review·menu·user·partnership·inquiry·report·auth 7개 도메인에 적용했다(admin 도메인은 범위가 커서 후속 작업으로 분리). 순수 애너테이션 위치 이동이라 컴파일과 기존 테스트로 검증했다.
public interface ReviewControllerV2Docs {
@Operation(summary = "리뷰 수정(글 수정)", description = """
리뷰 내용을 수정하는 API 입니다.<br><br>
글 수정만 가능하며 사진 수정은 지원하지 않습니다.
""")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "리뷰 수정 성공"),
@ApiResponse(responseCode = "403", description = "리뷰에 대한 권한이 없음",
content = @Content(schema = @Schema(implementation = BaseResponse.class))),
})
BaseResponse<?> updateReview(@Parameter(description = "reviewId") Long reviewId,
UpdateMealReviewRequest request,
CustomUserDetails customUserDetails);
}
public class ReviewControllerV2 implements ReviewControllerV2Docs {
@Override
@PatchMapping("/{reviewId}")
public BaseResponse<?> updateReview(@PathVariable("reviewId") Long reviewId,
@RequestBody UpdateMealReviewRequest request,
@AuthenticationPrincipal CustomUserDetails customUserDetails) {
reviewServiceV2.updateReview(customUserDetails, reviewId, request);
return BaseResponse.success();
}
}
✏️ 2. DTO 패키지를 request/response로 분리하기
문제상황
도메인마다 dto 패키지 하나에 요청/응답 DTO가 전부 평면으로 섞여 있었다. menu 도메인만 이미 dto/request, dto/response로 나뉘어 있었고 나머지 7개 도메인은 그대로였다.
애매한 케이스가 관건이었다
*Request, *Response 접미사가 붙은 파일은 기계적으로 옮기면 그만인데, 접미사가 없는 파일들이 도메인마다 꽤 있었다. 예를 들면:
- MenuBoard, MenuBoards, MenuLine, MenuSection, SectionInBoard - admin 응답 안에 중첩되는 뷰모델 조각들
- ReviewDetail, Tokens - 접미사는 없지만 컨트롤러가 BaseResponse<Tokens>, SliceResponse<ReviewDetail>로 직접 반환하는, 사실상 응답 타입
- BriefMenu, RatingsDto - QueryDSL Projections.constructor 전용 조회 프로젝션
- MealInfo, OAuthInfo - 컨트롤러/서비스 간에만 오가는 내부 값객체
- AppleKeys - 애플 공개키 API 응답을 역직렬화하기 위한 외부 API 파싱 전용 객체
- SectionInBoard - 심지어 DTO가 아니라 MenuSection/MealSection이 구현하는 마커 인터페이스
이런 걸 이름만 보고 넘겨짚으면 잘못 분류하기 쉬워서, 실제 사용처를 하나씩 추적했다.
"다른 Response 클래스의 필드 타입인가, 컨트롤러 리턴 타입에 포함되는가, 아니면 서비스 내부에서만 도는가"를 기준으로 삼았다.
조사 결과 접미사 없는 21개 클래스 중 20개는 응답에 직접 노출되거나 다른 Response에 중첩되는 게 확인돼 dto/response로 분류했고, 나머지 8개(QueryDSL 프로젝션, 내부 값객체, 외부 API 파싱용, 마커 인터페이스)는 request도 response도 아니라서 이번 범위에서는 dto 루트에 그대로 남겨뒀다. 이 8개를 dto 밖으로 완전히 재배치하는 건 도메인 경계를 다시 고민해야 하는 별도 논의라 후속 과제로 미뤘다.
결과
패키지 위치만 옮기고 클래스명·내용은 그대로 뒀지만, 이 DTO들을 참조하는 서비스·컨트롤러·(직전에 분리한) ControllerDocs 인터페이스·테스트 코드의 import를 전부 같이 고쳐야 했다. 기계적인 작업량 자체는 컸지만 컴파일러가 빠뜨린 import를 바로 잡아주기 때문에 리스크는 낮았다.
domain/review/dto/
├── request/
│ ├── CreateMealReviewRequest.java
│ └── ...
├── response/
│ ├── ReviewDetail.java # 접미사는 없지만 SliceResponse<ReviewDetail>로 직접 반환됨
│ ├── MenuIdNameDto.java # 다른 Response의 필드 타입으로 중첩됨
│ └── ...
├── RatingsDto.java # QueryDSL 프로젝션 전용, 그대로 유지
└── ReviewRatingCount.java # 내부 계산 결과값, 그대로 유지
✏️ 3.마무리
두 작업 모두 "동작은 그대로, 구조만 정리"하는 리팩토링이었지만, 막상 들어가보면 매번 애매한 경계 케이스가 나온다. Swagger 분리에서는 "매핑 애너테이션까지 옮길 것인가", DTO 분리에서는 "접미사 없는 DTO를 어떻게 분류할 것인가"가 그랬다. 이런 경계는 코드만 봐서는 정답이 없고, 결국 "이 프로젝트에서 컨트롤러/DTO를 열어봤을 때 뭘 바로 알 수 있어야 하는가"라는 기준을 팀이 먼저 정해야 흔들리지 않는다는 걸 다시 느꼈다.
'Back-End > 잇슈' 카테고리의 다른 글
| [EAT-SSU / 잇슈] - RDS 다운 원인을 찾아 떠난 트러블슈팅 기록 #1 (0) | 2026.08.13 |
|---|---|
| [EAT-SSU / 잇슈] - Grafana Cloud 429 트러블슈팅, 첫 비즈니스 메트릭 추가 (0) | 2026.07.28 |
| [EAT-SSU / 잇슈] - CI/CD 개선 작업 정리 #1 (0) | 2026.07.05 |
| [EAT-SSU / 잇슈] - Grafana Cloud + Alloy로 모니터링 구축 (0) | 2026.06.30 |
| [EAT-SSU / 잇슈] OAuth 토큰 로그 노출 방지 작업 정리 (0) | 2026.06.23 |
- Total
- Today
- Yesterday
- 카운팅 정렬
- HTML5
- js
- 유클리드 호제법
- java
- 이분 매칭
- 에라토스테네스의 체
- 우선순위 큐
- 백준
- DFS
- DP
- 투 포인터
- C++
- 유니온 파인드
- 알고리즘
- 자바스크립트
- 스택
- 알고리즘 공부
- BFS
- CSS
- Spring Boot
- 자료구조
- html
- 반복문
- 자바
- 스프링 부트 crud 게시판 구현
- Do it!
- c++ string
- 세그먼트 트리
- 백준 풀이
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | ||||||
| 2 | 3 | 4 | 5 | 6 | 7 | 8 |
| 9 | 10 | 11 | 12 | 13 | 14 | 15 |
| 16 | 17 | 18 | 19 | 20 | 21 | 22 |
| 23 | 24 | 25 | 26 | 27 | 28 | 29 |
| 30 | 31 |
