[start.spring.io](http://start.spring.io)
로 돌아가서 보면, 어제는 넘어갔던 설정 중 Project
와 Project Metadata
를 살펴보자.
이 Project 영역에는 세가지 선택지가 있는데, 크게 Gradle과 Maven으로 구분지을 수 있다.
이것이 필요한 이유가 무엇일까? 지난 시간 글을 보면 javac
와 java
명령어를 통해서 코드를 실행시킬 수 있을 것이다. 그러나 프로젝트에 있는 Controller, Service, Configuration 등등 자바 클래스가 많아지면 이 코드들을 실수 없이 복잡한 명령어를 통해 실행시키기 어렵다. 심지어 스프링 부트 코드는 외부에서 가져온 외부 라이브러리 형태로 배포된 코드들이다. javac
를 사용한다면 어떤 라이브러리를 사용할 것인지 매번 입력해 줘야 한다는 번거로움이 있다.
high level language를 low level language로 컴퓨터가 이해할 수 있게 번역해 주는 작업이 컴파일이다. 그렇다면 프로젝트를 컴파일된 결과물과 외부 라이브러리를 참고하여 사용자가 실행 가능한 프로그램으로 만드는 과정을 빌드라고 한다. 컴파일은 한 장 한 장을 따로따로 번역하는 과정이라면 빌드는 책을 모두 번역하고 표지도 바꾸고 일러스트도 새로 그려서 번역본이 출판된 것과 같다.
Maven과 Gradle은 JAVA 프로젝트를 빌드하는 데 사용되는 대표적인 도구이다. 소스코드 컴파일, 단위 테스트, 버전 관리 등 빌드 과정을 자동화해 주는 도구라고 생각하면 된다.
JAVA는 대표적으로 JAR 파일로 나오는데, 배포를 위해 JAVA Class를 어떻게 실행해야 하는지 부수적인 정보와 함께 압축한 파일이다.
둘 다 빌드를 관리하기 위한 도구들이라 기초적인 기능들은 비슷하다. 하지만 사용하면서 체감되는 차이점은 다음과 같다.
Maven은 XML을 기반으로 설정 파일을 작성한다. XML은 데이터를 정규화해서 표현하는 문서로 html과 비슷하다. 따라서 내용물을 바꾸거나 설정 파일을 바꾸기 어렵다. 따라서 작업 단계가 고정되어 있다.
Gradle은 Groovy 또는 Kotlin 기반의 설정 파일을 작성한다. Groovy와 Kotlin은 기능을 만드는 프로그램 언어이다. 따라서 작업 단계를 개발자가 어느 정도 커스텀할 수 있다.
프레임워크나 다른 라이브러리를 사용해서 외부에 의존성이 생기는 것이다. 다른 개발자가 이미 만들어 놓은 것을 활용하는 것이다. Maven은 다루기가 어렵지만 성능은 Gradle가 더욱 좋다. 개인적인 선호에 따라 사용하는 것이 맞지만, 보통 Gradle을 기준으로 학습한다.
각각의 프로젝트를 구분하기 위해 사용되는 식별자이다. Group ID는 해당 프로젝트를 관리하는 조직을 식별하고, Artifact ID는 해당 조직의 독립적인 프로젝트를 식별한다. 따라서 이 데이터들을 합치면 단 하나의 프로젝트를 식별할 수 있게 된다.
Group ID를 아무렇게나 만들어도 되지만 문제의 소지가 있어서 권장되지는 않는다. 의존성이 충돌할 문제가 있기 때문이다. 버전 정보를 명시하는 것이 권장되는데, start.spring.io에서는 프로젝트 설정으로 인해 버전이 자동으로 정해진다.
main 함수의 공식 명칭은 Entrypoint이다. 프로그렘의 실행 과정은 다음과 같다. 프로그램을 실행했을 때 필요한 객체를 만들고, 객체의 메소드를 실행하며 사용자에게 필요한 기능들을 수행하게 된다.
Entry Point는 CLI 콘솔의 주도권을 OS에게 받고 자신의 코드를 실행하는 시작점이고 프로그램의 시작 시점이라고 여기면 된다.
git을 CLI에서 사용할 때 git status
git add
등 다양한 값을 붙여서 실행한다. 이 부수적인 명령들을 수행하는 명령어들을 args라고 하는 변수에 들어가게 된다. 이 문자열을 돌면서 필요한 CLI 명령어를 명령줄에서 실행하게 된다. 예를 들어 java Main foo bar baz
이런 식의 실행이 일어나는 것이다.
git 을 예로 들면 git add a.txt
라는 명령어가 있을 때 args = [”add”, “a.txt”]
이 형태로 구성된다고 보면 된다.
만약 git 이 java 코드라면 main 함수에 add, commit 등의 문자열이 인자로 들어왔을 때의 동작을 정의하는 코드가 있어야 한다. 실제로 다음 코드를 작성해서 명령어를 확인해 볼 수 있다.
public class Main {
public static void main(String[] args) {
System.out.println("Hello World!");
for (String arg: args) {
System.out.println(arg);
}
}
}
코드를 작성한 뒤 Main Run Configuration에서 program argument에 foo
bar
baz
를 넣어 준다.
이후 실행하면 args가 잘 나오는 것을 확인할 수 있다.
git을 예로 들면 args라는 것 안에 add, commit 등의 명령어가 들어올 것을 예상할 수 있다. 해당 명령어가 들어왔을 때 명령어를 인식하고 프로그램을 자동시키는 코드를 다음과 같이 짤 수 있다.
package org.example;
public class Main {
public static void main(String[] args) {
System.out.println("Hello World!");
for (String arg: args) {
System.out.println(arg);
}
String command = args[0];
if (command.equals("add")) {
System.out.println("add function");
}
else if (command.equals("commit")) {
System.out.println("commit function");
}
else {
System.out.println("invalid command");
}
}
}
실무에서는 만약 args에 아무 명령어도 들어오지 않는다면 index 오류가 발생하니, 이를 어떻게 예외 처리를 할 것인지도 추가해서 작성한다.
JavaGit.java
라는 파일의 JavaGit
이라는 클래스의 main
메소드를 수정해서 만든다.java JavaGit
명령어를 이용해 이 프로그램을 사용한다고 가정한다.java JavaGit add <임의의 문자열 입력>
을 실행하면 add changes to git:
이라는 출력 다음 줄 부터 <임의의 문자열 입력>
부분에 지정된 파일들을 한줄씩 출력한다.java JavaGit commit -m "<임의의 문자열 입력>"
을 실행하면 commit with message: "<임의의 문자열 입력>"
이라고 출력한다.java JavaGit commit
다음에 m
이 없을 경우, no message specified
라고 출력한다.java JavaGit
을 추가 인자없이 실행할 경우 usage: add, commit
을 출력한다.public class Main {
public static void main(String[] args) {
int command_len = args.length;
if (command_len == 0){
System.out.println("usage: add, commit");
}
else if (command_len >= 1) {
String command = args[0];
if (command.equals("add")){
System.out.println("add changes to git:");
for (int i = 1; i < command_len; i++) {
System.out.println(args[i]);
}
}
else if (command.equals("commit")) {
if (command_len == 3 && args[1].equals("-m")) {
System.out.println(args[2]);
}
else {
System.out.println("no message specified");
}
}
}
}
}
public class Main {
public static void main(String[] args) {
// 인자를 하나 이상 받을 때만 실행
if (args.length > 0) {
// 첫 번째 인자 받기
String command = args[0];
// 각각 확인
if (command.equals("add")) {
System.out.println("add changes to gitL");
for (
// foreach문 내부에서 사용할 변수명
String filename:
// args를 1부터 끝까지 복사
Arrays.copyOfRange(args, 1, args.length)
) {
// 출력
System.out.println(filename);
}
}
else if (command.equals("commit")) {
if(!args[1].equals("-m")) {
System.out.println("no message specified");
} else {
System.out.println(String.format("commit with message: \"%s\"\n", args[2]));
}
}
}
}
}
최초의 웹에는 데이터에 따라 바뀌는 응답이라는 개념이 없었다. 그저 어딘가에 존재하는 서버 컴퓨터에 있는 데이터를 내 컴퓨터에서 확인하는 작업 중 하나였다. 따라서 요청에 해당하는 파일을 그저 돌려주는 역할을 하는 것이 Web Server이다. 물리적 서버에서 어떤 파일을 돌려줘야 하는지 판단하고 검증해 주는 프로그램이자 프로세스이다. 대표적으로 Apache, NginX 등이 있다.
사용자가 요청하는 파일의 형식에 따라서 전달한 요청에 따라 알맞은 응답을 생성해내는 응용 소프트웨어가 Web Application이다. 사용자가 요청을 보냈을 때 Presentaion, Business Logic, Persistence Logic 등등을 수행하고 원하는 대답을 생성해내는 것이다. Web Application 자체에는 직접 http 요청을 들을 수는 없었기 때문에 Web Server에 종속적이었고, Web Server가 있어야지만 작동할 수 있었다.
이런 문제점을 해결하기 위해 Web Application에 Web Server 기능을 내장시켜서 바로 실행할 수 있게 한 것이다. 내가 자체적으로 요청을 듣고 그에 맞는 응답을 생성한 뒤 답변을 보내는 것이다.
Spring Boot + Web Dependency를 사용하면 Web Application Server를 만들 수 있다.
90년대 말 Java Servlet이라는 개념의 등장과 함께, 사용자의 요청에 따라 동적인 페이지를 제공하는 웹 개발의 기술이 등장했다. 이때 당시에는 WAR이라고 하는 특수한 형태의 압축 파일을 Java 기반의 웹 서버에 전달하여 웹 어플리케이션을 사용자에게 제공했다.
myapp.war
├── WEB-INF/
│ ├── classes/
│ │ └── com/
│ │ └── example/
│ │ └── **MyServlet.class**
│ ├── lib/
│ │ ├── dependency1.jar
│ │ └── dependency2.jar
│ ├── web.xml
│ └── ...
└── index.jsp
WAR에는 Web Server가 HTTP 요청을 받았을 때 활용할 수 있는 Java Servlet의 구현체가 마련되어 있었고, Java 개발자는 이 Java Servlet을 직접 작성하고, web.xml
을 이용해 Web Server에게 어떤 요청이 들어왔을 때 어떤 Java Servlet을 사용할지를 정의해 주어야 했다.
public class MyServlet extends HttpServlet {
@Override // get 요청이 왔을때 실행할 메소드
protected void doGet(HttpServletRequest req, HttpServletResponse res)
throws ServletException, IOException{
// 여기에 코드 작성
}
}
<web-app xmlns="http://java.sun.com/xml/ns/javaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-app_4_0.xsd" version="4.0">
<!-- 이런 Servlet이 있다 -->
<servlet>
<!-- 이런 이름으로 부를거고 -->
<servlet-name>MyServlet</servlet-name>
<!-- 실제 클래스는 이거다. -->
<servlet-class>com.example.MyServlet</servlet-class>
</servlet>
<!-- 이제 요청에 어떤 Servlet 쓸지 알려주겠다. -->
<servlet-mapping>
<!-- 위에 말해둔 그 Servlet이다. -->
<servlet-name>MyServlet</servlet-name>
<!-- `/myservlet`으로 요청이 오면 써라. -->
<url-pattern>/myservlet</url-pattern>
</servlet-mapping>
</web-app>
기본적으로 Java 기반의 웹 개발을 한다면, 이렇게 Java가 제공하는 HttpServlet
인터페이스를 기반으로 구현하고, 이를 위한 설정을 web.xml
을 이용해 매핑을 해 줘야 했다.
어떤 url에 따라 어떤 Servlet을 써야 하는지 등등 Mapping을 수동으로 해야 했다.
Spring Framework와 Spring MVC의 등장으로 IoC Container를 사용해 개발자가 직접 객체를 만들지 않는다는 장점이 있다고 했다. 이외에도 Spring MVC의 DispatcherServlet
은 하나의 HttpServlet
으로 모든 요청을 받고, 이후 개발자가 정의한 POJO에게 GetMapping 등으로 요청을 위임한 것이다. POJO는 Plain Old Java Object의 약자로 어떤 인터페이스가 아닌 구현체가 있는 자바 클래스이다. 즉 요청이 오면 무조건 DispatcherServlet에 보낸 다음 얘가 알아서 맞는 Servletd으로 재요청을 보내 주는 것이다.
따라서 아래처럼 Servlet
대신 @Controller
만 작성해 주면 된다.
<web-app>
<servlet>
<servlet-name>dispatcher</servlet-name>
<!-- Spring MVC가 제공하는 Servlet 쓰겠다! -->
<servlet-class>
org.springframework.web.servlet.DispatcherServlet
</servlet-class>
</servlet>
<servlet-mapping>
<!-- 모든 요청에 대해 그 Servlet을 쓴다! -->
<servlet-name>dispatcher</servlet-name>
<url-pattern>/</url-pattern>
</servlet-mapping>
</web-app>
@Controller
public class DemoController {
@RequestMapping("home")
public String home() {
return "home.html";
}
}
하지만 여전히 설정이 많이 필요하긴 하다. Spring Framework의 부품을 사용하도록 web.xml 등을 작성해 줘야 했다. 따라서 설정할 필요가 없게 기본값들을 다 작성해 주고 Web Server도 내장시키자는 의견이 나와 Spring Boot가 탄생했다.
SpringBootApplication
은 @SpringBootConfiguration
@EnableAutoConfiguration @ComponentScan
세 개의 Annotation이 합쳐져서 사용되는 annotation이다.
@SpringBootConfiguration
:
@EnableAutoConfiguration
: Spring에서 수동으로 설정해 주던 것을 Spring boot에서는 자동으로 설정해 주며, 이를 열어 주는 어노테이션이다.
@ComponentScan
: 어떤 bean 객체를 IoC Container에 등록할 것인지를 담당하는 어노테이션이다. 특별한 인자가 전달되지 않는다면 현재 파일 기준 패키지 하위 폴더들을 모두 bean 객체로 인식한다. 이 패키지 위치를 바꾸고 싶다면 @ComponentScan(basePackages = "com.example.demo")
등으로 변경해서 사용 가능하다. 복수의 패키지 경로를 선정하고 싶다면 @ComponentScan(basePackages = {"com.example.demo1", "com.example.demo2"})
등으로 설정할 수 있다.
사용자 인터페이스를 비즈니스 로직으로부터 분리하는 것을 목표로 만들어진 디자인 패턴이다. MVC는 Model, View, Controller의 약자이다. 사용자가 보고 있는 것과 실제로 구현해야 하는 부분을 개별적으로 보자는 관점이다. 각자가 담당하는 부분을 명확히 분리하여 독립적으로 발전시킬 수 있도록 하는 관심사 분리에 초점을 맞추고 있다. 예를 들어 MySQL을 사용하다가 다른 데이터 서비스를 사용하더라도 각각의 서비스가 분리되어 있기 때문에 Model만 수정하면 되지 View와 Controller는 수정할 필요가 없어진다.
External Client
로부터 입력이 오면 Dispatcher Servlet
으로 일단 보낸 뒤 Mapping
을 통해 알맞은 Servlet
을 찾아간다. 이후 Controller
에서 입력을 분류하고 필요한 로직을 수행하기 위해 Model
단계를 거친 뒤 다시 Mapping
을 거쳐 Dispatcher Servlet
으로 가고, 이 결과물이 View Resolver
로 보여진다. 개발자가 실제로 개발을 담당해야 하는 부분은 Controller
와 Model
이다.
실제로 사용자의 요청과 응답을 담당하는 주체는 Dispatcher Servlet
이다. 이를 Front Controller Pattern이라고 한다.
Spring MVC의 View는 기본적으로 사용자가 보는 HTML을 의미한다. 최근의 홈페이지들은 내가 로그인을 했는지 안 했는지 등등의 데이터에 따라 view가 조금씩 달라지는 것을 알 수 있다. html은 틀만 되고, 그 안을 채우는 데이터들이 조금씩 변하는 것이다. 하지만 어느 부분에 로그인 틀이 있는지 등의 구조는 바뀌지 않는다. 마치 문서 양식만 작성하고 내용들을 채워 넣는 방식이다.
앞서 말한 것처럼 html의 내용물을 동적으로 바꿔 주는 라이브러리의 일종이다. 본래 JAVA 기반 웹 개발에서는 JSP를 많이 사용했지만 Spring boot에서는 html과의 유사성과 다양한 기능을 가진 Thymeleaf를 권장**한다. 이 Templete Engine을 관리하는 것이 Spring boot이며, Spring boot은 html을 응답해 준다.
앞서 이야기했듯이 Thymeleaf는 준비된 HTML의 문서에 빈칸을 채워 넣는 용도로 활용된다. 그렇다면 먼저 HTML 문서를 만들어야 한다. src/resources/templates
경로에 home.html
을 만든다.
<!DOCTYPE html>
<html lang="en">
<head>
<title>Home</title>
</head>
<body>
<h1>여기에 글을 입력할 예정입니다.</h1>
</body>
</html>
저희의 목적은 h1
요소의 내용물을 특정 데이터를 기준으로 채워 넣는 것이다. 이를 위해 @Controller
를 만들면 다음과 같다.
@Controller
public class MvcController {
@RequestMapping("/")
public String home() {
return "home";
}
}
이 Spring Boot 프로젝트를 실행해서 http://localhost:8080/
으로 이동하면 다음처럼 html 이 잘 작동하는 것을 알 수 있다.
th:text
, ${}
, [[ ]]
thymeText()
메소드를 아래와 같이 변환해 보면 다음과 같다.
@Controller
public class MvcController {
@RequestMapping("/")
public String home(Model model) {
model.addAttribute("message", "Hello, Thymeleaf!");
return "home";
}
}
그리고 home.html
은 다음과 같이 변환한다.
<!DOCTYPE html>
<html lang="en">
<head>
<title>Home</title>
</head>
<body>
<h1 th:text="${message}">~~여기에 글을 입력할 예정입니다.~~</h1>
</body>
</html>
이후 다시 실행 후 이동하면 아래와 같은 화면이 뜬다.
아래의 RequestMapping
메소드는 Model model
이라는 인자를 가지고 있다.
@RequestMapping("/")
public String home(Model model) {
model.addAttribute("message", "Hello, Thymeleaf!");
return "home";
}
Model은 DispatcherServlet
이 메소드를 실행할 때 전달해 주며, 메소드가 반환할 때, home.html
ViewResolver
한테 같이 넘겨 주게 되며, 최종적으로 Thymeleaf가 home.html
의 내용을 변환하는 데 사용한다. (Model의 변화가 View의 변화를 일으킨다) 그래서 저희는 Thymeleaf가 활용할 수 있도록 model.addAttribute
메소드를 통해 message
라는 이름을 붙인 데이터를 전달한다.
그리고 Thymeleaf는 넘겨받은 home.html
을 분석해서 어디에 데이터를 채워 넣어야 하는지를 살펴본다.
<h1 th:text="${message}"></h1>
이때 여기의 th:text
는 HTML 문법이 아닌 Thymeleaf의 문법입니다. Thymeleaf는 이 h1
요소에 넘겨받은 model
에 있는 데이터 중 이름이 message
인 데이터를 찾아서, h1
의 내용물로 채워 넣어 준다.
th:text
: 요소의 content를 대치한다.${data}
: 해당 내용을 model
이 가지고 있는 data
의 값으로 대치한다.상황에 따라 내용물의 일부만 보여주고 싶을 수도 있습니다. 이 경우 아래와 같이 작성할 수 있다.
<h1>[[${message}]]</h1>
이 경우 [[ ]]
내부의 ${name}
부분을 Thymeleaf가 model
의 message
로 변환해서 돌려준다.
[[ ]]
: 내부에 ${}
를 사용하여, 내용의 일부만 바꿔서 표현해 준다.이번엔 단순한 문자열이 아닌 임의의 객체를 HTML에서 표현하면 다음과 같다. 먼저 [Student.java](http://Student.java)
를 만든다.
public class Student {
private String name;
private String email;
public Student() {
}
public Student(String name, String email) {
this.name = name;
this.email = email;
}
// ... 생략
}
그리고 student.html
도 만든다.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Student</title>
</head>
<body>
<!-- 아래 수정 예정 -->
<p>이름: [[${ }]]</p>
<p>이메일: [[${ }]]</p>
</body>
</html>
그리고 이 student.html
을 보내 줄 메소드도 추가한다.
@RequestMapping("/student")
public String student(Model model) {
model.addAttribute("object", new Student("Jeeho Park", "jeeho.dev@gmail.com"));
return "student";
}
이러면 이제 HTML에서 Student
객체를 활용할 수 있다. 먼저 작성했던 student.html
을 아래와 같이 수정하고, http://localhost:8080/student
로 이동하면 전달해 준 인자들이 잘 뜨는 것을 확인할 수 있다.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Student</title>
</head>
<body>
<!-- 아래 수정 예정 -->
<p>이름: [[${object.name}]]</p>
<p>이메일: [[${object.email}]]</p>
</body>
</html>
${data}
를 이용해 사용하는 data
가 객체인 경우, 그 속성에는 .
을 이용해 접근할 수 있다.public
으로 선언하거나 getter
를 만들어 주어야 합니다.List
의 isEmpty()
등)th:if
, th:unless
상황에 따라서 (로그인 여부 등) HTML의 표현이 변화해야 하는 경우도 있다. 이런 경우 th:if
와 th:unless
를 활용할 수 있다. if-unless.html
과 새로운 메소드를 만든다.
<!DOCTYPE html>
<html lang="en">
<head>
<title>If Unless</title>
</head>
<body>
<h1>Welcome</h1>
<div th:if="${isLoggedIn}">
<p>You are logged in.</p>
</div>
<div th:unless="${isLoggedIn}">
<p>Please log in.</p>
</div>
</body>
</html>
@RequestMapping("/is-logged-in")
public String isLoggedIn(Model model) {
model.addAttribute("isLoggedIn", true); // 값을 true, false로 바꿔가며 테스트해 봅시다.
return "if-unless";
}
model
에 추가한 isLoggedIn
의 값에 따라 결과가 다르게 나옴을 확인할 수 있다.
만약 복수개의 데이터를 표현하고자 한다면 model
에 List
와 같은 복수의 데이터를 가진 객체를 활용하여 th:each
와 함께 사용할 수 있다. 이때 사용 가능한 객체는 다음과 같다.
Iterable
의 구현체Map
의 구현체복수의 데이터를 model
로 보내고 이를 th:each
로 표현해서 작성한다.
@RequestMapping("/each")
public String items(Model model) {
List<String> listOfStrings = Arrays.asList("foo", "bar", "baz");
model.addAttribute("itemList", listOfStrings);
return "each";
}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>For Each</title>
</head>
<body>
<h1>Item List</h1>
<div>
<p th:each="item: ${itemList}">[[${item}]]</p>
</div>
</body>
</html>
이 예시에서는 세개의 문자열 데이터를 포함한 리스트 객체를 model
에 전달하고, HTML에서 th:each
를 활용하고 있다. 이때 item: ${itemList}
와 같은 형태로 작성하게 되면, itemList
가 가지고 있는 아이템의 갯수만큼 p
요소를 반복하게 되고, p
요소 내부에서 item
의 이름으로 ${item}
과 같이 활용할 수 있다.
<!-- 두 주황색 item은 같은 대상을 가르킵니다. -->
<p th:each="item: ${itemList}">[[${item}]]</p>
th:each
: 복수의 아이템을 가진 객체에서, 각각의 아이템의 값을 활용하여 같은 요소를 만들어낸다.객체를 활용한 반복도 똑같이 활용할 수 있다. 좀전에 만들었던 Student
객체를 활용해 본다.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Student</title>
</head>
<body>
<div th:each="student: ${studentList}">
<p>이름: [[${student.name}]]</p>
<p>이메일: [[${student.email}]]</p>
</div>
</body>
</html>
@RequestMapping("/students")
public String students(Model model) {
List<Student> studentList = Arrays.asList(
new Student("Alex", "alex@gmail.com"),
new Student("Brad", "brad@gmail.com"),
new Student("Chad", "chad@gmail.com")
);
model.addAttribute("studentList", studentList);
return "students";
}
여기에 더해 th:if
와 th:unless
를 활용하면 리스트가 비었을때 표현되는 모습도 만들 수 있다.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Student</title>
</head>
<body>
<div th:if="${studentList.isEmpty()}">
<p>No Students Here...</p>
</div>
<div th:unless="${studentList.isEmpty()}" th:each="student: ${studentList}">
<p>이름: [[${student.name}]]</p>
<p>이메일: [[${student.email}]]</p>
</div>
</body>
</html>