[TIL/Nest] 2024/09/20

원민관·2024년 9월 20일
0

[TIL]

목록 보기
158/159
post-thumbnail

위 썸네일이 결론이다. 각 과정에서 어떤 스택을 사용할 것이며, 마주하는 문제에 대해 어떻게 최적화할 것인지만 달라지는 것이기에, flow 자체를 항상 염두에 두는 것이 중요하다.

✅ CRUD Blog MVP 모델 만들기

CRUD가 구현된 Blog MVP(Minimum Viable Product) 모델을 만들기 위해 다음과 같은 기술 스택이 동원되었다.

  1. MongoDB: 데이터베이스로 사용
  2. NestJS: 백엔드 프레임워크
  3. React: 프론트엔드 라이브러리
  4. NodeJS: JavaScript 런타임
  5. TypeScript: JavaScript의 상위 언어, 타입 안정성을 제공하기 위해 사용
  6. Material-UI: Google에서 제공하는 UI 컴포넌트 라이브러리
  7. Tanstack-Query: 서버 상태 관리를 위해 사용하는 라이브러리
  8. 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'가 끊임없이 쏟아져 나와야 하고, 오... 이건 내가 잘할 수 있는 부분인 것 같기도 하다. 잡생각이 많으니까 정리만 잘 하면 되지 않을까. 솔직한 개발을 하고 솔직한 인생을 살고 싶다.

profile
Write a little every day, without hope, without despair ✍️

0개의 댓글