이 글은 김영한님의 스프링 MVC 2편 - 백엔드 웹 개발 핵심 기술 강의중 검증편을 블로그장 입맛대로 요약한 것이며 강의 자료 및 출처는 가장 아래에서 확인할 수 있습니다.
컨트롤러의 중요한 역할중 하나는 HTTP 요청이 정상인지 검증하는 것이다.
먼저, 아이템을 등록하는 간단한 컨트롤러의 예시를 보자.
1. Map으로 검증 오류 결과 보관
@PostMapping("/add")
public String addItem(@ModelAttribute Item item, RedirectAttributes redirectAttributes, Model model) {
//검증 오류 결과를 보관
Map<String, String> errors = new HashMap<>();
//검증 로직
if(!StringUtils.hasText(item.getItemName())){
errors.put("itemName","상품 이름은 필수입니다.");
}
if(item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000){
errors.put("price","가격은 1,000 ~ 1,000,000 까지 허용합니다.");
}
if(item.getQuantity() == null || item.getQuantity() >= 9999){
errors.put("quantity","수량은 최대 9,999 까지 허용합니다.");
}
// 특정 필드가 아닌 복합 룰 검증
if(item.getPrice() != null && item.getQuantity() != null){
int resultPrice = item.getPrice() * item.getQuantity();
if(resultPrice < 10000){
errors.put("globalError","가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice);
}
}
// 검증에 실패하면 다시 입력 폼으로
if(!errors.isEmpty()){
model.addAttribute("errors",errors);
return "validation/v1/addForm";
}
//성공 로직
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v1/items/{itemId}";
}
errors라는 Map을 직접생성하여 Form에서 입력받은 데이터를 @ModelAttribute로 가져와 넣어주고 있는것을 확인할 수 있다.
하지만 위 상태라면 문제점이 있다. 만약 Item의 price같은 Integer필드에 문자가 들어온다면 오류가 발생한다.
스프링MVC는 HTTP 요청의 파라미터를 수신하고, 필드와 컨트롤러 메서드의 매개변수 간의 타입 변환을 자동으로 수행한다. 따라서 변환이 실패하게 되고, 컨트롤러의 내부 진입전에 400예외가 발생한다.
따라서 스프링은 BindingResult를 제공한다.
2. BindingResult - FieldError,ObjectError 사용
@PostMapping("/add")
public String addItemV1(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
log.info("필드 에러 리스트 :{}" , bindingResult.getFieldErrors()); // 검증 오류 발생시 Spring이 자동으로 FieldError를 생성하여 BindingResult에 넣어줌
// FieldError 생성자 > public FieldError(String objectName(@ModelAttribute 명), String field(오류가 발생한 필드 명), String defaultMessage(오류 메시지))
if (!StringUtils.hasText(item.getItemName())) {
bindingResult.addError(new FieldError("item", "itemName", "상품 이름은 필수입니다."));
}
if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) {
bindingResult.addError(new FieldError("item", "price", "가격은 1,000 ~ 1,000,000 까지 허용합니다."));
}
if (item.getQuantity() == null || item.getQuantity() > 10000) {
bindingResult.addError(new FieldError("item", "quantity", "수량은 최대 9,999 까지 허용합니다."));
}
//특정 필드 예외를 넘어서는 오류가 있다면 ObjectError 객체 사용
// ObjectError 생성자 > public ObjectError(String objectName(@ModelAttribute 명), String defaultMessage(오류 메시지))
if (item.getPrice() != null && item.getQuantity() != null) {
int resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 10000) {
bindingResult.addError(new ObjectError("item", "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice));
}
}
if(bindingResult.hasErrors()){
log.info("errors={}", bindingResult);
return "validation/v2/addForm";
}
// 성공 로직
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v2/items/{itemId}";
BindingResult가 없다면 400오류가 발생하며 컨트롤러가 호출되지 않고 오류 페이지로 이동하지만, 존재한다면 오류 정보(FieldError)를 BindingResult에 담아서 정상 호출한다.
실제로 form에서 잘못된 데이터를 넣고 위 코드에서 테스트를 위해 추가한 가장 상단의 bindingResult.getFieldErrors()에서 아래와같이 콘솔에 찍히는것을 확인할 수 있다.
검증 오류시 스프링이 자동으로 오류 정보FieldError객체를 생성하여 BindingResult에 넣어주는것을 확인할 수 있다.
타임리프를 사용한다면 #fields를 이용하여 BindingResult가 제공하는 검증 오류에 쉽게 접근할 수 있다.
* BindingResult
스프링이 제공하는 검증 오류를 보관하는 객체이다. 검증 오류가 발생하면 여기에 보관하면 된다.
BindingResult는 무조건 검증할 대상 바로 다음에 와야하며 자동으로 Model에 포함된다.
단순한 오류 저장과 조회 기능을 제공하는 Errors 인터페이스를 상속받고 있으며 그에 더해서 BindingResult는 추가적인 기능을 제공한다.
FieldError는 여러 생성자를 제공하는데 아래에서 살펴보자
new FieldError("item", "itemName", "상품 이름은 필수입니다.");
바로 위에서 사용한 FieldError의 생성자이다.
그러나 위의 상태라면 사용자가 입력한 값을 유지할수가 없다.
따라서 아래와 같이 사용할 수 있다.
new FieldError("item", "itemName", item.getItemName(),false,null,null,"상품 이름은 필수입니다.")
위에서 3번째 필드인 rejectedValue가 사용자가 입력한 값이된다. 나머지 파라미터는 아래를 참고하면 된다.
* FieldError 생성자
public FieldError(String objectName, String field, @Nullable Object rejectedValue, boolean bindingFailure, @Nullable String[] codes, @Nullable Object[] arguments, @Nullable String defaultMessage)
objectName : 오류가 발생한 객체 이름
field : 오류 필드
rejectedValue : 사용자가 입력한 값(거절된 값)
bindingFailure : 타입 오류 같은 바인딩 실패인지, 검증 실패인지 구분 값
codes : 메시지 코드
arguments : 메시지에서 사용하는 인자
defaultMessage : 기본 오류 메시지
위에서 codes와 arguments를 제공하는것을 확인할 수 있는데, 위 두 필드를 이용하여 메시지 파일로 관리해보자.
아래와 같이 errors.properties를 추가하고 errors.properties 파일을 인식하도록 application.properties에 코드를 추가한다.
required.item.itemName=상품 이름은 필수입니다.
range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
max.item.quantity=수량은 최대 {0} 까지 허용합니다.
totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}
spring.messages.basename=messages,errors
이후 코드 내부에서 new FieldErrors()생성자를 수정해준다.
new FieldError("item", "price", item.getPrice(),
false, new String[]{"range.item.price"}, new Object[]{1000, 1000000}, null)
codes : 메시지 코드는 하나가 아닌 배열로 여러 값을 전달할 수 있는데, 순서대로 매칭해서 처음 매칭되는 메시지가 사용된다.
arguments : Object[]{1000, 1000000}을 사용하여 코드의 {0}, {1}로 치환할 값을 전달한다.
이제 실행하면 아래와 같이 메시지에 설정된 값이 나타나는 것을 볼 수 있다.
그런데 위처럼 FieldError와 ObjectError는 다루기 번거롭기 때문에 오류 코드를 좀더 자동화할 필요가 있다.
다음단계에서 살펴보자.
3. BindingResult - rejectValue(), reject() 사용
@PostMapping("/add")
public String addItemV4(@ModelAttribute Item item, BindingResult bindingResult,
RedirectAttributes redirectAttributes) {
log.info("objectName={}", bindingResult.getObjectName());
log.info("target={}", bindingResult.getTarget());
if (!StringUtils.hasText(item.getItemName())) {
bindingResult.rejectValue("itemName", "required", null);
}
if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) {
bindingResult.rejectValue("price", "range", new Object[]{1000, 1000000}, null);
}
if (item.getQuantity() == null || item.getQuantity() > 10000) {
bindingResult.rejectValue("quantity", "max", new Object[]{9999}, null);
}
//특정 필드 예외가 아닌 전체 예외
if (item.getPrice() != null && item.getQuantity() != null) {
int resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 10000) {
bindingResult.reject("totalPriceMin", new Object[]{10000,
resultPrice}, null);
}
}
if (bindingResult.hasErrors()) {
log.info("errors={}", bindingResult);
return "validation/v2/addForm";
}
//성공 로직
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v2/items/{itemId}";
}
위와 같이 코드를 변경하고 실행하면 전단계와 똑같이 오류검증을 하는모습을 볼 수 있다.
컨트롤러에서 BindingResult는 검증해야 될 target(위에선 Item) 바로 다음에 오기때문에 이미 본인이 검증해야 될 target을 알고있다.
따라서 FieldError, ObjectError를 직접 생성하지 않고 rejectValue(), reject()를 사용하면 깔끔하게 검증 오류를 다룰 수 있다.
* rejectValue()
void rejectValue(@Nullable String field, String errorCode, @Nullable Object[] errorArgs, @Nullable String defaultMessage);
field : 오류 필드명
errorCode : 오류 코드(이 오류 코드는 메시지에 등록된 코드가 아니다. 뒤에서 설명할 messageResolver를 위한 오류 코드이다.)
errorArgs : 오류 메시지에서 {0} 을 치환하기 위한 값
defaultMessage : 오류 메시지를 찾을 수 없을 때 사용하는 기본 메시지
그런데 위 코드로만 오류검증이 되는것이 좀 이상하다. 전단계와 비교해보자
// 전단계
new FieldError("item", "price", item.getPrice(),
false, new String[]{"range.item.price"}, new Object[]{1000, 1000000}, null)
// 현단계
bindingResult.rejectValue("price", "range", new Object[]{1000, 1000000}, null)
전 단계는 FieldError에 "range.item.price"와 같이 오류 코드를 모두 선언하였지만, 현 단계는 "range"만 선언하였다.
결과는 둘다 프로퍼티에서 메시지를 꺼내왔다. 어떤 원리일까?
오류 코드와 메시지 처리
오류 코드를 만들때 "required.item.itemName"처럼 자세히 만들수도 있고, "required"처럼 단순하게 만들수도 있다.
단순하게 만들면 범용성이 좋아 여러곳에서 사용가능하고, 자세하게 만들면 메시지를 세밀하게 작성하는 대신 범용성이 낮아진다.
따라서 가장 좋은 방법은 범용성으로 사용하다가, 세밀하게 작성해야 하는 경우 메시지에 단계를 두는 방법이다.
예를 들면 아래와 같이 errors.properties에 선언하는 것이다.
#Level1
required.item.itemName: 상품 이름은 필수 입니다.
#Level2
required: 필수 값 입니다.
그렇다면 사용할때 범용성이 높은 메시지 또는 낮은 메시지를 선택해야 할텐데, 스프링은 MessageCodesResolver로 이런 기능을 지원하며, MessageCodesResolver는 구체적인 것에서 덜 구체적인 순서대로 코드를 찾는다.
MessageCodesResolver를 알아보기 위해 다음 코드를 실행해보자.
MessageCodesResolver codesResolver = new DefaultMessageCodesResolver();
String[] messageCodes = codesResolver.resolveMessageCodes("required","item","itemName",String.class);
for(String a : messageCodes){
System.out.println("test:"+a);
}
MessageCodesResolver는 인터페이스로 기본 구현체는 DefaultMessageCodesResolver이다.
결과는 아래와 같다.
DefaultMessageCodesResolver는 필드 오류 발생시 아래의 코드를 생성하는 것을 확인할 수 있다.
* DefaultMessageCodesResolver의 동작방식
필드 오류의 경우 다음 순서로 4가지 메시지 코드 생성
1.: code + "." + object name + "." + field
2.: code + "." + field
3.: code + "." + field type
4.: code
bindingResult.rejectValue("price", "range", new Object[]{1000, 1000000}, null)
위 코드를 예로들자면
codes [range.item.price, range.price, range.java.lang.Integer, range]
다음과 같이 생성될 것이다.
여기서 item값(object name)은 어떻게 알수있는가 묻는다면 위에서 언급했듯이 BindingResult는 target바로 뒤에 오기 때문에 객체값을 미리 알고있기때문이다.
예) 오류 코드: typeMismatch, object name "user", field "age", field type: int
1. "typeMismatch.user.age"
2. "typeMismatch.age"
3. "typeMismatch.int"
4. "typeMismatch"
rejectValue(), reject()는 내부에서 MessageCodesResolver를 사용하며, 메시지 코드들을 생성한다.
이제 동작 방식을 정리하면 아래와 같다.
1. rejectValue() 호출
2. MessageCodesResolver를 사용하여 메시지 코드 생성
3. new FieldError()를 생성하며 메시지 코드들을 보관
4. 타임리프 사용시 th:errors 에서 메시지 코드들로 메시지를 순서대로 찾고, 노출
타입이 안맞을때, 스프링은 BindingResult가 있을시 FieldError를 담아 컨트롤러를 호출한다고 했는데 그렇다면 타입이 안맞을때 FieldError는 뭘까?
숫자를 입력해야하는 price필드에 문자를 입력해보면 아래와 같이 로그를 확인할 수 있다.
codes[typeMismatch.item.price,typeMismatch.price,typeMismatch.java.lang.Integer,typ eMismatch]
이것은
typeMismatch.item.price
typeMismatch.price
typeMismatch.java.lang.Integer
typeMismatch
4가지 메시지 코드를 의미하며 스프링이 자동으로 typeMismatch 오류 코드를 등록한것을 확인할 수 있다.
따라서 error.properties에 다음을 추가하면
typeMismatch.java.lang.Integer=숫자를 입력해주세요.
typeMismatch=타입 오류입니다.
rejectValue()와 reject()를 이용하여 스프링이 자동등록한 오류 코드 또한 제어할수 있다
4. Validator 분리
스프링은 검증을 체계적으로 제공하기 위해 다음과 같은 인터페이스를 제공한다.
public interface Validator {
boolean supports(Class<?> clazz);
void validate(Object target, Errors errors);
}
supports는 해당 검증기를 지원하는 여부를 확인하며, validate는 검증 대상 객체(target)와 BindingResult(errors)로 내부에서 에러를 처리한다.
해당 인터페이스를 상속받은 구현체를 아래와 같이 생성한다.
@Component
public class ItemValidator implements Validator {
@Override
public boolean supports(Class<?> clazz) { // 해당 검증기를 지원하는 여부 확인
return Item.class.isAssignableFrom(clazz);
}
@Override
public void validate(Object target, Errors errors) { // 검증 대상 객체와 BindingResult
Item item = (Item) target;
/**
* ValidationUtils 사용전
* if(!StringUtils.hasText(item.getItemName()){
* bindingResult.rejectValue("itemName","required","기본: 상품 이름은 필수입니다.")
*
* ValidationUtils 사용후 > ValidationUtils는 공백 같은 단순한 기능만 제공
* ValidationUtils.rejectIfEmptyOrWhitespace(bindingResult, "itemName", "required");
*/
ValidationUtils.rejectIfEmptyOrWhitespace(errors, "itemName", "required");
if(item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000){
errors.rejectValue("price", "range",new Object[]{1000, 1000000}, null);
}
if (item.getQuantity() == null || item.getQuantity() > 10000) {
errors.rejectValue("quantity", "max", new Object[]{9999}, null);
}
//특정 필드 예외가 아닌 전체 예외
if (item.getPrice() != null && item.getQuantity() != null) {
int resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 10000) {
errors.reject("totalPriceMin", new Object[]{10000,
resultPrice}, null);
}
}
}
}
ValidationUtils.rejectIfEmptyOrWhitespace()는 .rejectValue()대신 위 주석과 같이 빈 값,공백 같은 단순한 기능을 제공한다.
컨트롤러를 살펴보자.
private final ItemValidator itemValidator;
@PostMapping("/add")
public String addItemV5(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
itemValidator.validate(item, bindingResult);
if (bindingResult.hasErrors()) {
log.info("errors={}", bindingResult);
return "validation/v2/addForm";
}
//성공 로직
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v2/items/{itemId}";
}
ItemValidator 빈을 직접 주입 받아서 호출하는 것을 확인할 수 있다.
위처럼 검증기를 직접 불러서 사용할수도 있는반면, 어노테이션으로 적용시킬 수 있는 방법도 있다.
WebDataBinder를 사용해보자.
5. WebDataBinder 사용
아래 코드를 추가하자.
@InitBinder
public void init(WebDataBinder dataBinder) {
log.info("init binder {}", dataBinder);
dataBinder.addValidators(itemValidator);
}
WebDataBinder는 스프링의 파라미터 바인딩의 역할을 해주고 검증 기능도 내부에 포함한다.
위 처럼 WebDataBinder에 검증기를 추가하면 해당 컨트롤러에서 검증기를 자동으로 적용할 수 있다.
여기서 해당 컨트롤러만 영향을 주는것이 @InitBinder이다.
컨트롤러를 살펴보자
@PostMapping("/add")
public String addItemV6(@Validated @ModelAttribute Item item, BindingResult bindingResult,RedirectAttributes redirectAttributes){
if(bindingResult.hasErrors()){
log.info("errors={}",bindingResult);
return "validation/v2/addForm";
}
//성공 로직
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v2/items/{itemId}";
}
전 단계는 아래와 같이 직접 검증기를 호출했다면
itemValidator.validate(item,bindingResult);
위 컨트롤러는 @Validated를 적용하여 자동으로 검증기를 호출하도록 변경하였다.
동작방식은 다음과 같다.
@Validated는 검증기를 실행하라는 어노테이션으로, 사용하게 되면 WebDataBinder에 등록한 검증기를 찾아서 실행한다.
만약 여러 검증기를 등록한다면 어떤 검증기를 사용해야 할지 모르기에 이때 supports()가 사용된다.
아까 ItemValidator에서 아래와 같이 supports()를 재정의 했었다.
@Component
public class ItemValidator implements Validator {
...
@Override
public boolean supports(Class<?> clazz) {
return Item.class.isAssignableFrom(clazz);
}
...
}
따라서 컨트롤러를 실행하면 위 supports()가 호출되어 true로 리턴되고 ItemValidator가 실행되게 된다.
만약 @InitBinder가 아닌 글로벌 설정은 아래와 같이 사용할 수 있다.
@SpringBootApplication
public class ItemServiceApplication implements WebMvcConfigurer {
public static void main(String[] args) {
SpringApplication.run(ItemServiceApplication.class, args);
}
@Override
public Validator getValidator() {
return new ItemValidator();
}
}
WebMvcConfigurer를 상속받아 getValidator()내부에 사용할 Validator를 등록한다.
6. Bean Validation 사용
위와 같이 직접 Validator를 등록하는 것은 번거로울수 있기 때문에 BeanValidation을 사용하면 간단하게 검증할 수 있다.
* BeanValidation 정리
Bean Validation은 구현체가 아닌 Bean Validation 2.0(JSR-380) 기술 표준으로, 검증 어노테이션과 여러 인터페이스의 모음이다. 대표적으로 실제로 구현한 기술은 hibernate Validator이다.( hibernate가 붙어서 ORM과 관련있어 보이지만 관련없다.)
javax.validation로 시작하면 특정 구현에 관계없이 제공되는 표준 인터페이스이고,
org.hibernate.validator로 시작하면 hibernate validator 구현체를 사용할 때만 제공되는 검증기능이다.
스프링 부트는 spring-boot-starter-validation 라이브러리를 넣으면 자동으로 Bean Validator를 인지하여 스프링에 통합하고, 자동으로 글로벌 Validator로 등록한다.
따라서 @Valid , @Validated 만 적용하면 된다
원리도 똑같이 검증 오류가 발생하면 , FieldError, ObjectError를 생성해서 BindingResult에 담아준다.
참고로 @Validated는 스프링 전용 검증 어노테이션이고, @Valid는 자바 표준 검증 어노테이션으로 둘중 아무거나 사용해도 동일하게 작동한다.
BeanValidation은 아래와 같이 적용하면 된다.
@Data
public class ItemSaveForm {
@NotBlank
private String itemName;
@NotNull
@Range(min = 1000, max = 100000)
private Integer price;
@NotNull
@Max(value = 9999)
private Integer quantity;
}
@NotBlank, @NotNull, @Range등등 모두 BeanValidation이 제공하는 어노테이션으로 위와같이 간편하게 사용할수있다.
* Bean Validation 메시지는 어떻게 등록하나?
Bean Validation도 기존에 했던 typeMismatch와 유사하게 MessageCodesResolver를 통해 아래와 같이 생성된다.
@NotBlank
NotBlank.item.itemName
NotBlank.itemName
NotBlank.java.lang.String
NotBlank
따라서 아래와 같이 추가하면 된다. ( {0} : 필드명 , {1},{2}... 는 각 어노테이션 별로 상이 )
#Bean Validation 추가
NotBlank={0} 공백X
Range={0}, {2} ~ {1} 허용
Max={0}, 최대 {1}
*BeanValidation의 메시지를 찾는 순서
1. 생성된 메시지 코드 순서대로 messageSource 에서 메시지 찾기
2. 애노테이션의 message 속성 사용 > @NotBlank(message = "공백! {0}")
3. 라이브러리가 제공하는 기본 값 사용 > 공백일 수 없습니다.
그렇다면, BeanValidation에서 특정 필드가 아닌 오브젝트 관련 오류는 어떻게 처리할까?
@ScriptAssert()를 사용하는 방법이 있지만 제약이 많고 복잡하기 때문에 위에서 사용한것과 같이 자바코드로 직접 사용하는것을 권장한다고 한다.
if (item.getPrice() != null && item.getQuantity() != null) {
int resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 10000) {
bindingResult.reject("totalPriceMin", new Object[]{10000,resultPrice}, null);
}
}
BeanValidation은 @RequestBody에도 적용할 수 있는데 주의할 점이 있다.
@ModelAttribute로 호출시 특정 필드 타입이 달라 바인딩 되지 않아도 나머지 필드는 정상 바인딩 되어, 컨트롤러가 정상 호출된다.
그러나 @RequestBody는 HttpMessageConverter의 변환단계에서 오류가 발생하여 예외가 발생한다.
따라서 컨트롤러 자체가 호출되지 않고, Validator도 적용할 수 없다.
HttpMessageConverter는 @ModelAttribute와 달리 필드 개별 적용이 아닌, 전체 객체 단위로 적용되기 때문이다.
* 출처 자료
https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-mvc-2/dashboard
'Spring' 카테고리의 다른 글
Spring DB 정리 1) JDBC,커넥션풀과 데이터소스 (0) | 2023.12.28 |
---|---|
Spring MVC 정리 4) 예외 처리 (0) | 2023.12.18 |
Spring MVC 정리 2)단계별로 구현하며 알아보는 스프링의 핵심 기술 (0) | 2023.10.25 |
Spring MVC 정리 1)웹 어플리케이션의 이해 (0) | 2023.10.20 |
빈의 생명주기와 스코프 , DL(의존성 검색) (0) | 2023.09.22 |