🔥새해맞이 특별 라이브 선착순 신청🔥

[인프런 워밍업 클럽_0기 BE] 4일차 과제 - 과일 API 만들기

[인프런 워밍업 클럽_0기 BE] 4일차 과제 - 과일 API 만들기

자 오늘의 문제는 수업시간에 예제로 다루었던 Fruit로 API를 만들어보는 시간입니다. 저는 다음과 같이 테이블을 생성했습니다.

 

create table fruit
(
    id              bigint auto_increment,
    name            varchar(20) not null,
    warehousing_date date not null,
    price           bigint not null,
    is_sold          boolean default 0,
    primary key (id)
);

문제 1번에서는 과일 정보를 저장해야했는데 그 정보에는 과일의 이름과 창고에 입고된 날짜, 그리고 가격이 들어있습니다.

그에 따른 코드는 다음과 같습니다.

Fruit

@Entity
public class Fruit {
	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	private Long id;

	@Column(nullable = false)
	private String name;

	@Column(nullable = false)
	private LocalDate warehousingDate;

	@Column(nullable = false)
	private Long price;

	@Column(nullable = false)
	private Boolean isSold;

	protected Fruit() {
	}

	public Fruit(String name, LocalDate warehousingDate, Long price) {
		this.name = name;
		this.warehousingDate = warehousingDate;
		this.price = price;
		this.isSold = false;
	}
}

입고 요청의 Body에는 이름과 적재일, 가격만 들어옴으로 팔렸는지 아닌지의 여부를 결정하는 isSold 필드에는 생성자 내부에서 기본 값을 false로 세팅해주었습니다.

 

여기서 멘토님의 한걸음 더!에 적혀있는 내용을 알아봅시다. 왜 가격은 int 혹은 Integer가 아닌 long 혹은 Long으로 받아야할까요?

가격 정보는 그 정확도가 중요하기 때문입니다. intlong보다 더 큰 범위의 값을 저장할 수 있습니다. 당장에는 '과일'이라는 범주만을 다루므로, 도메인의 특징 상 가격의 값이 그리 크지 않을 수 있지만 가격이라는 것은 시간에 따라 변화하고 어떻게 될지 모르는 것이기 때문에 일반적으로 +-21억의 범위를 저장할 수 있는 int보다는 long을 선택하는 것이 좋습니다. 그리고 int로 선언했을 경우 어떠한 연산이 이뤄지느냐에 따라 그 범위가 int의 표현 범위를 넘어서서 오버플로우가 생길 수 있으므로 long으로 선언하는 것이 정확성 측면에 있어서 안전합니다. 그리고 자바에는 이러한 정확한 연산을 지원하는 BigDecimal을 지원하며, 실제로 숫자에 예민한 비즈니스를 수행하는 금융, 커머스 등에서는 BigDecimal을 사용한다고 합니다.

 

그리고 더 나아가 그 가격이 null로 지정될만한 일, 그러니까 가격 정보가 아직 정해지지 않은 경우에는 null로 저장을 해야될 때도 있는데요. 이럴때를 위해 Long이라는 wrapper 클래스 종류로 선언할 수도 있습니다.

 

이제 API와 관련된 코드를 보겠습니다. 저는 다음과 같이 작성했습니다.

@RestController
@RequestMapping("/api/v1/fruit")
public class FruitController {

	private final FruitService fruitService;

	public FruitController(FruitService fruitService) {
		this.fruitService = fruitService;
	}

	@PostMapping
	public void createFruit(@RequestBody FruitCreateRequest request) {

		if (request.getName().isBlank()) {
			throw new IllegalArgumentException("이름은 비어있어서는 안됩니다.");
		}

		if (request.getPrice() < 0) {
			throw new IllegalArgumentException("가격은 0원 이상이어야 합니다.");
		}

		if (request.getWarehousingDate() == null) {
			throw new IllegalArgumentException("입고 날짜를 입력하세요.");
		}
		fruitService.saveFruit(request);
	}
}

입력값을 검증하는 코드들이 들어가있는데요. 이 부분은 추후 Bean Validation을 도입함으로써 개선할 여지가 있는 부분입니다.

public class FruitCreateRequest {
	private String name;
	private LocalDate warehousingDate;
	private Long price;

	public String getName() {
		return name;
	}

	public LocalDate getWarehousingDate() {
		return warehousingDate;
	}

	public Long getPrice() {
		return price;
	}
}
@Service
@Transactional
public class FruitService {

	private final FruitRepository fruitRepository;

	public FruitService(FruitRepository fruitRepository) {
		this.fruitRepository = fruitRepository;
	}

	public void saveFruit(FruitCreateRequest request) {
		fruitRepository.save(new Fruit(request.getName(), request.getWarehousingDate(), request.getPrice()));
	}
}

DTO와 서비스 레이어의 코드까지 작성했습니다. JPA 레포지토리 코드는 생략했습니다. 꽤 간단한 문제였고 요청을 다음과 같이 보내고 정상적으로 200 OK 응답을 받을 수 있었습니다.

imageimageimage

테이블에 데이터가 잘 들어간 것을 확인할 수 있고, 저는 뒤의 문제를 위해 몇 가지 데이터를 조금 더 추가해주었습니다.

 

문제 2번은 과일이 팔리게되면 그것을 기록하는 문제입니다. API의 스펙은 다음과 같습니다.

  • HTTP Method : PUT

  • path : /api/v1/fruit

요청 Body는 다음과 같이 들어옵니다.

{
    "id" : long
}

그리고 응답은 다음과 같이 전달해야하고 상태코드는 200으로 와야합니다.

{
    "id" : 3
}

저는 위에서 상품이 판매되었는지의 여부를 알 수 있는 isSold라는 필드를 선언해놓았습니다. 이 예제는 조금 더 복잡하게는 판매 데이터를 모아놓는 테이블에 따로 저장되어야할 것 같기는 합니다. 하지만 여기서는 단순히 isSold라는 값을 true, 데이터베이스 상에서는 1로 바꾸면 될 것 같습니다.

@PutMapping
public FruitSoldResponse updateStatus(@RequestBody FruitSoldRequest request) {
	if (request.getId() == null) {
		throw new IllegalArgumentException("조회하고자 하는 과일의 id는 null이 될 수 없습니다.");
	}

	Long soldFruitId = fruitService.updateSoldStatus(request.getId());
        return new FruitSoldResponse(soldFruitId);
}
public Long updateSoldStatus(Long id) {
	Fruit fruit = fruitRepository.findById(id).orElseThrow(IllegalAccessError::new);
	fruit.changeSoldStatus();
	return id;
}

자, 작성중에 코드들이 뭔가 다 회색으로 나오는게 맘에 들지 않습니다만, 이렇게 서비스 코드를 작성했습니다. 직접 디비의 값을 업데이트 쳐주기보다는 도메인 로직에 비즈니스 로직을 포함시켜 더 객체지향적으로 코드를 작성했습니다.

public void changeSoldStatus() {
	isSold = !this.isSold;
}

위 코드는 Fruit안에 있는 코드입니다. 현재 상태와 정 반대되는 값을 넣어주도록 했습니다. 판매가 되었다면, 교환이나 환불이 생길수도 있겠죠? 그래서 과일이 팔렸을 때 true를 바로 넣어주기보다는 현재 상태와 반대되는 값을 넣어주도록 했습니다.

 

imageimageimage요청을 날리고, 응답을 확인하고, DB까지 확인한 결과 모두 정상적으로 작동하고 있는 것을 확인했습니다.

 

문제 3번은 특정 과일을 기준으로 팔린 금액, 팔리지 않은 금액을 조회하는 API를 작성하는 것입니다.

(1, 사과, 5000원, 판매 O) (2, 사과, 4000원, 판매 X) (3, 사과, 3000원, 판매 O)가 있다면 판매된 금액은 8000원이고 판매되지 않은 금액은 4000원입니다. API의 스펙은 아래와 같습니다.

 

  • HTTP Method : GET

  • path : /api/v1/fruit/stat

  • query :

    • name : 과일 이름

응답은 아래와 같이 나와야 합니다.

{
    "salesAmount" : long,
    "notSalesAmount" : long
}

그리고 현재 테이블의 상태는 다음과 같습니다. 만약 요청을 정상적으로 처리했다면 salesAmount는 8000원, 그리고 notSalesAmount는 4000원을 내보내야 합니다.

image

@GetMapping("/stat")
public FruitStatResponse getStatOfFruit(@RequestParam("name") String name) {
	List<Long> stats = fruitService.fruitStat(name);

	return new FruitStatResponse(stats.get(0), stats.get(1));
}

컨트롤러 코드에서는 이름을 파라미터로 입력을 받아서 서비스에 넘겨주고 있습니다.

public List<Long> fruitStat(String name) {
	fruitRepository.findFruitsByName(name);
	List<Fruit> findFruits = fruitRepository.findFruitsByName(name);

	if (findFruits.size() == 0) {
		throw new IllegalArgumentException("해당 이름을 갖고 있는 과일이 없습니다.");
	}

	Long salesAmount = findFruits.stream().filter(Fruit::getSold).mapToLong(Fruit::getPrice).sum();
	Long notSalesAmount = findFruits.stream().filter(fruit -> !fruit.getSold()).mapToLong(Fruit::getPrice).sum();

	return List.of(salesAmount, notSalesAmount);
}

그리고 만약 이름을 통해서 가져온 findFruits의 사이즈가 0이면 해당 이름을 가진 과일 자체가 없으므로 통계를 낼 수 없습니다. 그래서 이 부분에서 에러를 던지는 방어로직을 만들어주었습니다.

 

그리고 스트림을 이용해서 팔린 상품의 가격과 팔리지 않은 상품의 가격을 합해서 List로 반환한 후 인덱스에 직접 접근해서 DTO에 담아 내보내게 되어 있습니다.

public interface FruitRepository extends JpaRepository<Fruit, Long> {
	List<Fruit> findFruitsByName(String name);
}

레포지토리에 따로 작성한 쿼리 메서드는 위와 같습니다.

 

그리고 요청은 아래와 같이 보냈고, 정상적으로 동작한 것을 확인할 수 있습니다.

image

image

성공적으로 수행했음을 확인한 후에 과제창을 닫으려는 찰나..! 아, 진도상 아직 JPA를 사용하기보단 JdbcTemplate를 사용한다고 가정한 과제이구나!! 라는 것을 깨달았습니다. 물론 문제를 풀어내는 방법은 다양하므로 코치님께서 정답이 없다고 했으니 괜찮..겠죠? 물론 SUMGROUP BY로 문제를 풀 수도 있었습니다.

SELECT SUM(CASE WHEN is_sold = 1 THEN price ELSE 0 END) AS 판매금액,
       SUM(CASE WHEN is_sold = 0 THEN price ELSE 0 END) AS 판매안한금액
FROM
    fruit
GROUP BY name;

저는 위와 같은 쿼리로 처리했습니다. 다르게 처리할 수도 있습니다. GROUP BY의 기준을 판매 여부로도 나누어 2개의 행으로 나오게 할 수도 있고, 위와 같이 두 개의 컬럼과 하나의 행으로 나오게 할 수도 있습니다.

image

쿼리 결과입니다!

 

오늘 과제도 끝!

댓글을 작성해보세요.

채널톡 아이콘