Backend
home
🕠

2024. 9. 3 (rest-api)

생성일
2025/01/24 05:52
태그

Rest-api를 활용한 블로그 구현

기능 구현

React
PostCard.jsx
import { Avatar, Card, CardContent, CardHeader, CardMedia, Typography, useTheme } from "@mui/material"; import { useNavigate } from "react-router-dom"; const PostCard = ({ post }) => { const navigate = useNavigate(); const theme = useTheme(); return ( <Card sx={{ maxWidth: 345, cursor: "pointer" }} onClick={() => navigate(`/post/${post.id}`)}> <CardHeader avatar={ <Avatar sx={{ bgcolor: theme.palette.main.main }}> {post.id} </Avatar> } title={post.title} subheader={post.createdAt} /> {/* <CardMedia component="img" height="194" image="" alt="게시글 이미지" /> <CardContent> <Typography gutterBottom sx={{ color: 'text.secondary', fontSize: 12 }}> {post.author.name} - {post.author.email} </Typography> <Typography variant="body2" sx={{ color: 'text.secondary' }}> {post.content} </Typography> </CardContent> */} </Card> ); } export default PostCard;
JavaScript
복사
PostForm.jsx
import { Button, Grid2, TextField, Typography } from "@mui/material"; import axios from "axios"; import { useForm } from "react-hook-form"; import { useNavigate } from "react-router-dom"; const PostForm = () => { const { register, handleSubmit, formState: { errors }, } = useForm(); const navigate = useNavigate(); const onSubmit = async (data) => { data.authorId = 1; // 로그인 기능 추가 전까지 임의로 작성 console.log("서버에 게시글 요청을 보낼 데이터 : ", data) // 서버에 요청을 보내고 try { const res = await axios.post("http://localhost:8080/api/post", data); // 정상적인 응답이면 -> 게시글 목록으로 보내주자 navigate("/post"); } catch (error) { // 비정상이면 -> 에러페이지 ㄱㄱ navigate("/error"); } }; // watch("이름") -> 해당 입력 "이름"의 값을 가져온다. // console.log(watch("example")); // watch input value by passing the name of it return ( <> {/* "handleSumbit 함수는 onSubmit 동작 전에 입력값에 대한 유효값 검증(validation)을 한다." */} <form onSubmit={handleSubmit(onSubmit)}> <Grid2 container direction={"column"} spacing={3} sx={ { width : {xs: '250px', sm: '500px'}} } > {/* ...register("이름") -> 값이 전달된다. */} {/* 필수값, 유효성 검증 등을 추가할 수 있다. */} {/* 제목 (필수, 50자 이내) */} <TextField variant="outlined" label="제목" error={errors.title && true} helperText={errors.title && "제목은 필수값이며, 50자 이내로 작성해야 합니다."} {...register("title", { required: true, maxLength: 50 })} fullWidth /> {/* 내용 (필수) */} <TextField id="outlined-multiline-flexible" label="내용" multiline rows={4} error={errors.content ? true : false} helperText={errors.content && "내용은 필수값입니다."} {...register("content", { required: true })} /> {/* 비밀번호 (필수) */} <TextField id="outlined-password-input" label="비밀번호" type="password" autoComplete="current-password" error={errors.password ? true : false} helperText={errors.password && "비밀번호는 필수값이며, 영어와 숫자를 포함해 8자리 이상 20자리 이하로 작성해주세요."} {...register("password", { required: true, pattern: /^(?=.*[a-zA-Z])(?=.*[0-9]).{8,20}$/ })} fullWidth /> {/* 유효값 검증 실패 시 나타나는 문구 */} <Button fullWidth type="submit" variant="outlined" color="main">제출</Button> </Grid2> </form> </> ); } export default PostForm;
JavaScript
복사
백엔드 연동
const onSubmit = async (data) => { data.authorId = 1; // 로그인 기능 추가 전까지 임의로 작성 console.log("서버에 게시글 요청을 보낼 데이터 : ", data) // 서버에 요청을 보내고 try { const res = await axios.post("http://localhost:8080/api/post", data); // 정상적인 응답이면 -> 게시글 목록으로 보내주자 console.log(res); } catch (error) { // 비정상이면 -> 에러페이지 ㄱㄱ } };
JavaScript
복사
App.js
import logo from './logo.svg'; import './App.css'; import Layout from './components/layouts/Layout'; import { Route, Routes } from 'react-router-dom'; import Post from './components/posts/Post'; import PostForm from './components/posts/PostForm'; import PostDetail from './components/posts/PostDetail'; function App() { return ( <Layout> <Routes> <Route path='/' element={<h1></h1>} /> <Route path='/post' element={<Post />} /> <Route path='/post/write' element={<PostForm/>} /> <Route path='/post/:postId' element={<PostDetail />} /> <Route path='/search' element={<h1>검색</h1>} /> <Route path='/error' element={<h1>에러</h1>} /> <Route path='*' element={<h1>낫 파운드</h1>} /> </Routes> </Layout> ); } export default App;
JavaScript
복사
SpringBoot
글 추가 테스트
ImageFile 엔티티 생성
package com.kosta.entity; import jakarta.persistence.*; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; @Entity @Data public class ImageFile { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(updatable = false) private Long id; @Column(nullable = false, name = "original_name") private String originFileName; @Column(nullable = false, name = "saved_name") private String savedName; @Column(nullable = false, name = "file_size") private int fileSize; }
Java
복사
PostController
@PostMapping("") /* { "title" : "제목", "content" : "내용", "password" : "1234", "authorId" : 1 } */ public ResponseEntity<PostResponse> writePost(PostRequest post) { PostResponse savedPost = postService.insertPost(post); return ResponseEntity.status(HttpStatus.CREATED).body(savedPost); }
Java
복사
Post Entity
package com.kosta.entity; import jakarta.persistence.*; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import org.springframework.data.annotation.CreatedDate; import org.springframework.data.annotation.LastModifiedDate; import org.springframework.data.jpa.domain.support.AuditingEntityListener; import java.time.LocalDateTime; @Entity @EntityListeners(AuditingEntityListener.class) // 생성, 수정 날짜 추적 -> Application.java (@EnableJpaAuditing) @Data // toString, getter, setter, equals, hashCode @Builder @AllArgsConstructor @NoArgsConstructor public class Post { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(updatable = false) private Long id; @Column(nullable = false) private String title; @Column(nullable = false, length = 1000) private String content; @Column(nullable = false) private String password; @JoinColumn(name = "author_id", nullable = false) @ManyToOne private User author; @JoinColumn(name = "image_id", nullable = true) @ManyToOne private ImageFile image; @CreatedDate @Column(name = "created_at") private LocalDateTime createdAt; @LastModifiedDate @Column(name = "updated_at") private LocalDateTime updatedAt; }
Java
복사
결과 확인
{ "id": 10, "title": "제목", "content": "내용", "author": { "id": 1, "email": "test@gmail.com", "name": "민성" }, "createdAt": "2024-09-03", "updatedAt": "2024-09-03" }
JSON
복사
ImageFileRepository
package com.kosta.repository; import com.kosta.entity.ImageFile; import com.kosta.entity.Post; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; @Repository public interface ImageFileRepository extends JpaRepository<ImageFile, Long>{ }
Java
복사
PostServiceImpl - insertPost
@Override public PostResponse insertPost(PostRequest postDTO, MultipartFile file) { String originalFileName = file.getOriginalFilename(); Long fileSize = file.getSize(); // 파일을 저장하고, String savedFileName = FileUtils.fileUpload(file); if (savedFileName != null) { // DB에도 추가하고 ImageFile imageFile = ImageFile .builder() .originFileName(originalFileName) .savedName(savedFileName) .fileSize(fileSize) .build(); imageFileRepository.save(imageFile); } User user = userRepository.findById(postDTO.getAuthorId()) .orElseThrow(() -> new IllegalArgumentException("해당 유저를 찾을 수 없음")); Post post = postDTO.toEntity(user); Post savedPost = postRepository.save(post); PostResponse result = PostResponse.toDTO(savedPost); return result; }
Java
복사
공통 Util - 이미지 파일 고려
package com.kosta.util; import com.kosta.entity.ImageFile; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import org.springframework.web.multipart.MultipartFile; import java.io.InputStream; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.StandardCopyOption; import java.util.UUID; @Component public class FileUtils { // application.yml 파일의 location 정보 가져오기 @Value("${spring.upload.location}") private String uploadPath; public ImageFile fileUpload(MultipartFile file) { try { // 원본 파일명 가져오기 String originalFileName = file.getOriginalFilename(); // 파일 크기명 가져오기 Long fileSize = file.getSize(); // 새로운 파일명 만들어주기 String savedFileName = UUID.randomUUID() + "_" + originalFileName; // 해당 경로에 파일 이미지 업로드 InputStream inputStream = file.getInputStream(); Path path = Paths.get(uploadPath).resolve(savedFileName); Files.copy(inputStream, path, StandardCopyOption.REPLACE_EXISTING); // 이상없으면 새로운 파일명 반환 return ImageFile.builder() .originFileName(originalFileName) .savedName(savedFileName) .fileSize(fileSize) .build(); } catch (Exception e) { e.printStackTrace(); // 이상있으면 null 반환 return null; } } }
Java
복사
파일 다운로드
Controller
// 파일 다운로드 @GetMapping("/download/{imageId}") public ResponseEntity<Resource> downloadImage(@PathVariable("imageId") Long id) throws MalformedURLException { FileDTO fileDTO = postService.getImageByImageId(id); UrlResource resource = new UrlResource("file:" + uploadPath + "\\" + fileDTO.getSaved()); String fileName = UriUtils.encode(fileDTO.getOrigin(), StandardCharsets.UTF_8); String contentDisposition = "attachment; filename=\"" + fileName + "\""; return ResponseEntity.ok() .header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition) .body(resource); }
Java
복사
Service
@Override public FileDTO getImageByImageId(Long id) { ImageFile imageOpt = imageFileRepository.findById(id) .orElseThrow(() -> new IllegalArgumentException("해당 아이디에 맞는 파일 없음")); return FileDTO.toDTO(imageOpt); }
Java
복사
FileDTO
package com.kosta.domain; import com.kosta.entity.ImageFile; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @Data @AllArgsConstructor @Builder public class FileDTO { private String origin; private String saved; private String kbSize; private Long size; public static FileDTO toDTO(ImageFile imageFile) { if (imageFile == null) { return null; } return FileDTO.builder() .origin(imageFile.getOriginFileName()) .saved(imageFile.getSavedName()) .kbSize(((Double) (imageFile.getFileSize() / 1024.0)).toString()) .build(); } }
Java
복사
다운로드 (react)
import { Button, Grid2, TextField, Typography } from "@mui/material"; import axios from "axios"; import { useEffect } from "react"; import { useForm } from "react-hook-form"; import { useNavigate, useParams } from "react-router-dom"; const PostForm = () => { const { register, handleSubmit, formState: { errors }, setValue } = useForm(); const navigate = useNavigate(); const { postId } = useParams(); // 요청을 보내야 함 axios (http://localhost:8080/api/post/id) const getPost = async () => { try { const res = await axios.get(`http://localhost:8080/api/post/${postId}`); const data = res.data; console.log(data); setValue("title", data.title); setValue("content", data.content); } catch (error) { navigate("/error"); } } useEffect(() => { if (postId) { getPost(); } }, []); // 수정 const onSubmit = async (data) => { data.image = data.image[0]; data.authorId = 1; // 로그인 기능 추가 전까지 임의로 작성 const formData = new FormData(); // 이미지 등록을 위한 로직 구성 Object.keys(data).forEach(key => { formData.append(key, data[key]); }); try { if (postId) { formData.append("id", postId); const res = await axios.patch("http://localhost:8080/api/post", data); } else { // 서버에 요청을 보내고 - const res = await axios.post( "http://localhost:8080/api/post", formData, { headers: {"Content-Type": "multipart/form-data"} }); } // 정상적인 응답이면 -> 게시글 목록으로 보내주자 navigate("/post"); } catch (error) { // 비정상이면 -> 에러페이지 ㄱㄱ navigate("/error"); } }; // watch("이름") -> 해당 입력 "이름"의 값을 가져온다. // console.log(watch("example")); // watch input value by passing the name of it return ( <> {/* "handleSumbit 함수는 onSubmit 동작 전에 입력값에 대한 유효값 검증(validation)을 한다." */} <form onSubmit={handleSubmit(onSubmit)}> <Grid2 container direction={"column"} spacing={3} sx={ { width : {xs: '250px', sm: '500px'}} } > {/* ...register("이름") -> 값이 전달된다. */} {/* 필수값, 유효성 검증 등을 추가할 수 있다. */} {/* 제목 (필수, 50자 이내) */} <TextField variant="outlined" label="제목" error={errors.title && true} helperText={errors.title && "제목은 필수값이며, 50자 이내로 작성해야 합니다."} {...register("title", { required: true, maxLength: 50 })} fullWidth /> {/* 내용 (필수) */} <TextField id="outlined-multiline-flexible" label="내용" multiline rows={4} error={errors.content ? true : false} helperText={errors.content && "내용은 필수값입니다."} {...register("content", { required: true })} /> {/* 이미지 파일 (선택) */} <TextField type="file" label="이미지 파일" {...register("image", { required: false })} slotProps={{ htmlInput : {"accept": "image/*"} }} /> {/* 비밀번호 (필수) */} <TextField id="outlined-password-input" label="비밀번호" type="password" autoComplete="current-password" error={errors.password ? true : false} helperText={errors.password && "비밀번호는 필수값이며, 영어와 숫자를 포함해 8자리 이상 20자리 이하로 작성해주세요."} {...register("password", { required: true, pattern: /^(?=.*[a-zA-Z])(?=.*[0-9]).{8,20}$/ })} fullWidth /> {/* 유효값 검증 실패 시 나타나는 문구 */} <Button fullWidth type="submit" variant="outlined" color="main">제출</Button> </Grid2> </form> </> ); } export default PostForm;
JavaScript
복사
import axios from "axios"; import { useEffect, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { Avatar, Button, Card, CardActions, CardContent, CardHeader, CardMedia, Typography, useTheme } from "@mui/material"; import DeleteIcon from "@mui/icons-material/Delete"; import EditIcon from "@mui/icons-material/Edit"; import Swal from "sweetalert2"; const PostDetail = () => { // id 값 가져오기 (주소창에 있음) const { postId } = useParams(); const navigate = useNavigate(); const theme = useTheme(); const [post, setPost] = useState(); // 요청을 보내야 함 axios (http://localhost:8080/api/post/id) const getPost = async () => { try { const res = await axios.get(`http://localhost:8080/api/post/${postId}`); const data = res.data; setPost(data); } catch (error) { navigate("/error"); } } // 가져온 정보를 예쁘게 화면에 뿌려주자 useEffect(() => { getPost(); }, []); // 삭제 기능 const handleDelete = async () => { const result = await Swal.fire({ title: "삭제를 원해?", input: "password", inputLabel: "비밀번호", inputPlaceholder: "비밀번호를 입력하세요", inputAttributes: { maxlength: "20", autocapitalize: "off", autocorrect: "off" }, showCancelButton: true, showCloseButton: true }); // if (result.dismiss === "close") { // console.log("닫았네"); // } else if (result.dismiss === "cancel") { // console.log("취소했네"); // } const password = result.value; if (password) { const authorId = 1; // 로그인 기능 전까지 임시 작성자 아이디 try { const res = await axios.delete( `http://localhost:8080/api/post/${post.id}`, { data: { password, authorId } }); Swal.fire({ title: "잘했어요!", text: `${post.id}번 게시물이 삭제되었습니다.`, icon: "success" }); navigate("/post"); } catch (error) { navigate("/error"); } } } return ( <> <h1>게시물 상세정보</h1> {post && <Card sx={{ width: { xs: '250px', sm: '500px', md: '800px' } }}> <CardHeader avatar={ <Avatar sx={{ bgcolor: theme.palette.main.main }}> {post.id} </Avatar> } title={post.title} subheader={post.createdAt} /> { post.image && <CardMedia component="img" height="194" image={`http://localhost:8080/img/${post.image.saved}`} alt="게시글 이미지" /> } <CardContent> <Typography gutterBottom sx={{ color: 'text.secondary', fontSize: 12 }}> {post.author.name} - {post.author.email} </Typography> <Typography variant="body2" sx={{ color: 'text.secondary' }}> {post.content} </Typography> </CardContent> <CardActions> <Button variant="contained" color="bg2" size="small" startIcon={<EditIcon />} onClick={() => navigate(`/post/modify/${post.id}`)} > 수정 </Button> <Button variant="contained" color="sub" size="small" startIcon={<DeleteIcon />} onClick={handleDelete} > 삭제 </Button> </CardActions> </Card> } </> ); } export default PostDetail;
JavaScript
복사