관리 메뉴

거니의 velog

(10) 스프링 부트와 API 서버 5 본문

SpringBoot_React 풀스택 프로젝트

(10) 스프링 부트와 API 서버 5

Unlimited00 2024. 2. 28. 16:12

6. @RestControllerAdvice

* API 서버는 화면이 없는 상태에서 개발되기 때문에 잘못된 파라미터 등으로 인한 서버 내부의 예외 처리를 @RestControllerAdvice로 처리해 주는 것이 안전하다.

* 예를 들어 존재하지 않는 번호의 Todo를 조회하면 NoSuchElementException 에러가 발생한다.

- http://localhost:8080/api/todo/33

* 또한 페이지 번호를 숫자가 아닌 문자로 전달하면 MethodArgumentNotValidException 에러가 발생한다.

- http://localhost:8080/api/todo/list?page=A&size=10

* 이러한 예외를 처리하기 위해서 controller 패키지 내에서 advice 패키지를 추가하고 CustomControllerAdvice.java 파일을 추가한다.

package com.unlimited.mallapi.controller.advice;

import java.util.Map;
import java.util.NoSuchElementException;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
public class CustomControllerAdvice {

    // NoSuchElementException을 처리하는 예외 핸들러
    @ExceptionHandler(NoSuchElementException.class)
    protected ResponseEntity<?> notExist(NoSuchElementException e) {
        // 예외 메시지를 추출하여 클라이언트에게 NOT_FOUND 상태와 함께 반환
        String msg = e.getMessage();
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("msg", msg));
    }

    // MethodArgumentNotValidException을 처리하는 예외 핸들러
    @ExceptionHandler(MethodArgumentNotValidException.class)
    protected ResponseEntity<?> handleIllegalArgumentException(MethodArgumentNotValidException e) {
        // 예외 메시지를 추출하여 클라이언트에게 NOT_ACCEPTABLE 상태와 함께 반환
        String msg = e.getMessage();
        return ResponseEntity.status(HttpStatus.NOT_ACCEPTABLE).body(Map.of("msg", msg));
    }

}

* @RestControllerAdvice가 적용되면 예외가 발생해도 호출한 쪽으로 HTTP 상태 코드와 JSON 메시지를 전달할 수 있게 된다.

- http://localhost:8080/api/todo/33

- http://localhost:8080/api/todo/list?page=A&size=10


7. REST 관련 툴을 이용한 POST/PUT/DELETE

* GET 방식과 달리 POST 방식은 브라우저에서 확인이 어렵기 때문에 별도의 테스트 방식을 고민해야 한다. 크롬 확장 프로그램을 사용할 수도 있고, Postman 같은 프로그램을 사용할 수도 있다. 우리는 Postman으로 POST/PUT/DELETE 방식을 테스트한다.


(1) Formatter를 이용한 LocalDate 처리

* 처리를 위해서 전달되는 데이터는 JSON 형식의 데이터일 수도 있고 첨부파일 등이 포함되는 경우에는 form-data 혹은 일반적인 웹에서 사용하는 x-www-form-urlencoded 일 수도 있다. 예제에서는 JSON 데이터를 가정하고 @RequestBody를 이용해서 TodoDTO로 처리한다.

* 이러한 처리 과정에서 날짜/시간은 항상 주의해야 한다. 날짜/시간은 브라우저에서 문자열로 전송되지만, 서버에서는 LocalDate 혹은 LocalDateTile으로 처리된다. 그렇기 때문에 이를 변환해 주는 Formatter를 추가해서 이 과정을 자동으로 할 수 있도록 설정한다.

* controller 패키지 하위로 formatter 패키지를 선언하고 LocalDateFormatter 클래스를 추가한다.

package com.unlimited.mallapi.controller.formatter;

import java.text.ParseException;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.Locale;

import org.springframework.format.Formatter;

public class LocalDateFormatter implements Formatter<LocalDate> {

    // 문자열을 LocalDate로 파싱하는 메서드
    @Override
    public LocalDate parse(String text, Locale locale) throws ParseException {
        // 주어진 형식("yyyy-MM-dd")으로 문자열을 LocalDate로 변환하여 반환
        return LocalDate.parse(text, DateTimeFormatter.ofPattern("yyyy-MM-dd"));
    }

    // LocalDate를 문자열로 변환하는 메서드
    @Override
    public String print(LocalDate object, Locale locale) {
        // 주어진 형식("yyyy-MM-dd")으로 LocalDate를 문자열로 변환하여 반환
        return DateTimeFormatter.ofPattern("yyyy-MM-dd").format(object);
    }

}

* 작성된 LocalDateFormatter는 스프링의 MVC의 동작 과정에서 사용될 수 있도록 설정을 추가해 주어야 한다. config 패키지에 CustomServletConfig 클래스를 추가한다.

package com.unlimited.mallapi.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.format.FormatterRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

import com.unlimited.mallapi.controller.formatter.LocalDateFormatter;

@Configuration
public class CustomServletConfig implements WebMvcConfigurer {

    // FormatterRegistry에 LocalDateFormatter를 등록하는 메서드
    @Override
    public void addFormatters(FormatterRegistry registry) {
        // LocalDateFormatter를 FormatterRegistry에 추가하여 날짜 형식을 지원
        registry.addFormatter(new LocalDateFormatter());
    }

}

(2) POST 방식의 등록 처리

* 새로운 Todo의 등록은 단순 JSON 데이터라고 가정하고 이를 처리하기 위한 메서드를 TodoController에 추가한다.

package com.unlimited.mallapi.controller;

import java.util.Map;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
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;

import com.unlimited.mallapi.dto.PageRequestDTO;
import com.unlimited.mallapi.dto.PageResponseDTO;
import com.unlimited.mallapi.dto.TodoDTO;
import com.unlimited.mallapi.service.TodoService;

import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;

@RestController
@RequiredArgsConstructor
@Log4j2
@RequestMapping("/api/todo")
public class TodoController {

    private final TodoService service;

    // 특정 번호의 Todo 항목 조회
    @GetMapping("/{tno}")
    public TodoDTO get(@PathVariable(name = "tno") Long tno) {
        return service.get(tno);
    }

    // Todo 항목 목록 조회
    @GetMapping("/list")
    public PageResponseDTO<TodoDTO> list(PageRequestDTO pageRequestDTO) {
        log.info(pageRequestDTO);
        return service.list(pageRequestDTO);
    }

    // Todo 항목 등록
    @PostMapping("/")
    public Map<String, Long> register(@RequestBody TodoDTO todoDTO) {
        log.info("TodoDTO : {}", todoDTO);
        Long tno = service.register(todoDTO);
        return Map.of("TNO", tno);
    }

}

* 프로젝트를 실행하고 Postman을 사용해서 POST 방식으로 JSON 데이터를 전달해서 동작 여부를 확인한다. 실행 결과로는 새로운 번호(tno)가 생성된다.

{
    "title" : "Sample Title",
    "writer" : "user1",
    "dueDate" : "2023-10-10"
}

- http://localhost:8080/api/todo/

새로운 번호(tno) 102가 생성되었다.


(3) PUT 방식의 수정 처리

* Todo의 수정은 PUT 방식으로 한다. 수정될 수 있는 필드는 제목(title), 완료여부(complete), 만료일(dueDate)이다. TodoController에 @PutMapping으로 메서드를 추가한다.

package com.unlimited.mallapi.controller;

import java.util.Map;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import com.unlimited.mallapi.dto.PageRequestDTO;
import com.unlimited.mallapi.dto.PageResponseDTO;
import com.unlimited.mallapi.dto.TodoDTO;
import com.unlimited.mallapi.service.TodoService;

import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;

@RestController
@RequiredArgsConstructor
@Log4j2
@RequestMapping("/api/todo")
public class TodoController {

    private final TodoService service;

    // 특정 번호의 Todo 항목 조회
    @GetMapping("/{tno}")
    public TodoDTO get(@PathVariable(name = "tno") Long tno) {
        return service.get(tno);
    }

    // Todo 항목 목록 조회
    @GetMapping("/list")
    public PageResponseDTO<TodoDTO> list(PageRequestDTO pageRequestDTO) {
        log.info(pageRequestDTO);
        return service.list(pageRequestDTO);
    }

    // Todo 항목 등록
    @PostMapping("/")
    public Map<String, Long> register(@RequestBody TodoDTO todoDTO) {
        log.info("TodoDTO : {}", todoDTO);
        Long tno = service.register(todoDTO);
        return Map.of("TNO", tno);
    }

    // 특정 번호의 Todo 항목 수정
    @PutMapping("/{tno}")
    public Map<String, String> modify(
            @PathVariable(name = "tno") Long tno,
            @RequestBody TodoDTO todoDTO) {
        todoDTO.setTno(tno);
        log.info("Modify : {}", todoDTO);
        service.modify(todoDTO);
        return Map.of("RESULT", "SUCCESS");
    }

}

* Postman에서 아래와 같은 문자열을 이용해서 테스트를 진행한다.

{
    "tno" : 102,
    "title" : "Updated Title",
    "complete" : true,
    "dueDate" : "2024-03-03"
}

- http://localhost:8080/api/todo/102

DB도 항시 확인한다!

* REST 방식은 특별히 정해진 규격이 있는 것이 아니기 때문에 반드시 수정 작업을 PUT 방식으로 해야 할
  필요는 없다. 경우에 따라서 POST 방식을 이용하더라도 잘못 설계된 것은 아니다.
  관례적인 표현이다.

(4) DELETE 방식의 삭제 처리

* TodoController에서 삭제 처리는 번호를 이용해서 처리한다.

package com.unlimited.mallapi.controller;

import java.util.Map;

import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import com.unlimited.mallapi.dto.PageRequestDTO;
import com.unlimited.mallapi.dto.PageResponseDTO;
import com.unlimited.mallapi.dto.TodoDTO;
import com.unlimited.mallapi.service.TodoService;

import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;

@RestController
@RequiredArgsConstructor
@Log4j2
@RequestMapping("/api/todo")
public class TodoController {

    private final TodoService service;

    // 특정 번호의 Todo 항목 조회
    @GetMapping("/{tno}")
    public TodoDTO get(@PathVariable(name = "tno") Long tno) {
        return service.get(tno);
    }

    // Todo 항목 목록 조회
    @GetMapping("/list")
    public PageResponseDTO<TodoDTO> list(PageRequestDTO pageRequestDTO) {
        log.info(pageRequestDTO);
        return service.list(pageRequestDTO);
    }

    // Todo 항목 등록
    @PostMapping("/")
    public Map<String, Long> register(@RequestBody TodoDTO todoDTO) {
        log.info("TodoDTO : {}", todoDTO);
        Long tno = service.register(todoDTO);
        return Map.of("TNO", tno);
    }

    // 특정 번호의 Todo 항목 수정
    @PutMapping("/{tno}")
    public Map<String, String> modify(
            @PathVariable(name = "tno") Long tno,
            @RequestBody TodoDTO todoDTO) {
        todoDTO.setTno(tno);
        log.info("Modify : {}", todoDTO);
        service.modify(todoDTO);
        return Map.of("RESULT", "SUCCESS");
    }

    // 특정 번호의 Todo 항목 삭제
    @DeleteMapping("/{tno}")
    public Map<String, String> remove(@PathVariable(name = "tno") Long tno) {
        log.info("Remove : {}", tno);
        service.remove(tno);
        return Map.of("RESULT", "SUCCESS");
    }

}

- http://localhost:8080/api/todo/102

102 번이 잘 삭제되었다.

* DELETE 방식의 경우 POST/PUT과 달리 전달하는 데이터(payload)가 제한적이다. URL에서 사용되는 특수문자
  등을 이용하기 위해서는 URL 인코딩 처리를 해 주어야 한다. 
  공백문자나 특수문자가 포함된 데이터는 정상적으로 처리가 되지 않기 때문에 주의해야 한다.

[CORS 관련 설정]

* Ajax를 이용해서 서비스를 호출하게 되면 반드시 '교차 출처 리소스 공유(Cross-Origin Resource Sharing - 이하 CORS)'로 인해 정상적으로 호출이 제한된다. 리액트에서 스프링 부트로 동작하는 서버를 호출해야 하므로 CustomServletConfig에 추가적인 설정이 필요하다.

* CORS 설정은 @Controller가 있는 클래스에 @CrossOrigin을 적용하거나 Spring Security를 이용하는 설정이
  있다. @CrossOrigin 설정은 모든 컨트롤러에 개별적으로 적용해야 하므로 예제에서는 WebMvcConfigurer의
  설정으로 사용한다.

package com.unlimited.mallapi.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.format.FormatterRegistry;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

import com.unlimited.mallapi.controller.formatter.LocalDateFormatter;

@Configuration
public class CustomServletConfig implements WebMvcConfigurer {

    // 날짜 형식 변환을 위한 LocalDateFormatter를 등록하는 메서드
    @Override
    public void addFormatters(FormatterRegistry registry) {
        registry.addFormatter(new LocalDateFormatter());
    }

    // CORS(Cross-Origin Resource Sharing) 설정을 위한 메서드
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
                .allowedOrigins("*") // 모든 오리진에 대한 접근 허용
                .allowedMethods("HEAD", "GET", "POST", "PUT", "DELETE", "OPTIONS") // 허용하는 HTTP 메서드
                .maxAge(300) // 캐시 유효 시간 설정
                .allowedHeaders("Authorization", "Cache-Control", "Content-Type"); // 허용하는 헤더
    }

}