Ajax(Asynchronous JavaScript and XML)는 비동기적으로 서버와 브라우저 간에 데이터를 교환하는 기술로 웹 페이지를 새로고침하지 않고도 서버로부터 데이터를 받아와서 웹 페이지의 일부분을 업데이트할 수 있도록 해줌
-> 기존에는 웹 페이지를 다시 로드할 때 전체 페이지를 다시 받아와야했기 때문에 사용자 경험이 좋지 않았음
-> Ajax를 사용하면 웹 페이지를 로드한 후에도 웹 페이지와 서버 간에 데이터를 주고 받을 수 있으므로 사용자 경험을 향상시킬 수 있다.
Asynchronous : 비동기
synchronous : 동기
JavaScript 연결해서 사용
html
<!DOCTYPE html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TodoList - Ajax</title>
<link rel="stylesheet" href="/css/ajax-main.css">
</head>
<body>
<h1>Todo List - Ajax 버전</h1>
<pre>
Ajax(Asynchronous JavaScript and XML)는 비동기적으로 서버와 브라우저 간에 데이터를 교환하는 기술로
웹 페이지를 새로고침 하지 않고도 서버로부터 데이터를 받아와서 웹 페이지의 일부분을 업데이트할 수 있도록 해주는 기술
-> 기존에는 웹 페이지를 다시 로드할 때 전체 페이지를 다시 받아와야했기 때문에 사용자 경험이 좋지 않았음
-> Ajax를 사용하면 웹 페이지를 로드한 후에도 웹 페이지와 서버 간에 데이터를 주고 받을 수 있으므로 사용자 경험을
향상시킬 수 있다.
Asynchronous : 비동기
synchronous : 동기
에이젝스 아작스
</pre>
<hr>
<!-- form 태그 제출은 무조건 동기식 -->
<div>
<h4>할 일 추가</h4>
<div>
제목 : <input type="text" id="todoTitle">
</div>
<div>
<textarea id="todoContent"
cols="50" rows="5" placeholder="상세 내용"></textarea>
</div>
<button id="addBtn">추가하기</button>
</div>
<h3 id="todoHeader">
전체 Todo 개수 : <span id="totalCount">0</span>개
/
완료된 Todo 개수 : <span id="completeCount">0</span>개
<button id="reloadBtn">새로고침</button>
</h3>
<table border="1" style="border-collapse: collapse;">
<thead>
<th>번호</th>
<th>할 일 제목</th>
<th>완료 여부</th>
<th>등록 날짜</th>
</thead>
<tbody id="tbody">
</tbody>
</table>
<!--
할 일 상세 조회 시 출력되는 화면(모달창)
popup layer : 현재 페이지 위에 새로운 레이어를 띄우는 것
-->
<!-- 처음에 숨겨 놓기 -->
<div id="popupLayer" class="popup-hidden">
<div class="popup-row">
번호 : <span id="popupTodoNo"></span>
|
제목 : <span id="popupTodoTitle"></span>
<span id="popupClose">×</span>
</div>
<div class="popup-row">
완료 여부 : <span id="popupComplete"></span>
|
등록일 : <span id="popupRegDate"></span>
</div>
<div class="popup-row">
[내용]
<div id="popupTodoContent"></div>
</div>
<div class="btn-container">
<button id="changeComplete">완료 여부 변경</button>
<button id="updateView">수정</button>
<button id="deleteBtn">삭제</button>
</div>
</div>
<!-- 수정 팝업 레이어 (처음에 숨겨져 있음) -->
<div id="updateLayer" class="popup-hidden">
<div class="popup-row">
제목 : <input type="text" id="updateTitle">
</div>
<div class="popup-row">
<textarea id="updateContent" cols="50" rows="5"></textarea>
</div>
<div class="btn-container">
<button id="updateBtn">수정</button>
<button id="updateCancel">취소</button>
</div>
</div>
<script src="/js/ajax-main.js"></script>
</body>
</html>
/* 요소 얻어와서 변수에 저장 */
const totalCount = document.querySelector("#totalCount");
const completeCount = document.querySelector("#completeCount");
const reloadBtn = document.querySelector("#reloadBtn");
// 할 일 추가 관련 요소
const todoTitle = document.querySelector("#todoTitle");
const todoContent = document.querySelector("#todoContent");
const addBtn = document.querySelector("#addBtn");
// 할 일 목록 조회 관련 요소
const tbody = document.querySelector("#tbody");
// 할 일 상세 조회 관련 요소
const popupLayer = document.querySelector("#popupLayer");
const popupTodoNo = document.querySelector("#popupTodoNo");
const popupTodoTitle = document.querySelector("#popupTodoTitle");
const popupComplete = document.querySelector("#popupComplete");
const popupRegDate = document.querySelector("#popupRegDate");
const popupTodoContent = document.querySelector("#popupTodoContent");
const popupClose = document.querySelector("#popupClose");
// 상세 조회 버튼
const deleteBtn = document.querySelector("#deleteBtn");
const updateView = document.querySelector("#updateView");
const changeComplete = document.querySelector("#changeComplete");
// 수정 레이어 버튼
const updateLayer = document.querySelector("#updateLayer");
const updateTitle = document.querySelector("#updateTitle");
const updateContent = document.querySelector("#updateContent");
const updateBtn = document.querySelector("#updateBtn");
const updateCancel = document.querySelector("#updateCancel");
// 전체 Todo 개수 조회 및 출력하는 함수 정의
function getTotalCount() {
// 비동기로 서버(DB)에서 전체 Todo 개수 조회하는
// fetch() API 코드 작성
// (fetch : 가지고 오다) 기본 GET 요청
fetch("/ajax/totalCount") // 비동기 요청 수행 -> Promise 객체 반환 (응답 형태)
.then(response => {
// response : 비동기 요청에 대한 응답이 담긴 객체 (매개변수명)
console.log("response : ", response);
// response.text() : 응답 데이터를 문자열/숫자 형태로 변환한
// 결과를 가지는 Promise 객체 반환 (두번째 then 한테 넘겨줌)
return response.text();
})
// 두 번째 then의 매개변수 (result)
// == 첫 번째 then에서 반환된 Promise 객체의 PromiseResult 값 (응답값)
.then(result => {
// result 매개변수 == Controller 메서드에서 반환된 값
console.log("result : ", result);
// #totalCount 요소의 내용을 result 변경
totalCount.innerText = result;
});
}
// completeCount 값 비동기 통신으로 얻어와서 화면 출력
// 완료된 Todo 개수
function getCompleteCount() {
// fetch() : 비동기로 요청해서 결과 데이터 가져오기
// 첫 번째 then의 response :
// - 응답 결과, 요청 주소, 응답 데이터 등이 담겨있음
// response.text() : 응답 데이터를 text 형태로 변환
// 두 번째 then 의 result
// - 첫 번째 then 에서 text로 변환된 응답 데이터
fetch("/ajax/completeCount")
.then(response => {
return response.text();
})
.then(result => {
// #completeCount 요소에 내용으로 result 값 출력
completeCount.innerText = result;
});
}
// 새로고침 버튼이 클릭 되었을 때
reloadBtn.addEventListener("click", () => {
getTotalCount(); // 비동기로 전체 할 일 개수 조회
getCompleteCount(); // 비동기로 완료된 할 일 개수 조회
});
// -----------------------------------------------------
// 할 일 추가 버튼 클릭 시 동작
addBtn.addEventListener("click", () => {
// 비동기로 할 일 추가하는 fetch() 코드 작성
// - 요청 주소 : "/ajax/add"
// - 데이터 전달 방식(method) : "POST" 방식
// 파라미터를 저장한 JS 객체
const param = {
// Key : Value
"todoTitle" : todoTitle.value,
"todoContent" : todoContent.value
};
/* javaScript 객체 형태를 java 에서 그대로 쓸 수 없음 json 사용할거임 */
fetch("/ajax/add" , { // ajax 앞에 / 빼먹으면 안됨
// 옵션에 대한 key, value 형태로 작성
// key : value
method : "POST", // POST 방식 요청
headers : {"Content-Type" : "application/json"}, // 요청 데이터의 형식을 JSON으로 지정해서 보냄
body : JSON.stringify(param) // param 객체를 JSON(string)으로 변환
})
.then(resp => resp.text()) // 반환된 값을 text로 변환
.then(temp => { // 첫번째 then 에서 반환된 값 중 변환된 text를 temp에 저장
if(temp > 0) { // 성공
alert("추가 성공");
// 추가 성공한 제목, 내용 지우기
todoTitle.value = "";
todoContent.value = "";
// 할 일이 추가되었기 때문에 전체 Todo 개수 다시 조회
getTotalCount();
// 할 일 목록 다시 조회
selectTodoList();
} else { // 실패
alert("추가 실패");
}
});
});
// --------------------------------------------------------------
// 비동기(ajax)로 할 일 상세 조회하는 함수
const selectTodo = (url) => {
// 매개변수 url == "/ajax/detail?todoNo=10" 형태의 문자열
// response.json() :
// - 응답 데이터가 JSON인 경우
// 이를 자동으로 Object 형태로 변환하는 메서드
// == JSON.parse(JSON 데이터)
fetch(url)
.then(resp => resp.json())
.then(todo => {
// 매개 변수 todo :
// - 서버 응답(JSON)이 Object로 변환된 값
// const todo = JSON.parse(result);
// javaScript 객체 형태로 바꿔주는 애 parse string으로 넘어온 값을
// console.log(todo);
console.log(todo);
// popup Layer에 조회된 값을 출력
popupTodoNo.innerText = todo.todoNo;
popupTodoTitle.innerText = todo.todoTitle;
popupComplete.innerText = todo.complete;
popupRegDate.innerText = todo.regDate;
popupTodoContent.innerText = todo.todoContent;
// popup layer 보이게 하기
popupLayer.classList.remove("popup-hidden");
// update Layer가 혹시라도 열려있으면 숨기기
updateLayer.classList.add("popup-hidden");
});
};
// -----------------------------------------------------
// popup layer의 x 버튼 (#popupClose) 클릭 시 닫기
popupClose.addEventListener("click", () => {
popupLayer.classList.add("popup-hidden");
});
// 비동기로 할 일 목록을 조회하는 함수
const selectTodoList = () => {
fetch("/ajax/selectList")
.then(resp => resp.text()) // 응답 결과를 text로 변환
.then(result => {
console.log(result);
console.log(typeof result); // 객체가 아닌 문자열 형태
// 문자열은 가공은 할 수 있지만 너무 힘들다.
// -> JSON.parse(JSON데이터) 이용
// JSON.parse(JSON데이터) : string -> object
// - string 형태의 JSON 데이터를 JS Object 타입으로 변환
// JSON.stringify(JS Object) : object -> string
// - JS Object 타입을 string 형태의 JSON 데이터로 변환
const todoList = JSON.parse(result);
console.log(todoList);
// ------------------------------------------------------
// 기존에 출력되어있던 할 일 목록을 모두 삭제
tbody.innerHTML = "";
// #tbody에 tr/td 요소를 생성해서 내용 추가
for(let todo of todoList) { // 향상된 for문
// tr 태그 생성
const tr = document.createElement("tr");
const arr = ['todoNo', 'todoTitle', 'complete', 'regDate'];
for(let key of arr) {
const td = document.createElement("td");
// 제목인 경우
if(key === 'todoTitle') {
const a = document.createElement("a"); // a 태그 생성
a.innerText = todo[key]; // 제목을 a 태그 내용으로 대입
a.href = "/ajax/detail?todoNo=" + todo.todoNo;
td.append(a);
tr.append(td);
// a 태그 클릭 시 기본 이벤트(페이지 이동) 막기
a.addEventListener("click", (e) => {
e.preventDefault(); // 기본 이벤트 막아주는 메서드
// 페이지 이동하는 게 동기 요청이라서 막고 비동기 요청으로
// 할 일 상세 조회 비동기 요청
selectTodo(e.target.href);
// e.target.href : 클릭된 a태그의 href 속성 값
});
continue;
}
td.innerText = todo[key];
tr.append(td);
}
// tbody의 자식으로 tr(한 행) 추가
tbody.append(tr);
}
});
};
// -------------------------------------------------
// 삭제 버튼 클릭 시
deleteBtn.addEventListener("click", () => {
// 취소 클릭 시 아무것도 안함
if( !confirm("정말 삭제하시겠습니까?") ) { return; }
// 삭제할 할 일 번호 얻어오기
const todoNo = popupTodoNo.innerText; // #popupTodoNo 내용 얻어오기
// 비동기 DELETE 방식 요청 ajax REST API
fetch("/ajax/delete", {
method : "DELETE", // DELETE 방식 요청 -> @DeleteMapping 처리
// 데이터 하나를 전달해도 application/json 작성
headers : {"content-type" : "application/json"},
body : todoNo // todoNo 값을 body에 담아서 전달
// -> @RequestBody로 꺼냄
})
.then(resp => resp.text())
.then(result => {
if(result > 0) { // 삭제 성공
alert("삭제 되었습니다.");
// 상세 조회 창 닫기
popupLayer.classList.add("popup-hidden");
// 전체, 완료된 할 일 개수 다시 조회
// + 할 일 목록 다시 조회
getTotalCount();
getCompleteCount();
selectTodoList();
} else { // 삭제 실패
alert("삭제 실패");
}
});
});
/* changeComplete.addEventListener("click", () => {
let complete = popupComplete.innerText;
const todoNo = popupTodoNo.innerText;
console.log(complete);
console.log(todoNo);
complete = (complete === 'Y') ? 'N' : 'Y';
popupComplete.innerHTML = complete;
const param = {
// Key : Value
"todoNo" : todoNo,
"complete" : complete
};
fetch("/ajax/changeComplete", {
method : "PUT",
headers : {"content-type" : "application/json"},
body : JSON.stringify(param)
})
.then(resp => resp.text())
.then(result => {
if(result > 0) { // 수정 성공
// alert("수정 완료");
// popupLayer.classList.add("popup-hidden");
// getTotalCount();
getCompleteCount();
selectTodoList();
} else {
alert("수정 실패");
}
});
}); */
// ------------------------------------------------------------------
// 완료 여부 변경 버튼 클릭 시
changeComplete.addEventListener("click", () => {
// 변경할 할 일 번호, 완료 여부 (Y <-> N)
const todoNo = popupTodoNo.innerText;
const complete = popupComplete.innerText === 'Y' ? 'N' : 'Y';
// SQL 수행에 필요한 값을 객체로 묶음 (JS 객체 형태로)
const obj = {"todoNo" : todoNo, "complete" : complete};
// 비동기로 완료 여부 변경
fetch("/ajax/changeComplete", {
method: "PUT", // 변경 수정할 때 사용 (UPDATE)
headers : {"Content-type" : "application/json"}, // 값을 하나만 보내더라도 꼭 써줘야함
body : JSON.stringify(obj) // obj 라는 객체를 JSON 으로 변경해서 java로 넘겨줌 지금 obj 는 JS 객체
})
.then(resp => resp.text())
.then(result => {
if(result > 0) { // 성공
// update 된 DB 데이터를 다시 조회해서 화면에 출력
// -> 서버 부하가 큼
// 서버 부하를 줄이기 위해 상세 조회에서 Y/N만 바꾸기
popupComplete.innerText = complete;
// getCompleteCount();
// 서버 부하를 줄이기 위해 완료된 Todo 개수 +-1
const count = Number(completeCount.innerText); // 넘어온 값이 String 이라서 Number로 형변환
if(complete === 'Y') completeCount.innerText = count + 1;
else completeCount.innerText = count - 1;
// 서버 부하 줄이기 가능 -> 코드 조금 복잡
selectTodoList();
} else { // 실패
alert("완료 여부 변경 실패");
}
});
});
// --------------------------------------------------------------------
// 상세 조회에서 수정 버튼 (#updateView) 클릭 시
updateView.addEventListener("click", () => {
// 기존 팝업 레이어는 숨기고
popupLayer.classList.add("popup-hidden");
// 수정 레이어 보이게
updateLayer.classList.remove("popup-hidden");
// 수정 레이어 보일 때
// 팝업 레이어에 작성된 제목, 내용을 얻어와 세팅
updateTitle.value = popupTodoTitle.innerText;
updateContent.value = popupTodoContent.innerHTML.replaceAll("<br>", "\n");
// HTML 에서는 줄바꿈이 <br> 이고 textarea 에서는 줄바꿈이 \n 이라서
// innerHTML 로 그냥 가져오면 줄바꿈 인식 못함 가져올 때 <br> 이면 \n 로 바꿈
// updateContent.value = popupTodoContent.innerText;
// HTML 화면에서 줄 바꿈이 <br>로 인식되고 있는데
// textarea에서는 \n으로 바꿔줘야 줄 바꿈으로 인식된다.
// 수정 레이어 -> 수정 버튼에 data-todo-no 속성 추가
updateBtn.setAttribute("data-todo-no", popupTodoNo.innerText);
});
// -------------------------------------------------------------------------
// 수정 레이어에서 취소 버튼(#updateCancel)이 클릭되었을 때
updateCancel.addEventListener("click", () => {
// 수정 레이어 숨기기
updateLayer.classList.add("popup-hidden");
// 팝업 레이어 보이기
popupLayer.classList.remove("popup-hidden");
});
// -------------------------------------------------------------------------
updateBtn.addEventListener("click", e => {
// 위에서 setAttribute 로 data-todo-no 속성을 추가해둠
// e.target.dataset 안에 있는 todoNo 으로 가져옴
// 서버로 전달해야되는 값을 객체로 묶어둠
const obj = {
"todoNo" : e.target.dataset.todoNo,
"todoTitle" : updateTitle.value,
"todoContent" : updateContent.value
};
// 비동기 요청
fetch("/ajax/update", {
method : "PUT",
headers : {"Content-Type" : "application/json"},
body : JSON.stringify(obj)
})
.then(resp => resp.text())
.then(result => {
if(result > 0) {
alert("수정 성공");
// 수정 레이어 숨기기
updateLayer.classList.add("popup-hidden");
// 목록 다시 조회
selectTodoList();
popupTodoTitle.innerText = updateTitle.value;
popupTodoContent.innerHTML
= updateContent.value.replaceAll("\n", "<br>");
popupLayer.classList.remove("popup-hidden");
// 수정 레이어에 있는 남은 흔적 제거
updateTitle.value = "";
updateContent.value = "";
updateBtn.removeAttribute("data-todo-no"); // 속성 제거
} else {
alert("수정 실패");
}
});
});
selectTodoList();
getTotalCount(); // 함수 호출
getCompleteCount();
Controller
package edu.kh.todo.controller;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
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.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import edu.kh.todo.model.dto.Todo;
import edu.kh.todo.model.service.TodoService;
import lombok.extern.slf4j.Slf4j;
/* @ResponseBody
* - 컨트롤러 메서드의 반환값을
* HTTP 응답 본문에 직접 바인딩하는 역할임을 명시
*
* - 컨트롤러 메서드의 반환값을
* 비동기 요청했던 HTML/JS 파일 부분에
* 값을 돌려보낼 것이다 명시
*
* - forward/redirect 로 인식 X
*
* @RequestBody
* - 비동기 요청(ajax) 시 전달되는 데이터 중
* body 부분에 포함된 요청 데이터를
* 알맞은 Java 객체 타입으로 바인딩하는 어노테이션
*
* - 비동기 요청 시 body 에 담긴 값을
* 알맞은 타입으로 변환해서 매개변수에 저장
*
* [HttpMessageConverter]
* Spring 에서 비동기 통신 시
* - 전달되는 데이터의 자료형
* - 응답하는 데이터의 자료형
* 위 두가지를 알맞은 형태로 가공(변환)해주는 객체
*
* - 문자열, 숫자 <-> TEXT
* - DTO <-> JSON
* - Map <-> JSON
*
* (참고)
* HttpMessageConverter 가 동작하기 위해서는
* Jackson-data-bind 라이브러리가 필요한데
* Spring Boot 모듈에 내장되어 있음
* (Jackson : 자바에서 JSON 다루는 방법을 제공하는 라이브러리)
* (Spring Legacy 는 내장 X 직접 추가해줘야함)
* */
@Controller // 요청/응답 제어 역할 명시 + Bean 등록
@RequestMapping("ajax")
@Slf4j
public class AjaxController {
// @Autowired
// - 등록된 Bean 중 같은 타입 또는 상속관계인 Bean 을
// 해당 필드에 의존성 주입(DI)
@Autowired
private TodoService service;
@GetMapping("main") // /ajax/main GET 요청 매핑
public String ajaxMain() {
// 접두사 : classpath:templates/
// 접미사 : .html
return "ajax/main";
}
// Spring Controller 메서드 return 자리는 응답 페이지 쪽 보여주는 자리
// forward / redirect
// 전체 Todo 개수 조회
@ResponseBody // 값 그대로 호출한 곳에 돌려보내는 어노테이션
@GetMapping("totalCount")
public int getTotalCount() {
// 전체 할 일 개수 조회 서비스 호출 및 응답
int totalCount = service.getTotalCount();
return totalCount;
}
@ResponseBody
@GetMapping("completeCount")
public int getCompleteCount() {
// int completeCount = service.getCompleteCount();
// return completeCount;
return service.getCompleteCount();
}
@ResponseBody // 비동기 요청 결과로 값 자체를 반환
@PostMapping("add")
public int addTodo(
// JSON 이 파라미터로 전달된 경우 아래 방법으로 얻어오기 불가능
// @RequestParam("todoTitle") String todoTitle,
// @RequestParam("todoContent") String todoContent
@RequestBody Todo todo // 요청 body 에 담긴 값을 Todo 에 저장
) {
log.debug(todo.toString());
return service.addTodo(todo.getTodoTitle(), todo.getTodoContent());
}
@ResponseBody
@GetMapping("selectList")
public List<Todo> selectList() {
List<Todo> todoList = service.selectList();
return todoList;
// List(Java 전용 타입)를 반환
// -> JS가 인식할 수 없기 때문에
// HttpMessageConverter 가
// JSON 형태로 반환하여 반환
// -> [{}, {}, {}] JSONArray
}
@ResponseBody
@GetMapping("detail")
public Todo selectTodo(@RequestParam("todoNo") int todoNo) {
// return 자료형 : Todo
// -> HttpMessageConverter 가 String(JSON) 형태로 변환해서 반환
return service.todoDetail(todoNo);
}
@ResponseBody
@DeleteMapping("delete") // Delete 방식 요청 처리 (비동기 요청만 가능)
public int todoDelete(@RequestBody int todoNo) {
// GET/POST (동기/비동기)
// DELETE/PUT (비동기)
// REST API (AJAX) 자원 중심 언어간의 상호작용 수월하게 해주는 모바일이든 웹이든
/* HTTP 메서드
* - GET : 자원 조회
* - POST : 자원 생성
* - PUT : 자원 업데이트
* - DELETE : 자원 삭제
* */
return service.todoDelete(todoNo);
}
// 비동기 요청 return 에 작성하는 값이 비동기 요청 보낸 쪽으로 돌려주려면 어노테이션 추가
// return 에 주소값이 아닌 객체 그대로 돌려줄 때
// 완료 여부 변경
@ResponseBody
@PutMapping("changeComplete")
public int changeComplete(@RequestBody Todo todo) {
return service.changeComplete(todo);
}
// 할 일 수정
@ResponseBody
@PutMapping("update")
public int todoUpdate(@RequestBody Todo todo) {
return service.todoUpdate(todo);
}
}