상품 관리 애플리케이션 만들기 (2)

sookyoung.k·2024년 6월 11일
0

☕Java

목록 보기
5/11
post-thumbnail

🗂️ DTOgetter, setter

상품 추가는 이대로 끝이 아니다. 우리는 DTO에 대해서도 알아야 한다.

👉🏻 DTOMODEL MAPPER

DTO(Data Transfer Object)는 말 그대로 데이터를 전송하는 역할을 가진 객체이다.

현재까지 작성한 코드에서는 도메인 객체인 Product가 표현 계층인 컨트롤러에서도 사용되고, 응용 계층인 어플리케이션 서비스와 인프라스트럭처 계층인 레포지토리에서도 사용된다.

만일 Product의 필드가 변경된다면 전 계층이 영향을 받아버리게 된다. 실무에서는 데이터 구조가 바뀌는 일이 자주 일어나고(^^), 때로는 동일한 도메인 객체여도 클라이언트에게 조금씩 다르게 전달해야 할 때도 있다. 이를 위해 DTO가 필요한 것이다!

ProductDto.java

package kr.co.hanbit.product.management.presentation;

public class ProductDto {
    private Long id;
    private String name;
    private Integer price;
    private  Integer amount;

    public Long getId() {
        return id;
    }

    public String getName() {
        return name;
    }

    public Integer getPrice() {
        return price;
    }

    public Integer getAmount() {
        return amount;
    }
}

id 필드에 대한 setter가 없다는 점을 제외하고는 Product 클래스와 거의 똑같다.

Product.java

package kr.co.hanbit.product.management.domain;

public class Product {
    private Long id;
    private String name;
    private Integer price;
    private Integer amount;

    public void setId(Long id) {
        this.id = id;
    }

}

getter는 HTTP에 응답을 주기 위해 추가되었던 메서드였다. 하지만 Product가 더이상 HTTP 응답을 주는데 사용하지 않기 때문에 Product에 있는 getter는 제외해도 된다.

ProductController.java

package kr.co.hanbit.product.management.presentation;

(생략)

@RestController
public class ProductController {

(생략)

    @RequestMapping(value = "/products", method = RequestMethod.POST)
    public ProductDto createProduct(@RequestBody ProductDto productDto) {
        return simpleProductService.add(productDto);
    }

}

컨트롤러에 있는 Product를 ProductDto로 바꿔준 후

SimpleProductService.java

package kr.co.hanbit.product.management.application;

(생략)

@Service
public class SimpleProductService {

    private ListProductRepository listProductRepository;

    @Autowired
    SimpleProductService(ListProductRepository listProductRepository) {
        this.listProductRepository = listProductRepository;
    }

    public ProductDto add(ProductDto productDto) {
        // 1. ProductDto를 Product로 변환하는 코드
        Product product = ??;

        // 2. 레포지토리를 호출하는 코드
        Product savedProduct = listProductRepository.add(product);

        // 3. Product를 ProductDto로 변환하는 코드
        ProductDto savedProductDto = ??;

        // 4. Dto를 반환하는 코드
        return savedProductDto;
    }

}

서비스에 있는 Product부분도 ProductDto로 변경해준다.

DTO는 표현 계층부터 응용 계층까지 역할을 한다. (그 안쪽으로 전달되지는 않는다.)

Product와 ProductDto를 서로 변환하기 위해서는 어떻게 해야 할까?

  • 생성자 사용
  • '정적 팩토리 메서드'라는 이름의 디자인 패턴 사용 → 이를 위해서는 Product에서 제거한 getter를 다시 추가해야 한다.
  • ModelMapper 매핑 라이브러리 사용 → getter 없이 두 클래스 변환 가능!

* DTO와 엔티티?!
ProductDto를 DTO라고 한다면, Product는 도메인 객체라고 부른다. 구체적으로는 엔티티라고 부를 수 있다. 도메인 객체이면서 id를 가지는 존재를 '엔티티(Entity)', 도메인 객체이면서 id를 가지지 않는 존재를 '값 객체(Value Object)'라고 부른다.

ModelMapper 라이브러리

자바에서 제공하는 '리플렉션 API'를 사용하여 두 클래스 사이의 변환 기능을 제공한다.

자바의 기본 제공 라이브러리가 아니기 때문에 의존성을 추가해줘야 한다. 책에서는 메이븐을 사용했지만, 나는 그래들 사용!

build.gradle

(생략)

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-web'
    // 최신 버전 확인하여 해당 버전으로 지정
	implementation group: 'org.modelmapper', name: 'modelmapper', version: '2.1.1'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

tasks.named('test') {
	useJUnitPlatform()
}

의존성을 다운로드한 후 main 함수가 있는 Application 클래스에 아래의 코드를 추가한다.

매번 new 키워드로 ModelMapper를 생성하는 방법보다는 미리 빈으로 등록한 다음 의존성을 주입받아서 사용하는 것이 성능상 유리하다. ModelMapper 클래스의 인스턴스를 생성한 후 빈으로 등록한다.

Application.java

package kr.co.hanbit.product.management;

(생략)

@SpringBootApplication
public class Application {

	public static void main(String[] args) {
		SpringApplication.run(Application.class, args);
	}

	@Bean
	public ModelMapper modelMapper() {
		ModelMapper modelMapper = new ModelMapper();
		modelMapper.getConfiguration()
				.setFieldAccessLevel(Configuration.AccessLevel.PRIVATE)
				.setFieldMatchingEnabled(true);

		return modelMapper;
	}

}

ModelMapper의 기본 설정은 '매개변수가 없는 생성자로 인스턴스를 생성한 후 setter로 값을 초기화하여 변환'하는 것이다.

setter 없이도 Product와 ProductDto를 변환 가능하도록 하려면 위와 같은 설정으로 ModelMapper 빈을 생성해야 한다. 이 설정이 private인 필드에 리플렉션 API로 접근하여 변환할 수 있게 만들어준다.

SimpleProductService.java

package kr.co.hanbit.product.management.application;

(생략)

@Service
public class SimpleProductService {

    private ListProductRepository listProductRepository;
    private ModelMapper modelMapper;

    @Autowired
    SimpleProductService(ListProductRepository listProductRepository, ModelMapper modelMapper) {
        this.listProductRepository = listProductRepository;
        this.modelMapper = modelMapper;
    }

    public ProductDto add(ProductDto productDto) {
        // 1. ProductDto를 Product로 변환하는 코드
        Product product = modelMapper.map(productDto, Product.class);

        // 2. 레포지토리를 호출하는 코드
        Product savedProduct = listProductRepository.add(product);

        // 3. Product를 ProductDto로 변환하는 코드
        ProductDto savedProductDto = modelMapper.map(savedProduct, ProductDto.class);

        // 4. Dto를 반환하는 코드
        return savedProductDto;
    }

}

그리고 서비스에서도 ModelMapper의 의존성을 주입받아서 사용할 수 있도록 코드를 변환해준다.

ModelMapper의 map() 메서드를 사용할 때는 첫 번째로 변환시킬 대상을 넣고, 두 번째로 어떤 타입으로 변환할지 [클래스 이름.class]의 형태로 넣어주면 된다. 그렇게 되면 자동으로 동일한 필드 이름에 해당하는 값을 복사하여 변환해준다.

👉🏻 getter, setter

도메인 객체에 대한 getter와 setter는 반드시 필요한 경우가 아니라면 최대한 표현력 있는 메서드를 만들어서 사용하는 것이 좋다.

👉🏻 도메인 객체를 캡슐화 시켜 객체지향적인 코드 만들기에 도움이 된다.
✓ getter의 경우 필요한 경우가 꽤 있으므로 필요할 때 구현하도록 하자.
✓ setter의 경우 생성자를 사용하거나 표현력 있는 메서드를 활용하여 대체가 가능하다.

중요한 것은

  • 도메인 계층은 다른 계층에 의존하지 않아야 한다.
  • 다른 모든 계층은 인프라스트럭처 계층에 의존하지 않아야 한다.

* DTO 대신 record

DTO를 사용하면 반복적으로 생성자, getter, hashCode, equals 메서드를 정의하게 된다. 반복적인 작업을 하다 보면 메서드를 정의하지 않고도 해당 메서드를 사용할 수 있는 방법을 고민하게 된다. 이에 자바 14 버전부터 추가된 record 키워드가 도움이 된다.

public record ProductDto(Long id, String name, Integer price, Integer amount) {}

레코드를 사용하면 직접 작성했던 ProductDto와 동일한 기능을 한다. 하지만 setter 메서드를 자동으로 만들어주지 않기 때문에 이는 직접 정의해주거나 값이 변경될 때 생성자를 통해 직접 생성해주어야 한다.

🧺 상품 조회/수정/삭제 구현

🎁 상품 번호를 기준으로 조회 기능 추가

ListProductRepository.java

package kr.co.hanbit.product.management.infrastructure;

(생략)

@Repository
public class ListProductRepository {

    private List<Product> products = new CopyOnWriteArrayList<>();
    private AtomicLong sequence = new AtomicLong(1L);

    (생략)

    public Product findById(Long id) {
        return products.stream()
                .filter(product -> product.sameId(id))
                .findFirst()
                .orElseThrow();
    }

}
  • filter() 메서드 → filter의 결과가 참인 Product만 뽑아내는 코드

  • findFirst() 메서드 → 스트림API의 filter에 걸린 Product 중 첫 번째 Product에 대한 Optional 객체 반환! 비어있거나 Product가 들어있을 수 있다. ( * 타입이 Optional<Product>임에 주의)

  • orElseThrow() → 해당 Optional 객체가 비어 있으면 NoSuchElementException이라는 이름의 예외를 던지고, Product가 들어있으면 Product를 반환해준다.

Product.java

package kr.co.hanbit.product.management.domain;

public class Product {
    private Long id;
    
    (생략)

    public Boolean sameId(Long id) {
        return this.id.equals(id);
    }

}

Product에 sameId 메서드 정의

SimpleProductService.java

(생략)

@Service
public class SimpleProductService {
	
    (생략)
    
    public ProductDto findById(Long id) {
        Product product = listProductRepository.findById(id);
        ProductDto productDto = modelMapper.map(product, ProductDto.class);
        return productDto;
    }
}

ProductController.java

(생략) 

@RestController
public class ProductController {

	(생략) 
    
    @RequestMapping(value = "/products/{id}", method = RequestMethod.GET)
    public ProductDto findProductById(@PathVariable Long id) {
        return simpleProductService.findById(id);
    }
    
 }

컨트롤러 요청 경로에 {} → {} 안에 있는 값과 매개변수의 변수 이름이 같으면 요청된 값이 매개변수로 들어와서 실행된다. (경로에 있던 id가 메서드의 매개변수 id로 들어오는 것)

🎁 전체 상품 목록 조회 기능 추가

ListProductRepository.java

(생략)

@Repository
public class ListProductRepository {

   private List<Product> products = new CopyOnWriteArrayList<>();
   
   (생략)

   public List<Product> findAll() {
       return products;
   }

}

SimpleProductService.java

(생략)

@Service
public class SimpleProductService {
	
    private ListProductRepository listProductRepository;
    private ModelMapper modelMapper;
    
    (생략)
    
    public List<ProductDto> findAll() {
        List<Product> products = listProductRepository.findAll();
        List<ProductDto> productDtos = products.stream()
                .map(product -> modelMapper.map(product, ProductDto.class))
                .toList();
        return productDtos;
    }
    
}

Product 리스트를 ProductDto 리스트로 변환하는 것이 가장 중요하다.

ProductController.java

(생략) 

@RestController
public class ProductController {

    private SimpleProductService simpleProductService;

	(생략) 

    @RequestMapping(value = "/products", method = RequestMethod.GET)
    public List<ProductDto> findAllProduct() {
		return simpleProductService.findaAll();    
    }
    
}

상품 이름에 포함된 문자열로 검색하는 기능 추가

Product.java

package kr.co.hanbit.product.management.domain;

public class Product {
    private Long id;
    
    (생략)

    public Boolean containsName(String name) {
        return this.name.contains(name);
    }

}

우선 도메인 객체인 Product에 메서드를 추가한다. 매개변수로 받은 문자열이 포함된 상품 이름을 가지고 있는지 확인하는 메서드이다.

ListProductRepository.java

(생략)

@Repository
public class ListProductRepository {

    private List<Product> products = new CopyOnWriteArrayList<>();
    
    (생략)

    public List<Product> findByNameContaining(String name) {
        return products.stream()
                .filter(product -> product.containsName(name))
                .toList();
    }

}

여러 개의 상품이 검색될 것이기 때문에 리스트를 반환하도록 했다.

SimpleProductService.java

(생략)

@Service
public class SimpleProductService {
	
    private ListProductRepository listProductRepository;
    private ModelMapper modelMapper;
    
    (생략)
    
    public List<ProductDto> findByNameContaining(String name) {
        List<Product> products = listProductRepository.findByNameContaining(name);
        List<ProductDto> productDtos = products.stream()
                .map(product -> modelMapper.map(product, ProductDto.class))
                .toList();
        return productDtos;
    }
    
}

리스트로 넘어온 Product를 ProductDto로 바꿔주기 때문에 findAll 메서드와 거의 동일하게 작성되고 있다.

ProductController.java

(생략) 

@RestController
public class ProductController {

    private SimpleProductService simpleProductService;

	(생략) 
    
    @RequestMapping(value = "/products", method = RequestMethod.GET)
    public List<ProductDto> findAllProduct(
            @RequestParam(required = false) String name
    ) {
        if (null == name)
            return simpleProductService.findAll();

        return simpleProductService.findByNameContaining(name);
    }
    
 }

기존 전체 상품 조회 API를 수정했다.

  • 쿼리 파라미터가 넘어오지 않을 경우 기존처럼 전체 목록을 조회한다.

  • name이라는 쿼리 파라미터가 넘어오면 name을 기준으로 검색하는 서비스 메서드를 실행한다.

    * @RequestParam은 기본 속성이 해당 파라미터를 필수로 받도록 하는 것이기 때문에 required = false를 반드시 명시해주어야 한다.

🎁 상품 수정하기

  1. 컨트롤러에서 상품 번호를 받을 수 있어야 한다. 특정 자원을 지칭해야 하기 때문에 패스 배리어블로 id를 받는다. 수정하는 것이기 때문에 메서드는 PUT을 사용한다.

  2. 상품 번호를 제외한 나머지 정보를 모두 수정하는 것이기 때문에 상품에 대한 정보를 전부 받아야 한다. 상품 추가 기능과 같이 요청 바디를 통해 상품에 대한 JSON을 받아서, 그 값으로 정보를 수정한다.

  3. 레파지토리를 호출하고, 호출된 결과로 반환되고 수정된 Product를 다시 DTO로 변환하여 컨트롤러로 반환해줘야 한다.

  4. 레파지토리에서는 기존 리스트에 저장되어 있던 Product를 가져와서 setter를 통해 바꿔줄 수도 있다. 하지만! setter를 사용하기 싫어! 그렇다면?!

ProductController.java

(생략) 

@RestController
public class ProductController {

    private SimpleProductService simpleProductService;

	(생략) 
    
    @RequestMapping(value = "/products/{id}", method = RequestMethod.PUT)
    public ProductDto updateProduct(
        @PathVariable Long id,
        @RequestBody ProductDto productDto
    ) {
        productDto.setId(id);
        return simpleProductService.update(productDto);
    }
    
}

ModelMapper를 통해 DTO에서 변환된 Product를 수정되기 전의 Product와 바꿔버린다!

SimpleProductService.java

(생략)

@Service
public class SimpleProductService {
	
    private ListProductRepository listProductRepository;
    private ModelMapper modelMapper;
    
    (생략)
    
    public ProductDto update(ProductDto productDto) {
        Product product = modelMapper.map(productDto, Product.class);
        Product updateProduct = listProductRepository.update(product);
        ProductDto updatedProductDto = modelMapper.map(updateProduct, ProductDto.class);
        return updatedProductDto;
    }
    
}

상품 추가 기능을 구현할 때 사용한 add 메서드와 거의 동일하다.

ListProductRepository.java

(생략)

@Repository
public class ListProductRepository {

    private List<Product> products = new CopyOnWriteArrayList<>();
    
    (생략)

    public Product update(Product product) {
        Integer indexToModify = products.indexOf(product);
        products.set(indexToModify, product);
        return product;
    }

}

레파지토리에서 Product를 통째로 바꿔버리는 코드이다.

indexOf() → 리스트의 요소 중 매개변수로 받은 인스턴스와 동일한 인스턴스의 index를 반환한다. ( * 동일성 판단은 해당 요소의 equals() 메서드로 판단함.)

Product.java

package kr.co.hanbit.product.management.domain;

import java.util.Objects;

public class Product {
    private Long id;
    
    (생략)

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Product product = (Product) o;
        return Objects.equals(id, product.id);
    }
}

Product의 equals를 오버라이드하여 위와 같이 작성해준다.

🎁 상품 삭제하기

상품 삭제하기는 특정 id에 해당하는 Product를 리스트에서 지워 버리는 방식으로 구현하면 된다. 패스 배리어블로 id만 받고, HTTP 메서드는 DELETE가 되어야 한다.

ProductController.java

(생략) 

@RestController
public class ProductController {

    private SimpleProductService simpleProductService;

	(생략) 
    
    @RequestMapping(value = "/products/{id}", method = RequestMethod.DELETE)
    public void deleteProduct(@PathVariable Long id) {
        simpleProductService.delete(id);
    }
    
}

이미 삭제된 상품이므로... 응답을 줬을 때 큰 의미가 없다. 때문에 void를 반환 타입으로 선언한다.

SimpleProductService.java

(생략)

@Service
public class SimpleProductService {
	
    private ListProductRepository listProductRepository;
    private ModelMapper modelMapper;
    
    (생략)
    
    public void delete(Long id) {
        listProductRepository.delete(id);
    }
    
}

ListProductRepository.java

(생략)

@Repository
public class ListProductRepository {

    private List<Product> products = new CopyOnWriteArrayList<>();
    
    (생략)

    public void delete(Long id) {
        Product product = this.findById(id);
        products.remove(product);
    }

}

findById()를 통해 Product를 조회한 다음 remove의 인자로 넣어주면 간단하다.


<이것이 백엔드 개발이다 with 자바> 책을 공부하고 정리한 내용입니다.

profile
영차영차 😎

0개의 댓글