들어가기 전에
하기 포스팅은 "스프링부트 시작하기(김인우 저)" 책을 공부하며 적은 포스팅입니다. 이번 포스팅에서는 RESTful에 대해 살펴보도록 하겠습니다.
REST란?
REST란 REpresentational State Tranfer의 약자로, HTTP 창시자 중 한 사람인 로이 필딩(Roy Fielding)이 2000년에 발표한 박사 학위 논문에서 소개되었습니다. 로이 필딩은 기존 웹 아키텍처가 HTTP 본래의 우수성을 잘 활용하지 못한다고 생각하여 HTTP의 장점을 최대한 활용할 수 있는 아키텍처로 REST를 소개했습니다.
잘 표현된 HTTP URI로 리소스를 정의하고, HTTP 메소드로 리소스에 대한 행위를 정의합니다. 리소스는 JSON, XML과 같은 여러 언어로 표현할 수 있습니다.
REST의 특징을 지키는 API를 'RESTful하다'라고 표현하기도 합니다.
리소스
리소스는 서비스를 제공하는 시스템의 자원을 의미하며 URI(Uniform Resource Identifier)로 정의됩니다. 즉, REST API의 URI는 리소스의 자원을 표현해야 합니다. REST API의 URI 설계 시 일반적으로 아래와 같은 규칙을 적용합니다.
- URI는 명사를 사용합니다.
- 예를 들어 회원 목록을 조회하는 REST API는 아래와 같이 표현할 수 있습니다.
- GET /members
- GET은 HTTP 메소드로 URI의 리소스를 조회하는 것을 의미합니다. members라는 명사를 통해 회원 목록임을 알 수 있습니다.
- 잘 표현된 URI란, 이처럼 보기만 해도 직관적으로 의미를 이해할 수 있는 URI를 의미합니다.
- 예를 들어 회원 목록을 조회하는 REST API는 아래와 같이 표현할 수 있습니다.
- 슬래시(/)로 계층 관계를 나타냅니다.
- 예를 들어 개라는 목록에는 진돗개, 사모예드, 포메라니안 등 서로 다른 여러 종이 있습니다. 따라서, 개와 진돗개의 계층 관계는 다음과 같이 나타냅니다.
- GET /dogs/jindo
- 예를 들어 개라는 목록에는 진돗개, 사모예드, 포메라니안 등 서로 다른 여러 종이 있습니다. 따라서, 개와 진돗개의 계층 관계는 다음과 같이 나타냅니다.
- URI 마지막에는 슬래시를 사용하지 않습니다.
- 끝에 슬래시를 넣어도 실행하는 데에는 문제가 없지만, 슬래시로 인해 다음 계층이 있는 것으로 오해할 수 있기 때문에 명확하게 하기 위해 URI 마지막에 슬래시를 넣지 않습니다.
- URI는 소문자로만 작성합니다.
- URI 문법 형식을 나타내는 RFC 3986에서는 URI 스키마와 호스트를 제외하고는 대소문자를 구별하도록 규정하기 때문입니다.
- 대소문자를 구별하기 때문에, 같이 사용하게 되면 URI를 기억하기 어렵고 URI 호출 시 잘못 쓰기 쉽습니다.
- 가독성을 높잉기 위해 하이픈(-)은 사용할 수 있지만, 밑줄(_)은 사용하지 않습니다.
HTTP 메소드
HTTP에는 여러 메소드가 있는데 REST 서비스에서는 CRUD에 해당하는 4개의 메소드를 사용합니다. CRUD란, Create/Read/Update/Delete의 약자로 소프트웨어의 기본적인 데이터 처리 기능을 나타냅니다.
HTTP 메소드 | 의미 | 역할 |
POST | Create | 리소스를 생성합니다. |
GET | Read | 해당 URI의 리소스를 조회합니다. |
PUT | Update | 해당 URI의 리소스를 수정합니다. |
DELETE | Delete | 해당 URI의 리소스를 삭제합니다. |
REST API에서는 4개의 메소드를 이용해 리소스에 대한 행위를 정의합니다.
RESTful 게시판으로 변경하기
컨트롤러 작성하기
이전까지 만든 BoardController 클래스와 비교해서 확인할 수 있도록 RESTful 컨트롤러를 새로 만들겠습니다. 앞서 만든 게시글 목록 및 상세 화면, 수정, 삭제, 첨부파일 관련 기능까지 한번에 작성합니다.
기능 | RESTful URI | 요청 방식 | 대응되는 BoardController의 URI |
게시판 목록 | /board | GET | /board/openBoardList.do |
게시글 작성 화면 | /board/write | GET | /board/openBoardWrite.do |
게시글 작성 | /board/write | POST | /board/insertBoard.do |
게시글 상세 화면 | /board/글번호 | GET | /board/openBoardDetail.do |
게시글 수정 | /board/글번호 | PUT | /board/updateBoard.do |
게시글 삭제 | /board/글번호 | DELETE | /board/deleteBoard.do |
첨부파일 다운로드 | /board/file | GET | /board/downloadBoardFile.do |
첨부파일 삭제 | /board/file | DELETE | /board/deleteBoardFile.do |
controller 패키지에 RestBoardController 클래스를 만들고 아래와 같이 코드를 작성합니다.
package board.board.controller;
import java.io.File;
import java.net.URLEncoder;
import java.util.List;
import javax.servlet.http.HttpServletResponse;
import org.apache.commons.io.FileUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.util.ObjectUtils;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.multipart.MultipartHttpServletRequest;
import org.springframework.web.servlet.ModelAndView;
import board.board.dto.BoardDto;
import board.board.dto.BoardFileDto;
import board.board.service.BoardService;
@Controller
public class RestBoardController {
@Autowired
private BoardService boardService;
@RequestMapping(value="/board", method=RequestMethod.GET)
public ModelAndView openBoardList() throws Exception {
ModelAndView mv = new ModelAndView("/board/restBoardList");
List<BoardDto> list = boardService.selectBoardList();
mv.addObject("list", list);
return mv;
}
@RequestMapping(value="/board/write", method=RequestMethod.GET)
public String openBoardWrite() throws Exception {
return "/board/restBoardWrite";
}
@RequestMapping(value="/board/write", method=RequestMethod.POST)
public String insertBoard(BoardDto board, MultipartHttpServletRequest multipartHttpServletRequest) throws Exception {
boardService.insertBoard(board, multipartHttpServletRequest);
return "redirect:/board";
}
@RequestMapping(value="/board/{boardIdx}", method=RequestMethod.GET)
public ModelAndView openBoardDetail(@PathVariable("boardIdx") int boardIdx) throws Exception {
ModelAndView mv = new ModelAndView("/board/restBoardDetail");
BoardDto board = boardService.selectBoardDetail(boardIdx);
mv.addObject("board", board);
return mv;
}
@RequestMapping(value="/board/{boardIdx}", method=RequestMethod.PUT)
public String updateBoard(BoardDto board) throws Exception {
boardService.updateBoard(board);
return "redirect:/board";
}
@RequestMapping(value="/board/{boardIdx}", method=RequestMethod.DELETE)
public String deleteBoard(@PathVariable("boardIdx") int boardIdx) throws Exception {
boardService.deleteBoard(boardIdx);
return "redirect:/board";
}
@RequestMapping(value="/board/file", method=RequestMethod.GET)
public void downloadBoardFile(@RequestParam int idx, @RequestParam int boardIdx, HttpServletResponse response) throws Exception {
BoardFileDto boardFile = boardService.selectBoardFileInformation(idx, boardIdx);
if(ObjectUtils.isEmpty(boardFile) == false) {
String fileName = boardFile.getOriginalFileName();
byte[] files = FileUtils.readFileToByteArray(new File(boardFile.getStoredFilePath()));
response.setContentType("application/octet-stream");
response.setContentLength(files.length);
response.setHeader("Content-Disposition", "attachment; fileName=\"" + URLEncoder.encode(fileName, "UTF-8") + "\";");
response.setHeader("Content-Transfer-Encoding", "binary");
response.getOutputStream().write(files);
response.getOutputStream().flush();
response.getOutputStream().close();
}
}
@RequestMapping(value="/board/file", method=RequestMethod.DELETE)
public String deleteBoardFile(@RequestParam int idx, @RequestParam int boardIdx) throws Exception {
boardService.deleteBoardFile(idx, boardIdx);
return "redirect:/board/"+boardIdx;
}
}
- @RequestMapping(value="/board", method=RequestMethod.GET): 기존 BoardController에서는 @RequestMapping 어노테이션에 주소만 입력하여 value 속성을 생략할 수 있었습니다. RESTful 서비스에서는 주소와 요청 방법, 이 두 가지 속성은 반드시 지정해야 합니다.
- 먼저, value 속성으로 주소를 지정하고 method 속성으로 요청 방식을 정의합니다.
- @RequestMapping 대신, @GetMapping, @PostMapping, @PutMapping, @DeleteMapping 어노테이션(각각 GET, POST, PUT, DELETE)을 사용할 수도 있습니다. 이 경우, 이름에 이미 HTTP 요청 방식이 정의되어 있어 주소만 지정하면 됩니다.
- ModelAndView mv = new ModelAndView("/board/restBoardList"): RESTful 게시판을 위해 컨트롤러를 새로 만든 것처럼 뷰 템플릿 역시 새로 만들 예정입니다. 따라서 호출할 뷰 템플릿을 미리 변경해두었습니다.
- insertBoard 메소드와 openBoardWrite 메소드는 작성하는 주소(@RequestMapping의 value)는 동일하고 요청 방식만 GET과 POST로 다릅니다. 즉, /board/write이라는 주소로 호출할 때 GET 방식으로 요청하면 게시글 작성 화면이 호출되고 POST 방식으로 요청하면 게시글이 등록됩니다.
- @PathVariable("boardIdx") int boardIdx: @PathVariable 어노테이션은 메소드의 파라미터가 URI의 변수로 사용되는 것을 의미합니다.
- 기존에 사용하던 @RequestParam의 경우는 URL 파라미터로 값을 넘기는 방식이었습니다.
서비스, 매퍼, 쿼리 영역
서비스, 매퍼, 쿼리는 기존에 만들었던 클래스 및 쿼리를 동일하게 사용하므로 따로 수정하지 않았습니다.
뷰 템플릿
뷰 템플릿의 경우, 기존 뷰와 비교했을 때 호출하는 주소가 변경된 것 외에는 큰 변경사항이 없습니다. 따라서, 기존 뷰 템플릿을 복사하여 동일한 파일을 만들고 몇 가지 수정하면 됩니다.
먼저 templates.board 폴더 밑의 기존 템플릿을 복사하여 각각 restBoardList.html, restBoardWrite.html, restBoardDetail.html 파일을 생성합니다.
게시글 목록
게시글 목록 화면에서는 상세 화면 및 작성 화면의 링크 주소와 변수들의 이름만 변경하면 됩니다.
<!-- AS-IS -->
...
<a href="/board/openBoardDetail.do?boardIdx="
th:attrappend="href=${list.boardIdx}"
th:text="${list.title}"></a></td>
...
<a href="/board/openBoardWrite.do" class="btn">글 쓰기</a>
...
<!-- TO-BE -->
...
<a href="/board/"
th:attrappend="href=${list.boardIdx}"
th:text="${list.title}"></a></td>
...
<a href="/board/write/" class="btn">글 쓰기</a>
...
- <a href="/board/" th:attrappend="href=${list.boardIdx}" th:Text="${list.title}"></a>: 컨트롤러에서 게시글 상세 화면의 주소는 GET 방식의 /board/게시글번호 형식입니다. 따라서, Thymeleaf에서 제공하는 th:attrappend 속성을 이용해 /board/ 뒤에 게시글 번호를 붙여줍니다.
- <a href="/board/write/" class="btn">: 컨트롤러에서 게시글 작성 화면의 주소는 GET 방식의 /board/write이므로 그에 맞게 주소를 변경합니다.
게시글 작성 화면
게시글 작성 화면은 데이터를 전송하는 폼의 주소만 변경되면 됩니다.
<!-- AS-IS -->
...
<form id="frm" name="frm" method="post" action="/board/insertBoard.do" enctype="multipart/form-data">
...
<!-- TO-BE -->
...
<form id="frm" name="frm" method="post" action="/board/write" enctype="multipart/form-data">
...
게시글 상세 화면
...
</table>
<input type="hidden" name="boardIdx" th:value="${board.boardIdx}">
<!-- ADD START -->
<input type="hidden" id="method" name="_method"/>
<!-- ADD END -->
</form>
<div class="file_list" th:each="list : ${board.fileList}">
<!-- MODIFY START -->
<a style="float:left" th:href="@{/board/file(idx=${list.idx}, boardIdx=${list.boardIdx})}" th:text="|${list.originalFileName}(${list.fileSize} kb)|"></a>
<form th:method="delete" th:action="@{/board/file(idx=${list.idx}, boardIdx=${list.boardIdx})}">
<input type="submit" id="delete_file" th:value="파일삭제">
</form>
<!-- MODIFY END -->
</div>
...
<!-- MODIFY START -->
<script type="text/javascript">
$(document).ready(function(){
$("#list").on("click", function(){
location.href = "/board/";
});
$("#edit").on("click", function(){
$("input:hidden[name=_method]").val("put");
var frm = $("#frm")[0];
frm.action = "/board"+$("input:hidden[name=boardIdx]").val();
frm.submit();
});
$("#delete").on("click", function(){
$("input:hidden[name=_method]").val("delete");
var frm = $("#frm")[0];
frm.action = "/board/"+$("input:hidden[name=boardIdx]").val();
frm.submit();
});
})
</script>
<!-- MODIFY END -->
- restBoardDetail.html 내 form의 경우 기본적으로 method="post"로 설정해두었습니다. 폼은 POST로 동작하지만, 서버에서는 각 _method 파라미터로 요청방식을 결정합니다.
- <input type="hidden" id="method" name="_method"/>: 앞서 HTML은 POST와 GET 방식의 요청만 지원하고 PUT, DELETE 방식은 지원하지 않는다고 했습니다. 스프링은 웹 브라우저에서 사용되는 POST, GET 방식을 이용해 PUT과 DELETE 방식을 사용할 수 있는 기능을 지원하는데, 이를 HiddenHttpMethodFilter라고 합니다.
- HiddenHttpMethodFilter는 _method라는 이름의 파라미터가 존재할 경우 그 값을 요청 방식으로 사용합니다. 즉, _method의 값을 PUT으로 보내면 컨트롤러에서 RequestMethod.PUT의 값을 가진 URI가 호출됩니다.
- th:href="@{/board/file(idx=${list.idx}, boardIdx=${list.boardIdx})}" th:text="|${list.originalFileName}(${list.fileSize} kb)|">: 컨트롤러에서 파일을 다운 받는 URI는 /board/file로 지정했고, 컨트롤러에서 각 파일을 조회하기 위해서는 파일 번호와 게시글 번호가 필요했습니다. 따라서, Thymeleaf를 통해 파일 번호와 게시글 번호를 전달할 수 있게 작성했습니다(기존 boardDetail.html 역시 URI를 제외하고 동일합니다).
- <input type="button" id="delete_file" th:method="delete" th:value="파일삭제" th:onclick="'location.href=\''+@{/board/file(idx=${list.idx}, boardIdx=${list.boardIdx})}+'\''">: 파일 삭제 역시 href 수정 및 method가 delete일 때만 작동할 수 있도록 합니다.
- 이 부분은 책을 보고 한 실습이 아니라, 정확하지 않을 수 있습니다.
- $("#method").val("put"): HiddenHttpMethodFilter를 등록하면 _method 파라미터로 요청 방식을 선택할 수 있다고 위에서 설명했습니다. 수정하기와 삭제하기 버튼을 선택할 경우, 위에서 숨겨진 입력창에 각각 put과 delete라는 값을 설정하고 /board/게시글번호를 호출합니다.
- frm.action = "/board/"+$("input:hidden[name=boardIdx]").val(): 책에서는 "/board/"+$("#boardIdx").val()을 사용하라고 했지만, 막상 해보면 URI에 undefined로 나타나 구글링을 통해 위 방식으로 작성했습니다.
application.properties 속성 추가하기
HiddenHttpMethodFilter을 사용하기 위해서는 아래 속성이 true여야 합니다. 기본적으로 HiddenHttpMethodFilter는 SpringBoot 2.2부터는 내장되어 있지만, default값으로 false가 설정되어 있기 때문에 _method를 통해 form 태그를 PUT/DELETE 방식으로 사용하기 위해서는 하기 속성 true로 바꿔주어야 합니다.
spring.mvc.hiddenmethod.filter.enabled=true
application.yml을 사용한다면 아래처럼 작성해줍니다.
spring:
mvc:
hiddenmethod:
filter:
enabled: true
게시글 작성 확인하기
먼저, localhost:8080/board/를 호출하여 게시글 목록 화면으로 이동합니다. 그 후 "글 쓰기" 버튼을 클릭하여 게시판 등록 화면을 띄웁니다. 테스트 내용 및 첨부파일을 넣어 저장 버튼을 클릭하여 게시글 목록에 신규 게시글이 생성되었는지 확인합니다. 신규 게시글을 열어보면 이전에 썼던 내용이 잘 들어가있음을 확인할 수 있습니다.
RESTful로 작성하여, 주소창에 간결하게 URI가 뜨는 것을 확인할 수 있습니다.
게시글 수정, 삭제 확인하기
다음으로 게시글의 수정 및 삭제가 정상적으로 동작하는지 확인하겠습니다. 게시글 상세, 수정, 삭제 기능은 모두 /board/글 번호로 URI가 동일하고 요청 방식만 구분되어 있습니다. HTML에서 form의 DELETE, PUT 요청 방식을 지원하지 않아 HiddenHttpMethodFilter를 등록하고 화면에서 _method 파라미터를 사용했던 점을 생각하면서 확인해보겠습니다.
RESTful 게시판 생성 시 직면할 수 있는 문제점
org.springframework.web.HttpRequestMethodNotSupportedException: Request method 'POST' not supported 에러
책을 보고 한 실습이지만, 아래와 같이 org.sprngframework.web.HttpRequestMethodNotSupportedException: Request method 'POST' not supported 에러에 직면했습니다. 처음에는 위 restBoardDetail.html 내용에 적어둔 URI의 undefined 문제인 줄 알았으나, 해당 문제를 해결해도 아래와 같이 에러를 만났습니다.
일차적으로 해당 에러가 나왔다는 것은, /board/게시글번호 URI를 가진 컨트롤러 메소드 중 POST를 허용하는 메소드가 없어 발생했다는 것입니다. 현재, 컨트롤러상에서는 POST가 아닌, GET/PUT/DELETE용 메소드만 생성했기 때문에 POST를 허용하지 않았습니다.
HiddenHttpMethodFilter를 사용하면, POST로 form을 보내되, name이 _method인 태그의 값(value)을 찾아 해당 값의 HTTP 방식을 허용하는 컨트롤러 메소드를 찾게 됩니다. 위 상황상 _method에 HTTP 방식(예: put, delete)을 넣어서 보냈으나 무슨 이유 때문인지 해당 값을 인지하지 못하는 것으로 보였습니다.
관련하여 찾아보니, 기존 게시글에 파일 추가하여 수정할 수 있도록 코드를 변경할 때 파일 업로드를 위해 form 태그의 속성 중 enctype="multipart/form-data"를 사용한 것이 문제였습니다.
기본적으로 Apache에서 나온 FileUpload의 ServletFileUpload의 isMultipartContent 내용을 보면 아래와 같습니다.
- POST 메소드가 아닐 경우엔 무시하도록 하드 코딩 되어 있음
public static final boolean isMultipartContent(HttpServletRequest request) {
if (!POST_METHOD.equalsIgnoreCase(request.getMethod())) {
return false;
}
return FileUploadBase.isMultipartContent(new ServletRequestContext(request));
}
위와 같이 하드코딩으로 직접 POST만 되도록 구현한 이유는 아래 두 글을 참고하면 좋을 것 같습니다. 해당 사이트에 들어가면 HTTP 창시자 중 한 사람인 로이 필딩이 관련해서 코멘드를 남겼고 해당 코멘드를 보면 이유를 알 수 있습니다.
PUT 메소드 자체가 리소스 값을 교체한다는 의미이기 때문에, form 안의 모든 데이터를 multipart로 보내지 말고 이미지와 같은 파일의 경우 리소스 URI를 따로 빼서 PUT 요청을 하라는 의미로 보면 될 것 같습니다. SpringBoot를 좀 더 잘 사용할 수 있다면, form을 두개로 나누어 보내보겠지만, 아직은 조금 부족한 감이 있어 일단 게시글 수정 시 파일을 추가 업로드 하는 기능은 빼고 구현했습니다.
게시글 수정 시 파일을 추가 업로드 하는 기능을 빼면 form 태그의 enctype 속성을 뺄 수 있어 문제 없이 구현이 가능합니다.
issues.apache.org/jira/browse/FILEUPLOAD-197
'FRAMEWORK > Spring' 카테고리의 다른 글
[SpringBoot] 게시판 구현하기 17 (스프링 데이터 JPA 사용해보기) (2) | 2021.01.03 |
---|---|
[SpringBoot] 게시판 구현하기 16 (REST API로 변경하기) (0) | 2021.01.01 |
[SpringBoot] 게시판 구현하기 14 (존재하는 게시글에 파일 추가하기) - 책 응용편 (0) | 2021.01.01 |
[SpringBoot] 게시판 구현하기 13 (파일 삭제하기) - 책 응용편 (0) | 2020.12.31 |
[SpringBoot] 게시판 구현하기 12 (파일 업로드와 다운로드) (4) | 2020.12.27 |