저번 섹션까지 서블릿과 관련한 프로젝트는 마무리됐다. 이후 스프링 MVC를 집중적으로 학습하기 위해 새로운 스프링 프로젝트를 생성한다.
1. 프로젝트 생성 및 기초 작업
이때 뷰 템플릿 엔진으로 JSP 대신 Thymeleaf를 사용하기 때문에 Packaging을 War 대신 Jar로 선택해야 한다.
- Jar를 사용하면 항상 내장 서버(톰캣 등)를 사용하고, webapp 경로도 사용하지 않는다. 내장 서버 사용에 최적화돼 있는 기능이라, 최근에는 스프링 부트 사용 시 주로 이 방식을 사용한다.
- War는 WAS를 별도로 설치하고, 그곳에 빌드된 파일을 넣을 때 사용한다. 물론 내장 서버도 사용 가능하지만, 주로 외부 서버에 배포하는 목적으로 사용한다.
Welcome 페이지 만들기
스프링 부트에 Jar를 사용하면 스프링 부트가 제공하는 정적 컨텐츠인 /resource/static/ 위치에 index.html 파일을 두면 Welcome 페이지로 처리한다.
참고
스프링 부트 Welcome 페이지 지원
스프링 부트는 static과 templated welcome 페이지를 지원한다. 먼저, 정적 컨텐츠 위치에서 index.html 파일을 찾는다. 여기서 찾지 못한 경우, index template를 찾는다. 둘 중 하나가 발견되면, 자동으로 애플리케이션의 welcome 페이지로 사용된다.
2. 로깅(logging)
운영 시스템에서는 System.out.println() 같은 시스템 콘솔로 필요한 정보를 출력하지 않고, 별도의 로깅 라이브러리를 통해 로그를 출력한다. 일단은 로깅 라이브러리의 최소한의 사용법만 알아두자.
로깅 라이브러리
스프링 부트 라이브러리를 사용하면, 스프링 부트 로깅 라이브러리(spring-boot-starter-logging)가 함께 포함된다.
스프링 부트 로깅 라이브러리는 기본값으로 다음 로깅 라이브러리를 사용한다.
Logback, Log4J, Log4J2 등 수많은 로그 라이브러리가 있는데, 그것들을 통합해서 어댑터 같은 Interface로 제공하는 것이 SLF4J 라이브러리다.
- SLF4J는 Interface고, 그 구현체로 Logback 같은 로그 라이브러리를 선택하는 거라고 보면 된다.
- 실무에서는 스프링 부트가 기본으로 제공하는 Logback을 대부분 사용한다.
로그 사용 및 장점
a. 선언
// 필드로 선언
private Logger log = LoggerFactory.getLogger(getClass());
private static final Logger log = LoggerFactory.getLogger(xxx.class);
// 롬복 사용 가능
@Slf4j
b. 호출
// 로그 호출
log.info("logging...");
// 시스템 콘솔로 직접 출력
System.out.println("logging...");
c. 장점
시스템 콘솔로 직접 출력하는 것보다 로그를 사용하면 다음과 같은 장점이 있다. 실무에서는 항상 로그를 사용하는 것이 권장된다.
- 쓰레드 정보, class 이름 같은 부가 정보를 함께 볼 수 있고, 출력 모양을 조절할 수 있다.
- 로그 레벨에 따라 개발 서버에서는 모든 로그를 출력하고, 운영 서버에서는 출력하지 않는 등 로그를 상황에 맞게 조절할 수 있다.
- 시스템 아웃 콘솔에만 출력하는 것이 아니라, 파일이나 네트워크 등, 로그를 별도의 위치에 남길 수 있다. 특히 파일로 남길 때는 일별, 특정 용량에 따라 로그를 분할하는 것도 가능하다.
- 성능도 일반 System.out보다 좋다. (내부 버퍼링, 멀티 쓰레드 등)
- 운영 서버에서 System.out으로 로그 모두 출력하게 되면 성능이 떨어지게 된다. 따라서 중요한 것만 남길 수 있도록 로그를 사용해야 한다.
//@Slf4j
@RestController
public class LogTestController {
private final Logger log = LoggerFactory.getLogger(getClass());
@RequestMapping("/log-test")
public String logTest() {
String name = "Spring";
// 실무에서 사용하지 않음
System.out.println("name = " + name);
// 괄호가 오른쪽 변수로 치환됨
log.trace("trace log={}", name); // 추적용 - 출력 X
log.debug("debug log={}", name); // 개발 서버용 - 출력 X
log.info("info log={}", name); // 비즈니스 정보 등 필수 정보
log.warn("warn log={}", name); // 경고용
log.error("error log={}", name); // 에러용
// 로그를 사용하지 않아도 a+b 계산 로직이 먼저 실행됨, 이런 방식으로 사용하면 X
log.debug("String concat log=" + name);
// @RestController를 쓰면 String이 HTTP 메시지 바디에 그대로 반환됨
return "ok";
}
}
위의 log에서 INFO, WARN, ERROR만 로그가 찍힌다.
매핑 정보
- @Controller는 반환 값이 String이면 View 이름으로 인식된다. 따라서 View를 찾고, View가 렌더링 된다.
- @RestController는 반환 값으로 View를 찾지 않고, HTTP 메시지 바디에 바로 입력한다. 따라서 실행 결과로 ok 메시지를 받을 수 있다. (@ResponseBody와 관련이 있다.)
테스트
- 로그 출력 포맷 확인
- 시간 / 로그 레벨 / 프로세스 ID / 쓰레드 명 / class 명 / 로그 메시지
- 로그 레벨 설정을 변경해서 출력 결과를 보자.
- LEVEL: TRACE > DEBUG > INFO > WARN > ERROR
- 개발 서버는 DEBUG까지 출력
- 운영 서버는 INFO까지 출력
- @Slf4j를 추가하면 Logger를 선언하지 않고 log.info() 등을 바로 쓸 수 있다.
// private final Logger log = LoggerFactory.getLogger(getClass());
로그 레벨 설정 - application.properties
#전체 로그 레벨 설정 (기본값은 info)
logging.level.root=info
#hello.springmvc 패키지와 그 하위 로그 레벨 설정
#debug로 해두면 trace는 뜨지 않음 / trace로 해두면 모두 뜸
logging.level.hello.springmvc=debug
올바른 로그 사용법
- log.debug("data=" + data)
- 로그 출력 레벨을 info로 설정해도 해당 코드에 있는 "data=" + data가 실제 실행이 되어 버린다. 결과적으로 문자 더하기 연산이 발생한다.
- 연산이 발생하면서 메모리나 CPU를 사용하게 된다는 게 문제다. 로그는 사용하지 않으면서 리소스를 낭비하게 되는 것이다.
- log.debug("data={}", data)
- 로그 출력 레벨을 info로 설정하면 아무 일도 발생하지 않는다. 따라서 앞과 같은 의미 없는 연산이 발생하지 않는다.
참고
스프링 부트가 제공하는 로그 기능
3. 요청 매핑
요청 매핑은 요청이 왔을 때 어떤 Controller가 호출이 돼야 하는지 매핑하는 것이다. 단순히 URL뿐만 아니라 다양한 정보를 통해 매핑할 수 있다. Controller를 만들고 Postman으로 테스트해보자.
private Logger log = LoggerFactory.getLogger(getClass());
/**
* 기본 요청
* HTTP 메서드 모두 허용 - GET, POST, PUT, PATCH, DELETE
*/
// @RequestMapping({"/hello-basic", "hello-go"})
@RequestMapping("/hello-basic")
public String helloBasic() {
log.info("helloBasic");
return "ok";
}
매핑 정보
@RequestMapping("/hello-basic")
- /hello-basic URL 호출이 오면 이 메서드가 실행되도록 매핑한다.
- 대부분의 속성을 배열[]로 제공하므로 다중 설정이 가능하다.
- ex. {"/hello-basic", "/hello-go"}
- 스프링 부트 3.0부터는 /hello-basic/과 /hello-basic은 서로 다른 URL 요청을 사용해야 한다.
HTTP 메서드
@RequestMapping에 method 속성으로 HTTP 메서드를 지정하지 않으면, HTTP 메서드와 무관하게 호출된다. 따라서 GET, POST, PUT, PATCH, DELETE 모두 허용한다.
- 아래 URL에 POST 요청이 들어오면, 스프링 MVC는 HTTP 405 상태코드(Method Not Allowed)를 반환한다. (Postman으로 테스트)
/**
* method 특정 HTTP 메서드 요청만 허용
* GET, HEAD, POST, PUT, PATCH, DELETE
*/
@RequestMapping(value = "/mapping-get-v1", method = RequestMethod.GET)
public String mappingGetV1() {
log.info("mappingGetV1");
return "ok";
}
- 위의 코드보단 아래처럼 HTTP 메서드를 축약한 애노테이션을 사용하는 것이 더 직관적이다. 코드를 보면 내부에서 @RequestMapping과 method를 지정해서 사용하는 것을 확인할 수 있다. (Postman으로 테스트)
/**
* 편리한 축약 애노테이션 (코드보기)
* @GetMapping
* @PostMapping
* @PutMapping
* @DeleteMapping
* @PatchMapping
*/
@GetMapping(value = "/mapping-get-v2")
public String mappingGetV2() {
log.info("mapping-get-v2");
return "ok";
}
PathVariable(경로 변수) 사용
최근 HTTP API는 다음과 같이 리소스 경로에 식별자를 넣는 스타일을 선호한다.
- ex. /mapping/userA, /users/1
- @RequestMapping은 URL 경로를 템플릿화할 수 있는데, @PathVariable을 사용하면 매칭되는 부분을 편리하게 조회할 수 있다.
- 경로 뒤에 {data} 형식으로 붙이고, 파라미터로 data를 받아오면 된다.
- @PathVariable의 이름과 파라미터 이름이 같으면, @PathVariable 뒤의 ("")를 생략할 수 있다.
/**
* PathVariable 사용
* 변수명이 같으면 생략 가능
* @PathVariable("userId") String userId -> @PathVariable String userId
*/
@GetMapping("/mapping/{userId}")
public String mappingPath(@PathVariable("userId") String data) { // -> @PathVariable 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";
}
특정 헤더 조건 매핑
파라미터 매핑과 비슷하지만, HTTP 헤더를 사용한다. (Postman으로 테스트)
/**
* 특정 헤더로 추가 매핑
* 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";
}
미디어 타입 조건 매핑
HTTP 요청 Content-Type, consumes
HTTP 요청의 Content-Type 헤더를 기반으로 미디어 타입으로 매핑한다. 만약 맞지 않으면 HTTP 415 상태코드(Unsupported Media Type)를 반환한다.
/**
* Content-Type 헤더 기반 추가 매핑 Media Type
* consumes="application/json"
* consumes="!application/json"
* consumes="application/*"
* consumes="*\/*"
* MediaType.APPLICATION_JSON_VALUE
*/
@PostMapping(value = "/mapping-consume", consumes = "application/json")
public String mappingConsumes() {
log.info("mappingConsumes");
return "ok";
}
- ex. consumes
consumes = "text/plain"
consumes = {"text/plain", "application/*"}
consumes = MediaType.TEXT_PLAIN_VALUE
HTTP 요청 Accept, produces
HTTP 요청의 Accept 헤더를 기반으로 미디어 타입으로 매핑한다. 만약 맞지 않으면 HTTP 406 상태코드(Not Acceptable)를 반환한다.
/**
* Accept 헤더 기반 Media Type
* produces = "text/html"
* produces = "!text/html"
* produces = "text/*"
* produces = "*\/*"
*/
@PostMapping(value = "/mapping-produce", produces = "text/html")
public String mappingProduces() {
log.info("mappingProduces");
return "ok";
}
- ex. produces
produces = "text/plain"
produces = {"text/plain", "application/*"}
produces = MediaType.TEXT_PLAIN_VALUE
produces = "text/plain;charset=UTF-8"
API 예시
회원 관리를 HTTP API로 만든다 생각하고 요청 매핑을 어떻게 하는지 알아보자.
- 같은 URL이라도 HTTP method로 기능을 구분할 수 있다.
기능 | HTTP method | URL |
회원 목록 조회 | GET | /users |
회원 등록 | POST | /users |
회원 조회 | GET | /users/{uesrId} |
회원 수정 | PATCH | /users/{userId} |
회원 삭제 | DELETE | /users/{userId} |
실제 데이터가 넘어가는 부분은 생략하고 URL 매핑만 진행해 본다.
@RestController
@RequestMapping("/mapping/users")
public class MappingClassController {
/**
* GET /mapping/users
*/
@GetMapping
public String users() {
return "get users";
}
/**
* POST /mapping/users
*/
@PostMapping
public String addUser() {
return "post user";
}
/**
* GET /mapping/users/{userId}
*/
@GetMapping("/{userId}")
public String findUser(@PathVariable String userId) {
return "get userId=" + userId;
}
/**
* PATCH /mapping/users/{userId}
*/
@PatchMapping("/{userId}")
public String updateUser(@PathVariable String userId) {
return "update userId=" + userId;
}
/**
* DELETE /mapping/users/{userId}
*/
@DeleteMapping("/{userId}")
public String deleteUser(@PathVariable String userId) {
return "delete userId=" + userId;
}
}