
Smeem 프로젝트를 하면서 경험했던 부분을 작성한 포스트입니다.
Layered Architecture에서 Presentation Layer는 클라이언트로 부터 요청을 받아, 하위 계층(Application Layer)에 전달하고 하위 계층으로 부터 전달받은 응답 값을 클라이언트에게 응답해주는 Layer이다.
Project를 하면서 기본적으로 클라이언트에서 Request Body가 존재하는 API의 경우에는 아래 코드와 같이 DTO 클래스를 생성하고,
Controller에서 @RequestBody 어노테이션을 사용하여 해당 객체를 Service에 그대로 넘겨주는 형태로 개발을 해왔었다.
@PostMapping("/member")
public ApiResponse<MemberGetResponse> saveMember(@RequestBody MemberPostRequest request) {
return ApiResponse.success(Success.CREATE_MEMBER_SUCCESS, memberService.saveMember(request));
}
하지만 이렇게 클라이언트에서 요청한 값을 담은 DTO를 Service에서 그대로 사용하고, Service에서 응답해주는 DTO를 그대로 클라이언트에 전달해주는 형태의 단점이 존재했다.
기본적으로 Service에서 작성하는 코드가 클라이언트 요청에 의존하게 될 수도 있다는 것이었다.
예를 들어, API 스펙이 변경되어 요청 객체의 필드가 변경되는 경우에는 요청 객체를 수정해야 한다. 그러면 Service에서 해당 필드를 사용하던 부분도 자연스럽게 수정이 일어나게 된다. 물론, @JsonProperty와 같은 어노테이션을 사용해서 해결할 수 있지만, 해당 어노테이션으로 대체한 필드가 많아지면, 이후에 개발을 하면서 필드에 대한 이해도가 떨어질 수 있다고 생각했다. 클라이언트의 요청과 Service 계층에서 처리하는 로직을 최대한 분리하는 방향을 고민했다.
또한 API 중에 응답 필드의 key가 target인 API가 있었다.
해당 key는 어떤 외국어를 대상으로 학습을 할건지에 대한 데이터였고, 프로젝트에서는 해당 로직을 LangType이라는 Enum 객체로 다루고 있다.
public enum LangType {
en;
public static LangType defaultLangType() {
return en;
}
}
따라서 실제 로직을 처리할 때는 LangType에 맞춰서 langType 또는 type이라는 변수를 사용하여 개발하고, 응답 데이터로 나갈때만
target이라는 필드로 나가게 개발하고 싶었다.
이러한 고민의 결과로 Controller와 Service Layer에 각각 DTO를 둔 뒤에 개발하는 방향으로 리팩토링을 진행했다.
다음은 사용자의 Profile 정보를 update 해주는 API의 Request Body인데,
public record MemberUpdateRequest(
@ValidUsername
String username,
Boolean termAccepted
) {
}
해당 값과 Controller에서 Header에 있는 JWT를 decode 해줘서 가져오는 memberId를 Parameter로 하는 DTO인 MemberServiceUpdateUserProfileRequest를 만들었다.
@Builder(access = AccessLevel.PRIVATE)
public record MemberServiceUpdateUserProfileRequest(
long memberId,
String username,
Boolean termAccepted
) {
public static MemberServiceUpdateUserProfileRequest of(long memberId, MemberUpdateRequest request) {
return MemberServiceUpdateUserProfileRequest.builder()
.memberId(memberId)
.username(request.username())
.termAccepted(request.termAccepted())
.build();
}
}
이렇게 클래스를 생성한 뒤에, Controller에서 MemberUpdateRequest를 MemberServiceUpdateUserProfileRequest로 바꿔주고,
@PatchMapping
public ResponseEntity<SuccessResponse<MemberUpdateResponse>> updateProfile(
Principal principal,
@RequestBody MemberUpdateRequest request) {
val memberId = PrincipalConverter.getMemberId(principal);
val response = MemberUpdateResponse.from(memberService.updateUserProfile(MemberServiceUpdateUserProfileRequest.of(memberId, request)));
return ApiResponseGenerator.success(SUCCESS_UPDATE_USERNAME, response);
}
Service에서는 MemberServiceUpdateUserProfileRequest를 Parameter로 받아와 로직을 처리한다.
Query String이 많은 API의 경우 값을 직접 파라미터로 넣어주면 service 메소드의 파라미터가 많아지는 경우가 있었는데, 이렇게 구현하면 service에서 파라미터를 ServiceRequest 객체 하나만 받아오면 되므로 훨씬 깔끔하게 구현할 수 있다.
@Transactional
public MemberUpdateServiceResponse updateUserProfile(final MemberServiceUpdateUserProfileRequest request) {
...
return MemberUpdateServiceResponse.of(badges);
}
또한 응답 DTO의 경우도 동일한 방식으로 리팩토링 했다.
사용자 정보를 조회하는 API에서 Service 계층에서 응답하는 MemberGetServiceResponse를 먼저 작성하고,
@Builder(access = PRIVATE)
public record MemberGetServiceResponse(
String username,
GoalType goalType,
String way,
String detail,
LangType targetLangType,
boolean hasPushAlarm,
TrainingTimeServiceResponse trainingTime,
BadgeServiceResponse badge
) {
public static MemberGetServiceResponse of(
GoalGetServiceResponse goal,
Member member,
TrainingTimeServiceResponse trainingTime,
BadgeServiceResponse badge
) {
return MemberGetServiceResponse.builder()
.username(member.getUsername())
.goalType(goal.goalType())
.way(goal.way())
.detail(goal.detail())
.targetLangType(member.getTargetLang())
.hasPushAlarm(member.isHasPushAlarm())
.trainingTime(trainingTime)
.badge(badge)
.build();
}
}
클라이언트에 Response Body로 나가는 MemberGetResponse를 만들고, MemberGetServiceResponse로 해당 클래스를 만들 수 있도록 static method를 만들었다.
@Builder(access = AccessLevel.PRIVATE)
public record MemberGetResponse(
String username,
String target,
String title,
String way,
String detail,
String targetLang,
boolean hasPushAlarm,
TrainingTimeResponse trainingTime,
BadgeResponse badge
) {
public static MemberGetResponse from(MemberGetServiceResponse response) {
return MemberGetResponse.builder()
.username(response.username())
.target(response.goalType().name())
.title(response.goalType().getDescription())
.way(response.way())
.detail(response.detail())
.targetLang(response.targetLangType().toString())
.hasPushAlarm(response.hasPushAlarm())
.trainingTime(TrainingTimeResponse.from(response.trainingTime()))
.badge(BadgeResponse.from(response.badge()))
.build();
}
}
이렇게 구현함으로 우선 MemberGetResponse를 만드는 객체에서 Entity를 직접 사용하지 않을 수 있었고 파라미터MemberGetServiceResponse 하나만 사용하므로 깔끔하게 변환 method를 작성할 수 있었다.
또한 service 로직이 변경되어도, MemberGetResponse 자체는 바뀌지 않기 때문에 API의 일관성도 더욱 유지할 수 있는 설계방향이라는 생각이 들었다. ServiceResponse의 필드명이 바뀐다고 해도, MemberGetResponse의 from() 의 builder에 들어가는 값만 수정하면 되기 때문이다.
물론 기존방식에서도 @JsonProperty 와 같은 어노테이션으로 필드를 수정할 수 있지만, 분명 이러한 코드가 많아지면 관리 및 추적이 어려워질 것이라고 생각하고, 별도의 Converting 과정 없이 요청, 응답에 대해서 일관성을 유지할 수 있다고 생각했다.
단점으로 기본적으로 작성해야되는 코드양이 많아지는 것이 있고, 간단한 API의 경우는 이렇게 처리해야되나? 라는 생각도 들었는데, 일관성을 유지해서 리팩토링을 완료했을 때 Layered Architecture에서 Layer간의 의존도도 줄이고 유지 보수성도 높일 수 있는 설계라는 생각이 들었다.
'Spring' 카테고리의 다른 글
| [Spring] JPA ID 설계 전략 (0) | 2024.06.13 |
|---|---|
| [Spring] RestDocs 기반으로 API 문서 작성해보기 (0) | 2024.03.16 |
| [Study] Test Code Study 정리 (1) (1) | 2024.02.27 |
| [Spring] AWS EC2에 Spring Boot Project 배포하기. (0) | 2023.06.05 |
| [Spring Boot] Spring Boot로 HTTP API 설계하기 (2) | 2023.05.10 |