Spring

[스프링 MVC - 백엔드 웹 개발 핵심 기술] 06. 스프링 MVC 기본 기능

binning 2025. 2. 17. 18:04

1. 로깅

운영 시스템에서는 System.out.println()같은 시스템 콘솔을 사용해서 필요한 정보를 출력하지 않고, 별도의 로깅 라이브러리를 사용해서 로그를 출력한다.

로그 라이브러리는 Logback, Log4J, Log4J2 등등 수 많은 라이브러리가 있는데, 그것을 통합해서 인터페이스로 제공하는 것이 바로 SLF4J 라이브러리다.

쉽게 이야기해서 SLF4J는 인터페이스이고, 그 구현체로 Logback 같은 로그 라이브러리를 선택하면 된다.

실무에서는 스프링 부트가 기본으로 제공하는 Logback을 대부분 사용한다.

@Slf4j
@RestController
public class LogTestController {

    //@Slf4j과 동치
    //private final Logger log = LoggerFactory.getLogger(LogTestController.class);

    @RequestMapping("/log-test")
    public String logTest() {
        String name = "Spring";

        log.trace("trace log = {}", name);
        log.debug("debug log = {}", name);
        log.info("info log = {}", name);
        log.warn("warn log = {}", name);
        log.error("error log = {}", name);

        return "ok";
    }
}
//application.properties

#전체 로그 레벨 설정(기본 info)
logging.level.root=info
#hello.springmvc 패키지와 그 하위 로그 레벨 설정
logging.level.hello.springmvc=debug

 

올바른 로그 사용법

- log.debug("data="+data) (X)

로그 출력 레벨을 info로 설정해도 해당 코드에 있는 "data="+data가 실제 실행이 되어 버린다. 결과적으 로 문자 더하기 연산이 발생한다.

- log.debug("data={}", data) (O)

로그 출력 레벨을 info로 설정하면 아무일도 발생하지 않는다. 따라서 앞과 같은 의미없는 연산이 발생하지 않는다.

 

로그 사용시 장점

- 쓰레드 정보, 클래스 이름 같은 부가 정보를 함께 볼 수 있고, 출력 모양을 조정할 수 있다.

- 로그 레벨에 따라 개발 서버에서는 모든 로그를 출력하고, 운영서버에서는 출력하지 않는 등 로그를 상황에 맞게 조절할 수 있다.

- 시스템 아웃 콘솔에만 출력하는 것이 아니라, 파일이나 네트워크 등, 로그를 별도의 위치에 남길 수 있다. 특히 파일로 남길 때는 일별, 특정 용량에 따라 로그를 분할하는 것도 가능하다.

 

2. 요청 매핑

@Controller는 반환 값이 String이면 뷰 이름으로 인식된다. 그래서 뷰를 찾고 뷰가 렌더링 된다.

@RestController는 반환 값으로 뷰를 찾는 것이 아니라, HTTP 메시지 바디에 바로 입력한다.

@RestController
public class MappingController {

    private Logger log = LoggerFactory.getLogger(getClass());

    @RequestMapping(value = "/hello-basic")
    public String helloBasic() {
        log.info("helloBasic");
        return "ok";
    }

    @RequestMapping(value = "/mapping-get-v1", method = RequestMethod.GET)
    public String mappingGetV1() {
        log.info("mappingGetV1");
        return "ok";
    }

    /**
     * 편리한 축약 애노테이션 (코드보기)
     * @GetMapping
     * @PostMapping
     * @PutMapping
     * @DeleteMapping
     * @PatchMapping
     */
    @GetMapping(value = "/mapping-get-v2")
    public String mappingGetV2() {
        log.info("mapping-get-v2");
        return "ok";
    }

    /**
     * PathVariable 사용
     * 변수명이 같으면 생략 가능
     *
     * @PathVariable("userId") String data -> @PathVariable userId
     * /mapping/userA
     */
    @GetMapping("/mapping/{userId}")
    public String mappingPath(@PathVariable("userId") String data) {
        log.info("mappingPath userId={}", data);
        return "ok";
    }

    /**
     * PathVariable 사용 다중
     */
    @GetMapping("/mapping/users/{userId}/orders/{orderId}")
    public String mappingPath(@PathVariable String userId, @PathVariable Long orderId) {
        log.info("mappingPath userId={}, orderId={}", userId, orderId);
        return "ok";
    }

    /**
     * 파라미터로 추가 매핑
     * params="mode",
     * params="!mode"
     * params="mode=debug"
     * params="mode!=debug" (! = )
     * params = {"mode=debug","data=good"}
     */
    @GetMapping(value = "/mapping-param", params = "mode=debug")
    public String mappingParam() {
        log.info("mappingParam");
        return "ok";
    }

    /**
     * 특정 헤더로 추가 매핑
     * headers="mode",
     * headers="!mode"
     * headers="mode=debug"
     * headers="mode!=debug" (! = )
     */
    @GetMapping(value = "/mapping-header", headers = "mode=debug")
    public String mappingHeader() {
        log.info("mappingHeader");
        return "ok";
    }

    /**
     * Content-Type 헤더 기반 추가 매핑 Media Type
     * consumes="application/json"
     * consumes="!application/json"
     * consumes="application/*"
     * consumes="*\/*"
     * MediaType.APPLICATION_JSON_VALUE
     */
    @PostMapping(value = "/mapping-consume", consumes = MediaType.APPLICATION_JSON_VALUE)
    public String mappingConsumes() {
        log.info("mappingConsumes");
        return "ok";
    }

    /**
     * Accept 헤더 기반 Media Type
     * produces = "text/html"
     * produces = "!text/html"
     * produces = "text/*"
     * produces = "*\/*"
     */
    @PostMapping(value = "/mapping-produce", produces = MediaType.TEXT_HTML_VALUE)
    public String mappingProduces() {
        log.info("mappingProduces");
        return "ok";
    }
}
//API 예시
@RestController
@RequestMapping("/mapping/users")
public class MappingClassController {

    @GetMapping
    public String user() {
        return "get users";
    }

    @PostMapping
    public String addUser() {
        return "post user";
    }

    @GetMapping("/{userId}")
    public String findUser(@PathVariable String userId) {
        return "get userId=" + userId;
    }

    @PatchMapping("/{userId}")
    public String updateUser(@PathVariable String userId) {
        return "update userId=" + userId;
    }

    @DeleteMapping("/{userId}")
    public String deleteUser(@PathVariable String userId) {
        return "delete userId=" + userId;
    }

}

 

3. HTTP 요청 - 기본, 헤더 조회

@Slf4j
@RestController
public class RequestHeaderController {

    @RequestMapping("/headers")
    public String headers(HttpServletRequest request,
                          HttpServletRequest response,
                          HttpMethod httpMethod,
                          Locale locale,
                          @RequestHeader MultiValueMap<String, String> headerMap,
                          @RequestHeader("host") String host,
                          @CookieValue(value = "myCookie", required = false) String cookie
    ) {

        log.info("request={}", request); //요청정보
        log.info("response={}", response); //응답정보
        log.info("httpMethod={}", httpMethod); //HTTP메서드
        log.info("locale={}", locale); //Locale정보
        log.info("headerMap={}", headerMap); //모든 HTTP 헤더 조회
        log.info("header host={}", host); //특정 HTTP 헤더 조회
        log.info("myCookie={}", cookie); //특정 쿠키 조회
        return "ok";
    }
}

 

 

4. HTTP 요청 파라미터 - @RequestParam

@ResponseBody는 View 조회를 무시하고, HTTP 메시지 바디에 바로 입력한다. (@RestController 대신 사용)

@Slf4j
@Controller
public class RequestParamController {

    @RequestMapping("/request-param-v1")
    public void requestParamV1(HttpServletRequest request, HttpServletResponse response) throws IOException {
        String username = request.getParameter("username");
        int age = Integer.parseInt(request.getParameter("age"));

        log.info("username={}, age={}", username, age);
        response.getWriter().write("ok");
    }

    //@RequestParam: 파라미터 이름으로 바인딩
    @ResponseBody
    @RequestMapping("/request-param-v2")
    public String requestParamV2(
            @RequestParam("username") String memberName,
            @RequestParam("age") int memberAge) {

        log.info("username={}, age={}", memberName, memberAge);
        return "ok";
    }

    //HTTP 파라미터 이름이 변수 이름과 같으면 (name="xx") 생략 가능
    @ResponseBody
    @RequestMapping("/request-param-v3")
    public String requestParamV3(
            @RequestParam String username,
            @RequestParam int age) {

        log.info("username={}, age={}", username, age);
        return "ok";
    }

    //String, int, Integer 등의 단순 타입이면 @RequestParam도 생략 가능 (But 과한 생략)
    @ResponseBody
    @RequestMapping("/request-param-v4")
    public String requestParamV4(String username, int age) {
        log.info("username={}, age={}", username, age);
        return "ok";
    }

    //파라미터 필수 여부 (기본값은 true)
    @ResponseBody
    @RequestMapping("/request-param-required")
    public String requestParamRequired(
            @RequestParam(required = true) String username,
            @RequestParam(required = false) Integer age) {

        log.info("username={}, age={}", username, age);
        return "ok";
    }

    //기본값 적용
    @ResponseBody
    @RequestMapping("/request-param-default")
    public String requestParamDefault(
            @RequestParam(required = true, defaultValue = "guest") String username,
            @RequestParam(required = false, defaultValue = "-1") int age) {

        log.info("username={}, age={}", username, age);
        return "ok";
    }

    //Map, MultiValueMap으로 조회
    @ResponseBody
    @RequestMapping("/request-param-map")
    public String requestParamMap(@RequestParam Map<String, Object> paramMap) {
        log.info("username={}, age={}", paramMap.get("username"), paramMap.get("age"));
        return "ok";
    }
}

 

5. HTTP 요청 파라미터 - @ModelAttribute

실제 개발을 하면 요청 파라미터를 받아서 필요한 객체를 만들고 그 객체에 값을 넣어주어야 한다. ㅡ> @ModelAttribute

@Data //@Getter + @Setter + @ToString + @EqualsAndHashCode + @RequiredArgsConstructor
public class HelloData {
    private String username;
    private int age;
}
@Slf4j
@Controller
public class RequestParamController {

    @ResponseBody
    @RequestMapping("/model-attribute-v1")
    public String modelAttributeV1(@ModelAttribute HelloData helloData) {
        log.info("username={}, age={}", helloData.getUsername(), helloData.getAge());
        return "ok";
    }

    @ResponseBody
    @RequestMapping("/model-attribute-v2")
    public String modelAttributeV2(HelloData helloData) {
        log.info("username={}, age={}", helloData.getUsername(), helloData.getAge());
        return "ok";
    }
}

스프링MVC는 @ModelAttribute가 있으면 다음을 실행한다.

- HelloData 객체를 생성한다.

- 요청 파라미터의 이름으로 HelloData객체의 프로퍼티를 찾는다.

- 해당 프로퍼티의 setter를 호출해서 파라미터의 값을 입력(바인딩) 한다.

 

@ModelAttribute는 생략할 수 있다. 그런데 @RequestParam도 생략할 수 있으니 혼란이 발생할 수 있다.

- String, int, Integer 같은 단순 타입 = @ RequestParam

- 나머지 = @ModelAttribute (argument resolver로 지정해둔 타입 외)

 

6. HTTP 요청 메시지 - 단순 텍스트

요청 파라미터와 다르게, HTTP 메시지 바디를 통해 데이터가 직접 넘어오는 경우에 @RequestParam, @ModelAttribute를 사용할 수 없다. (물론 HTML Form 형식으로 전달되는 경우는 요청 파라미터로 인정된다.)

@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("/request-body-string-v2")
    public void requestBodyStringV2(InputStream inputStream, Writer responseWriter) throws IOException {

        String messageBody = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8);
        log.info("messageBody={}", messageBody);
        responseWriter.write("ok");
    }

    @PostMapping("/request-body-string-v3")
    public HttpEntity<String> requestBodyStringV3(HttpEntity<String> httpEntity) throws IOException {

        String messageBody = httpEntity.getBody();
        log.info("messageBody={}", messageBody);

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

    @ResponseBody
    @PostMapping("/request-body-string-v4")
    public String requestBodyStringV4(@RequestBody String messageBody) {
        log.info("messageBody={}", messageBody);
        return "ok";
    }
}

스프링 MVC는 다음 파라미터를 지원한다.

- InputStream(Reader): HTTP 요청 메시지 바디의 내용을 직접 조회
- OutputStream(Writer): HTTP 응답 메시지의 바디에 직접 결과 출력

- HttpEntity: HTTP header, body 정보를 편리하게 조회 / 메시지 바디 정보를 직접 조회, 반환 (응답에도 사용 가능)

 

HttpEntity를 상속받은 객체들도 같은 기능을 제공한다.

- RequestEntity: HttpMethod, url 정보가 추가

- ResponseEntity: HTTP 상태 코드 설정 가능

 

@RequestBody를 사용하면 HTTP 메시지 바디 정보를 편리하게 조회할 수 있다. 참고로 헤더 정보가 필요하다면 HttpEntity를 사용하거나 @RequestHeader를 사용하면 된다.

 

7. HTTP 요청 메시지 - JSON

@Slf4j
@Controller
public class RequestBodyJsonController {

    private ObjectMapper objectMapper = new ObjectMapper();

    @PostMapping("/request-body-json-v1")
    public void requestBodyJsonV1(HttpServletRequest request, HttpServletResponse response) throws IOException {
        ServletInputStream inputStream = request.getInputStream();
        String messageBody = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8);

        log.info("messageBody={}", messageBody);
        HelloData helloData = objectMapper.readValue(messageBody, HelloData.class);
        log.info("username={}, age={}", helloData.getUsername(), helloData.getAge());

        response.getWriter().write("ok");
    }

    //@RequestBody로 데이터를 꺼내고 자바 객체로 변환
    @ResponseBody
    @PostMapping("/request-body-json-v2")
    public String requestBodyJsonV2(@RequestBody String messageBody) throws IOException {

        log.info("messageBody={}", messageBody);
        HelloData helloData = objectMapper.readValue(messageBody, HelloData.class);
        log.info("username={}, age={}", helloData.getUsername(), helloData.getAge());

        return "ok";
    }

    //@RequestBody에 직접 만든 객체를 지정할 수 있다.
    @ResponseBody
    @PostMapping("/request-body-json-v3")
    public String requestBodyJsonV3(@RequestBody HelloData data) {
        log.info("username={}, age={}", data.getUsername(), data.getAge());
        return "ok";
    }

    //HttpEntity를 사용해도 된다.
    @ResponseBody
    @PostMapping("/request-body-json-v4")
    public String requestBodyJsonV4(HttpEntity<HelloData> httpEntity) {
        HelloData data = httpEntity.getBody();
        log.info("username={}, age={}", data.getUsername(), data.getAge());
        return "ok";
    }

    //해당 객체를 HTTP 메시지 바디에 직접 넣어 응답할 수 있다.
    @ResponseBody
    @PostMapping("/request-body-json-v5")
    public HelloData requestBodyJsonV5(@RequestBody HelloData data) {
        log.info("username={}, age={}", data.getUsername(), data.getAge());
        return data;
    }
}

@RequestBody요청: JSON요청 ㅡ> HTTP 메시지 컨버터 ㅡ> 객체

@ResponseBody응답: 객체 ㅡ> HTTP 메시지 컨버터 ㅡ> JSON응답

 

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

스프링 부트는 클래스패스의 다음 디렉토리에 있는 정적 리소스를 제공한다.

/static, /public, /resources, /META-INF/resources

src/main/resources는 리소스를 보관하는 곳이고, 또 클래스 패스의 시작 경로이다.

 

8-1. 정적 리소스

src/main/resources/static에 리소스를 넣어두면 스프링 부트가 정적 리소스로 서비스를 제공한다.

src/main/resources/static/index.html ㅡ> Welcome 페이지

src/main/resources/static/basic/hello-form.html ㅡ> http://localhost:8080/basic/hello-form.html

 

8-2. 뷰 템플릿

뷰 템플릿을 거쳐서 HTML이 생성되고, 뷰가 응답을 만들어서 전달한다.

스프링 부트는 src/main/resources/templates를 기본 뷰 템플릿 경로로 제공한다.

@Controller
public class ResponseViewController {

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

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

    //void를 반환하면 요청 URL을 참고하여 논리 뷰 이름으로 사용 (명시성X, 권장X)
    @RequestMapping("/response/hello")
    public void responseViewV3(Model model) {
        model.addAttribute("data", "hello!");
    }
}

@ResponseBody, @RestController가 없으면 return값으로 뷰 리졸버가 실행되어서 뷰를 찾고 렌더링 한다.

ㅡ> templates/response/hello.html의 뷰 템플릿이 렌더링 된다.

 

9. HTTP 응답 - HTTP API (메시지 바디에 직접 입력)

HTTP API를 제공하는 경우에는 HTML이 아니라 데이터를 전달해야 하므로, HTTP 메시지 바디에 JSON같은 형식으로 데이터를 실어 보낸다.

@Slf4j
@RestController //Rest API를 만들 때 주로 사용
public class ResponseBodyController {

    @GetMapping("/response-body-string-v1")
    public void responseBodyV1(HttpServletResponse response) throws IOException {
        response.getWriter().write("ok");
    }

    //HttpEntity를 상속 받은 ResponseEntity 사용
    @GetMapping("/response-body-string-v2")
    public ResponseEntity<String> responseBodyV2() {
        return new ResponseEntity<>("ok", HttpStatus.OK);
    }

    //@ResponseBody
    @GetMapping("/response-body-string-v3")
    public String responseBodyV3() {
        return "ok";
    }

    //HTTP 메시지 컨버터를 통해서 ReponseEntity객체가 JSON형식으로 변환되어 반환
    @GetMapping("/response-body-json-v1")
    public ResponseEntity<HelloData> responseBodyJsonV1() {
        HelloData helloData = new HelloData();
        helloData.setUsername("userA");
        helloData.setAge(20);
        return new ResponseEntity<>(helloData, HttpStatus.OK);
    }

    //@ResponseSatuts로 상태코드를 설정하고 객체 직접 반환 
    @ResponseStatus(HttpStatus.OK)
    //@ResponseBody
    @GetMapping("/response-body-json-v2")
    public HelloData responseBodyJsonV2() {
        HelloData helloData = new HelloData();
        helloData.setUsername("userA");
        helloData.setAge(20);
        return helloData;
    }

}

 

10. HTTP 메시지 컨버터

JSON 데이터를 HTTP 메시지 바디에서 직접 읽거나 쓰는 경우 HTTP 메시지 컨버터를 사용한다.

스프링 MVC는 다음의 경우에 HTTP 메시지 컨버터를 사용한다.

- HTTP 요청: @RequestBody, HttpEntity(RequestEntity)

- HTTP 응답: @ResponseBody, @HttpEntity(ResponseEntity)

@ResponseBody를 사용

- HTTP의 BODY에 문자 내용을 직접 반환

- viewResolver 대신에 HttpMessageConverter가 동작

- 기본 문자처리: StringHttpMessageConverter

- 기본 객체처리: MappingJackson2HttpMessageConverter

 

스프링 부트 기본 메시지 컨버터

- 대상 클래스 타입과 미디어 타입을 체크해서 사용여부를 결정한다. 만족하지 않으면 다음 메세지 컨버터로 넘어간다.

- ByteArrayHttpMessageConverter 클래스타입: byte[], 미디어타입: */*

- StringHttpMessageConverter 클래스타입: String, 미디어타입: */*

- MappingJackson2HttpMessageConverter 클래스타입: 객체 또는 HashMap, 미디어타입: application/json

 

HTTP 요청 데이터 읽기

- HTTP 요청이 오고, 컨트롤러에서 @RequestBody, HttpEntity 파라미터를 사용한다.

- 메시지 컨버터가 메시지를 읽을 수 있는지 확인하기 위해 canRead()를 호출한다. (클래스 타입과 Content-Type을 체크)

- canRead() 조건을 만족하면 read()를 호출해서 객체를 생성하고, 반환한다.

 

HTTP 응답 데이터 생성

- 컨트롤러에서 @ResponseBody, HttpEntity로 값이 반환된다.

- 메시지 컨버터가 메시지를 쓸 수 있는지 확인하기 위해 canWrite()를 호출한다. (클래스 타입과 Accept 타입을 체크)

- canWrite() 조건을 만족하면 write()를 호출해서 HTTP 응답 메시지 바디에 데이터를 생성한다.

 

11. RequestMappingHandlerAdapter 구조

ArgumentResolver

컨트롤러는 HttpServletRequest, Model, @RequestParam, @ModelAttribute, @RequestBody, HttpEntity 등 매우 다양한 파라미터를 사용할 수 있었다.

이렇게 파라미터를 유연하게 처리할 수 있는 이유가 ArgumentResolver 덕분이다.

애노테이션 기반 컨트롤러를 처리하는 RequestMappingHandlerAdapter는 ArgumentResolver를 호출해서 컨트롤러가 필요로 하는 다양한 파라미터의 값(객체)을 생성한다.

파라미터의 값이 모두 준비되면 컨트롤러를 호출하면서 값을 넘겨준다.

스프링은 30개가 넘는 ArgumentResolver를 기본으로 제공한다.

 

ReturnValueHandler

ArgumentResolver와 비슷한데, 이것은 응답 값을 변환하고 처리한다.

컨트롤러에서 String으로 뷰 이름을 반환해도 동작하는 이유가 ReturnValueHandler 덕분이다.

스프링은 10개가 넘는 ReturnValueHandler를 지원한다. (ModelAndView, @ResponseBody, HttpEntity, String...)

 

ArgumentResolver, ReturnValueHandler가 HTTP메시지 컨버터를 사용하여 필요한 것을 생성한다.

스프링은 다음을 모두 인터페이스로 제공한다. 따라서 필요하면 언제든지 기능을 확장할 수 있다.

- HandlerMethodArgumentResolver

- HandlerMethodReturnValueHandler

- HttpMessageConverter

- 스프링이 필요한 대부분의 기능을 제공하기 때문에 실제 기능을 확장할 일이 많지는 않다.

- 기능 확장은 WebMvcConfiguer를 상속 받아서 스프링 빈으로 등록하면 된다.