Validation - 요청 그렇게 하는거 아닌데

날씨는 맑음·2022년 7월 23일
0

Spring

목록 보기
1/1
post-thumbnail

해당 글은 외부의 도움 없이 Validation을 수행하는 상태에서 Spring의 기능 이것 저것을 다 사용하며 Validation을 수행하는 상태까지 점진적으로 개선해 나가는 강의 같은 형식으로 진행됩니다. Spring에서 하는 방법만 궁금하신 분들은 앞쪽 챕터는 넘기시고 Java Bean Validation 쪽 부터 보시면 됩니다.

Validation이란?

validation은 한글로 번역하면 검증하다, 확인하다 정도로 해석된다. 프로그래밍에서는 현재 상태나 입력값을 확인하는 것을 validation이라 한다.

하지만 validation은 검증에서만 끝이 아니라 무엇이 잘못 되었을 때 클라이언트에 현재 요청이 잘못 되었음을 알려줘야 한다. 클라이언트님 요청 그렇게 하는거 아닌데..

즉, validation은 시스템이 잘못된 요청을 처리하지 못하도록 방지함과 동시에 무엇이 잘못 되었는지 까지 알려야 되는 것이다.

Validation 방법의 종류

Range Check

Range Check는 값의 범위를 검사하는 방식이다.

고기집에서는 한 번에 주문 할 수 있는 양이 1~10인분 까지로 제한되어있다면, 손님이 "여기 삼겹살 -5인분 주세요~" 또는 "여기 삼겹살 243,123,141인분 주세요"라고 하면 "한번에 1~10인분까지만 주문 할 수 있어요"라고 하며 정상적으로 주문이 이루어 지지 않을 것이다.

즉, 입력의 최소값(1)최대값(10)을 지정해 두고 입력이 해당 범위(range)안에 유효한지 확인하는 방식이다.

Type Check

Type check는 입력 값의 유형을 검사하는 방식이다.

돼지고기 집에서는 돼지고기만 주문할 수 있다. 근데 돼지고기 집 가서 "소고기 주세요"라고 하면 "저희 집은 돼지고기만 팔아요"라고 하며 정상적으로 주문이 이루어 지지 않을 것이다.

즉, 입력된 type(소고기)이 우리가 정해놓은 type(돼지고기)과 일치하는지 확인하는 방식이다.

Length check

Length check는 입력 값의 길이를 검사하는 방식이다.

고기를 구울 때, 불판의 지름 or 길이는 한정되어 있기 때문에 고기를 해당 불판의 길이에 맞게 잘라서 올려야 된다. 너무 길면 불판의 길이를 벗어나 테이블 위로 올라가버리니 문제가 될것이고, 너무 짧다면 불판 사이로 빠져버리니 문제가 될것이다.

즉, 입력 값의 길이가 예상했던 범위를 벗어나지는 않는지 확인하는 방법이다.

Range check와 비슷해 보이지만 조금 다르다. Ranage check는 보통 날짜 or 숫자 유형에 적용하는 방식이고 Length check는 문자열 유형 적용하는 방식이다.

Presence check

Presence check는 값 자체의 존재 유무를 확인하는 방식이다.

고기집에서 벨을 누르면 사장님은 손님이 뭘 주문할지 기다리는데 아무런 말도 하지 않고 있으면 사장님의 상태는 몰?루 상태가 된다. 무슨 말이라도 해주세요 손님..

즉, 입력이 존재 할것으로 예상 되었지만 아무런 입력도 오지 않아 아무것도 입력되지 않는 것을 막는 방식이다.

Check digit

Check digit는 숫자 값이 올바르게 입력되었는지 확인하는 방식이다.

전송 중에 값에 변조가 있거나 실수로라도 잘못 된 값이 입력되지 않도록 막는 방식이다. 해당 내용을 다 설명하면 현재 주제인 validation의 범위를 넘어가게 되니 궁금하면 찾아보는 것을 권장한다.

Spring 예제 코드로 확인하기

예제 소스 코드

아래와 같은 간단한 회원가입 요청을 받을 수 있는 컨트롤러가 있다고 가정하자.

@Slf4j
@RestController
public class ValidationController {

    @PostMapping("/signIn")
    public String signIn(@RequestBody Member member){
        log.info("회원 가입 성공={}", member);
        return "ok";
    }

    @Data
    static class Member{
        private String name;
        private Integer age;
    }
}

해당 컨트롤러로 아래와 같이 요청을 보내면 로그가 찍히히고 "ok"로 응답이 내려가는 간단한 예제이다

{
  "name": "날씨는 맑음",
  "age": 20
}
=================
회원 가입 성공=ValidationController.Member(name=날씨는 맑음, age=20)

이제 해당 값에 대해서 요구사항을 정의하고 점진적으로 validation을 개선하며 진행해보자.

  • name : 필수(Presence Check). 문자열(Type Check). 길이는 1~8자로 제한(Length Check).
  • age : 선택. 숫자(Type Check). 범위는 1~100으로 제한(Range Check).
  • 입력값이 유효하지 않으면 에러 메시지 전달

자체 Validation

일단 Spring Validation의 도움을 받지 않고 validation을 진행해 보자.

@PostMapping("/signIn")
public String signIn(@RequestBody Member member){

	if(member.getName() == null){
		return "이름을 입력해주세요.";
	}
	if(member.getName().length() < 1 || member.getName().length() > 8){
		return "이름은 1~8자 사이로 입력해주세요.";
	}
	if (member.getAge() != null && (member.getAge() < 1 || member.getAge() > 100)) {
		return "나이는 1~100 사이의 값으로 입력해주세요.";
	}

	log.info("회원 가입 성공={}", member);
	return "ok";
}

Controller의 코드가 지저분하긴 하지만 입력된 값에 대한 검증을 요구사항에 맞게 하고 응답도 잘 내려주고 있다.

하지만 실제로 응답을 받는 쪽에서 보면 HTTP 상태 코드가 200 OK로 표시되고 있다. HTTP에서는 응답에 대한 상태를 코드로 간단하게 표시해 줄 수 있는데, 분명 요청이 실패 되었지만 HTTP 코드가 200으로 내려오니 수정이 필요해 보인다.

Spring에서는 ResponseEntity를 이용해서 HTTP 상태 코드를 정의 할 수 있다. 현재 상태는 클라이언트의 잘못된 요청으로 실패한 경우이다. HTTP 상태 코드는 맨 앞자리 숫자에 의해서 그룹을 정할 수 있는데, 4xx번의 코드의 경우 클라이언트 측에서 잘못했을 경우 사용되는 그룹이다. 4xx번대에서도 현재는 사용자가 잘못된 요청을 보냈으므로 400 Bad Request를 응답으로 내려주면 적절할 듯 하다. 이제 실패한 경우 HTTP 상태 코드400 Bad Request으로 응답하도록 수정해보자.

@PostMapping("/signIn")
public ResponseEntity<String> signIn(@RequestBody Member member){

	if(member.getName() == null){
		return ResponseEntity.status(HttpStatus.BAD_REQUEST)
				.body("이름을 입력해주세요.");
	}
	if(member.getName().length() < 1 || member.getName().length() > 8){
		return ResponseEntity.status(HttpStatus.BAD_REQUEST)
				.body("이름은 1~8자 사이로 입력해주세요.");
	}
	if (member.getAge() != null && (member.getAge() < 1 || member.getAge() > 100)) {
		return ResponseEntity.status(HttpStatus.BAD_REQUEST)
				.body("나이는 1~100 사이의 값으로 입력해주세요.");
	}
	log.info("회원 가입 성공={}", member);
	return ResponseEntity.status(HttpStatus.CREATED)
			.body("OK");
}

수정하는 김에 정상적인 동작에도 응답 코드를 201 Created로 수정했다. 이제 HTTP 상태 코드도 제대로 내려주고 메시지도 제대로 내려준다. 하지만 컨트롤러의 코드가 지저분한 건 해결되지 않았다. 이 부분을 조금 해결해보자.

현재 validaiton에 의해 요청이 실패되면 ResponseEntity를 바로 반환하도록 하는데 RuntimeException을 던져서 다른 곳에서 한 번에 처리 할 수 있도록 해보자.

Srping에서는 @ExceptionHandler를 이용해서 특정 예외를 잡아내서 처리 할 수 있다. validation에 실패해서 던져지는 RuntimeException을 잡아서 해당 에러의 메시지를 body에 담아서 보내도록 하자.

@PostMapping("/signIn")
public ResponseEntity<String> signIn(@RequestBody Member member){

	if(member.getName() == null){
		throw new RuntimeException("이름을 입력해주세요.");
	}
	if(member.getName().length() < 1 || member.getName().length() > 8){
		throw new RuntimeException("이름은 1~8자 사이로 입력해주세요.");
	}
	if (member.getAge() != null && (member.getAge() < 1 || member.getAge() > 100)) {
		throw new RuntimeException("나이는 1~100 사이의 값으로 입력해주세요.");
	}

	log.info("회원 가입 성공={}", member);
	return ResponseEntity.status(HttpStatus.CREATED)
			.body("OK");
}

@ExceptionHandler(RuntimeException.class)
public ResponseEntity<String> invalidException(RuntimeException e){
	return ResponseEntity.status(HttpStatus.BAD_REQUEST)
			.body(e.getMessage());
}

❗️@ExceptionHandler 주의점
실제로 사용하실 때에는 RuntimeException을 상속해서 특정 상황에 맞는 예외를 만드는 것을 추천드립니다. @ExceptionHandler는 해당 에러의 부모 타입까지 다 잡아서 처리 할 수 있는데 Spring에서는 대부분의 에러와 Checked ExceptionUnchecked Exception으로 변환해서 RuntimeException의 하위 타입으로 던지도록 되어 있기 때문에 validation에서 실패해서 던져진 예외가 아닌 다른 계층에서 넘어온 예외까지 잡아서 처리하게 됩니다. 따라서 RuntimeExcpetion을 상속 받아, PropertyInvalidException 같은 예외를 만들어서 던져주는 것이 좋습니다.

이제 validation 코드 자체는 어느정도 괜찮아졌지만 컨트롤러에 validation하는 코드 자체가 들어와 있는 것이 마음에 들지 않는다. 리팩토링 기법 중에 Extract Method 기법을 이용해서 따로 분리해 내도록 하자. Extract Method를 쉽게 할 수 있도록 단축키를 제공한다. Mac 기준으로 대상 코드를 드래그 해서 option + command + m 단축키로 코드를 쉽게 분리 할 수 있다.

@PostMapping("/signIn")
public ResponseEntity<String> signIn(@RequestBody Member member){

	validationMember(member);

	log.info("회원 가입 성공={}", member);
	return ResponseEntity.status(HttpStatus.CREATED)
			.body("OK");
}

private void validationMember(Member member) {
	if(member.getName() == null){
		throw new RuntimeException("이름을 입력해주세요.");
	}
	if(member.getName().length() < 1 || member.getName().length() > 8){
		throw new RuntimeException("이름은 1~8자 사이로 입력해주세요.");
	}
	if (member.getAge() != null && (member.getAge() < 1 || member.getAge() > 100)) {
		throw new RuntimeException("나이는 1~100 사이의 값으로 입력해주세요.");
	}
}

이제 컨트롤러 내부에서 validationMember를 호출만 함으로써 컨트롤러의 로직이 깔끔해졌다. 하지만 아직 validation 로직에서는 조건을 한눈에 파악하기 어렵다. 해당 조건 식을 Member 내부로 옮기고 마무리하도록 하자.

@Slf4j
@RestController
public class ValidationController {

    @PostMapping("/signIn")
    public ResponseEntity<String> signIn(@RequestBody Member member){

        validationMember(member);

        log.info("회원 가입 성공={}", member);
        return ResponseEntity.status(HttpStatus.CREATED)
                .body("OK");
    }

    private void validationMember(Member member) {
        if(member.isNameEmpty()){
            throw new RuntimeException("이름을 입력해주세요.");
        }
        if(member.nameLengthCheck(1, 8)){
            throw new RuntimeException("이름은 1~8자 사이로 입력해주세요.");
        }
        if (member.ageRangeCheck(1, 100)) {
            throw new RuntimeException("나이는 1~100 사이의 값으로 입력해주세요.");
        }
    }

    @ExceptionHandler(RuntimeException.class)
    public ResponseEntity<String> invalidException(RuntimeException e){
        return ResponseEntity.status(HttpStatus.BAD_REQUEST)
                .body(e.getMessage());
    }

    @Data
    static class Member {
        private String name;
        private Integer age;

        public boolean isNameEmpty() {
            return getName() == null;
        }

        public boolean nameLengthCheck(int min, int max){
            return getName().length() < min || getName().length() > max;
        }

        public boolean ageRangeCheck(int min, int max){
            return getAge() != null && (getAge() < min || getAge() > max);
        }
    }
}

이제 각 코드들이 제자리에 잘 찾아가서 역할을 잘 수행하고 있는 것 같다. 이제 해당 코드를 Spring Validation의 도움을 받아서 수정해보자

Validator Interface

스프링에서는 Validator라는 인터페이스를 제공합니다. 해당 인터페이스를 이용해서 validation을 진행해보겠습니다.

public interface Validator {

    boolean supports(Class<?> clazz);
    void validate(Object target, Errors errors);
}
  • supprots(Class<?> clazz) : 제공된 Class 타입이 해당 Validator가 처리 할 수 있는 타입인지 확인할 때 사용
  • validate(Object target, Errors errors) : target에 대한 Validation을 진행하고 에러가 있는 경우 Errors에 등록

위의 Validator 인터페이스를 구현한 MemberValidator를 만듭니다.

@Component
public class MemberValidator implements Validator {

    @Override
    public boolean supports(Class<?> clazz) {
        return Member.class.equals(clazz);
    }

    @Override
    public void validate(Object target, Errors errors) {
        Member member = (Member) target;
        if(member.isNameEmpty()){
            errors.rejectValue("name", "name.empty","이름을 입력해주세요.");
        }
        else if(member.nameLengthCheck(1, 8)){
            errors.rejectValue("name", "name.length","이름은 1~8자 사이로 입력해주세요.");
        }
        else if (member.ageRangeCheck(1, 100)) {
            errors.rejectValue("age", "age.range", "나이는 1~100 사이의 값으로 입력해주세요.");
        }
    }
}

이제 위의 ValidatorController에서 주입받아서 사용해봅시다.

@RequiredArgsConstructor
public class ValidationController {

    private final MemberValidator memberValidator;
    
    ...
    
    private void validationMember(Member member) {
    Errors errors = new DirectFieldBindingResult(member, "Member");
    memberValidator.validate(member, errors);
    if(errors.hasErrors()){
        	ObjectError objectError = errors.getAllErrors().get(0);
        	throw new RuntimeException(objectError.getDefaultMessage());
    	}
	}
}

기존에 직접 구현했던 validation에서 validationMember()라는 메소드를 따로 만들어 뒀기 때문에 해당 메소드에서만 MemberValidator를 가져와 수정하면 됩니다.

Java Bean Validation

Spring에서는 Java Bean Validation에 대한 지원을 제공합니다.

Bean Validation은 constraint declaration과 metadata를 통해 Java applicaion에 대해 일반적인 validation을 제공합니다.

@Constraint Annotation

Bean Validation에는 @Constraint라는 어노테이션이 존재하고 해당 어노테이션이 붙은 곳에 대해 Validation을 수행 할 수 있습니다.

@Documented
@Target({ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface Constraint {
    Class<? extends ConstraintValidator<?, ?>>[] validatedBy();
}
@Null // null만 허용
@NotNull // null 허용 안함
@NotEmpty // null 허용 안함. 비어있지 않아야 함. "":X, " ":O
@NotBlack // null 허용 안함. 공백이 아닌 문자 최소 하나 이상을 포함해야 함. "" : X, " " : X

@AssertTrue // must be true, null are valid
@AssertFalse // must be false, null are valid

@Min // 지정된 값 이상만 허용
@Max  // 지정된 값 이하만 허용
@DecimalMin // @Min과 같으나 CharSequence 제외
@DecimalMax // @Max와 같으나 CharSequence 제외
@Negative // 음수만 허용. 0 is invalid
@NegativeOrZero // 음수와 0 허용
@Positive // 양수만 허용. 0 is invalid
@PositiveOrZero // 양수와 0 허용
@Size(min= , max= ) // min과 max 사이의 값만 허용
@Digits // 범위 내의 숫자만 허용

@Past // 과거만 허용
@PastOrPresent // 과거 또는 현재만 허용
@Future // 미래만 허용
@FutureOrPresent // 미래 또는 현재만 허용

@Pattern // 정규표현식에 의해 지정
@Email // Email 형식만 허용

Built-in Constraint

Constraint를 이용해 미리 정의된 다양한 어노테이션 들이 있습니다. 아래와 같이 @NotEmpty라는 타입에 @Constraint 어노테이션이 붙어 있는 것을 확인할 수 있습니다.

@Constraint(validatedBy = {})
...
public @interface NotEmpty {
    String message() default "{javax.validation.constraints.NotEmpty.message}";

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};

    @Target({ElementType.METHOD, ElementType.FIELD, ElementType.ANNOTATION_TYPE, ElementType.CONSTRUCTOR, ElementType.PARAMETER, ElementType.TYPE_USE})
    @Retention(RetentionPolicy.RUNTIME)
    @Documented
    public @interface List {
        NotEmpty[] value();
    }
}

사용해보기

이제 요구사항을 이제 Built-in Constraint를 이용해서 Member Class에 적용해 보자.

@Data
public class Member {

    @NotBlank(message = "이름을 입력해주세요.")
    @Length(min = 1, max = 8, message = "이름은 {min}~{max}자 사이로 입력해주세요.")
    private String name;

    @Range(min = 1, max = 100, message = "나이는 {min}~{max} 사이의 값으로 입력해주세요.")
    private Integer age;
}

이제 적용하기 전에 간단하게 테스트 코드를 통해 제대로 동작하는지 확인해 봅시다.

class MemberTest {

    Validator validator;

    @BeforeEach
    public void setupValidatorInstance(){
        validator = Validation.buildDefaultValidatorFactory().getValidator();
    }

    @Test
    public void whenNullName(){
        Member member = new Member();
        member.setAge(20);
        Set<ConstraintViolation<Member>> validate = validator.validate(member);
        printResult(validate);
        assertThat(validate.size()).isEqualTo(1);
    }

    private void printResult(Set<ConstraintViolation<Member>> validate) {
        validate.forEach(mv -> System.out.println(mv.getMessage()));
    }

    @Test
    public void whenOverLengthName(){
        Member member = new Member();
        member.setName("날씨는 맑음날씨는 맑음");
        member.setAge(20);
        Set<ConstraintViolation<Member>> validate = validator.validate(member);
        printResult(validate);
        assertThat(validate.size()).isEqualTo(1);
    }
    @Test
    public void whenOverRangeAge(){
        Member member = new Member();
        member.setName("날씨는 맑음");
        member.setAge(1000);
        Set<ConstraintViolation<Member>> validate = validator.validate(member);
        printResult(validate);
        assertThat(validate.size()).isEqualTo(1);
    }

    @Test
    public void validation_success(){
        Member member = new Member();
        member.setName("날씨는 맑음");
        member.setAge(20);
        Set<ConstraintViolation<Member>> validate = validator.validate(member);
        printResult(validate);
        assertThat(validate.size()).isEqualTo(0);
    }
}
========================
whenNullName() - 이름을 입력해주세요.
whenOverLengthName() - 이름은 1~8자 사이로 입력해주세요.
whenOverRangeAge() - 나이는 1~100 사이의 값으로 입력해주세요.
validation_success()

테스트 결과 모든 요구사항에 대해 제대로 동작하고 메시지도 이전과 같이 만들어진다.

이제 validation을 적용 할 곳에 @Validated 어노테이션을 붙여주면된다. 우리는 Controller에 validation을 적용 할 것이니 해당 메소드의 파라미터 타입에 붙여주도록 하자. 그리고 기존에 컨트롤러에서 호출하던 Validation 로직은 지워주도록 하자.

@PostMapping("/signIn")
public ResponseEntity<String> signIn(@Validated @RequestBody Member member){
    log.info("회원 가입 성공={}", member);
    return ResponseEntity.status(HttpStatus.CREATED)
            .body("OK");
}

Spring에서는 @Valid@Validated 둘 다 사용이 가능하다. @Validjavax.validation에 정의된 표준이고 @Validated는 그룹화 기능이 추가된 스프링에서 지원하는 기능이다. @Validated@Valid의 기능을 지원하기 때문에 대신 사용 할 수 있다.

이제 Spring에서 지원하는 Validation을 적용하면 컨트롤러에서는 validation과 관련된 로직을 완전히 몰라도 된다. validation도 잘 동작하고 코드도 깔끔해졌지만 사용자에게 에러 메시지를 전달 하지 못한다.

validation을 통과하지 못하게 메시지를 만들어서 보내면 로그에 MethodArgumentNotValidException이 발생해서 DefaultHandlerExceptionResolver가 처리했음을 알 수 있다. 우리는 위에서 예외를 잡아서 처리 할 수 있는 @ExceptionHandler를 알고있다. 하지만 컨트롤러 외부에서 발생했기 때문에 제대로 잡아서 처리하지 못한다.

해결 방법 1 - BindingResult

아까 Validator 인터페이스를 이용해서 BindingResult에서 에러를 확인하는 방식을 잠깐 봤었다. Controller에서는 @Validated 어노테이션이 붙은 파라미터 타입 뒤로 validation 결과가 저장된 BindingResult 타입의 파라미터를 받을 수 있다.

@PostMapping("/signIn")
public ResponseEntity<String> signIn(@Validated @RequestBody Member member, BindingResult result) {
    if(result.hasErrors()){
        ObjectError objectError = result.getAllErrors().get(0);
        throw new RuntimeException(objectError.getDefaultMessage());
    }
    log.info("회원 가입 성공={}", member);
    return ResponseEntity.status(HttpStatus.CREATED)
            .body("OK");
}

위와 같이 처리할 수 있지만 다시 컨트롤러 내부에 validation과 관련된 로직이 들어오게 된다. Validation의 도움을 받아 로직을 완전히 분리해 냈지만 다시 들어오니 모양새가 영 좋지못하다. 다행히 해결 방법이 하나 더 존재한다.

해결 방법 2 - @ControllerAdvice

@ExceptionHandler를 컨트롤러에 선언하면 해당 컨트롤러에서 발생한 예외만 받지만 전역적으로 발생한 예외를 @ControllerAdvice 어노테이션이 붙은 Class를 이용해서 처리 할 수 있다.

@RestControllerAdvice
public class ValidationAdvice {

    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public String invalidException(MethodArgumentNotValidException e){
        ObjectError objectError = e.getAllErrors().get(0);
        return objectError.getDefaultMessage();
    }
}

마무리

마지막 상태의 코드

@Slf4j
@RestController
public class ValidationController {

    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @PostMapping("/signIn")
    public String signIn(@Validated @RequestBody Member member) {
        log.info("회원 가입 성공={}", member);
        return "OK";
    }
}

=============================

@RestControllerAdvice
public class ValidationAdvice {

    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public String invalidException(MethodArgumentNotValidException e){
        ObjectError objectError = e.getAllErrors().get(0);
        return objectError.getDefaultMessage();
    }
}

=============================

@Data
public class Member {

    @NotBlank(message = "이름을 입력해주세요.")
    @Length(min = 1, max = 8, message = "이름은 {min}~{max}자 사이로 입력해주세요.")
    private String name;

    @Range(min = 1, max = 100, message = "나이는 {min}~{max} 사이의 값으로 입력해주세요.")
    private Integer age;
}

정리

Validation을 수행하기 위해 외부의 도움을 최소한으로 한 상태에서부터 최대한 도움을 받아 깔끔하게 정리하는 상태까지 점진적으로 개선하 나가면서 많은 것을 배웠습니다. 해당 내용을 혼자 공부하기엔 아깝다 생각하여 필요하신 분들이 있을까 싶어 공유한 내용이기 때문에 내용이 정확하지 않을 수 있습니다.

틀린 내용 또는 추가해야 할 내용이 있다면 댓글로 피드백 주시면 최대한 반영해서 수정해 보도록 하겠습니다.

완성된 결과에서 다듬어야 할 부분들이 많지만 코드를 줄이고 이해를 쉽게 하기 위해서 Validation에만 집중해서 코드를 작성했습니다. 추가로 궁금하신 부분은 댓글을 통해 질문 주시면 제 능력 선에서 아는한 답변드리도록 하겠습니다.

참고

0개의 댓글