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