인프런 워밍업 클럽 BE - 4일차 과제

진도표 4일차와 연결됩니다

우리는 GET API와 POST API를 만드는 방법을 배웠습니다. 👍 추가적인 API 들을 만들어 보며 API 개발에 익숙해져 봅시다!


우선 강의에 따라 h2 database를 쓰지 않고 mysql db와 연동하였다. 이를 위해 build.gradle 파일에 의존성을 추가하였다.

 

dependencies { 
  ...
  // 추가
  runtimeOnly 'mysql:mysql-connector-java:8.0.32'
}

버전을 명시하지 않고 dependencies를 추가했더니 오류가 발생하였다. 그래서 Maven Repository에서 최신 버전을 찾아 명시하였더니 해결되었다. (원래 버전이 없어도 되는건줄 알았는데 스프링 버전의 차이에 따라서 다른걸까.. ?)

Fruit Database 생성 및 테이블 생성

-- mysql dependencies 추가 (버전 명시하지 않은 경우 오류)
​
create database fruit;
​
create table fruit(
    id bigint auto_increment,
    name varchar(45),
    warehousingDate timestamp,
    price bigint,
    primary key (id)
)

Request 및 Response를 위한 기본 FruitDto 생성

테이블에 데이터를 저장하는 것은 응답값이 없으므로 response에서 DTO로 가져올 필요가 없어 getter만 추가하였다.

// FruitDto
​
package com.example.libraryapp.dto.fruit.request;
​
import java.time.LocalDate;
​
public class FruitDto {
    private String name;
    private LocalDate warehousingDate;
    private long price;
    
    public String getName() {
        return name;
    }
​
    public LocalDate getWarehousingDate() {
        return warehousingDate;
    }
​
    public long getPrice() {
        return price;
    }
}

FruitController 구현

@RestController
@RequestMapping("/api/v1")
public class FruitController {
​
    private final JdbcTemplate jdbcTemplate;
​
    public FruitController(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }
​
    @PostMapping("/fruit")
    public void saveFruit(@RequestBody FruitDto request) {
        String sql = "insert into fruit (name, warehousingDate, price) values (?, ?, ?)";
        jdbcTemplate.update(sql, request.getName(), request.getWarehousingDate(), request.getPrice());
    }
}

테이블에 정보가 저장되는 것과 200 ok 응답을 확인할 수 있다.

Int 대신 long을 사용한 이유는 무엇일까?

int는 -2.1억~2.1억 까지의 정수값을 가질 수 있다 (-2^31~2^31-1)

long은 -922경~922경 까지의 정수값을 가질 수 있다. (-2^63~2^63-1)

id 같은 경우는, 행이 점점 늘어남에 따라 int 타입의 범위를 벗어나는 상황이 발생할 수 있으므로, 애플리케이션의 확장성을 고려하여 초기 단계부터 long 타입을 관행처럼 사용하고 있다. 또한, price와 같은 경우에도 더 큰 범위를 필요로 하는 상황이 발생할 수 있으므로 (ex. 50년 동안의 전체 판매 금액, 아파트 가격 등) 사용할 수 있는 값이 제한적인 int 타입을 사용하는 것 보다 long 타입을 사용하는 것이 더 적합할 수 있다.


판매된 과일 정보를 관리하기 위해서 soldout 이라는 컬럼을 Boolean 타입으로 추가해준다.

또한, 기본적으로는 판매되지 않았으므로 default값은 false로 주었다.

alter table fruit add column soldout boolean not null default false;

이후 Controller에 sellFruit 함수를 추가해준다.

    @PutMapping("/fruit")
    public void sellFruit(@RequestBody SellFruitRequest request) {
        String sql = "UPDATE fruit SET soldout = true WHERE id = ?";
        jdbcTemplate.update(sql, request.getId());
    }

여기서, request parameter는 1개이긴 하지만 @RequestBody로 넘겨야하므로 sellFruit을 위한 DTO 클래스를 새로 생성하였다.

// SellFruitRequest
public class SellFruitRequest {
    private long id;
​
    public long getId() {
        return id;
    }
}


이제 과일 이름으로 조회하여 팔린 금액과 팔리지 않은 금액을 출력해야한다. 이를 위해 ResponseDto를 한 개 생성해줬다.

public class FruitStatResponse {
    private long salesAmount = 0L;
    private long notSalesAmount = 0L;
​
    public FruitStatResponse(long salesAmount, long notSalesAmount) {
        this.salesAmount = salesAmount;
        this.notSalesAmount = notSalesAmount;
    }
​
    public FruitStatResponse() {
        
    }
​
    public long getSalesAmount() {
        return salesAmount;
    }
​
    public long getNotSalesAmount() {
        return notSalesAmount;
    }
​
    public void setSalesAmount(long salesAmount) {
        this.salesAmount = salesAmount;
    }
​
    public void setNotSalesAmount(long notSalesAmount) {
        this.notSalesAmount = notSalesAmount;
    }
}

단 하나도 팔리지 않았거나, 모두 팔린 경우를 대비하여 각각의 변수에 0L로 디폴트 값을 설정해주었다.

이후, 컨트롤러 부분을 정의하였는데 단일 값을 출력할 것이므로 query 가 아닌 queryForObject 를 사용했다.

queryForObject: sql의 결과가 단 하나인 경우에 사용한다.

    @GetMapping("/fruit/stat")
    public FruitStatResponse showFruitStat(@RequestParam String name) {
        String salesAmountSql = "SELECT SUM(price) AS sales_amount FROM fruit WHERE name = ? and soldout = true";
        long salesAmount = jdbcTemplate.queryForObject(salesAmountSql, Long.class, name);
​
        String notSalesAmountSql = "SELECT SUM(price) AS sales_amount FROM fruit WHERE name = ? and soldout = false";
        long notSalesAmount = jdbcTemplate.queryForObject(notSalesAmountSql, Long.class, name);
​
        return new FruitStatResponse(salesAmount, notSalesAmount);
    }

이 경우, Long.class 로 리턴하기 때문에 null 값으로 인한 NullPointerException이 등장할 수 있는데, 이 경우 핸들링을 위해 try-catch 문으로 감싸서 데이터가 없는 경우를 핸들링 해주었다.

    @GetMapping("/fruit/stat")
    public FruitStatResponse showFruitStat(@RequestParam String name) {
        try {
            String salesAmountSql = "SELECT SUM(price) FROM fruit WHERE name = ? and soldout = true";
            long salesAmount = jdbcTemplate.queryForObject(salesAmountSql, Long.class, name);
​
            String notSalesAmountSql = "SELECT SUM(price) FROM fruit WHERE name = ? and soldout = false";
            long notSalesAmount = jdbcTemplate.queryForObject(notSalesAmountSql, Long.class, name);
​
            return new FruitStatResponse(salesAmount, notSalesAmount);
        } catch (Exception e) {
            throw e;
        }
    }

3번을 다 풀고보니 이런 내용이 있었다.

왜 sum은 적용하려고 하였으나 group by는 적용하려 하지 않았는가 ..

잠시 뇌가 멈췄었나보다. 이제 group by로 다시 적용을 해보자. 그럼 query를 통해서 바로 적용이 가능할것 같다.

강의에서는 정보를 조회해서 결과로 뿌릴때, List<responseDto> 와 같은 형태로 사용했으므로 jdbcTemplate.query 를 사용해서 RowMapper<responseDto> 익명클래스를 선언하고 mapRow 메서드를 오버라이드 해주었다.

하지만, 이번 케이스에서는 queryRowMapper를 사용할 수 없었다. 왜냐하면, RowMapper는 여러 행을 객체 리스트로 변환할 때 사용되는 클래스이기 때문이다. 이 경우, 리턴값이 List 형식이 아니라 단일한 객체 한 개 리턴이 필요하기 때문에, RowMapper가 아닌 ResultSetExtractor를 사용했다.

그럼 여기서, ResultSetExtractor는 무엇일까?

ResultSetExtractor

query 메소드를 ResultSetExtractor와 함께 사용할 때, 전체 ResultSet을 단일 객체로 추출할 때 사용된다.

정확히 우리가 하려는 목적과 일치한다. query의 resultSet을 단일객체로 추출하려면 ResultSetExtractor를 사용하면 되는것이다.

RowMapper: query의 resultSet을 객체 리스트로 변환

ResultSetExtractor: query의 resultSet을 단일 객체로 변환

ResultSetExtractor의 메서드는 extractData 이므로, 이를 이용하여 아래와 같이 구현하였다.

    @GetMapping("/fruit/stat")
    public FruitStatResponse showFruitStat(@RequestParam String name) {
        String sql = "SELECT soldout, SUM(price) as total FROM fruit WHERE name = ? GROUP BY soldout";
        return jdbcTemplate.query(sql, new Object[]{name}, new ResultSetExtractor<FruitStatResponse>() {
            @Override
            public FruitStatResponse extractData(ResultSet rs) throws SQLException, DataAccessException {
                FruitStatResponse fruitStatResponse = new FruitStatResponse();
                while (rs.next()) {
                    boolean soldout = rs.getBoolean("soldout");
                    long total = rs.getLong("total");
                    if (soldout) {
                        fruitStatResponse.setSalesAmount(total);
                    } else {
                        fruitStatResponse.setNotSalesAmount(total);
                    }
                }
                return fruitStatResponse;
            }
        });
    }

그리고 이를 람다식으로 바꾸어 마무리 해주었다.

    @GetMapping("/fruit/stat")
    public FruitStatResponse showFruitStat(@RequestParam String name) {
        String sql = "SELECT soldout, SUM(price) as total FROM fruit WHERE name = ? GROUP BY soldout";
        return jdbcTemplate.query(sql, new Object[]{name}, rs -> {
            FruitStatResponse fruitStatResponse = new FruitStatResponse();
            while (rs.next()) {
                boolean soldout = rs.getBoolean("soldout");
                long total = rs.getLong("total");
                if (soldout) {
                    fruitStatResponse.setSalesAmount(total);
                } else {
                    fruitStatResponse.setNotSalesAmount(total);
                }
            }
            return fruitStatResponse;
        });
    }


강의에서 jdbcTemplate.query, 그리고 RowMapper을 배울때도 개념이 어렵다고 생각했는데, 이를 변형해서 단일객체로 리턴하기 위해 내용들을 찾아보면서 어려움을 많이 겪었다. 이번 과제를 통해 queryForObjectquery에 대해서 더 자세히 알게 되어서 아주 많이 배우게 된 과제였다.


강의 출처: https://inf.run/XKQg)

댓글을 작성해보세요.

채널톡 아이콘