[인프런 워밍업 스터디 클럽] 0기 백엔드 미션 - API 실습 (Day2)

[인프런 워밍업 스터디 클럽] 0기 백엔드 미션 - API 실습 (Day2)

API 실습

벌써 2번째 미션을 진행할 차례가 되었다. 강의 중에 GET, POST API 개발을 해보았고 포스트맨으로 테스팅도 해보았다.

또한 실제 프로젝트처럼 ui가 존재하는 화면과 연동하는 유저 생성 및 조회 API를 개발하면서 뭔가 실무를 체험하는 것과 같은 느낌이 들었다. 하지만, 아직 조금 부족하다고 많이 느끼게 되었다. 또한 많은 연습이 필요하다고 느꼈다. 그런데 마침 코치님께서 친절하게 미션을 통하여 API 연습을 하게 도와주셨다. 😆 그럼 미션을 통하여 나의 코드를 글로 표현해보겠다.

 

문제1

요구조건

image

해결과정

  1. 당연하겠지만 스프링 프로젝트를 만든다. 나는 IntelliJ Ultimate를 사용하고 있는 관계로 start.spring.io를 통하여 프로젝트를 생성하지 않고 직접 인텔리제이를 통하여 프로젝트를 생성할 수 있다. 아래는 프로젝트를 세팅한 화면이다.

image

  1. controller 패키지 생성 후, 문제1에 대한 컨트롤러 클래스 생성

package me.sungbin.mission.controller;

public class MissionController {
}
  1. API를 만들기 위해 코드를 작성한다. 나는 아래와 같이 작성하였다.

package me.sungbin.mission.controller;

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

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


}

📚 문제1~3까지 제시된 api는 /api/v1/으로 시작한다. 따라서 @RequestMapping을 통하여 공통된 api url부분을 제시해준다.

 

  1. 문제 1에 대한 API를 정의해야 한다. 제시된 조건은 /api/v1/calc의 path를 가지며, 쿼리 파리미터로 num1과 num2를 가진다. 이에 따라 정의를 해볼려고 한다. 그런데 문제는 응답하는 값이 json 형태로 반환되므로 DTO 객체를 통하여 반환하도록 하자. 그러면 DTO 응답 객체부터 만들자. DTO 응답 객체는 다음과 같다.

package me.sungbin.mission.dto.response;

public class CalculationResponseDto {

    private final int add;

    private final int minus;
    
    private final int multiply;

    public CalculationResponseDto(int add, int minus, int multiply) {
        this.add = add;
        this.minus = minus;
        this.multiply = multiply;
    }

    public int getAdd() {
        return add;
    }

    public int getMinus() {
        return minus;
    }

    public int getMultiply() {
        return multiply;
    }
}

 

롬복을 통하여 생서자와 getter를 만들 수도 있고, JDK17 이상부터는 record를 이용하여 만들 수도 있다.

하지만, 미션의 취지와 강의에 설명한 데로 생성해보겠다.

 

💡 record를 통하여 DTO 생성

package me.sungbin.mission.dto.response;

public record CalculateResponseRecordDto(int add, int minus, int multiply) {

    @Override
    public int add() {
        return add;
    }

    @Override
    public int minus() {
        return minus;
    }

    @Override
    public int multiply() {
        return multiply;
    }
}

 

  1. parameter를 객체를 통하여 전달주려고 한다. 물론 @RequestParam을 통하여 전달줄 수 있다. 아래와 같이 DTO 요청 객체를 만들었다.

     

     

    package me.sungbin.mission.dto.request;
    
    
    public class CalculationRequestDto {
    
        private final int num1;
    
        private final int num2;
    
        public CalculationRequestDto(int num1, int num2) {
            this.num1 = num1;
            this.num2 = num2;
        }
    
        public int getNum1() {
            return num1;
        }
    
        public int getNum2() {
            return num2;
        }
    }
    
    

 

  1. 그리고 컨트롤러 클래스를 마저 작성하면 아래와 같다.

package me.sungbin.mission.controller;

import me.sungbin.mission.dto.request.CalculationRequestDto;
import me.sungbin.mission.dto.response.CalculationResponseDto;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

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

    @GetMapping("/calc")
    public CalculationResponseDto calculate(CalculationRequestDto requestDto) {
        return new CalculationResponseDto(
                requestDto.getNum1() + requestDto.getNum2(),
                requestDto.getNum1() - requestDto.getNum2(),
                requestDto.getNum1() * requestDto.getNum2()
        );
    }
}

  1. 그리고 포스트맨으로 테스트를 해보니 아래와 같이 에러가 발생한다.

     

image

그래서 에러 내용을 보니 아래와 같다.

 

image

트러블 슈팅

그래서 대체 이유가 뭘까 고민을 하다가 name 속성을 줘서 풀어보니 정상동작을 하였다. 그래서 이런 문제는 공식문서에 있을법해서 구글링 및 공식문서 이슈사항을 보았다.

 

image

위의 공식문서에서 업데이트 기록에 나와있었다.

 

Spring Boot 3.2에서 사용되는 Spring Framework 버전은 더 이상 바이트코드를 구문 분석하여 매개변수 이름을 추론하려고 시도하지 않습니다.

 

즉, 스프링 부트 3.2부터 자바 컴파일러에 -parameters 옵션을 넣어주어야 애노테이션의 이름을 생략할 수 있다.

또 한 가지 방법으로는 gradle을 사용해 빌드를 하고 실행하는 방법이 있다. 나는 Build and run using를 IntelliJ IDEA로 선택하였습니다. (체감상 Gradle보단 빨라서...) Gradle로 선택한 경우에는 Gradle이 컴파일 시점에 해당 옵션을 자동으로 적용해준다.

 

그래서 인텔리제이의 세팅에 Build, Execution, Deployment > Build Tools > Gradle 을 들어가서 아래 세팅처럼 Gradle로 변경한다. 초기세팅은 Gradle이다. 나처럼 IntelliJ로 변경한 사람만 적용하면 된다.

 

image

그 후에 다시 실행하면 정상적으로 결과가 나온다.

 

  1. 결과 확인

image

리펙토링

이제 컨트롤러에 있는 비즈니스 로직을 좀 더 리팩토링해보자. 여기서 든 생각은 더하기, 빼기, 곱하기 로직은 다른 클래스로 분리하면 좋을 것 같다는 생각이 들었다. 🤔

 

먼저 서비스 패키지를 구성하고 서비스 클래스를 만들어보자. 그리고 거기다가 로직을 추가해보자.

 

package me.sungbin.mission.service;

import me.sungbin.mission.dto.request.CalculationRequestDto;
import org.springframework.stereotype.Service;

@Service
public class CalculationService {

    /**
     * 더하기 로직
     * @param requestDto
     * @return 
     */
    public int add(CalculationRequestDto requestDto) {
        return requestDto.getNum1() + requestDto.getNum2();
    }

    /**
     * 빼기 로직
     * @param requestDto
     * @return
     */
    public int minus(CalculationRequestDto requestDto) {
        return requestDto.getNum1() - requestDto.getNum2();
    }

    /**
     * 곱하기 로직
     * @param requestDto
     * @return
     */
    public int multiply(CalculationRequestDto requestDto) {
        return requestDto.getNum1() * requestDto.getNum2();
    }
}

 

다음으로 컨트롤러 코드를 수정하자.

 

package me.sungbin.mission.controller;

import me.sungbin.mission.dto.request.CalculationRequestDto;
import me.sungbin.mission.dto.response.CalculationResponseDto;
import me.sungbin.mission.service.CalculationService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

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

    private final CalculationService calculationService;

    public MissionController(CalculationService calculationService) {
        this.calculationService = calculationService;
    }

    @GetMapping("/calc") // GET /api/v1/calc
    public CalculationResponseDto calculate(CalculationRequestDto requestDto) {
        return new CalculationResponseDto(calculationService.add(requestDto), calculationService.minus(requestDto), calculationService.multiply(requestDto));
    }
}

 

그리고 포스트맨으로 실행해보면 정상적으로 결과가 나온다.

 

image

 

테스트 코드

그러면 포스트맨으로 테스팅을 해보았지만, 테스트 코드를 통해 확실한 검증을 가보자.

다만, 테스트 코드는 실패하는 로직과 성공하는 로직을 작성해야하지만 이번 문제는 성공하는 로직만 작성해보겠다.

또한 비즈니스 로직은 단순 연산이므로 통합테스트로 과정설명없이 아래와 같이 작성했다.

 

package me.sungbin.mission.controller;

import me.sungbin.mission.dto.request.CalculationRequestDto;
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.test.web.servlet.MockMvc;

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

@SpringBootTest
@AutoConfigureMockMvc
class MissionControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    @DisplayName("문제 1번 통합 테스트 - 성공")
    void calculate_test_success() throws Exception {
        CalculationRequestDto calculationRequestDto = new CalculationRequestDto(10, 5);

        this.mockMvc.perform(get("/api/v1/calc")
                        .param("num1", String.valueOf(calculationRequestDto.getNum1()))
                        .param("num2", String.valueOf(calculationRequestDto.getNum2())))
                .andDo(print())
                .andExpect(status().isOk());
    }
}

결과는 아래와 같다.

 

image

문제2

요구사항

image

문제풀이

  1. 기본적으로 IDE 열고 프로젝트 세팅은 생략하겠다.

  2. 컨트롤러 클래스에 경로를 지정해주기 전에, 응답객체부터 먼저 만들어보자.

응답객체는 아래와 같다.

 

package me.sungbin.mission.dto.response;

public class DayOfTheWeekResponseDto {
    private final String dayOfTheWeek;

    public DayOfTheWeekResponseDto(String dayOfTheWeek) {
        this.dayOfTheWeek = dayOfTheWeek;
    }

    public String getDayOfTheWeek() {
        return dayOfTheWeek;
    }
}

 

다음으로 컨트롤러 코드의 비즈니스 로직 부분을 작성해보자.

 

package me.sungbin.mission.controller;

import me.sungbin.mission.dto.request.CalculationRequestDto;
import me.sungbin.mission.dto.response.CalculationResponseDto;
import me.sungbin.mission.dto.response.DayOfTheWeekResponseDto;
import me.sungbin.mission.service.CalculationService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import java.time.LocalDate;
import java.time.format.TextStyle;
import java.util.Locale;

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

    private final CalculationService calculationService;

    public MissionController(CalculationService calculationService) {
        this.calculationService = calculationService;
    }

    @GetMapping("/calc") // GET /api/v1/calc
    public CalculationResponseDto calculate(CalculationRequestDto requestDto) {
        return new CalculationResponseDto(calculationService.add(requestDto), calculationService.minus(requestDto), calculationService.multiply(requestDto));
    }

    @GetMapping("/day-of-the-week") // GET /api/v1/day-of-the-week
    public DayOfTheWeekResponseDto findDayOfTheWeek(@RequestParam LocalDate date) {
        return new DayOfTheWeekResponseDto(date.getDayOfWeek().getDisplayName(TextStyle.SHORT, Locale.US).toUpperCase());
    }
}

 

  1. 결과 확인

image

🙋🏻 처음에는 당황했다. 분명 제대로 로직이 갔는데 예시랑 다르기 때문이다. 하지만 달력을 확인해도 2023년 01월 01일은 일요일이 맞다!

 

📚 나는 문제를 단순 속성이 1개이기 때문에 단순 타입으로 받았지만 만약에 단순 타입이 아니라 객체로도 넘길 수 있다.

 

@DateTimeFormat : 객체로 받을 시, 필드에다가 이 어노테이션을 붙여주고 패턴을 지정해줘야 한다. 왜냐하면 스프링의 기본 날짜/시간 파싱 규칙은 LocalDate의 경우 ISO 형식(예: yyyy-MM-dd)을 사용합니다. 따라서, 클라이언트 요청이 이 형식을 따른다면 @DateTimeFormat 어노테이션이 없어도 문제없이 파싱될 수 있습니다. 다만, 아래의 경우에 문제가 발생한다.

  • 날짜 형식 불일치: 클라이언트가 다른 형식(예: dd-MM-yyyy)을 사용하여 데이터를 보내면, 스프링은 이를 올바르게 파싱하지 못하고 오류를 반환합니다.

  • 명확성 부족: @DateTimeFormat 어노테이션을 사용하지 않으면, API를 사용하는 클라이언트 개발자들이 요구되는 정확한 날짜 형식을 명확하게 알 수 없습니다. 이는 API의 사용성을 저하시킬 수 있습니다.

그러면 객체로 받는 예시도 보여주겠다.

 

DTO

package me.sungbin.mission.dto.request;

import org.springframework.format.annotation.DateTimeFormat;

import java.time.LocalDate;

public class DayOfTheWeekRequestDto {

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

    public DayOfTheWeekRequestDto(LocalDate date) {
        this.date = date;
    }

    public LocalDate getDate() {
        return date;
    }
}

 

Controller

package me.sungbin.mission.controller;

import me.sungbin.mission.dto.request.CalculationRequestDto;
import me.sungbin.mission.dto.request.DayOfTheWeekRequestDto;
import me.sungbin.mission.dto.response.CalculationResponseDto;
import me.sungbin.mission.dto.response.DayOfTheWeekResponseDto;
import me.sungbin.mission.service.CalculationService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import java.time.LocalDate;
import java.time.format.TextStyle;
import java.util.Locale;

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

    private final CalculationService calculationService;

    public MissionController(CalculationService calculationService) {
        this.calculationService = calculationService;
    }

    @GetMapping("/calc") // GET /api/v1/calc
    public CalculationResponseDto calculate(CalculationRequestDto requestDto) {
        return new CalculationResponseDto(calculationService.add(requestDto), calculationService.minus(requestDto), calculationService.multiply(requestDto));
    }

    @GetMapping("/day-of-the-week") // GET /api/v1/day-of-the-week
    public DayOfTheWeekResponseDto findDayOfTheWeek(DayOfTheWeekRequestDto requestDto) {
        return new DayOfTheWeekResponseDto(requestDto.getDate().getDayOfWeek().getDisplayName(TextStyle.SHORT, Locale.US).toUpperCase());
    }
}

 

리팩토링

이제 비즈니스 로직을 서비스 클래스에 넣어서 좀 더 리팩토링 해보자. validation도 적용할려나 @DateTimeFormat을 이용하면 스프링에서 알아서 TypeMismatchException을 발생시켜준다. 따라서 @RestControllerAdvice를 이용하여 할 수 있다.

 

/**
 * 요일 찾기 비즈니스 로직
 * @param requestDto
 * @return
 */
public String findDayOfTheWeek(DayOfTheWeekRequestDto requestDto) {
    return requestDto.getDate().getDayOfWeek().getDisplayName(TextStyle.SHORT, Locale.US).toUpperCase();
}

위의 코드는 요일 찾기 로직을 서비스 클래스에 옮긴 것이다. 다음으로 컨트롤러 클래스를 아래와 수정하자.

 

package me.sungbin.mission.controller;

import me.sungbin.mission.dto.request.CalculationRequestDto;
import me.sungbin.mission.dto.request.DayOfTheWeekRequestDto;
import me.sungbin.mission.dto.response.CalculationResponseDto;
import me.sungbin.mission.dto.response.DayOfTheWeekResponseDto;
import me.sungbin.mission.service.CalculationService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import java.time.LocalDate;
import java.time.format.TextStyle;
import java.util.Locale;

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

    private final CalculationService calculationService;

    public MissionController(CalculationService calculationService) {
        this.calculationService = calculationService;
    }

    @GetMapping("/calc") // GET /api/v1/calc
    public CalculationResponseDto calculate(CalculationRequestDto requestDto) {
        return new CalculationResponseDto(this.calculationService.add(requestDto), calculationService.minus(requestDto), calculationService.multiply(requestDto));
    }

    @GetMapping("/day-of-the-week") // GET /api/v1/day-of-the-week
    public DayOfTheWeekResponseDto findDayOfTheWeek(DayOfTheWeekRequestDto requestDto) {
        return new DayOfTheWeekResponseDto(this.calculationService.findDayOfTheWeek(requestDto));
    }
}

📚 LocalDate 참고

아래의 코드는 블로그를 통하여 유용한 LocalDate 함수를 사용한 것이다. 확인해보자.

// 특정 날짜의 요일 구하기 
LocalDate.of( 2022, 12, 12 ).getDayOfWeek(); // MONDAY

// 특정 날짜로부터 일, 월, 주, 연 차이 나는 날짜 구하기 
localDate.minusDays( 5 ); // @param long daysToSubtract
localDate.minusMonths( 5 ); // @param long daysToSubtract
localDate.minusWeeks( 5 ); // @param long daysToSubtract
localDate.minusYears( 5 ); // @param long daysToSubtract

// 특정 날짜로부터 몇 일 이후 날짜 구하기 ( 위와 유사 ) 
localDate.plusDays( 7 ); // @param long amountToAdd 

// 특정 날짜가 해당하는 주의 특정 요일 일자 구하기 
localDate.with( DayOfWeek.FRIDAY); // 2022-12-16 ( @param DayOfWeek )

// 특정 날짜에서 특정 부분만 바꾸기 
LocalDate localDate = LocalDate.now(); // 2022-12-12

localDate.withDayOfMonth( 31 ); // 2022-12-31 ( @param int dayOfMonth )
localDate.withMonth( 1 ); // 2022-01-12 ( @param int month )
localDate.withYear( 2023 ); // 2023-12-12 ( @param int year )

// 윤년 여부 
localDate.isLeapYear(); // false 

// 해당 월의 첫째 날 구하기 
localDate.withDayOfMonth( 1 );

// 해당 월의 마지막 날 구하기 
localDate.withDayOfMonth( localDate.lengthOfMonth() );

// 두 날짜 사이의 간격 구하기 
LocalDate start = LocalDate.of( 2021, 10, 1 );
LocalDate end = LocalDate.of( 2022, 12, 31 );

Period diff = Period.between( start, end );

diff.getYears(); // 1
diff.getMonths(); // 2 
diff.getDays(); // 30 

// ChronoUnit 을 이용한 두 날짜 사이 간격 구하기
long diffMonth = ChronoUnit.MONTHS.between( start, end ); // 14
long diffWeek = ChronoUnit.WEEKS.between( start, end ); // 65
long diffDay = ChronoUnit.DAYS.between( start, end ); // 456

 

테스트 코드

이번엔 테스트 코드를 작성하자. 성공과 실패 케이스 둘 다 작성해보겠다.

package me.sungbin.mission.controller;

import me.sungbin.mission.dto.request.CalculationRequestDto;
import me.sungbin.mission.dto.request.DayOfTheWeekRequestDto;
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.test.web.servlet.MockMvc;

import java.time.LocalDate;

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

@SpringBootTest
@AutoConfigureMockMvc
class MissionControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    @DisplayName("문제 1번 통합 테스트 - 성공")
    void calculate_test_success() throws Exception {
        CalculationRequestDto calculationRequestDto = new CalculationRequestDto(10, 5);

        this.mockMvc.perform(get("/api/v1/calc")
                        .param("num1", String.valueOf(calculationRequestDto.getNum1()))
                        .param("num2", String.valueOf(calculationRequestDto.getNum2())))
                .andDo(print())
                .andExpect(status().isOk());
    }

    @Test
    @DisplayName("문제 2번 통합 테스트 - 성공")
    void find_day_of_the_week_test_success() throws Exception {
        DayOfTheWeekRequestDto requestDto = new DayOfTheWeekRequestDto(LocalDate.of(2023, 1, 1));

        this.mockMvc.perform(get("/api/v1/day-of-the-week")
                        .param("date", String.valueOf(requestDto.getDate())))
                .andDo(print())
                .andExpect(status().isOk());
    }
}

결과를 보자.

 

image

문제3

image

문제풀이

이제는 익숙해졌을거라 보고 최종 코드만 확인해보겠다.

 

  • DTO

package me.sungbin.mission.dto.request;

import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;

import java.util.List;

public class ListNumberDataRequestDto {

    private final List<Integer> numbers;

    public ListNumberDataRequestDto(@JsonProperty("numbers") List<Integer> numbers) {
        this.numbers = numbers;
    }

    public List<Integer> getNumbers() {
        return numbers;
    }
}

 

  • Controller

package me.sungbin.mission.controller;

import jakarta.validation.Valid;
import me.sungbin.mission.dto.request.CalculationRequestDto;
import me.sungbin.mission.dto.request.DayOfTheWeekRequestDto;
import me.sungbin.mission.dto.request.ListNumberDataRequestDto;
import me.sungbin.mission.dto.response.CalculationResponseDto;
import me.sungbin.mission.dto.response.DayOfTheWeekResponseDto;
import me.sungbin.mission.service.CalculationService;
import org.springframework.web.bind.annotation.*;

import java.time.LocalDate;
import java.time.format.TextStyle;
import java.util.Locale;

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

    private final CalculationService calculationService;

    public MissionController(CalculationService calculationService) {
        this.calculationService = calculationService;
    }

    @GetMapping("/calc") // GET /api/v1/calc
    public CalculationResponseDto calculate(CalculationRequestDto requestDto) {
        return new CalculationResponseDto(this.calculationService.add(requestDto), calculationService.minus(requestDto), calculationService.multiply(requestDto));
    }

    @GetMapping("/day-of-the-week") // GET /api/v1/day-of-the-week
    public DayOfTheWeekResponseDto findDayOfTheWeek(DayOfTheWeekRequestDto requestDto) {
        return new DayOfTheWeekResponseDto(this.calculationService.findDayOfTheWeek(requestDto));
    }

    @PostMapping("/sum-of-numbers-in-list")
    public Integer sumOfNumbersInList(@RequestBody @Valid ListNumberDataRequestDto requestDto) {
        return requestDto.getNumbers().stream().mapToInt(Integer::intValue).sum();
    }
}

📚 비즈니스 로직 부분은 Java8에 나온 Stream API와 메서드 레퍼런스를 이용하여 만들었다. 이 API는 다음 미션때 자세히 보도록 하겠다.

 

또한 나처럼 DTO의 필드를 final로 설정하면 생성자 부분에 @JsonProperty를 빼고 진행하면 에러가 발생한다.

Cannot construct instance of me.sungbin.mission.dto.request.ListNumberDataRequestDto (no Creators, like default constructor, exist): cannot deserialize from Object value (no delegate- or property-based Creator)

 

트러블 슈팅

이 오류 메시지는 스프링 부트와 Jackson 라이브러리가 ListNumberDataRequestDto 클래스의 인스턴스를 JSON 데이터로부터 역직렬화할 때 발생한다. 오류 메시지는 ListNumberDataRequestDto에 기본 생성자가 없거나, Jackson이 JSON 데이터를 객체의 필드에 매핑하기 위해 사용할 수 있는 적절한 생성자나 세터 메서드가 없음을 나타낸다. 이 경우, 클래스에는 파라미터를 받는 생성자만 정의되어 있으며, final 키워드로 선언된 numbers 필드 때문에 수정자(setter) 메서드를 추가할 수 없다.

 

따라서 Jackson이 객체를 역직렬화할 때 사용할 수 있는 "속성 기반 생성자"를 제공하기 위해, 생성자 파라미터에 @JsonProperty 어노테이션을 사용할 수 있다. 이 방법은 Jackson에게 JSON 데이터의 어떤 필드가 클래스 생성자의 어떤 파라미터와 매핑되는지 명확하게 지시한다.

 

아니면 final 키워드를 없애는 방법이 있다. 나는 이 예시를 보이기 위해 의도적으로 이렇게 작성하겠다.

 

결과를 보자.

 

image

 

리팩토링

비즈니스 로직을 서비스 클래스에 옮기자.

 

/**
 * 배열의 합 구하는 로직
 * @param requestDto
 * @return
 */
public Integer sumOfNumbersInList(ListNumberDataRequestDto requestDto) {
    return requestDto.getNumbers().stream().mapToInt(Integer::intValue).sum();
}

컨트롤러 코드도 수정하자.

 

package me.sungbin.mission.controller;

import jakarta.validation.Valid;
import me.sungbin.mission.dto.request.CalculationRequestDto;
import me.sungbin.mission.dto.request.DayOfTheWeekRequestDto;
import me.sungbin.mission.dto.request.ListNumberDataRequestDto;
import me.sungbin.mission.dto.response.CalculationResponseDto;
import me.sungbin.mission.dto.response.DayOfTheWeekResponseDto;
import me.sungbin.mission.service.CalculationService;
import org.springframework.web.bind.annotation.*;

import java.time.LocalDate;
import java.time.format.TextStyle;
import java.util.Locale;

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

    private final CalculationService calculationService;

    public MissionController(CalculationService calculationService) {
        this.calculationService = calculationService;
    }

    @GetMapping("/calc") // GET /api/v1/calc
    public CalculationResponseDto calculate(CalculationRequestDto requestDto) {
        return new CalculationResponseDto(this.calculationService.add(requestDto), calculationService.minus(requestDto), calculationService.multiply(requestDto));
    }

    @GetMapping("/day-of-the-week") // GET /api/v1/day-of-the-week
    public DayOfTheWeekResponseDto findDayOfTheWeek(DayOfTheWeekRequestDto requestDto) {
        return new DayOfTheWeekResponseDto(this.calculationService.findDayOfTheWeek(requestDto));
    }

    @PostMapping("/sum-of-numbers-in-list")
    public Integer sumOfNumbersInList(@RequestBody @Valid ListNumberDataRequestDto requestDto) {
        return this.calculationService.sumOfNumbersInList(requestDto);
    }
}

 

그리고 마지막으로 validation을 추가하자!

물론 서비스 클래스에 아래와 같은 로직을 넣을 수 있지만

if (requestDto.getNumbers() == null || requestDto.getNumbers().isEmpty()) {
            throw new IllegalArgumentException("리스트는 공란이거나 null일 수 없습니다.");
}

 

좀 편하게 spring-boot-starter-validation을 이용하여

DTO 클래스를 변경해보자.

 

package me.sungbin.mission.dto.request;

import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;

import java.util.List;

public class ListNumberDataRequestDto {

    @NotEmpty(message = "리스트의 적어도 하나의 원소가 존재해야 합니다.")
    @NotNull(message = "리스트는 null일 수 없습니다.")
    private final List<Integer> numbers;

    public ListNumberDataRequestDto(@JsonProperty("numbers") List<Integer> numbers) {
        this.numbers = numbers;
    }

    public List<Integer> getNumbers() {
        return numbers;
    }
}

 

테스트 코드

이번에는 테스트 실패와 성공케이스 둘다 적어보자.

 

  • 실패

@Test
@DisplayName("문제 3번 통합 테스트 - 실패 (빈 List)")
void sum_of_the_list_numbers_test_fail_caused_by_list_empty() throws Exception {
    List<Integer> list = new ArrayList<>();
    ListNumberDataRequestDto requestDto = new ListNumberDataRequestDto(list);

    this.mockMvc.perform(post("/api/v1/sum-of-numbers-in-list")
                    .contentType(MediaType.APPLICATION_JSON)
                    .content(objectMapper.writeValueAsString(requestDto)))
            .andDo(print())
            .andExpect(status().isBadRequest());
}

@Test
@DisplayName("문제 3번 통합 테스트 - 실패 (null인 List)")
void sum_of_the_list_numbers_test_fail_caused_by_list_null() throws Exception {
    List<Integer> list = null;
    ListNumberDataRequestDto requestDto = new ListNumberDataRequestDto(list);

    this.mockMvc.perform(post("/api/v1/sum-of-numbers-in-list")
                    .contentType(MediaType.APPLICATION_JSON)
                    .content(objectMapper.writeValueAsString(requestDto)))
            .andDo(print())
            .andExpect(status().isBadRequest());
}

결과 (1,2번 실패 테스트는 response 동일)

image

  • 성공

package me.sungbin.mission.controller;

import com.fasterxml.jackson.databind.ObjectMapper;
import me.sungbin.mission.dto.request.CalculationRequestDto;
import me.sungbin.mission.dto.request.DayOfTheWeekRequestDto;
import me.sungbin.mission.dto.request.ListNumberDataRequestDto;
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 java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

import static org.junit.jupiter.api.Assertions.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
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;

@SpringBootTest
@AutoConfigureMockMvc
class MissionControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private ObjectMapper objectMapper;

    @Test
    @DisplayName("문제 1번 통합 테스트 - 성공")
    void calculate_test_success() throws Exception {
        CalculationRequestDto calculationRequestDto = new CalculationRequestDto(10, 5);

        this.mockMvc.perform(get("/api/v1/calc")
                        .param("num1", String.valueOf(calculationRequestDto.getNum1()))
                        .param("num2", String.valueOf(calculationRequestDto.getNum2())))
                .andDo(print())
                .andExpect(status().isOk());
    }

    @Test
    @DisplayName("문제 2번 통합 테스트 - 성공")
    void find_day_of_the_week_test_success() throws Exception {
        DayOfTheWeekRequestDto requestDto = new DayOfTheWeekRequestDto(LocalDate.of(2023, 1, 1));

        this.mockMvc.perform(get("/api/v1/day-of-the-week")
                        .param("date", String.valueOf(requestDto.getDate())))
                .andDo(print())
                .andExpect(status().isOk());
    }

    @Test
    @DisplayName("문제 3번 통합 테스트 - 실패 (빈 List)")
    void sum_of_the_list_numbers_test_fail_caused_by_list_empty() throws Exception {
        List<Integer> list = new ArrayList<>();
        ListNumberDataRequestDto requestDto = new ListNumberDataRequestDto(list);

        this.mockMvc.perform(post("/api/v1/sum-of-numbers-in-list")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(requestDto)))
                .andDo(print())
                .andExpect(status().isBadRequest());
    }

    @Test
    @DisplayName("문제 3번 통합 테스트 - 실패 (null인 List)")
    void sum_of_the_list_numbers_test_fail_caused_by_list_null() throws Exception {
        List<Integer> list = null;
        ListNumberDataRequestDto requestDto = new ListNumberDataRequestDto(list);

        this.mockMvc.perform(post("/api/v1/sum-of-numbers-in-list")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(requestDto)))
                .andDo(print())
                .andExpect(status().isBadRequest());
    }

    @Test
    @DisplayName("문제 3번 통합 테스트 - 성공")
    void sum_of_the_list_numbers_test_success() throws Exception {
        List<Integer> list = Arrays.asList(1, 2, 3, 4, 5);
        ListNumberDataRequestDto requestDto = new ListNumberDataRequestDto(list);

        this.mockMvc.perform(post("/api/v1/sum-of-numbers-in-list")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(requestDto)))
                .andDo(print())
                .andExpect(status().isOk());
    }
}

 

결과

image

📚 참조

자바의 정석

https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-3.2-Release-Notes

댓글을 작성해보세요.