[비트캠프] 88일차(19주차1일) - Spring Framework: myapp-64(라이브러리 적용: Handlebars, Bootstrap)
### 64. Back-end 와 Front-end 분리하기: 클라이언트 렌더링 방식으로 전환
@PutMapping 에 멱등성(idempotent) 적용
우선, WEB-INF 의 defs, jsp, thymeleaf, tiles 폴더 삭제한다.
PUT request 는 여러번 시도 하더라도 결과가 동일하다 = 멱등(idempotent)하다.
PUT, DELETE 를 /boards/{no} 형식으로 요청하도록 변경한다.
package bitcamp.myapp.controller;
import java.io.File;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import javax.servlet.ServletContext;
import javax.servlet.http.HttpSession;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import bitcamp.myapp.service.BoardService;
import bitcamp.myapp.vo.Board;
import bitcamp.myapp.vo.BoardFile;
import bitcamp.myapp.vo.Member;
import bitcamp.util.ErrorCode;
import bitcamp.util.RestResult;
import bitcamp.util.RestStatus;
@RestController
@RequestMapping("/boards")
public class BoardController {
// 입력: POST => /boards
// 목록: GET => /boards
// 조회: GET => /boards/{no}
// 변경: PUT => /boards/{no}
// 삭제: DELETE => /boards/{no}
Logger log = LogManager.getLogger(getClass());
{
log.trace("BoardController 생성됨!");
}
@Autowired private ServletContext servletContext;
@Autowired private BoardService boardService;
@PostMapping
public Object insert(
Board board,
List<MultipartFile> files,
HttpSession session) throws Exception{
Member loginUser = (Member) session.getAttribute("loginUser");
Member writer = new Member();
writer.setNo(loginUser.getNo());
board.setWriter(writer);
List<BoardFile> boardFiles = new ArrayList<>();
for (MultipartFile file : files) {
if (file.isEmpty()) {
continue;
}
String filename = UUID.randomUUID().toString();
file.transferTo(new File(servletContext.getRealPath("/board/upload/" + filename)));
BoardFile boardFile = new BoardFile();
boardFile.setOriginalFilename(file.getOriginalFilename());
boardFile.setFilepath(filename);
boardFile.setMimeType(file.getContentType());
boardFiles.add(boardFile);
}
board.setAttachedFiles(boardFiles);
boardService.add(board);
return new RestResult()
.setStatus(RestStatus.SUCCESS);
}
@GetMapping
public Object list(String keyword) {
log.debug("BoardController.list() 호출됨!");
// MappingJackson2HttpMessageConverter 가 jackson 라이브러리를 이용해
// 자바 객체를 JSON 문자열로 변환하여 클라이언트로 보낸다.
// 이 컨버터를 사용하면 굳이 UTF-8 변환을 설정할 필요가 없다.
// 즉 produces = "application/json;charset=UTF-8" 를 설정하지 않아도 된다.
return new RestResult()
.setStatus(RestStatus.SUCCESS)
.setData(boardService.list(keyword));
}
@GetMapping("{no}")
public Object view(@PathVariable int no) {
Board board = boardService.get(no);
if (board != null) {
return new RestResult()
.setStatus(RestStatus.SUCCESS)
.setData(board);
} else {
return new RestResult()
.setStatus(RestStatus.FAILURE)
.setErrorCode(ErrorCode.rest.NO_DATA);
}
}
@PutMapping("{no}")
public Object update(
@PathVariable int no,
Board board,
List<MultipartFile> files,
HttpSession session) throws Exception {
Member loginUser = (Member) session.getAttribute("loginUser");
// URL 의 번호와 요청 파라미터의 번호가 다를 경우를 방지하기 위해
// URL의 번호를 게시글 번호로 설정한다.
board.setNo(no);
Board old = boardService.get(board.getNo());
if (old.getWriter().getNo() != loginUser.getNo()) {
return new RestResult()
.setStatus(RestStatus.FAILURE)
.setErrorCode(ErrorCode.rest.UNAUTHORIZED)
.setData("권한이 없습니다.");
}
List<BoardFile> boardFiles = new ArrayList<>();
for (MultipartFile file : files) {
if (file.isEmpty()) {
continue;
}
String filename = UUID.randomUUID().toString();
file.transferTo(new File(servletContext.getRealPath("/board/upload/" + filename)));
BoardFile boardFile = new BoardFile();
boardFile.setOriginalFilename(file.getOriginalFilename());
boardFile.setFilepath(filename);
boardFile.setMimeType(file.getContentType());
boardFile.setBoardNo(board.getNo());
boardFiles.add(boardFile);
}
board.setAttachedFiles(boardFiles);
boardService.update(board);
return new RestResult()
.setStatus(RestStatus.SUCCESS);
}
@DeleteMapping("{no}")
public Object delete(@PathVariable int no, HttpSession session) {
Member loginUser = (Member) session.getAttribute("loginUser");
Board old = boardService.get(no);
if (old.getWriter().getNo() != loginUser.getNo()) {
return new RestResult()
.setStatus(RestStatus.FAILURE)
.setErrorCode(ErrorCode.rest.UNAUTHORIZED)
.setData("권한이 없습니다.");
}
boardService.delete(no);
return new RestResult()
.setStatus(RestStatus.SUCCESS);
}
@DeleteMapping("{boardNo}/files/{fileNo}")
public Object filedelete(
@PathVariable int boardNo,
@PathVariable int fileNo,
HttpSession session) {
Member loginUser = (Member) session.getAttribute("loginUser");
Board old = boardService.get(boardNo);
if (old.getWriter().getNo() != loginUser.getNo()) {
return new RestResult()
.setStatus(RestStatus.FAILURE)
.setErrorCode(ErrorCode.rest.UNAUTHORIZED)
.setData("권한이 없습니다.");
} else {
boardService.deleteFile(fileNo);
return new RestResult()
.setStatus(RestStatus.SUCCESS);
}
}
}
insert(@RequestBody Student student) 에서 index.html 의 <input name="name"> 이 있으면 index.js 에서 JSON 형식으로 변환한 formData 요청할 때 "name" 으로 요청이 된다. 이를 "name" 프로퍼티가 있는 Student 객체에 @RequestBody 붙이면 값을 매핑해준다.
package bitcamp.myapp.controller;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import bitcamp.myapp.service.StudentService;
import bitcamp.myapp.vo.Student;
import bitcamp.util.RestResult;
import bitcamp.util.RestStatus;
@RestController
@RequestMapping("/students")
public class StudentController {
Logger log = LogManager.getLogger(getClass());
{
log.trace("StudentController 생성됨!");
}
@Autowired private StudentService studentService;
@PostMapping
public Object insert(@RequestBody Student student) {
studentService.add(student);
return new RestResult()
.setStatus(RestStatus.SUCCESS);
}
@GetMapping
public Object list(String keyword) {
return new RestResult()
.setStatus(RestStatus.SUCCESS)
.setData(studentService.list(keyword));
}
@GetMapping("{no}")
public Object view(@PathVariable int no) {
return new RestResult()
.setStatus(RestStatus.SUCCESS)
.setData(studentService.get(no));
}
@PutMapping("{no}")
public Object update(
@PathVariable int no,
@RequestBody Student student) {
log.debug(student);
// 보안을 위해 URL 번호를 게시글 번호로 설정한다.
student.setNo(no);
studentService.update(student);
return new RestResult()
.setStatus(RestStatus.SUCCESS);
}
@DeleteMapping("{no}")
public Object delete(@PathVariable int no) {
studentService.delete(no);
return new RestResult()
.setStatus(RestStatus.SUCCESS);
}
}
TeacherController.java 도 동일하다.
package bitcamp.myapp.controller;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import bitcamp.myapp.service.TeacherService;
import bitcamp.myapp.vo.Teacher;
import bitcamp.util.RestResult;
import bitcamp.util.RestStatus;
@RestController
@RequestMapping("/teachers")
public class TeacherController {
Logger log = LogManager.getLogger(getClass());
{
log.trace("TeacherController 생성됨!");
}
@Autowired private TeacherService teacherService;
@PostMapping
public Object insert(@RequestBody Teacher teacher) {
teacherService.add(teacher);
return new RestResult()
.setStatus(RestStatus.SUCCESS);
}
@GetMapping
public Object list() {
return new RestResult()
.setStatus(RestStatus.SUCCESS)
.setData(teacherService.list());
}
@GetMapping("{no}")
public Object view(@PathVariable int no) {
return new RestResult()
.setStatus(RestStatus.SUCCESS)
.setData(teacherService.get(no));
}
@PutMapping("{no}")
public Object update(
@PathVariable int no,
@RequestBody Teacher teacher) {
log.debug(teacher);
teacher.setNo(no);
teacherService.update(teacher);
return new RestResult()
.setStatus(RestStatus.SUCCESS);
}
@DeleteMapping("{no}")
public Object delete(@PathVariable int no) {
teacherService.delete(no);
return new RestResult()
.setStatus(RestStatus.SUCCESS);
}
}
RootConfig.java에서 TilesConfigurer 및 templateResolver, templateEngine 메서드 필요 없으므로 삭제한다.
package bitcamp.myapp.config;
import javax.sql.DataSource;
import org.apache.ibatis.session.SqlSessionFactory;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.mybatis.spring.SqlSessionFactoryBean;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.ComponentScan.Filter;
import org.springframework.context.annotation.FilterType;
import org.springframework.context.annotation.PropertySource;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.EnableTransactionManagement;
@ComponentScan(
value = "bitcamp.myapp",
excludeFilters = {
@Filter(
type = FilterType.REGEX,
pattern = {"bitcamp.myapp.controller.*"})
})
@PropertySource("classpath:/bitcamp/myapp/config/jdbc.properties")
@MapperScan("bitcamp.myapp.dao")
@EnableTransactionManagement
public class RootConfig {
Logger log = LogManager.getLogger(getClass());
{
log.trace("RootConfig 생성됨!");
}
@Bean
public DataSource dataSource(
@Value("${jdbc.driver}") String jdbcDriver,
@Value("${jdbc.url}") String url,
@Value("${jdbc.username}") String username,
@Value("${jdbc.password}") String password) {
log.trace("DataSource 생성됨!");
DriverManagerDataSource ds = new DriverManagerDataSource();
ds.setDriverClassName(jdbcDriver);
ds.setUrl(url);
ds.setUsername(username);
ds.setPassword(password);
return ds;
}
@Bean
public PlatformTransactionManager transactionManager(DataSource dataSource) throws Exception {
log.trace("PlatformTransactionManager 객체 생성! ");
return new DataSourceTransactionManager(dataSource);
}
@Bean
public SqlSessionFactory sqlSessionFactory(DataSource dataSource, ApplicationContext appCtx) throws Exception {
log.trace("SqlSessionFactory 객체 생성!");
// Mybatis 로깅 기능을 활성화시킨다.
org.apache.ibatis.logging.LogFactory.useLog4J2Logging();
SqlSessionFactoryBean factoryBean = new SqlSessionFactoryBean();
factoryBean.setDataSource(dataSource);
factoryBean.setTypeAliasesPackage("bitcamp.myapp.vo");
factoryBean.setMapperLocations(appCtx.getResources("classpath*:bitcamp/myapp/mapper/*Mapper.xml"));
return factoryBean.getObject();
}
}
AppConfig.java, AdminConfig.java 에서 JSP, Tiles, Thymeleaf Resolver 메서드 필요 없으므로 삭제한다.
package bitcamp.myapp.config;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.ComponentScan.Filter;
import org.springframework.context.annotation.FilterType;
import org.springframework.web.multipart.MultipartResolver;
import org.springframework.web.multipart.support.StandardServletMultipartResolver;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import bitcamp.myapp.controller.StudentController;
import bitcamp.myapp.controller.TeacherController;
import bitcamp.myapp.web.interceptor.AuthInterceptor;
//@Configuration
@ComponentScan(
value = "bitcamp.myapp.controller",
excludeFilters = {
@Filter(
type = FilterType.ASSIGNABLE_TYPE,
classes = {StudentController.class, TeacherController.class})
})
@EnableWebMvc
public class AppConfig implements WebMvcConfigurer {
Logger log = LogManager.getLogger(getClass());
{
log.trace("AppConfig 생성됨!");
}
@Bean
public MultipartResolver multipartResolver() {
log.trace("MultipartResolver 생성됨!");
return new StandardServletMultipartResolver();
}
// WebMvcConfigurer 규칙에 맞춰 인터셉터를 등록한다.
@Override
public void addInterceptors(InterceptorRegistry registry) {
log.trace("AppConfig.addInterceptors() 호출됨!");
registry.addInterceptor(new AuthInterceptor()).excludePathPatterns("/auth/**");
}
}
package bitcamp.myapp.config;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.ComponentScan.Filter;
import org.springframework.context.annotation.FilterType;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import bitcamp.myapp.controller.AuthController;
import bitcamp.myapp.controller.BoardController;
import bitcamp.myapp.controller.DownloadController;
import bitcamp.myapp.web.interceptor.AdminCheckInterceptor;
import bitcamp.myapp.web.interceptor.AuthInterceptor;
//@Configuration
@ComponentScan(
value = "bitcamp.myapp.controller",
excludeFilters = {
@Filter(
type = FilterType.ASSIGNABLE_TYPE,
classes = {AuthController.class, BoardController.class, DownloadController.class})
})
@EnableWebMvc // 프론트 컨트롤러 각각에 대해 설정해야 한다.
public class AdminConfig implements WebMvcConfigurer {
Logger log = LogManager.getLogger(getClass());
{
log.trace("AdminConfig 생성됨!");
}
// WebMvcConfigurer 규칙에 맞춰 인터셉터를 등록한다.
@Override
public void addInterceptors(InterceptorRegistry registry) {
log.trace("AdminConfig.addInterceptors() 호출됨!");
registry.addInterceptor(new AuthInterceptor());
registry.addInterceptor(new AdminCheckInterceptor());
}
}
Handlebars 설치
Handlebars 라이브러리 이점은 가독성, 유지보수성, 재사용성, 성능, 확장성이다.
javascript 파일에 html 태그를 추가하지 않고, html 파일에 HTML 태그를 넣어놓고 javascript 에서 fetch 로 데이터 가져올때 태그에 값 집어넣는다.
cmd 로 webapp 폴더에서 아래 명령어 실행한다.
C:\Users\bitcamp\git\bitcamp-ncp\myapp\app-server\src\main\webapp>npm install handlebars --save
node_modules 에 handlebars 폴더 생성된다.
handlebars 참고 사이트 : https://handlebarsjs.com/guide/
Handlebars 실습 예제 소스
eomcs-java\eomcs-web\app\src\main\resources\static\handlebars
exam01.html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>exam</title>
</head>
<body>
<h1>HandlebarsJS 라이브러리 준비(handlebarsjs.com)</h1>
<pre>
1. 'npm install handlebars --save' 실행
3. handlebars.min.js 자바스크립트 추가
</pre>
<script src="../node_modules/handlebars/dist/handlebars.min.js"></script>
</body>
</html>
exam02.html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>exam</title>
</head>
<body>
<h1>Handlebars 사용전</h1>
<button id="btn1" type="button">데이터를 가져와서 출력하기</button>
<div id="d1"></div>
<script src="../node_modules/handlebars/dist/handlebars.min.js"></script>
<script src="../node_modules/jquery/dist/jquery.min.js"></script>
<script>
var data = {
no:1,
title:"제목1",
writer:"홍길동"
};
// 태그에 삽입할 HTML 내용을 다음과 같이 자바스크립트 코드로 작성한다.
var str = "<table border='1'>" +
"<tbody>" +
"<tr>" +
" <th>번호</th>" +
" <td>" + data.no + "</td>" +
"</tr>" +
"<tr>" +
" <th>제목</th>" +
" <td>" + data.title + "</td>" +
"</tr>" +
"<tr>" +
" <th>작성자</th>" +
" <td>" + data.writer + "</td>" +
"</tr>" +
"</tbody>" +
"</table>";
var btn1Button = document.querySelector("#btn1");
btn1Button.onclick = (event) => {
document.querySelector("#d1").innerHTML = str;
};
</script>
</body>
</html>
exam03.html
.html 맨 밑에 handlebars 를 위한 <script> 를 준비한다. type="text/x-handlebars-template" 으로 한다.
HTML 태그 작성 후 원하는 객체의 필드명을 {{ }} 안에 적는다.
그 후 아래에 handlebars 라이브러리를 <script src=" "> 으로 불러온다.
handlebars 적용할 HTML 소스를 객체로 가져와서 Handlerbars.compile( ) 안에 넣어 함수를 준비한다. 함수 매개변수에 data 를 넣어 html 태그를 만들고 이를 원하는 곳에 집어넣는다.
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>exam</title>
<style>
#d1 {
border: 1px solid red;
min-height: 200px;
}
</style>
</head>
<body>
<h1>Handlebars 사용후</h1>
<button id="btn1" type="button">script 태그의 HTML을 읽어서 div에 넣기</button>
<div id="d1"></div>
<script id="t1" type="text/x-handlebars-template">
<table border="1">
<tbody>
<tr>
<th>번호</th>
<td>{{no}}</td>
</tr>
<tr>
<th>제목</th>
<td>{{title}}</td>
</tr>
<tr>
<th>작성자</th>
<td>{{writer}}</td>
</tr>
</tbody>
</table>
</script>
<script src="../node_modules/handlebars/dist/handlebars.min.js"></script>
<script>
var data = {
no:1,
title:"제목1",
writer:"홍길동"
};
document.querySelector("#btn1").onclick = () => {
//1) Handlebars 템플릿 엔진이 사용할 HTML 소스를 준비한다.
// - 보통 script 태그에 작성한 HTML 소스를 가져온다.
var templateSrc = document.querySelector("#t1").innerHTML;
//2) 템플릿 소스와 데이터를 가지고 HTML을 생성할 함수를 준비한다.
var htmlGenerator = Handlebars.compile(templateSrc);
//3) 템플릿을 처리하는 함수를 호출한다.
// - 이 함수는 파라미터로 받은 데이터를 사용하여 템플릿 소스의 지정된 위치에 값을 삽입한다.
// - 그리고 최종 생성된 HTML을 리턴한다.
var html = htmlGenerator(data);
//4) 템플릿 함수가 리턴한 HTML을 div 태그에 넣는다.
document.querySelector("#d1").innerHTML = html;
};
</script>
</body>
</html>
### 64. Back-end 와 Front-end 분리하기: 클라이언트 렌더링 방식으로 전환
### 64. Back-end 와 Front-end 분리하기: 클라이언트 렌더링 방식으로 전환
- 요청 핸들러에서 JSON을 리턴하는 방법
- 웹 페이지에서 JSON을 받아 처리하는 방법
- Handlebars 자바스크립트 템플릿 라이브러리 사용법
- Bootstrap의 모달상자 사용법
내용이 입력될 태그를 <script> 태그 안에 handlebars 문법으로 작성한다. 이는 밑에서 javascript 에서 template 으로 쓰이는 재료이다.
밑에 <script> 로 handlebars 라이브러리 불러들인다.
밑에 fetch(...) 로 받은 객체에서 값 꺼내 template 내용 채워넣는다. 원하는 태그의 innerHTML 로 설정한다.
<!-- board/list.html -->
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>비트캠프 - NCP 1기</title>
</head>
<body>
<h1>게시판</h1>
<div><a href="form.html">새 글</a></div>
<table id="board-table" border="1">
<thead>
<tr>
<th>번호</th>
<th>제목</th>
<th>작성자</th>
<th>작성일</th>
<th>조회수</th>
</tr>
</thead>
<tbody></tbody>
</table>
<form action="list" method="get">
<input type="text" name="keyword" value="" />
<button>검색</button>
</form>
<script id="tr-template" type="text/x-handlebars-template">
{{#each this}}
<tr>
<td>{{no}}</td>
<td><a href="view.html?no={{no}}">
{{#if title}}{{title}}{{else}}제목없음{{/if}}</a></td>
<td>{{writer.name}}</td>
<td>{{createdDate}}</td>
<td>{{viewCount}}</td>
</tr>
{{/each}}
</script>
<script src="../node_modules/handlebars/dist/handlebars.js"></script>
<script>
// 템플릿으로 사용할 HTML을 준비한다.
const html = document.querySelector("#tr-template").innerHTML;
// HTML을 가지고 템플릿 엔진을 생성한다.
const templateEngine = Handlebars.compile(html);
fetch("../app/boards")
.then((response) => {
return response.json();
})
.then((result) => {
document.querySelector("#board-table > tbody").innerHTML =
templateEngine(result.data);
});
</script>
</body>
</html>
student 관련 index.html 에서 .css, .js 로 분리한다.
index.html 에 handlebars 적용한다.
getStudent(event) 를 전역 유효 범위(global scope) 에서 찾으므로 index.js 에 작성되어 있어도 사용 가능하다.
<!-- student/index.html -->
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>비트캠프 - NCP 1기</title>
<link rel="stylesheet" href="index.css" />
</head>
<body>
<h1>학생</h1>
<aside>
<div>
<input type="text" name="keyword" value="" />
<button id="btn-search" type="button">검색</button>
</div>
<table id="student-table" border="1">
<thead>
<tr>
<th>번호</th>
<th>이름</th>
<th>재직</th>
<th>전화</th>
</tr>
</thead>
<tbody></tbody>
</table>
</aside>
<article>
<form id="student-form" action="update" method="post">
<table border="1">
<tr class="edit">
<th>번호</th>
<td><input id="f-no" type="text" name="no" readonly /></td>
</tr>
<tr>
<th>이름</th>
<td><input id="f-name" type="text" name="name" /></td>
</tr>
<tr>
<th>이메일</th>
<td><input id="f-email" type="email" name="email" /></td>
</tr>
<tr>
<th>암호</th>
<td><input id="f-password" type="password" name="password" /></td>
</tr>
<tr>
<th>전화</th>
<td><input id="f-tel" type="tel" name="tel" /></td>
</tr>
<tr>
<th>우편번호</th>
<td><input id="f-postNo" type="text" name="postNo" /></td>
</tr>
<tr>
<th>기본주소</th>
<td>
<input id="f-basicAddress" type="text" name="basicAddress" />
</td>
</tr>
<tr>
<th>상세주소</th>
<td>
<input id="f-detailAddress" type="text" name="detailAddress" />
</td>
</tr>
<tr>
<th>재직여부</th>
<td>
<input
id="f-working"
type="checkbox"
name="working"
value="true"
/>
재직중
</td>
</tr>
<tr>
<th>성별</th>
<td>
<input type="radio" name="gender" value="M" /> 남
<input type="radio" name="gender" value="W" checked /> 여
</td>
</tr>
<tr>
<th>전공</th>
<td>
<select id="f-level" name="level">
<option value="0">비전공자</option>
<option value="1">준전공자</option>
<option value="2">전공자</option>
</select>
</td>
</tr>
<tr class="edit">
<th>등록일</th>
<td><span id="f-createdDate"></span></td>
</tr>
</table>
<div>
<button id="btn-insert" type="button" class="input">추가</button>
<button id="btn-update" type="button" class="edit">변경</button>
<button id="btn-delete" type="button" class="edit">삭제</button>
<button id="btn-cancel" type="reset" class="edit">취소</button>
</div>
</form>
</article>
<script id="tr-template" type="text/x-handlebars-template">
{{#each this}}
<tr data-no="{{no}}" onclick="getStudent(event)">
<td>{{no}}</td>
<td>{{name}}</td>
<td>{{#if working}}예{{else}}아니오{{/if}}</td>
<td>{{tel}}</td>
</tr>
{{/each}}
</script>
<script src="../node_modules/handlebars/dist/handlebars.js"></script>
<script src="index.js"></script>
</body>
</html>
/* index.css */
aside {
/*border: 1px solid red;*/
box-sizing: border-box;
float: left;
width: 260px;
}
article {
/*border: 1px solid black;*/
box-sizing: border-box;
margin-left: 270px;
margin-top: 50px;
}
#student-table > tbody > tr:hover {
background-color: navy;
color: white;
}
.invisible {
display: none;
}
목록에서 변경, 삭제, 취소 버튼 보이도록 showInput() 정의하고 실행한다.
e.classList.remove("invisible") 에서 class="invisible" 이 없으면 그냥 무시되므로 에러가 발생하지 않는다.
// 목록에서 변경, 삭제, 취소 버튼 보이도록 정의하고 실행한다.
showInput();
getStudents();
const html = document.querySelector("#tr-template").innerHTML;
const templateEngine = Handlebars.compile(html);
function showInput() {
let el = document.querySelectorAll(".input");
for (let e of el) {
e.classList.remove("invisible");
}
el = document.querySelectorAll(".edit");
for (let e of el) {
e.classList.add("invisible");
}
}
function showEdit() {
let el = document.querySelectorAll(".input");
for (let e of el) {
e.classList.add("invisible");
}
el = document.querySelectorAll(".edit");
for (let e of el) {
e.classList.remove("invisible");
}
}
// 검색 키보드 입력 마다 SQL 질의하여 검색 결과 목록 바뀌도록 한다.
document.querySelector("input[name='keyword']").onkeyup = (e) => {
getStudents(e.target.value);
};
// 검색 버튼 클릭시 검색 결과 목록 나오도록 한다.
document.querySelector("#btn-search").onclick = () => {
getStudents(keyword);
};
// keyword 있을 경우 QueryString 을 함께 요청한다.
function getStudents(keyword) {
let qs = "";
if (keyword) {
qs = `?keyword=${keyword}`;
}
fetch("../admin/students" + qs)
.then((response) => {
return response.json();
})
.then((result) => {
document.querySelector("#student-table > tbody").innerHTML =
templateEngine(result.data);
});
}
function getLevelTitle(level) {
switch (level) {
case 0:
return "비전공자";
case 1:
return "준전공자";
case 2:
return "전공자";
default:
return "기타";
}
}
function getStudent(e) {
let no = e.currentTarget.getAttribute("data-no");
fetch("../admin/students/" + no)
.then((response) => {
return response.json();
})
.then((result) => {
if (result.status == "failure") {
alert("학생을 조회할 수 없습니다.");
return;
}
let student = result.data;
console.log(student);
document.querySelector("#f-no").value = student.no;
document.querySelector("#f-name").value = student.name;
document.querySelector("#f-email").value = student.email;
document.querySelector("#f-tel").value = student.tel;
document.querySelector("#f-postNo").value = student.postNo;
document.querySelector("#f-basicAddress").value = student.basicAddress;
document.querySelector("#f-detailAddress").value = student.detailAddress;
document.querySelector("#f-working").checked = student.working;
document.querySelector(
`input[name="gender"][value="${student.gender}"]`
).checked = true;
document.querySelector("#f-level").value = student.level;
document.querySelector("#f-createdDate").innerHTML = student.createdDate;
showEdit();
});
}
document.querySelector("#btn-insert").onclick = () => {
const form = document.querySelector("#student-form");
const formData = new FormData(form);
let json = JSON.stringify(Object.fromEntries(formData));
fetch("../admin/students", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: json,
})
.then((response) => {
return response.json();
})
.then((result) => {
if (result.status == "success") {
location.reload();
} else {
alert("입력 실패!");
console.log(result.data);
}
})
.catch((exception) => {
alert("입력 중 오류 발생!");
console.log(exception);
});
};
document.querySelector("#btn-update").onclick = () => {
const form = document.querySelector("#student-form");
const formData = new FormData(form);
// FormData ==> Query String
// 방법1)
//let qs = [...formData.entries()].map(x => `${encodeURIComponent(x[0])}=${encodeURIComponent(x[1])}`).join('&');
// 방법2)
//let qs = new URLSearchParams(formData).toString();
//console.log(qs);
let json = JSON.stringify(Object.fromEntries(formData));
//console.log(json);
fetch("../admin/students/" + document.querySelector("#f-no").value, {
method: "PUT",
headers: {
"Content-Type": "application/json",
//"Content-Type": "application/x-www-form-urlencoded"
},
//body: formData
body: json,
//body: qs
})
.then((response) => {
return response.json();
})
.then((result) => {
if (result.status == "success") {
alert("변경 했습니다.");
location.reload();
} else {
alert("변경 실패!");
console.log(result.data);
}
})
.catch((exception) => {
alert("변경 중 오류 발생!");
console.log(exception);
});
};
document.querySelector("#btn-delete").onclick = () => {
fetch("../admin/students/" + document.querySelector("#f-no").value, {
method: "DELETE",
})
.then((response) => {
return response.json();
})
.then((result) => {
if (result.status == "success") {
location.reload();
} else {
alert("학생 삭제 실패!");
}
})
.catch((exception) => {
alert("학생 삭제 중 오류 발생!");
console.log(exception);
});
};
document.querySelector("#btn-cancel").onclick = () => {
showInput();
};
// entries ==> query string
function toQueryStringFromEntries(entries) {
let qs = "";
for (let [key, value] of entries) {
if (qs.length > 0) {
qs += "&";
}
qs += encodeURIComponent(key) + "=" + encodeURIComponent(value);
}
return qs;
}
function toQueryStringFromEntries2(entries) {
let arr = [];
for (let entry of entries) {
arr.push(entry);
}
//console.log(arr);
let arr2 = arr.map(
(x) => `${encodeURIComponent(x[0])}=${encodeURIComponent(x[1])}`
);
//console.log(arr2);
let str = arr2.join("&");
//console.log(str);
return str;
}
function toQueryStringFromEntries3(entries) {
let arr = [...entries];
//console.log(arr);
let arr2 = arr.map(
(x) => `${encodeURIComponent(x[0])}=${encodeURIComponent(x[1])}`
);
//console.log(arr2);
let str = arr2.join("&");
//console.log(str);
return str;
}
Bootstrap 추가
모달 상자 사용을 위해 bootstrap 라이브러리 추가한다.
C:\Users\bitcamp\git\bitcamp-ncp\myapp\app-server\src\main\webapp>npm install bootstrap --save
bootstrap 설정 참고 사이트 : https://getbootstrap.com/docs/5.3/getting-started/introduction/
Bootstrap에서 제공하는 그리드 시스템을 적용하기 위해 아래 <div class="container"> 로 감싼다. 이는 반응형 웹 디자인 적용할때 편하다.

### 64. Back-end 와 Front-end 분리하기: 클라이언트 렌더링 방식으로 전환
### 64. Back-end 와 Front-end 분리하기: 클라이언트 렌더링 방식으로 전환
- 요청 핸들러에서 JSON을 리턴하는 방법
- 웹 페이지에서 JSON을 받아 처리하는 방법
- Handlebars 자바스크립트 템플릿 라이브러리 사용법
- Bootstrap의 모달상자 사용법
teacher 는 목록 클릭시 모달창 나오도록 한다.
<head> 에 bootstrap 불러온다. 그 밑에 우리가 작성한 css 불러온다.
<body> 의 자식을 <div class="container"> 로 감싼다.
bootstrap 스타일 적용을 위해 class="btn btn-primary btn-secondary" 등을 지정한다.
handlebars 코드 아래쪽에 둔다.
{{#each this}} 에서 list 넘어오는데 반복문 돌며 객체 각각이 this 에 들어간다.
<!-- teacher/index.html -->
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>비트캠프 - NCP 1기</title>
<link
rel="stylesheet"
href="../node_modules/bootstrap/dist/css/bootstrap.css"
/>
<link rel="stylesheet" href="index.css" />
</head>
<body>
<div class="container">
<h1>강사</h1>
<aside>
<div>
<button id="btn-new" class="btn btn-primary">새 강사</button>
</div>
<table id="teacher-table" border="1">
<thead>
<tr>
<th>번호</th>
<th>이름</th>
<th>전화</th>
<th>학위</th>
<th>전공</th>
<th>시강료</th>
</tr>
</thead>
<tbody></tbody>
</table>
</aside>
<!-- Modal -->
<div
class="modal fade"
id="teacherModal"
tabindex="-1"
aria-labelledby="exampleModalLabel"
aria-hidden="true"
>
<form id="teacher-form" action="update" method="post">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="exampleModalLabel">
강사
</h1>
<button
type="button"
class="btn-close"
data-bs-dismiss="modal"
aria-label="Close"
></button>
</div>
<div class="modal-body">
<table border="1">
<tr class="edit">
<th>번호</th>
<td><input id="f-no" type="text" name="no" readonly /></td>
</tr>
<tr>
<th>이름</th>
<td><input id="f-name" type="text" name="name" /></td>
</tr>
<tr>
<th>이메일</th>
<td><input id="f-email" type="email" name="email" /></td>
</tr>
<tr>
<th>암호</th>
<td>
<input id="f-password" type="password" name="password" />
</td>
</tr>
<tr>
<th>전화</th>
<td><input id="f-tel" type="tel" name="tel" /></td>
</tr>
<tr>
<th>학위</th>
<td>
<select id="f-degree" name="degree">
<option value="1">고졸</option>
<option value="2">전문학사</option>
<option value="3">학사</option>
<option value="4">석사</option>
<option value="5">박사</option>
<option value="0">기타</option>
</select>
</td>
</tr>
<tr>
<th>학교</th>
<td><input id="f-school" type="text" name="school" /></td>
</tr>
<tr>
<th>전공</th>
<td><input id="f-major" type="text" name="major" /></td>
</tr>
<tr>
<th>강의료(시급)</th>
<td><input id="f-wage" type="number" name="wage" /></td>
</tr>
<tr class="edit">
<th>등록일</th>
<td><span id="f-createdDate"></span></td>
</tr>
</table>
</form>
</div>
<div class="modal-footer">
<button
id="btn-insert"
type="button"
class="input btn btn-primary"
>
추가
</button>
<button
id="btn-update"
type="button"
class="edit btn btn-primary"
>
변경
</button>
<button
id="btn-delete"
type="button"
class="edit btn btn-primary"
>
삭제
</button>
<button
id="btn-cancel"
type="reset"
class="edit btn btn-secondary"
data-bs-dismiss="modal"
>
취소
</button>
</div>
</div>
</div>
</form>
</div>
</div>
<script id="tr-template" type="text/x-handlebars-template">
{{#each this}}
<tr data-no="{{no}}" onclick="getTeacher(event)">
<td>{{no}}</td>
<td>{{name}}</td>
<td>{{tel}}</td>
<td>{{#degreeLabel}}{{degree}}{{/degreeLabel}}</td>
<td>{{major}}</td>
<td>{{wage}}</td>
</tr>
{{/each}}
</script>
<script src="../node_modules/handlebars/dist/handlebars.js"></script>
<script src="../node_modules/bootstrap/dist/js/bootstrap.bundle.js"></script>
<script src="index.js"></script>
</body>
</html>
/* index.css */
aside {
/*border: 1px solid red;*/
box-sizing: border-box;
float: left;
width: 450px;
}
article {
/*border: 1px solid black;*/
box-sizing: border-box;
margin-left: 460px;
}
#teacher-table > tbody > tr:hover {
background-color: navy;
color: white;
}
.invisible {
display: none;
}
handlebars 확장 태그 등록한다.
목록 클릭시 모달창 띄울수 있게 코드 삽입한다.
// index.js
showInput();
getTeachers();
// handlebars 확장 태그 등록
Handlebars.registerHelper("degreeLabel", function (options) {
return getDegreeLabel(parseInt(options.fn(this)));
});
const html = document.querySelector("#tr-template").innerHTML;
const templateEngine = Handlebars.compile(html);
function showInput() {
let el = document.querySelectorAll(".input");
for (let e of el) {
e.classList.remove("invisible");
}
el = document.querySelectorAll(".edit");
for (let e of el) {
e.classList.add("invisible");
}
}
function showEdit() {
let el = document.querySelectorAll(".input");
for (let e of el) {
e.classList.add("invisible");
}
el = document.querySelectorAll(".edit");
for (let e of el) {
e.classList.remove("invisible");
}
}
function getTeachers(keyword) {
let qs = "";
if (keyword) {
qs = `?keyword=${keyword}`;
}
fetch("../admin/teachers" + qs)
.then((response) => {
return response.json();
})
.then((result) => {
document.querySelector("#teacher-table > tbody").innerHTML =
templateEngine(result.data);
});
}
function getDegreeLabel(degree) {
switch (degree) {
case 1:
return "고졸";
case 2:
return "전문학사";
case 3:
return "학사";
case 4:
return "석사";
case 5:
return "박사";
default:
return "기타";
}
}
function getTeacher(e) {
let no = e.currentTarget.getAttribute("data-no");
fetch("../admin/teachers/" + no)
.then((response) => {
return response.json();
})
.then((result) => {
if (result.status == "failure") {
alert("강사를 조회할 수 없습니다.");
return;
}
let teacher = result.data;
document.querySelector("#f-no").value = teacher.no;
document.querySelector("#f-name").value = teacher.name;
document.querySelector("#f-email").value = teacher.email;
document.querySelector("#f-tel").value = teacher.tel;
document.querySelector("#f-degree").value = teacher.degree;
document.querySelector("#f-school").value = teacher.school;
document.querySelector("#f-major").value = teacher.major;
document.querySelector("#f-wage").value = teacher.wage;
document.querySelector("#f-createdDate").innerHTML = teacher.createdDate;
showEdit();
const modal = new bootstrap.Modal("#teacherModal", {});
modal.show();
});
}
document.querySelector("#btn-insert").onclick = () => {
const form = document.querySelector("#teacher-form");
const formData = new FormData(form);
let json = JSON.stringify(Object.fromEntries(formData));
fetch("../admin/teachers", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: json,
})
.then((response) => {
return response.json();
})
.then((result) => {
if (result.status == "success") {
location.reload();
} else {
alert("입력 실패!");
console.log(result.data);
}
})
.catch((exception) => {
alert("입력 중 오류 발생!");
console.log(exception);
});
};
document.querySelector("#btn-update").onclick = () => {
const form = document.querySelector("#teacher-form");
const formData = new FormData(form);
let json = JSON.stringify(Object.fromEntries(formData));
fetch("../admin/teachers/" + document.querySelector("#f-no").value, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: json,
})
.then((response) => {
return response.json();
})
.then((result) => {
if (result.status == "success") {
alert("변경 했습니다.");
location.reload();
} else {
alert("변경 실패!");
console.log(result.data);
}
})
.catch((exception) => {
alert("변경 중 오류 발생!");
console.log(exception);
});
};
document.querySelector("#btn-delete").onclick = () => {
fetch("../admin/teachers/" + document.querySelector("#f-no").value, {
method: "DELETE",
})
.then((response) => {
return response.json();
})
.then((result) => {
if (result.status == "success") {
location.reload();
} else {
alert("강사 삭제 실패!");
}
})
.catch((exception) => {
alert("강사 삭제 중 오류 발생!");
console.log(exception);
});
};
document.querySelector("#btn-cancel").onclick = () => {
showInput();
};
document.querySelector("#btn-new").onclick = () => {
const modal = new bootstrap.Modal("#teacherModal", {});
modal.show();
};
조언
*누가 어떤 라이브러리를 많이 알고 잘 사용하느냐에 프로젝트의 질이 달려있다.
*오픈소스 라이브러리는 지구상에 2가지 형태이다. CSS 사용(예: bootstrap)하거나 javascript 사용(예: jQuery)하거나
과제
/