pma 프로젝트에 rest api적용하기 -2-
쇼핑몰 프로젝트
- EmployeeApiController -
// 전체 업데이트 http put 메서드
@PutMapping(consumes = "application/json")
@ResponseStatus(HttpStatus.OK) // http 상태 메시지
public Employee update(@RequestBody @Valid Employee employee) {
return empRepo.save(employee); // save는 id가 있으면 업데이트 없으면 새로 생성
}
업데이트하기위한 employee객체를 입력받음(이름, 성, 이메일).
consumes 는 서버에서 받을 데이터타입을 지정.
여기서는 json타입으로 받음.
포스트맨으로 확인.
직원 중 한명의 정보를 모두 수정 한 후 send시 수정된 정보를 리턴받아 화면에 출력함.
put메서드를 사용했을때는 업데이트할 모든 속성의 데이터가 필요.
=> 하나라도 데이터가 없으면 업데이트가 안됨.
- EmployeeApiController -
// 부분 업데이트시 업데이트할 직원의 id가 필요
@PutMapping(path = "/{id}", consumes = "application/json")
public Employee update(@PathVariable long id, @RequestBody Employee employee) {
Employee emp = empRepo.findByEmployeeId(id); // 업데이트 전 원래 데이터를 불러옴
if(employee.getEmail() != null) {
emp.setEmail(employee.getEmail());
}
if(employee.getFirstName() != null) {
emp.setFirstName(employee.getFirstName());
}
if(employee.getLastName() != null) {
emp.setLastName(employee.getLastName());
}
return empRepo.save(emp); // 업데이트된 emp를 저장
}
각 속성의 데이터가 null인지 체크하여 null값이 아닐 경우에만 해당속성을 업데이트.
http://localhost:8080/app-api/employees/9
id를 9로 넣어 9번 직원 '김유신'의 정보를 put으로 부분업데이트.
수정한 정보만 업데이트됨.
- EmployeeApiController -
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable long id) {
// 예외가 발생하여 데이터가 삭제되지 않았을 경우를 알기위해 try/catch문 사용
try {
empRepo.deleteById(id);
} catch (EmptyResultDataAccessException e) {
System.out.println("삭제 안됨");
}
}
http://localhost:8080/app-api/employees/11
11번 직원인 최창수를 삭제. 삭제 성공 시 데이터를 불러오지 않음.
page: 가져올 페이지의 수
size: 한 페이지에 들어갈 데이터 수
일때 요청주소(parameter)는 employees?page=1&size=5 의 형식을 띄며, 1개의 페이지에 표시될 5개의 데이터만 리턴하게됨.
페이지 계산을 해주는 @PagingAndSortingRepository 을 사용.
=> 전체 정보를 가져오는것이 아닌 원하는 수(page, size로 지정)의 데이터만 객체에 담아 리턴.
Pageable: 페이징한 데이터를 저장해 리턴하는 객체
Sort: 정렬한 데이터를 저장해 리턴하는 객체
- EmployeeApiController -
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
...생략...
// @GetMapping // 직원을 모두 가져오는 findAll은 주석처리
// public Iterable<Employee> getEmployees(){
// return empRepo.findAll();
// }
// 페이징을 적용한 직원 리스트
@GetMapping
@ResponseStatus(HttpStatus.OK)
public Iterable<Employee> findPaginatedEmployees(@RequestParam(value = "page", defaultValue = "1") int page,
@RequestParam(value = "size", defaultValue = "5") int size) {
// 페이지설정 리파지토리를 변경(상속받는 클래스를 변경)
Pageable pageable = PageRequest.of(page, size);
return empRepo.findAll(pageable);
}
매개변수로 page와 size를 넘겨받음.
@GetMapping(params = {"page", "size"})의 params는 필요없음.
=> page와 size속성을 무조건 paramter로 입력받고싶을 경우는 남겨둬야하나, 여기서는 기본데이터를 지정해주었으므로(page=0, size=5) params를 선언하지 않아야 http://localhost:8080/app-api/employees 로 요청을 보냈을 때 기본데이터대로 처리됨.
- EmployeeRepository -
public interface EmployeeRepository extends PagingAndSortingRepository<Employee, Long> {
...생략...
}
상속받는 클래스를 PagingAndSortingRepository로 변경.
PagingAndSortingRepository는 CrudRepository를 포함하므로 CrudRepository는 삭제해도 무관.
http://localhost:8080/app-api/employees?page=0&size=3
페이지는 0부터 시작이므로 첫 페이지는 0페이지가 됨.
새 프로젝트 생성 후 http://localhost:8080/ 로 접속해 Whitelabel Error Page가 나오면 완료.
부트스트랩 4.6버전 사용.
부트스트랩 최신버전인 5버전은 제이쿼리를 사용하지 않아(순수자바스크립트사용) 여기서는 4.6버전을 사용.
bootstrap v4.6 템플릿 페이지소스 복사하여 home.html 생성
html태그로 보기: ctrl + u
새 클래스 생성
- WebConfig -
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addViewControllers(ViewControllerRegistry registry) {
// controller 대신 view를 매핑함
registry.addViewController("/").setViewName("home");
}
}
앞선 프로젝트들과 달리 이번엔 controller대신 @Configuration을 사용하여 뷰를 매핑함.
기본주소(/)일때 home페이지를 찾아 매핑함.(확장자 관계없이 이름으로 찾음)
적용시 화면.
thymeleaf태그를 사용하여 분리
폴더구조
- home.html -
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head th:replace="/fragments/head :: head-front"> </head>
<body>
<nav th:replace="/fragments/nav :: nav-front"></nav>
<main role="main" class="container"></main>
<footer th:replace="/fragments/footer :: footer"></footer>
</body>
</html>
- head.html -
<head th:fragment="head-front">
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
<title>쇼핑몰 홈</title>
<!-- Bootstrap core CSS -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.6.1/dist/css/bootstrap.min.css" />
<!-- 커스텀 CSS -->
<link rel="stylesheet" th:href="@{/css/style.css}" />
</head>
<head th:fragment="head-admin">
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
<title>쇼핑몰 관리자홈</title>
<!-- Bootstrap core CSS -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.6.1/dist/css/bootstrap.min.css" />
<!-- 커스텀 CSS -->
<link rel="stylesheet" th:href="@{/css/style.css}" />
</head>
head태그를 두개 생성하여 하나는 head(고객용), 하나는 head-admin(관리자용)으로 명명
- nav.html -
<nav th:fragment="nav-front" class="navbar navbar-expand-md navbar-dark bg-dark">
<a class="navbar-brand" href="#">쇼핑몰🎁</a>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarShop" aria-controls="navbarShop" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarShop">
<ul class="navbar-nav mr-auto">
<li class="nav-item active">
<a class="nav-link" href="#">Home</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#">Link</a>
</li>
</ul>
</div>
</nav>
<nav th:fragment="nav-admin" class="navbar navbar-expand-md navbar-dark bg-dark">
<a class="navbar-brand" th:href="@{/}">관리자⛑</a>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarShop" aria-controls="navbarShop" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarShop">
<ul class="navbar-nav mr-auto">
<li class="nav-item active">
<a class="nav-link" th:href="@{/admin/pages}">Pages</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#">Link</a>
</li>
</ul>
</div>
</nav>
마찬가지로 nav역시 nav-front와 nav-admin(관리자)으로 명명.
- footer.html -
<footer>
<script src="https://cdn.jsdelivr.net/npm/jquery@3.5.1/dist/jquery.slim.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@4.6.1/dist/js/bootstrap.bundle.min.js"></script>
</footer>
기본페이지 http://localhost:8080/ 접근 시 아래 사진처럼 나와야함.(background는 style.css에서 지정해둠)
shoppingmall스키마 생성
pages 테이블 생성
CREATE TABLE `shoppingmall`.`pages` (
`id` INT NOT NULL AUTO_INCREMENT,
`title` VARCHAR(45) NOT NULL,
`slug` VARCHAR(45) NOT NULL,
`content` MEDIUMTEXT NOT NULL,
`sorting` INT NOT NULL,
PRIMARY KEY (`id`));
Lombok사용을 위한 설치과정
설치 전 STS종료 => 설치 완료 후 STS다시 켜서 확인
C:\Users\admin.m2\repository\org\projectlombok\lombok\1.18.22 경로에서
위의 파일 더블클릭하여 실행
=> 안될 시 https://projectlombok.org/download 에서 다운로드, SpringWorkspace폴더에서 git bash로 실행(압축해제x 실행o)하여 java -jar lombok.jar
로 jar파일을 실행하면 설치화면이 나옴
설치는 STS에 해줌. 인스톨 클릭하면 완료됨.
새 패키지, 클래스 생성
- Page -
// 실제 테이블과 매핑
@Entity
@Table(name = "pages")
@Data // lombok에 의해 get, set, 생성자, toString 자동생성됨
public class Page {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private int id;
private String title;
private String slug;
private String content;
private int sorting;
}
=> lombok설치 후 @Data 사용시 자동으로 get, set, 생성자, toString가 생성됨.
- application.properties -
# DB setting
spring.datasource.url=jdbc:mysql://localhost:3306/shoppingmall?useSSL=false&characterEncoding=UTF-8&serverTimezone=UTC
spring.datasource.username=root
spring.datasource.password=1234
username과 password, url은 현재 사용환경에 맞게 입력.
pom.xml에 MySQL Driver 추가.
- index.html -
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head th:replace="/fragments/head :: head-admin"> </head>
<body>
<nav th:replace="/fragments/nav :: nav-admin"></nav>
<main role="main" class="container"></main>
<footer th:replace="/fragments/footer :: footer"></footer>
</body>
</html>
새 클래스 생성
- AdminPageController -
@Controller
@RequestMapping("/admin/pages")
public class AdminPageController {
@GetMapping
public String index() {
return "admin/pages/index";
}
}
기본주소 localhost:8080 으로 접근 시 쇼핑몰페이지(home.html) 출력.
http://localhost:8080/admin/pages 으로 접근 시 관리자페이지(index.html) 출력.
관리자⛑
클릭 시 쇼핑몰페이지(home.html)로 이동함.
새 인터페이스 생성
Repository는 인터페이스임
- PageRepository -
public interface PageRepository extends JpaRepository<Page, Integer> {
// JpaRepository상속 시 List<PAGE>로 리턴되는 findAll 등 여러 메서드가 추가됨.
}
JpaRepository를 상속받을 경우
@Override default List<Page> findAll();
가 이미 생성되어있으므로 @Override로 재정의하지 않아도 사용할 수 있음.
- AdminPageController -
@Controller
@RequestMapping("/admin/pages")
public class AdminPageController {
@Autowired
private PageRepository pageRepo;
@GetMapping
public String index(Model model) {
List<Page> pages = pageRepo.findAll();
model.addAttribute("pages", pages);
return "admin/pages/index";
}
}
view(admin/pages/index)로 Page List를 넘김.
- index.html -
<main role="main" class="container">
<div class="display-2">Pages</div>
<a <th:href="@{/admin/pages/add}" class="btn btn-primary my-3">추가하기</a>
<div th:if="${!pages.empty}">
<table class="table" id="pages">
<tr class="home">
<th>제 목</th>
<th>슬러그</th>
<th>수 정</th>
<th>삭 제</th>
</tr>
<tr th:each="page : ${pages}">
<td th:text="${page.title}"></td>
<td th:text="${page.slug}"></td>
<td><a th:href="@{'/admin/pages/edit/' + ${page.id}}">수정</a></td>
<td><a th:href="@{'/admin/pages/delete/' + ${page.id}}">삭제</a></td>
</tr>
</table>
</div>
<div th:if="${pages.empty}">
<div class="display-4">현재 페이지가 없습니다...</div>
</div>
</main>
controller에서 model에 담아 넘긴 페이지정보를 th:if에서 pages.empty로 검사하여 페이지가 있는지 구별함.
insert into pages(id, title, slug, content, sorting)
values (1, 'Home', 'home', '홈페이지', 0);
insert into pages(id, title, slug, content, sorting)
values (2, 'Service', 'service', '서비스페이지', 1);
DB에 테스트용데이터 입력
http://localhost:8080/admin/pages 로 관리자페이지에 접근하여 확인.
DB의 pages테이블의 데이터가 출력됨.
DB의 pages테이블에 에 저장된 데이터가 없을 때 출력되는 화면.
- AdminPageController -
@GetMapping("/add")
public String add(Model model) {
model.addAttribute("page", new Page());
return "admin/pages/add";
}
혹은
@GetMapping("/add")
public String add(@ModelAttribute Page page) {
return "admin/pages/add";
}
로 작성해도 같음.
- add.html -
<div class="container">
<div class="display-2">페이지 추가</div>
<a th:href="@{/admin/pages}" class="btn btn-primary my-3">돌아가기</a>
<form method="post" th:object="${page}" th:action="@{/admin/pages/add}">
<div class="form-group">
<label for="">제 목</label>
<input type="text" class="form-control" th:field="*{title}" placeholder="제목" />
</div>
<div class="form-group">
<label for="">슬러그</label>
<input type="text" class="form-control" th:field="*{slug}" placeholder="슬러그" />
</div>
<div class="form-group">
<label for="">컨텐트</label>
<textarea class="form-control" th:field="*{content}" cols="30" rows="10" placeholder="컨텐트"></textarea>
</div>
<button type="submit" class="btn btn-danger">추 가</button>
</form>
</div>
pom.xml에 추가
- Page -
...생략...
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private int id;
@NotBlank(message = "제목을 입력해주세요")
@Size(min = 2, message = "제목은 2자 이상")
private String title;
private String slug; // title을 소문자로 띄워쓰기 특스문자등을 - 로 바꿈
@Size(min = 5, message = "내용은 5자 이상")
private String content;
private int sorting; // 정렬
...생략...
- AdminPageController -
@GetMapping("/add") 아래에 새로 추가.
@PostMapping("/add")
public String add(@Valid Page page, BindingResult bindingResult) {
//유효성검사 결과 에러가 있으면 다시 돌아감
if (bindingResult.hasErrors()) return "admin/pages/add";
return "redirect:admin/pages/add";
}
- add.html -
<div class="container">
<div class="display-2">페이지 추가</div>
<a th:href="@{/admin/pages}" class="btn btn-primary my-3">돌아가기</a>
<form method="post" th:object="${page}" th:action="@{/admin/pages/add}">
<div th:if="${#fields.hasErrors('*')}" class="alert alert-danger">에러 발생</div>
<div class="form-group">
<label for="">제 목</label>
<input type="text" class="form-control" th:field="*{title}" placeholder="제목" />
<span class="error" th:if="${#fields.hasErrors('title')}" th:errors="*{title}"></span>
</div>
<div class="form-group">
<label for="">슬러그</label>
<input type="text" class="form-control" th:field="*{slug}" placeholder="슬러그" />
</div>
<div class="form-group">
<label for="">내 용</label>
<textarea class="form-control" th:field="*{content}" cols="30" rows="10" placeholder="컨텐트"></textarea>
<span class="error" th:if="${#fields.hasErrors('content')}" th:errors="*{content}"></span>
</div>
<button type="submit" class="btn btn-danger">추 가</button>
</form>
</div>
각 input태그 아래에 span태그를 만들어 에러메시지가 출력되도록 작성.
- style.css -
span.error {
color: coral !important;
/* 에러 메시지를 표시할 색 */
}
유효성검사에 통과하지 못하면 에러메시지가 출력되며 다시 admin/add 페이지로 돌아감.
post방식으로 넘어가므로 주소창에는 변화가 없음.
- AdminPageController -
// model은 요청이 그대로 들어갈때만 사용할 수 있으므로 여기서는 RedirectAttributes 사용
@PostMapping("/add")
public String add(@Valid Page page, BindingResult bindingResult, RedirectAttributes attr) {
//유효성검사 결과 에러가 있으면 다시 돌아감
if (bindingResult.hasErrors()) return "admin/pages/add";
// 유효성 검사 통과 시
attr.addFlashAttribute("message", "성공적으로 페이지 추가됨");
attr.addFlashAttribute("alertClass", "alert-success"); // 부트스트랩 경고창(color: succeess)
// 슬러그 검사 (슬러그 미입력시 title의 소문자로 하고, 공백은 - 로 대체), 입력시 소문자공백은 -로 대체
String slug = page.getSlug() == "" ? page.getTitle().toLowerCase().replace(" ", "-") : page.getSlug();
Page slugExist = pageRepo.findBySlug(slug); // 슬러그는 유일한 값이어야하므로 유일한지 검사. 슬러그로 DB검색 후 있으면 Page로 리턴
if (slugExist != null) { // 동일한 slug가 존재하면 저장x
attr.addFlashAttribute("message", "입력한 slug가 이미 존재합니다.");
attr.addFlashAttribute("alertClass", "alert-danger");
attr.addFlashAttribute("page", page); // 입력된 데이터가 저장된 페이지객체를 그대로 유지함
} else {
page.setSlug(slug); // 소문자, '-' 수정 된 slug를 업데이트
page.setSorting(100); // 기본 sorting값
pageRepo.save(page); // 위의 과정은 page를 저장하기 전 유효성을 검사한 것이므로 저장되는건 page객체이다
}
return "redirect:/admin/pages/add"; // post-redirect-get(새로고침시 다시 반복된요청이 가는걸 방지)
}
String slug = page.getSlug() == "" ? page.getTitle().toLowerCase().replace(" ", "-") : page.getSlug();
=> 참일때 String slug = page.getTitle().toLowerCase().replace(" ", "-");
거짓일때 String slug = page.getSlug();
- PageRepository -
Page findBySlug(String slug); // 실제 구현은 jpa 하이버네이트가 함
=> jpa를 사용하지 않으면 직접 쿼리를 작성하여야 함.
- add.html -
유효성검사에 대한 경고를 보여주도록 뷰를 수정.
<form method="post" th:object="${page}" th:action="@{/admin/pages/add}">
<div th:if="${#fields.hasErrors('*')}" class="alert alert-danger">에러 발생</div>
<div th:if="${message}" th:class="${'alert' + alertClass}" th:text="${message}"></div>
...생략...
<button type="submit" class="btn btn-danger">추 가</button>
</form>
th:text="{message}": 넘어온 message가 있으면 출력
th:class="${'alert' + alertClass}": 유효성검사에서 넘겨준 alertClass는 경고창의 color값이므로 alertdanger, alertsuccess 등의 값이 됨
중복된 슬러그 입력 시 에러 (데이터가 입력된 page객체를 다시 넘겨주어 입력한 값을 유지)