검증 2 - Bean Validation 2

LeeKyoungChang·2022년 1월 28일
1
post-thumbnail

'스프링 MVC 2편 - 백엔드 웹 개발 활용 기술' 수업을 듣고 정리한 내용입니다.

 

📚 1. Bean Validation - 한계

데이터를 등록할 때와 수정할 때는 요구사항이 다를 수 있다.

 

📣 등록시 기존 요구사항
(1) 타입 검증

  • 가격, 수량에 문자가 들어가면 검증 오류 처리

(2) 필드 검증

  • 상품명: 필수, 공백X
  • 가격: 1000원 이상, 1백만원 이하
  • 수량: 최대 9999

(3) 특정 필드의 범위를 넘어서는 검증

  • 가격 * 수량의 합은 10,000원 이상

 

📣 수정시 요구사항
등록시 vs 수정시 수량(quantity)

  • 등록시에는 quantity 수량을 최대 9999까지 등록할 수 있다.
  • 수정시에는 수량을 무제한으로 변경할 수 있다.

등록시 vs 수정시 아이디(id)

  • 등록시에는 id 에 값이 없어도 된다.
  • 수정시에는 id 값이 필수이다.

 

📖 A. 수정 요구사항 적용

수정시에는 Item 에서 id 값이 필수이고, quantity 도 무제한으로 적용할 수 있다.

 

package hello.itemservice.domain.item;

import lombok.Data;
import org.hibernate.validator.constraints.Range;
import javax.validation.constraints.Max;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;

@Data
public class Item {

    @NotNull // 수정 요구사항 추가
    private Long id;

    @NotBlank
    private String itemName;

    @NotNull
    @Range(min = 1000, max = 1000000)
    private Integer price;

    @NotNull
    //@Max(9999)  // 수정 요구사항 추가
    private Integer quantity;

    public Item() {
    }

    public Item(String itemName, Integer price, Integer quantity) {
        this.itemName = itemName;
        this.price = price;
        this.quantity = quantity;
    }
}

 

💡 참고

  • 현재 구조에서는 수정시 itemid 값은 항상 들어있도록 로직이 구성되어 있다.
  • 검증하지 않아도 된다고 생각할 수도 있다.
  • 그런데 HTTP 요청은 언제든지 악의적으로 변경해서 요청할 수 있으므로 서버에서 항상 검증해야 한다.
  • 예를 들어서 HTTP 요청을 변경해서 itemid 값을 삭제하고 요청할 수도 있다.
  • 따라서 최종 검증은 서버에서 진행하는 것이 안전한다.

 

실행 결과

  • 수정은 정상 실행된다.
스크린샷 2022-01-27 오후 6 11 45

 

그런데 수정은 잘 동작하지만 등록에서 문제가 발생한다.

스크린샷 2022-01-27 오후 6 12 08
  • 등록시에는 id 에 값도 없고, quantity 수량 제한 최대 값인 9999도 적용되지 않는 문제가 발생한다. (위에 있는 소스를 보면 @NotNull, @MAX 추가 및 삭제로 인해)

 

등록시 화면이 넘어가지 않으면서 다음과 같은 오류를 볼 수 있다.

스크린샷 2022-01-27 오후 6 12 28

'id': rejected value [null];

  • 왜냐하면 등록시에는 id 에 값이 없다.
  • 따라서 @NotNull id 를 적용한 것 때문에 검증에 실패하고 다시 폼 화면으로 넘어온다.
스크린샷 2022-01-27 오후 6 15 00
  • 결국 등록 자체도 불가능하고, 수량 제한도 걸지 못한다.

 

결과적으로 item 은 등록과 수정에서 검증 조건의 충돌이 발생하고, 등록과 수정은 같은 BeanValidation 을 적용할 수 없다.

 

📚 2. Bean Validation - groups

동일한 모델 객체를 등록할 때와 수정할 때 각각 다르게 검증하는 방법을 알아보자!

 

📣 방법 2가지
(1) BeanValidationgroups 기능을 사용한다.
(2) Item을 직접 사용하지 않고, ItemSaveForm, ItemUpdateForm 같은 폼 전송을 위한 별도의 모델 객체를 만들어서 사용한다.

 

📖 A. BeanValidation groups 기능 사용

  • 이런 문제를 해결하기 위해 Bean Validationgroups라는 기능을 제공한다.
  • 예를 들어서 등록시에 검증할 기능과 수정시에 검증할 기능을 각각 그룹으로 나누어 적용할 수 있다.

 

(1) groups 적용

저장용 groups 생성

package hello.itemservice.domain.item;
  public interface SaveCheck {
}

 

수정용 groups 생성

package hello.itemservice.domain.item;
  public interface UpdateCheck {
}

 

Item - groups 적용

package hello.itemservice.domain.item;

import lombok.Data;
import org.hibernate.validator.constraints.Range;
import javax.validation.constraints.Max;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;

@Data
public class Item {

    @NotNull(groups = UpdateCheck.class) // 수정 요구사항 추가
    private Long id;

    @NotBlank(groups = {SaveCheck.class, UpdateCheck.class})
    private String itemName;

    @NotNull(groups = {SaveCheck.class, UpdateCheck.class})
    @Range(min = 1000, max = 1000000, groups = {SaveCheck.class, UpdateCheck.class})
    private Integer price;

    @NotNull(groups = {SaveCheck.class, UpdateCheck.class})
    @Max(value = 9999, groups = {SaveCheck.class}) // 등록시에만 적용
    private Integer quantity;

    public Item() {
    }

    public Item(String itemName, Integer price, Integer quantity) {
        this.itemName = itemName;
        this.price = price;
        this.quantity = quantity;
    }
}

 

ValidationItemControllerV3 - 저장 로직에 SaveCheck Groups 적용

@PostMapping("/add")
  public String addItemV2(@Validated(SaveCheck.class) @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {

	  //...

}
  • addItem() 를 복사해서 addItemV2() 생성, SaveCheck.class 적용
  • 기존 addItem() @PostMapping("/add") 주석처리

 

ValidationItemControllerV3 - 수정 로직에 UpdateCheck Groups 적용

@PostMapping("/{itemId}/edit")
public String editV2(@PathVariable Long itemId, @Validated(UpdateCheck.class) @ModelAttribute Item item, BindingResult bindingResult) {

		//...
	
}
  • edit() 를 복사해서 editV2() 생성, UpdateCheck.class 적용
  • 기존 edit() @PostMapping("/{itemId}/edit") 주석처리

 

💡 참고

  • @Valid 에는 groups를 적용할 수 있는 기능이 없다.
  • 따라서 groups를 사용하려면 @Validated 를 사용해야 한다.

 

실행

  • http://localhost:8080/validation/v3/items
스크린샷 2022-01-28 오전 11 01 14

 

📌 정리

  • groups 기능을 사용해서 등록과 수정시에 각각 다르게 검증을 할 수 있었다.
  • 그런데 groups 기능을 사용하니 Item 은 물론이고, 전반적으로 복잡도가 올라갔다.
  • 실무에서 groups 기능은 잘 사용되지 않는다.
    • 실무에서는 주로 다음에 등장하는 등록용 폼 객체와 수정용 폼 객체를 분리해서 사용하기 때문에 groups를 사용하지 않는다.

 

📚 3. Form 전송 객체 분리 - 소개

  • v3v4로 변경
  • 실무에서는 groups 를 잘 사용하지 않는다.
  • 바로 등록시 폼에서 전달하는 데이터가 Item 도메인 객체와 딱 맞지 않기 때문이다.
  • 실무에서는 회원 등록시 회원과 관련된 데이터만 전달받는 것이 아니라, 약관 정보도 추가로 받는 등 Item 과 관계없는 수 많은 부가 데이터가 넘어온다.
  • 그래서 보통 Item 을 직접 전달받는 것이 아니라, 복잡한 폼의 데이터를 컨트롤러까지 전달할 별도의 객체를 만들어서 전달한다.
  • 예를 들면 ItemSaveForm 이라는 폼을 전달받는 전용 객체를 만들어서 @ModelAttribute 로 사용한다.
  • 이것을 통해 컨트롤러에서 폼 데이터를 전달 받고, 이후 컨트롤러에서 필요한 데이터를 사용해서 Item 을 생성한다.

 

✓ 폼 데이터 전달에 Item 도메인 객체 사용

HTML Form → Item → Controller → Item → Repository
  • 장점: Item 도메인 객체를 컨트롤러, 리포지토리 까지 직접 전달해서 중간에 Item을 만드는 과정이 없어서 간단하다.
  • 단점: 간단한 경우에만 적용할 수 있다. 수정시 검증이 중복될 수 있고, groups를 사용해야 한다.

 

✓ 폼 데이터 전달을 위한 별도의 객체 사용

HTML Form → ItemSaveForm → Controller → Item 생성 → Repository
  • 장점: 전송하는 폼 데이터가 복잡해도 거기에 맞춘 별도의 폼 객체를 사용해서 데이터를 전달 받을 수 있다. 보통 등록과, 수정용으로 별도의 폼 객체를 만들기 때문에 검증이 중복되지 않는다.
  • 단점: 폼 데이터를 기반으로 컨트롤러에서 Item 객체를 생성하는 변환 과정이 추가된다.

 

  • Item 도메인 객체를 폼 전달 데이터로 사용하고, 그대로 쭉 넘기면 편리하겠지만,
  • 실무에서는 Item 의 데이터만 넘어오는 것이 아니라 무수한 추가 데이터가 넘어온다.
  • 그리고 Item 을 생성하는데 필요한 추가 데이터를 데이터베이스나 다른 곳에서 찾아와야 할 수도 있다.

 

  • 따라서 이렇게 폼 데이터 전달을 위한 별도의 객체를 사용하고, 등록, 수정용 폼 객체를 나누면 등록, 수정이 완전히 분리되기 때문에 groups 를 적용할 일은 드물다.

 

💡 참고
Q: 이름은 어떻게 지어야 하나요?

  • 이름은 의미있게 지으면 된다.
  • ItemSave 라고 해도 되고, ItemSaveForm , ItemSaveRequest , ItemSaveDto 등으로 사용해도 된다.
  • 중요한 것은 일관성이다.

Q: 등록, 수정용 뷰 템플릿이 비슷한데 합치는게 좋을까요?

  • 뷰 템플릿 파일을 등록과 수정을 합치는게 좋을지 고민이 될 수 있다.
  • 각각 장단점이 있으므로 고민하는게 좋지만, 어설프게 합치면 수 많은 분기분(등록일 때, 수정일 때) 때문에 나중에 유지보수에서 고통을 맛본다.
  • 이런 어설픈 분기분들이 보이기 시작하면 분리해야 할 신호이다.
  • 등록과 수정용 뷰 템플릿은 분리하는게 좋다!

 

📚 4. Form 전송 객체 분리 - 개발

ITEM 원복

이제 Item 의 검증은 사용하지 않으므로 검증 코드를 제거해도 된다.

 

package hello.itemservice.domain.item;

import lombok.Data;
import org.hibernate.validator.constraints.Range;
import javax.validation.constraints.Max;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;

@Data
public class Item {


    private Long id;
    private String itemName;
    private Integer price;
    private Integer quantity;

    public Item() {
    }

    public Item(String itemName, Integer price, Integer quantity) {
        this.itemName = itemName;
        this.price = price;
        this.quantity = quantity;
    }
}

 

ItemSaveForm - ITEM 저장용 폼

package hello.itemservice.web.validation.Form;

import lombok.Data;
import org.hibernate.validator.constraints.Range;

import javax.validation.constraints.Max;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;

@Data
public class ItemSaveForm {

    @NotBlank
    private String itemName;

    @NotNull
    @Range(min = 1000, max = 1000000)
    private Integer price;

    @NotNull
    @Max(value = 9999)
    private Integer quantity;


}

 

ItemUpdateForm - ITEM 수정용 폼

package hello.itemservice.web.validation.Form;

import lombok.Data;
import org.hibernate.validator.constraints.Range;

import javax.validation.constraints.Max;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;

@Data
public class ItemUpdateForm {

    @NotNull
    private Long id;

    @NotBlank
    private String itemName;

    @NotNull
    @Range(min = 1000, max = 1000000)
    private Integer price;

    // 수정에서 수량은 자유롭게 변경할 수 있다.
    private Integer quantity;


}

 

ValidationItemControllerV4

이제 등록, 수정용 폼 객체를 사용하도록 컨트롤러를 수정하자.

 

package hello.itemservice.web.validation;

import hello.itemservice.domain.item.Item;
import hello.itemservice.domain.item.ItemRepository;
import hello.itemservice.web.validation.Form.ItemSaveForm;
import hello.itemservice.web.validation.Form.ItemUpdateForm;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;

import java.util.List;

@Slf4j
@Controller
@RequestMapping("/validation/v4/items")
@RequiredArgsConstructor
public class ValidationItemControllerV4 {

    private final ItemRepository itemRepository;
    @GetMapping
    public String items(Model model) {
        List<Item> items = itemRepository.findAll();
        model.addAttribute("items", items);
        return "validation/v4/items";
    }

    @GetMapping("/{itemId}")
    public String item(@PathVariable long itemId, Model model) {
        Item item = itemRepository.findById(itemId);
        model.addAttribute("item", item);
        return "validation/v4/item";
    }

    @GetMapping("/add")
    public String addForm(Model model) {
        model.addAttribute("item", new Item());
        return "validation/v4/addForm";
    }

    @PostMapping("/add")
    public String addItem(@Validated @ModelAttribute("item") ItemSaveForm form, BindingResult bindingResult, RedirectAttributes redirectAttributes) {

        //특정 필드 예외가 아닌 전체 예외
        if (form.getPrice() != null && form.getQuantity() != null) {
            int resultPrice = form.getPrice() * form.getQuantity();
            if (resultPrice < 10000) {
                bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
            }
        }

        // 검증에 실패하면 다시 입력 폼으로
        if (bindingResult.hasErrors()) {
            log.info("errors = {}", bindingResult);
            return "validation/v4/addForm";
        }

        // 성공 로직
        Item item = new Item();
        item.setItemName(form.getItemName());
        item.setPrice(form.getPrice());
        item.setPrice(form.getQuantity());
        item.setQuantity(form.getQuantity());

        Item savedItem = itemRepository.save(item);
        redirectAttributes.addAttribute("itemId", savedItem.getId());
        redirectAttributes.addAttribute("status", true);
        return "redirect:/validation/v4/items/{itemId}";
    }


    @GetMapping("/{itemId}/edit")
    public String editForm(@PathVariable Long itemId, Model model) {
        Item item = itemRepository.findById(itemId);
        model.addAttribute("item", item);
        return "validation/v4/editForm";
    }

    @PostMapping("/{itemId}/edit")
        public String edit(@PathVariable Long itemId, @Validated @ModelAttribute("item") ItemUpdateForm form, BindingResult bindingResult) {

        //특정 필드 예외가 아닌 전체 예외
        if (form.getPrice() != null && form.getQuantity() != null) {
            int resultPrice = form.getPrice() * form.getQuantity();
            if (resultPrice < 10000) {
                bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
            }
        }

        if(bindingResult.hasErrors()){
            log.info("errors={}", bindingResult);
            return "validation/v4/editForm";
        }


        Item itemParam = new Item();
        itemParam.setItemName(form.getItemName());
        itemParam.setPrice(form.getPrice());
        itemParam.setQuantity(form.getQuantity());

        itemRepository.update(itemId, itemParam);
        return "redirect:/validation/v4/items/{itemId}";
    }

}

 

폼 객체 바인딩

@PostMapping("/add")
public String addItem(@Validated @ModelAttribute("item") ItemSaveForm form, BindingResult bindingResult, RedirectAttributes redirectAttributes) {

		// ...
		
}
  • Item 대신에 ItemSaveform 을 전달 받는다.
  • 그리고 @Validated 로 검증도 수행하고, BindingResult로 검증 결과를 받는다.

 

⚠️ 주의

  • @ModelAttribute("item")item 이름을 넣어준 부분을 주의하자❗
  • 이것을 넣지 않으면 ItemSaveForm 의 경우 규칙에 의해 itemSaveForm 이라는 이름으로 MVC Model에 담기게 된다.
  • 이렇게 되면 뷰 템플릿에서 접근하는 th:object 이름도 함께 변경해주어야 한다.

 

폼 객체를 Item으로 변환

//성공 로직
Item item = new Item();
item.setItemName(form.getItemName()); 
item.setPrice(form.getPrice()); 
item.setQuantity(form.getQuantity());

Item savedItem = itemRepository.save(item);
  • 폼 객체의 데이터를 기반으로 Item 객체를 생성한다.
  • 이렇게 폼 객체 처럼 중간에 다른 객체가 추가되면 변환하는 과정이 추가된다.

 

수정


@PostMapping("/{itemId}/edit")
public String edit(@PathVariable Long itemId, @Validated @ModelAttribute("item") ItemUpdateForm form, BindingResult bindingResult) {

		// ...
	
}
  • 수정의 경우도 등록과 같다.
  • 그리고 폼 객체를 Item 객체로 변환하는 과정을 거친다.

 

수정 실행

  • 입력에만 수량에 입력 제한이 있다.
  • 수정에는 수량에 입력 제한이 없다.
  • http://localhost:8080/validation/v4/items
스크린샷 2022-01-28 오후 12 59 36

 

📌 정리

  • Form 전송 객체 분리해서 등록과 수정에 딱 맞는 기능을 구성하고, 검증도 명확히 분리했다.

 

📚 5. Bean Validation - HTTP 메시지 컨버터

@Valid , @ValidatedHttpMessageConverter ( @RequestBody )에도 적용할 수 있다.

 

💡 참고

  • @ModelAttribute 는 HTTP 요청 파라미터(URL 쿼리 스트링, POST Form)를 다룰 때 사용한다.
  • @RequestBody 는 HTTP Body의 데이터를 객체로 변환할 때 사용한다. 주로 API JSON 요청을 다룰 때 사용한다.

 

ValidationItemApiController 생성

package hello.itemservice.web.validation;

import hello.itemservice.web.validation.Form.ItemSaveForm;
import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;
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;

@Slf4j
@RestController
@RequestMapping("/validation/api/items")
public class ValidationItemApiController {

    @PostMapping("/add")
    public Object addItem(@RequestBody @Validated ItemSaveForm form, BindingResult bindingResult) {
        log.info("API 컨트롤러 호출");

        if (bindingResult.hasErrors()) {
            log.info("검증 오류 발생 errors={}", bindingResult);
            return bindingResult.getAllErrors();
        }

        log.info("성공 로직 실행");
        return form;
    }
}

 

📖 A. Postman을 사용해서 테스트

🔔 API의 3가지 경우

  • 성공 요청: 성공
  • 실패 요청: JSON을 객체로 생성하는 것 자체가 실패함
  • 검증 오류 요청: JSON을 객체로 생성하는 것은 성공했고, 검증에서 실패함

 

(1) 성공 요청

Postman에서 Body → raw → JSON을 선택해야 한다.

스크린샷 2022-01-28 오후 1 21 44

 

성공 요청 로그

  • API 컨트롤러 호출
  • 성공 로직 실행
스크린샷 2022-01-28 오후 1 21 26

 

(2) 실패 요청

price 의 값에 숫자가 아닌 문자를 전달해서 실패하게 만들어보자.

스크린샷 2022-01-28 오후 1 22 23

 

실패 요청 로그

.w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.http.converter.HttpMessageNotReadableException: JSON parse error: Cannot deserialize value of type `java.lang.Integer` from String "qqq": not a valid Integer value; nested exception is com.fasterxml.jackson.databind.exc.InvalidFormatException: Cannot deserialize value of type `java.lang.Integer` from String "qqq": not a valid Integer value
 at [Source: (PushbackInputStream); line: 1, column: 30] (through reference chain: hello.itemservice.web.validation.Form.ItemSaveForm["price"])]
  • HttpMessageConverter 에서 요청 JSON을 Item 객체로 생성하는데 실패한다.
  • 이 경우는 Item 객체를 만들지 못하기 때문에 컨트롤러 자체가 호출되지 않고 그 전에 예외가 발생한다.
  • 물론 Validator도 실행되지 않는다.

 

(3) 검증 오류 요청

이번에는 HttpMessageConverter 는 성공하지만 검증(Validator)에서 오류가 발생하는 경우를 확인해보자.

스크린샷 2022-01-28 오후 1 29 16
  • 수량( quantity )이 10000 이면 BeanValidation @Max(9999) 에서 걸린다.

 

검증 오류 결과

[
    {
        "codes": [
            "Max.itemSaveForm.quantity",
            "Max.quantity",
            "Max.java.lang.Integer",
            "Max"
        ],
        "arguments": [
            {
                "codes": [
                    "itemSaveForm.quantity",
                    "quantity"
                ],
                "arguments": null,
                "defaultMessage": "quantity",
                "code": "quantity"
            },
            9999
        ],
        "defaultMessage": "9999 이하여야 합니다",
        "objectName": "itemSaveForm",
        "field": "quantity",
        "rejectedValue": 10000,
        "bindingFailure": false,
        "code": "Max"
    }
]
  • return bindingResult.getAllErrors();ObjectErrorFieldError 를 반환한다.
  • 스프링이 이 객체를 JSON으로 변환해서 클라이언트에 전달했다.
  • 여기서는 예시로 보여주기 위해서 검증 오류 객체들을 그대로 반환했다.
  • 실제 개발할 때는 이 객체들을 그대로 사용하지 말고, 필요한 데이터만 뽑아서 별도의 API 스펙을 정의하고 그에 맞는 객체를 만들어서 반환해야 한다.

 

검증 오류 요청 로그

2022-01-28 13:58:24.494  INFO 2489 --- [nio-8080-exec-6] h.i.w.v.ValidationItemApiController      : API 컨트롤러 호출
2022-01-28 13:58:24.494  INFO 2489 --- [nio-8080-exec-6] h.i.w.v.ValidationItemApiController      : 검증 오류 발생 errors=org.springframework.validation.BeanPropertyBindingResult: 1 errors
Field error in object 'itemSaveForm' on field 'quantity': rejected value [10000]; codes [Max.itemSaveForm.quantity,Max.quantity,Max.java.lang.Integer,Max]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [itemSaveForm.quantity,quantity]; arguments []; default message [quantity],9999]; default message [9999 이하여야 합니다]
  • 로그를 보면 검증 오류가 정상 수행된 것을 확인할 수 있다.

 

📖 B. @ModelAttribute vs @RequestBody

  • HTTP 요청 파리미터를 처리하는 @ModelAttribute 는 각각의 필드 단위로 세밀하게 적용된다.
  • HttpMessageConverter@ModelAttribute 와 다르게 각각의 필드 단위로 적용되는 것이 아니라, 전체 객체 단위로 적용된다.
    • 따라서 메시지 컨버터의 작동이 성공해서 Item 객체를 만들어야 @Valid , @Validated 가 적용된다.

 

@ModelAttribute

  • 필드 단위로 정교하게 바인딩이 적용된다.
  • 특정 필드가 바인딩 되지 않아도 나머지 필드는 정상 바인딩 되고, Validator를 사용한 검증도 적용할 수 있다.

@RequestBody

  • HttpMessageConverter 단계에서 JSON 데이터를 객체로 변경하지 못하면 이후 단계 자체가 진행되지 않고 예외가 발생한다.
  • 컨트롤러도 호출되지 않고, Validator도 적용할 수 없다.

 

💡 참고

  • HttpMessageConverter 단계에서 실패하면 예외가 발생한다.
  • 예외 발생시 원하는 모양으로 예외를 처리하는 방법은 예외 처리 부분에서 다룬다.

 


참고

profile
"야, (오류 만났어?) 너두 (해결) 할 수 있어"

0개의 댓글