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

[인프런 워밍업 스터디 클럽] 0기 - 백엔드 과제 #7

[인프런 워밍업 스터디 클럽] 0기 - 백엔드 과제 #7

JPA 이용해서 API 만들기

이 글은 자바와 스프링 부트로 생애 최초 서버 만들기, 누구나 쉽게 개발부터 배포까지! [서버 개발 올인원 패키지]를 수강하고 인프런 워밍업 클럽에 참여하여 쓰는 첫번째 회고록입니다.

문제 1

과제 #6의 Fruit 기능들을 JPA를 이용하게끔 변경해보자.

  1. application.yml에 jpa 설정 추가

  jpa:
    hibernate:
      ddl-auto: none
    properties:
      hibernate:
        show_sql: true
        format_sql: true
        dialect: org.hibernate.dialect.MySQL8Dialect
  1. domain에 있던 Fruit을 @Entity annotation 붙혀주기

@Entity
public class Fruit {
​
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private long id;
​
    @Column(nullable = false)
    private String name;
    private LocalDate warehousing_date;
    private long price;
    public boolean is_sold;
​
    protected Fruit() {} // protected로 기본생성자 생성
​
    public Fruit(long id, String name, LocalDate warehousing_date, long price) {
        this.id = id;
        this.name = name;
        this.warehousing_date = warehousing_date;
        this.price = price;
        this.is_sold = false;
    }
​
    public long getId() {
        return id;
    }
​
    public String getName() {
        return name;
    }
​
    public LocalDate getWarehousing_date() {
        return warehousing_date;
    }
​
    public long getPrice() {
        return price;
    }
​
    public boolean is_sold() {
        return is_sold;
    }
}
  1. FruitJpaRepository interface 만들기

public interface FruitJpaRepository extends JpaRepository<Fruit, Long> {}
  1. FruitService 새로 만들기

  • saveFruit(FruitCreateRequest request)

@Override
public void saveFruit(FruitCreateRequest request) {
    String sql = "INSERT INTO fruit(name, warehousing_date, price) VALUES (?, ?, ?)";
    jdbcTemplate.update(sql, request.getName(), request.getDate(), request.getPrice());
}
public void saveFruit(FruitCreateRequest request) {
    fruitRepository.save(new Fruit(request.getName(), request.getDate(), request.getPrice()));
}
  • FruitReadSalesAmountRespond getSalesFruitAmount(String name)

    • 내가 서칭을 해본 결과 현재 이해하는 방법이 두가지가 있었다.

      • 직접 쿼리 사용(Native Query)

      • Java Stream 사용

    • JPA는 데이터베이스 종속성을 피하고, 좀 더 객체지향적인 코드(확장성, 유지보수성을 고려하는 코드)를 짜기 위한 방법이므로 Java Stream을 써서 해결했다.

    • 그리고 마침 어제 메모리에 저장하는 코드를 작성할 때 Stream으로 만들어 놨어서 작성하기 수월했다.

@Override
public FruitReadSalesAmountRespond getSalesFruitAmount(String name) {
    String salesAmountSql = "SELECT SUM(price) FROM fruit WHERE name = ? GROUP BY is_sold";
    List<Long> salesAmounts = jdbcTemplate.query(salesAmountSql, (rs, rowNum) -> rs.getLong(1), name);
​
    Long salesAmount = salesAmounts.get(0);
    Long notSalesAmount = salesAmounts.get(1);
​
    return new FruitReadSalesAmountRespond(salesAmount, notSalesAmount);
}

 

  • 먼저, FruitJpaRepository에 List<Fruit>타입의 findByName을 선언해준다.

    public interface FruitJpaRepository extends JpaRepository<Fruit, Long> {
    ​
        List<Fruit> findByName(String name);
    ​
    }

     

    그리고, FruitServieV2에 다음 코드를 작성한다.

    public FruitReadSalesAmountRespond getSalesFruitAmount(String name) {
            List<Fruit> fruits = fruitRepository.findByName(name);
    ​
            Long salesAmount = fruits.stream()
                    .filter(Fruit::isSold)
                    .mapToLong(Fruit::getPrice)
                    .sum();
    ​
            Long notSalesAmount = fruits.stream()
                    .filter(fruit -> !fruit.isSold())
                    .mapToLong(Fruit::getPrice)
                    .sum();
    ​
            return new FruitReadSalesAmountRespond(salesAmount, notSalesAmount);
    }

    계산하는 로직을 공통으로 묶어 리팩토링 하면 다음과 같다.

    public FruitReadSalesAmountRespond getSalesFruitAmount(String name) {
            List<Fruit> fruits = fruitRepository.findByName(name);
    ​
            Long salesAmount = calculateAmount(fruits, true);
            Long notSalesAmount = calculateAmount(fruits, false);
    ​
            return new FruitReadSalesAmountRespond(salesAmount, notSalesAmount);
        }
    ​
        private Long calculateAmount(List<Fruit> fruits, boolean isSold) {
            return fruits.stream()
                         .filter(fruit -> fruit.isSold() == isSold)
                         .mapToLong(Fruit::getPrice)
                         .sum();
        }
  • updateSoldFruitInformation(Long id)

public void updateSoldFruitInformation(Long id) {
        if (fruitRepository.isNotExistFruit(id)) {
            throw new IllegalArgumentException();
        }
​
        fruitRepository.updateFruit(id);
    }

update메서드가 따로 없기 때문에 Fruit 도메인에 updateSoldInformation 메서드를 하나 만든다.

public void updateSoldInformation() { // Fruit class에 추가
        this.isSold = true;
    }
public void updateSoldFruitInformation(Long id) {
        Fruit fruit = fruitRepository.findById(id)
                .orElseThrow(IllegalAccessError::new);
​
        fruit.updateSoldInformation();
        fruitRepository.save(fruit);
    }

최종코드는 다음과 같다.

@Service
public class FruitServiceV2 {
​
    private final FruitJpaRepository fruitRepository;
​
    public FruitServiceV2(FruitJpaRepository fruitRepository) {
        this.fruitRepository = fruitRepository;
    }
​
    public void saveFruit(FruitCreateRequest request) {
        fruitRepository.save(new Fruit(request.getName(), request.getDate(), request.getPrice()));
    }
​
    public FruitReadSalesAmountRespond getSalesFruitAmount(String name) {
        List<Fruit> fruits = fruitRepository.findByName(name);
​
        Long salesAmount = calculateAmount(fruits, true);
        Long notSalesAmount = calculateAmount(fruits, false);
​
        return new FruitReadSalesAmountRespond(salesAmount, notSalesAmount);
    }
​
    public void updateSoldFruitInformation(Long id) {
        Fruit fruit = fruitRepository.findById(id)
                .orElseThrow(IllegalAccessError::new);
​
        fruit.updateSoldInformation();
        fruitRepository.save(fruit);
    }
​
    private Long calculateAmount(List<Fruit> fruits, boolean isSold) {
        return fruits.stream()
                .filter(fruit -> fruit.isSold() == isSold)
                .mapToLong(Fruit::getPrice)
                .sum();
    }
    
}
  • 테스트

image

image

image

image

image

id가 2인 사과의 is_sold가 0에서 1로 바뀐 것을 확인할 수 있다.

문제2

특정 과일을 기준으로 판매된 과일 갯수를 세는 api 구현

HTTP spec

  • HTTP method : GET

  • HTTP path : /api/v1/fruit/count

  • HTTP query

    • name : 과일 이름

  • 예시 GET /api/v1/fruit/count?name=사과

  • HTTP 응답 Body

{
    "count": long
}
  1. FruitController

spec에 명시된 내용을 그대로 코드에 옮기면 된다.

@GetMapping("/fruit/count") // method, path
public Long getSoldFruitCount(String name) { // 요청 query, 반환타입(Long)
    return fruitServiceV2.getSoldFruitCount(name); // 반환값
}
  1. FruitServiceV2

문제를 한국어 문장으로 풀어보면 결국 다음과 같다.

요청한 쿼리의 name과 repository에 있는 name이 일치하는 과일 중에

팔린(isSold가 true인) 것들의 갯수를 반환

  • 반환 : 타입(Long), 값 return

  • 요청한 쿼리의 name과 repository에 있는 name이 일치하는 과일 중: fruitRepository.findByName(name).stream()

  • 팔린(isSold가 true인) 것들:

    .filter(fruit -> fruit.isSold == true)

  • 의 갯수:

    .count()

public Long getSoldFruitCount(String name) {
    return fruitRepository.findByName(name).stream()
            .filter(fruit -> fruit.isSold == true)
            .count();
}
  1. 테스트

image

팔린 '낑깡'은 1개이다.

image실제로 요청을 하면 1이 반환되는 것을 알 수 있다.

하.지.만 실제 문제를 보면

{
    "count": long
}

이렇게 json형태로 응답을 요구했다. 따라서, respond객체를 만들고 반환 타입을 이 객체 타입으로 바꾸면 된다.

  1. (수정)FruitController

@GetMapping("/fruit/count")
public FruitSoldCountRespond getSoldFruitCount(String name) {
    return fruitServiceV2.getSoldFruitCount(name);
}

  1. (수정)FruitServiceV2

public FruitSoldCountRespond getSoldFruitCount(String name) {
        Long count = fruitRepository.findByName(name).stream()
                .filter(fruit -> fruit.isSold == true)
                .count();
        return new FruitSoldCountRespond(count);
    }
  1. 새로 만든 respond 객체

public class FruitSoldCountRespond {
​
    private final Long count;
​
    public FruitSoldCountRespond(Long count) {
        this.count = count;
    }
​
    public Long getCount() {
        return count;
    }
}
​
  1. 테스트

image

  1. 문제

  • 객체를 새로 담아서 응답해야 하는데 그냥 무지성으로 Fruit Entity에다가 count variable을 만들어줬었다. 그 때 에러는 다음과 같다.

image

500에러는 내부 서버문제니 ide에서 확인이 가능하다.

image

SQLSyntaxErrorException 에러가 났다.

image

f1_0.count가 Unknown column이라고 한다.

application.yml에서 설정했던걸 활용했다. 찍힌 값을 확인해보니 다음과 같았다.

image

즉, Fruit Entity는 실제 테이블과 매칭이 되는 건데 실제 테이블에는 count라는 column이 없어서 난 에러였다. 생각을 안해서 생긴 에러지만, 한 에러부분을 발견할 수 있었다. 

  1. 문제(2)

image

응답객체를 만들어서 반환해줬는데 위와 같이 406에러코드가 반환되었다.

Spring에서 Content-Type에 선언된 형식으로 변환이 불가능할 경우 406에러가 발생하며, 어떤 요청을 받았는지 또는 어떤 응답을 보내야하는지에 따라 사용하는 HttpMessageConverter가 달라진다.

JSON 요청이고 JSON 본문이 들어올 경우(Content-type이 JSON) JsonMessageConverter가 사용되서 요청 JSON 메세지를 User 객체로 변경하며 이 때 자바 빈 규약에 따른 프로퍼티 바인딩 (Getter/Setter)가 발생하며 Getter를 생략하였기 때문에 406에러가 발생하는 것이라고 한다.

[참고]

@ResponseBody 사용할 때 객체에 getter가 없을 경우

https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/http/converter/HttpMessageConverter.html

HttpMessageConverter는 어디에서 발견되는것인지 궁금하여 찾아보았다. @RequestBody의 javadoc에 친절하게 설명되어 있었다.

image

Annotation indicating a method parameter should be bound to the body of the web request. The body of the request is passed through an HttpMessageConverter to resolve the method argument depending on the content type of the request. Optionally, automatic validation can be applied by annotating the argument with @Valid.

메서드 파라미터를 가리키는 어노테이션은 웹 요청의 바디에 바운드되어야 한다. 요청의 바디는 요청의 content type에 의존하는 메서드 인자를 찾기위해 HttpMessageConverter를 지나간다.

문제 3

판매되지 않은 특정 금액 이상 혹은 특정 금액 이하의 과일 목록 받는 GET api 구현

HTTP spec

  • HTTP method : GET

  • HTTP path : /api/v1/fruit/list

  • HTTP query

    • option : "GTE" 혹은 "LTE" 라는 문자열이 들어온다.

      • GET : greather than equal

      • LTE : less than equal

    • price : 기준이 되는 금액이 들어온다.

  • 예시 1 - GET /api/v1/fruit/list?option=GTE&price=3000

    • 판매되지 않은 3000원 이상의 과일 목록을 반환해야 한다.

  • 예시 2 - GET /api/v1/fruit/list?option=LTE&price=5000

    • 판매되지 않은 5000원 이하의 과일 목록을 반환해야 한다.

  • HTTP 응답 Body

[{
    "name": String,
    "price": long,
    "warehousingDate": LocalDate
}, ...]
  1. FruitController

@GetMapping("/fruit/list") // method, path
public List<FruitsSpecificOptionPriceRespond> getSpecificOptionPriceFruits(@RequestParam String option, @RequestParam Long price) { // query, 반환 타입
    // name, price, warehousingDate
    return fruitServiceV2.getSpecificOptionPriceFruits(option, price); // 반환값
}
  1. FruitServiceV2

public List<FruitsSpecificOptionPriceRespond> getSpecificOptionPriceFruits(String option, Long price) {
        // GTE : select * from fruit where price >= ? and is_sold = false
        // LTE : select * from fruit where price <= ? and is_sold = false
​
        if (isNotSpecificPriceOption(option)) {
            throw new IllegalArgumentException();
        }
​
        if ("GTE".equals(option)) {
            return fruitRepository.findByPriceGreaterThanEqualAndIsSold(price, false)
                    .stream()
                    .map(FruitsSpecificOptionPriceRespond::new)
                    .collect(Collectors.toList());
        } else {
            return fruitRepository.findByPriceLessThanEqualAndIsSold(price, false)
                    .stream()
                    .map(FruitsSpecificOptionPriceRespond::new)
                    .collect(Collectors.toList());
        }
    }
​
    private boolean isNotSpecificPriceOption(String option) { // 에러 핸들링 메서드 따로 분리
        return !("GTE".equals(option) || "LTE".equals(option));
    }
  1. refactoring

if-else 구문에서 "LTE"의 조건이 명확하게 표시가 안되서 조금 헷갈리게 할 수도 있다. else를 제거해보자.

public List<FruitsSpecificOptionPriceRespond> getSpecificOptionPriceFruits(String option, Long price) {
        if (isNotSpecificPriceOption(option)) {
            throw new IllegalArgumentException();
        }
​
        if ("GTE".equals(option)) {
            return fruitRepository.findByPriceGreaterThanEqualAndIsSold(price, false)
                    .stream()
                    .map(FruitsSpecificOptionPriceRespond::new)
                    .collect(Collectors.toList());
        }
​
        if ("LTE".equals(option)) {
            return fruitRepository.findByPriceLessThanEqualAndIsSold(price, false)
                    .stream()
                    .map(FruitsSpecificOptionPriceRespond::new)
                    .collect(Collectors.toList());
        }
    }

각각의 조건문도 메서드로 따로 빼자.

public List<FruitsSpecificOptionPriceRespond> getSpecificOptionPriceFruits(String option, Long price) {
        if (isNotSpecificPriceOption(option)) {
            throw new IllegalArgumentException();
        }
​
        if (isGTE(option)) {
            return fruitRepository.findByPriceGreaterThanEqualAndIsSold(price, false)
                    .stream()
                    .map(FruitsSpecificOptionPriceRespond::new)
                    .collect(Collectors.toList());
        }
​
        if (isLTE(option)) {
            return fruitRepository.findByPriceLessThanEqualAndIsSold(price, false)
                    .stream()
                    .map(FruitsSpecificOptionPriceRespond::new)
                    .collect(Collectors.toList());
        }
    }
​
    private boolean isNotSpecificPriceOption(String option) {
        return !("GTE".equals(option) || "LTE".equals(option));
    }
​
    private boolean isGTE(String option) {
        return "GTE".equals(option);
    }
​
    private boolean isLTE(String option) {
        return "LTE".equals(option);
    }

그러나, getSpecificOptionPriceFruits메서드의 분기처리를 제대로 하지 않아서 컴파일 에러가 난다. 그래서 아래와 같이 List<Fruit> fruits 를 따로 선언해준다. 그리고 if문 return값이 공통인 부분을 따로 메서드로 빼서 만들어주자.

public List<FruitsSpecificOptionPriceRespond> getSpecificOptionPriceFruits(String option, Long price) {
        if (isNotSpecificPriceOption(option)) {
            throw new IllegalArgumentException();
        }
​
        List<Fruit> fruits = new ArrayList<>();
​
        if (isGTE(option)) {
            fruits =  fruitRepository.findByPriceGreaterThanEqualAndIsSold(price, false);
        }
​
        if (isLTE(option)) {
            fruits =  fruitRepository.findByPriceLessThanEqualAndIsSold(price, false);
        }
​
        return convertToFruitsSpecificOptionPriceRespond(fruits);
    }

댓글을 작성해보세요.

  • 임형준
    임형준

    2/28 [14:26] 2차 수정

  • 임형준
    임형준

    2/28 [01:48] 수정

채널톡 아이콘