위 썸네일이 결론이다. 각 과정에서 어떤 스택을 사용할 것이며, 마주하는 문제에 대해 어떻게 최적화할 것인지만 달라지는 것이기에, flow 자체를 항상 염두에 두는 것이 중요하다.
✅ CRUD Blog MVP 모델 만들기
CRUD가 구현된 Blog MVP(Minimum Viable Product) 모델을 만들기 위해 다음과 같은 기술 스택이 동원되었다.
MongoDB
: 데이터베이스로 사용NestJS
: 백엔드 프레임워크React
: 프론트엔드 라이브러리NodeJS
: JavaScript 런타임TypeScript
: JavaScript의 상위 언어, 타입 안정성을 제공하기 위해 사용Material-UI
: Google에서 제공하는 UI 컴포넌트 라이브러리Tanstack-Query
: 서버 상태 관리를 위해 사용하는 라이브러리Vite
: 웹 애플리케이션 빌드 도구✅ 프로젝트 구조
blog/
├── backend/
│ ├── src/
│ │ ├── posts/
│ │ │ ├── posts.controller.ts
│ │ │ ├── posts.module.ts
│ │ │ ├── posts.service.ts
│ │ │ └── schemas/
│ │ │ └── post.schema.ts
│ │ └── app.module.ts
│ └── package.json
├── frontend/
│ ├── src/
│ │ ├── Blog.tsx
│ │ └── api.ts
│ └── package.json
└── README.md
✅ Backend 관련 설정
1. 백엔드 초기 설정
1-1. NestJS 프로젝트 생성
npm i -g @nestjs/cli
nest new backend
@nestjs/cli는 NestJS 애플리케이션을 생성하고 관리하는 데 필요한 CLI 도구다. 해당 CLI를 통해 NestJS 프로젝트를 효율적으로 설정, 구성 및 관리할 수 있다.
1-2. MongoDB 연결
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { PostsModule } from './posts/posts.module';
@Module({
imports: [
MongooseModule.forRoot("mongodb+srv://<username>:<password>@cluster.mongodb.net/"),
PostsModule,
],
})
export class AppModule {}
2. Post 관련 설정
2-1. app.module.ts
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { PostsModule } from './posts/posts.module';
@Module({
imports: [
MongooseModule.forRoot(
"mongodb+srv://<username>:<db_password>@cluster0.mongodb.net/"
),
PostsModule, // MongoDB 연결
],
})
export class AppModule {}
2-2. post.controller.ts
import { Controller, Get, Post, Body, Patch, Param, Delete } from '@nestjs/common';
import { PostsService } from './posts.service';
import { Post as PostModel } from './schemas/post.schema';
@Controller('posts')
export class PostsController {
constructor(private readonly postsService: PostsService) {}
@Get()
findAll() {
return this.postsService.findAll();
}
@Post()
create(@Body() post: PostModel) {
return this.postsService.create(post);
}
@Patch(':id')
update(@Param('id') id: string, @Body() post: Partial<PostModel>) {
return this.postsService.update(id, post);
}
@Delete(':id')
remove(@Param('id') id: string) {
return this.postsService.remove(id);
}
}
2-3. post.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import { Post, PostDocument } from './schemas/post.schema';
@Injectable()
export class PostsService {
constructor(@InjectModel(Post.name) private postModel: Model<PostDocument>) {}
async findAll(): Promise<Post[]> {
return this.postModel.find().exec();
}
async create(post: Post): Promise<Post> {
const newPost = new this.postModel(post);
return newPost.save();
}
async update(id: string, post: Partial<Post>): Promise<Post> {
const updatedPost = await this.postModel.findByIdAndUpdate(id, post, { new: true });
if (!updatedPost) {
throw new NotFoundException(`Post with ID ${id} not found`);
}
return updatedPost;
}
async remove(id: string): Promise<void> {
const result = await this.postModel.findByIdAndRemove(id);
if (!result) {
throw new NotFoundException(`Post with ID ${id} not found`);
}
}
}
2-4. post.schema.ts
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { Document } from 'mongoose';
export type PostDocument = Post & Document;
@Schema()
export class Post {
@Prop({ required: true })
title: string;
@Prop({ required: true })
content: string;
}
export const PostSchema = SchemaFactory.createForClass(Post);
✅ Frontend 관련 설정
1. 프론트엔드 초기 설정
1-1. React 프로젝트 생성
npx create-react-app frontend --template typescript
cd frontend
1-2. 필요한 패키지 설치
yarn add @mui/material @emotion/react @emotion/styled @mui/icons-material react-query
2. API 요청 및 UI 설정
2-1. api.ts
import axios from 'axios';
const API_URL = 'http://localhost:3000/posts';
export const fetchPosts = async () => {
const response = await axios.get(API_URL);
return response.data;
};
export const createPost = async (newPost: { title: string; content: string }) => {
const response = await axios.post(API_URL, newPost);
return response.data;
};
export const updatePost = async (id: string, updatedPost: { title: string; content: string }) => {
const response = await axios.patch(`${API_URL}/${id}`, updatedPost);
return response.data;
};
export const deletePost = async (id: string) => {
await axios.delete(`${API_URL}/${id}`);
};
2-2. Blog.tsx
import { useState, FormEvent } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { fetchPosts, createPost, updatePost, deletePost } from "./api";
import {
Container,
Typography,
TextField,
Button,
List,
ListItem,
ListItemText,
CircularProgress,
IconButton,
} from "@mui/material";
import EditIcon from "@mui/icons-material/Edit";
import DeleteIcon from "@mui/icons-material/Delete";
interface Post {
_id: string;
title: string;
content: string;
}
const Blog = () => {
const queryClient = useQueryClient();
const { data: posts, isLoading } = useQuery<Post[]>({
queryKey: ["posts"],
queryFn: fetchPosts,
});
const mutationCreate = useMutation({
mutationFn: (newPost: { title: string; content: string }) => createPost(newPost),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["posts"] });
},
});
const mutationUpdate = useMutation({
mutationFn: ({ id, updatedPost }: { id: string; updatedPost: { title: string; content: string } }) =>
updatePost(id, updatedPost),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["posts"] });
},
});
const mutationDelete = useMutation({
mutationFn: (id: string) => deletePost(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["posts"] });
},
});
const [title, setTitle] = useState<string>("");
const [content, setContent] = useState<string>("");
const [editingPostId, setEditingPostId] = useState<string | null>(null);
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
if (editingPostId) {
mutationUpdate.mutate({ id: editingPostId, updatedPost: { title, content } });
setEditingPostId(null);
} else {
mutationCreate.mutate({ title, content });
}
setTitle("");
setContent("");
};
const handleEdit = (post: Post) => {
setEditingPostId(post._id);
setTitle(post.title);
setContent(post.content);
};
const handleDelete = (id: string) => {
mutationDelete.mutate(id);
};
if (isLoading) return <CircularProgress />;
return (
<Container maxWidth="sm" sx={{ marginTop: 4 }}>
<Typography variant="h4" align="center" gutterBottom>
Blog
</Typography>
<form onSubmit={handleSubmit}>
<TextField
label="Title"
variant="outlined"
fullWidth
margin="normal"
value={title}
onChange={(e) => setTitle(e.target.value)}
required
/>
<TextField
label="Content"
variant="outlined"
fullWidth
multiline
rows={4}
margin="normal"
value={content}
onChange={(e) => setContent(e.target.value)}
required
/>
<Button variant="contained" color="primary" type="submit" fullWidth sx={{ marginTop: 2 }}>
{editingPostId ? "Update Post" : "Create Post"}
</Button>
</form>
<List>
{posts?.map((post) => (
<ListItem
key={post._id}
secondaryAction={
<>
<IconButton onClick={() => handleEdit(post)}>
<EditIcon />
</IconButton>
<IconButton onClick={() => handleDelete(post._id)}>
<DeleteIcon />
</IconButton>
</>
}
>
<ListItemText primary={post.title} secondary={post.content} />
</ListItem>
))}
</List>
</Container>
);
};
export default Blog;
✅ 구현된 모습
일단 돌아가게 만들어 놓은 모델이라 이해의 수준이 굉장히 낮은 상태라고 볼 수 있고, 따라서 사실상 이제 공부 시작이다.
✅ 회고
문유석 판사의 <개인주의자 선언>에는 다음과 같은 문장이 등장한다.
실제 인간세계에서 벌어지는 일들 중 상당수는 인과관계도, 동기도, 선악 구분도 명확하지 않다. 신문기사처럼 몇 문장으로 쉽게 설명하기 어려운 일이 참으로 많다. 그래서 흔히들 생각하는 것과 달리 냉정한 '팩트' 집합으로 보이는 신문기사보다 주관적인 내면 고백 덩어리로 보이는 문학이 실제 인간이 저지르는 일들을 더 잘 설명해 줄 때가 많다.
학교를 다니고 있지만 어쨌든 아기 취준생으로서, 현재의 과정을 타인과의 경쟁으로 인식할 수밖에 없었던 시간이 있었다. 그런데 어차피 못 이긴다. 세상에는 똑똑한 인간들이 너무 많다. 그래서 유일해지는 것이 최고의 전략이다. 물론 이 얘기는 준비하는 분야에 따라 달라진다 당연히
어차피 비슷한 코드를 작성할 뿐이라면, 나만의 이유가 중요하다. 'why'가 끊임없이 쏟아져 나와야 하고, 오... 이건 내가 잘할 수 있는 부분인 것 같기도 하다. 잡생각이 많으니까 정리만 잘 하면 되지 않을까. 솔직한 개발을 하고 솔직한 인생을 살고 싶다.