본문 바로가기
Spring

[Spring] HTTP 데이터를 객체로 처리하는 방법

by worldcenter 2024. 9. 27.

form 태그 POST

  • HTML의 form 태그를 사용하여 POST 방식으로 HTTP 요청을 보낼 수 있습니다.
  • 이 때 해당 데이터는 HTTP Body에 name=Robbie&age=10 (QueryString) 형태로 서버로 전달됩니다.
  • 해당 데이터를 Java의 객체 형태로 받는 방법@ModelAttribute 어노테이션을 사용한 후 Body 데이터를 Star 객체에 담습니다.
  • Spring 내부적으로는 Jackson 라이브러리를 통해 String이 객체로 변환
POST http://localhost:8080/hello/request/form/model
// Star 클래스
@Getter
@AllArgsConstructor
@NoArgsConstructor
public class Star {
    private String name;
    private int age;
}
@Controller
@RequestMapping({"/hello/request"})
public class RequestController {
    public RequestController() {
    }

    @GetMapping({"/form/html"})
    public String helloForm() {
        return "hello-request-form";
    }

    @PostMapping({"/form/model"})
    @ResponseBody
    public String helloRequestBodyForm(@ModelAttribute Star star) {
        return String.format("Hello, @ModelAttribute.<br> name = %s, age = %d", star.getName(), star.getAge());
    }
}

 

API로 통신 테스트한 결과 다음과 같은 결과가 나왔습니다. 디버깅을 해봤을 때 객체에 데이터가 제대로 담기지 못한 것을 확인할 수 있었습니다. 

 

컴파일 된 Star.class 파일을 확인했을 때도 특이사항이 보이지 않았습니다. 제가 생각했을 때 Client에서 데이터가 넘어오면 helloRequestBodyForm() 메서드가 실행되면서 Star star = new Star("Robbert", 10) 로 객체가 생성되고 Getter를 통해 데이터를 가져와야 하는데 왜 제대로 객체가 생성되지 못할까 고민했습니다.

public class Star {
    private String name;
    private int age;

    @Generated
    public String getName() {
        return this.name;
    }

    @Generated
    public int getAge() {
        return this.age;
    }

    @Generated
    public Star(final String name, final int age) {
        this.name = name;
        this.age = age;
    }

    @Generated
    public Star() {
    }
}

 

원인은 @ModelAttribute 어노테이션 동작 방식에서 찾을 수 있었습니다. 

@ModelAttribute는 ModelAttributeMethodProcessor 클래스에서 데이터 바인딩이 이루어 집니다. resolveArgument() 메서드를 따라가보면 동작 방식을 확인할 수 있습니다. 순서는 다음과 같습니다.

  • 적절한 생성자를 찾아 새 인스턴스 생성
  • 클라이언트가 요청한 파라미터를 기준으로 setter 메서드를 찾아서 실행

코드를 보며 좀 더 자세히 알아보겠습니다. 생성자를 다음과 같은 방법으로 찾습니다. BeanUtils의 getResolvableConstructor() 메서드를 살펴보겠습니다.

public static <T> Constructor<T> getResolvableConstructor(Class<T> clazz) {
    Constructor<T> ctor = findPrimaryConstructor(clazz);
    if (ctor != null) {
        return ctor;
    }

    Constructor<?>[] ctors = clazz.getConstructors();
    if (ctors.length == 1) {
        // A single public constructor
        return (Constructor<T>) ctors[0];
    }
    else if (ctors.length == 0) {
        // No public constructors -> check non-public
        ctors = clazz.getDeclaredConstructors();
        if (ctors.length == 1) {
            // A single non-public constructor, e.g. from a non-public record type
            return (Constructor<T>) ctors[0];
        }
    }

    // Several constructors -> let's try to take the default constructor
    try {
        return clazz.getDeclaredConstructor();
    }
    catch (NoSuchMethodException ex) {
        // Giving up...
    }

    // No unique constructor at all
    throw new IllegalStateException("No primary or single unique constructor found for " + clazz);
}

 

가장 먼저 public으로 선언된 생성자를 찾습니다. public 생성자가 없다면 public이 아닌 기본 생성자를 먼저 찾고 다른 생성자가 있다면 매개변수가 가장 적은 생성자를 선택합니다. 즉, 어떤 접근 제한자의 생성자가 존재하더라도 @ModelAttribute를 위한 객체를 생성하는 것에는 문제가 없습니다.

자세한 내용은 다음 링크에서 확인이 가능합니다.

 

이 내용을 통해 왜 객체에 클라이언트 데이터가 제대로 담기지 않았는지 이해할 수 있었습니다. 기본 생성자가 있다면 기본 생성자가 우선이 되어 객체가 생성되기 때문에 Getter를 이용하여 데이터를 가져와도 default 값만 존재합니다.

그래서 Star 클래스를 수정한다면 다음 2가지 방법이 있습니다.

// 1. 첫 번째 방법
@Getter
@Setter
@NoArgsConstructor
public class Star {
    private String name;
    private int age;
}

// 2. 두 번째 방법
@Getter
@AllArgsConstructor
public class Star {
    private String name;
    private int age;
}

 

 

Query String 방식

?name=Robbie&age=95 처럼 데이터가 두 개만 있다면 괜찮지만 여러 개 있다면 @RequestParam 어노테이션 하나씩 받아오기 힘들 수 있습니다. 이 때 @ModelAttribute 어노테이션을 사용한다면 Java의 객체로 데이터를 받아올 수 있습니다.

파라미터에 선언한 Star 객체가 생성되고 오버로딩된 생성자 혹은 Setter 메서드를 통해 요청된 name&age 값이 담겨집니다.

@Controller
@RequestMapping("/hello/request")
public class RequestController {
    @GetMapping("/form/html")
    public String helloForm() {
        return "hello-request-form";
    }

    @GetMapping("/form/param/model")
    @ResponseBody
    public String helloRequestParam(@ModelAttribute Star star) {
        return String.format("Hello, @ModelAttribute.<br> name = %s, age = %d", star.getName(), star.getAge());
    }

}

@ModelAttribute는 생략이 가능합니다.
Spring에서는 @ModelAttribute 뿐만 아니라 @RequestParam도 생략이 가능합니다.
그렇다면 Spring은 이를 어떻게 구분할까요?
간단하게 설명하자면 Spring은 해당 파라미터(매개변수)가 SimpleValueType 이라면 @RequestParam으로 간주하고 아니라면 @ModelAttribute가 생략되어있다 판단합니다.
SimpleValueType은 원시타입(int), Wrapper타입(Integer), Data 등의 타입을 의미합니다.

 

 

@RequestBody

HTTP Body에 JSON 데이터를 담아 서버에 전달할 때 해당 Body 데이터를 Java의 객체로 전달 받을 수 있습니다.

@Controller
@RequestMapping("/hello/request")
public class RequestController {
    @GetMapping("/form/html")
    public String helloForm() {
        return "hello-request-form";
    }

    @PostMapping("/form/json")
    @ResponseBody
    public String helloPostRequestJson(@RequestBody Star star) {
        return String.format("Hello, @RequestBody.<br> name = %s, age = %d", star.getName(), star.getAge());
    }

}

 

HTTP Body에 { "name":"Jenny", "age":59 } JSON 형태로 데이터가 서버에 전달됐을 때 @RequestBody 어노테이션을 사용해 데이터를 객체 형태로 받을 수 있습니다.