gpgp
[프로젝트] GPGP (Good Product Good Price)
GPGP (Good Product Good Price)
GPGP 사이트는 다양한 쇼핑몰 사이트입니다.
프로젝트의 퀄리티가 낮을 수 있으나, 프로젝트를 진행하면서 고민했던 점,
프로젝트 사전 작업
자바 버전 11
Gradle
스프링 부트 2.6.7
의존성
- jpa
- string-web
- validation
- spring-boot-devtools
데이터 베이스
- 운영 서버 : mysql
- 테스트 서버 : h2
Day - 01
기획이 제일 어려운 것 같다. 어떤 기능을 넣을지, 어떻게 파트 분담을 해야할지 정하는 것이 매우 어려웠다. 우선 전체적인 틀을 설계하고 세부구현을 하고자 했다.
쇼핑몰이기 때문에 대충 이런 DB 설계 틀을 잡았다. 그리고 괜찮은지에 대해서 피드백 받고자 현업자들에게 물어봤다. 결과는 엄청 혼났다. 왜냐하면 처음부터 모든 기능을 상세하게 적고 DB 설게를 다 하고 설계를 마치고 구현하는 것은 절대 하면 안된다고 했다. 왜냐하면 실무에서는 끊임없이 바뀌는 요구사항에 대응하기 위해서는 유스케이스 단위로 작게 설계하고, 작게 구현하고, 또 작게 설계하고 작계 구현하는 방식으로 해야 변화에 유연하게 대응할 수 있다고 했다. 그래서 이것을 엎고 유스케이스 단위로 파트 분배를 했다.
나는 상품 파트를 분배 받았다. 상팜 등록 / 조회 / 수정 API를 먼저 작성하기로 했다.
이런식으로 상품 주문, 등록에 무엇이 필요한지에 대해서 요구사항을 작성하고 구현에 들어가기로 했다.
저번에 배운 문자열 계산기를 TDD 한 것을 적용하기 위해서 이번 프로젝트에서도 TDD를 적용하기로 했다. 나는 그래도 TDD를 약간 맛보기를 해봤지만, 팀원분은 TDD가 처음이였다. 그래서 TDD는 테스트 코드를 먼저 작성하고 구현하는 것이라고 말해줬다. 그리고 각자 엔티티에 대해서 TDD를 작성했다.
TDD 작성이 끝나고 서로가 어떻게 TDD를 구현했는지 공유했다. 팀원분의 코드를 본 순간 나는 당황했다. 테스트 코드에서 테스트만 작성된 것이 아니라, 실제 구현까지 되어있는 상태였기 때문이다. TDD에 대한 개념조차 모르시는 분이였다. 그래서 내가 TDD를 알려주기로 했다. 물론 나도 TDD에 대해서 잘 아는 것은 아니지만 내가 아는선까지는 최선을 다해서 알려줬다. 내가 했었던 문자열 계산기를 직접 코드로 TDD로 어떻게 구현하는지에 대해서 실시간 라이브 코딩으로 작성하는 것을 보여주고 알려줬다.
솔직히 팀원분이 TDD에 대한 개념이 아예 없던 것을 알고나서 이번 프로젝트에 TDD를 적용하는 것이 맞을까에 대한 고민도 했다. 왜냐하면 나도 TDD에 대해서 그렇게 잘하는편도 아니고, 팀 수준도 아키텍처의 일부이기에 팀 역량에 맞지 않는 요구를 하는 것은 팀을 망치는 것이라고 생각했다. 그래서 팀원분한테 TDD 방식을 알려주고 TDD로 할지 말지에 대해 어떻게 하면 좋겠는지 팀원분의 의견을 듣기로 했다. 다행히 팀원분도 나랑 같이 이번에 TDD도 배우면서 프로젝트에 적용시키고 싶다고 해서 TDD 방식을 적용해나가기로 했다. 그리하여 팀원분의 TDD 코드를 전체 엎기로 했다.
Day - 02
팀원분의 TDD 코드를 엎고 다시 제대로 된 TDD 코드를 작성했다. 그리고 서로 코드를 공유하여 코드 리뷰를 하고 보완할 점을 서로 짚어줬다. 그리고 현업자한테도 피드백을 같이 받았다. 결과를 먼저 말하자면, 풍부한 도메인 모델을 유지하되, 도메인 객체에서 유효성 검사는 굳이 할 이유가 없다는 것이었다. 내가 작성한 Product 클래스에는 도메인 객체의 상태를 변화시키는 비즈니스 로직과 생성자에 필드값의 유효성 검사가 전부 들어있다. 이것을 풍부한 도메인 모델이라고 부른다. 내가 작성한 도메인 모델의 장점은 도메인 객체의 생성자가 유효성 검사를 해준다. 그리하여 유효한 필드값이 생성자에 들어오지 않으면 인스턴스 자체가 생성되지 않는다. 그러면 영속성 컨텍스트가 DB 커넥션을 연결하기도 전에 예외를 발생시켜서 DB 커넥션 비용을 아낄 수 있다. DB 커넥션은 고비용이다. 단점으로는 도메인 객체에 비즈니스 로직뿐만 아니라 필드값의 유효성 검사까지 들어있어서 코드가 지저분해보인다. 생각해보니 DB에서도 필드 값의 유효성을 체크할 수 있다. 근데 DB에 필드값의 유효성 검사를 맡기는 것은 엔티티가 DB에 의존하는 것이 아닌가 생각이 들기도 한다. 왜냐하면 클린 아키텍처에 따르면 엔티티는 클린 아키텍처의 코어로 그 어떠한 것도 의존해서는 안된다. 그러면 엔티티 코드를 간결하게 작성하면서 유효성 검사도 DB에 의존하지 않는 방법에 대해서 고민했다.
첫번째 방안은 Bean validation 사용으로 유효성을 검사하는 것이었다. @Range, @Min 이런 애노테이션을 사용하여 유효성 로직을 애노테이션으로 대체하는 것이다. 하지만 이것에는 문제가 있다. 바로 외부 라이브러이에 의존해야한다는 것이다. @Max @Min 같은 애노테이션은 Javax로 자바 표준이지만 @Range같은 애노테이션의 구현체는 하이버네이트다. 즉, 외부 라이브러리를 의존하는 것이다. 엔티티는 순수 POJO로 작성해야하기에 Bean validation을 사용할 수 없다.
두번째 방안은 도메인 모델에는 항상 유효한 필드값이 들어온다고 가정하는 것이다. 그리고 그 유효성 검사를 DTO 객체를 통해서 하는 것이다. 어차피 컨트롤러의 입력값이나 반환값이 모두 DTO로 반환되어야하기 때문에 거기서 유효성 검사를 미리 하는 것이다. DTO는 외부 라이브러리를 의존해도 괜찮기 때문에 괜찮은 방안 같다.
Day - 03
뭔가 계속 코드를 작성하고 있지만 이렇게 설계하면서 프로젝트를 하는것이 맞는가에 대한 의문이 계속 남아있었다. 그래서 그것을 해결하기 위해서 서점으로 달려갔다. 그리고 아키텍처에 대한 책들을 훑어봤다. 내가 찾는 책은 아키텍처에 대한 지식을 쉽게 알려주는 입문자용이고, 프로젝트에 바로 적용해야하기 때문에 적당히 가벼운 책을 고르기로 했다. 그 결과 '톰 홈버그'의 '만들면서 배우는 클린 아키텍처' 책을 골랐다. 이 책은 자바 코드 구현을 통해 예시를 들기도 하고 대략 140p의 비교적 가벼운 책이다. '클린 아키텍처' 책도 훑어봤지만 '만들면서 배우는 클린 아키텍처'가 더 구체적인 것 같아서 이 책을 골랐다.
책을 사고 초반부를 읽었는데 나는 좌절을 느꼈다. 왜냐하면 책에서는 설계 시작은 유스케이스부터 해야한다고 주장하고 있기 때문이다. 영속성 계층을 먼저 설계하면 DB 의존적인 데이터베이스 주도 개발을 하게된다고 책에서 말하고 있었다. 즉, 도메인 로직을 먼저 설계하고 나서 이를 기반으로 영속성 계층과 웹 계층을 만들어야 한다는 것이다.
데이터베이스 주도 개발의 문제점은 유스케이스가 들어있는 도메인 계층과 엔티티가 들어있는 영속성 계층이 서로 강한 결합을 구조를 띄고 있기 때문이다. 개발자는 비즈니스 로직 중심으로 개발해야한다.
결국 나는 엔티티를 먼저 생성했기 때문에 데이터베이스 중심 설계가 되어버렸는가에 대해 고민했다. 데이터베이스 중심 설계일경우 다시 다 엎고 처음부터 유스케이스를 먼저 설계해야하나 그것까지 생각했다. 물론 아직 프로젝트 초기 단계라 엎는것에 대해서는 그렇게 큰 부담은 아니였다. 걱정인 점은 무언가를 잘못했을 때마다 매번 엎을 수는 없다는 것이다.
데이터베이스 주도 설계의 단점은 도메인보다 디비 설계가 우선이 되니깐 실제 도메인과 괴리감이 생길 수 있다. 즉, 비즈니스를 위한 개발이 아닌 개발을 위한 개발이 될 수 있다. 그리고 DB를 먼저 설계하면 DB
Day - 04
ProductService를 작성하는데 하나의 고민이 생겼다. 상품 정보 수정시, 컨트롤러에서 ProductDto로 받아서 Product로 변환을 한 다음에 ProductService의 update() 를 통해 수정한다. 처음 내가 작성한 코드는 ProductService 인터페이스에서 update() 메서드의 파라미터로 DTO를 받는것으로 설계했다.
하지만 파라미터를 DTO로 받으면 ProductService 로직이 DTO에 의존하게 되어서 DTO가 변하면 비즈니스 로직도 의존하게 되는 것 같다. 내가 생각했을 때 Service에서는 오로지 비즈니스 로직만 들어있어야한다고 생각한다.
그래서 DTO와 Product를 서로 변환해주는 Service 계층을 하나 더 만들면 어떨까 생각했다. 근데 여기에는 단점이 있다. 바로 컨트롤러에서 변환해주는 Service 계층을 추가로 의존해야한다는 것이다.
일단 어느 코드가 더 나은지 서로 비교해보자.
🔍 ProductService에서 파라미터를 DTO로 받을 경우
📌 ProductService (interface)
package kr.co.gpgp.domain.product;
import java.util.List;
import kr.co.gpgp.domain.product.dto.CreateProductRequest;
import kr.co.gpgp.domain.product.dto.CreateProductResponse;
import kr.co.gpgp.domain.product.entity.Product;
public interface ProductService {
Long save(Product product);
void update(Long productId, CreateProductRequest request);
List<Product> findProducts();
Product findOne(Long productId);
Product create(CreateProductRequest request);
CreateProductResponse conversionDto(Product product);
}
그리고 conversionDto()라는 비즈니스 로직와 무관한 로직이 깃들여있다.
package kr.co.gpgp.web.api;
@RequestMapping("/api/products")
@RestController
@RequiredArgsConstructor
@Slf4j
public class ProductController {
private final ProductService productService;
@PostMapping
public <T> ApiModel<T> saveProduct(@RequestBody CreateProductRequest request) {
Product product = productService.create(request);
Long savedProductId = productService.save(product);
URI location = ServletUriComponentsBuilder.fromCurrentRequest()
.path("/{id}")
.buildAndExpand(savedProductId)
.toUri();
return response(HttpStatus.CREATED, ResponseEntity.created(location).build());
}
@GetMapping
public <T> ApiModel<T> findAll() {
List<Product> products = productService.findProducts();
List<CreateProductResponse> productDts = products.stream()
.map(productService::conversionDto)
.collect(Collectors.toList());
return response(HttpStatus.OK, productDts);
}
@GetMapping("/{id}")
public <T> ApiModel<T> findOne(@PathVariable Long id) {
Product findProduct = productService.findOne(id);
CreateProductResponse productDto = productService.conversionDto(findProduct);
return response(HttpStatus.OK, productDto);
}
@PatchMapping("/{id}")
public <T> ApiModel<T> updateOne(@PathVariable Long id, @RequestBody CreateProductRequest request) {
productService.update(id, request);
return response(HttpStatus.NO_CONTENT, null);
}
}
컨트롤은 ProductService만 의존한다.
🔍 DTO와 Product를 서로 변환해주는 Service 계층 추가
package kr.co.gpgp.domain.product.service.dto;
public interface ProductDtoService {
CreateProductResponse productConversionDto(Product product);
Product dtoConversionProduct(CreateProductRequest request);
}
Product 와 Dto를 서로 변환해주는 Service 계층을 하나 더 만들었다.
package kr.co.gpgp.domain.product.service;
public interface ProductService {
Long save(Product product);
void update(Long productId, Product product);
List<Product> findAll();
Product findOne(Long productId);
}
package kr.co.gpgp.web.api;
@RequestMapping("/api/products")
@RestController
@RequiredArgsConstructor
@Slf4j
public class ProductController {
private final ProductService productService;
private final ProductDtoService productDtoService;
@PostMapping
public <T> ApiModel<T> saveProduct(@RequestBody CreateProductRequest request) {
Product product = productDtoService.dtoConversionProduct(request);
Long savedProductId = productService.save(product);
URI location = ServletUriComponentsBuilder.fromCurrentRequest()
.path("/{id}")
.buildAndExpand(savedProductId)
.toUri();
return response(HttpStatus.CREATED, ResponseEntity.created(location).build());
}
@GetMapping
public <T> ApiModel<T> findAll() {
List<Product> products = productService.findAll();
List<CreateProductResponse> productDts = products.stream()
.map(productDtoService::productConversionDto)
.collect(Collectors.toList());
return response(HttpStatus.OK, productDts);
}
@GetMapping("/{id}")
public <T> ApiModel<T> findOne(@PathVariable Long id) {
Product findProduct = productService.findOne(id);
CreateProductResponse productDto = productDtoService.productConversionDto(findProduct);
return response(HttpStatus.OK, productDto);
}
@PatchMapping("/{id}")
public <T> ApiModel<T> updateOne(@PathVariable Long id, @RequestBody CreateProductRequest request) {
Product newProduct = productDtoService.dtoConversionProduct(request);
productService.update(id, newProduct);
return response(HttpStatus.NO_CONTENT, null);
}
}
컨트롤은 ProductService, ProductDtoService을 의존한다. 기존 방식에 비해 의존 주입이 한 개 더 늘어났다.
컨트롤러에서 의존성 주입이 한 개 더 추가됐지만 ProductService에서 순수 비즈니스 로직만 담을 수 있도록 깔끔하게 분리하는 것이 더 괜찮아보인다. 그렇지 않으면 DTO가 변하면 ProductService도 같이 변경을 해줘야하기 때문에 ProductService에서 변경 사유가 2개가 되어버린다.
Day - 05
피드백에 도움을 주신 분 :
팀원 : https://k-kjh.tistory.com/