[LG CNS AM CAMP 1기] 백엔드 II 6 | SpringBoot & React

letthem·2025년 2월 3일
0

LG CNS AM CAMP 1기

목록 보기
22/31
post-thumbnail

REST(REpresentational State Transfer)

REST(Representational State Transfer)는 분산 시스템을 설계하기 위한 소프트웨어 아키텍처 스타일로, 특히 HTTP 프로토콜을 기반으로 한 웹 서비스 설계에서 주로 사용된다. REST는 클라이언트와 서버 간 통신에서 리소스의 상태와 표현(Representation)을 전송한다는 개념에서 이름이 유래되었다.

REST는 2000년, 로이 필딩(Roy Fielding)의 박사 논문에서 처음으로 제안되었다. 로이 필딩은 HTTP와 URI 설계에 깊이 관여한 개발자로, REST는 그의 논문에서 웹의 확장성과 상호운용성을 극대화하기 위해 정의된 아키텍처 스타일이다. 이 개념은 특히 웹의 발전과 HTTP 프로토콜 표준화 과정에서 큰 영향을 미쳤다.

REST의 주요 목적은 시스템 간 상호운용성을 높이고, 단순하면서도 확장 가능한 웹 애플리케이션 설계를 지원하는 데 있다.

REST API 성숙도 모델


6가지 제약 조건

=> 이를 준수해야 진정한 RESTful 시스템

  • 클라이언트-서버 구조: 클라이언트와 서버를 명확히 분리하여 각자의 역할에 집중할 수 있도록 한다.
  • 상태 비저장성 (Stateless): 서버는 클라이언트의 상태 정보를 저장하지 않는다. 각 요청은 독립적으로 처리되어야 하며, 필요한 모든 정보는 요청에 포함되어야 한다. 요청 건 by 건 => 토큰 기반 인증 도입
  • 캐시 가능성 (Cacheability): 서버 응답이 캐시될 수 있어야 하며, 클라이언트는 이를 활용하여 네트워크 트래픽과 서버 부하를 줄일 수 있다.
  • 계층화 시스템 (Layered System): 클라이언트와 서버 간에 여러 계층을 둘 수 있으며, 각 계층은 독립적으로 동작한다.
  • 인터페이스 일관성 (Uniform Interface): REST 시스템은 일관된 인터페이스를 제공해야 하며, 이를 통해 리소스와 상호작용할 수 있어야 한다.
  • 주문형 코드 (Code on Demand): 필요에 따라 서버가 클라이언트에 코드를 전송하여 실행할 수 있다. 예: JavaScript, 플러그인 등.

RESTful 게시판

기능 ⬇️RESTful URI요청방식(method)BoardController URI
게시판 목록 조회/boardGET/board/openBoardList.do
게시판 작성 화면/board/writeGET/board/openBoardWrite.do
게시판 저장 처리/board/writePOST/board/insertBoard.do
게시판 상세 조회/board/글번호GET/board/openBoardDetail.do
게시판 수정 처리/board/글번호PUT/board/updateBoard.do
게시판 삭제 처리/board/글번호DELETE/board/deleteBoard.do
첨부파일 다운로드/board/file?boardIdx=000&idx=000GET/board/downloadBoardFile.do

RestController

@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();
    }
}

BoardApplication.java 파일에 HiddenHttpMethodFilter를 빈으로 추가

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();
	}
}


⭐️⭐️⭐️⭐️⭐️ REST API

=> 화면을 제공하지 않고 데이터만 제공

RestApiBoardController

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 Test

목록 조회

등록

상세 조회

수정

삭제


REST API 문서화

Swagger

SpringDoc

스프링 부트 프로젝트를 사용해 API 문서 생성을 자동화

의존성 추가

build.gradle에 추가

// 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#/

문서 경로 변경

application.properties 파일에 설정 정보를 추가

springdoc.swagger-ui.path=/swagger-ui.html
springdoc.api-docs.path=/api-docs

문서에 포함할 RestController 지정

application.properties

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"))
        );
    }
}

설정 속성

API 명세에 내용을 추가

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();
    }
}

HTTP 상태 코드

상세 조회 기능에 조회 결과 여부에 따라 상태 코드를 반환하도록 수정

RestApiBoardController 수정 => ResponseEntity를 사용하도록 변경

// 상세 조회
@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);
    }
}

BoardServiceImp 수정 => 게시판 정보가 존재하는 경우에만 첨부 파일을 조회하도록 수정

@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;
}

용도에 맞게 DTO를 세분화

하나의 객체를 이용해서 사용자 입력 부터 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를 추가

입력 부분을 입력 전용 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";
}

불필요한 데이터가 들어오고 나가는 것을 방지

응답 DTO

RestApiBoardController를 확인해 보면, DB에서 조회한 내용을 그대로 전달

// 목록 조회
@Operation(summary = "게시판 목록 조회", description = "등록된 게시물 목록을 조회해서 반환합니다.")
@GetMapping("/board")
public List<BoardDto> openBoardList() throws Exception {
    return boardService.selectBoardList();
}

게시판 조회 결과를 응답으로 전달할 DTO 객체를 정의

BoardListResponse

package board.dto;

import lombok.Data;

@Data
public class BoardListResponse {
    private int boardIdx;
    private String title;
    private int hitCnt;
    private String createdDt;
}

RestApiBoardController 게시판 목록 조회 API를 수정

// 목록 조회
@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;
}

ModelMapper를 이용한 DTO 간 값 변환

build.gradle 파일에 의존 추가

implementation group: 'org.modelmapper', name: 'modelmapper', version: '3.2.2'

RestController

// 저장 처리
@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";
}

RestApiBoardController

// 목록 조회
@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;
}

RestApiBoardController ⇒ 람다식으로 변경

// 목록 조회
@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;
}

REST API 활용 ⇒ React App 연동

프로젝트 생성

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 도 적용

App.js 에 RouterProvider로 라우팅 설정

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.jsx

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));
}, []);

🚨 CORS 오류 발생

http://localhost:8080/api/board		⇐ 스프링부트 서버
http://localhost:5173				⇐ 리액트 개발 서버
~~~~   ~~~~~~~~~ ~~~~
스킴   호스트    포트
~~~~~~~~~~~~~~~~~~~~~
Origin = 출처 = 기원

응답헤더에 Access-Control-Allow-Origin 이 없다.
그래서 브라우저가 주소를 읽어오긴 하지만 화면에 표시를 못한다.
=> REST API에 CORS 설정을 해줘야 한다.

REST API에 CORS 설정

방법1. 컨트롤러 클래스에 @CrossOrigin 어노테이션을 추가

RestApiBoardController

@CrossOrigin(origins = "http://localhost:5173")
public class RestApiBoardController {
	...
}

방법2. 컨트롤러 메서드에 @CrossOrigin 어노테이션을 추가

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 {

⭐️⭐️⭐️⭐️⭐️ 방법3. WebMvcConfigurer 구현 클래스에 addCorsMappings 메서드를 오버라이딩

WebMvcConfiguration

@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");
    }
}

가져온 데이터를 출력

BoardList.jsx

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>
    </>
  );
}

BoardDetail.jsx

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>
    </>
  );
}

게시판 상세 조회 결과를 반영하도록 수정

BoardDetail.jsx

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>
    </>
  );
}

제목과 내용을 수정할 수 있도록 수정

BoardDetail.jsx

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>
    </>
  );
}

⭐️⭐️⭐️⭐️⭐️ 버튼 기능 구현

BoardDetail.jsx

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 에러 !!

WebMvcConfiguration 클래스에 POST, PUT, DELETE 메서드를 허용하도록 수정

@Override
public void addCorsMappings(CorsRegistry registry) {
    registry
            .addMapping("/api/**")
            .allowedOrigins("http://localhost:5173")
            .allowedMethods("GET", "POST", "PUT", "DELETE");
}

첨부파일 다운로드 기능

방법1. 링크 이용

<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>

방법2. 이벤트 핸들러 이용

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>

저장 기능

BoardWrite.jsx

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>
    </>
  );
}

0개의 댓글

관련 채용 정보