개발자로 후회없는 삶 살기

spring PART.스프링 기본 기능 2, 웹 쇼핑몰 개발 본문

[백엔드]/[spring | 학습기록]

spring PART.스프링 기본 기능 2, 웹 쇼핑몰 개발

몽이장쥰 2023. 4. 15. 12:44

서론

※ 이 포스트는 다음 강의의 학습이 목표임을 밝힙니다.

https://www.inflearn.com/roadmaps/373

 

우아한형제들 최연소 기술이사 김영한의 스프링 완전 정복 - 인프런 | 로드맵

Spring, MVC 스킬을 학습할 수 있는 개발 · 프로그래밍 로드맵을 인프런에서 만나보세요.

www.inflearn.com

 

본론

- Model Attribute

실제 개발을 하면 요청을 받아서 객체를 만들고 그 객체에 값을 넣어주고 보통 위 코드처럼 예전에 코드를 작성했습니다. 지금 요청 파라미터를 배우는 과정이니 객체를 담아서 return하는 게 아니라 그냥 객체에 담아서 get으로 사용하는 과정입니다. 스프링은 이 과정을 완전히 자동화해주는 @Modelattribute를 제공합니다.

 

@Data
public class HelloData {
    String username;
    int age;
}

새로운 클래스를 만들고 이 객체에 값을 담고 주고 받을 것입니다. 롬복의 @Data를 쓰고 필드를 만듭니다. @를 쓰면 게터, 세터, ToString, RequiredArgsConstructor를 다 자동으로 만들어줍니다.

 

@RequestMapping("/model-attribute-v1")
@ResponseBody
public String modelAttributeV1(@RequestParam String username, @RequestParam int age) {
    HelloData helloData = new HelloData();
    helloData.setAge(age);
    helloData.setUsername(username);

    return "OK";
}

이렇게 하고 @Requestmapping 메서드를 하나 만들고 인자로 원래대로라면 매개로 질의 문자열을 받고 그것을 객체에 set해서 필드에 값을 저장하고 return해야 합니다.

 

@RequestMapping("/model-attribute-v2")
@ResponseBody
public String modelAttributeV2(@ModelAttribute HelloData helloData) {
    return "OK";
}

모델attribute를 쓰면 이것을 싹다 지워버릴 수 있습니다. RequestParam하는 부분, 매개로 받아서 객체에 set하는 부분이 다 없어집니다. 실행해보면 잘 나옵니다. 이게 Modelattribute의 힘입니다. 실행해보면 HelloData 객체가 생성되고 요청 파라미터의 값도 모두 객체의 필드에 들어가 있습니다.

 

-> 스프링은 Model Attribute가 있으면 이렇게 동작합니다.

1) HelloData 객체를 생성
2) 요청 파라미터 이름으로 HelloData의 객체 프로퍼티를 찾습니다. 프로퍼티가 게터, 세터이다. 여기서는 set을 해야하니 세터를 찾습니다. 세터를 호출하고 setusername을 해서 파라미터 값을 바인딩해줍니다. 즉 setUsername해서 파라미터인 username의 값을 객체의 username 필드에 넣어줍니다.

-> 바인도 오류

Str에 int를 넣는 등 타입이 안 맞으면 BindException이 발생합니다.

 

-> 개선

@RequestMapping("/model-attribute-v2")
@ResponseBody
public String modelAttributeV2(HelloData helloData) {
    return "OK";
}

ModelAttri를 생략할 수 있습니다. 생략해도 똑같이 객체에 값이 들어갑니다. 근데 생략하면 @RequestParam도 생략 가능해서 혼란이 발생할 수 있습니다. 따라서 스프링은 다음과 같은 규칙이 있습니다.

 

1) 매개에 Str, int, Integer 같은 단순 타입은 @RequestParam이 되고

2) 나머지는 다 @ModelAttri가 됩니다.

 

 

- HTTP 요청 메세지 - 단순 텍스트

이제는 요청 파라미터가 아니고 바디에 데이터를 직접 담아서 요청합니다. 데이터 형식은 주로 JSON을 사용합니다. 메서드는 put, post를 사용합니다. 요청 파라미터와 다르게 바디로 데이터가 넘어오면 RequestParam, ModelAttri를 쓸 수 없습니다. 이 경우는 바디를 직접 조회해야합니다.

 

> 먼저 HTTP  메시지 바디에 가장 단순한 메시지를 HTTP 바디에 담아서 전송하고 읽어보자 InputStream을 사용해서 직접 읽을 수 있습니다.

@Slf4j
@Controller
public class RequestBodyStringController {
     @PostMapping("/request-body-string-v1")
     public void requestBodyString(HttpServletRequest request, HttpServletResponse response) throws IOException {
     ServletInputStream inputStream = request.getInputStream();
     String messageBody = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8);
     
     log.info("messageBody={}", messageBody);
     response.getWriter().write("ok");
     }
}

> 새로운 컨트롤러를 만들고 PostMapping을 씁니다. 요청 메시지는 POST로 메서드를 한다고 했습니다. 서블릿에서는 InputStream 얻고 streamutils을 사용했습니다. 항상 스트림은 바이트 코드인데 바이트 코드를 문자로 바꿀 때는 어떤 인코딩으로 문자를 바꿀 지 항상 지정해야한다. 예전 서블릿에서는 이렇게 했습니다.

 

-> 스프링 방식

@Slf4j
@Controller
public class RequestBodyStringController {
    @PostMapping("/request-body-string-v2")
    public void requestBodyStringV2(InputStream inputStream, Writer responseWriter) throws IOException {
        String messageBody = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8);
        log.info("message = {}", messageBody);

        responseWriter.write("OK");
    }
}

스프링은 @기반이라서 매우 유연하고 매개로 req, resp 뿐만아니라 직접 세세하게 다 받을 수 있습니다. 그래서 InputStream을 직접 받을 수 있습니다. 

 

req, resp을 통으로 받지 말고 InputStream과 , resp에 "OK"하기 위한 Writer를 매개로 직접 받습니다.

 

@PostMapping("/request-body-string-v3")
public HttpEntity<String> requestBodyStringV3(HttpEntity<String> httpEntity) throws IOException {
    String messageBody = httpEntity.getBody();
    log.info("message = {}", messageBody);

    return new HttpEntity<>("OK");
}

이렇게 받아도 stream을 사용하는게 별로입다. 이제부터 진짜가 나옵니다. StreamUtils를 하기 싫으니깐 스프링이 알아서 해줬으면 좋겠습니다. 그게 HttpMessageConverter라는 기능입니다. (json converter도 있었습니다. 스프링 MVC 내부에서 HTTP 메시지 바디를 읽어서 문자나 객체로 변환해서 전달해줍니다.) HttpEntity<String>을 하면 스프링이 "아! 너 문자구나 그러면 내가 Http Body에 있는 것을 문자로 바꿔서 너에게 줄게"라는 streamutils를 대신 실행시켜주는 httpmessageConverter가 동작을 합니다.

 

> httpEntity getBody하면 메세지가 꺼내져옵니다. 반환도 비슷하게 됩니다. response도 필요없습니다. 반환형에 HttpEntity를 쓰면 되는데 이게 http 메세지를 스펙화해서 가져온 것이라고 보면 됩니다. return하고 파라미터에 바디 메세지를 넣으면 됩니다.

 

-> 설명

스프링은 httpEntity를 제공합니다. 얘는 http 바디 정보를 편리하게 조회할 수 있는 객체입니다.

 

바디 정보를 직접 조회할 수 있습니다. 헤더 조회도 가능합니다. 응답에도 반환할 수 있으며 response 바디처럼 바디에 콱 박아버리는 것입니다.

 

-> 개선

@PostMapping("/request-body-string-v4")
public String requestBodyStringV3(@RequestBody String messageBody) throws IOException {
    log.info("message = {}", messageBody);
    return "OK";
}

entity도 쓰는게 너무 귀찮습니다. 그래서 RequestBody가 제공됩니다. @RequestBody String messagebody라고 하면 끝납니다. 진짜 편리해집니다. ResponseBody는 그냥 바로 바디에 콱 박아버리고 RequestBody는 그냥 바디에서 데이터를 읽어서 문자로 바꿔버립니다. 결국 RequestBody를 실무에서 씁니다. 헤더 정보는 HttpEntity.getHeader()를 쓸 수도 있고 @RequestHeader를 매개로 받아도 됩니다.

 

 

"HttpMessageConverter"는 스트림으로 바이트 데이터를 받고 string으로 utf8로 바꾸는 것을 직접 바디에서 땡겨서 str로 바꿔주는 것을 스프링이 자동으로 해줍니다.

 

 

- HTTP 요청 메시지 - json

드디어 HTTP API에서 주로 사용하는 JSON 데이터 형식을 조회합니다.

@Slf4j
@Controller
public class RequestBodyJsonController {
    private ObjectMapper objectMapper = new ObjectMapper();
    @PostMapping("/request-body-json-v1")
    public void requestBodyJsonV1(InputStream inputStream, HttpServletResponse response) throws IOException {
        String messageBody = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8);
        HelloData helloData = objectMapper.readValue(messageBody, HelloData.class);

        log.info("username = {}, age = {}", helloData.getUsername(), helloData.getAge());
        response.getWriter().write("OK");
    }
}

새로운 컨트롤러를 만들고 바디 내용물은 {username : "Hello"}, content-type은 app/json으로 요청할 것입니다. 서블릿 방식을 먼저 할 것입니다. 먼저 json이니깐 ObjectMapper가 필요합니다. input 스트림이 필요하고 copyToStr로 해서 메시지를 str로 받고 objmapper로 객체로 반환받으면 됩니다. 메세지를 받는 것은 단순 텍스트 때와 똑같이 inputStream과 utils로 하는데 오브젝트 맵퍼로 (이름 그대로 객체에 맵핑해줍니다.) 객체에 set합니다.

 

실행해보면 잘 동작합니다. 이건 서블릿에서 한 것과 같은 내용입니다.

 

-> 개선

@PostMapping("/request-body-json-v2")
public String requestBodyJsonV2(@RequestBody String messageBody) throws IOException {
    HelloData helloData = objectMapper.readValue(messageBody, HelloData.class);
    log.info("username = {}, age = {}", helloData.getUsername(), helloData.getAge());
    return "OK";
}

v2는 RequestBody를 직접 쓰는 것입니다. 그러면 json을 문자로 가져오는 과정을 빠르게 할 수 있으니 objmap만 하면 됩니다.  ★ 근데 이게 불편합니다. 굳이 objmap로 바꿔야하나 싶습니다. model attri에서도 reqParam으로 받고 객체에 넣는 것보다 Model Attri가 다 바인딩 해주니 편했습니다.

 

@PostMapping("/request-body-json-v3")
@ResponseBody
public String requestBodyJsonV3(@RequestBody HelloData helloData) throws IOException {
    log.info("username = {}, age = {}", helloData.getUsername(), helloData.getAge());
    return "OK";
}

그게 가능합니다. v3에서는 String이 아니고 HelloData를 바로 넣을 수 있습니다. 맵퍼를 다 없앨 수 있습니다. RequestBody는 그대로 쓰고 변수만 객체로 바꾸면 됩니다. json이 넘어오면 객체로 받으면 객체에 바인딩 되고 text로 넘어오면 Str로 바인딩 되나봅니다. 이때 Str 컨버터로 읽을지 Json 컨버터로 읽을지를 Argument Resolver가 선택해 줄 것입니다.

 

-> 원리

RequestBody에 직접 만든 객체를 만들 수 있습니다. RequestBody를 사용하면 HTTP 메세지 컨버터가 메시지 바디의 내용을 내가 원하는 문자나 객체 등으로 변환해줍니다. 지금 바디에 json이 넘어오고 type은 app/json이 넘어옵니다. 그러면 컨버터가 "어? 너 타입이 json이네, 그러면 넌 json이겠구나"하고 json 컨버터가 선택되어 읽어서 객체에 맞게 반환해줍니다. json이면 json 컨버터가, 텍스트면 String 컨버터가 동작합니다. 걔가 v2에서 한 objmap.readvalue를 대신 해줍니다.

 

@PostMapping("/request-body-json-v4")
@ResponseBody
public String requestBodyJsonV4(HttpEntity<HelloData> httpEntity) throws IOException {
    HelloData helloData = httpEntity.getBody();
    log.info("username = {}, age = {}", helloData.getUsername(), helloData.getAge());
    return "OK";
}

> 물론 HttpEntity로 해도 됩니다. HttpEntity는 Http메시지 전체를 다루는 개념이라서 getBody로 꺼내야합니다. 따라서 getHeader 같은 것도 되는 것입니다. ResquestBody는 바디만 다루는 개념입니다.

 

-> 응답

@PostMapping("/request-body-json-v5")
@ResponseBody
public HelloData requestBodyJsonV5(@RequestBody HelloData helloData) throws IOException {
    log.info("username = {}, age = {}", helloData.getUsername(), helloData.getAge());
    return helloData;
}

반환도 객체로 할 수 있습니다. HTTP Message Converter가 요청할 때도 적용이 되지만 ResponseBody가 있으면 응답할 때도 적용이 됩니다. 문자가 ResponseBody가 있으면 메시지 바디에 팍 들어가게 되는데 그것처럼 이렇게 하면 객체가 컨버터에 의해서 json으로 바뀌고 바디에 콱 들어갑니다.

 

실행하보면 요청을 json으로 보냈는데 응답도 json으로 왔습니다. json > 객체 > json구조로 json으로 왔다가 객체가 됐다가 다시 json으로 응답가는 것입니다. 이때 헤더의 accept는 app/json이어야합니다.

 

 

- HTTP 응답 - 정적 리소스, 뷰 템플릿

응답도 3가지입니다. 정적 리소스, 뷰 템플릿(동적 html), HTTP 메시지를 사용합니다.

 

- 정적 리소스

스프링 부트는 web app이 없습니다. ★ 어떤 경로에 올라가나 보자 static에 쓴 자원은 다 정적 톰캣이 서빙합니다. 그래서 src/main/resources/static/basic/hello.html이 있으면 웹 브라우저에서 localhost:8080/basic/hello.html이 되는 것입니다.

 

 

- 뷰 템플릿

뷰 템플릿을 거쳐서 html이 생성되고 뷰 리졸버 통하고 뷰 객체 생성하고 이거입니다. 경로는 src/main/resources/templates입니다. src/main/resources/templates/hi.html을 만듭니다. 얘는 브라우저에서 직접 접근이 불가능합니다. webapp/WEB-INF처럼 그렇습니다. 그래서 컨트롤러를 만들고 호출해야합니다.

@Controller
public class ResponseViewController {
    @RequestMapping("/response-view-v1")
    public ModelAndView responseViewV1() {
        ModelAndView modelAndView = new ModelAndView("response/hi")
                .addObject("data", "hello!!!");
        return modelAndView;
    }
}

 

새로운 패키지와 컨트롤러를 만들고 RequestMapping함수를 만들고 v1은 모델 뷰를 반환하는 것으로 해보겠습니다. 모델 뷰는 논리 이름을 생성자로 받았고 논리 이름은 template 바로 밑에 쓰면 hi만 하면 되는데 response 폴더를 template에 넣을 것이라서 response/hi로 해야합니다. 모델 뷰의 addObject를 하면 모델 뷰의 모델에 값이 들어갔었습니다.

 

@RequestMapping("/response-view-v2")
public String responseViewV2(Model model) {
    model.addAttribute("data", "hello");
    return "response/hi";
}

v2로는 논리 이름을 Str로 반환합니다. 그러니 모델뷰를 없애고 모델이 필요합니다. v4로 갈 때 모델 뷰를 없애면서 모델을 넣어줬었습니다. 그리고 그냥 모델이 필요하니 Str로 할 때 모델이 필요하면 Model을 넣어줍니다.

 

-> 정리

return "hi"하면 뷰 리졸버 (논리 이름을 물리 이름으로 바꾸고 뷰 객체 생성하는 인터페이스)가 실행되면서 뷰를 찾고 랜더링을 합니다. 그래들에 타임리프가 설정되어 있으면 스프링 부트가 타임리프 뷰 리졸버와 타임리브 뷰를 다 등록을 해줍니다. 그리고 프로퍼티스에 프리픽스, 서브픽스도 다 해줍니다.

 

이걸 jsp로 바꾸고 싶으면 이 부분을 바꾸면 됩니다. 루트가 templates/였는데 이제 루트가 view/로 하겠다는 것입니다. 이래서 cspop에서 view가 루트로 되는 것입니다.

 

 

- HTTP 응답 - HTTP API, 메시지 바디에 직접 응답

@Controller
public class ResponseBodyController {
    @GetMapping("/resonse-body-jsonv2")
    @ResponseBody
    @ResponseStatus(HttpStatus.OK)
    public HelloData responseBodyJsonV2() {
        HelloData helloData = new HelloData();
        helloData.setUsername("hsb");
        helloData.setAge(25);

        return helloData;
    }
}

json으로 응답하는 방법은 요청에서 @RequestBody에서 HelloData helloData를 안 하면 컨트롤러에서 new Hello를 해서 객체를 생성한 후 set으로 데이터 넣고 반환해야합니다. 이때 응답 결과 상태를 줄 수 있는데 @ResponseStatus()를 주면 됩니다.

 

-> 동적인 응답 상태 변환

@GetMapping("/resonse-body-jsonv1")
@ResponseBody
public ResponseEntity<HelloData> responseBodyJsonV1() {
    HelloData helloData = new HelloData();
    helloData.setUsername("hsb");
    helloData.setAge(25);

    return new ResponseEntity<>(helloData, HttpStatus.OK);
}

뭔가 동적으로 응답 상태 변환을 주려면 ResponseEntity를 써야합니다.

 

@RestController
//@ResponseBody
public class ResponseBodyController {

ResponseBody가 모든 메서드마다 다 붙는게 귀찮으면 클래스에 ResponseBody를 붙일 수 있는데 그럴 바에 RestController를 쓸 것입니다.

 

 

- http 메세지 컨버터

json을 바디에서 직접 읽거나 응답 바디에 넣을 때는 컨버터를 사용하면 편리합니다. 안쓰면 스트림, response.writer를 썼는데 불편합니다.

 

-> 스프링 입문 강의 설명

Responsebody 사용 원리는 요청이 오면 톰캣이 컨트롤러를 호출하고 여기에 Responsebody가 있으면 뷰 리졸버 대신에 httpMessageConverter라는게 동작을 해서 이 중에서 string으로 나갈지, json으로 나갈지 선택이 되어서 나갔었습니다.

> 기본 문자 반환은 stringconverter가 동작하고 객체 반환은 mappingJacksonconverter가 동작해서 객체가 json으로 바뀌어서 응답 메시지에 박힙니다.

 

※ 참고로 content-type은 클라가 요청을 보낼 때 "지금 내가 보내는 메세지는 이 타입이에요." 이거고 accept 헤더는 "나는 이런 메세지를 해석할 수 있어요, 나에게는 json으로 주세요" 이런 건데 응답의 경우 헤더 accept와 서버의 컨트롤러 반환 타입 정보를 조합해서 뷰 리졸버가 아닌 HttpMessageConverter가 선택됩니다.

 

-> 스프링 MVC는 다음의 경우 HTTP 메시지 컨버터를 적용합니다.

1) 요청의 경우 컨트롤러가 호출되기 전에 @RequestBody라는 게 있으면 바디에서 데이터를 꺼내서 뭔가 변환을 한 다음에 객체든 문자든 넘겨줍니다.

2) 응답의 경우 ResponseBody가 있을 경우 스프링이 HTTP 메시지 컨버터를 적용하여 바디에 메세지를 write합니다.

 

 

-> 인터페이스

컨버터는 인터페이스로 되어있고 객체와 문자 처리가 각각 인터를 구현한 것입니다. HTTP 메시지 컨버터는 HTTP 요청, HTTP 응답 둘 다 사용됩니다. 요청을 받고 메세지 바디에서 컨버터가 데이터를 꺼내서 객체로 바꿔서 컨트롤러에게 주는 역할도 하고 또 하나는 컨트롤러 리턴값 가지고 응답 메시지에 넣는 것도 합니다. 따라서 read, write가 있습니다. 컨버터를 통해서 양방향으로 읽고 씁니다.

 

-> 순위

스프링 부트가 기본적으로 다양한 메시지 컨버터를 제공합니다. byte는 바이트로 바꿔주는 것이고 string, 객체(json)으로 바꾸는 것 등 있습니다. 미디어 타입을 체크해서 컨버터가 선택이 되는 것입니다. 만약 만족하지 않으면 다음 순위의 컨버터로 넘어가는 것입니다.

> 컨버터마다 미디어 타입을 처리하는데 응답할 때는 json이면 미디어 타입에 application/json으로 쓰고 요청이면 content-type이 app/json이라고 하고 이것을 보고 jackson 컨버터가 적용이 됩니다. 미디어 타입은 타입의 최상위 개념이고 요청할 때는 content-type이 미디어 타입에 영향을 주고 응답할 때는 accept가 미디어 타입에 영향을 줍니다. ( 자세한 흐름은 스프링 MVC 기본 기능 PDF의 마지막 부분 )

 

 

ex) 예제

 

요청 예제을 보겠습니다.

 

content - type: app/json

@RequestMapping
void hello (@RequestBody String data) { }

이렇게 오면 0순위 바이트 컨버터를 봐서 매개 변수를 봅니다. 바이트는 바이트 배열을 지원하는데 매개변수에 String으로 와서 바로 1 순위를 보면 OK입니다. 이게 클래스 타입 정보입니다. 근데 검사할 때 클래스 타입 정보와 미티어 타입 정보도 같이 봅니다. app/json입니다. String 컨버터는 미티어 타입이 */*이라서 모든 타입이 다 된다는 것이고 따라서 String 컨버터가 동작합니다.

 

 

- RequestMapping 핸들러 어댑터 구조

HTTP 메시지 컨버터가 스프링 MVC에서 어디쯤에서 사용되는 것일지 다음 그림에서 안보입니다. 바로 핸들러 어댑터에서 관련이 있습니다.

 

-> 어댑터 동작방식

구조를 보면 디스패쳐가 어댑터의 핸들 메서드를 호출하고 어댑터에서 컨트롤러를 호출해야했습니다. 컨트롤러의 서비스 메서드에 매개변수로 들어오는 Model Attribute, HTTPRequest, Model, RequestParam 등을 다 누군가가 던져줘야하는데 이를 알아보면 HTTP 메시지 컨버터 동작을 알 수 있습니다.

 

-> ArgumentResolver

Argument가 매개변수라는 뜻으로 이거를 처리해주는 것입니다. 여기에 컨버터의 비밀이 있습니다. 스프링 MVC에서는 컨트롤러의 메서드를 처리할 때 매개변수로 매우 큰 유연함을 보여줍니다. 이는 다 ArgumentResolver 덕분입니다. 어댑터가 ArgumentResolver을 호출해서 핸들러가 필요로 하는 값을 다 생성합니다. 생성이 끝나면 어댑터가 매개변수를 가지고 컨트롤러를 호출하면서 넘겨줍니다.

스프링에는 30개가 넘는 많은 ArgumentResolver이 있습니다. 어댑터를 선택할 때처럼 ArgumentResolver 중 해당 파라미터를 생성할 수 있는 애인지 체크하고 지원하면(support) 선택하고 ArgumentResolver가 파라미터를 생성하고 컨트롤러 호출시에 넘깁니다. 이 또한 내 프로젝트에 맞는 ArgumentResolver를 만들어서 등록할 수도 있습니다. 정말 스프링은 역할과 구현으로 철저하게 설계가 되어있습니다.

 

-> ReturnValueHandler

이건 뭐냐면 예전에 컨트롤러가 모델 뷰를 반환한 적이 있고 언제는 Str을 반환하고 막 바꿔가면서 했습니다. 이를 처리해주는 게 이것입니다. 얘가 응답 값을 변환하고 처리합니다.

 

-> HTTP 메시지 컨버터

그러면 파라미터 넘기는 건 ArgumentResolver이 하고 반환은 ReturnValueHandler가 하는 거 알겠습니다. 그러면 HTTP 메시지 컨버터는 어디에 있을까요?

> ArgumentResolver가 파라미터를 만든다고 했는데 컨버터를 ArgumentResolver도 사용하고 ReturnValueHandler도 사용합니다. HTTP 메시지 컨버터가 요청을 받을 때, 반환을 할 때 둘 다 사용하는 양방향이라고 했습니다.

 

-> 그림

요청의 경우 RequestBody를 처리하는 ArgumentResolver가 있습니다. 또 HTTP ENTITY를 처리하는 ArgumentResolver가 있습니다. 이 ArgumentResolver들은 HTTP 메시지 컨버터를 사용해서 필요한 객체를 생성합니다. HTTP 요청을 사용하지 않는 ArgumentResolver은 그냥 혼자서도 파라미터를 생성할 수 있는데 HTTP 메시지 컨버터를 사용하는 @RequestBody, or HTTPEntity 요청은 ArgumentResolver 중에서 HTTP 메시지 컨버터를 사용해서 파라미터를 생성합니다. 컨버터에 순위가 있다고 했는데 선택을 하는 게 ArgumentResolver입니다.

 

HttpEntityMethodProcessor라는 ArgumentResolver가 supportParameter 메서드에서 HttpEntity가 있으면 "어! 이거 내꺼네 하고" 이 ArgumentResolver가 동작을 하고

 

resolveArgument가 이제 파라미터를 만들어서 Obj 타입으로 반환하는 것인데 컨버터를 써서 HttpEntity객체를 만들어 냅니다. 반환을 하면 그 Obj를 받아서 핸들러가 컨트롤러를 실행할 때 넣어줍니다.

 

> 얘가 컨버터 중에서 caRread를 돌려서 그에 맞는 컨버터를 순위를 돌려가며 찾고 read로 요청 메시지 바디 값을 읽습니다.

 

-> 정리

요청만 정리해 보자면 ArgumentResolver가 각 컨트롤러의 매개를 넣어주는 것인데 "HTTP 메시지 컨버터를 사용하는 @RequestBody"가 있으면 컨버터를 사용해서 body에서 값을 읽어야합니다. 그러면 HTTP 메시지 컨버터가 동작해서 String Converter나 Json Converter가 선택이 됩니다.

 

> @RequestBody가 있으면 support 메서드로 그에 맞는 ArgumentResolver를 어댑터가 호출하고 ArgumentResolver의 메서드 중 resolveArgument가 readWithMessageConverter를 사용해서 순위가 있는 컨버터 중에 선택을 합니다. 선택을 할 때는 for문을 돌려서 canRead로 @RequestBody 타입을 읽을 수 있는 컨버터인지 확인해서 선택하고 찾으면 read해서 컨버터가 요청 바디 메시지를 읽습니다. 그것을 반환합니다.

+ 결국 readWithMessageConverter의 반환 값으로 컨버터가 만든 값을 반환하고 그게 어댑터의 컨트롤러 호출에 담겨서 들어갑니다.

※ 보통 ArgumentResolver 혼자 할 수 있는데 HTTP 메시지 컨버터를 써야할 때인 RequestBody, HttpEntity를 처리하는 것에 한에서만 컨버터를 씁니다. 다른 파라미터에 한에서는 ArgumentResolver가 혼자 다 파라미터를 생성해서 컨트롤러에 넣어줄 수 있습니다.

 

 

- 스프링 웹 쇼핑몰 만들기

사이트를 하나 만들고 PRG, redirect 패턴을 활용하는 법을 알아보겠습니다.

 

- 프로젝트 기획 단계

=> 서비스 기능 설계 정리

1. 설계 : 도메인 설계(도메인 별 협력 관계 설계, 도메인 별 클래스 다이어그램 설계)

2. 기능 : 상품 목록 조회, 상품 상세 조회, 상품 등록, 상품 수정, 상품 삭제(별도 추가 예정)

 

 

- 프로젝트 설계 단계

1. 도메인 설계

 

2. 도메인 별 협력 관계 설계

 

3. 도메인 별 클래스 다이어그램 설계

4. 웹 UI

 

5. 서비스 제공 흐름

검은 색이 컨트롤러입니다. 지금 MVC 패턴을 사용하므로 항상 컨트롤러를 통해서 뷰가 호출이 됩니다. 클라가 상품 목록(컨트)에 들어가면 뷰로 목록 랜더링을 합니다. 목록에서는 상품 등록 폼으로 이동할 수 있습니다. 폼 컨트롤러에서 상품 등록 폼을 보여줍니다. > 등록 버튼을 누르면 상품 저장 컨트롤러로 이동하여 상품을 저장하고 상품 상세로 이동합니다.

> 상품 목록에서 상품 상세 컨트를 호출하면 상세 컨트 뷰로 가고 상품 상세에서 수정 폼으로 갈 수 있습니다. 수정 폼에서 값을 수정하면 redirect를 해서 상품 상세로 갈 것입니다.

> 이렇게 요구사항이 정리되고 디자이너, 웹 퍼블리셔, 백엔드 개발자가 업무를 나누어 진행하기로 했습니다. 디자이너는 디자인 결과물을 웹 퍼블리셔에게 제공한다. 웹 퍼블리셔는 html, css를 만들어 백엔드 개발자에게 제공합니다. 백엔드 개발자는 디자이너, 웹 퍼블리셔를 통해서 HTML 화면이 나오기 전까지 시스템을 설계하고 핵심 비즈니스 모델을 개발합니다. 이후 HTML이 나오면 이 HTML을 뷰 템플릿으로 변환해서 동적으로 화면을 그리고 웹 화면의 흐름을 제어한다. 만약 프엔 개발자가 있으면 백엔드 개발자는 렌더링 없이 HTTP API를 통해 기능만 제공하면 됩니다.

 

 

=> 설계 마무리하고 개발 시작

-> 상품 도메인 개발

item 상품 객체를 만들어보자 도메인 패키지를 만들고 item 패키지를 만듭니다. 그 안에 item 클래스를 만듭니다.

 

@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;
        Quantity = quantity;
    }
}

필드를 넣는데 null이 들어가야 하는 경우는 기본타입으로 하면 안 됩니다. 생성자는 id를 빼고 초기화를 해서 만듭니다. 게터, 세터는 @Data로 합니다. 근데 이게 되게 위험합니다. @Getter나 @Setter 정도만 하는게 좋습니다.

 

아이템 저장소 레포를 만듭니다. 원래는 레포 패키지를 분리합니다.

 

@Repository
public class ItemRepositoy {
    private static final Map<Long, Item> store = new HashMap<>();
    private static Long sequence = 0L;

    public Item save(Item item) {
        item.setId(++sequence);
        store.put(item.getId(), item);
        return item;
    }

    public Item findById(Long itemID) {
        return store.get(itemID);
    }

    public ArrayList<Item> findByAll() {
        return new ArrayList<>(store.values());
    }

    public void updateItem(Long itemID, Item paramItem) {
        Item findItem = findById(itemID);
        findItem.setItemName(paramItem.getItemName());
        findItem.setPrice(paramItem.getPrice());
        findItem.setQuantity(paramItem.getQuantity());
    }
}

지금은 DB를 안 쓰니 Map을 쓸 것이다. 키는 Long으로 아이템 id이고 값은 item입니다. seq는 일련번호로 이 두개는 static으로 해서 서비스 시작하고 단 한 번만 일어나게 해야합니다. 실무에서는 ConcurrentHashMap으로 해야하고 Long도 아토믹 Long을 써야합니다. 멀티스레드에 싱글톤 빈이라서 동시에 접근하면 값이 꼬일 수 있습니다.

기능을 만들자 목록, 상세, 등록, 수정이 있었습니다. 조회할 때 전체를 반환할 때 즉, 컬랙션을 반환할 때 map.values()로 하지 말고 new ArrayList<>(map.values())로 한 번 감싸서 내보냅니다. 바로 내보내면 레퍼런스로 참조가 가능하여 실제 map의 값이 바뀔 수 있는데 그걸 안전하게 처리한 것입니다.

수정은 id와 아이템과 관련된 파라미터(수정 값)를 넣으면 수정되게 하겠습니다. > 수정을 하려면 id로 item을 찾고 set으로 값을 수정합니다. 실무에서는 파라미터를 만드는게 아니고 별도의 업뎃되는 name, price, quantity 이거 3개만의 객체를 만드는게 맞습니다. 지금 id가 사용이 안되기 때문에 정석으로 하기 위해서는 itemParamDTO 객체를 만들고 거기에 파라미터 3개만 넣는게 맞습니다. 이게 항상 명확한게 좋다는 것입니다.

 

> 마지막으로 test에서 쓰기 위해서 clearStore를 만듭니다.

 

-> 레포 테스트

만들었으면 잘 만들어졌는지 테스트를 해봐야합니다. 당연히 테스트할 레포를 먼저 생성해야합니다. AfterEach에 clear를 합니다.

 

1. save

class ItemRepositoryTest {
    private final ItemRepository itemRepositoy = new ItemRepository();

    @AfterEach
    void afterEach() {
        itemRepositoy.clearStore();
    }

    @Test
    void save() {
        //given
        Item item = new Item("A", 100, 1);

        //when
        Item saveItem = itemRepositoy.save(item);

        //then
        assertThat(saveItem).isSameAs(item);
    }

given, when, then 구조로 갑니다. save는 item을 생성합니다. 생성자 파라미터 3개짜리로 item을 만들고 저장합니다. find한 후 Assertions에서 찾은 게 저장된 것과 같은지 해봅니다.

 

2. findAll

@Test
void findByAll() {
    //given
    Item itemA = new Item("A", 100, 1);
    Item itemB = new Item("B", 101, 1);

    itemRepositoy.save(itemA);
    itemRepositoy.save(itemB);

    //when
    ArrayList<Item> items = itemRepositoy.findByAll();

    //then
    assertThat(items.size()).isEqualTo(2);
    assertThat(items).contains(itemA, itemB);
}

item을 두개 만들고 리스트에 2개 들었나 실험해보겠습니다. when이 뭘 했을 때! 이므로 when에서 find All을 하고 then에서 검증을 합니다. size가 2개여야한다. 또 다른 검증은 contains로 result가 item1, item2를 포함하느냐가 됩니다.

 

3. update

@Test
void updateItem() {
    //given
    Item itemA = new Item("A", 100, 1);
    itemRepositoy.save(itemA);

    Item paramItem = new Item("B", 100, 1);

    //when
    itemRepositoy.updateItem(itemA.getId(), paramItem);

    //then
    assertThat(itemA.getItemName()).isEqualTo(paramItem.getItemName());
    assertThat(itemA.getQuantity()).isEqualTo(paramItem.getQuantity());
    assertThat(itemA.getPrice()).isEqualTo(paramItem.getPrice());
}

item을 저장하는 시나리오가 필요합니다. 비즈니스 로직을 그대로 수행하는 것이 test이기 때문입니다. 수정할 값을 updateParam item에 넣고 id로 update합니다. 테스트는 찾은 아이템의 변경값이 변경하려고 한 값과 같은지 보면 됩니다.

 

 

- 상품 서비스 HTML

핵심 비즈니스 로직을 개발하는 동안 웹 퍼블리셔가 html을 개발 완료했다고 합니다.

 

- 타임리프로 동적인 화면 구성

@Controller
@RequiredArgsConstructor
@RequestMapping("/basic/items")
public class BasicItemController {
    private final ItemRepository itemRepository;

    @GetMapping
    public String items(Model model) {
        List<Item> items = itemRepository.findByAll();
        model.addAttribute("items", items);
        return "basic/items";
    }
    
    @PostConstruct
    public void init() {
        itemRepository.save(new Item("itemA", 10000, 10));
        itemRepository.save(new Item("itemB", 20000, 20));
    }
}

상품 목록 컨트롤러와 타임리프를 만들어보자 패키지를 만들고 컨트롤러를 만듭니다. RequestMapping 을 클래스 단위에 붙이고 레포를 주입받습니다.

 


> GetMapping 메서드로 모든 item 목록을 뿌리는 메서드를 만듭니다. 모델에 가져온 목록을 넣고 뷰에 뿌립니다. PostConstruct로 이 컨트롤러가 빈에 등록이 되면 임의의 아이템이 등록되게 합니다. 이제 화면을 만들어야합니다.

 

-> 동적인 화면 만들기

이전에 만든 정적인 HTML중 items.html을 templates에 동적으로 타임리프로 만들 것입니다. 아까 만든 것은 그냥 html입니다. 이제부터 이것을 동적으로 하기 위해서 타임리프로 만들 것입니다.

 

<html xmlns:th="http://www.thymeleaf.org"> 먼저 위에 선언을 하나 합니다. 실행을 해서 컨트롤러에서 호출이 되는지 시행을 해보자 > 이를 동적으로 타임리프로 바꿔보겠습니다.

 

1) bootstrap을 상대 경로가 아니고 타임리프로 절대 경로로 넣자 th라는게 있으면 기존 것을 덮어버리고 바뀝니다. 타임리프는 기존게 있고 그 옆에 th써서 덮어버리는 형식의 문법입니다. html을 그냥 쓰면 href가 나오고 뷰 템플릿을 거치면 th:href가 사용됩니다. 서버 사이드 렌더링이 되면 th:href로 기존 속성을 치환합니다. @를 링크 표현식이라고 합니다.

 

2) 지금 상품 목록에서 상품 등록을 누르면 addForm으로 href를 하는데 이것을 th로 바꿔보자 /add 맵핑 메서드로 가도록 할 것입니다.

 

tr 태그에 th:each를 하면 tr이 리스트 개수만큼 생길 것입니다. 그러면 내부에 td를 만들어야합니다. 상품 id, 상품 명, 가격, 수량을 items를 돌리는 item에서 .으로 참조합니다.

> 되는지 돌려보면 잘 됩니다. item이 3, 4개 있으면 계속 증가합니다.

 

 

4) 링크를 고쳐보자 링크를 누르면 item의 상세 페이지가 나올 것인데 그 전에 상세 정보를 모델에 담아야할 것이니 url mapping 경로를 씁니다. 렌더링이 되면 동적 경로로 들어가야합니다. 무슨 의미냐면 타임리프에서 $는 값인데 @안에 {}하고 ()를 넣으면 {}사이에 치환이 됩니다. 마치 pathvariable처럼 합니다.

 

 

> 눌러보면 작성한대로 /basic/items/1이 들어갑니다. 실제로 pathvariable url 호출 방식으로 컨트롤러를 호출하게 됩니다. 타임리프는 링크 표현식에서 pathvariable말고 쿼리 파라미터 형식도 지원하는데 만약 내가 localhost:8080/basic/items/1?query=test를 하고 싶다면 th:href="@{/basic/items/{itemId}(itemId=${item.id}, query='test')}" 이렇게 , query = 'test' 를 하면 컨트롤러를 쿼리 파라미터 형식으로 호출할 수 있습니다. 이게 스프링에 친화적으로 만들어진 것이라고 할 수 있습니다.

 

 

- 상품 상세

@GetMapping("/{itemID}")
public String item(@PathVariable Long itemID, Model model) {
    Item item = itemRepository.findById(itemID);
    model.addAttribute("item", item);
    return "basic/item";
}

상품 상세 컨트롤러와 뷰를 만들어보자 컨트롤러에 GetMapping 메서드를 만듭니다. id를 url을 받고 pathvariable를 쓰고, 상품 상세 뷰에 던질 Model을 가져옵니다. url로 들어온 찾을 itemId로 레포에서 item을 찾아오고 그 item을 뷰에 뿌리기 위해 모델에 넣습니다. 이후 이 item을 타임리프에 $로 화면에 뿌릴 것입니다.

상품을 클릭해보면 @{/basic/items/{itemid}(itemid = ${item.id})}로 상품 목록 html에서 받은 {itemid}가 GetMapping에 pathvariable로 들어오고 상품 상세 뷰가 보입니다.

 

-> 뷰 화면

화면에 아까와 같은 것을 합니다. 타임리프 설정, CSS 절대 경로 설정을 합니다. > 화면 렌더링을 하자 렌더링은 서버 사이드 렌더링을 생각하면 이해하기 쉽습니다. html을 동적으로 만드는 것이 렌더링입니다. value값을 item의 필드값으로 덮어 씌우면 되는 것입니다.

 

돌려보면 저장한 대로 잘 나옵니다. > 수정 버튼과 목록으로 버튼의 링크를 동적으로 바꿉니다. 수정하기 위한 아이템의 id가 들어간 컨트롤러 의 pathvariable 링크를 실행하게 해야합니다. 목록 버튼은 그냥 다시 /basic/items 컨트롤러를 호출하게 하면 됩니다.

 

- 상품 등록 폼

@GetMapping("/add")
public String addFrom() {
    return "basic/addForm";
}

등록하는게 아니고 등록하는 폼으로 들어가보자 그냥 addFrom으로 가는 것입니다. 모든 뷰는 컨트롤러를 거쳐서 가야하니 그냥 뷰네임만 적으면 됩니다.

 


-> 뷰 렌더링

등록을 누르면 등록 컨트롤러로 가게 해야합니다. 지금 form을 연 경로과 같은 url mapping을 가지게 할 것입니다. 대신에 form을 열때는 get, 실제 저장할 떄는 post를 쓸 것입니다.

> th:action을 할 건데 원래는 th:action="/basic/items/add"를 해야하지만 같은 url일 경우 비워놔도 됩니다. 취소를 누르면 그냥 목록으로 돌아가면 됩니다.

 

 

> 상품 등록 버튼을 누르고 개발자 모드로 보면 넘어가는 데이터가 보입니다. 

 

-> 상품 등록 처리

상품 등록 폼에서 전달된 데이터로 실제 상품 등록을 처리해보겠습니다. 지금 상품 등록은 form 형식으로 넘어오니 폼 파라미터 형식으로 넘어옵니다. 

 

1) requestparam

@PostMapping("/add")
public String addItemV1(@RequestParam String itemName,
						@RequestParam int price,
    					@RequestParam Integer quantity,
        				Model model) {
    Item item = new Item();
    item.setItemName(itemName);
    item.setPrice(price);
    item.setQuantity(quantity);
    itemRepository.save(item);
    
    model.addAttribute("item", item);
    return "basic/item";
}

일단 requestparam으로 받아보자 쿼리 파라미터 형식으로 하면 RequestParam을 쓰면 된다고 했습니다. 이렇게 하면 Item 객체를 만들고 set하고 save해서 item을 저장합니다. 저장을 하면 등록한 item을 상세 화면에 보여주고 싶습니다. 따라서 모델에 저장한 item을 넣습니다. 그리고 뷰는 이전에 만든 item 상세 뷰를 뿌리면 됩니다.

 

2) modelAttribute

@PostMapping("/add")
public String save(@ModelAttribute Item item, Model model) {
    itemRepository.save(item);
    model.addAttribute("item", item);
    return "basic/item";
}

modelAttribute를 써서 해보자 Item을 만들고 set을 하는 것을 그대로 지울 수 있습니다. 지금 ModelAttribute에 ()에 이름을 넣었습니다. 얘가 하는 일이 2가지 기능이 있습니다. ① 값을 넣는 것도 하는데 ② ()에 있는 "item"이라는 이름으로 모델에 넣어주는 것도 합니다. 

 

@PostMapping("/add")
public String save(@ModelAttribute("item") Item item, Model model) {
    itemRepository.save(item);
//        model.addAttribute("item", item);
    return "basic/item";
}

그래서 model.addAttribute를 지워도 됩니다. > 근데 ()를 없애면 Item item에서 타입 Item의 첫글자를 소문자로 고친 것이 모델에 addAttribute 됩니다. 그래서 그냥 ()없는 처음에 한 ModelAttribute 방식을 쓰면 됩니다.

> 또한 ModelAttribute도 생략할 수 있다고 했습니다. 단순 타입은 RequestParam이 되고 나머지가 다 ModelAttribute로 된다고 했는데 이는 Item item에서 Item -> item이 되는 것과 같게 동작합니다.

 

> 결과를 보면 저장한 상세가 잘 보입니다.

 

- 상품 수정

 

상품 수정 폼을 보여주고 수정을 하는 2단계로 할 것입니다. 수정 폼 컨트롤러부터 만들자 폼을 보여주는 html form 기반 api 설계는 get입니다. 상품 상세에서 위처럼 /basic/items/{itemId}/edit으로 했기에 mapping url을 이렇게 해야합니다. 

 

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

매개로는 Pathvariable하고 수정할 item의 id를 받습니다. 모델도 받고 add item합니다. 모델로 부터 받은 item의 필드 값으로 미리 form에 입력 창을 채워놓기 위함입니다.

 

-> 뷰 렌더링

타임리프로 value를 모델로 부터 받은 값으로 입력 창을 미리 채워둡니다. 아직 저장하는 기능은 안했고 바꾸고 저장하는 기능을 만들어야합니다.

저장 버튼을 누르면 action을 post로 같은 url을 보게 할 것입니다. 이게 html form 기반 api 설계입니다. 취소 버튼을 누르면 상세로 돌아가게 할 것입니다. /basic/items/{itemId}로 상세로 돌아가게 하겠습니다.

 

-> 수정 컨트롤러

폼으로 가는 url과 같은 url로 컨트롤러를 만듭니다. 수정 기능은 레포의 update 메서드를 하면 됩니다. 수정을 누르면 수정 폼으로부터 4가지 필드값을 받습니다. 그것을 컨트롤러에서 ModelAttribute로 받습니다. 그중 3가지만 수정에 쓸 것입니다. 따라서 수정 폼에서 id는 읽기 전용입니다.

 

@PostMapping("/{userID}/edit")
public String edit(@PathVariable Long userID, @ModelAttribute Item item) {
    itemRepository.updateItem(userID, item);
    return "redirect:/basic/items/{userID}";
}

> 저장 후에는 상품 상세로 리다이렉트로 이동할 것입니다. 스프링에서는 redirect:/basic/items/{itemId}를 하고 이렇게 하면 pathvariable에 있는 것을 그대로 쓸 수 있습니다.

 

@PostMapping("/{itemId}/edit")
public String edit(@PathVariable Long itemId,
        @ModelAttribute Item paramItem, Model model) {
    itemRepository.updateItem(itemId, paramItem);

    log.info("hi");
    Item findItem = itemRepository.findById(itemId);
    model.addAttribute("findItem", findItem);

    return "redirect:/basic/items/{findItem.getId()}";
}

안보고 다시 만들었을 때 이렇게 작성했습니다. redirect를 할 것인데 상품 상세 페이지가 Model을 받아서 화면에 뿌리기에 수정 후 다시 findById를 하고 model에 넣어주었습니다. 여기서 문제는 2가지입니다.

 

1) 첫번째는 redirect를 하는데 뷰를 호출하듯이 model을 준비했다는 것입니다. redirect는 뷰가 아니라 컨트롤러 url 맵핑 메서드를 다시 브라우저가 호출하게 하는 개념입니다. 따라서 모델 이런게 필요가 없습니다.

 

2) 두번째는 findItem.getId()를 한 것입니다. {} 사이에 메서드는 못 씁니다. 그래서 위에서 미리 구하고 값을 넣어야합니다.

 

 

> 수정을 누르면 상태코드가 302가 나옵니다. location으로 웹 브라우저가 자동으로 url 엔터를 치게 합니다. 등록을 할 때는 redirect를 안하고 수정을 할 때는 redirect를 썼습니다. 이는 PRG 패턴과 관련이 있습니다. 사실 등록도 redirect를 해야하는데 안하면 어떻게 되는지 보여주기 위해서 안 한 것입니다.

 

- PRG 패턴

@PostMapping("/add")
public String save(@ModelAttribute("item") Item item, Model model) {
    itemRepository.save(item);
//        model.addAttribute("item", item);
    return "basic/item";
}

사실 지금 상품 등록 컨트롤러는 심각한 문제가 있습니다. 지금 상품 등록은 그냥 컨트롤러에 basic/item뷰를 보여줬습니다.

 

 

새로고침 해보면 경고가 뜹니다. 그리고 새로고침하면 id가 계속 올라갑니다. 새로고침 버튼을 눌렀을 뿐인데 계속 등록이 됐습니다.

 

> 화면의 흐름은 이렇습니다. 등록폼에서 등록을 하면 저장 컨트롤러에서 상품 상세 뷰를 호출합니다. 그러면 url은 계속 저장 컨트롤러를 호출한 url이 남아있는 것입니다. 마지막에 남이있는 url이 저장 url이니깐 계속 저장 컨트롤러 url 맵핑이 호출되서 저장이 계속 됩니다.

 

> 웹 브라우저 입장에서는 POST의 add를 요청한게 마지막입니다. 새로고침이란게 내가 마지막으로 한 것을 다시하는 것입니다. 그래서 POST의 add가 계속 됩니다.

 

그러면 url은 계속 저장 컨트롤러를 호출한 url이 남아있는 것입니다.

 

 

-> 해결 방법

리다이렉트를 하면 된다. 상품 저장을 한 후 뷰를 보여주지 말고 리다이렉트을 해서 뷰를 보여줘라 뷰를 보여주면 마지막 요청이 POST의 add인데 리다이렉트는 웹 브라우저 입장에서는 완전히 새로 요청하는 것입니다. POST의 return이 상품 상세 뷰를 보여주는 것이었는데 그렇게 하지말고 웹 브라우저를 상품 상세로 리다이렉트로 보내버려야합니다.

 

> 그러면 마지막으로 요청한게 POST가 아니고 상품 상세가 되는 것입니다. > 이걸 POST Redirect GET이라고 해서 POST로 보냈는데 Redirect를 해서 GET으로 다시 보내라는 의미입니다. 결론은 POST 이후에 Redirect를 해야합니다.

 

@PostMapping("/add")
public String save(@ModelAttribute("item") Item item, Model model) {
    itemRepository.save(item);
//        model.addAttribute("item", item);
    return "redirect:/basic/items/" + item.getId();
}

> 새로운 컨트롤러의 return을 상품 상세를 컨트롤러에 요청할 때의 GET 맵핑으로 합니다. 근데 여기서 주의할 게 있습니다. 이렇게 + 넣으면 안 됩니다. 지금은 Long이니깐 망정이지 한글이나 영어면 url 인코딩이 안 됩니다.

 

상품 상세화면으로 리다이렉트 되어있습니다.

 

- RedirectAttributes

상품을 등록하고 리다이렉트까지 하는 것은 좋았습니다. 근데 고객 입장에서 저장이 잘 된 건지 아닌지 확신이 안 듭니다. 사용자 친화적이지 않습니다. 그래서 저장을 하고 저장이 잘 됐다는 말을 해달라는 요구사항이 왔습니다. 별도의 화면을 만들어야할까요? 이를 간단하게 해결하는 방법이 RedirectAttributes입니다.

 

-> 개선

@PostMapping("/add")
public String addItemV6(Item item, RedirectAttributes redirectAttributes) {
    Item savedItem = itemRepository.save(item);
    redirectAttributes.addAttribute("itemId", savedItem.getId());
    redirectAttributes.addAttribute("status", true);
    redirectAttributes.addAttribute("status2", false);
    return "redirect:/basic/items/{itemId}";
}

아이디어는 간단하다. 리다이렉트를 할 때 파라미터를 붙여서 보내는 것으로 파라미터가 있으면 저장됐다는 메세지를 보여주게 할 것입니다. RedirectAttributes를 파라미터에 넣고 리다이렉트에 관련된 값들을 addAttribute합니다. 저장된 id를 넣고 status가 true면 이건 저장이 됐을 때만 넘어온 것이라고 할 것이다. 저장이 되어서 넘어온 것입니다. 이렇게 하면 RedirectAttributes에 넣은 itemId값이 {}에 치환이 됩니다.

 

> status는 쿼리 파라미터 형식으로 들어가게 됩니다. 등록을 해보면 치환이 되고 true가 쿼리 파라미터로 들어갑니다. 이렇게 하면 + 를 안해서 좋고, true로 저장되었다는 것을 알 수 있습니다.

 

> 그러면 그 /basic/items/{itemId} 경로의 컨트롤러에 보면 item 뷰를 반환합니다. 여기에 true에 관한 작업을 해줘야합니다. th는 타임리프에서 쿼리 파라미터 값을 꺼내서 쓸 수 있게 만들어 놨습니다. 현재 넘어온 url의 쿼리 파라미터의 키값이 status인 애의 값이 true면 이라는 뜻입니다.

 

 

-> 정리

덕분에 치환을 해주고 url 인코딩도 해줍니다. addAttribute를 했는데 return에 치환이 있는 건 치환이 되고 치환이 없는 건 쿼리 파라미터로 넘어갑니다. RedirectAttributes는 URL 인코딩, pathVariable, 쿼리 파라미터까지 전부 다 처리해줍니다. 요청이 pathVariable, 쿼리 파라미터로 온 것을 처리해주는 건 아니고 리다이렉트로 요청을 할 때 pathVariable, 쿼리 파라미터로 요청을 만들어줍니다.

Comments