Thymeleaf는 웹 및 독립 실행형 환경 모두를 위한 최신 서버 측 Java 템플릿 엔진입니다. Thymeleaf의 주요 목표는 귀하의 개발 워크플로에 우아하고 자연스러운 템플릿을 가져오는 것입니다. HTML은 브라우저에 올바르게 표시될 수 있고 정적 프로토타입으로도 작동하여 개발 팀에서 보다 강력한 협업을 가능하게 합니다. Spring Framework용 모듈, 선호하는 도구와의 통합 호스트 및 고유한 기능을 플러그인할 수 있는 기능을 갖춘 Thymeleaf는 할 수 있는 것이 훨씬 더 많지만 현대 HTML5 JVM 웹 개발에 이상적입니다. 출처 : https://www.thymeleaf.org/
<form action="multi-file" method="post" encType="multipart/form-data">
파일 : <input type="file" name="multiFiles" multiple><br>
파일 설명 : <input type="text" name="multiFileDescription"><br>
<input type="submit">
</form>
위 코드에서 input 태그의 multiple은 파일 여러 개를 한 번에 업로드 하기 위한 속성이다.
2. 파일 업로드를 처리할 컨트롤러 생성
업로드 파일이 같은 name 속성으로 여러 개 전달 되므로 MultipartFile클래스는 List타입으로 선언해야 한다.
@PostMapping("multi-file")
public String multiFileUpload(@RequestParam List<MultipartFile> multiFiles,
String multiFileDescription, Model model) {
System.out.println("multiFiles : " + multiFiles);
System.out.println("multiFileDescription : " + multiFileDescription);
/* 파일을 저장할 경로 설정 */
String root = "src/main/resources/static";
System.out.println("root : " + root);
String filePath = root + "/uploadFiles";
File dir = new File(filePath);
if(!dir.exists()) {
dir.mkdirs();
}
List<FileDTO> files = new ArrayList<>();
try {
for(MultipartFile file : multiFiles) {
/* 파일명 변경 처리 */
String originFileName = file.getOriginalFilename();
String ext = originFileName.substring(originFileName.lastIndexOf("."));
String savedName = UUID.randomUUID().toString().replace("-", "") + ext;
/* 파일에 관한 정보 추출 후 보관 */
files.add(new FileDTO(originFileName, savedName, filePath,
multiFileDescription));
/* 파일을 저장 */
file.transferTo(new File(filePath + "/" + savedName));
}
model.addAttribute("message", "파일 업로드 성공!");
} catch (Exception e) {
e.printStackTrace();
/* 실패 시 이전에 저장 된 파일 삭제 */
for(FileDTO file : files) {
new File(filePath + "/" + file.getSavedName()).delete();
}
model.addAttribute("message", "파일 업로드 실패!!");
}
return "result";
}
@RequestParam어노테이션을 이용하여 요청 파라미터 중 multiFiles라는 이름으로 전송된 파일을 List<MultipartFile>객체로 받아온다. 이후 해당 파일을 처리하는데 필요한 정보를 DTO 타입을 선언해서 다룰 수 있다. 추후 DB에 저장하는 등의 작업이 필요하다. 실패 시 이전에 저장 된 파일은 삭제하며 최종적으로 result라는 뷰 페이지를 반환한다.
3. FileDTO
파일을 처리하는데 필요한 정보를 DTO 타입을 선언해서 다룰 수 있다. 추후 DB에 저장하는 등의 작업 시 활용 된다.
spring-boot-starter-web에는 file upload를 위한multipartResolver가 기본 빈으로 등록 되어 있기 때문에 추가적으로 등록할 필요는 없다. 파일 저장 경로, 용량 등에 대한 설정을 application.properties를 이용해서 할 수 있다.
파일의 크기가 fize-size-threshold 값 이하라면 임시파일을 생성하지 않고 메모리에서 즉시 파일을 읽어서 생성할 수 있다. 속도는 빠르지만 쓰레드가 작업을 수행하는 동안 부담이 될 수 있다.
파일의 크기가 fize-size-threshold 값을 초과한다면 파일은 spring.servlet.multipart.location 경로에 저장되어 해당 파일을 읽어서 작업을 하도록 되어있다. 이 예제에서는 프로젝트 내부 경로에 업로드 파일을 저장하려고 하니 프로젝트 절대 경로를 파일 저장 경로로 설정한다.
# 파일 저장 경로
spring.servlet.multipart.location=프로젝트절대경로
# 최대 업로드 파일 크기
spring.servlet.multipart.max-file-size=10MB
# 최대 요청 파일 크기
spring.servlet.multipart.max-request-size=10MB
2. 파일 업로드 뷰 페이지 작성
업로드 폼을 보여줄 뷰 페이지를 작성한다.
<form action="single-file" method="post" encType="multipart/form-data">
파일 : <input type="file" name="singleFile"><br>
파일 설명 : <input type="text" name="singleFileDescription"><br>
<input type="submit">
</form>
위 코드에서 enctype="multipart/form-data"는 파일 업로드를 위한 인코딩 타입을 지정하는 속성이다.
3. 파일 업로드를 처리할 컨트롤러 생성
Spring Framework에서는 MultipartFile클래스를 이용하여 파일 업로드를 처리한다. 이를 처리할 컨트롤러를 생성한다.
@PostMapping("single-file")
public String singleFileUpload(@RequestParam MultipartFile singleFile,
String singleFileDescription, Model model) {
System.out.println("singleFile : " + singleFile);
System.out.println("singleFileDescription : " + singleFileDescription);
/* 파일을 저장할 경로 설정 */
String root = "src/main/resources/static";
String filePath = root + "/uploadFiles";
File dir = new File(filePath);
System.out.println(dir.getAbsolutePath());
if(!dir.exists()) {
dir.mkdirs();
}
/* 파일명 변경 처리 */
String originFileName = singleFile.getOriginalFilename();
String ext = originFileName.substring(originFileName.lastIndexOf("."));
String savedName = UUID.randomUUID().toString().replace("-", "") + ext;
/* 파일을 저장 */
try {
singleFile.transferTo(new File(filePath + "/" + savedName));
model.addAttribute("message", "파일 업로드 성공!");
} catch (Exception e) {
e.printStackTrace();
model.addAttribute("message", "파일 업로드 실패!!");
}
return "result";
}
@RequestParam어노테이션을 이용하여 요청 파라미터 중 singleFile이라는 이름으로 전송된 파일을 MultipartFile객체로 받아온다. 이후 해당 파일을 리네임 처리하여 저장하고, result라는 뷰 페이지를 반환한다.
Spring Interceptor는 Spring 프레임워크에서 제공하는 기능 중 하나로, 클라이언트의 요청을 가로채서 처리하는 역할을 한다. 이를 통해 공통적인 로직(로깅, 성능 측정, 캐싱)을 처리하거나, 보안(인증, 권한) 등의 목적으로 특정 조건을 검사하고 해당 요청을 처리하거나, 무시할 수 있다.
Interceptor는 특정 요청 URL에만 적용되도록 매핑할 수 있다는 점이 필터와 유사하다. 하지만 필터와 달리
Interceptor는 스프링 웹 애플리케이션 컨텍스트에 구성하기 때문에 컨테이너의 기능을 자유롭게 활용할 수 있으며 그 내부에 선언된 모든 빈을 참조할 수 있다.
1. Spring MVC request life cycle
2. Interceptor 적용하기
Interceptor를 이용해서 handler의 수행 시간을 측정하는 테스트를 진행한다.
클라이언트 측에서 수행 시간을 확인하는 요청을 보낸다.
<button onclick="location.href='stopwatch'">수행 시간 확인하기</button>
컨트롤러의 핸들러 메소드에서 무언가 수행하는 상황을 상정하기 위해 Thread.sleep(밀리세컨)를 사용하여 잠시 멈추었다가 진행되도록 한다.
@GetMapping("stopwatch")
public String handlerMethod() throws InterruptedException {
System.out.println("핸들러 메소드 호출함...");
/* 아무 것도 하는 일이 없으니 수행시간이 0으로 나올 수 있어서 Thread.sleep(1000) 호출 */
Thread.sleep(1000);
return "result";
}
핸들러로 가는 요청을 가로챌 Interceptor를 작성한다. Interceptor는HandlerInterceptor 인터페이스를 구현하여 개발할 수 있으며, preHandle(), postHandle(), afterCompletion() 등의 메소드를 오버라이드하여 사용한다. preHandle() 메소드는 컨트롤러 실행 전, postHandle() 메소드는 컨트롤러 실행 후, afterCompletion() 메소드는 뷰가 렌더링 된 후 호출된다.
@Component
public class StopWatchInterceptor implements HandlerInterceptor {
private final MenuService menuService;
/* 인터셉터는 스프링 컨테이너에 존재하는 빈을 의존성 주입 받을 수 있다. */
public StopWatchInterceptor(MenuService menuService) {
this.menuService = menuService;
}
/* 전처리 메소드로 메소드 실행 시작 시간을 request에 attribute로 저장한다. */
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
Object handler) throws Exception {
System.out.println("preHandler 호출함...");
long startTime = System.currentTimeMillis();
request.setAttribute("startTime", startTime);
/* true이면 컨트롤러를 이어서 호출한다. false이면 핸들러 메소드를 호출하지 않는다. */
return true;
}
/* 후처리 메소드로 메소드 실행 종료 시간을 구해 실행 시작 시간과 연산하여 걸린 시간을 계산한다. */
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response,
Object handler, @Nullable ModelAndView modelAndView) throws Exception {
System.out.println("postHandler 호출함...");
long startTime = (Long) request.getAttribute("startTime");
request.removeAttribute("startTime");
long endTime = System.currentTimeMillis();
modelAndView.addObject("interval", endTime - startTime);
}
/* 뷰가 렌더링 된 후 호출하는 메소드 */
public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
Object handler, @Nullable Exception ex) throws Exception {
System.out.println("afterComplate 호출함...");
menuService.method();
}
}
MenuService는 의존성 주입 테스트를 위해 작성하는 클래스이다.
@Service
public class MenuService {
public void method() {
System.out.println("메소드 호출 확인");
}
}
@ControllerAdvice 어노테이션은 스프링에서 예외 처리를 담당하는 어노테이션이다. @ControllerAdvice 어노테이션이 붙은 클래스는 전역 예외 처리를 담당하게 된다. 즉, 여러 개의 컨트롤러에서 발생하는 예외를 일괄적으로 처리하기 위해 사용한다. 이 어노테이션을 사용하면 모든 컨트롤러에서 발생하는 예외를 한 곳에서 처리할 수 있어 코드의 중복을 방지할 수 있다.
=>@ControllerAdvice 어노테이션을 사용하면 전역 예외 처리를 할 수 있다는 장점이 있지만, 이 어노테이션을 사용하면서 주의해야 할 점도 있다. @ControllerAdvice 어노테이션이 붙은 클래스는 컨트롤러에서 발생하는 모든 예외를 처리하게 되므로, 예외 처리에 대한 로직이 많아지면 클래스의 크기가 커질 수 있다. 따라서 이를 방지하기 위해서는 역할에 따라 클래스를 나눠야 한다.
1. 기본 에러 화면
클라이언트에서 다음과 같은 버튼 클릭 이벤트를 통해 요청한다.
<button onclick="location.href='other-controller-null'">
NullPointerException 테스트
</button>
<button onclick="location.href='other-controller-user'">
사용자 정의 Exception 테스트
</button>
새로운 Controller 클래스를 만들어 위의 요청과 연결하고 다시 한 번 컨트롤러의 핸들러 메소드에 의도적으로 NullPointerException
과, 사용자 정의 Exception인 MemberRegistException을 발생시키는 코드를 작성한다.
@GetMapping("other-controller-user")
public String otherUserExceptionTest() throws MemberRegistException {
boolean check = true;
if(check) {
throw new MemberRegistException("당신 같은 사람은 회원으로 받을 수 없습니다!");
}
return "/";
}
이전의 Controller 클래스에 작성 했던 @ExceptionHandler 어노테이션이 붙은 메소드는 동작하지 않는다. 따라서 기본 에러 응답 화면을 확인할 수 있다.
2. Global 레벨의 Exception 처리
별도의 클래스에@ControllerAdvice 어노테이션을 붙이고 @ExceptionHandler 어노테이션을 붙인 메소드를 정의한다.
@GetMapping("controller-user")
public String userExceptionTest() throws MemberRegistException {
boolean check = true;
if(check) {
throw new MemberRegistException("당신 같은 사람은 회원으로 받을 수 없습니다!");
}
return "/";
}
Exception Handling이 되어 있지 않아 기본 화면으로 오류 화면이 응답된다.
2. Controller 레벨의 Exception 처리
동일 클래스에 아래와 같은 @ExceptionHandler어노테이션이 붙은 메소드를 정의하고 응답할 뷰도 작성한다.
ModelAndView 타입으로 반환하는 경우, 모델과 뷰 정보를 한 번에 담아서 반환하게 된다. String과 ModelAndView는 모두 forward와 redirect를 사용할 수 있다. ModelAndView를 사용한 redirect 시 RedirectAttributes사용도 동일하게 가능하다.
1. view name 반환(forward)
GET 방식의 /modelandview요청을 전달한다.
<h3>ModelAndView로 뷰 이름 지정해서 반환하기</h3>
<button onclick="location.href='modelandview'">ModelAndView로 뷰 이름 지정해서 반환받기</button>
발생하는 요청을 매핑할 controller의 handler method이다.
ModelAndView타입은 모델과 뷰를 합친 개념이다. 핸들러 어댑터가 핸들러 메소드를 호출하고 반환받은 문자열을 ModelAndView로 만들어 dispatcherServlet에 반환한다. 이 때 문자열을 반환해도 되지만 ModelAndView를 미리 만들어서 반환할 수 도 있다.
@GetMapping("modelandview")
public ModelAndView modelAndViewReturning(ModelAndView mv) {
mv.addObject("forwardMessage", "ModelAndView를 이용한 모델과 뷰 반환");
mv.setViewName("result");
return mv;
}
응답할 뷰에서는 model에 추가 된 forwardMessage를 화면에 출력하도록 한다
view resolver가 올바르게 동작하였음을 응답 화면을 통해 확인할 수 있다.
2. redirect
GET 방식의 /modelandview-redirect요청을 전달한다.
<h3>ModelAndView로 redirect 하기</h3>
<button onclick="location.href='modelandview-redirect'">ModelAndView로 뷰 이름 반환하여 리다이렉트</button>
발생하는 요청을 매핑할 controller의 handler method이다. ModelAndView 객체에서도 동일하게 접두사로 redirect:를 붙이면 forward 되지 않고 redirect 된다.
@GetMapping("modelandview-redirect")
public ModelAndView modelAndViewRedirect(ModelAndView mv) {
mv.setViewName("redirect:/");
return mv;
}
버튼 클릭 시 / 경로로 리다이렉트 되는 것을 확인할 수 있다.
3. RedirectAttributes
GET 방식의 /modelandview-redirect-attr 요청을 전달한다.
<h3>ModelAndView로 뷰 이름 반환하면서 flashAttribute 추가 하기</h3>
<button onclick="location.href='modelandview-redirect-attr'">
ModelAndView로 뷰 이름 반환하여 리다이렉트
</button>
발생하는 요청을 매핑할 controller의 handler method이다.
ModelAndView사용시에도 동일하게RedirectAttributes타입을 통해 redirect 시 속성 값을 저장할 수 있다.
@GetMapping("modelandview-redirect-attr")
public ModelAndView modelAndViewRedirect(ModelAndView mv, RedirectAttributes rttr) {
rttr.addFlashAttribute("flashMessage2", "ModelAndview를 이용한 redirect attr");
mv.setViewName("redirect:/");
return mv;
}
응답 시 requestScope에 보존 된 flashMessage2을 꺼내 alert 창에 띄운다.
핸들러 메소드가 요청을 처리하고 논리 뷰 이름을 반환하면 DispatcherServlet은 화면에서 데이터를 표시하도록 뷰 템플릿에 제어권을 넘긴다. 스프링 MVC에서는 다양한 전략에 맞게 뷰를 해석할 수 있는 ViewResolver 구현체 몇 가지가 있다.
그 중 MVC 기본 설정에는 템플릿 명과 위치에 따른 뷰를 해석하는InternalResourceViewResolver를 기본으로 사용하고 있다.
prefix/suffix를 이용해 뷰 이름을 특정 애플리케이션 디렉터리에 대응시킨다.InternalResourceViewResolver는 사용이 간단해서 좋기는 하지만RequestDispatcher가 forward할 수 있는 내부 리소스(jsp또는 서블릿)만 해석이 가능하기 때문에, 다른 뷰 템플릿을 사용하는 경우에는 다른 viewResolver를 사용해야 한다.
타임리프 또한 동일한 방식의 뷰 리졸버인ThymeleafViewResolver를 사용한다. 다만prefix가resources/templates/이고suffix가.html이다.
1. String 타입으로 반환
1. view name 반환(forward)
GET 방식의 /string요청을 전달한다.
<h3>문자열로 뷰 이름 반환하기</h3>
<button onclick="location.href='string'">문자열로 뷰 이름 반환</button>
발생하는 요청을 매핑할 controller의 handler method이다.
@GetMapping("string")
public String stringReturning(Model model) {
model.addAttribute("forwardMessage", "문자열로 뷰 이름 반환함...");
return "result";
}
문자열로 뷰 이름을 반환한다는 것은 반환 후ThymeleafViewResolver에게resources/templates/를prefix로.html을suffix로 하여resources/templates/result.html파일을 응답 뷰로 설정하라는 의미가 된다.
<h3>문자열로 redirect 하기</h3>
<button onclick="location.href='string-redirect'">문자열로 뷰 이름 반환하여 리다이렉트</button>
발생하는 요청을 매핑할 controller의 handler method이다. 동일하게 String 반환 값을 사용하지만 접두사로 redirect:를 붙이면 forward 되지 않고 redirect 된다.
@GetMapping("string-redirect")
public String stringRedirect() {
return "redirect:/";
}
버튼 클릭 시 / 경로로 리다이렉트 되는 것을 확인할 수 있다.
3. RedirectAttributes
GET 방식의 /string-redirect-attr 요청을 전달한다.
<h3>문자열로 뷰 이름 반환하면서 flashAttribute 추가 하기</h3>
<button onclick="location.href='string-redirect-attr'">
문자열로 뷰 이름 반환하여 리다이렉트 & flashAttr 사용하기
</button>
발생하는 요청을 매핑할 controller의 handler method이다.
기본적으로 redirect시에는 재요청이 발생하므로 request scope는 소멸된다. 하지만 스프링에서는RedirectAttributes타입을 통해 redirect 시 속성 값을 저장할 수 있도록 하는 기능을 제공한다. 리다이렉트 시 flash 영역에 담아서 redirect 할 수 있다. 자동으로 모델에 추가되기 때문에 requestScope에서 값을 꺼내면 된다. 세션에 임시로 값을 담고 소멸하는 방식이기 때문에 session에 동일한 키 값이 존재하지 않아야 한다.
@GetMapping("string-redirect-attr")
public String stringRedirectFlashAttribute(RedirectAttributes rttr) {
rttr.addFlashAttribute("flashMessage1", "리다이렉트 attr 사용하여 redirect..");
return "redirect:/";
}
응답 시 requestScope에 보존 된 flashMessage1을 꺼내 alert 창에 띄운다.
그리고 클래스 레벨에 @SessionAttributes("id")를 설정한다. 클래스 레벨에 @SessionAttributes 어노테이션을 이용하여 세션에 값을 담을 key값을 설정 해두면 Model 영역에 해당 key로 값이 추가되는 경우 session에 자동 등록을 한다.
@Controller
@RequestMapping("/first/*")
@SessionAttributes("id")
public class FirstController {
...생략
}
세션에 값이 잘 저장 되었음을 응답 화면을 통해 확인할 수 있다. 단, 로그아웃 버튼을 눌러도 세션 객체가 제거 되지 않는 문제가 있다. 따라서 로그아웃 버튼도 다시 추가해서 로그아웃 로직을 추가 작성한다.
SessionAttributes로 등록된 값은 session의 상태를 관리하는 SessionStatus의 setComplete()메소드를 호출해야 사용이 만료된다.
@GetMapping("logout2")
public String logoutTest2(SessionStatus sessionStatus) {
/* 현재 컨트롤러 세션에 저장된 모든 정보를 제거한다. 개별 제거는 불가능하다. */
sessionStatus.setComplete();
return "first/loginResult";
}