새로고침이 사라지는 순간
현대 웹사이트는 이런 식으로 동작 안 합니다. 댓글은 즉시 추가되고 화면은 그대로여야 합니다.
전체 페이지를 새로 받는 대신, JS 로 백그라운드에서 서버에 요청 → JSON 받아 → 화면의 일부분만 직접 수정.
v6 게시판의 글 상세 페이지에 댓글 기능을 추가합니다:
CREATE TABLE myreply (
num INT PRIMARY KEY AUTO_INCREMENT,
content VARCHAR(500) NOT NULL,
boardnum INT NOT NULL,
writer VARCHAR(50) NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (boardnum) REFERENCES myboard(num) ON DELETE CASCADE
);
👉 boardnum 이 myboard.num 을 가리키는 FK 첫 등장. ON DELETE CASCADE — 글이 지워지면 댓글도 함께 삭제.
// com.smhrd.domain.Reply
@Data @AllArgsConstructor @NoArgsConstructor
public class Reply {
private int num;
private String content;
private int boardnum;
private String writer;
private LocalDateTime createdAt;
}
// com.smhrd.mapper.ReplyMapper
public interface ReplyMapper {
List<Reply> selectList(int boardnum);
void insert(Reply r);
}
<mapper namespace="com.smhrd.mapper.ReplyMapper">
<select id="selectList" parameterType="int"
resultType="com.smhrd.domain.Reply">
SELECT num, content, boardnum, writer, created_at
FROM myreply
WHERE boardnum = #{boardnum}
ORDER BY num ASC
</select>
<insert id="insert" parameterType="com.smhrd.domain.Reply"
useGeneratedKeys="true" keyProperty="num">
INSERT INTO myreply(content, boardnum, writer)
VALUES(#{content}, #{boardnum}, #{writer})
</insert>
</mapper>
👉 useGeneratedKeys + keyProperty: INSERT 후 자동 증가된 num 이 객체로 돌아옴.
package com.smhrd.service;
@Service
public class ReplyService {
@Autowired
private ReplyMapper mapper;
public List<Reply> list(int boardnum) {
return mapper.selectList(boardnum);
}
public Reply add(Reply r) {
mapper.insert(r);
// insert 후 num 이 채워진 r 이 그대로 돌아옴
return r;
}
}
package com.smhrd.controller;
@RestController
@RequestMapping("/api/replies")
public class ReplyApiController {
@Autowired
private ReplyService service;
// GET /api/replies?boardnum=3
@GetMapping
public List<Reply> list(@RequestParam int boardnum) {
return service.list(boardnum);
}
}
👉 글 번호로 댓글 목록을 JSON 배열로 반환.
// POST /api/replies
@PostMapping
public Reply add(@RequestBody Reply r,
HttpSession session) {
Member u = (Member) session.getAttribute("loginUser");
if (u == null) {
throw new RuntimeException("로그인이 필요합니다");
}
r.setWriter(u.getId()); // 세션에서 작성자 ID
return service.add(r);
}
👉 세션에서 작성자를 채워 넣고 새 댓글 객체를 그대로 응답.
<!-- /WEB-INF/views/board/view.jsp 의 일부 -->
<h1>${board.title}</h1>
<div>${board.content}</div>
<hr/>
<h3>댓글</h3>
<div id="replyList">
<!-- 여기에 JS 로 댓글 추가됨 -->
</div>
<c:if test="${not empty sessionScope.loginUser}">
<form id="replyForm" onsubmit="addReply(event)">
<input type="text" id="replyInput" placeholder="댓글" />
<button type="submit">등록</button>
</form>
</c:if>
<script>
const BOARD_NUM = ${board.num};
/* 다음 슬라이드의 JS */
</script>
// 페이지 로드 시 기존 댓글 가져오기
async function loadReplies() {
const res = await fetch(`/api/replies?boardnum=${BOARD_NUM}`);
const replies = await res.json();
const list = document.getElementById('replyList');
list.innerHTML = '';
replies.forEach(r => {
list.appendChild(renderReply(r));
});
}
// 페이지 로드 시 한 번 실행
loadReplies();
👉 fetch 로 JSON 배열을 받아 한 줄씩 DOM 으로 그립니다.
function renderReply(r) {
const div = document.createElement('div');
div.className = 'reply';
div.innerHTML = `
<strong>${r.writer}</strong>
<span>${r.createdAt}</span>
<p>${r.content}</p>
`;
return div;
}
👉 댓글 객체 하나를 받아 DOM 노드 하나를 만듭니다. 등록 시에도 재사용.
async function addReply(event) {
event.preventDefault(); // form 의 기본 제출 막기
const input = document.getElementById('replyInput');
const content = input.value.trim();
if (!content) return;
const res = await fetch('/api/replies', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
boardnum: BOARD_NUM,
content: content
})
});
/* 다음 슬라이드 — 응답 처리 */
}
👉 preventDefault() 로 페이지 새로고침을 막고, JSON 으로 POST 합니다.
try {
if (!res.ok) throw new Error('서버 오류');
const newReply = await res.json();
// 응답으로 받은 댓글을 화면 끝에 추가
document.getElementById('replyList')
.appendChild(renderReply(newReply));
input.value = ''; // 입력창 비움
} catch (e) {
alert('댓글 등록 실패: ' + e.message);
}
}
👉 응답 JSON 을 같은 renderReply 로 그려서 끝에 붙이고, 입력창만 비움. 페이지 그대로.
<form action="/reply/add"
method="post">
<input name="content"/>
<button>등록</button>
</form>
// 서버 (@Controller)
return "redirect:/board/view?num=...";
// → 페이지 전체 새로고침
👉 등록 버튼을 누르면 페이지가 통째로 다시 그려집니다. 스크롤 위치도 잃음.
<form onsubmit="addReply(event)">
<input id="replyInput"/>
<button>등록</button>
</form>
// 서버 (@RestController)
return r; // JSON
// → JS 가 화면 끝에 새 댓글만 추가
// 페이지 그대로
👉 같은 화면 안에서 댓글만 살짝 추가됩니다. 스크롤·입력 상태 보존.
// 옛 방식 (Promise)
fetch('/api/replies?boardnum=3')
.then(res => res.json())
.then(replies => {
replies.forEach(r => { ... });
})
.catch(err => console.error(err));
// 현대 (async / await — 가독성 ↑)
async function load() {
try {
const res = await fetch('/api/replies?boardnum=3');
const replies = await res.json();
replies.forEach(r => { ... });
} catch (err) {
console.error(err);
}
}
👉 await 는 async 함수 안에서만. 비동기를 동기처럼 읽기 좋게.
| 함정 | 증상 |
|---|---|
event.preventDefault() 빠짐 | fetch 도 가지만 form 도 제출됨 → 화면 전체 새로고침 |
Content-Type: application/json 누락 | 서버에서 @RequestBody 매핑 실패 |
JSON.stringify 누락 | 객체가 [object Object] 문자열로 보내짐 |
await 누락 | res 가 Promise 객체 그대로 → res.json() 에러 |
두 효과가 모이면 사용자는 「부드러운 앱」 같은 경험을 합니다. 이게 현대 웹의 표준.
v8 에서 만든 REST API 가 처음으로 클라이언트와 만나는 차시. v9 부터 우리 사이트는:
두 형태가 공존하며, 부분 부분 비동기로 옮겨가는 길.
REST API 가 만들어졌다. 하지만 클라이언트가 안 씀.
JS 가 fetch 로 REST API 호출. 댓글이 새로고침 없이 등장. 새 시대의 웹 인터랙션.
event.preventDefault() 로 form 기본 동작 막기다음: v10 자잘한 비동기 + 첨부파일 — 같은 패턴 응용.