안녕하세요. dev_writer입니다.
F-Lab 멘토링 기간이 얼마 안 남았기도 했고, 리뷰를 받고 싶은 최소 부분들이 거의 다 구현 완료가 되어 추가 리뷰를 위해 리팩터링 작업을 진행하고 있습니다.
그러던 중 마주친 고민점에 대해 공유드리고자 글을 작성하게 되었습니다.
마주친 문제: Food (도메인)를 FoodService (서비스)에서 직접 생성? 🤷
리팩터링을 하기 전에는, 음식 (Food) 도메인 엔티티 (정확히 말하면 Food의 vo인 FoodNutrient)를 직접 FoodService에서 빌더 패턴을 이용해 생성했었습니다.
우선 상황을 더 잘 이해하실 수 있도록, 음식 데이터를 저장하는 데 사용되는 FoodCreateRequest, Food / FoodNutrient / FoodWeight를 보여드리겠습니다.
FoodCreateRequest (food/application/dto)
FoodCreateRequest는 컨트롤러 (웹 계층)과 상호작용하여 음식 저장 시 필요한 데이터를 request body로 받는 dto입니다.
public record FoodCreateRequest(
@NotEmpty(message = "음식 이름이 있어야 합니다.")
String name,
@NotNull(message = "음식 사이즈가 있어야 합니다.")
@DecimalMin(value = "0", inclusive = false, message = "음식 사이즈는 0보다 커야 합니다.")
BigDecimal servingSize,
@NotEmpty(message = "음식 사이즈 단위가 있어야 합니다.")
String unit,
@NotNull(message = "음식 칼로리가 있어야 합니다.")
@DecimalMin(value = "0", message = "음식 칼로리는 0 이상이어야 합니다.")
BigDecimal kcal,
@NotNull(message = "음식 탄수화물이 있어야 합니다.")
@DecimalMin(value = "0", message = "음식 탄수화물은 0 이상이어야 합니다.")
BigDecimal carbohydrate,
@NotNull(message = "음식 단백질이 있어야 합니다.")
@DecimalMin(value = "0", message = "음식 단백질은 0 이상이어야 합니다.")
BigDecimal protein,
@NotNull(message = "음식 지방이 있어야 합니다.")
@DecimalMin(value = "0", message = "음식 지방은 0 이상이어야 합니다.")
BigDecimal fat,
@NotNull(message = "음식 나트륨이 있어야 합니다.")
@DecimalMin(value = "0", message = "음식 나트륨은 0 이상이어야 합니다.")
BigDecimal sodium,
@NotEmpty(message = "음식 이미지 주소가 있어야 합니다.")
String url
) {
}
보시는 바와 같이, 회원이 먹은 음식 정보를 저장하기 위해서는 영양소 등 많은 정보가 필요한 상황입니다.
Food (food/domain)
Food는 영양 성분 (FoodNutrient)과 음식 무게 정보 (FoodWeight), 이름, 사진, 회원 id 등을 가집니다.
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@SuperBuilder
@Entity
public class Food extends BaseEntity { // BaseEntity는 createdAt을 가지고 있음
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Embedded
private FoodWeight weight;
@Embedded
private FoodNutrient nutrient;
@Column(nullable = false)
private String url;
@Column(nullable = false)
private Long memberId;
public static Food createWith(
final String name,
final FoodWeight weight,
final FoodNutrient nutrient,
final String url,
final Long memberId
) {
return Food.builder()
.name(name)
.weight(weight)
.nutrient(nutrient)
.url(url)
.memberId(memberId)
.build();
}
}
FoodWeight (food/domain/vo)
FoodWeight는 음식의 무게, 무게 단위 (FoodUnit - "g", "kg" enum)를 가지는 VO입니다. FoodUnit은 단순한 enum이기에 별도로 작성하지 않겠습니다.
@Getter
@AllArgsConstructor(access = AccessLevel.PROTECTED)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Embeddable
public class FoodWeight {
@Column(nullable = false)
private BigDecimal servingSize;
@Enumerated(value = EnumType.STRING)
@Column(nullable = false)
private FoodUnit unit;
public static FoodWeight createWith(final BigDecimal servingSize, final String unit) {
return new FoodWeight(servingSize, FoodUnit.findByName(unit));
}
}
FoodNutrient (food/domain/vo)
FoodNutrient는 음식에 대한 영양 성분을 가지는 VO입니다. 이 클래스가 FoodCreateRequest 매개변수가 많아진 원인입니다.
@Getter
@AllArgsConstructor(access = AccessLevel.PROTECTED)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Builder
@Embeddable
public class FoodNutrient {
@Column(nullable = false)
private BigDecimal kcal;
@Column(nullable = false)
private BigDecimal carbohydrate;
@Column(nullable = false)
private BigDecimal protein;
@Column(nullable = false)
private BigDecimal fat;
@Column(nullable = false)
private BigDecimal sodium;
public static FoodNutrient createWith(
final BigDecimal kcal,
final BigDecimal carbohydrate,
final BigDecimal protein,
final BigDecimal fat,
final BigDecimal sodium
) {
return FoodNutrient.builder()
.kcal(kcal)
.carbohydrate(carbohydrate)
.protein(protein)
.fat(fat)
.sodium(sodium)
.build();
}
}
FoodService (food/application)
다음으로는 문제가 되었던 FoodService 코드입니다.
package com.flab.eattofit.food.application;
import com.flab.eattofit.food.application.dto.FoodCreateRequest;
import com.flab.eattofit.food.domain.Food;
import com.flab.eattofit.food.domain.FoodReposiory;
import com.flab.eattofit.food.domain.FoodSearchManager;
import com.flab.eattofit.food.domain.vo.FoodNutrient;
import com.flab.eattofit.food.domain.vo.FoodWeight;
import com.flab.eattofit.food.infrastructure.dto.PredictFoodSearchResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
@Slf4j
@Transactional
@Service
public class FoodService {
private static final String FOOD_SEARCH_MANAGER = "doinglabFoodSearchManager";
private final FoodRepository foodRepository;
private final FoodSearchManager foodSearchManager;
public FoodService(
final FoodRepository foodRepository,
@Qualifier(value = FOOD_SEARCH_MANAGER) final FoodSearchManager foodSearchManager
) {
this.foodRepository = foodRepository;
this.foodSearchManager = foodSearchManager;
}
// 좋지 않았던 코드
public Food createFood(final FoodCreateRequest request, final Long memberId) {
FoodWeight weight = FoodWeight.createWith(request.servingSize(), request.unit());
FoodNutrient nutrient = FoodNutrient.builder()
.kcal(request.kcal())
.carbohydrate(request.carbohydrate())
.protein(request.protein())
.fat(request.fat())
.sodium(request.sodium())
.build();
Food food = Food.createWith(request.name(), weight, nutrient, request.url(), memberId);
return foodRepository.save(food);
}
public PredictFoodSearchResponse foodSearch(final Long memberId, final String url) {
log.info("{} 회원이 {}로 음식 검색 요청", memberId, url);
return foodSearchManager.search(url);
}
}
사용자가 음식을 만들 때에는 FoodController를 탄 다음, FoodService의 createFood 메서드를 호출하게 됩니다.
그런데 매개변수가 많다 보니, (FoodWeight의 경우에는 쉽게 생성할 수 있었지만) FoodNutrient에서는 빌더 패턴을 사용했었습니다.
뒤늦게 파악한 결과, FoodNutrient에서 이미 만들어 둔 FoodNutrient.createWith 메서드를 이용하면 빌더 패턴을 서비스에서 제거할 수 있음을 알게 되었습니다.
왜 서비스에서 도메인의 빌더 패턴을 제거하는 방향으로 설계하였나?
빌더 패턴은 매개변수가 많을 때 헷갈리지 않고 이름을 지정할 수 있어 유용하지만, 크게 두 가지 문제가 있다고 판단했습니다.
첫 번째, 한편으로는 도메인의 캡슐화, 자율성을 저해하는 행위가 될 수 있겠다고 생각했습니다. 도메인의 생성자나 정적 팩토리 메서드를 타서 도메인 객체가 만들어지는 게 아니라, 서비스에서 직접 도메인의 빌더 패턴을 이용함으로써 도메인 입장에서 예상치 못한 값을 서비스에서 직접 주입할 수도 있기 때문입니다.
두 번째, 빌더 패턴을 이용하면 값을 누락하는 경우가 생길 수 있다고 생각했습니다. 예시로 사람의 이름과 나이를 함께 입력해야 하는데, 나이를 빼먹고 빌더를 완료한 경우가 생길 수 있어 이는 곧 첫 번째의 경우처럼 도메인 입장을 고려하지 못한 상황이 생길 수 있다고 판단했습니다.
그러므로 도메인 객체의 캡슐화와 자율성을 보장하기 위해 서비스에서 도메인의 빌더 패턴을 이용하지 않는 방향으로 설계하였습니다.
개발을 진행하며 참고한 <도메인 주도 개발 시작하기 : DDD 핵심 개념 정리부터 구현까지>에도, 관련된 내용이 있었습니다.
"도메인 로직을 도메인 영역과 응용 서비스에 분산해서 구현하면 코드 품질에 문제가 발생한다. 첫 번째는 코드의 응집성이 떨어진다는 것이다. 도메인 데이터와 그 데이터를 조작하는 도메인 로직이 한 영역에 위치하지 않고 서로 다른 영역에 위치한다는 것은 도메인 로직을 파악하기 위해 여러 영역을 분석해야 한다는 것을 의미한다." - 도메인 주도 개발 시작하기, 205p
도메인의 생성 또한, 책에서 나온 "도메인 로직"에 포함된다고 생각했습니다.
해결법 1: 빌더 패턴 대신 미리 만들어 둔 createWith 메서드를 이용하자. (선택 X)
그러면 빌더 패턴을 포기하고, createWith 메서드로 변경하면 끝.. 일 수 있겠으나, 이 부분에서 추가적인 고민이 발생하였습니다. 바로 빌더 패턴을 사용하지 않다 보니, 매개변수가 많아질수록 헷갈릴 수 있겠다는 것이었습니다.
해결법 1의 문제: 매개변수가 많아질수록 순서가 헷갈려진다.
public Food createFood(final FoodCreateRequest request, final Long memberId) {
FoodWeight weight = FoodWeight.createWith(request.servingSize(), request.unit());
FoodNutrient nutrient = FoodNutrient.createWith(
request.kcal(),
request.carbohydrate(),
request.protein(),
request.fat(),
request.sodium()
);
Food food = Food.createWith(request.name(), weight, nutrient, request.url(), memberId);
return foodRepository.save(food);
}
위의 코드대로 수정했다면, kcal, carbohydrate 등 FoodCreateRequest의 필드 이름으로 어떤 것을 의미하는지는 이해가 될 수 있겠으나 자칫하면 매개변수의 순서가 의도치 않게 넣어질 수 있습니다. (request.kcal()과 request.carbohydrate()의 순서가 바뀐다면..?)
... 요약하면, 점층적 생성자 패턴도 쓸 수는 있지만, 매개변수 개수가 많아지면 클라이언트 코드를 작성하거나 읽기 어렵다. 코드를 읽을 때 각 값의 의미가 무엇인지 헷갈릴 것이고, 매개변수가 몇 개인지도 주의해서 세어 보아야 할 것이다. 타입이 같은 매개변수가 연달아 늘어서 있으면 찾기 어려운 버그로 이어질 수 있다. 클라이언트가 실수로 매개변수의 순서를 바꿔 건네줘도 컴파일러는 알아채지 못하고, 결국 런타임에 엉뚱한 동작을 하게 된다. - 이펙티브 자바, 아이템 2: 생성자에 매개변수가 많다면 빌더를 고려하라, 15p
따라서, 서비스에서 도메인에 대해 빌더 패턴을 쓰지 않으면서도, 매개변수가 많은 경우에도 헷갈리지 않고 무사히 도메인을 만들 수 있는 방법을 찾아야 했습니다.
해결법 2: FoodCreateRequest에서 Food를 만들 수 있는 toEntity 메서드를 작성하자. (선택 X)
쉬운 방법 중 하나이고, 멘토님께서도 말씀해 주셨던 방법입니다. 바로 전달받았던 FoodCreateRequest를 Food로 매핑하는 메서드로 작성하는 방법입니다.
서비스에서의 헷갈렸던 문제를 없앨 수 있습니다.
public record FoodCreateRequest(
...위에 작성해 둔 필드들
) {
public Food toEntity(final Long memberId) {
FoodWeight weight = FoodWeight.createWith(servingSize, unit);
FoodNutrient nutrient = FoodNutrient.createWith(kcal, carbohydrate, protein, fat, sodium);
return Food.createWith(name, weight, nutrient, url, memberId);
}
}
// FoodService의 createFood
public Food createFood(final FoodCreateRequest request, final Long memberId) {
Food food = request.toEntity(memberId);
return foodRepository.save(food);
}
그러나 아래 단점이 있어 사용하지 않기로 하였습니다.
해결법 2의 문제: 도메인의 변화가 웹 계층에까지 직/간접적 영향을 끼친다.
FoodCreateRequest는 서비스 계층이 컨트롤러 (웹) 계층과 상호작용하기 위한 dto입니다. 이 dto 안에 도메인 생성 로직이 있다면, 도메인의 생성 로직에 변화가 생겼을 경우 해당 클래스에도 변경이 발생하게 되어 도메인의 변화가 서비스뿐 아니라 웹 계층에까지 전파되는 단점이 있다고 생각했습니다.
예시로 createWith 메서드를 of로 이름 변경 하거나, 컨트롤러에서는 넣지 않고 서비스에 있는 변수를 도메인의 마지막 매개변수로 추가 (또는 특정 매개변수를 제거) 해야 하는 상황이 생겼을 경우에도 FoodCreateRequest 코드에 변경이 발생하게 되어 다른 방법을 고민하게 되었습니다.
또한, DTO는 가급적 자신의 로직을 가지지 않게 하고 데이터 전달만 하는 식으로 설계하고 싶은 마음도 있었습니다.
해결법 3: 도메인 전용 DTO를 만들자. (선택 O)
마지막으로 고민한 방법은 도메인 전용 DTO를 만드는 것이었습니다.
지금까지는 createWith 메서드를 이용해 직접 필드를 받아 만드는 방식이었는데, 도메인 전용 DTO 필드를 만들어두고 그 필드로부터 값을 주입받도록 하면 괜찮지 않을까라는 생각이 들었습니다. 어차피 도메인 전용 DTO는 도메인이 필요하기에 만든 DTO이기 때문입니다.
food/domain/dto에 FoodCreateDto를 만들어보면 두 가지 방법으로 만들 수 있었습니다.
해결법 3-1. FoodCreateDto (food/domain/dto)가 FoodCreateRequest를 의존하도록 개발 (선택 X)
package com.flab.eattofit.food.domain.dto;
import com.flab.eattofit.food.application.dto.FoodCreateRequest;
import java.math.BigDecimal;
public record FoodCreateDto(
Long memberId,
String name,
BigDecimal servingSize,
String unit,
BigDecimal kcal,
BigDecimal carbohydrate,
BigDecimal protein,
BigDecimal fat,
BigDecimal sodium,
String url
) {
public static FoodCreateDto createWith(final FoodCreateRequest request, final Long memberId) {
return new FoodCreateDto(
memberId,
request.name(),
request.servingSize(),
request.unit(),
request.carbohydrate(),
request.protein(),
request.fat(),
request.sodium(),
request.url()
);
}
}
// FoodService의 createFood
public Food createFood(final FoodCreateRequest request, final Long memberId) {
FoodCreateDto foodCreateDto = FoodCreateDto.createWith(request, memberId);
Food food = Food.from(foodCreateDto);
return foodRepository.save(food);
}
이렇게 도메인 전용 dto인 FoodCreateDto를 개발하면 서비스 단에서는 쉽게 FoodCreateDto를 만들어내고, 이를 통해 객체를 손쉽게 생성할 수 있습니다. 객체 생성에 사용되는 필드들은 도메인 dto에서 전담하기 때문에 헷갈릴 수 있는 여지가 많이 줄었다고 볼 수 있습니다. (서비스에서 아예 모르므로)
// Food.java에 추가된 from 정적 팩터리 메서드
public static Food from(final FoodCreateDto foodCreateDto) {
FoodNutrientCreateDto foodNutrientCreateDto = FoodNutrientCreateDto.from(foodCreateDto);
return Food.builder()
.weight(FoodWeight.createWith(foodCreateDto.servingSize(), foodCreateDto.unit()))
.nutrient(FoodNutrient.from(foodNutrientCreateDto))
.name(foodCreateDto.name())
.url(foodCreateDto.url())
.memberId(foodCreateDto.memberId())
.build();
}
그러나 DDD의 의존 관계가 걸렸습니다.
현재 프로젝트에 적용한 패턴인 DDD, 도메인 주도 설계에서는 의존 관계 규칙을 아래처럼 정의하고 있습니다.
![](https://blog.kakaocdn.net/dn/dylUqW/btsKodHAKuY/jkSsReQzDu0lha3ttGNIO0/img.png)
계층 구조는 그 특성상 상위 계층에서 하위 계층으로의 의존만 존재하고 하위 계층은 상위 계층에 의존하지 않는다. 예를 들어 표현 계층은 응용 계층에 의존하고 응용 계층이 도메인 계층에 의존하지만, 반대로 인프라스트럭처 계층이 도메인에 의존하거나 도메인이 응용 계층에 의존하지는 않는다. - 도메인 주도 개발 시작하기, 65p
인프라스트럭처의 경우, 응용/도메인 계층이 인프라스트럭처를 의존할 경우 상세한 기술 구현에 종속되어 이를 해결하기 위해 DIP (의존관계 역전 원칙)을 적용하기도 하지만, 적어도 도메인이 응용 계층에 의존하지 않도록 해야겠다고 생각했습니다.
그런데 지금 작성한 FoodCreateDto를 보면 food/domain/dto에 위치해 있으며, food/application/dto에 있는 FoodCreateRequest를 의존하여 역참조가 발생하게 되었음을 보실 수 있습니다.
![](https://blog.kakaocdn.net/dn/xpSei/btsKnX6dkJE/eYPUR6ngouhkH694yMgV51/img.png)
해결법 3-2. 서비스에서 서비스 DTO -> 도메인 DTO로 변환하는 코드를 만들고 이를 이용하자. (선택 O)
따라서 FoodCreateDto가 FoodCreateRequest를 의존하는 방법보다는, FoodService에서 FoodCreateDto를 만드는 방식을 이용하기로 했습니다.
이때 FoodCreateDto가 가지고 있는 매개변수가 매우 많다 보니 첫 번째로 겪었던 문제점인 매개변수의 순서가 헷갈릴 수 있는 문제를 또다시 겪을 수도 있었으나, FoodCreateDto에 빌더 패턴을 적용함으로써 해결하였습니다.
도메인에 대한 빌더 패턴을 쓰는 것은 민감한 문제라 서비스에서 쓰지 않기로 하였지만, DTO의 경우에는 상대적으로 덜하기 때문입니다. (도메인 DTO의 값이 잘못 전달될 경우 도메인 단에서 검증 가능하기 때문)
// FoodCreateDto
@Builder
public record FoodCreateDto(
... 위에 작성해 둔 필드들
) {
}
// FoodService의 createFood, convertFoodCreateDto
public Food createFood(final FoodCreateRequest request, final Long memberId) {
FoodCreateDto foodCreateDto = convertFoodCreateDto(request, memberId);
Food food = Food.from(foodCreateDto);
return foodRepository.save(food);
}
private FoodCreateDto convertFoodCreateDto(final FoodCreateRequest request, final Long memberId) {
return FoodCreateDto.builder()
.memberId(memberId)
.name(request.name())
.servingSize(request.servingSize())
.unit(request.unit())
.kcal(request.kcal())
.carbohydrate(request.carbohydrate())
.protein(request.protein())
.fat(request.fat())
.sodium(request.sodium())
.build();
}
결론
이로써 매개변수가 많을 때에는 도메인 dto를 이용하여 해결해 보자라는 나름의 기준을 세울 수 있었습니다.
사실, 해당 방법도 아주 완벽한 것은 아닙니다. 도메인 dto를 작성하게 되어 결국 작성해야 할 코드 수가 늘어나고, 서비스에서 dto 간 변환해야 하는 함수도 작성해야 하기 때문입니다.
그래도 개인적으로는 DDD의 의존 관계를 해치지 않으면서, 도메인의 캡슐화를 저해하지 않은 방법이라 생각했습니다.
이런 것을 보면 개발에는 완전한 답은 없는 것 같다는 생각을 하게 되었으며, 적어도 개발을 한다면 선택한 스타일에 대한 이유와 근거를 가지는 것이 중요하겠다는 것을 배울 수 있었습니다.
추가로 얻은 고민점: FoodNutrientCreateDto를 만들 때에는 FoodCreateDto를 의존하도록 할까? 매개변수를 직접 넘기게 할까?
마지막으로 얻었던 고민점은 Food의 하위 VO인 FoodNutrientCreateDto를 만들 때, FoodCreateDto를 의존하도록 할지 (현재 방식), 매개변수를 직접 넘기게 할 지에 대한 고민이 있었습니다.
이 또한 엄격하게 하자면 매개변수를 직접 넘기게 하는 게 좋을 것 같다는 생각이 들었으나, 한편으로는 FoodNutrient는 Food가 있어야만 존속 가능한 객체이기 때문에 FoodCreateDto를 받아도 괜찮겠다고 판단하였습니다. (또 다른 방법으로는 Food에서 빌더 패턴을 이용하는 것도 괜찮을 것 같습니다.)
public record FoodNutrientCreateDto(
kcal, carbohydrate, protein, fat, sodium..
) {
public static FoodNutrientCreateDto from(final FoodCreateDto foodCreateDto) {
// foodCreateDto로부터 위 속성들 받기
}
}
'✨ 프로젝트 > EatToFit [F-Lab]' 카테고리의 다른 글
[EatToFit] DB 설계 과정에서의 고민 (5) | 2024.09.15 |
---|---|
[EatToFit] API 설계 과정에서의 고민 (2) (0) | 2024.08.27 |
[EatToFit] API 설계 과정에서의 고민 (1) (3) | 2024.08.09 |
[EatToFit] 프로젝트 아이디어 소개 (0) | 2024.08.05 |