개발자는 기록이 답이다

@RequestBody는 어떻게 바인딩 되는걸까? (with. 디버깅 과정) 본문

Spring/트러블 슈팅

@RequestBody는 어떻게 바인딩 되는걸까? (with. 디버깅 과정)

slow-walker 2024. 2. 9. 14:42

 

 

 

@RequestBody가 바인딩하는 과정을 알기 위해서 우선 Spring Web MVC 모듈에서 HTTP 요청이 오면 어떤 일이 일어나는 지 알고 있어야 합니다. 관련된 내용은 여기에 자세한 내용이 나와있어서 참고했습니다.

 

 

망나니 개발자 https://mangkyu.tistory.com/18

 

하지만 직접 눈으로 보지 않으면  완전히 이해하기가 어렵고, 머리속에서 금방 잊혀져서 실제로 디버깅을 해보고자 했습니다.


 

1. RequestBody는 기본생성자만 사용한다

 

저는 이전에 오브젝트 서적을 완독했기 때문에, 이제 드디어 "객체 지향적인" 코드를 작성해봐야 겠다고 생각했습니다. 그래서 RequestDTO의 필드들에 대한 검증을 java.util.regex.Pattern를 이용해서 처리하고 객체 생성할때 validate메소드를 호출하면 될 것이라고 생각했습니다. 해당 코드는 아래 MemberMapperDTO 와 같습니다.

 

@Setter
@NoArgsConstructor
public class MemberMapperDTO {

    private String email;
    private String password;
    private String name;
    private String birthDate;
    private String phone;

    public MemberMapperDTO(String email, String password, String name, String birthDate, String phone) {
        this.email = validateEmail(email);
        this.password = validatePassword(password);
        this.name = name;
        this.birthDate = validateBirthDate(birthDate);
        this.phone = validatePhone(phone);
    }

    public String validateEmail(String email) {
        String pattern = "^[0-9a-zA-Z]([-_.]?[0-9a-zA-Z])*@[0-9a-zA-Z]([-_.]?[0-9a-zA-Z])*.[a-zA-Z]{2,3}$";
        if (!Pattern.matches(pattern,email)) {
            throw new IllegalArgumentException();
        }
        return email;
    }

    public String validatePhone(String phone) {
        String pattern = "^\\d{3}-\\d{3,4}-\\d{4}$";
        if (!Pattern.matches(pattern,phone)) {
            throw new IllegalArgumentException();
        }
        return phone;
    }

    public String validateBirthDate(String birthDate) {
        String pattern = "^\\d{4}-\\d{2}-\\d{2}$";
        if (!Pattern.matches(pattern, birthDate)) {
            throw new IllegalArgumentException();
        }
        return birthDate;
    }

    public String validatePassword(String password) {
        String pattern = "^(?=.*[a-z])(?=.*\\d).{8,13}$";
        if (!Pattern.matches(pattern, pattern)) {
            throw new IllegalArgumentException();
        }
        return password;
    }
}

 

 

하지만 postman으로 검증에 실패하는 케이스를 요청해도 200OK 응답을 받는 상황이 있었습니다.

문제는 Spring MVC패턴에서는 생성자에 위와 같은 로직을 만들어도 해당 생성자를 사용하지 않는다는 점이었습니다.

 

코드를 다시 한번 살펴보면 총 2개의 생성자가 있는 것을 알 수 있습니다.

  • 기본 생성자 : @NoArgsConstructor
  • 매개변수가 있는 생성자 : public MemberMapperDTO(String email, String password, String name, String birthDate, String phone) {...}

 

그렇다면 비즈니스 로직이 포함되어 있는 매개변수 있는 생성자가 아닌 기본 생성자를 사용하는 것 같은데, 요청으로 들어오는 Json과 DTO를 어떻게 바인딩하는 것 일까요?

@RequiredArgsConstructor
@RequestMapping("/members")
@RestController
public class MemberController {

    private final MemberService memberService;

    @PostMapping("/signup")
    public ResponseEntity<ResponseDTO> signup(@RequestBody final MemberMapperDTO memberMapperDTO) {
        ResponseDTO responseDTO = memberService.signUp(memberMapperDTO);
        return ResponseEntity.ok(responseDTO);
    }
	...
}

 


Spring MVC에서 @RequestBody 어노테이션이 사용되면 해당 메소드 매개변수로 들어오는 HTTP 요청의 본문(body)을 읽어서 자바 객체(JavaType)로 변환해줍니다. (클라이언트가 JSON, XML 또는 다른 형식으로 요청한 데이터를 받으면 서버에서 자바 객체로 직렬화하는 과정입니다)

 

결론부터 먼저 말하자면 보통 HTTP 메시지 컨버터(Message Converter)를 사용하여 바인딩이 이루어집니다.

JSON 형식의 데이터를 처리하기 위해서는 보통 Jackson 라이브러리를 사용하며, MappingJackson2HttpMessageConverter가 JSON 데이터를 자바 객체로 변환해주는 역할을 합니다.

 

그러면 이제 디버깅을 확실히 보기 위해 Lombok을 풀고 코드를 구현해보겠습니다.

    public MemberMapperDTO() {
    }

    public void setEmail(String email) {
        this.email = email;
    }

    public void setPassword(String password) {
        this.password = password;
    }

    public void setName(String name) {
        this.name = name;
    }

    public void setBirthDate(String birthDate) {
        this.birthDate = birthDate;
    }

    public void setPhone(String phone) {
        this.phone = phone;
    }

※ 참고로 lombok으로나 코드로나 setter, getter로 어떤 게 있어도 다 바인딩이 됩니다. (그 이유는 아래 4번에서 리플랙션 관련해서 나옵니다.)


2. DispatcherSelvet에서부터 HandlerMapping과 HandlerAdapter 디버깅

 

Http요청이 들어오게 되면 Webcontext에서 FrameServlet클래스의 processRequest 메소드 내부에서 FrameServlet클래스의 doService()가 호출됩니다. 그리고 doService()내부에서는 Dispatcherservlet클래스의 doDispatch()메소드를 호출합니다.

 

doService() 메서드는 HTTP 요청을 처리하고 doDispatch() 메서드를 호출하여 핸들러에게 전달하는 역할을 합니다. doDispatch() 메서드는 핸들러 매핑과 핸들러 어댑터를 사용하여 실제 핸들러를 찾고 실행합니다.

 

  • doService() 메서드
    • doService() 메서드는 FrameworkServlet 클래스의 메서드로서, 실제로 DispatcherServlet이 상속받아 사용하는 메서드 중 하나입니다.
    • doService() 메서드는 주로 HTTP 요청을 처리하고, 요청을 적절한 핸들러로 전달하는 역할을 합니다.
    • 예를 들어, 클라이언트로부터 HTTP 요청이 도착하면 이 메서드에서는 doDispatch() 메서드를 호출하여 요청을 처리하고 결과를 반환합니다.
  • doDispatch() 메서드
    • doDispatch() 메서드는 DispatcherServlet 클래스에 구현된 메서드로서, doService() 메서드 내에서 호출되는 메서드 중 하나입니다.
    • doDispatch() 메서드는 주로 핸들러 매핑과 핸들러 어댑터를 사용하여 요청을 실제 핸들러(Controller)에 매핑하고 실행하는 역할을 합니다.
    • 핸들러 매핑은 URL과 요청을 처리할 핸들러를 매핑하는 역할을 하고, 핸들러 어댑터는 실제 핸들러의 실행을 담당합니다.

/**
* 실제 핸들러로의 디스패치를 처리합니다. 
* 서블릿의 HandlerMapping을 순서대로 적용하여 핸들러를 얻습니다. 
* HandlerAdapter는 핸들러 클래스를 지원하는 첫 번째 것을 찾기 위해 서블릿에 설치된 HandlerAdapter를 쿼리하여 얻을 수 있습니다. 
* 모든 HTTP 메서드는 이 메서드로 처리됩니다.
*/
protected void doDispatch(HttpServletRequest request, HttpServletResponse response) throws Exception {...}

 

doDispatch() 메서드에서 본격적인 핸들러 매핑과 어댑터가 이뤄지는 것을 확인할 수 있습니다.

 

먼저 getHandler() 메소드를 살펴보겠습니다.

handlerMappings를 순회하면서 mapping.getHandler()를 통해 요청과 일치하는 핸들러를 찾습니다.

 

HandlerMapping의 종류는 5가지 입니다.

  1. RouterFunctionMapping
  2. RequestMappingHandlerMapping
  3. WelcomePageHandlerMapping
  4. BeanNameUrlHandlerMapping
  5. WelcomePageNotAcceptableHandlerMapping
  6. SimpleUrlHandlerMapping

이 중에서 RquestMappingHandlerMapping이 선택되는데, 해당 핸들러매핑이 @RequestMapping 어노테이션을 사용하여 매핑된 핸들러 메서드를 처리하는 HandlerMapping 구현체이기 때문입니다.

디버깅 캡쳐 화면을 보면 핸들러 매핑 과정에서 매핑된 컨트롤러의 메서드를 찾은 것을 볼 수 있습니다.

{컨트롤러 클래스 경로}#{메서드 이름}({메서드 파라미터})
  • 컨트롤러 클래스 경로: "com.flab.offcoupon.controller.api.MemberController"
  • 메서드 이름: "signup"
  • 메서드 파라미터: "MemberMapperDTO"

다음은 실제로 해당 컨트롤러를 실행할 수 있도록 getHandlerAdapter()메소드를 통해 핸들러 객체에 대한 핸들러 어댑터를 찾습니다.

handlerAdapters를 순회하면서 apapter.supports()를 통해 어떤 종류의 컨트롤러 메서드를 처리할 수 있는지 판단합니다.

 

HandlerAdapter의 종류는 4가지 입니다.

 

  1. RequestMappingHandlerAdapter
  2. HandlerFunctionAdapter
  3. HttpRequestHandlerAdapter
  4. SimpleControllerHandlerAdapter

RequestMapping을 사용했으니 해당 핸들러를 처리할 수 있는 RequestMappingHandlerAdapter가 선택됩니다.

 

이후로 다시 doDispatch메소드로 돌아와서 요청의 메소드가 무엇인지 확인하고 실제로 핸들러를 호출하는 HandlerAdapter의  handle()메소드로 갑니다.

 

AbstractHandlerMethodAdapter는 Handlerdapter인터페이스를 구현하는 추상클래스로 컨트롤러 메서드를 처리하기 위해 내부적으로 RequestMappingHanderAdapter의 handleInternal()를 호출합니다.

 

RequestMappingHanderAdapter 클래스의의 handleInternal() 메소드 내부에서는 invokeHandlerMethod()를 호출합니다.

 

  • handleInternal() 메소드
    • handleInternal() 메소드는 실제 핸들러 메서드를 실행하고, 그 결과를 ModelAndView로 반환합니다.
    • 해당 메소드는 DispatcherServlet이 HTTP 요청을 처리하는 중요한 부분 중 하나로, 핸들러 메서드의 실행을 책임집니다.

  • invokeHandlerMethod() 메소드
    • invokeHandlerMethod() 메소드는 HandlerMethod 객체를 기반으로 실제 핸들러 메서드를 실행합니다.
    • HandlerMethod는 핸들러 메서드와 해당 메서드를 처리할 컨트롤러 객체를 캡슐화한 클래스입니다.
    • invokeHandlerMethod()는 HandlerMethod에 포함된 핸들러 메서드를 호출하고, 결과를 처리하여 ModelAndView를 반환합니다.

 

 

이후로 invokeHandlerMethod()메소드 내부에서 다양한 메소드들을 호출합니다. (너무 많아서 핵심적인 부분으로 바로 넘어가겠습니다)

 

→ ServletInvocableHandlerMethod 의 invokeAndHandle

→ invokeAndHandlerMethod 의 invokeForRequest

→ invokeAndHandlerMethod 의 getMethodArgumentValues

→ HandlerMethodArgumentResolverComposite의 resolveArgument

 

3. resolveArgument 와 readWithMessageConverters 메소드

 

그리고  RequestResonseBodyMethodProcessor클래스 의 resolveArgument() 로 오게 되는데, 바로 여기에서 @RequestBody 어노테이션이 적용된 메소드 파라미터의 값을 처리합니다.

 

자바 문서에도 보면 검증이 일치하지 않을 경우 MethodArgumentNotValidException가 발생하고,

RequestBody.required()부분이 true이거나 본문이 없을 경우 HttpMessageNotReadableException 가 발생한다고 나와 있습니다.

(기본 생성자가 없을 경우 HttpMessageNotReadableException가 발생합니다)

 

/**
 * 1. @RequestBody 어노테이션이 적용된 메소드 파라미터의 값을 해결하는 역할을 합니다.
 * 2. 메시지 컨버터(Message Converter)를 사용하여 HTTP 요청의 본문(body)을 파라미터 타입으로 변환합니다.
 * 3. 변환된 결과를 반환합니다.
 * 4. WebDataBinderFactory를 사용하여 웹 데이터 바인더(WebDataBinder)를 생성하고, 필요한 경우 유효성 검사를 수행합니다.
 * 5. 만약 바인딩 중 에러가 발생하고, 바인딩 예외가 필요한 경우 MethodArgumentNotValidException을 던집니다.
 * 6. WebDataBinder의 바인딩 결과를 ModelAndViewContainer에 추가합니다.
 * 7. 최종적으로 적절하게 변환된 인자를 반환합니다.
 */
@Override
public Object resolveArgument(MethodParameter parameter, @Nullable ModelAndViewContainer mavContainer,
        NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory) throws Exception {

    // 1. 메소드 파라미터를 Optional을 고려하여 처리합니다.
    parameter = parameter.nestedIfOptional();

    // 2. 메시지 컨버터를 사용하여 HTTP 요청의 본문을 파라미터 타입으로 변환합니다.
    Object arg = readWithMessageConverters(webRequest, parameter, parameter.getNestedGenericParameterType());

    // 4. WebDataBinderFactory를 통해 웹 데이터 바인더를 생성하고, 유효성 검사를 수행합니다.
    if (binderFactory != null) {
        String name = Conventions.getVariableNameForParameter(parameter);
        ResolvableType type = ResolvableType.forMethodParameter(parameter);
        WebDataBinder binder = binderFactory.createBinder(webRequest, arg, name, type);

        // 5. 바인딩 중 에러가 발생하고, 바인딩 예외가 필요한 경우 MethodArgumentNotValidException을 던집니다.
        if (arg != null) {
            validateIfApplicable(binder, parameter);
            if (binder.getBindingResult().hasErrors() && isBindExceptionRequired(binder, parameter)) {
                throw new MethodArgumentNotValidException(parameter, binder.getBindingResult());
            }
        }

        // 6. 바인딩 결과를 ModelAndViewContainer에 추가합니다.
        if (mavContainer != null) {
            mavContainer.addAttribute(BindingResult.MODEL_KEY_PREFIX + name, binder.getBindingResult());
        }
    }

    // 7. 최종적으로 변환된 인자를 반환합니다.
    return adaptArgumentIfNecessary(arg, parameter);
}

 

2번 메세지 컨버터를 이용하는 readWithMessageConverters() 메소드를 살펴보겠습니다.

/**
 * 1. NativeWebRequest와 MethodParameter 등을 활용하여 ServletServerHttpRequest을 생성합니다.
 * 2. 메시지 컨버터(Message Converter)를 사용하여 HTTP 요청의 본문(body)을 파라미터 타입으로 변환합니다.
 * 3. 변환된 결과가 null이고, 필수 요청인 경우 HttpMessageNotReadableException을 던집니다.
 * 4. 변환된 결과를 반환합니다.
 */
@Override
protected <T> Object readWithMessageConverters(NativeWebRequest webRequest, MethodParameter parameter,
        Type paramType) throws IOException, HttpMediaTypeNotSupportedException, HttpMessageNotReadableException {

    // 1. NativeWebRequest와 MethodParameter 등을 활용하여 ServletServerHttpRequest을 생성합니다.
    ServletServerHttpRequest inputMessage = createInputMessage(webRequest);

    // 2. 메시지 컨버터를 사용하여 HTTP 요청의 본문을 파라미터 타입으로 변환합니다.
    Object arg = readWithMessageConverters(inputMessage, parameter, paramType);

    // 3. 변환된 결과가 null이고, 필수 요청인 경우 HttpMessageNotReadableException을 던집니다.
    if (arg == null && checkRequired(parameter)) {
        throw new HttpMessageNotReadableException("Required request body is missing: " +
                parameter.getExecutable().toGenericString(), inputMessage);
    }

    // 4. 변환된 결과를 반환합니다.
    return arg;
}

 

먼저 NativeWebRequest타입을 createInputMessage()를 통해 SerlvetServerHttpReqeust로 만들어줍니다.

 

그리고  다음 줄에 있는 readWithMessageConverters()로  주어진 HttpInputMessage, parameter, targetType을 넘겨주면서 본격적인 바인딩 작업이 시작됩니다. 여기서부터가 진짜 시작입니다!

※ 여기서부터 디버깅하고 싶은분들 AbstractMessageConverterMethodArgumentResolver 추상클래스의 readWithMessageConverters()에 중단점 찍고 시작해주세요!

 

요청으로 들어온 모든 정보(contentType, HttpMethod 등)를 파악한뒤, 메세지 컨버터 목록을 순회하고 타입이랑 일치한다면 변환작업이 들어갑니다. 메소드가 길어서 캡쳐 2개를 이어서 붙이겠습니다.

 

MessageConverter의 종류는 총 8개 입니다. (2번과 3번, 7번과 8번은 이름이 중복되는데 무슨 차이인지는 잘 모르겠습니다)

  1. ByteArrayHttpMessageConverter
  2. StringHttpMessageConverter
  3. StringHttpMessageConverter
  4. ResourceHttpMessageConverter
  5. ResourceRegionHttpMessageConverter
  6. AllEncompassingFormHttpMessageConverter
  7. MappingJackson2HttpMessageConverter
  8. MappingJackson2HttpMessageConverter

메세지 컨버터를 순회하다보면 MappingJackson2HttpMessageConverter를 만나게 됩니다. 위에서 해당 클래스가 JSON 데이터를 자바 객체로 변환해주는 역할을 한다고 언급했던걸 떠올릴 때가 왔습니다.

 

genericConverter의 canRead 메소드를 호출하여 변환이 가능한지 확인합니다.

 

/**
 * 이 HttpMessageConverter가 주어진 Type, contextClass 및 mediaType을 읽을 수 있는지 여부를 결정합니다.
 * @param type 읽을 Type
 * @param contextClass 사용할 context 클래스
 * @param mediaType 읽을 미디어 타입 또는 지정되지 않은 경우 null
 * @return 이 컨버터가 주어진 타입을 읽을 수 있는 경우 true; 그렇지 않으면 false
 */
@Override
public boolean canRead(Type type, @Nullable Class<?> contextClass, @Nullable MediaType mediaType) {
    // 1. 미디어 타입이 읽을 수 있는지 확인
    if (!canRead(mediaType)) {
        return false;
    }

    // 2. 주어진 Type 및 contextClass를 기반으로 JavaType 생성
    JavaType javaType = getJavaType(type, contextClass);

    // 3. 지정된 미디어 타입에 대한 ObjectMapper 선택
    ObjectMapper objectMapper = selectObjectMapper(javaType.getRawClass(), mediaType);

    // 4. ObjectMapper가 없으면 false 반환
    if (objectMapper == null) {
        return false;
    }

    // 5. ObjectMapper가 주어진 JavaType을 역직렬화할 수 있는지 확인
    AtomicReference<Throwable> causeRef = new AtomicReference<>();
    if (objectMapper.canDeserialize(javaType, causeRef)) {
        // 6. 역직렬화가 가능하면 true 반환
        return true;
    }

    // 7. 역직렬화가 불가능한 경우 경고 로깅
    logWarningIfNecessary(javaType, causeRef.get());

    // 8. 역직렬화가 불가능한 경우 false 반환
    return false;
}

 

바인딩이 가능하다는 true결과가 나오면 다시 readWithMessageConverters() 로 돌아와서 반복문 내 다음 코드를 실행합니다.

// 1. HTTP 요청 메시지에 body가 있는 경우 실행
if (message.hasBody()) {
    // 2. beforeBodyRead 메소드를 호출하여 메시지 변환 이전의 작업을 수행하고 메시지를 적절히 가공
    HttpInputMessage msgToUse = getAdvice().beforeBodyRead(message, parameter, targetType, converterType);
    
    // 3. genericConverter가 null이 아니면 genericConverter를 사용하여 타겟 타입에 맞게 body를 읽어들임
    //    그렇지 않으면 일반적인 HttpMessageConverter를 사용하여 타겟 타입에 맞게 body를 읽어들임
    body = (genericConverter != null ? genericConverter.read(targetType, contextClass, msgToUse) :
            ((HttpMessageConverter<T>) converter).read(targetClass, msgToUse));
    
    // 4. afterBodyRead 메소드를 호출하여 메시지 변환이 완료된 후의 작업을 수행하고 최종적인 body를 반환
    body = getAdvice().afterBodyRead(body, msgToUse, parameter, targetType, converterType);
}

 

 

2번 변환 이전 작업에서

read() -> readJavaType() -> objectReader.readValue() -> _bindAndClose() -> readRootValue() -> deser.deserializae() -> deserializeFromObject() -> creadteUsingDefault() -> call() 를 호출하게 되는데,  바로 이 곳에서 지금까지 가지고 있던 Class 객체로 리플랙션을 통해 생성자를 만들게 된다.

 

newInstance0()는 native메소드라 내부 구현이 어떻게 되어있는지 모르지만, step into 디버깅을 했을때 기본 생성자로 오는것을 확인할 수 있습니다.

 

다시말해서, deserializeFromObject()메소드는 JSON을 읽어와서 제공된 JsonParser 및 DeserializationContext를 사용하여 객체를 역직렬화합니다.

  • final Object bean = _valueInstantiator.createUsingDefault(ctxt); : 기본 생성자를 사용하여 객체 생성
  • SettableBeanProperty prop = _beanProperties.find(propName);  : 빈에서 프로퍼티를 찾음
  • prop.deserializeAndSet(p, ctxt, bean); : JsonParser(p)에서 값을 읽어와서, 해당 값을 객체(bean)의 프로퍼티에 설정

그리고 MethodProperty클래스의 deserializeAndSet()메소드에서 setter를 통해 필드에 값을 할당합니다.

 

여기서 _setter라는건 자바 리플랙션으로 꺼낸 Method클래스로서, 디버깅화면을 보면 DTO내부의 setter 메소드를 찾아옵니다.

 

deserializeFromObject()메소드 내부에서 do while문을 통해 이 과정을 반복하면서 @RequestBody를 통해 요청 본문에서 각 필드를 찾아서 하나씩 매핑됩니다. 드디어 이제 RequestBody는 기본생성자와 set메소드로 바인딩된다는 것을 알아냈습니다.

 

4. get메소드 vs lombok의 getter, setter

 

그러면 이제 get메소드만 있을 경우도 한번 비교해보겠습니다.

Lombok의 getter와 setter같은 경우에는 컴파일 시점에 get메소드와 set메소드를 만들어주므로 동작원리가 똑같습니다.

 

get메소드만 있을 경우

 

위와 동일한 과정을 거칩니다. 기본 생성자를 통해 bean을 만들고, 빈에서 프로퍼티를 찾아서, 해당 값을 객체(bean)의 프로퍼티에 설정하려고 시도합니다. 하지만 이번에는 MethodProperty가 아니라 FieldProperty의 deserializeAndSet 메소드가 호출됩니다. (다형성으로 인해 런타임 시점에 다른 클래스의 메소드가 호출되는것 같습니다)

 

결국  get메소드가 있고 없고의 차이가 아니라

  • set메소드가 있을 경우 자바 리플랙션에서 Method클래스를 통해 set메소드로 주입하고,
  • set메소드가 없을 경우에는 Field클래스를 통해 private을 public으로 변경하고 주입합니다.

 

 

정리

1. @RequestBody 어노테이션은 기본 생성자와 setter가 있으면 바인딩이 가능하지만, setter가 없이 getter만 있어도 가능합니다

2. getter의 경우 비즈니스 로직에서 DTO에 있는 필드를 조회하기 위해 항상 필요하기 때문에 있어야 합니다.

3. getter가 있다고 바인딩 과정에서 get메소드를 이용하는게 아니라 Field클래스를 이용해서 값을 할당합니다.

4. setAccessible(true)를 하는 코드는 아직 못봤지만, 결과적으로 가능합니다.

5. 결론적으로 RequestDTO를 검증하기 위해서는 생성자 내부에서 검증 로직을 사용하는게 아니라, 다른 방법을 사용해야 합니다.

6. Java Bean Validation, CustomArgumentResolver, 컨트롤러 내부에서 Assert 단언문 사용

 

 

 

참고 블로그 :

https://jojoldu.tistory.com/407

@RequestBody에 왜 기본 생성자는 필요하고, Setter는 필요 없을까? #3

https://dev-monkey-dugi.tistory.com/125