[인프런 워밍업 스터디 클럽] 0기 백엔드 미션 - DB연동 API 테스트 (Day4)

[인프런 워밍업 스터디 클럽] 0기 백엔드 미션 - DB연동 API 테스트 (Day4)

미션

벌써 4일차가 되었다. 오늘은 지난 시간 DB연동을 통해 유저를 생성하고 조회하는 실습을 하였다면 오늘은 유저를 수정하고 삭제하고 예외처리를 정리하는 등 전반적인 CRUD를 적용시키는 실습을 했다. 이제 이것을 바탕으로 API 실습 미션을 진행해보도록 하자.


문제1.

 

요구사항

image

문제해결

먼저 API를 개발하기 전에 우리 PC에 설치 된 MySQL에 접속하여, 데이터베이스와 테이블을 생성해야 한다.

 

1. 데이터베이스 생성

CREATE DATABASE mission;

위와 같이 데이터베이스를 생성한다. 나는 mission이라는 이름의 데이터베이스를 생성하였다.

 

2. 데이터 베이스 접속

use mission;

 

3. 테이블 생성

과일정보를 담는 테이블을 생성해야 한다. 아래와 같이 생성해보자. (속성들은 문제3번까지 확인 후 미리 한번에 만듬)

CREATE TABLE fruit (
    id bigint auto_increment,
    name varchar(20) not null,
    warehousingDate date not null,
    price bigint not null,
    is_sold boolean not null default false,
    primary key (id)
);

이제 아래의 sql로 테이블이 잘 생성 되었는지 확인해보자.

show tables;

 

4. 스프링 부트 프로젝트에 DB연동 정보 기입

이제 해당 DB와 우리의 스프링 부트 프로젝트를 연동할 차례이다. 프로젝트의 resources 디렉토리 아래에 application.yml에 설정정보를 기입하자.

 

유의

처음 resources 디렉토리 안으로 가보면 application.properties 파일이 있을 것이다. 여기다가 DB정보를 기입해도 좋지만, 하이라키 구조를 눈에 띄게 보고 싶고 yml에 익숙해서 나는 yml로 변경하여 작성하겠다.

 

아래와 같이 작성한다.

spring:
  datasource:
    url: "jdbc:mysql://localhost/mission"
    username: "root"
    password: ""
    driver-class-name: com.mysql.cj.jdbc.Driver

 

5. 컨트롤러 클래스 개발

이제 컨트롤러 클래스를 만들어보자.

 

package me.sungbin.controller.fruit;

import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

/**
 * @author : rovert
 * @packageName : me.sungbin.controller.fruit
 * @fileName : FruitController
 * @date : 2/22/24
 * @description :
 * ===========================================================
 * DATE           AUTHOR        NOTE
 * -----------------------------------------------------------
 * 2/22/24       rovert         최초 생성
 */
@RestController
@RequestMapping("/api/v1")
public class FruitController {
}
  • @RequestMapping을 통하여 문제1~3번까지 제시된 API는 /api/v1으로 시작함으로 컨트롤러의 전체 매핑을 해준다.

6. Entity 개발

이제 DB 테이블 설계를 한 데로 그와 1:1 매칭이 되는 클래스를 만들어주겠다.

(속성들은 문제3번까지 확인 후 미리 한번에 만듬)

package me.sungbin.entity.fruit;

import java.time.LocalDate;

/**
 * @author : rovert
 * @packageName : me.sungbin.entity.fruit
 * @fileName : Fruit
 * @date : 2/22/24
 * @description :
 * ===========================================================
 * DATE           AUTHOR        NOTE
 * -----------------------------------------------------------
 * 2/22/24       rovert         최초 생성
 */

public class Fruit {

    private long id;

    private String name;

    private LocalDate warehousingDate;

    private long price;

    private boolean isSold;

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

    public long getId() {
        return id;
    }

    public String getName() {
        return name;
    }

    public LocalDate getWarehousingDate() {
        return warehousingDate;
    }

    public long getPrice() {
        return price;
    }

    public boolean isSold() {
        return isSold;
    }
}
  • 이 클래스는 실제 DB와 1:1되는 클래스이다. 다중생성자와 getter를 만들어 두었다.

     

엔티티 클래스에는 Setter 지양?!

setter 메서드는 항상 public으로 어디든 접근이 가능하다. 이로 인하여 의도치 않게 다른 곳에서 엔티티의 속성들의 값이 변경될 우려가 있으므로 setter를 지양하는 것이 좋다.

 

7. DTO 개발

package me.sungbin.dto.fruit.request;

import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import me.sungbin.entity.fruit.Fruit;
import org.springframework.format.annotation.DateTimeFormat;

import java.time.LocalDate;

/**
 * @author : rovert
 * @packageName : me.sungbin.dto.fruit.request
 * @fileName : SaveFruitInfoRequestDto
 * @date : 2/22/24
 * @description :
 * ===========================================================
 * DATE           AUTHOR        NOTE
 * -----------------------------------------------------------
 * 2/22/24       rovert         최초 생성
 */
public class SaveFruitInfoRequestDto {

    @NotBlank(message = "과일 이름이 공란일 수 없습니다.")
    @NotNull(message = "과일 이름이 null일 수는 없습니다.")
    private String name;

    @DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
    private LocalDate warehousingDate;


    @Min(value = 0, message = "가격이 음수가 될 수는 없습니다.")
    private long price;

    public SaveFruitInfoRequestDto(String name, LocalDate warehousingDate, long price) {
        this.name = name;
        this.warehousingDate = warehousingDate;
        this.price = price;
    }

    public String getName() {
        return name;
    }

    public LocalDate getWarehousingDate() {
        return warehousingDate;
    }

    public long getPrice() {
        return price;
    }

    public Fruit toEntity() {
        return new Fruit(name, warehousingDate, price);
    }
}
  • 요청 DTO로 각 필드마다 제약조건을 추가해줬다. 이로 인해서 name이 null이거나 공란이거나 price가 음수거나 warehousingDate가 DATE형이 아닐 때 예외를 발생시키게 validation을 해주었다.

  • 마지막에 toEntity()로 DTO로 실제 엔티티를 변환하는 메서드를 만들었다.

📚 요청과 응답으로 Entity 대신에 DTO 사용!

위와 같이하면 다음과 같은 이점이 존재한다.

1. 엔티티 내부 구현을 캡슐화 할 수 있다.

2. 필요한 데이터만 선별이 가능하다.

3. 순환참조를 예방할 수 있다.

4. validation코드와 모델링 코드를 분리할 수 있다.

8. Repository interface와 구현체 개발

Repository interface

package me.sungbin.repository.fruit;

import me.sungbin.entity.fruit.Fruit;

/**
 * @author : rovert
 * @packageName : me.sungbin.repository.fruit
 * @fileName : FruitRepository
 * @date : 2/22/24
 * @description :
 * ===========================================================
 * DATE           AUTHOR        NOTE
 * -----------------------------------------------------------
 * 2/22/24       rovert         최초 생성
 */
public interface FruitRepository {
    void saveFruitInfo(Fruit fruit);
}
  • Repository 구현체

package me.sungbin.repository.fruit;

import me.sungbin.entity.fruit.Fruit;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;

/**
 * @author : rovert
 * @packageName : me.sungbin.repository.fruit
 * @fileName : FruitJdbcRepository
 * @date : 2/22/24
 * @description :
 * ===========================================================
 * DATE           AUTHOR        NOTE
 * -----------------------------------------------------------
 * 2/22/24       rovert         최초 생성
 */
@Repository
public class FruitJdbcRepository implements FruitRepository {

    private final JdbcTemplate jdbcTemplate;

    public FruitJdbcRepository(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    @Override
    public void saveFruitInfo(Fruit fruit) {
        String sql = "INSERT INTO fruit (name, warehousingDate, price) VALUES (?, ?, ?)";

        jdbcTemplate.update(sql, fruit.getName(), fruit.getWarehousingDate(), fruit.getPrice());
    }
}
  • POST요청이고 저장하는 요청으로 위와 같이 saveFruitInfo 메서드에 INSERT 쿼리를 작성 후, jdbcTemplate을 이용한다.

  • 그리고 파라미터로 넘어오는 Fruit의 name, warehousingDate, price값이 넘어와 '?'와 매칭되고 쿼리가 실행된다.

📚 Repository를 이렇게 나눈 이유?

1. 관심사의 분리(Separation of Concerns): 이 구조는 애플리케이션의 다른 부분에서 데이터 액세스 로직을 분리합니다. 이렇게 하면 애플리케이션의 유지 보수가 용이해지고, 코드의 가독성이 향상됩니다.

2. 확장성 및 유연성(Extensibility and Flexibility): 인터페이스를 사용함으로써, 다양한 유형의 저장소 구현체(예: JdbcTemplate, JPA, Hibernate 등)를 손쉽게 교체하거나 추가할 수 있습니다. 이는 애플리케이션의 요구사항이 변경되었을 때 새로운 기술을 적용하기 용이하게 만듭니다.

3. 테스트 용이성(Testability): 인터페이스를 사용하면 개발자가 단위 테스트를 작성할 때 실제 데이터베이스에 의존하지 않고도 모의 객체(Mock Objects)를 사용하여 테스트를 할 수 있습니다. 이는 테스트의 실행 속도를 높이고, 테스트 환경을 간소화합니다.

 

9. 서비스 레이어 클래스 개발

package me.sungbin.service.fruit;

import me.sungbin.dto.fruit.request.SaveFruitInfoRequestDto;
import me.sungbin.entity.fruit.Fruit;
import me.sungbin.repository.fruit.FruitRepository;
import org.springframework.stereotype.Service;

/**
 * @author : rovert
 * @packageName : me.sungbin.service.fruit
 * @fileName : FruitService
 * @date : 2/22/24
 * @description :
 * ===========================================================
 * DATE           AUTHOR        NOTE
 * -----------------------------------------------------------
 * 2/22/24       rovert         최초 생성
 */
@Service
public class FruitService {

    private final FruitRepository fruitRepository;

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

    public void saveFruitInfo(SaveFruitInfoRequestDto requestDto) {
        Fruit fruit = requestDto.toEntity();

        this.fruitRepository.saveFruitInfo(fruit);
    }
}
  • requestDto의 엔티티 변환 메서드를 실행하여 DTO를 엔티티 타입으로 변환한다.

  • repository 구현체에 작성했던 저장 쿼리가 있는 메서드를 호출한다.

10. 컨트롤러 코드 수정

package me.sungbin.controller.fruit;

import jakarta.validation.Valid;
import me.sungbin.dto.fruit.request.SaveFruitInfoRequestDto;
import me.sungbin.service.fruit.FruitService;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

/**
 * @author : rovert
 * @packageName : me.sungbin.controller.fruit
 * @fileName : FruitController
 * @date : 2/22/24
 * @description :
 * ===========================================================
 * DATE           AUTHOR        NOTE
 * -----------------------------------------------------------
 * 2/22/24       rovert         최초 생성
 */
@RestController
@RequestMapping("/api/v1")
public class FruitController {

    private final FruitService fruitService;

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

    @PostMapping("/fruit")
    public void saveFruitInfo(@RequestBody @Valid SaveFruitInfoRequestDto requestDto) {
        this.fruitService.saveFruitInfo(requestDto);
    }
}
  • DTO를 요청의 body로 보낸다. 따라서 @RequestBody 어노테이션을 추가

  • 그럼 DTO는 서비스 레이어 저장하는 로직이 담긴 saveFruitInfo 메서드의 파라미터로 담기고 이 dto가 서비스 레이어에서 엔티티로 변환되고 이 엔티티가 repository로 들어가 insert 쿼리에 필요한 정보를 가져올 수 있게 되는 것이다.

  • body에 담기 전에 dto에 적어준 validation 어노테이션이 동작하려면 @Valid 어노테이션이 있어야 한다.

실행결과

 

image

image

오류 응답

그러면 만약에 가격이 음수고 이름이 공란이거나 null이면 어떻게 될까? 200 OK가 뜰까? 당연히 안 뜰것이고 뜨는게 이상할 것이다. 코치님이 강의 중에 말씀하신 부분과 동일하다. 즉, validation 부분에서 예외가 발생하면 MethodArgumentNotValidException이 발생하는데 이 예외는 400에러 코드를 가진다. 따라서 400 Bade Request가 나올 것이다.

image

테스트 코드

이제 테스트코드를 한번 확인해보자. 테스트코드는 실패 테스트와 성공테스트 2개를 할 것이며, Junit5를 이용하여 테스트해보겠다.

 

1. 실패코드 (가격이 음수거나 과일 이름이 공란)

package me.sungbin.controller.fruit;

import com.fasterxml.jackson.databind.ObjectMapper;
import me.sungbin.dto.fruit.request.SaveFruitInfoRequestDto;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;

import java.time.LocalDate;

import static org.junit.jupiter.api.Assertions.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

/**
 * @author : rovert
 * @packageName : me.sungbin.controller.fruit
 * @fileName : FruitControllerTest
 * @date : 2/22/24
 * @description :
 * ===========================================================
 * DATE           AUTHOR        NOTE
 * -----------------------------------------------------------
 * 2/22/24       rovert         최초 생성
 */

@SpringBootTest
@AutoConfigureMockMvc
class FruitControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private ObjectMapper objectMapper;

    @Test
    @DisplayName("문제 1번 통합 테스트 - 실패 (가격이 음수거나 과일 이름이 공란)")
    void question1_test_fail_caused_by_price_is_minus_or_fruit_name_is_empty() throws Exception {
        SaveFruitInfoRequestDto requestDto = new SaveFruitInfoRequestDto("", LocalDate.of(2024, 1, 1), -1000);

        this.mockMvc.perform(post("/api/v1/fruit")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(requestDto)))
                .andDo(print())
                .andExpect(status().isBadRequest());
    }

}
결과

image

2. 성공코드

@Test
@DisplayName("문제 1번 통합 테스트 - 성공")
void question1_test_success() throws Exception {
    SaveFruitInfoRequestDto requestDto = new SaveFruitInfoRequestDto("파인애플", LocalDate.of(2024, 2, 2), 20000);

    this.mockMvc.perform(post("/api/v1/fruit")
                    .contentType(MediaType.APPLICATION_JSON)
                    .content(objectMapper.writeValueAsString(requestDto)))
            .andDo(print())
            .andExpect(status().isOk());
}
결과

image

한걸음 더!

자바에서 정수를 다루는 방법이 int와 long으로 2가지 존재한다. 그런데 이 2가지 방법중에 위의 API에서 long을 사용한 이유가 뭘까?

간단하다. int는 자료형이 4byte로 4byte의 범위(-21억~21억)를 넘어가는 가격이 존재할 수 있을 것이다. 예를 들어, 은행에서 대기업과 대기업사이의 돈 송금을 할때도 충분히 자료형을 벗어날 법하다. 또한 테이블의 PK에도 long타입을 자바에서 작성했는데 그 또한 마찬가지다. 지금은 데이터가 몇건이 없지만 추후에 서비스가 커지고 큰 구조가 된다면 int형은 충분히 넘을 것이다.

즉, 확장성을 고려해서라도 설계때부터 long타입을 담아두는 것이다.


문제2

image

문제해결

1. Body로 넘길 DTO 개발

package me.sungbin.dto.fruit.request;

/**
 * @author : rovert
 * @packageName : me.sungbin.dto.fruit.request
 * @fileName : UpdateFruitRequestDto
 * @date : 2/22/24
 * @description :
 * ===========================================================
 * DATE           AUTHOR        NOTE
 * -----------------------------------------------------------
 * 2/22/24       rovert         최초 생성
 */
public class UpdateFruitRequestDto {
    private long id;

    public UpdateFruitRequestDto(long id) {
        this.id = id;
    }

    public long getId() {
        return id;
    }
}
  • body를 id 하나의 필드만 넘겨주므로 id 하나의 필드만 존재하는 request DTO 개발

2. Repository와 Repository 구현체에 메서드 추가

package me.sungbin.repository.fruit;

import me.sungbin.entity.fruit.Fruit;

/**
 * @author : rovert
 * @packageName : me.sungbin.repository.fruit
 * @fileName : FruitRepository
 * @date : 2/22/24
 * @description :
 * ===========================================================
 * DATE 			AUTHOR			 NOTE
 * -----------------------------------------------------------
 * 2/22/24       rovert         최초 생성
 */
public interface FruitRepository {
    void saveFruitInfo(Fruit fruit);

    void updateFruitInfo(long id);
}
  • 과일 정보 업데이트 선언부 정의

package me.sungbin.repository.fruit;

import me.sungbin.entity.fruit.Fruit;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;

/**
 * @author : rovert
 * @packageName : me.sungbin.repository.fruit
 * @fileName : FruitJdbcRepository
 * @date : 2/22/24
 * @description :
 * ===========================================================
 * DATE 			AUTHOR			 NOTE
 * -----------------------------------------------------------
 * 2/22/24       rovert         최초 생성
 */
@Repository
public class FruitJdbcRepository implements FruitRepository {

    private final JdbcTemplate jdbcTemplate;

    public FruitJdbcRepository(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    @Override
    public void saveFruitInfo(Fruit fruit) {
        String sql = "INSERT INTO fruit (name, warehousingDate, price) VALUES (?, ?, ?)";

        jdbcTemplate.update(sql, fruit.getName(), fruit.getWarehousingDate(), fruit.getPrice());
    }

    @Override
    public void updateFruitInfo(long id) {
        validateForUpdate(id);

        String sql = "UPDATE fruit SET is_sold = 1 WHERE id = ?";
        jdbcTemplate.update(sql, id);
    }

    /**
     * 존재하지 않는 과일정보가 있을 것을 대비해 DB에 id값으로 조회 후, true false 반환
     * @param id
     * @return
     */
    private boolean isNotExistsFruitInfo(long id) {
        String readSQL = "SELECT * FROM fruit WHERE id = ?";

        return jdbcTemplate.query(readSQL, (rs, rowNum) -> 0, id).isEmpty();
    }

    /**
     * 존재하지 않는 과일정보를 접근할 경우 Exception 발생
     * @param id
     */
    private void validate(long id) {
        if (isNotExistsFruitInfo(id)) {
            throw new IllegalArgumentException("존재하는 과일정보가 없습니다.");
        }
    }
}
  • 주석에 써 있듯이 private 메서드들은 유효하지 않는 과일정보 접근을 대비해 예외처리를 해준 것이다.

    • isNotExistsFruitInfo 메서드는 한번 DB를 id값으로 조회해서 유효한 과일정보면 false를 아니면 true를 반환

    • validate 메서드를 통해 유효하지 않는 과일정보를 접근하려 하면 IllegalArgumentException을 발생

  • 업데이트 로직 전에 유효성 검사를 통하여 유효한 과일정보만 업데이트.

서비스 코드 작성

package me.sungbin.service.fruit;

import me.sungbin.dto.fruit.request.SaveFruitInfoRequestDto;
import me.sungbin.dto.fruit.request.UpdateFruitRequestDto;
import me.sungbin.entity.fruit.Fruit;
import me.sungbin.repository.fruit.FruitRepository;
import org.springframework.stereotype.Service;

/**
 * @author : rovert
 * @packageName : me.sungbin.service.fruit
 * @fileName : FruitService
 * @date : 2/22/24
 * @description :
 * ===========================================================
 * DATE           AUTHOR        NOTE
 * -----------------------------------------------------------
 * 2/22/24       rovert         최초 생성
 */
@Service
public class FruitService {

    private final FruitRepository fruitRepository;

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

    public void saveFruitInfo(SaveFruitInfoRequestDto requestDto) {
        Fruit fruit = requestDto.toEntity();

        this.fruitRepository.saveFruitInfo(fruit);
    }

    public void updateFruitInfo(UpdateFruitRequestDto requestDto) {
        this.fruitRepository.updateFruitInfo(requestDto.getId());
    }
}
  • 요청 DTO의 getter로 id값을 가져와 repository 코드에 전달

컨트롤러 코드 작성

package me.sungbin.controller.fruit;

import jakarta.validation.Valid;
import me.sungbin.dto.fruit.request.SaveFruitInfoRequestDto;
import me.sungbin.dto.fruit.request.UpdateFruitRequestDto;
import me.sungbin.service.fruit.FruitService;
import org.springframework.web.bind.annotation.*;

/**
 * @author : rovert
 * @packageName : me.sungbin.controller.fruit
 * @fileName : FruitController
 * @date : 2/22/24
 * @description :
 * ===========================================================
 * DATE           AUTHOR        NOTE
 * -----------------------------------------------------------
 * 2/22/24       rovert         최초 생성
 */
@RestController
@RequestMapping("/api/v1")
public class FruitController {

    private final FruitService fruitService;

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

    @PostMapping("/fruit")
    public void saveFruitInfo(@RequestBody @Valid SaveFruitInfoRequestDto requestDto) {
        this.fruitService.saveFruitInfo(requestDto);
    }

    @PutMapping("/fruit")
    public void updateFruitInfo(@RequestBody UpdateFruitRequestDto requestDto) {
        this.fruitService.updateFruitInfo(requestDto);
    }
}
  • PUT HTTP method를 이용하여 long 타입의 id가 존재하는 객체 body로 전달

결과
  • 기존의 데이터

image

위와 같이 데이터가 있다고 했을 때, 파인애플이 팔렸다고 해보자. 그러면 포스트맨으로 실습하면 아래와 같다.

 

image

  • DB도 정확히 반영이 완료되었다.

     

image

에러 상황

만약에 3번 id를 접근한다면 어떻게 될까? 한번 포스트맨으로 확인해보자.

image

예상대로 500 에러가 발생했다. 그리고 콘솔도 확인해보자.

 

image

내가 작성한 메세지가 잘 출력 된 것을 확인할 수 있다.

 

테스트 코드

그럼 테스트코드를 작성해보자. 이번 테스트코드는 성공 케이스만 해보자.

기존 테이블의 데이터가 아래와 같이 있다 하자.

 

image

이 때 테스트 코드는 아래와 같다.

@Test
@DisplayName("문제 2번 통합 테스트 - 성공")
void question2_test_fail_caused_by_not_exists_fruit_id() throws Exception {
    UpdateFruitRequestDto requestDto = new UpdateFruitRequestDto(3);

    this.mockMvc.perform(put("/api/v1/fruit")
                    .contentType(MediaType.APPLICATION_JSON)
                    .content(objectMapper.writeValueAsString(requestDto)))
            .andDo(print())
            .andExpect(status().isOk());
}

결과

image

image


문제3

image

문제해결

먼저 문제3에 맞게 데이터를 맞춰본다.

INSERT INTO fruit (name, warehousingDate, price, is_sold) values ("사과", "2024-01-01", 3000, true);
INSERT INTO fruit (name, warehousingDate, price, is_sold) values ("사과", "2024-01-02", 4000, false);
INSERT INTO fruit (name, warehousingDate, price, is_sold) values ("사과", "2024-01-03", 3000, true);

위의 insert 쿼리문을 이용하여 데이터를 넣는다.

 

image

 

1. 응답 DTO 개발

package me.sungbin.dto.fruit.response;

/**
 * @author : rovert
 * @packageName : me.sungbin.dto.fruit.response
 * @fileName : GetFruitResponseDto
 * @date : 2/22/24
 * @description :
 * ===========================================================
 * DATE           AUTHOR        NOTE
 * -----------------------------------------------------------
 * 2/22/24       rovert         최초 생성
 */
public class GetFruitResponseDto {

    private long salesAmount;

    private long notSalesAmount;

    public GetFruitResponseDto(long salesAmount, long notSalesAmount) {
        this.salesAmount = salesAmount;
        this.notSalesAmount = notSalesAmount;
    }

    public long getSalesAmount() {
        return salesAmount;
    }

    public long getNotSalesAmount() {
        return notSalesAmount;
    }
}
  • 요구조건데로 각 필드는 long 타입으로 생성자와 getter를 만들어 두었다.

2. Repository, Repository 구현체 개발

package me.sungbin.repository.fruit;

import me.sungbin.dto.fruit.response.GetFruitResponseDto;
import me.sungbin.entity.fruit.Fruit;

/**
 * @author : rovert
 * @packageName : me.sungbin.repository.fruit
 * @fileName : FruitRepository
 * @date : 2/22/24
 * @description :
 * ===========================================================
 * DATE           AUTHOR        NOTE
 * -----------------------------------------------------------
 * 2/22/24       rovert         최초 생성
 */
public interface FruitRepository {
    void saveFruitInfo(Fruit fruit);

    void updateFruitInfo(long id);

    GetFruitResponseDto getFruitInfo(String name);
}
package me.sungbin.repository.fruit;

import me.sungbin.dto.fruit.response.GetFruitResponseDto;
import me.sungbin.entity.fruit.Fruit;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;

import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
 * @author : rovert
 * @packageName : me.sungbin.repository.fruit
 * @fileName : FruitJdbcRepository
 * @date : 2/22/24
 * @description :
 * ===========================================================
 * DATE           AUTHOR        NOTE
 * -----------------------------------------------------------
 * 2/22/24       rovert         최초 생성
 */
@Repository
public class FruitJdbcRepository implements FruitRepository {

    private final JdbcTemplate jdbcTemplate;

    public FruitJdbcRepository(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    @Override
    public void saveFruitInfo(Fruit fruit) {
        String sql = "INSERT INTO fruit (name, warehousingDate, price) VALUES (?, ?, ?)";

        jdbcTemplate.update(sql, fruit.getName(), fruit.getWarehousingDate(), fruit.getPrice());
    }

    @Override
    public void updateFruitInfo(long id) {
        validate(id);

        String sql = "UPDATE fruit SET is_sold = 1 WHERE id = ?";
        jdbcTemplate.update(sql, id);
    }

    @Override
    public GetFruitResponseDto getFruitInfo(String name) {
        String salesAmountSQL = "SELECT price FROM fruit WHERE name = ? AND is_sold = 1";
        List<Long> salesAmounts = jdbcTemplate.query(salesAmountSQL, new Object[]{name}, (rs, rowNum) -> rs.getLong("price"));
        long salesAmount = salesAmounts.stream().reduce(0L, Long::sum);

        String notSalesAmountSQL = "SELECT price FROM fruit WHERE name = ? AND is_sold = 0";
        List<Long> notSalesAmounts = jdbcTemplate.query(notSalesAmountSQL, new Object[]{name}, (rs, rowNum) -> rs.getLong("price"));
        long notSalesAmount = notSalesAmounts.stream().reduce(0L, Long::sum);

        validateGetFruitAmount(salesAmount, notSalesAmount);

        return new GetFruitResponseDto(salesAmount, notSalesAmount);
    }
    
   /**
     * 과일이 존재하지 않을 때
     * @param salesAmount
     * @param notSalesAmount
     */
    private void validateGetFruitAmount(long salesAmount, long notSalesAmount) {
        if (salesAmount == 0L && notSalesAmount == 0L) {
            throw new IllegalArgumentException("존재하는 과일이 없습니다.");
        }
    }

    /**
     * 존재하지 않는 과일정보가 있을 것을 대비해 DB에 id값으로 조회 후, true false 반환
     * @param id
     * @return
     */
    private boolean isNotExistsFruitInfo(long id) {
        String readSQL = "SELECT * FROM fruit WHERE id = ?";

        return jdbcTemplate.query(readSQL, (rs, rowNum) -> 0, id).isEmpty();
    }

    /**
     * 존재하지 않는 과일정보를 접근할 경우 Exception 발생
     * @param id
     */
    private void validate(long id) {
        if (isNotExistsFruitInfo(id)) {
            throw new IllegalArgumentException("존재하는 과일정보가 없습니다.");
        }
    }
}
  • 팔린 양에 대한 SQL과 팔리지 않는 SQL을 따로 분리하여 나온 각 데이터의 price를 stream API를 이용하여 합친 후, 각각을 응답객체로 전달

  • 또한 각각의 데이터 합이 0인 경우는 과일이 존재하지 않는 것으로 알 수 있어 예외처리

 

3. 서비스 레이어 코드 작성

package me.sungbin.service.fruit;

import me.sungbin.dto.fruit.request.SaveFruitInfoRequestDto;
import me.sungbin.dto.fruit.request.UpdateFruitRequestDto;
import me.sungbin.dto.fruit.response.GetFruitResponseDto;
import me.sungbin.entity.fruit.Fruit;
import me.sungbin.repository.fruit.FruitRepository;
import org.springframework.stereotype.Service;

/**
 * @author : rovert
 * @packageName : me.sungbin.service.fruit
 * @fileName : FruitService
 * @date : 2/22/24
 * @description :
 * ===========================================================
 * DATE           AUTHOR        NOTE
 * -----------------------------------------------------------
 * 2/22/24       rovert         최초 생성
 */
@Service
public class FruitService {

    private final FruitRepository fruitRepository;

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

    public void saveFruitInfo(SaveFruitInfoRequestDto requestDto) {
        Fruit fruit = requestDto.toEntity();

        this.fruitRepository.saveFruitInfo(fruit);
    }

    public void updateFruitInfo(UpdateFruitRequestDto requestDto) {
        this.fruitRepository.updateFruitInfo(requestDto.getId());
    }

    public GetFruitResponseDto calculateSalesAmountAndNotSalesAmount(String name) {
        return this.fruitRepository.getFruitInfo(name);
    }
}

 

  • calculateSalesAmountAndNotSalesAmount 함수는 repository 구현체가 만든 메서드를 컨트롤러 쪽으로 다시 반환한다.

4. Controller 코드 작성

package me.sungbin.controller.fruit;

import jakarta.validation.Valid;
import me.sungbin.dto.fruit.request.SaveFruitInfoRequestDto;
import me.sungbin.dto.fruit.request.UpdateFruitRequestDto;
import me.sungbin.dto.fruit.response.GetFruitResponseDto;
import me.sungbin.service.fruit.FruitService;
import org.springframework.web.bind.annotation.*;

/**
 * @author : rovert
 * @packageName : me.sungbin.controller.fruit
 * @fileName : FruitController
 * @date : 2/22/24
 * @description :
 * ===========================================================
 * DATE           AUTHOR        NOTE
 * -----------------------------------------------------------
 * 2/22/24       rovert         최초 생성
 */
@RestController
@RequestMapping("/api/v1")
public class FruitController {

    private final FruitService fruitService;

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

    @PostMapping("/fruit")
    public void saveFruitInfo(@RequestBody @Valid SaveFruitInfoRequestDto requestDto) {
        this.fruitService.saveFruitInfo(requestDto);
    }

    @PutMapping("/fruit")
    public void updateFruitInfo(@RequestBody UpdateFruitRequestDto requestDto) {
        this.fruitService.updateFruitInfo(requestDto);
    }

    @GetMapping("/fruit/stat")
    public GetFruitResponseDto getFruitInfo(@RequestParam String name) {
        return this.fruitService.calculateSalesAmountAndNotSalesAmount(name);
    }
}
  • 쿼리파라미터는 단순 1개이므로 DTO 형식이 아닌 일반 타입으로 받고 서비스 레이어의 메서드를 실행해서 반환한다.

결과

image

에러

만약에 존재하지 않는 과일을 파라미터로 넘겨주면 어떻게 될까? 테스트해보자. 위에서 예외처리를 해두었으므로 테스트만 해보자.

image

  • 성공적으로 500에러가 잘 나온다.

image

  • 콘솔도 잘 찍히고 정의한 메세지도 잘 출력이 된다.

 

더 나아가기

SQL의 SUMGROUP BY 키워드를 적용해보라는 미션이 추가적으로 있다.

미션을 수행하기 전에 각각의 키워드가 무엇인지 찾아봤다.

 

SUM: 집계함수로, 총 합계를 구해주는 키워드

GROUP BY: 집계함수의 결과를 특정 컬럼을 기준으로 묶어 결과를 출력해주는 쿼리

 

그럼 이제 Repository 구현 코드를 변경해보자.

 

@Override
    public GetFruitResponseDto getFruitInfo(String name) {
        long salesAmount = 0;
        long notSalesAmount = 0;

        String sql = "SELECT SUM(price) as total, is_sold FROM fruit WHERE name = ? GROUP BY is_sold";

        Map<Boolean, Long> aggregatedData = jdbcTemplate.query(sql, new Object[]{name}, rs -> {
            HashMap<Boolean, Long> map = new HashMap<>();
            while (rs.next()) {
                map.put(rs.getBoolean("is_sold"), rs.getLong("total"));
            }
            return map;
        });

        if (aggregatedData.containsKey(true)) {
            salesAmount = aggregatedData.get(true);
        }
        if (aggregatedData.containsKey(false)) {
            notSalesAmount = aggregatedData.get(false);
        }

        validateGetFruitAmount(salesAmount, notSalesAmount);

        return new GetFruitResponseDto(salesAmount, notSalesAmount);
    }

 

이렇게 작성하니 쿼리가 정말 간단하게 나왔다. 결과는 위와 동일했다.

집계함수는 SUM외에도 여러가지 있으니 추후에 찾아봐야겠다. 더욱 열심히 해보자! 🔥

 

테스트 코드

테스트 코드를 작성해보자. 이번에도 성공하는 경우만 작성해보겠다.

@Test
@DisplayName("문제 3번 통합 테스트 - 성공")
void question3_test_success() throws Exception {
    this.mockMvc.perform(get("/api/v1/fruit/stat")
                    .param("name", "사과"))
            .andDo(print())
            .andExpect(status().isOk());
}
결과

image



📚 참고

https://inf.run/XKQg)

 

댓글을 작성해보세요.