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

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

본 내용은 자바와 스프링 부트로 생애 최초 서버 만들기, 누구나 쉽게 개발부터 배포까지! [서버 개발 올인원 패키지] 를 수강하고 쓴 글입니다.

 

2일차에 GET API와 POST API에 대해서 배웠습니다. 하지만, 문제가 생겼습니다. 바로 서버를 종료하면 메모리에 저장되었던 데이터들이 사라지게 된다는 점입니다.

 

3일차에서는 이를 해결하기 위해 데이터베이스를 사용하게 됩니다. 메모리에 저장했던 방식을 데이터베이스의 디스크에 저장하는 방식으로 바꾸면 데이터들은 서버를 종료해도 계속 남아있게 됩니다.

 

4일차에서는 더 나아가 UPDATE API와 DELETE API에 대해서 배웠습니다. 하지만 또 문제가 발생했습니다. 기존에 존재하지 않은 데이터를 삭제하거나 수정하면 HTTP status code가 '200 ok'로 나온다는 점입니다. 이를 방지하기 위해 예외처리를 하였습니다.

 

[과제에서 사용한 tool]

운영체제 : MacBook Air M1, 2020

Java : 17.0.9-amzn

springboot : 3.2.2

MySql : mysql ver. 8.0.25

IDE : IntelliJ Ultimate

API test : HTTPie

 

과제


목표

주어진 요구사항들을 구현하면서 API개발에 익숙해지기

 

문제 1

우리는 작은 과일 가게를 운영하고 있습니다. 과일 가게에 입고된 "과일 정보"를 저장하는 API를 만들어 봅시다.

[스펙]

  • HTTP method : POST

  • HTTP path : /api/v1/fruit

  • HTTP 요청 body

{
  "name": String, 

  "warehousingDate": LocalDate,

  "price": long
}
  • 응답 : 성공시 HTTP status code 200

[설계]

  • HTTP method 가 POST : @RestController -> @PostMapping

  • HTTP path 가 /api/v1/fruit : @RequestMapping("/api/v1") -> @PostMapping("/fruit")

  • 반환타입 : 단지 상태코드만 반환되면 된다 -> void

  • 메서드명 : 과일 정보를 저장 -> createFruit()

설계를 바탕으로 api를 구현하면 아래와 같다.

 

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

    private final JdbcTemplate jdbcTemplate;

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

    @PostMapping("/fruit")
    public void createFruit(@RequestBody FruitCreaeteRequest request) {
        String sql = "INSERT INTO fruit(name, warehousing_date, price) VALUES (?, ?, ?)";
        jdbcTemplate.update(sql, request.getName(), request.getDate(), request.getPrice());
    }
}
public class FruitCreateRequest {
    private long id;
    private String name;
    private LocalDate date;
    private long price;

    public long getId() {
        return id;
    }

    public String getName() {
        return name;
    }

    public LocalDate getDate() {
        return date;
    }

    public long getPrice() {
        return price;
    }
}

터미널에 다음과 같이 입력한 후 결과

$ http POST localhost:8080/api/v1/fruit name=딸기 date=2024-02-02 price=5000

image

 

문제 2

과일이 팔리게 되면, 우리 시스템에 팔린 과일 정보를 기록해야 합니다.

[스펙]

  • HTTP method : PUT

  • HTTP path : /api/v1/fruit

  • HTTP 요청 body

{
  "id" : long
}

[설계]

  • HTTP method 가 PUT : @RestController -> @PutMapping

  • HTTP path 가 /api/v1/fruit : @RequestMapping("/api/v1") -> @PutMapping("/fruit")

  • 반환타입 : 단지 상태코드만 반환되면 된다 -> void

  • 메서드명 : 과일 정보를 업데이트 -> updateFruitInformation()

  • 팔린 과일 정보 : 팔렸는지 안팔렸는지 상태를 확인해줄 수 있는 데이터 컬럼 추가 -> is_sold

     

  • is_sold 컬럼이 추가 -> FruitUpdateRequest를 생성하고 isSold 멤버변수 추가

ALTER TABLE fruit
    ADD is_sold boolean DEFAULT FALSE;

-- 참고 https://dev.mysql.com/doc/refman/8.0/en/alter-table-generated-columns.html
public class FruitUpdateRequest {
    long id;
    boolean isSold;

    public long getId() {
        return id;
    }

    public boolean isSold() {
        return isSold;
    }
}
@PutMapping("/fruit")
public void updateFruitInformation(@RequestBody FruitUpdateRequest request) {
    String sql = "UPDATE fruit SET is_sold = ? WHERE id = ?";
    jdbcTemplate.update(sql, request.isSold(), request.getId());
}

위에서 입력했던 터미널 명령어와 비슷하게 요번에 PUT으로 입력하면,

$ http -v PUT localhost:8080/api/v1/fruit id=3 isSold=TRUE

image

이어서 데이터베이스에서 확인을 해봤더니 다음과 같았다.

image

생성자를 안만들어줬더니 status code는 200이 뜨지만 실제로는 바뀌지 않았다는 걸 알 수 있었다. 생성자를 만들고 다시 위와 같은 실행 과정을 반복해보자.

public class FruitUpdateRequest {
    long id;
    boolean isSold;

    // 추가
    public FruitUpdateRequest(long id, boolean isSold) {
        this.id = id;
        this.isSold = isSold;
    }

    public long getId() {
        return id;
    }

    public boolean isSold() {
        return isSold;
    }
}
$ http -v PUT localhost:8080/api/v1/fruit id=3 isSold=TRUE

image

image

아까는 0이었던 게 1로 바뀌었다. 그런데 왜 true false가 아니고 0, 1로 표시될까?

mysql 메뉴얼

image

TINYINT(1)와 동의어라고 한다.

 

하지만, 아직 남아있는게 많다. 주어진 요구사항은 "id"만 JSON형태로 반환하는 것이다. 그리고 예외처리도 해주어야 한다.

  • 메서드명 : updateSoldFruitInformation() 으로 변경

  • sql 쿼리 : UPDATE fruit SET is_sold = 1 WHERE id = ? 으로 변경

  • 예외 처리 로직 작성

@PutMapping("/fruit")
public void updateSoldFruitInformation(@RequestBody FruitUpdateRequest request) {
    String readSql = "SELECT * FROM fruit WHERE id = ?";
    boolean isEmpty = jdbcTemplate.query(readSql, (rs, rowNum) -> 0, request.getId()).isEmpty();
    if (isEmpty) {
        throw new IllegalArgumentException();
    }
    String sql = "UPDATE fruit SET is_sold = 1 WHERE id = ?";
    jdbcTemplate.update(sql, request.getId());
}
$ http -v PUT localhost:8080/api/v1/fruit id=4

image

문제 3

특정 과일을 기준을 팔린 금액, 팔리지 않은 금액을 조회하고 싶습니다.

예를 들어

(1, 사과, 3000원, 판매 O)

(2, 사과, 4000원, 판매 X)

(3, 사과, 3000원, 판매 O)

와 같은 세 데이터가 있다면 우리의 API는 판매된 금액 : 6000원, 판매되지 않은 금액 4000원 이라고 응답해야 합니다.

 

[스펙]

  • HTTP method : GET

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

  • HTTP query 

     

    • name : 과일 이름

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

  • HTTP 응답 Body 예시

{
  "salesAmount": 6000, 
  "notSalesAmount": 4000
  }

 

[설계]

  • HTTP method 가 GET : @RestController -> @GetMapping

  • HTTP path 가 /api/v1/fruit/stat : @RequestMapping("/api/v1") -> @GetMapping("/fruit/stat")

  • 반환타입 : JSON -> FruitReadSalesAmountRespond

  • 메서드명 : 팔리거나 안필린 금액 합계 -> readSalesFruitAmount()

     

     

image

image

public class FruitReadSalesAmountRespond {

    private long salesAmount;
    private long notSalesAmount;

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

    public long getSalesAmount() {
        return salesAmount;
    }

    public long getNotSalesAmount() {
        return notSalesAmount;
    }
}
private long salesAmount;
private long notSalesAmount;

@GetMapping("/fruit/stat")
public FruitReadSalesAmountRespond readSalesFruitAmount(@RequestParam String name) {
    String sql = "SELECT * FROM fruit WHERE name = ?";
    List<FruitReadSalesAmountRespond> query = jdbcTemplate.query(sql, (rs, rowNum) -> {
        long price = rs.getLong("price");
        boolean isSold = rs.getBoolean("is_sold");
        if (isSold) {
            salesAmount += price;
        }

        if (!isSold) {
            notSalesAmount += price;
        }

        return new FruitReadSalesAmountRespond(salesAmount, notSalesAmount);
    }, name);
    return new FruitReadSalesAmountRespond(query.get(query.size() - 1).getSalesAmount(), query.get(query.size() - 1).getNotSalesAmount());
}
$ http -v GET ":8080/api/v1/fruit/stat?name=사과"

image

1차적으로는 구현을 했다. 하지만, 이 메서드에서만 쓰이는 salesAmount와 notSalesAmount를 FruitController의 멤버변수에 선언하는 것은 컨트롤러의 규모가 늘어날 때마다 신경을 써줘야 하는 부분이 있다. 가독성 부분에서도 뜬금없이 튀어나와서 좋지 못하다. 

WHERE절에 AND를 써서 리팩터링 해보자.

@GetMapping("/fruit/stat")
public FruitReadSalesAmountRespond readSalesFruitAmount(@RequestParam String name) {

    long salesAmount = 0;
    long notSalesAmount = 0;

    String salesAmountSql = "SELECT * FROM fruit WHERE name = ? AND is_sold = 1";
    List<Long> salesPrices = jdbcTemplate.query(salesAmountSql, (rs, rowNum) -> rs.getLong("price"), name);

    String notSalesAmountSql = "SELECT * FROM fruit WHERE name = ? AND is_sold = 0";
    List<Long> notSalesPrices = jdbcTemplate.query(notSalesAmountSql, (rs, rowNum) -> rs.getLong("price"), name);

    for (Long salesPrice : salesPrices) {
        salesAmount += salesPrice;
    }

    for (Long notSalesPrice : notSalesPrices) {
        notSalesAmount += notSalesPrice;
    }

    return new FruitReadSalesAmountRespond(salesAmount, notSalesAmount);
}

 위와 같이 지역변수로 선언할 수 있게 되었다. 하지만, 더해지는 과정을 코드로 계속 더하는거 보다 좋은 방법이 없을까? sql의 집계함수를 이용하면 된다.

다음과 같은 쿼리를 날려보니 아래와 같은 결과가 나온다.

imageimage즉, 첫번째가 판매된 사과들의 가격의 합, 두번째가 판매되지 않은 사과들의 가격의 합이다. 이걸 코드에 적용해보면 다음과 같다.

@GetMapping("/fruit/stat")
public FruitReadSalesAmountRespond readSalesFruitAmount(@RequestParam 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);
}

 

댓글을 작성해보세요.

채널톡 아이콘