안녕하세요 ~ 헤나입니다 ! 😄
🟧 01. id(pk값)을 제외하고 모든 정보를 수정한다고 할 때 PUT 메서드를 사용하는 것은 괜찮을까 ?
상품 정보를 수정하는 컨트롤러를 작성하던 중에 문득 고민된 부분이 있었다.
PUT과 PATCH 메서드의 차이 때문이다.
현재 상품 수정 컨트롤러 코드는 아래와 같다.
public class ProductApi {
@PatchMapping("/{id}")
public ResponseEntity<ProductResponse> updateProduct(
@PathVariable(value = "id") long productId,
@RequestBody @Valid ProductUpdateRequest request
) {
final ProductResponse productResponse = productService.update(ProductId.from(productId), request);
return ResponseEntity.ok(productResponse);
}
}
수정을 요청할 때는 PUT 또는 PATCH 메서드를 이용할 수 있는데 어떤 Http Method를 고를지 고민됐다.
(PUT과 PATCH 메서드의 차이는 다음 포스트에서 볼 수 있다.)
그래서 리뷰어한테 현재 상황에 대해서 여쭤보았다.
🤔 본인
PATCH 메서드는 자원을 부분적으로 수정한다는 의미로 알고 있습니다.
PUT 메서드는 자원을 모두 덮어씌운다는 의미라고 알고 있습니다.
이 때 PATCH 메서드를 PUT 메서드라고 얘기할 수 있을지 궁금합니다.
Http Method는 어떤 행동을 할지에 대한 명시이기에 더 헷갈리는거 같습니다.
현재 상품 갱신 다음과 같습니다.
CREATE TABLE IF NOT EXISTS products (
id BIGINT NOT NULL AUTO_INCREMENT,
name VARCHAR(10) NOT NULL,
price DOUBLE NOT NULL,
image TEXT NOT NULL,
PRIMARY KEY(id)
);
UPDATE products SET name = ?, price = ?, image = ? WHERE id = ?
PATCH /update/1
Request Body
{
"name" : "헤나",
"price" : 10000,
"image" : "이미지-url"
}
🤔 본인
이와 같은 경우 PATCH 메서드로 진행하면 4개 중 3개를 수정하고 있습니다.
즉, 자원을 부분적으로 수정한다고 생각합니다.
저는 Id (PK)를 제외하고 수정하는 것은 PUT 메서드라고 할 수 있다고 생각합니다.
id(PK값)은 고유의 값으로 수정할 수 없기 때문에 예외적으로 PUT 메서드도 괜찮을거 같습니다.
PUT /update/1
Request Body
{
"name" : "헤나",
"price" : 10000,
"image" : "이미지-url"
}
답변은 다음과 같다.
💁♂️ 리뷰어
헤나의 말에 동의해요.
우리는 REST API의 개념을 지키기 위해 HTTP 메서드를 명확히 사용하려하죠.
REST API의 개념 중에는 자원을 식별하는 식별자 개념이 있죠.
그리고 이는 일반적인 데이터와는 별도로 취급해요.
수정되어서는 안되구요.
그렇기에 식별자를 제외한 다른 데이터를 모두 수정하면 PUT 메서드 사용하는 방식에 동의합니다~!
좋은 고민해주셨어요 👍
식별자값인 상품 번호(PK값)를 수정하지 않고 이외의 모든 데이터를 수정하면 PUT 메서드라고 보신다고 하셨다 !!
모두 기준이 다르겠지만 나는 리뷰어의 말처럼 식별자값은 제외하고 생각해도 된다고 느낀다.
🟧 02. Request 객체에 기본생성자를 넣는 것이 좋을까 ?
아래와 같은 상황에서 요청한 데이터를 Jackson 라이브러리가 자동으로 만들어주기 위해서는 기본 생성자가 필요했다.
@RequestMapping("/products")
@RestController
public class ProductApi {
@PostMapping
public ResponseEntity<Void> createProduct(@RequestBody @Valid ProductCreateRequest request) {
final ProductId saveProductId = productService.save(request);
final URI uri = URI.create("/products/" + saveProductId.getId());
return ResponseEntity.created(uri).build();
}
}
public class ProductCreateRequest {
@NotBlank(message = "상품명은 공백일 수 없습니다.")
@Length(min = 1, max = 20, message = "상품명은 1글자 이상 20글자 이하로 작성해주세요.")
private String name;
@PositiveOrZero(message = "금액은 음수일 수 없습니다.")
private double price;
@NotNull(message = "이미지 주소를 추가해주세요.")
private String image;
public ProductCreateRequest() {
}
public ProductCreateRequest(String name, double price, String image) {
this.name = name;
this.price = price;
this.image = image;
}
// ...
}
하지만 의외로 기본 생성자 없이도 Jackson 라이브러리는 객체를 문제 없이 생성할 수 있었다.
public class ProductCreateRequest {
@NotBlank(message = "상품명은 공백일 수 없습니다.")
@Length(min = 1, max = 20, message = "상품명은 1글자 이상 20글자 이하로 작성해주세요.")
private String name;
@PositiveOrZero(message = "금액은 음수일 수 없습니다.")
private double price;
@NotNull(message = "이미지 주소를 추가해주세요.")
private String image;
public ProductCreateRequest(String name, double price, String image) {
this.name = name;
this.price = price;
this.image = image;
}
// ...
}
기본 생성자 없이 ProductCreateRequest를 받아올 수 있다 ?!
Gradle 기반의 SpringBoot는 추가적인 설정과 플러그인을 제공하기 때문이다.
- ParameterNames 모듈 추가한다 !!
원리에 대한 이야기는 하지 않으려 한다.
내가 궁금했던 부분은 이러한 Request 객체에 기본 생성자를 넣을지 말지이다.
마찬가지로 리뷰어님께 질문했다.
🤔 본인
클라이언트가 요청한 데이터(RequestBody)를 Request객체로 받는다고 할 때 DTO에 기본생성자를 넣는게 나을지 궁금합니다. 🤔
저는 Request객체에 기본 생성자를 넣는 것을 선호합니다.
gradle을 이용할 경우 기본 생성자가 없어도 객체를 자동으로 생성해줄 수 있습니다.
IntelliJ로 설정할 경우는 기본 생성자 없으면 인자가 있는 생성자의 타입을 알지 못해 객체를 자동으로 생성할 수 없습니다.
저는 모든 곳에서 사용할 수 있도록 기본 생성자를 추가해줬는데 토니는 어떻게 생각하시는지 궁금합니다 !
요약하자면 리뷰어님도 마찬가지로 기본 생성자를 만드는 것을 권장하셨다.
💁♂️ 리뷰어
두번째 질문도 이야기 나눠봐요~!
Request 객체에 기본생성자를 넣는 것이 좋을까 ?
예외 케이스가 있을 때에는 더 포괄적으로 적용될 수 있는 방향으로 컨벤션을 정할 것 같아요.
즉 모두 기본 생성자를 넣을 것 같습니다.
어떤 경우는 넣고 어떤 경우는 넣지 않거나 그레이들 혹은 인텔리제이로 실행하는 방식에 따라 빼거나 넣기보다는 모든 경우에 대응할 수 있도록 통일할듯해요.
아래에서 설명할 -parameter 옵션의 유무와 같은 부분도 같이 개발하는 협업자가 알아차리기 어려우니까요.
직렬화 라이브러리인 잭슨이 기본 생성자가 없어도 처리할 수 있는 기능을 2.13 버전부터 추가했다고해요.
그리고 gradle을 사용하면 이를 바로 이용하구요.
이는 컴파일시 -parameter라는 옵션을 자동으로 넣어주어 처리한다네요.
하지만 intellij를 이용하면 컴파일 옵션을 따로 넣어주지 않아서 정상 동작하지 않아요.
인텔리제이 설정에서 해당 옵션을 넣어줄 경우에는 기본 생성자가 없어도 동작함을 확인할 수 있습니다.
이와 관련하여 잘 정리된 글을 공유해요 : https://mangkyu.tistory.com/223
만약 팀이
- Jackson 라이브러리를 2.13 버전 이상
- gradle 이용
- 컴파일시 -parameter 옵션
이런 식으로 맞추고 진행했다면 기본 생성자 없어도 좋을거 같다.
다만 확실하게 정해진게 없을 시에는 기본 생성자를 만들어서 예외가 발생하지 않도록 하자 !
🟧 03. 로그 레벨 정하기
전역 예외 처리를 구현했을 때 로그 레벨을 INFO, ERROR로 잡았다.
코드는 아래와 같다.
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(value = {
IllegalArgumentException.class,
HttpRequestMethodNotSupportedException.class,
MissingServletRequestParameterException.class,
MethodArgumentNotValidException.class,
HttpMediaTypeNotSupportedException.class,
HttpMessageNotReadableException.class,
MethodArgumentTypeMismatchException.class
})
public ResponseEntity<ErrorResponse> handleBadRequestException(Exception e) {
// BAD_REQUEST(400)을 응답한다.
// INFO
return info(e);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleInternalServerErrorException(Exception e) {
// INTERNAL_SERVER_ERROR(500)을 응답한다.
// ERROR
return error(e);
}
}
INFO - BAD_REQUEST (400)
- 클라이언트 측의 요청이 잘못됐을 경우
- 개발자는 신경쓰지 않아도 되는 수준
ERROR - INTERNAL_SERVER_ERROR (500)
- 서버 내부에서 에러가 발생했을 경우
- 개발자는 최대한 빨리 에러에 대처해야 한다.
이런 식으로 생각하며 구현했고 리뷰어님께서도 여쭤보셨다.
💁♂️ 리뷰어
info 로그에 대해서는 bad request, error 로그에 대해서는 internal server error를 반환하는군요~!
이렇게 처리해주신 이유가 궁금해요 🙂
🤔 본인
저는 다음과 같이 기준을 잡았습니다.
INFO : 클라이언트 측의 잘못된 요청(BAD_REQUEST)과 같은 상황으로 개발자가 신경쓰지 않아도 되는 부분입니다.
ERROR : 서버 내에서 개발자가 예측하지 못한 예외(INTERNAL_SEVER_ERROR)가 발생한 것으로 바로 확인해야하는 심각한 상황입니다.
💁♂️ 리뷰어
좋네요~! 저도 헤나의 기준에 공감해요.
추후에는 ERROR 같은 경우 알람이 가도록 설정하여 개발자가 인지하게 할 수도 있을거에요.
추가로 정보를 더 드리면 BAD_REQUEST는 같은 값으로 재시도하면 다시 실패할 확률이 높겠죠.
그러나 INTERNAL_SEVER_ERROR는 일시적인 서버의 오류일 수도 있어서 재시도하면 해결될 수도 있어요.
그래서 내부적으로 재시도 로직을 짜둘 수도 있답니다.
헤나의 기준을 공유해주셔서 감사합니다!
리뷰어의 얘기를 듣고 조금 더 나아갈 수 있었다.
전역 예외처리로 잡지 않은 예외들은 ERROR 레벨로 로그가 찍힌다.
그리고 심각한 경우가 아닌 일시적인 서버 오류라면 개발자가 긴급출동할 필요가 없다.
조금 더 상세하게 예외를 잡지 않는다면 ERROR 레벨의 로그에 맞지않지 않은 상황이 일어날 수 있다.
아직 어떤 상황에서 개발자가 긴급 출동을 해야할 만큼 중요한 에러가 발생하는지 경험하지 못했다.
(경험해서도 안되지만 ??)
로그 레벨을 적절하게 사용해야하는 것은 알고 있지만 예외 상황에 대해서 조금 더 학습이 필요하다고 느낀 부분이다.
🟧 04. 중첩 테스트
상품 엔티티 테스트에서 리뷰어가 조금 더 체계적으로 작성해보는 것도 좋을거 같다고 피드백해주셨다.
💁♂️ 리뷰어
이 테스트를 조금더 체계화 시켜볼까요...?! 중첩 테스트를 활용해보는거에요.
(참고 사이트 링크)
지금의 테스트가 올바르지 않다는 코멘트가 아니라 학습 차원에서 새로운 방식의 테스트를 소개해드리는 목적의 코멘트에요.
학습 후 적용해보시고 기존의 방식에 비해 어떤 장단점이 있는지를 알려주시면 좋겠네요~!
예시 구조)
상픔을 생성한다.
정상적으로 생성된다.
상품명이 올바르지 않은 경우
예외가 발생한다.
상품 가격이 올바르지 않은 경우
예외가 발생한다.
상품 사진이 올바르지 않은 경우
예외가 발생한다.
중첩 테스트는 많이 들어봤지만 실제로 사용해본 적은 없었다.
리뷰어님이 주신 사이트를 보면서 간단하게 학습 후에 바로 적용해봤다.
중접 테스트를 이용했을 때 코드는 조금 보기 어려운 감이 있었다.
이어서 읽기 위해 클래스명을 계층별로 읽어야 했기 때문이다.
연관되어 있는 테스트가 많아서 코드 관리도 쉽지 않다.
class ProductTest {
@DisplayName("상품을 생성할 때")
@Nested
class Validate {
@DisplayName("상품명 길이가 1이상 20이하가")
@Nested
class NameLength {
@ValueSource(strings = {"", " ", "012345678901234567890"})
@ParameterizedTest(name = "아닐 경우 예외가 발생한다. [상품명 : {0}]")
void greater(final String name) {
assertThatThrownBy(() -> new Product(1L, name, 300, "이미지-url"))
.isInstanceOf(IllegalArgumentException.class);
}
@ValueSource(strings = {
"0", "01", "012", "0123", "01234",
"0123456789012345", "01234567890123456", "012345678901234567", "0123456789012345678", "01234567890123456789"
})
@ParameterizedTest(name = "맞을 경우 생성 성공한다. [상품명 : {0}]")
void ok(final String name) {
assertDoesNotThrow(() -> new Product(1L, name, 300, "이미지-url"));
}
}
}
다만 계층 구조를 가지면서 테스트 결과를 읽기는 수월해졌다.
@Nested 테스트를 사용하면 테스트 결과를 읽기 수월하다는 장점이 있지만
점차 복잡해질 수 있다는 단점도 매우 크다.
간단한 상황일 경우에는 @Nested를 이용한 테스트가 좋을 수도 있겠지만
이번에 사용해보면서 이후 변경이 발생할 경우 같이 변경해야하는 부분도 많을 수 있으므로 자주 사용할거 같지는 않다고 느꼈다.
🟧 05. API 경로 설계
API 설계에 대해서 피드백 해주셨다.
@RequestMapping("/products")
@RestController
public class ProductApi {
}
💁♂️ 리뷰어
현업에서는 외부로 나가는 public/external와
내부에서만 사용하는 private/internal/system API 등으로 경로가 더 복잡해질 수 있어요.
추가로 API에 버저닝이 될 수도 있죠!
ex) apis/system/v2/products그렇기에 저는 이처럼 한 곳에서 관리하는 걸 선호하긴합니다.
이번 장바구니 미션을 보면 컨트롤러를 크게 두 종류로 나눌 수 있다.
- View를 응답하는 컨트롤러
- Json을 응답하는 컨트롤러
한결같은 내 URI를 보고 View를 응답하는 컨트롤러와 Json을 응답하는 컨트롤러를 구분해보자 !!
GET /
GET /admin
GET /products
POST /products
PATCH /products/{id}
DELETE /products/{id}
어렵다. 어떤 URI가 어떤 응답을 해줄지 알기 힘들다.
만약 여러 형태에 따라 구별 할 수 있도록 작성했다면 쉽게 파악할 수 있다.
GET /views/v1
GET /views/v1/admin
GET /apis/v1/products
POST /apis/v1/products
PATCH /apis/v1/products/{id}
DELETE /apis/v1/products/{id}
설계할 때 생각할 부분은 다음과 같다.
- URI를 통해서 무엇을 반환하는지 알 수 있다는 점
- 버전이 필요없는 경우는 v1이라는 경로를 추가하는 것은 불필요한 행동이라는 점
👊 정리하자면
- PUT 메서드로 자원을 수정할 때는 식별자값은 고려하지 않는다.
- 컨트롤러 파라미터에 있는 Request 객체는 기본 생성자를 만들어주자.
- 로그 레벨은 뜻이 있다. 그러니 어떤 상황에 어떤 로그 레벨을 사용할지 꼼꼼하게 생각해야 한다.
- @Nested를 이용한 중첩 테스트는 결과를 볼 때 수월하게 읽을 수 있지만 유지보수는 어려워진다.
- api 경로를 설계할 때 무엇을 어떤 버전으로 반환하게 될 지 명시하면 관리하기도 사용하기도 편리할 수 있다.
'👨🚀 우아한테크코스 5기' 카테고리의 다른 글
[20230521] 우아한테크코스 5기 LEVEL 2 - 지하철 미션, 추가 비용 정책을 데이터베이스에서 가져오려면 (0) | 2023.05.21 |
---|---|
[20230508] 우아한테크코스 5기 LEVEL 2 - 장바구니 미션 2단계 (0) | 2023.05.08 |
[20230424] 우아한테크코스 5기 LEVEL 2 - 웹 자동차 경주 1단계 & 2단계 회고 (3) | 2023.04.16 |
[20230329] 우아한테크코스 5기 LEVEL 1 - 레벨 인터뷰 회고 (0) | 2023.04.01 |
[20230327] 우아한테크코스 5기 LEVEL 1 - 체스 1단계 & 2단계 회고 (0) | 2023.04.01 |