REST(Representational State Transfer)는 분산 시스템을 설계하기 위한 소프트웨어 아키텍처 스타일로, 특히 HTTP 프로토콜을 기반으로 한 웹 서비스 설계에서 주로 사용된다. REST는 클라이언트와 서버 간 통신에서 리소스의 상태와 표현(Representation)을 전송한다는 개념에서 이름이 유래되었다.
REST는 2000년, 로이 필딩(Roy Fielding)의 박사 논문에서 처음으로 제안되었다. 로이 필딩은 HTTP와 URI 설계에 깊이 관여한 개발자로, REST는 그의 논문에서 웹의 확장성과 상호운용성을 극대화하기 위해 정의된 아키텍처 스타일이다. 이 개념은 특히 웹의 발전과 HTTP 프로토콜 표준화 과정에서 큰 영향을 미쳤다.
REST의 주요 목적은 시스템 간 상호운용성을 높이고, 단순하면서도 확장 가능한 웹 애플리케이션 설계를 지원하는 데 있다.
=> 이를 준수해야 진정한 RESTful 시스템
토큰 기반 인증
도입기능 ⬇️ | RESTful URI | 요청방식(method) | 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?boardIdx=000&idx=000 | GET | /board/downloadBoardFile.do |
@Slf4j
@Controller
public class RestController {
@Autowired
private BoardService boardService;
// 목록 조회
@GetMapping("/board")
public ModelAndView openBoardList() throws Exception {
ModelAndView mv = new ModelAndView("/board/restBoardList");
List<BoardDto> list = boardService.selectBoardList();
mv.addObject("list", list);
return mv;
}
// 작성 화면
@GetMapping("/board/write")
public String openBoardWrite() throws Exception {
return "/board/restBoardWrite";
}
// 저장 처리
@PostMapping("/board/write")
public String insertBoard(BoardDto boardDto, MultipartHttpServletRequest request) throws Exception {
boardService.insertBoard(boardDto, request);
return "redirect:/board";
}
// 상세 조회
@GetMapping("/board/{boardIdx}")
public ModelAndView openBoardDetail(@PathVariable("boardIdx") int boardIdx) throws Exception {
BoardDto boardDto = boardService.selectBoardDetail(boardIdx);
ModelAndView mv = new ModelAndView("/board/restBoardDetail");
mv.addObject("board", boardDto);
return mv;
}
// 수정 처리
@PutMapping("/board/{boardIdx}")
public String updateBoard(@PathVariable("boardIdx") int boardIdx, BoardDto boardDto) throws Exception {
boardDto.setBoardIdx(boardIdx);
boardService.updateBoard(boardDto);
return "redirect:/board";
}
// 삭제 처리
@DeleteMapping("/board/{boardIdx}")
public String deleteBoard(@RequestParam("boardIdx") int boardIdx) throws Exception {
boardService.deleteBoard(boardIdx);
return "redirect:/board";
}
// 첨부파일 다운로드
@GetMapping("/board/file")
public void downloadBoardFile(@RequestParam("idx") int idx, @RequestParam("boardIdx") int boardIdx, HttpServletResponse response) throws Exception {
BoardFileDto boardFileDto = boardService.selectBoardFileInfo(idx, boardIdx);
if (ObjectUtils.isEmpty(boardFileDto)) {
return;
}
Path path = Paths.get(boardFileDto.getStoredFilePath());
byte[] file = Files.readAllBytes(path);
response.setContentType("application/octet-stream");
response.setContentLength(file.length);
response.setHeader("Content-Disposition", "attachment; fileName=\"" + URLEncoder.encode(boardFileDto.getOriginalFileName(), "UTF-8") + "\";");
response.setHeader("Content-Transfer-Encoding", "binary");
response.getOutputStream().write(file);
response.getOutputStream().flush();
response.getOutputStream().close();
}
}
package board;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.web.filter.HiddenHttpMethodFilter;
@SpringBootApplication
public class BoardApplication {
public static void main(String[] args) {
SpringApplication.run(BoardApplication.class, args);
}
@Bean
public HiddenHttpMethodFilter hiddenHttpMethodFilter() {
return new HiddenHttpMethodFilter();
}
}
=> 화면을 제공하지 않고 데이터만 제공
package board.controller;
import java.net.URLEncoder;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.util.ObjectUtils;
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.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartHttpServletRequest;
import com.fasterxml.jackson.databind.ObjectMapper;
import board.dto.BoardDto;
import board.dto.BoardFileDto;
import board.service.BoardService;
import jakarta.servlet.http.HttpServletResponse;
@RestController
@RequestMapping("/api")
public class RestApiBoardController {
@Autowired
private BoardService boardService;
// 목록 조회
@GetMapping("/board")
public List<BoardDto> openBoardList() throws Exception {
return boardService.selectBoardList();
}
// 저장 처리
@PostMapping(value = "/board", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public void insertBoard(@RequestParam("board") String boardData, MultipartHttpServletRequest request) throws Exception {
ObjectMapper objectMapper = new ObjectMapper();
BoardDto boardDto = objectMapper.readValue(boardData, BoardDto.class);
boardService.insertBoard(boardDto, request);
}
// 상세 조회
@GetMapping("/board/{boardIdx}")
public BoardDto openBoardDetail(@PathVariable("boardIdx") int boardIdx) throws Exception {
return boardService.selectBoardDetail(boardIdx);
}
// 수정 처리
@PutMapping("/board/{boardIdx}")
public void updateBoard(@PathVariable("boardIdx") int boardIdx, @RequestBody BoardDto boardDto) throws Exception {
boardDto.setBoardIdx(boardIdx);
boardService.updateBoard(boardDto);
}
// 삭제 처리
@DeleteMapping("/board/{boardIdx}")
public void deleteBoard(@PathVariable("boardIdx") int boardIdx) throws Exception {
boardService.deleteBoard(boardIdx);
}
// 첨부파일 다운로드
@GetMapping("/board/file")
public void downloadBoardFile(@RequestParam("idx") int idx, @RequestParam("boardIdx") int boardIdx, HttpServletResponse response) throws Exception {
BoardFileDto boardFileDto = boardService.selectBoardFileInfo(idx, boardIdx);
if (ObjectUtils.isEmpty(boardFileDto)) {
return;
}
Path path = Paths.get(boardFileDto.getStoredFilePath());
byte[] file = Files.readAllBytes(path);
response.setContentType("application/octet-stream");
response.setContentLength(file.length);
response.setHeader("Content-Disposition", "attachment; fileName=\"" + URLEncoder.encode(boardFileDto.getOriginalFileName(), "UTF-8") + "\";");
response.setHeader("Content-Transfer-Encoding", "binary");
response.getOutputStream().write(file);
response.getOutputStream().flush();
response.getOutputStream().close();
}
}
스프링 부트 프로젝트를 사용해 API 문서 생성을 자동화
// https://mvnrepository.com/artifact/org.springdoc/springdoc-openapi-starter-webmvc-ui
implementation group: 'org.springdoc', name: 'springdoc-openapi-starter-webmvc-ui', version: '2.8.4'
http://localhost:8080/swagger-ui/index.html#/
springdoc.swagger-ui.path=/swagger-ui.html
springdoc.api-docs.path=/api-docs
springdoc.packages-to-scan=board.controller, board.test
springdoc.paths-to-match=/v1, /api/**
package board.configuration;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.info.License;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class SpringDocConfiguration {
@Bean
public OpenAPI openAPI() {
return new OpenAPI().info(
new Info()
.title("SpringBoot Board REST API")
.description("스프링 부트 기반의 게시판 REST API")
.version("v1.0")
.license(new License().name("Apache 2.0").url("http://myapiserver.com"))
);
}
}
package board.controller;
import java.net.URLEncoder;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.util.ObjectUtils;
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.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartHttpServletRequest;
import com.fasterxml.jackson.databind.ObjectMapper;
import board.dto.BoardDto;
import board.dto.BoardFileDto;
import board.service.BoardService;
import jakarta.servlet.http.HttpServletResponse;
@RestController
@RequestMapping("/api")
public class RestApiBoardController {
@Autowired
private BoardService boardService;
// 목록 조회
@Operation(summary = "게시판 목록 조회", description = "등록된 게시물 목록을 조회해서 반환합니다.")
@GetMapping("/board")
public List<BoardDto> openBoardList() throws Exception {
return boardService.selectBoardList();
}
// 저장 처리
@PostMapping(value = "/board", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public void insertBoard(@RequestParam("board") String boardData, MultipartHttpServletRequest request) throws Exception {
ObjectMapper objectMapper = new ObjectMapper();
BoardDto boardDto = objectMapper.readValue(boardData, BoardDto.class);
boardService.insertBoard(boardDto, request);
}
// 상세 조회
@Operation(summary = "게시판 상세 조회", description = "게시물 아이디와 일치하는 게시물의 상세 정보를 조회해서 반환합니다.")
@Parameter(name = "boardIdx", description = "게시물 아이디", required = true)
@GetMapping("/board/{boardIdx}")
public BoardDto openBoardDetail(@PathVariable("boardIdx") int boardIdx) throws Exception {
return boardService.selectBoardDetail(boardIdx);
}
// 수정 처리
@PutMapping("/board/{boardIdx}")
public void updateBoard(@PathVariable("boardIdx") int boardIdx, @RequestBody BoardDto boardDto) throws Exception {
boardDto.setBoardIdx(boardIdx);
boardService.updateBoard(boardDto);
}
// 삭제 처리
@DeleteMapping("/board/{boardIdx}")
public void deleteBoard(@PathVariable("boardIdx") int boardIdx) throws Exception {
boardService.deleteBoard(boardIdx);
}
// 첨부파일 다운로드
@GetMapping("/board/file")
public void downloadBoardFile(@RequestParam("idx") int idx, @RequestParam("boardIdx") int boardIdx, HttpServletResponse response) throws Exception {
BoardFileDto boardFileDto = boardService.selectBoardFileInfo(idx, boardIdx);
if (ObjectUtils.isEmpty(boardFileDto)) {
return;
}
Path path = Paths.get(boardFileDto.getStoredFilePath());
byte[] file = Files.readAllBytes(path);
response.setContentType("application/octet-stream");
response.setContentLength(file.length);
response.setHeader("Content-Disposition", "attachment; fileName=\"" + URLEncoder.encode(boardFileDto.getOriginalFileName(), "UTF-8") + "\";");
response.setHeader("Content-Transfer-Encoding", "binary");
response.getOutputStream().write(file);
response.getOutputStream().flush();
response.getOutputStream().close();
}
}
// 상세 조회
@Operation(summary = "게시판 상세 조회", description = "게시물 아이디와 일치하는 게시물의 상세 정보를 조회해서 반환합니다.")
@Parameter(name = "boardIdx", description = "게시물 아이디", required = true)
@GetMapping("/board/{boardIdx}")
public ResponseEntity<Object> openBoardDetail(@PathVariable("boardIdx") int boardIdx) throws Exception {
BoardDto boardDto = boardService.selectBoardDetail(boardIdx);
if (boardDto == null) {
Map<String, Object> result = new HashMap<>();
result.put("code", HttpStatus.NOT_FOUND.value());
result.put("name", HttpStatus.NOT_FOUND.name());
result.put("message", "게시판 번호 " + boardIdx + "와 일치하는 게시물이 존재하지 않습니다.");
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(result);
} else {
return ResponseEntity.status(HttpStatus.OK).body(boardDto);
}
}
@Override
public BoardDto selectBoardDetail(int boardIdx) {
boardMapper.updateHitCnt(boardIdx);
BoardDto boardDto = boardMapper.selectBoardDetail(boardIdx);
if (boardDto != null) {
List<BoardFileDto> boardFileInfoList = boardMapper.selectBoardFileList(boardIdx);
boardDto.setFileInfoList(boardFileInfoList);
}
return boardDto;
}
하나의 객체를 이용해서 사용자 입력 부터 DB 연계 까지 사용하는 경우
sql-board.xml 파일에 내용이 아래와 같이 잘못되어 있는 경우
sql-board.xml
<insert id="insertBoard" parameterType="board.dto.BoardDto" useGeneratedKeys="true" keyProperty="boardIdx">
insert into t_board(title, contents, created_dt, created_id, hit_cnt)
values (#{title}, #{contents}, now(), #{createdId}, #{hitCnt})
</insert>
사용자 화면에서 조작된 요청(매개변수를 추가)을 전달하면 해당 값이 저장되는 문제가 있음 ⬇️
이렇게 hit_cnt를 잘못 넣어도 mapping 돼서 기본값으로 설정돼서 넘어갈 것이다.
입력 부분을 입력 전용 dto인 request dto로 바꾼다.
BoardInsertRequest
package board.dto;
import lombok.Data;
@Data
public class BoardInsertRequest {
private String title;
private String contents;
}
#### RestController
```java
// 저장 처리
@PostMapping("/board/write")
// public String insertBoard(BoardDto boardDto, MultipartHttpServletRequest request) throws Exception {
public String insertBoard(BoardInsertRequest boardInsertRequest, MultipartHttpServletRequest request) throws Exception {
// 서비스 메서드에 맞춰서 데이터를 변경
BoardDto boardDto = new BoardDto();
boardDto.setTitle(boardInsertRequest.getTitle());
boardDto.setContents(boardInsertRequest.getContents());
boardService.insertBoard(boardDto, request);
return "redirect:/board";
}
불필요한 데이터가 들어오고 나가는 것을 방지
// 목록 조회
@Operation(summary = "게시판 목록 조회", description = "등록된 게시물 목록을 조회해서 반환합니다.")
@GetMapping("/board")
public List<BoardDto> openBoardList() throws Exception {
return boardService.selectBoardList();
}
BoardListResponse
package board.dto;
import lombok.Data;
@Data
public class BoardListResponse {
private int boardIdx;
private String title;
private int hitCnt;
private String createdDt;
}
// 목록 조회
@Operation(summary = "게시판 목록 조회", description = "등록된 게시물 목록을 조회해서 반환합니다.")
@GetMapping("/board")
public List<BoardListResponse> openBoardList() throws Exception {
List<BoardDto> boardList = boardService.selectBoardList();
List<BoardListResponse> results = new ArrayList<>();
for (BoardDto dto : boardList) {
BoardListResponse res = new BoardListResponse();
res.setBoardIdx(dto.getBoardIdx());
res.setTitle(dto.getTitle());
res.setHitCnt(dto.getHitCnt());
res.setCreatedDt(dto.getCreatedDt());
results.add(res);
}
return results;
}
implementation group: 'org.modelmapper', name: 'modelmapper', version: '3.2.2'
// 저장 처리
@PostMapping("/board/write")
// public String insertBoard(BoardDto boardDto, MultipartHttpServletRequest request) throws Exception {
public String insertBoard(BoardInsertRequest boardInsertRequest, MultipartHttpServletRequest request) throws Exception {
// 서비스 메서드에 맞춰서 데이터를 변경
/*
BoardDto boardDto = new BoardDto();
boardDto.setTitle(boardInsertRequest.getTitle());
boardDto.setContents(boardInsertRequest.getContents());
*/
BoardDto boardDto = new ModelMapper().map(boardInsertRequest, BoardDto.class);
boardService.insertBoard(boardDto, request);
return "redirect:/board";
}
// 목록 조회
@Operation(summary = "게시판 목록 조회", description = "등록된 게시물 목록을 조회해서 반환합니다.")
@GetMapping("/board")
public List<BoardListResponse> openBoardList() throws Exception {
List<BoardDto> boardList = boardService.selectBoardList();
List<BoardListResponse> results = new ArrayList<>();
for (BoardDto dto : boardList) {
/*
BoardListResponse res = new BoardListResponse();
res.setBoardIdx(dto.getBoardIdx());
res.setTitle(dto.getTitle());
res.setHitCnt(dto.getHitCnt());
res.setCreatedDt(dto.getCreatedDt());
results.add(res);
*/
BoardListResponse res = new ModelMapper().map(dto, BoardListResponse.class);
results.add(res);
}
return results;
}
// 목록 조회
@Operation(summary = "게시판 목록 조회", description = "등록된 게시물 목록을 조회해서 반환합니다.")
@GetMapping("/board")
public List<BoardListResponse> openBoardList() throws Exception {
List<BoardDto> boardList = boardService.selectBoardList();
List<BoardListResponse> results = new ArrayList<>();
/*
for (BoardDto dto : boardList) {
BoardListResponse res = new BoardListResponse();
res.setBoardIdx(dto.getBoardIdx());
res.setTitle(dto.getTitle());
res.setHitCnt(dto.getHitCnt());
res.setCreatedDt(dto.getCreatedDt());
results.add(res);
}
*/
boardList.forEach(dto -> results.add(new ModelMapper().map(dto, BoardListResponse.class)));
return results;
}
npm create vite board-app
npm install
npm install axios react-router-dom
npm run dev
게시판 목록 | src/board/BoardList.jsx |
게시판 상세 | src/board/BoardDetail.jsx |
게시판 글쓰기 | src/board/BoardWrite.jsx |
export default function BoardDetail() {
return (
<>
<h1>게시판 상세</h1>
</>
);
}
이런식으로 List, Write 도 적용
import { createBrowserRouter, Outlet, RouterProvider, Link } from 'react-router-dom';
import './App.css';
import BoardList from './board/BoardList';
import BoardDetail from './board/BoardDetail';
import BoardWrite from './board/BoardWrite';
const Layout = () => (
<>
<nav>
<Link to="/list">게시판 목록</Link>:<Link to="/detail/8">게시판 상세</Link>:
<Link to="/write">게시판 글쓰기</Link>
</nav>
<Outlet />
</>
);
const router = createBrowserRouter([
{
path: '/',
element: <Layout />,
children: [
{ path: '', element: <BoardList /> },
{ path: 'list', element: <BoardList /> },
{ path: 'detail/:boardIdx', element: <BoardDetail /> },
{ path: 'write', element: <BoardWrite /> },
],
},
]);
export default function App() {
return <RouterProvider router={router} />;
}
boardList.html 파일에 <div class="container">
부분의 내용을 BoardList.jsx 파일에 추가하고, Thymeleaf 관련 내용을 삭제, class, colspan 등을 JSX 문법에 맞도록 수정
export default function BoardList() {
return (
<>
<div className="container">
<h2>게시판 목록</h2>
<table className="board_list">
<colgroup>
<col width="15%" />
<col width="*" />
<col width="15%" />
<col width="20%" />
</colgroup>
<thead>
<tr>
<th scope="col">글번호</th>
<th scope="col">제목</th>
<th scope="col">조회수</th>
<th scope="col">작성일</th>
</tr>
</thead>
<tbody>
<tr>
<td></td>
<td className="title">
<a href="/board/openBoardDetail.do?boardIdx="></a>
</td>
<td></td>
<td></td>
</tr>
<tr>
<td colSpan="4">조회된 결과가 없습니다.</td>
</tr>
</tbody>
</table>
<a href="/board/openBoardWrite.do" className="btn">
글쓰기
</a>
</div>
</>
);
}
리액트의 index.css 파일의 내용을 모두 삭제하고, 스프링부트의 board.css 파일의 내용을 App.css 파일에 추가
useEffect(() => {
axios
.get('http://localhost:8080/api/board')
.then((res) => console.log(res))
.catch((err) => console.log(err));
}, []);
http://localhost:8080/api/board ⇐ 스프링부트 서버
http://localhost:5173 ⇐ 리액트 개발 서버
~~~~ ~~~~~~~~~ ~~~~
스킴 호스트 포트
~~~~~~~~~~~~~~~~~~~~~
Origin = 출처 = 기원
응답헤더에 Access-Control-Allow-Origin 이 없다.
그래서 브라우저가 주소를 읽어오긴 하지만 화면에 표시를 못한다.
=> REST API에 CORS 설정을 해줘야 한다.
REST API에 CORS 설정
@CrossOrigin(origins = "http://localhost:5173")
public class RestApiBoardController {
...
}
@RestController
@RequestMapping("/api")
// @CrossOrigin(origins = "http://localhost:5173")
public class RestApiBoardController {
@Autowired
private BoardService boardService;
// 목록 조회
@CrossOrigin(origins = "http://localhost:5173")
@Operation(summary = "게시판 목록 조회", description = "등록된 게시물 목록을 조회해서 반환합니다.")
@GetMapping("/board")
public List<BoardListResponse> openBoardList() throws Exception {
@Configuration
public class WebMvcConfiguration implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoggerInterceptor());
}
@Override
public void addCorsMappings(CorsRegistry registry) {
registry
.addMapping("/api/**")
.allowedOrigins("http://localhost:5173")
.allowedMethods("GET");
}
}
import axios from 'axios';
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
export default function BoardList() {
const [datas, setDatas] = useState({});
useEffect(() => {
axios
.get('http://localhost:8080/api/board')
.then((res) => {
console.log(res);
res && res.data && setDatas(res.data);
})
.catch((err) => console.log(err));
}, []);
return (
<>
<div className="container">
<h2>게시판 목록</h2>
<table className="board_list">
<colgroup>
<col width="15%" />
<col width="*" />
<col width="15%" />
<col width="20%" />
</colgroup>
<thead>
<tr>
<th scope="col">글번호</th>
<th scope="col">제목</th>
<th scope="col">조회수</th>
<th scope="col">작성일</th>
</tr>
</thead>
<tbody>
{datas.length > 0 &&
datas.map((board) => (
<>
<tr>
<td>{board.boardIdx}</td>
<td className="title">
<Link to={`/detail/${board.boardIdx}`}>{board.title}</Link>
</td>
<td>{board.hitCnt}</td>
<td>{board.createdDt}</td>
</tr>
</>
))}
{datas.length === 0 && (
<tr>
<td colSpan="4">조회된 결과가 없습니다.</td>
</tr>
)}
</tbody>
</table>
<Link to="/write" className="btn">
글쓰기
</Link>
</div>
</>
);
}
export default function BoardDetail() {
return (
<>
<div className="container">
<h2>게시판 상세</h2>
<form id="frm" method="post">
<input type="hidden" name="boardIdx" />
<table className="board_detail">
<colgroup>
<col width="15%" />
<col width="*" />
<col width="15%" />
<col width="35%" />
</colgroup>
<tbody>
<tr>
<th>글번호</th>
<td></td>
<th>조회수</th>
<td></td>
</tr>
<tr>
<th>작성자</th>
<td></td>
<th>작성일</th>
<td></td>
</tr>
<tr>
<th>제목</th>
<td colSpan="3">
<input type="text" id="title" name="title" />
</td>
</tr>
<tr>
<td colSpan="4">
<textarea id="contents" name="contents"></textarea>
</td>
</tr>
</tbody>
</table>
</form>
<div className="file_list">
<a></a>
</div>
<input type="button" id="list" className="btn" value="목록으로" />
<input type="button" id="update" className="btn" value="수정하기" />
<input type="button" id="delete" className="btn" value="삭제하기" />
{/* 버튼 동작은 이벤트 핸들러로 대체 */}
</div>
</>
);
}
import axios from 'axios';
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
export default function BoardDetail() {
const [board, setBoard] = useState({});
const { boardIdx } = useParams();
useEffect(() => {
axios
.get(`http://localhost:8080/api/board/${boardIdx}`)
.then((res) => res && res.data && setBoard(res.data))
.catch((err) => console.log(err));
}, []);
return (
<>
<div className="container">
<h2>게시판 상세</h2>
<form id="frm" method="post">
<input type="hidden" name="boardIdx" value={board.boardIdx} />
<table className="board_detail">
<colgroup>
<col width="15%" />
<col width="*" />
<col width="15%" />
<col width="35%" />
</colgroup>
<tbody>
<tr>
<th>글번호</th>
<td>{board.boardIdx}</td>
<th>조회수</th>
<td>{board.hitCnt}</td>
</tr>
<tr>
<th>작성자</th>
<td>{board.createdId}</td>
<th>작성일</th>
<td>{board.createdDt}</td>
</tr>
<tr>
<th>제목</th>
<td colSpan="3">
<input type="text" id="title" name="title" value={board.title} />
</td>
</tr>
<tr>
<td colSpan="4">
<textarea id="contents" name="contents" value={board.contents}></textarea>
</td>
</tr>
</tbody>
</table>
</form>
<div className="file_list">
{board.fileInfoList &&
board.fileInfoList.map((file, index) => (
<p key={index}>
{file.originalFileName} ({file.fileSize}kb)
</p>
))}
</div>
<input type="button" id="list" className="btn" value="목록으로" />
<input type="button" id="update" className="btn" value="수정하기" />
<input type="button" id="delete" className="btn" value="삭제하기" />
{/* 버튼 동작은 이벤트 핸들러로 대체 */}
</div>
</>
);
}
import axios from 'axios';
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
export default function BoardDetail() {
const [board, setBoard] = useState({});
const [title, setTitle] = useState('');
const [contents, setContents] = useState('');
const { boardIdx } = useParams();
useEffect(() => {
axios
.get(`http://localhost:8080/api/board/${boardIdx}`)
.then((res) => {
res && res.data && setBoard(res.data);
res && res.data && setTitle(res.data.title);
res && res.data && setContents(res.data.contents);
})
.catch((err) => console.log(err));
}, []);
return (
<>
<div className="container">
<h2>게시판 상세</h2>
<form id="frm" method="post">
<input type="hidden" name="boardIdx" value={board.boardIdx} />
<table className="board_detail">
<colgroup>
<col width="15%" />
<col width="*" />
<col width="15%" />
<col width="35%" />
</colgroup>
<tbody>
<tr>
<th>글번호</th>
<td>{board.boardIdx}</td>
<th>조회수</th>
<td>{board.hitCnt}</td>
</tr>
<tr>
<th>작성자</th>
<td>{board.createdId}</td>
<th>작성일</th>
<td>{board.createdDt}</td>
</tr>
<tr>
<th>제목</th>
<td colSpan="3">
<input
type="text"
id="title"
name="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</td>
</tr>
<tr>
<td colSpan="4">
<textarea
id="contents"
name="contents"
value={contents}
onChange={(e) => setContents(e.target.value)}
></textarea>
</td>
</tr>
</tbody>
</table>
</form>
<div className="file_list">
{board.fileInfoList &&
board.fileInfoList.map((file, index) => (
<p key={index}>
{file.originalFileName} ({file.fileSize}kb)
</p>
))}
</div>
<input type="button" id="list" className="btn" value="목록으로" />
<input type="button" id="update" className="btn" value="수정하기" />
<input type="button" id="delete" className="btn" value="삭제하기" />
{/* 버튼 동작은 이벤트 핸들러로 대체 */}
</div>
</>
);
}
import axios from 'axios';
import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
export default function BoardDetail() {
const [board, setBoard] = useState({});
const [title, setTitle] = useState('');
const [contents, setContents] = useState('');
const { boardIdx } = useParams();
useEffect(() => {
axios
.get(`http://localhost:8080/api/board/${boardIdx}`)
.then((res) => {
res && res.data && setBoard(res.data);
res && res.data && setTitle(res.data.title);
res && res.data && setContents(res.data.contents);
})
.catch((err) => console.log(err));
}, []);
const navigate = useNavigate();
const listButtonClick = (e) => {
e.preventDefault();
navigate('/list');
};
const updateButtonClick = (e) => {
e.prevendDefault();
axios
.put(`http://localhost:8080/api/board/${boardIdx}`, { title, contents })
.then((res) => res && res.state === 200 && navigate('/list'))
.error((err) => console.log(err));
};
const deleteButtonClick = (e) => {
e.prevendDefault();
axios
.delete(`http://localhost:8080/api/board/${boardIdx}`)
.then((res) => res && res.state === 200 && navigate('/list'))
.error((err) => console.log(err));
};
return (
<>
<div className="container">
<h2>게시판 상세</h2>
<form id="frm" method="post">
<input type="hidden" name="boardIdx" value={board.boardIdx} />
<table className="board_detail">
<colgroup>
<col width="15%" />
<col width="*" />
<col width="15%" />
<col width="35%" />
</colgroup>
<tbody>
<tr>
<th>글번호</th>
<td>{board.boardIdx}</td>
<th>조회수</th>
<td>{board.hitCnt}</td>
</tr>
<tr>
<th>작성자</th>
<td>{board.createdId}</td>
<th>작성일</th>
<td>{board.createdDt}</td>
</tr>
<tr>
<th>제목</th>
<td colSpan="3">
<input
type="text"
id="title"
name="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</td>
</tr>
<tr>
<td colSpan="4">
<textarea
id="contents"
name="contents"
value={contents}
onChange={(e) => setContents(e.target.value)}
></textarea>
</td>
</tr>
</tbody>
</table>
</form>
<div className="file_list">
{board.fileInfoList &&
board.fileInfoList.map((file, index) => (
<p key={index}>
{file.originalFileName} ({file.fileSize}kb)
</p>
))}
</div>
<input type="button" id="list" className="btn" value="목록으로" onClick={listButtonClick} />
<input
type="button"
id="update"
className="btn"
value="수정하기"
onClick={updateButtonClick}
/>
<input
type="button"
id="delete"
className="btn"
value="삭제하기"
onClick={deleteButtonClick}
/>
</div>
</>
);
}
CORS 에러 !!
@Override
public void addCorsMappings(CorsRegistry registry) {
registry
.addMapping("/api/**")
.allowedOrigins("http://localhost:5173")
.allowedMethods("GET", "POST", "PUT", "DELETE");
}
<div className="file_list">
{board.fileInfoList &&
board.fileInfoList.map((file, index) => (
<p key={index}>
<a
href={`http://localhost:8080/api/board/file?boardIdx=${file.boardIdx}&idx=${file.idx}`}
>
{file.originalFileName} ({file.fileSize}kb)
</a>
</p>
))}
</div>
const fileDownload = (e, file) => {
e.prevendDefault();
const { boardIdx, idx, originalFileName } = file;
axios({
url: `http://localhost:8080/api/board/file?boardIdx=${boardIdx}&idx=${idx}`,
method: 'GET',
responseType: 'blob',
}).then((res) => {
const href = URL.createObjectURL(res.data);
const link = document.createElement('a');
link.href = href;
link.setAttribute('download', originalFileName);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(href);
});
};
...
<div className="file_list">
{board.fileInfoList &&
board.fileInfoList.map((file, index) => (
<p key={index}>
<a onClick={(e) => fileDownload(e, file)}>
{file.originalFileName} ({file.fileSize}kb)
</a>
</p>
))}
</div>
export default function BoardWrite() {
return (
<>
<div className="container">
<h2>게시판 등록</h2>
<form id="frm" method="post" action="/board/insertBoard.do" enctype="multipart/form-data">
<table className="board_detail">
<tbody>
<tr>
<td>제목</td>
<td>
<input type="text" id="title" name="title" />
</td>
</tr>
<tr>
<td colSpan="2">
<textarea id="contents" name="contents"></textarea>
</td>
</tr>
</tbody>
</table>
<input type="file" id="files" name="files" multiple="multiple" />
<input type="submit" id="submit" value="저장" className="btn" />
</form>
</div>
</>
);
}
import { useState } from 'react';
export default function BoardWrite() {
const [title, setTitle] = useState('');
const [contents, setContents] = useState('');
const changeTitle = (e) => setTitle(e.target.value);
const changeContents = (e) => setContents(e.target.value);
return (
<>
<div className="container">
<h2>게시판 등록</h2>
<form id="frm" method="post" action="/board/insertBoard.do" encType="multipart/form-data">
<table className="board_detail">
<tbody>
<tr>
<td>제목</td>
<td>
<input type="text" id="title" name="title" value={title} onChange={changeTitle} />
</td>
</tr>
<tr>
<td colSpan="2">
<textarea
id="contents"
name="contents"
value={contents}
onChange={changeContents}
></textarea>
</td>
</tr>
</tbody>
</table>
<input type="file" id="files" name="files" multiple="multiple" />
<input type="submit" id="submit" value="저장" className="btn" />
</form>
</div>
</>
);
}
참고: 폼 데이터와 함께 파일 업로드를 구현
cf_1
cf_2
import { useRef, useState } from 'react';
export default function BoardWrite() {
const [title, setTitle] = useState('');
const [contents, setContents] = useState('');
const changeTitle = (e) => setTitle(e.target.value);
const changeContents = (e) => setContents(e.target.value);
const refFiles = useRef();
const [files, setFiles] = useState([]);
const changeFiles = (e) => {
const files = e.target.files;
if (files.length > 3) {
alert('이미지는 최대 3개까지만 업로드 가능합니다.');
refFiles.current.value = '';
setFiles([]);
return;
}
setFiles([...files]);
};
return (
<>
<div className="container">
<h2>게시판 등록</h2>
<form id="frm" method="post" action="/board/insertBoard.do" encType="multipart/form-data">
<table className="board_detail">
<tbody>
<tr>
<td>제목</td>
<td>
<input type="text" id="title" name="title" value={title} onChange={changeTitle} />
</td>
</tr>
<tr>
<td colSpan="2">
<textarea
id="contents"
name="contents"
value={contents}
onChange={changeContents}
></textarea>
</td>
</tr>
</tbody>
</table>
<input
ref={refFiles}
onChange={changeFiles}
type="file"
id="files"
name="files"
multiple="multiple"
/>
<input type="submit" id="submit" value="저장" className="btn" />
</form>
</div>
</>
);
}
import axios from 'axios';
import { useRef, useState } from 'react';
export default function BoardWrite() {
const [title, setTitle] = useState('');
const [contents, setContents] = useState('');
const changeTitle = (e) => setTitle(e.target.value);
const changeContents = (e) => setContents(e.target.value);
const refFiles = useRef();
const [files, setFiles] = useState([]);
const changeFiles = (e) => {
const files = e.target.files;
if (files.length > 3) {
alert('이미지는 최대 3개까지만 업로드 가능합니다.');
refFiles.current.value = '';
setFiles([]);
return;
}
setFiles([...files]);
};
const formData = new FormData();
const data = { title, contents };
formData.append("board", JSON.stringify(data));
Object.values(files).forEach(file => formData.append("files", file));
const handlerSubmit = (e) => {
e.preventDefault();
axios({
method: 'POST',
url: 'http://localhost:8080/api/board',
data: FormData,
headers: { 'Content-Type': 'multipart/form-data' },
})
.then((res) => console.log(res))
.catch((err) => console.log(err));
};
return (
<>
<div className="container">
<h2>게시판 등록</h2>
<form id="frm" onSubmit={handlerSubmit}>
<table className="board_detail">
<tbody>
<tr>
<td>제목</td>
<td>
<input type="text" id="title" name="title" value={title} onChange={changeTitle} />
</td>
</tr>
<tr>
<td colSpan="2">
<textarea
id="contents"
name="contents"
value={contents}
onChange={changeContents}
></textarea>
</td>
</tr>
</tbody>
</table>
<input
ref={refFiles}
onChange={changeFiles}
type="file"
id="files"
name="files"
multiple="multiple"
/>
<input type="submit" id="submit" value="저장" className="btn" />
</form>
</div>
</>
);
}
import axios from 'axios';
import { useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
export default function BoardWrite() {
const [title, setTitle] = useState('');
const [contents, setContents] = useState('');
const changeTitle = (e) => setTitle(e.target.value);
const changeContents = (e) => setContents(e.target.value);
const refFiles = useRef();
const [files, setFiles] = useState([]);
const changeFiles = (e) => {
const files = e.target.files;
if (files.length > 3) {
alert('이미지는 최대 3개까지만 업로드 가능합니다.');
refFiles.current.value = '';
setFiles([]);
return;
}
setFiles([...files]);
};
const formData = new FormData();
const data = { title, contents };
formData.append('board', JSON.stringify(data));
Object.values(files).forEach((file) => formData.append('files', file));
const navigate = useNavigate();
const handlerSubmit = (e) => {
e.preventDefault();
axios({
method: 'POST',
url: 'http://localhost:8080/api/board',
data: formData,
headers: { 'Content-Type': 'multipart/form-data' },
})
.then((res) => res && res.status === 200 && navigate('/list'))
.catch((err) => console.log(err));
};
return (
<>
<div className="container">
<h2>게시판 등록</h2>
<form id="frm" onSubmit={handlerSubmit}>
<table className="board_detail">
<tbody>
<tr>
<td>제목</td>
<td>
<input type="text" id="title" name="title" value={title} onChange={changeTitle} />
</td>
</tr>
<tr>
<td colSpan="2">
<textarea
id="contents"
name="contents"
value={contents}
onChange={changeContents}
></textarea>
</td>
</tr>
</tbody>
</table>
<input
ref={refFiles}
onChange={changeFiles}
type="file"
id="files"
name="files"
multiple="multiple"
/>
<input type="submit" id="submit" value="저장" className="btn" />
</form>
</div>
</>
);
}