-
0922. 댓글처리 Ajax, REST APISpring 2023. 9. 22. 17:51
🌱서론
자바스크립트는 멀티스레드를 지원하지 않기 때문에 비동기로만 할 수 있다.
<요청 보내거나 받을 때 쓰는 인코딩>
지금까지는 데이터를 form 태그를 사용한 1, 2번 방식으로 보냈다.
<form enctype=" ">
1. 이름 = 값&이름=값& ... => default 인코딩 : 제일 많이 씀
2. header 띄우고 body ...=> multipart 인코딩 : 바이너리파일
이제 나오는 건 자바스크립트로 표현할 때 쓰는 Ajax 형식이다.
{
"키" : 값,
"키" : 값 ... }
=> application/json 인코딩 : Ajax
📍Ajax
Asynchronous Javascript and XML (비동기 자바스크립트와 XML)
페이지의 이동(깜빡임) 없이 자바스크립트로 서버와 통신하는 기법이다.
최근 데이터 표현 : xml < Json
*전송과 응답 시 데이터모드를 .application/json 인코딩으로 한다.
브라우저에서 XMLHttpRequest 객체를 제공하지만 타이핑 양이 많아서 사용이 어렵고,
이 객체를 사용할 수 있는 간소화된 라이브러리를 사용한다.
<라이브러리 종류>
- $.ajax : jquery 에서 제공하는 ajax 메서드, 예전에 주름잡음
- ⭐ fetch() : js 기본함수로 제공, 요즘 많이 씀 => react나 순수 자바스크립트에서 많이 씀
- axios : 최근에 jquery 안 쓰면 쓰는 또다른 후보 => 프론트에서 많이 씀
📍REST API
Representational State Transfer "상태를 표현"
함수호출과 다를바 없으므로 일종의 라이브러리이다.- URI와 메서드 : 함수명
- {bno} 관련게시글 : 변수
기능적 매커니즘은 함수호출이고 다른 컴퓨터의 서버에서 함수호출이 제공된다는 차이점만 있다.
=> "RPC(Remote Procedure Call) 다른 컴퓨터의 함수를 호출하는 매커니즘"
[장점]
1. 클라이언트 파트가 브라우저일 필요없이 일반 어플리케이션도 웹서버의 자원을 사용할 수 있다.(자원=정보)
2. 통신만 http로 하지 주고 받는 데이터에 html 처리를 할 필요가 없으니, 클라이언트 종류에 제한이 생기지 않는다.
예시로 휴대폰을 사용할 때,
JSON으로 데이터를 호출, 응답하고 있으니 (REST API는 아닐 수 있지만!)휴대폰은 브라우저가 아닌데 웹서버 정보 사용할 수 있는 이유이다.
인터넷에서 웹서버는 자원을 관리한다.
자원이라함은, 정보인데 게시글 1개나 여행지정보 1개 등을 예로 들 수 있고 이 정보를 식별하는 게 자원관리이다.
이 정보는 해당 웹서버에 존재하는데 이를 URL(정확히는 URI)로 나타낼 수 있다.
=> URI로 정보를 식별
이전에 쓰던 URL 개념은 "페이지를 식별"하는 용도였다.
REST API를 배울 때는 "정보를 식별"하자🖤즉, REST API란 URI 정보(자원)와 메서드(행위)로 대상과 어떤 일을 할지 표현하는 라이브러리이다.
표현방식으론 일반적으로 JSON을 사용한다.
✨REST API의 메서드
<예전>
read : GET
insert,update,delete : POST
<현재>- read : GET
- create : POST
- modify : PUT
- remove : DELETE
* board 뒤에 식별번호={bno} : 그 글 한 개를 나타내겠다.
- 1 -
차이점은 board 뒤에 오는 url에서 나타난다.
- 2 -
추가된 정보 {bno} 부분은 key가 되고 이 key 에 대응하는 정보 하나로 해석한다.
표현을 볼 때는 uri 와 메서드까지 다 봐야한다.
수정과 삭제는 대상 지칭이 필요하니 key(={bno})가 들어가는 것이다.
참고로 수정은 board의 해당 id가 게시글 수정을 하는 것이니 body와 함께 no를 컨트롤러에서 변수로 받음.
나머지도 uri가 같을 땐, 메서드로 구분을 해야 한다.
이제 서버는 해당 정보를 보고 할 일 한다.
- 3 -
GET(추출) 요청일 때는 전달할 게 없으니 요청이 끝이지만(=DELETE는 body 말고 대상만 필요)
POST(생성)는 body의 내용이 전달되어야 한다. PUT(수정)도 마찬가지.
- 4 -
여지껏 어떤 페이지를 보고 싶을 때 Spring에서 파라미터를 받는 방식으로
쿼리스트링인 @RequestParam(Get방식 통신)을 써서 ?bno=10 으로 했다면,
rest 통신을 하려면 @PathVariable(RESTful방식 통신)을 사용하여 {bno}를 사용하면 된다.
delete를 @PathVariable을 쓴 예로 표현식은 이렇게 된다.>> @DeleteMapping('/api/board/{bno}')
- 5 -
POST와 PUT에서 body구성은 Json으로 한다.
요청과 응답 둘다 Json.(지금까지 응답은 Html로 받았다면 Ajax에서는 Json으로 받는다.)
🌏 중간 참고하면 좋은 글
https://velog.io/@dmchoi224/Rest-API-RequestParam-%EA%B3%BC-PathVariable
🌱댓글처리 REST API
다른 uri와 구분하기 위해 (웹브라우저가 아님을 표현) api를 앞에 붙인다.
구분하지 않으면 보안 설정 시 까다로워진다.
관련게시글 번호 : 변수 파트 {bno}
댓글 id : Primary Key {no}
view를 사용하지 않아서 html응답이 아니다.
view를 통하지 않고 직접 응답하는 Json이다. (ex: avatar -> 이미지로 직접 응답함)
직접 응답하려면 아바타처럼 responsebody를 직접 다 붙여줘야하는데 귀찮으니까 특수 컨트롤러를 만들자.
JSON으로 응답하는 특수 컨트롤러
@RestController처리결과를 뷰로 보내는 게 아닌 직접 응답하는 컨트롤러
모든 메서드에 @ResponseBody 가 자동으로 추가되고 처리결과는 객체로 바로 리턴이 가능하다.
❗단, Gson(구글), Jackson같은 라이브러리가 등록되어있어야 한다.
에러가 항상 200으로 나가서(=해당 객체를 json문자열로 변경해서 클라이언트에 응답) 처리에 대한 조작이 필요하다.
* ResponseEntity<T>를 리턴하면 응답코드, 응답 헤더, 내용에 대한 직접 지정이 가능하다.
⭐POST, PUT 요청에서 Body를 Json 인코딩(application/json)해서 보냈으므로 이 json을 디코딩해서 @ModelAttribute에 보내야한다.
❗모델객체 앞에 @RequestBody를 추가해야 값이 들어간다. (안 붙이면 아무 값도 안 들어감)<dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> <version>2.9.4</version> </dependency>
📍서버 측 설계
1. tbl_comment 댓글 테이블 생성drop table if exists tbl_comment; create table tbl_comment ( no integer auto_increment primary key, bno integer not null, content varchar(2000) not null, writer varchar(50) not null, reg_date datetime default now(), update_date datetime default now(), constraint fk_comment_board foreign key(bno) references tbl_board(bno), constraint fk_comment_member foreign key(writer) references tbl_member(username) ); select * from tbl_comment;
2. VO 객체 생성
CommentVOpackage org.galapagos.domain; import java.util.Date; import lombok.Data; @Data public class CommentVO { private Long no; private Long bno; private String writer; private String content; private Date regDate; private Date updateDate; }
3. 인터페이스 Mapping
CommentMapper 인터페이스 생성
RestAPI에 대응하도록- 목록보기(readAll) : 글번호(bno) 필요 - foreign key(Long bno)
- 하나 얻기-보기(get) : no (댓글 id - pk) >> (Long no)
- 생성(create) , 수정(update) : Body 전달 필요 (CommentVO vo)
- 삭제(delete) : comment의 id 필요 (Long no) *댓글삭제니까 댓글 id : pk
package org.galapagos.mapper; import java.util.List; import org.galapagos.domain.CommentVO; public interface CommentMapper { List<CommentVO> readAll(Long bno); CommentVO get(Long no); void create(CommentVO vo); void update(CommentVO vo); void delete(Long no); }
4. XML
CommentMapper.xml 생성
insert하면 pk는 auto_increment 라서 코멘트의 값에 어떤 값이(CommentVO no xxx) 배정됐는지 모른다.
이걸 확인하는 게 selectKey(VO객체의 keyproperty, keycolumn 확인)
selectKey 설정 안 하면 무슨 값인지 모르니까 넣어줘야 한다.⭐<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> <mapper namespace="org.galapagos.mapper.CommentMapper"> <select id="readAll" resultType="CommentVO"> select * from tbl_comment where bno = #{bno} <!-- Foreign Key : 글번호 bno --> </select> <select id="get" resultType="CommentVO"> select * from tbl_comment where no = #{no} <!-- 댓글 id - pk --> </select> <insert id="create"> insert into tbl_comment (bno, writer, content) values(#{bno}, #{writer}, #{content}) <!-- insert로 인해 property랑 column에 auto_increment 된 값이 무엇인지 확인용 --> <selectKey resultType="Long" keyProperty="no" keyColumn="no" order="AFTER"> SELECT LAST_INSERT_ID() </selectKey> </insert> <update id="update"> update tbl_comment set content = #{content}, update_date = now() where no = #{no} </update> <delete id="delete"> delete from tbl_comment where no = #{no} </delete> </mapper>
서비스 안 만들고 컨트롤러로 바로 연결하기
5. Controller 설정
CommentController 생성
*댓글 조회이니 게시글번호 필요없으면 빼도 된다.
29열 @Pathvariable Long bno <<
🖤@메서드명Mapping("")로 할 경우 공통 url로 들어갈 부분을 @RequestMapping으로 설정한다.package org.galapagos.controller; import java.util.List; import org.galapagos.domain.CommentVO; import org.galapagos.mapper.CommentMapper; 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; @RestController @RequestMapping("/api/board/{bno}/comment") //("")로 할 경우 공통 url로 들어갈 부분 public class CommentController { @Autowired CommentMapper mapper; @GetMapping("") public List<CommentVO> readComments(@PathVariable Long bno) { // 글번호(게시글 보여줌) return mapper.readAll(bno); //경로변수에 있던 글번호 } @GetMapping("/{no}") //자체적인 pathvariable List<>로 생성된 배열[] 뒤에 붙여라. public CommentVO readComment(@PathVariable Long bno, @PathVariable Long no) { //글번호(게시글) + 댓글 id(달린 댓글들) return mapper.get(no); //댓글 보는 거니 글번호 bno는 필요없으면 빼도 된다. } @PostMapping("") public CommentVO create(@RequestBody CommentVO vo) { // body 전달 (생성할 댓글내용) mapper.create(vo); return mapper.get(vo.getNo()); //생성한 댓글 다시 꺼내서 리턴(댓글 id = no) } @PutMapping("/{no}") public CommentVO update(@PathVariable Long no, @RequestBody CommentVO vo) { //댓글 id + body(수정할 댓글내용) System.out.println("==> " + vo); //수정한 댓글의 내용 body mapper.update(vo); return mapper.get(vo.getNo()); //올바른 방법 //return vo; //그냥 받은 대로 바로 리턴 } @DeleteMapping("/{no}") public String delete(@PathVariable Long no) { //댓글 id System.out.println("delete ==>" + no); // 댓글삭제 (댓글 id를 삭제했다고 알림) mapper.delete(no); return "OK"; } }
get 요청으로 댓글 있는지만 확인 가능한 단계이니 해 보자
>> localhost:8080/api/board/존재하는 게시글 번호 중 내가 아는 거/comment
빈 배열의 댓글 등장
❗클라이언트 측 설계를 하기 전에 REST 테스트가 필요하다.
PostMan은 가입이 번거로우니 크롬 확장 프로그램인 Talend API Tester를 설치하자.
설치한 화면댓글 조회
아까 사용한 댓글 요청 url 복사해서 입력 후 [Send] (alt+enter 해도 send 된다.)
응답 화면메서드 고를 수 있다. (밑에 3개는 지금 안 씀)
댓글 등록
POST 메서드에서 우측 body에 json 문자열을 만들면 된다.
❗해당 게시글의 작성자가 아닌 다른 작성자로 등록해야 한다.
(우리 설정이 본인글에는 대댓글만 되지 댓글은 안 되는 걸로 설정할 거라서;;)
key는 항상 문자열 등록하고 >> "bno" : 36
댓글 없는 상태에서 create하면 selectKey no 배정이 처리될 예정.
updateDate, regDate의 값은 안 넘겼으므로 default now가 들어가서 여전히 null이다.
=> null이면 create(vo)가 바로 안 넘어가고 no가 배정된다고 했다.
=> get이 필요하므로 DB에서 꺼내서 리턴해야 한다.
그러면 >>return mapper.get(vo.getNo()); 에서는 값이 들어가 있다.send해 보면 csrf 토큰 때문에 403 에러 뜬다.
REST API에 대해서는 csrf 토큰을 안 쓰겠다고 설정해 줘야 한다...
SecurityConfig에서 csrf 필터 아래에 코드 추가
ignoreingAntMatchers의 패턴에 맞으면 csrf 체크를 안 하겠다는 뜻 >>/api/**http.csrf().ignoringAntMatchers("/api/**");
다시 send 누르면 찰나의 시간동안 [abort]가 카운트와 함께 노란색 버튼으로 지나간 후 다시 send로 돌아간다.
abort는 중단버튼으로 찰나의 0.n초 안에 누르면 중단되나 보다.regDate와 updateDate에 값이 부여되었다.
sql에서 확인
이제 GET 요청으로 읽어보자url 뒤에 슬래시하고 댓글 no 입력하면 해당 댓글만 나온다.
>>http://localhost:8080/api/board/36/comment/1
댓글 수정
${content}와 ${no}를 꼭 넘겨야 한다.
>>http://localhost:8080/api/board/36/comment/1
PUT다른 부분 다 비워지고 보낸 데이터만 담김
updateDate 바뀜
DB 한 번 읽어서 리턴하는 게 올바른 방법이다.댓글 삭제
+) 없는 댓글을 삭제 요청했을 때
404를 기대했는데 서버 내부에서 에러가 발생해서 500에러가 아니면 정상처리로 간주해서 별 에러 없다.
에러로 취급하고 싶으면 @ResponseEntity<T> 를 써서 응답코드,헤더, 내용 직접 지정해서 대응 가능하다.
📍클라이언트 측 설계
Ajax
* fetch() 함수
매개변수 기본 2개를 받는다.
첫 번째 인자 : URL (주로 REST url 사용)
ex) http://localhost:8080/api/board/36/comment/6
두 번째 인자 : 옵션 객체 => content type, body 등
리턴 : Promise 타입 객체 (비동기 함수가 리턴하는 타입이다.)
비동기 함수는 함수가 끝났을 때 처리할 로직이 담긴 callback함수를 필수로 가지고 있다.(click, ready event처리 등)
그런데 이러면 콜백 안에 콜백 안에 콜백이 된다. 콜백지옥 => 해석불가
콜백지옥의 개선안으로 나온 게 Promise 객체이다.
커피 시키고 진동벨 받으면 울릴 때까지 다른 일 하는 것 : 비동기 (다른 일 못 하고 기다리면 동기)
진동벨 역할 : promise 객체
🖤promise 객체의 메서드 (fetch의 리턴값으로 호출된다.)
.then((response) => console.log("response:", response)) 작업 끝나면 실행하겠다
.catch((error) => console.log("error:", error)); 실행하다가 예외가 발생하면 작동
*function 키워드없이 괄호로만 하는 람다식(=화살표함수)
괄호 안 : 매개변수 / 화살표 우측 : 함수 body
함수가 한 줄 코드이면 중괄호{ }와 리턴이 생략되어있다.
"function(response){ } return console.~이하생략" 의 축약형태가 위(🖤)의 함수식이다.
[표준안]fetch(url).then((response) => response.json()).then((data) => console.log(data));
[새 버전]
비동기 매커니즘이지만 표기는 동기스타일로 지원해 주는 형식✨async
- await : 기다리겠다. "비동기"(빼먹으면 undefined 에러)
- let res 에서 res : response를 말함
- json 도 비동기라 기다림
- 예외는 catch가 처리
모양은 동기지만 실제 진행은 주루루 진행. promise의 then 절로 다 들어가버림.async function rest_get(url) { try { let res = await fetch(url); return await res.json(); } catch(e) { console.log(e); } }
await
1. Promise를 리턴하는 비동기 함수 앞에 붙일 수 있고
2. async 키워드가 붙은 함수에서만 쓸 수 있다.
3. await를 쓸지 안 쓸지는 선택사항이다.(빼도 문법 에러 아님)
4. await를 생략하면 실제 리턴값이 아니라 Promise 객체로 작업한 코드가 되어 의도치 않은 결과가 나온다.
(에러 날 수도, 이상한 데이터 들어가서 에러 안 날 수도)
<적용>
board의 get.jsp
1. 일단은 화살표 안 쓰고 익명함수 function을 사용해서 해 보자fetch('/api/board/36/comment') //경로만 가져오기, 두 번째 매개변수 없으니 GET요청 .then(function (res) {//익명함수 function console.log(res); }); //callback 1개 등록
게시글 하나 선택해서 개발자 모드로 콘솔을 찍어보면..콘솔 찍었던 부분을 return res.json(); 으로 바꾸면 promise 객체가 리턴 된다.
fetch('/api/board/36/comment') //경로만 가져오기, 두 번째 매개변수 없으니 GET요청 .then(function(res) {//익명함수 function, 리턴값의 메서드 then을 호출 -> response 객체 return res.json(); //Json 문자열을 실제 객체(promise)로 바꿔라 "역직렬화" }) //callback 1개 등록 .then(function(data) { console.log(data); //data라는 댓글 배열 }); // res=>console.log(res) 와 같음 });
비동기 함수를 동기처럼 나타낸 최종fetch('/api/board/36/comment') //경로만 가져오기, 두 번째 매개변수 없으니 GET요청 .then(function(res) {//익명함수 function, 리턴값의 메서드 then을 호출 -> response 객체 console.log('응답 수신'); return res.json(); //Json 문자열을 실제 객체(promise)로 바꿔라 "역직렬화" }) //callback 1개 등록 .then(function(data) { console.log('Json 데이터 변환완료'); console.log(data); //data라는 댓글 배열 }); console.log('fetch 호출 완료'); // res=>console.log(res) 와 같음 });
[순서]
비동기이므로 출력순서가 코드 순서 그대로 나오지 않는다.
(1) 하드코딩한 경로로 fetch 호출(GET 요청) 💁"커피 주문할게요"
➡️ (2) response 반응 🤹♀️"네, 기다리세요"
➡️ (3) data 정보 보냄 💁 ☕🤹♀️ "커피 나왔습니다"*모양
매개변수 => 함수 본체(body)
자 이제 고치자~! (길다)
🤹♀️더 정리할 수 있다.
리소스의 js 폴더에 rest.js 로 자바스크립트 코드 정리하기
예외도 이 코드가 처리해 줄 것이다!rest.js
async function rest_get(url) { try { let res = await fetch(url); return await res.json(); } catch(e) { console.log(e); } } /*이클립스의 자바스크립트 분석기가 await가 뭔지 몰라서 나는 에러이므로 브라우저에서는 괜찮다.*/
get.jsp에서 스크립트 태그로 정리<script src="/resources/js/rest.js"></script>
기존 fetch 함수 지우고 상수로 url 정의한 rest_get 요청 코드 삽입
const url = '/api/board/36/comment'; let data = await rest_get(url); console.log(data);
출력!
url 뒤에 댓글id 입력하면 역시 단일 데이터 리턴
⭐하드코딩 변수처리하기
- bno el로 얻기(board에 제시되어 있으니까 사용 가능)//철기 //bno 하드코딩 변수 처리 const bno = ${board.bno} const url = '/api/board/' + bno + '/comment'; console.log(url); let data = await rest_get(url); console.log(data);
진짜진짜 최종 get.jsp
❗괄호처리가 중간에 마무리되니 주의
❗❗주석 너무 많음 주의<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%> <%@ taglib uri="http://java.sun.com/jsp/jstl/core" prefix="c"%> <%@ taglib uri="http://java.sun.com/jsp/jstl/fmt" prefix="fmt"%> <%@include file="../layouts/header.jsp"%> <script src="/resources/js/rest.js"></script> <script> $(document).ready(async function() { $('.remove').click(function(){ if(!confirm('정말 삭제할까요?')) return; document.forms.removeForm.submit(); }); //철기 //bno 하드코딩 변수 처리 const bno = ${board.bno} const url = '/api/board/' + bno + '/comment'; console.log(url); let data = await rest_get(url); console.log(data); //청동기 //rest.js로 뺀 후 스크립트 처리 /*const url = '/api/board/36/comment/1'; let data = await rest_get(url); console.log(data); */ //신석기 // async로 가기 위한 준비 /* let res = await fetch('/api/board/36/comment'); //GET 요청 let data = await res.json()); console.log(data); */ }); //구석기 //callback .then 사용 /* fetch('/api/board/36/comment') //경로만 가져오기, 두 번째 매개변수 없으니 GET요청 .then((res) {//익명함수 function, 리턴값의 메서드 then을 호출 -> response 객체 console.log('응답 수신'); // 위에 두 줄이 res=>console.log(res) 와 같음 return res.json(); //Json 문자열을 실제 객체(promise)로 바꿔라 "역직렬화" }) //callback 1개 등록 .then(function(data) { console.log('Json 데이터 변환완료'); console.log(data); //data라는 댓글 배열 }); console.log('fetch 호출 완료'); }); */ </script> <h1 class="page-header"><i class="far fa-file-alt"></i> ${board.title}</h1> <div class="d-flex justify-content-between"> <div><i class="fas fa-user"></i> ${board.writer}</div> <div> <i class="fas fa-clock"></i> <fmt:formatDate pattern="yyyy-MM-dd" value="${board.regDate}"/> </div> </div> <hr> <div> ${board.content} </div> <div class="mt-4"> <a href="${cri.getLink('list')}" class="btn btn-primary list"> <i class="fas fa-list"></i> 목록</a> <c:if test="${username == board.writer}"> <a href="${cri.getLinkWithBno('modify', board.bno) }" class="btn btn-primary modify"> <i class="far fa-edit"></i> 수정</a> <a href="#" class="btn btn-danger remove"> <i class="fas fa-trash-alt"></i> 삭제</a> </c:if> </div> <%-- <form id="listForm" action="/board/list" method="get" > <input type="hidden" name="pageNum" value="${cri.pageNum}"/> <input type="hidden" name="amount" value="${cri.amount}"/> <input type="hidden" name="type" value="${cri.type}"/> <input type="hidden" name="keyword" value="${cri.keyword}"/> </form> <form id="modifyForm" action="/board/modify" method="get" > <input type="hidden" id="bno" name="bno" value="${board.bno}"/> <input type="hidden" name="pageNum" value="${cri.pageNum}"/> <input type="hidden" name="amount" value="${cri.amount}"/> <input type="hidden" name="type" value="${cri.type}"/> <input type="hidden" name="keyword" value="${cri.keyword}"/> </form> --%> <form action="remove" method="post" name="removeForm"> <!-- csrf 토큰 안 넣으면 404 에러 --> <input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token }" /> <input type="hidden" name="bno" value="${board.bno}"/> <input type="hidden" name="pageNum" value="${cri.pageNum}"/> <input type="hidden" name="amount" value="${cri.amount}"/> <input type="hidden" name="type" value="${cri.type}"/> <input type="hidden" name="keyword" value="${cri.keyword}"/> </form> <%@include file="../layouts/footer.jsp"%>
'Spring' 카테고리의 다른 글
0926. 답글 처리(서버 + 클라이언트) (0) 2023.09.27 0925. 댓글 화면 출력(댓글 작성, 수정-확인 및 취소, 삭제) (0) 2023.09.25 0918. Spring Form Tag 라이브러리 (0) 2023.09.20 0915. 여행 페이지를 만들자 (2) 2023.09.15