[SpringBoot] 게시판 구현하기 15 (RESTful 게시판 만들기)
FRAMEWORK/Spring

[SpringBoot] 게시판 구현하기 15 (RESTful 게시판 만들기)

반응형

들어가기 전에

하기 포스팅은 "스프링부트 시작하기(김인우 저)" 책을 공부하며 적은 포스팅입니다. 이번 포스팅에서는 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 설계 시 일반적으로 아래와 같은 규칙을 적용합니다.

  1. URI는 명사를 사용합니다.
    • 예를 들어 회원 목록을 조회하는 REST API는 아래와 같이 표현할 수 있습니다.
      • GET /members
      • GET은 HTTP 메소드로 URI의 리소스를 조회하는 것을 의미합니다. members라는 명사를 통해 회원 목록임을 알 수 있습니다. 
      • 잘 표현된 URI란, 이처럼 보기만 해도 직관적으로 의미를 이해할 수 있는 URI를 의미합니다.
  2. 슬래시(/)로 계층 관계를 나타냅니다.
    • 예를 들어 개라는 목록에는 진돗개, 사모예드, 포메라니안 등 서로 다른 여러 종이 있습니다. 따라서, 개와 진돗개의 계층 관계는 다음과 같이 나타냅니다.
      • GET /dogs/jindo
  3. URI 마지막에는 슬래시를 사용하지 않습니다.
    • 끝에 슬래시를 넣어도 실행하는 데에는 문제가 없지만, 슬래시로 인해 다음 계층이 있는 것으로 오해할 수 있기 때문에 명확하게 하기 위해 URI 마지막에 슬래시를 넣지 않습니다.
  4. URI는 소문자로만 작성합니다.
    • URI 문법 형식을 나타내는 RFC 3986에서는 URI 스키마와 호스트를 제외하고는 대소문자를 구별하도록 규정하기 때문입니다.
    • 대소문자를 구별하기 때문에, 같이 사용하게 되면 URI를 기억하기 어렵고 URI 호출 시 잘못 쓰기 쉽습니다.
  5. 가독성을 높잉기 위해 하이픈(-)은 사용할 수 있지만, 밑줄(_)은 사용하지 않습니다.

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로 나타나 구글링을 통해 위 방식으로 작성했습니다.

$("#boardIdx").val() (왼쪽) 과 $("input:hidden[name=boardIdx]").val() (오른쪽) 비교

 

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 문제인 줄 알았으나, 해당 문제를 해결해도 아래와 같이 에러를 만났습니다.

Request method 'POST' not supported

일차적으로 해당 에러가 나왔다는 것은, /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 속성을 뺄 수 있어 문제 없이 구현이 가능합니다.

 

blog.outsider.ne.kr/1001

 

multipart는 HTTP POST로만 전송해야 한다 :: Outsider's Dev Story

작업을 하다가 `multipart/form-data`로 PUT 전송을 할 일이 생겼다. 예를 들어 일반적인 회원가입 폼을 생각해 보면 회원가입시에는 `/signup`에 POST로 폼 전송을 하지만 회원 가입 후 회원정보를 갱신한

blog.outsider.ne.kr

issues.apache.org/jira/browse/FILEUPLOAD-197

 

[FILEUPLOAD-197] ServletFileUpload isMultipartContent method does not support HTTP PUT - ASF JIRA

This method explicitly checks for method POST. I believe the PUT method can also have multipart requests, and there may be others. In our case we are receiving rest calls using Spring Framework's CommonsMultipartResolver which in turn uses this method of t

issues.apache.org

 

반응형