반응형

아티클의 제목과 같은 에러가 발생했는데, 전문은 아래와 같다.

 

 

Logger로 기록한 error

[Nest] 64896  - 2022. 10. 13. 오후 8:00:56   ERROR [ExceptionHandler] Nest can't resolve dependencies of the ScriptService (FirstRepository, SecondRepository, ThirdRepository, ?). Please make sure that the argument SentenceRepository at index [3] is available in the ScriptModule context.

Potential solutions:
- If SentenceRepository is a provider, is it part of the current ScriptModule?
- If SentenceRepository is exported from a separate @Module, is that module imported within ScriptModule?
  @Module({
    imports: [ /* the Module containing SentenceRepository */ ]
  })

 

 

에러 내용 해석

Nest가 ScriptService에 ?에 해당하는 것의 의존성 주입을 할 수 없다는 뜻이다.

나는 Potential solutions 중 첫번째에 해당하는 간단한 오류였다.

ScriptModule에 SentenceRepository를 import하지 않고, ScriptService의 생성자에 추가했기 때문에 발생한 오류다.

 

 

 

해결 방법

// script.module.ts

@Module({
  imports: [
    TypeOrmModule.forFeature([
      ScriptRepository, ScriptExampleRepository, UserRepository, NewsRepository
    ]),
  ],
  controllers: [ScriptController],
  providers: [ScriptService, JwtModule]
})
export class ScriptModule {}

위와 같이 module에, service에서 사용한 repository를 import함으로써

의존성 주입에서 발생한 error를 수정할 수 있었다.

 

 

 

 

반응형
반응형

User와 News 사이에 즐겨찾기(Favorite)기능을 구현하고자 한다.

ManyToMany 관계를 설정하고

특정 API에 user와 news 정보를 보내면 관계가 추가되는 기능을 구현해야 한다.

 

1. 관계를 설정하고 (기본, lazy, eager 설명)

2. 기능을 구현하기까지 과정을 정리해보겠다. (lazy 관계 사용)

 

 

 

ManyToMany 관계 설정

관계는 아래와 같이 설정했다.

// user.entity.ts
  @ManyToMany(() => News, (news) => news.favorites)
  @JoinTable()
  favorites: Promise<News[]>;
// news.entity.ts
  @ManyToMany(() => User, (user) => user.favorites)
  favorites: User[]

여기서 드는 의문은,

favorites의 타입이 왜 Promise 형태인지이다. (이는 lazy 설정을 사용하기 위함이다!)

 

TypeORM에서 ManyToMany를 구현하는 형식은 세 가지가 있는데,

1. 기본 설정

2. lazy 관계

3. eager 관계 이다.

 

아래에서 각각의 설정에 대해 설명해보고, 필자가 왜 lazy 관계를 사용했는지도 설명할 것이다.

 

 

 

1. 기본 설정

기본 설정은 아래와 같이 관계를 설정한다.

// user.entity.ts
  @ManyToMany(() => News, (news) => news.favorites)
  @JoinTable()
  favorites: News[];
// news.entity.ts
  @ManyToMany(() => User, (user) => user.favorites)
  favorites: User[]

위와 같은 관계에서,

User를 초기 생성할 때 favorites을 선언하는 케이스는 예시가 많이 나온다.

 

const category1 = new Category()
category1.name = "animals"
await dataSource.manager.save(category1)

const category2 = new Category()
category2.name = "zoo"
await dataSource.manager.save(category2)

const question = new Question()
question.title = "dogs"
question.text = "who let the dogs out?"
question.categories = [category1, category2]
await dataSource.manager.save(question)

위의 예시는 TypeORM docs에서 발췌한 코드인데,

역시 선언하고 저장하는 형식이다.

 

그런데.. 이미 저장된 favorites을 불러와 정보를 추가하는 로직은

도저히 예시를 찾을 수가 없는 것이다.

그러다 발견한 것이 아래 방식이다!

 

 

 

 

2. eager 관계

// user.entity.ts
  @ManyToMany(() => News, (news) => news.favorites, {
    eager: true,
  })
  @JoinTable()
  favorites: News[];
// news.entity.ts
  @ManyToMany(() => User, (user) => user.favorites)
  favorites: User[]

위와 같이 관계를 설정하면,

어느 한 쪽 정보를 조회 시, favorites에 리스트로 저장된 반대편 정보도 함께 조회된다.

 

하지만, 필요하지 않은 경우에도 (많을지도 모르는 양의) 반대편 정보를 조회해야 한다는 단점이 있다.

 

 

 

 

 

3. lazy 관계

// user.entity.ts
  @ManyToMany(() => News, (news) => news.favorites)
  @JoinTable()
  favorites: Promise<News[]>;
// news.entity.ts
  @ManyToMany(() => User, (user) => user.favorites)
  favorites: User[]

 

lazy 관계를 맺으면, 평소에는 서로의 정보를 저장하지 않고

참조 시에만 불러오는 형식을 취한다.

 

즉, User의 입장에서

favorites을 조회하지 않는(News를 참조하지 않는) 경우에는 favorites에 정보가 저장되지 않고,

favorites을 조회하는(News를 참조하는) 경우에는 favorites에 정보가 저장된다.

 

이 정보는 user_favorite_news라고 하는 새로 생성된 Join 테이블에서 가져 온다.

 

lazy 관계를 사용하는 방식은, 위에서처럼 관계를 Promise로 정의하면 된다.

꺼내서 사용하는 경우에도 Promise로 다뤄줘야 하는데,

이는 아래에서 기능을 구현할 때 다시 언급하겠다.

 

 

 

 

기능 구현!

위에서 언급한 대로,

User가 가지고 있는 favorites(News의 리스트)에 원하는 News를 넣는 기능을 구현해야 한다.

그래서 아래와 같이 구현했다!

 

// user.repository.ts
    async addFavoriteNews(user: User, news: News): Promise<User> {
      const favorites = await user.favorites;
      favorites.push(news);
      user.save();
      return user;
    }
    
    async deleteFavoriteNews(user: User, news: News): Promise<User> {
      let favorites = await user.favorites;
      const newsId: number = news.id;

      user.favorites = Promise.resolve(
        favorites.filter((news) => {
          return news.id !== newsId;
        }),
      );

      user.save();
      return user;

    }

user.favorites은 Promise 객체이므로 await으로 불러와야 함에 주의해야 한다.

또한, 위와 같이 수정 시에도 Promise.resolve 형태로 사용해야 한다!

 

그 점만 주의한다면,

- 참조 시에만 데이터를 불러와 경제적이고,

- API 호출 시마다 관계를 추가할 수 있는

좋은 기능을 구현할 수 있었다!

 

위에서 return한 user를 response하면 아래와 같다!

{
    "status": 200,
    "success": true,
    "message": "좋아하는 뉴스 토글 성공",
    "data": {
        "socialId": "123",
        "nickname": "test",
        "__favorites__": [
            {
                "title": "Test title3",
                "category": "사회",
                "id": 3
            },
            {
                "title": "Test title4",
                "category": "연예",
                "id": 4
            }
        ],
        "__has_favorites__": true
    }
}

(status, success, message는 임의로 정한 규격이니 무시해도 좋다.)

__favorites__, __has_favorites__라는 속성으로 표현된다!

 

뭐니뭐니해도 공식문서가 짱이다.

 

 

 

 

참고 자료 :

https://orkhan.gitbook.io/typeorm/docs/eager-and-lazy-relations

 

Eager and Lazy Relations - typeorm

Note: if you came from other languages (Java, PHP, etc.) and are used to use lazy relations everywhere - be careful. Those languages aren't asynchronous and lazy loading is achieved different way, that's why you don't work with promises there. In JavaScrip

orkhan.gitbook.io

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

반응형
반응형
 

[무료] 따라하며 배우는 NestJS - 인프런 | 강의

이 강의를 통해 NestJS에 대해서 배울 수 있습니다., - 강의 소개 | 인프런...

www.inflearn.com

 

 

 

개요

인프런에서 제공하는 Nest.js 무료 강의.
다른 인프런 무료강의들처럼 유튜브로 배포된 영상을 강의형태로 모았다.

쉽고 재밌습니다.

 

 

 

 

 

 

강의 후기

 

 

🔎 학습 내용

- Nest.js 기본 요소 설명(MVC 패턴에 대한 간략한 설명도)
- 간단한 CRUD 구현 (DB 연결 X)
- Pipe 이용(유효성 체크 등을 위한 미들웨어)
- DB 연동 후 CRUD 수정(Postgresql & TypeORM)
- JWT를 활용한 인증 로직 구현
- 게시물 접근 권한 로직 구현
- 그 외 (Log, Configuration)

 

 

 

 

 

 

🔎 학습 시간

- 강의시간 : 약 10시간
- 공부시간 : 약 20시간

 

 

🔎 수강 조건

- 웹 프레임워크(Express면 더 좋고)에 대한 기본적인 이해

이해가 없어도 들을 수 있는 난이도이기는 하지만, 기초적인 것을 다 설명해주시지는 않기 때문에 기본적인 이해는 필요할 것 같다.

 

 

 

🔎 수강 대상

- Nest.js 기초를 빠르게 잡고 싶은 사람
- Express 개발자 (Nest 함 써보세요 편합니다 ^^)

 

 

 

 


주관적 평가

 

 

👍 좋았던 점

1) 적당한 난이도
어렵지는 않지만 직접 프로젝트를 만들 수 있을 정도의 예제로 구성되어 있어 좋았다! 강의 수강 후 바로 개발 시작하면 될듯.

 

2) 깔끔한 구성

유튜브에 배포된 강의가 맞나 싶을 정도로 강의 구성이 깔끔했다.

 

3) 왜케 재밌지?

너무 재밌어가지고 밥 먹을 때도 자꾸 다음 강의 생각나고 그래서 하루 만에 다 들었다.

 

 

 

 

 

👎 나빴던 점

버전 문제가 좀 있는데요, 강사님 깃에서 package.json, package.lock.json 그대로 복사해오면 해결되더라구요..

 

 

 


프로젝트 기술 스택을 Express에서 Nest.js로 옮기기 위해 급하게 들었던 강의.
아티클이랑 깃허브 리파지토리 뒤져서 그냥 시작할까하다가, 강의 한 번 들어보고 싶어서 들었는데
너무 잘한 선택이었다!

 

후기 끝.

 

 

 

 

 

 

 

반응형
반응형

 mongoose 내장 함수인 findByID, findByIdAndUpdate, findByIdAndDelete에 대해 알아 보자.

 

(Collection은 가칭)

Collection.findByID(id) : Collection에서 _id가 id인 document를 찾는다.

Collection.findByIdAndUpdate(id, data) : Collection에서 _id가 id인 document를 찾아서 data 내용에 따라 수정한다.

Collection.findByIdAndDelete(id) : Collection에서 _id가 id인 document를 찾아서 삭제한다.

 

코드를 통해 살펴보자.

const findUserById = async (userId: string): Promise<UserResponseDto | null> => {
    try {
        const user = await User.findById(userId);
        if (!user) {
            return null;
        }

        return user;
    } catch (error) {
        console.log(error);
        throw error;
    }
}

위 코드는 어떤 프로젝트의 Service 코드이다.

findById(나머지 둘도 마찬가지)는 document를 불러와 저장할 수 있다.

 

이때 유의할 점은, user가 존재하지 않을 수 있기 때문에(존재하지 않는 userId)

null을 반환할 수 있도록 반환 타입 명시와 반환값 설정을 위와 같이 해줘야 한다.

 

const updateMovie = async (movieId: string, movieUpdateDto: MovieUpdateDto): Promise<MovieResponseDto | null> => {
    try {
        const movie = await Movie.findByIdAndUpdate(movieId, movieUpdateDto);
        if (!movie) {
            return null;
        }

        return movie;
    } catch (error) {
        console.log(error);
        throw error;
    }
}

이번에는 findByIdAndUpdate를 사용했다.

유의할 점은, find And Update 순서에서 눈치챌 수 있듯이,

find가 먼저 적용되기 때문에,

movie에는 update 전 document 정보가 저장된다.

 

따라서, update 후 document 정보를 저장하고 싶다면 아래와 같이 코드를 수정해야 한다.

const updateMovie = async (movieId: string, movieUpdateDto: MovieUpdateDto): Promise<MovieResponseDto | null> => {
    try {
        const movie = await Movie.findByIdAndUpdate(movieId, movieUpdateDto);
        if (!movie) {
            return null;
        }
        const data = await Movie.findById(movieId);
        return data;
    } catch (error) {
        console.log(error);
        throw error;
    }
}

(더 좋은 방법이 있을텐데.. 아시는 분 있으시다면 댓글로 조언 부탁드립니다. 감사합니다.)

 

 

마지막으로 findByIdAndDelete를 사용해보자.

const deleteMovie = async (movieId: string): Promise<MovieResponseDto | null> => {
    try {
        const movie = await Movie.findByIdAndDelete(movieId);
        if (!movie) {
            return null;
        }

        return movie;
    } catch (error) {
        console.log(error);
        throw error;
    }
}

이때도 movie에는 삭제 전 정보가 저장된다.

내가 삭제한 정보에 대해서 확인하고 document를 보내주기 위해(?) 위와 같은 로직을 작성하였다.

 

 

 

이렇게 mongoose 내장 함수인 findById, findByIdAndUpdate, findByIdAndDelete를 통해

update, delete, get 메서드로 정보를 수정, 삭제, 조회하는 코드를 구현하는 방법을 공부해보았다.

 

 

 

반응형
반응형

NoSQL인 MongoDB는 Join의 개념이 없다!

하지만 분명 값을 참조해올 필요는 존재할 것이다.

 

본 포스팅에서는,

'User', 'Movie' 컬렉션과 'Review' 컬렉션이 존재할 때,

 

'Review' 컬렉션에 'movie'라는 field를 만들어 'Movie'의 값들을 전부 참조해오는 것과

'Review' 컬렉션에 'writer'라는 field를 만들어 'User'의 필드 중 'name'만 참조해오는 것을 구현해볼 것이다.

 


Collection 만들기

// Movie.ts
import mongoose from "mongoose";
import { MovieInfo } from "../interfaces/movie/MovieInfo";

const MovieSchema = new mongoose.Schema({
    title: {
        type: String,
        required: true,
    },
    director: {
        type: String,
        required: true,
    },
    startDate: {
        type: Date,
    },
    thumbnail: {
        type: String,
    },
    story: {
        type: String,
    },
});

export default mongoose.model<MovieInfo & mongoose.Document>("Movie", MovieSchema);

Movie 컬렉션은 별다른 참조 필드 없이 작성했다.

 

import mongoose from "mongoose";
import { ReviewInfo } from "../interfaces/review/ReviewInfo";

const ReviewSchema = new mongoose.Schema({
    writer: {
        type: mongoose.Types.ObjectId,
        required: true,
        ref: "User",
    },
    movie: {
        type: mongoose.Types.ObjectId,
        required: true,
        ref: "Movie",
    },
    title: {
        type: String,
        required: true,
    },
    content: {
        type: String,
        required: true,
    }
});

export default mongoose.model<ReviewInfo & mongoose.Document>("Review", ReviewSchema);

Review 컬렉션은

writer 필드에서 User의 id를,

movie 필드에서 Movie의 id를 참조했다.

 

User는 따로 적진 않겠지만, 'name'이라는 필드를 가지고 있다.

 

컬렉션 생성에 필요한 interface 들은 아래와 같다.

// ReviewInfo.ts
import mongoose from "mongoose";

export interface ReviewInfo {
    writer: mongoose.Types.ObjectId;
    movie: mongoose.Types.ObjectId;
    title: string;
    content: string;
}

// MovieInfo.ts
export interface MovieInfo {
    title: string;
    director: string;
    startDate: Date;
    thumbnail: string;
    story: string;
}

 

 

Service에서 참조 로직 작성하기

Controller 등 다른 것들은 참조 여부와 큰 관련이 없다.

Service 로직에서 참조를 어떻게 구현하는지 살펴보자.

 

첫번째로 'updateReview'를 만들 것이다.

도입부에서 언급한대로, writer(User의 id 참조)를 통해 User의 name을 참조해오고,

movie(Movie의 id 참조)를 통해 Movie의 전체 필드를 가져올 것이다.

const updateReview = async (reviewId: string, reviewUpdateDto: ReviewUpdateDto): Promise<ReviewResponseDto | null> => {
    
    try {
        const review = await Review.findByIdAndUpdate(reviewId, reviewUpdateDto)
        .populate('writer', 'name').populate('movie');
        if (!review) {
            return null;
        }
        const data: any = {
            writer: review.writer,
            movie: review.movie,
            title: review.title,
            content: review.content
        }
        return data;
    } catch (error) {
        console.log(error);
        throw error;
    }
}

.populate() 메서드를 통해 참조를 구현할 수 있다.

위에서 볼 수 있듯이,

특정 필드(name)만 참조해오려면 -> .populate('필드명', '참조해올 필드명')

전체를 참조해오려면 -> .populate('필드명')

과 같이 작성한다.

 

또한 받아온 정보를 사용하기 전에 가공 과정이 필요하다.

이때, TypeScript를 사용하고 있다면,

data에 any 타입을 지정해주는 것에 유의해야 한다.

 

참고로 반환 타입인 ReviewResponseDto는 아래 코드와 같다.

import { MovieInfo } from "../movie/MovieInfo";

export interface ReviewResponseDto {
    writer: string;
    title: string;
    content: string;
    movie: MovieInfo;
}

movie에 Moive 전체 필드를 가져올 것이므로, 타입을 MovieInfo로 지정해주었다.

 

 

두번째로는 'getReviews'를 만들어 보자.

'movie'가 특정 id값과 일치하는 모든 Review documents를 불러오는 로직이다.

 

이 또한, writer(User의 id 참조)를 통해 User의 name을 참조해오고,

movie(Movie의 id 참조)를 통해 Movie의 전체 필드를 가져올 것이다.

const getReviews = async (movieId: string): Promise<ReviewResponseDto[] | null> => {
    try {
         const reviews = await Review.find({
             movie: movieId
         }).populate('writer', 'name').populate('movie');
         if (reviews.length === 0) {
            return null;
        }

         const data = await Promise.all(reviews.map((review: any) => {           
             const result = {
                writer: review.writer.name,
                movie: review.movie,
                title: review.title,
                content: review.content
             };

             return result;
         }));

        return data;
    } catch (error) {
        console.log(error);
        throw error;
    }
}

참조해온 방식은 위의 ' updateReview'와 동일하다.

 

여기서는 mongoose 내장 함수인 find를 활용해

Movie 컬렉션에서 movie가 받아온 movieId와 같은 모든 document의 정보를 가져오고

각각을 populate로 참조해서 적용한다.

 

한 가지 유의할 다른 점은, 참조해온 데이터 가공 방식이다.

Promise.all()과 map을 활용하는데,

map이 여러개의 Promise를 반환하기 때문에 await Promise.all()을 활용해

await 병렬 구조로 실행한다.

 

Promise.all()을 쓰지 않아도 동일한 결과를 나타내지만(적어도 저 코드에서는 그렇더라는..),

성능을 위해 Promise.all()을 사용하는 것 같다.

 

 

아래는 위에서 작성한 로직(getReviews)의 Response 예시이다.

{
    "status": 200,
    "success": true,
    "message": "리뷰 조회 성공",
    "data": [
        {
            "writer": "test1",
            "movie": {
                "_id": "627b4f23f22c32b2fcddc951",
                "title": "닥터 스트레인지",
                "director": "이우진",
                "startDate": "2022-05-01T15:00:00.000Z",
                "thumbnail": "https://example-sopt.s3.ap-northeast-2.amazonaws.com/%E1%84%80%E1%85%A7%E1%86%BC%E1%84%87%E1%85%A9%E1%86%A8%E1%84%80%E1%85%AE%E1%86%BC.jpeg",
                "story": "재밌는 영화입니다."
            },
            "title": "첫번째 리뷰",
            "content": "재밌는 영화였습니다."
        },
        {
            "writer": "test1",
            "movie": {
                "_id": "627b4f23f22c32b2fcddc951",
                "title": "닥터 스트레인지",
                "director": "이우진",
                "startDate": "2022-05-01T15:00:00.000Z",
                "thumbnail": "https://example-sopt.s3.ap-northeast-2.amazonaws.com/%E1%84%80%E1%85%A7%E1%86%BC%E1%84%87%E1%85%A9%E1%86%A8%E1%84%80%E1%85%AE%E1%86%BC.jpeg",
                "story": "재밌는 영화입니다."
            },
            "title": "두번째 리뷰",
            "content": "재미 하나도 없는 영화였습니다."
        }
    ]
}

'movie'가 '627b4f23f22c32b2fcddc951' 인 모든 Review documents를 불러오고,

'movie'는 Movie document의 모든 필드를,

'writer'는 User document의 'name' 필드를 가져온다.

 

실제 Review에는 아래와 같이 저장되어 있다.

(tmi : __v 는 버전을 나타내는 versionKey 필드인데, mongoose를 통해 데이터를 집어넣으면 생긴다고 한다. 궁금해서 방금 쳐봤다.)

 

 

이렇게 MongoDB에서 데이터를 참조하는 것을 구현해보았다.

 

 

 

 

 

 

 

반응형
반응형

express-validator는 유효성 검사를 도와주는 npm 라이브러리이다.

 

본 포스팅을 통해 아래와 같은 유효성 검사 로직을 적용해보자.

비어있지 않음 - notEmpty()

email 형식 - isEmail()

date 형식 - isDate()

error message 변경 - withMessage()

 

또한 본 포스팅에서는

router에서 validation 로직을,

controller에서 error handling 로직을 작성할 것이다.

 

 

 


 

express-validator 설치

yarn add express-validator

// 또는
npm install express-validator

 

 

router 수정 (validation 부분)

// 적용 전
import { Router } from "express";
import { UserController } from "../controllers";

const router: Router = Router();
router.post('/', UserController.createUser);

// 적용 후
import { Router } from "express";
import { body } from "express-validator/check";
import { UserController } from "../controllers";

const router: Router = Router();
router.post('/', [
    body("name")
        .notEmpty().withMessage("name이 비어있습니다."),
    body("phone")
        .notEmpty().withMessage("phone이 비어있습니다."),
    body("email")
        .notEmpty().withMessage("email이 비어있습니다.")
        .isEmail().withMessage("email 형식이 올바르지 않습니다."),
], UserController.createUser);

notEmpty() : 비어있으면 error 발생

 

메서드명이 대부분 직관적으로 이해된다.

withMessage는 바로 앞 메서드에 대해 error message를 변경해준다.

(default value는 'Invalid value'로 심플해서 웬만하면 수정하는 것이 좋다.)

 

 

// 적용 전
import express, { Router } from "express";
import { body } from "express-validator/check";
import MovieController from "../controllers/MovieController";

const router: express.Router = Router();
router.post('/', MovieController.createMovie);

export default router;



// 적용 후
import express, { Router } from "express";
import { body } from "express-validator/check";
import MovieController from "../controllers/MovieController";

const router: express.Router = Router();
router.post('/', [
    body("title")
        .notEmpty().withMessage('title이 비어있습니다.'),
    body("director")
        .notEmpty().withMessage('director가 비어있습니다.'),
    body("startDate")
        .isDate().withMessage('startDate의 형식이 올바르지 않습니다.'),
] ,MovieController.createMovie);

export default router;

isDate() : date 형식이 아니면 error 발생

 

 

이번에는 controller에서 error를 handling 해보자.

 

 

controller 수정 (error handling 로직)

// 적용 전
const createMovie = async (req: Request, res: Response): Promise<void | Response> => {
    const movieCreateDto: MovieCreateDto = req.body;
    try {
        const data = await MovieService.createMovie(movieCreateDto);
        res.status(statusCode.CREATED).send(util.success(statusCode.CREATED, message.CREATE_MOVIE_SUCCESS, data));
    } catch (error) {
        console.log(error);
        res.status(statusCode.INTERNAL_SERVER_ERROR).send(util.fail(statusCode.INTERNAL_SERVER_ERROR, message.INTERNAL_SERVER_ERROR));
    }
}

// 적용 후
// ...
import { validationResult } from "express-validator";
// ...

const createMovie = async (req: Request, res: Response): Promise<void | Response> => {
    const error = validationResult(req);	// validation 결과 받아오기
    if (!error.isEmpty()) {
        console.log(error);
        return res.status(statusCode.BAD_REQUEST).send(util.fail(statusCode.BAD_REQUEST, error.array()[0].msg));
    }

    const movieCreateDto: MovieCreateDto = req.body;
    try {
        const data = await MovieService.createMovie(movieCreateDto);
        res.status(statusCode.CREATED).send(util.success(statusCode.CREATED, message.CREATE_MOVIE_SUCCESS, data));
    } catch (error) {
        console.log(error);
        res.status(statusCode.INTERNAL_SERVER_ERROR).send(util.fail(statusCode.INTERNAL_SERVER_ERROR, message.INTERNAL_SERVER_ERROR));
    }
}

위와 같은 로직으로 error를 받아와 그 여부를 확인(!error.isEmpty())하고  (-> error가 있으면 isEmpty() 결과가 false가 나온다.)

'error.array()[0].msg' 로 그 msg를 받아온다.

 

참고로 error에 대한 여러 정보가 error.array()에 담겨 있는데, 아래와 같다.

 

// console.log(error) 결과
Result {
  formatter: [Function: formatter],
  errors: [
    {
      value: undefined,
      msg: 'director가 비어있습니다.',
      param: 'director',
      location: 'body'
    }
  ]
}

// console.log(error.array()) 결과
[
  {
    value: undefined,
    msg: 'director가 비어있습니다.',
    param: 'director',
    location: 'body'
  }
]

저 배열 값을 활용하려고 이것 저것 써봤는데..

답은 error.array() 였다!

 

 

그 외에도 많은 기능과 용법을 공식 문서에서 확인할 수 있다!

https://express-validator.github.io/docs/

 

Getting Started · express-validator

express-validator is a set of [express.js](http://expressjs.com/) middlewares that wraps

express-validator.github.io

 

 

읽어주셔서 감사합니다.

 

 

 

반응형
반응형

node와 express를 활용한 프로젝트를 시작하고

간단한 CRUD api를 작성해보는 포스팅을 적어보려 한다.

 

프로젝트를 시작할 때 참고하면 좋을 것 같다.

 

DB 생성과 초기 세팅은 이전 포스팅 참고.

 

MongoDB Cluster 생성하기 (링크🔗)

Express 초기 세팅, yarn 사용법 (링크🔗)

 

 

 


 

.env 만들기

루트 디렉토리에 '.env' 파일을 만든다 (* 반드시 .gitignore에 추가한다.)

 

 

// .env
PORT=8080
MONGODB_URI=mongodb+srv://id:password@clusterName.dgufc.mongodb.net/어쩌구저쩌구?retryWrites=true&w=majority

위와 같이 입력하는데, 'MONGOB_URI'는 MongoDB 홈페이지에서 가져온다

 

홈페이지에서 로그인하면 아래 화면이 바로 나온다.

 

 

 

 

기타 파일 생성 및 수정

1. 'config/index.ts' 생성

import dotenv from "dotenv";

// Set the NODE_ENV to 'development' by default
process.env.NODE_ENV = process.env.NODE_ENV || "development";

const envFound = dotenv.config();
if (envFound.error) {
  // This error should crash whole process

  throw new Error("⚠️  Couldn't find .env file  ⚠️");
}

export default {
  /**
   * Your favorite port
   */
  port: parseInt(process.env.PORT as string, 10) as number,

  /**
   * MongoDB URI
   */
  mongoURI: process.env.MONGODB_URI as string,
};

 

2. 'loaders/db.ts' 생성

import mongoose from "mongoose";
import config from "../config";

const connectDB = async () => {
    try {
        await mongoose.connect(config.mongoURI);

        mongoose.set('autoCreate', true);

        console.log("Mongoose Connected ...");
    } catch (err: any) {
        console.error(err.message);
        process.exit(1);
    }
};

export default connectDB;

 

3. 'src/index.ts' 수정

import express, { Request, Response, NextFunction } from "express";
import config from "./config";
const app = express();
import connectDB from "./loaders/db";
import routes from './routes';
require('dotenv').config();

connectDB(); // DB 연결하기

app.use(express.urlencoded({ extended: true }));
app.use(express.json());

app.use(routes);   //라우터 분리
// error handler

interface ErrorType {
  message: string;
  status: number;
}

// 모든 에러에 대한 핸들링
app.use(function (err: ErrorType, req: Request, res: Response, next: NextFunction) {

  res.locals.message = err.message;
  res.locals.error = req.app.get("env") === "production" ? err : {};

  // render the error page
  res.status(err.status || 500);
  res.render("error");
});

app
  .listen(config.port, () => {
    console.log(`
    ################################################
          🛡️  Server listening on port 🛡️
    ################################################
  `);
  })
  .on("error", (err) => {
    console.error(err);
    process.exit(1);
  });

 

 

4. modules 폴더 만들기

// modules/util.ts -> success, fail 메세지 가공
const util = {
    success: (status: number, message: string, data?: any) => {
        return {
            status,
            success: true,
            message,
            data,
        };
    },
    fail: (status: number, message: string, data?: any) => {
        return {
            status,
            success: false,
            message,
        };
    },
};

export default util;

// modules/responseMessage.ts -> response message 가공
const message = {
    NULL_VALUE: '필요한 값이 없습니다.',
    NOT_FOUND: '존재하지 않는 자원',
    BAD_REQUEST: '잘못된 요청',
    INTERNAL_SERVER_ERROR: '서버 내부 오류',

    // 포스팅 조회
    READ_POST_SUCCESS: '포스팅 조회 성공',
    CREATE_POST_SUCCESS: '포스팅 생성 성공',
    DELETE_POST_SUCCESS: '포스팅 삭제 성공',
    UPDATE_POST_SUCCESS: '포스팅 수정 성공',
}

export default message;

// modules/statusCode.ts
const statusCode = {
    OK: 200,
    CREATED: 201,
    NO_CONTENT: 204,
    BAD_REQUEST: 400,
    UNAUTHORIZED: 401,
    FORBIDDEN: 403,
    NOT_FOUND: 404,
    CONFLICT: 409,
    INTERNAL_SERVER_ERROR: 500,
    SERVICE_UNAVAILABLE: 503,
    DB_ERROR: 600,
};

export default statusCode;

 

 

라우팅 설정하기

routes/PostRouter.ts

import { Router } from "express";
import { PostController } from "../controllers";

const router: Router = Router();

router.post('/', PostController.createPost);
router.put('/:postId', PostController.updatePost);
router.get('/:postId', PostController.findPostById);
router.delete('/:postId', PostController.deletePost);

export default router;
위에서 작성한대로 나머지를 작성하려고 한다.
PostController.ts를 만들어 createPost, updatePost, findPostById, deletePost 4개의 함수를 만든다.
put, get, delete 메서드에 대해서는 postId를 파라미터로 받는다.

 

routes/index.ts

//router index file
import { Router } from 'express';
import PostRouter from "./PostRouter";

const router: Router = Router();

router.use('/post', PostRouter);

export default router;

 

 

DTO 작성

DTO는 Data Type Object의 약자로,

데이터를 받아오거나 반환하는 규격으로 interface로 작성한다.

(장고의 serializer 생각하면 편하더라는..)

 

// interfaces/post/PostCreateDto.ts -> create 용
export interface PostCreateDto {
    title: string;
    content: string;
    additional?: {
        category: string;
        season: string;
    };
}

// interfaces/post/PostInfo.ts -> model 정의 용도
export interface PostInfo {
    title: string;
    content: string;
    additional: {
        category: string;
        season: string;
    };
}

// interfaces/post/PostResponseDto.ts -> 정보 조회 용
import mongoose from "mongoose";
import { PostCreateDto } from "./PostCreateDto";

export interface PostResponseDto extends PostCreateDto { // PostCreateDto 를 상속받아 확장시킨다.
    _id: mongoose.Schema.Types.ObjectId;
}


// interfaces/post/PostUpdateDto.ts -> update 용
import mongoose from "mongoose";

export interface PostUpdateDto {
    _id: mongoose.Schema.Types.ObjectId;
    title?: string;
    content?: string;
    additional?: {
        category: string;
        season: string;
    };
}

// interfaces/common/PostBaseResponseDto.ts -> id만 노출할 때
import mongoose from "mongoose";

export interface PostBaseResponseDto {
    _id: mongoose.Schema.Types.ObjectId;
}

다른 로직 작성하다가 필요에 맞게 DTO를 작성해주는 것이 좋다.

 

 

model 작성

models/Post.ts 를 만든다.

import mongoose from "mongoose";
import { PostInfo } from "../interfaces/post/PostInfo";

const PostSchema = new mongoose.Schema({
    title: {
        type: String,
        required: true
    },
    content: {
        type: String,
        required: true,
    },
    dateTimeOfPosting: {
        type: Date,
        required: true,
        default: Date.now,
    },
    additional: {
        category: { type: String },
        season: { type: String },
    },
});

export default mongoose.model<PostInfo & mongoose.Document>("Post", PostSchema);

dateTimeOfPosting은 생성 당시 일시를 저장한다.

additional은 중복 document(MongoDB에서 row에 해당하는 개념)



 

Controller 작성

import express, { Request, Response } from "express";
import { PostCreateDto } from "../interfaces/post/PostCreateDto";
import { PostUpdateDto } from "../interfaces/post/PostUpdateDto";
import statusCode from "../modules/statusCode";
import message from "../modules/responseMessage";
import util from "../modules/util";
import { PostService } from "../services";


const createPost = async (req: Request, res: Response): Promise<void> => {
    const postCreateDto: PostCreateDto = req.body;
    
    try {
        const data = await PostService.createPost(postCreateDto);
        res.status(statusCode.CREATED).send(util.success(statusCode.CREATED, message.CREATE_POST_SUCCESS, data));
    } catch (error) {
        res.status(statusCode.INTERNAL_SERVER_ERROR).send(util.fail(statusCode.INTERNAL_SERVER_ERROR, message.INTERNAL_SERVER_ERROR));
    }
}

const updatePost = async (req: Request, res: Response): Promise<void> => {
    const postUpdateDto: PostUpdateDto = req.body;
    const { postId } = req.params;

    try {
        const data = await PostService.updatePost(postId, postUpdateDto);
        res.status(statusCode.CREATED).send(util.success(statusCode.OK, message.UPDATE_POST_SUCCESS, data));
    } catch (error) {
        res.status(statusCode.INTERNAL_SERVER_ERROR).send(util.fail(statusCode.INTERNAL_SERVER_ERROR, message.INTERNAL_SERVER_ERROR));
    }
}

const findPostById = async (req: Request, res: Response): Promise<void> => {
    const { postId } = req.params;

    try {
        const data = await PostService.findPostById(postId);
        res.status(statusCode.CREATED).send(util.success(statusCode.OK, message.READ_POST_SUCCESS, data));
    } catch (error) {
        res.status(statusCode.INTERNAL_SERVER_ERROR).send(util.fail(statusCode.INTERNAL_SERVER_ERROR, message.INTERNAL_SERVER_ERROR));
    }
}

const deletePost = async (req: Request, res: Response): Promise<void> => {
    const { postId } = req.params;

    try {
        const data = await PostService.deletePost(postId);
        res.status(statusCode.CREATED).send(util.success(statusCode.OK, message.DELETE_POST_SUCCESS, data));
    } catch (error) {
        res.status(statusCode.INTERNAL_SERVER_ERROR).send(util.fail(statusCode.INTERNAL_SERVER_ERROR, message.INTERNAL_SERVER_ERROR));
    }
}

export default {
    createPost,
    updatePost,
    findPostById,
    deletePost,
}

Request body는 req.body에, Request parameters는 req.params에 저장되어 있어서

필요한 경우에 맞게 할당시켜 사용한다.

 

controllers/index.ts도 수정한다.

import PostController from "./PostController"

// controller index file
export {
   PostController,
}

 

 

Service 작성

import { PostBaseResponseDto } from "../interfaces/common/PostBaseResponseDto";
import { PostCreateDto } from "../interfaces/post/PostCreateDto";
import { PostResponseDto } from "../interfaces/post/PostResponseDtd";
import { PostUpdateDto } from "../interfaces/post/PostUpdateDto";
import Post from "../models/Post";



const createPost = async (postCreateDto: PostCreateDto): Promise<PostBaseResponseDto> => {
    try {
    	// create를 위해 각 filed명에 값들을 할당시켜준다.
        const post = new Post({
            title: postCreateDto.title,
            content: postCreateDto.content,
            additional: {
                category: postCreateDto.additional?.category,
                season: postCreateDto.additional?.season,
            }
        });
        await post.save();

        const data = {
            _id: post.id
        };

        return data;
    } catch (error) {
        console.log(error);
        throw error;
    }
}

const updatePost = async (postId: string, postUpdateDto: PostUpdateDto): Promise<PostUpdateDto | null> => {
    try {
        await Post.findByIdAndUpdate(postId, postUpdateDto); // update 로직
        const post = await findPostById(postId); // update 된 정보를 불러오는 로직
        // null이 될 경우를 처리해줘야 한다.
		if (!post) {
            return null;
        }
        return post;

    } catch (error) {
        console.log(error);
        throw error;
    }
}

const findPostById = async (postId: string): Promise<PostResponseDto | null> => {
    try {
        const post = await Post.findById(postId);
        if (!post) {
            return null;
        }
        return post;
        
    } catch (error) {
        console.log(error);
        throw error;
    }
}

const deletePost = async (postId: string): Promise<PostResponseDto | null> => {
    try {
        const post = await Post.findByIdAndDelete(postId);
        if (!post) {
            return null;
        }
        return post;
    } catch (error) {
        console.log(error)
        throw error;
    }
}

export default {
    createPost,
    updatePost,
    findPostById,
    deletePost,
}

findById와 같은 메서드들은 mongoose 내장 함수이다.

용법이 그렇게 어렵지 않다.

 

 services/index.ts 도 수정해준다.

import PostService from "./PostService"

//service index file
export {
    PostService,
}

 

이렇게 작성이 끝이 났다.

 

 

요청 보내기

yarn run dev 실행

 

POST

PUT

userId는 무시해도 좋다(포스팅 내용과 다른 코드 때문)

 

GET

 

DELETE

 

 


 

프로젝트 구조에 대해 간단히 언급하고 포스팅을 마치려 한다.

 

Request <--> Controller <--> Service <--> Response(DB)

 

Controller는 Request 데이터를 적절히 가공해서 Service에 전달하는 역할만 한다.

Service는 가공된 데이터를 가지고 원하는 로직을 적용한 뒤 DB에 요청을 전달해 결과값을 받아 온다.

그리고 그 과정에서 DTO라는 틀을 통해 데이터를 주고 받는다.

 

Service 단에서 사용하는 로직을 '비즈니스 로직'이라고 한다.

이 '비즈니스 로직'을 Route, Controller에서 분리시켜 재사용, 유지보수가 용이하게 한다.

 

Request 객체를 받아오고 Response 객체를 만들어 전달하는 것을 Controller에서,

그것에 원하는 로직을 적용하는 것은 Service에서 실행하는 것이다.

 


 

이렇게 express 프로젝트를 생성하고 간단하게 모델과 CRUD api를 작성해서 테스트해보았다.

앞으로 node, express, MongoDB를 사용하는 프로젝트가 있다면,

본 포스팅 코드를 기반으로 서버 구현을 시작해도 좋을 것 같다.

 

 

 

반응형
반응형

헷갈리는 동기, 비동기 개념을 정리하는 포스팅 (3)

 

1) callback에 대한 이해🔗 : https://woojin.tistory.com/27

2) JS 초짜가 Promise 완벽하게 이해하기🔗 : https://woojin.tistory.com/28

 

 

이번 포스팅은, async / await 키워드의 사용법과 의미에 대해

無의 상태로 돌아가서 공부하고 정리해보고자 한다.

(* 오류에 대한 지적 환영합니다.)

 

첫째로는, async/await의 사용법, 의미에 대해서 자세히 다룰 것이고

둘째로는, await의 유무와 비동기 처리에 관계에 대해 이해해볼 것이다.

마지막으로는, 비동기 코드를 병렬 처리하는 .all(), .race() 메서드에 대해 알아볼 것이다.

 

(유튜브 채널 '드림코딩'의 영상 코드를 토대로 공부했다.)

https://www.youtube.com/watch?v=aoQSOZfz3vQ 

 


async란? (+ 사용법)

async와 await은 비동기를 동기적으로 작성할 수 있는 키워드이다.

이게 도대체 뭔소린지 전혀 이해가 되지 않았다.

 

우선 Promise를 이해했으니,

Promise가 async로 대치되는 코드를 살펴보자.

 

// <<< Promise >>>
const fetchUser = () => {
    return new Promise((resolve, reject) => {
        // do network request in 10 secs ...
        resolve('woojin');
    });
}

const user = fetchUser();
user.then(console.log);

 

// <<< Async >>>
const fetchUserAsync = async () => {
    // do network request in 10 secs ...
    console.log('loading...');
    return 'woojin';
}

const user = fetchUser();
user.then(console.log);

보다시피 Promise에서 resolve하던 것을,

async 키워드를 붙이면 그냥 return하면 된다!

이렇게 Promise, resolve를 통해 느껴지던 동기 코드와의 이질감을 해소한다.

(reject는? -> throw error, try catch 사용하면 되는데, 에러 핸들링에 대해서는 자세히 다루지 않겠다.)

 

 

await이란? (+ 사용법)

await은 기다려주는 키워드이다!

이렇게 간단한 키워드가 죽어라 이해가 안 되었는데, 추가 설명이 필요하기 때문이다. 바로바로..

 

"await은 Promise를 반환하는 함수에 쓰여야 한다."

이해하고 나니 너무 당연하다.

서버에 api를 요청해서 유저의 mbti 정보를 받아와서 infp가 맞는지 여부를 반환한다고 생각해보자.

서버에 유저 mbti를 요청하고 받아오는 동안 여부를 확인하는 코드는 동작하면 안 된다!

"mbti 알아올 때까지 기다려~" 라고 말해줘야 하는 상황.

 

그렇기에 Promise를 반환하고 .then 메서드를 사용한 것 아닌가?

 

체이닝은 왜 사용했는가?

서버에서 유저의 mbti를 받아온 다음, 서버에 다시 mbti를 보내고 해당 mbti의 특징을 받아온다고 해보자.

mbti를 받아온 다음 특징을 받아와야 하기 때문에

mbti().then(mbti => 특징받아오기()).then(특징 => console.log(특징));

이런 식으로 체이닝 한 것이 아닌가?

 

아래 코드를 통해 정확히 이해해보자.

const delay = (ms) => {
    return new Promise(resolve => setTimeout(resolve, ms));
}

const getApple = async () => {
    await delay(4000);
    return '🍎';
}

const getBanana = async () => {
    await delay(2000);
    return '🍌';
}

getApple().then(console.log);
getBanana().then(console.log);

delay는 단순히 시간을 지연시키는 함수이다.

getApple은 delay를 통해 4초 멈추고 🍎를 반환하고, getBanana는 2초 멈추고 🍌를 반환한다.

 

위 코드를 실행하면 getApple(), getBanana() 순서로 Promise를 호출하기는 하지만,

비동기적으로 delay 함수가 실행되어 🍌, 🍎 순서로 출력된다.

 

그럼 마치 mbti를 알아야 그 특성을 아는 것처럼,

사과를 받아야 바나나를 받을 수 있다고 가정하고 순서대로 호출해보자.

먼저 promise chaning을 사용하면 아래와 같다.

const pickFruits = () => {
    return getApple().then(apple => {
        return getBanana().then(banana => `${apple} + ${banana}`);
    })
};

 apple에 🍎를 받아와 다시 promise를 반환하는 함수인 getBanana에 넣는다.

어라라? 어딘가 익숙하지 않은가? callback 지옥이 떠오른다..

이런 식으로 chaining한다면 callback 지옥과 다를 것이 없다.

 

하지만 async/await을 사용하면 아래와 같다.

const pickFruits = async () => {
    const apple = await getApple();
    const banana = await getBanana();
    return `${apple} + ${banana}`;
}

promise, .then()이 async와 await으로 대체된다.

본 코드는 4초 후 apple에 🍎를 저장하고, 다시 2초 후 banana에 🍌를 저장한다.

총 6초 후, '🍎 + 🍌'를 반환한다.

 

 

await 유뮤의 차이점

필자는 위 코드를 처음 봤을 때, await의 정확한 의미를 파악하지 못했다.

await이 있고 없고의 차이를 비교하면서 await의 정확한 용도를 파악할 수 있었다.

아직 await의 의미가 헷갈린다면, 아래 코드를 참고해보자.

 

const delayApple = (ms) => {
    return new Promise(resolve => setTimeout(() => resolve('🍎'), ms));
}

const getApple = async () => {
    a = await delayApple(10000); // await 사용
    fruit = a + '🍌'
    return fruit;
}

getApple().then(console.log);

* 참고로 setTimeout의 인자로 들어간 콜백 함수는 'resolve' 꼴이면 가능한데 'resolve('🍎') 꼴이면 안 된다.

resolve()는 함수라기보다 결과에 속하기 때문에, '() => resolve('🍎')' 꼴로 넣어줘야 한다.

 

위 코드에서, await 키워드 때문에 10초간 delayApple의 결과를 기다렸다가 a에 넣은 후에야

fruit = a + '🍌'가 실행된다. (해당 줄은 Promise를 반환하는 함수가 쓰인 것도 아니고 순서대로 실행하면 되므로 await을 넣을 이유가 없다!)

 

그렇다면 await이 없는 코드는 어떻게 작동할까?

const delayApple = (ms) => {
    return new Promise(resolve => setTimeout(() => resolve('🍎'), ms));
}

const getApple = async () => {
    a = delayApple(10000); // await 사용
    // console.log(a)
    fruit = a + '🍌'
    return fruit;
}

getApple().then(console.log);

해당 코드의 출력은 '[object Promise]🍌' 이다.

그리고 10초 후에 프로그램이 종료된다.

 

delayApple은 10초 후에 resolve('🍎')를 가져오는데,

promise였다면 .then()을 통해 기다렸겠지만,

위 코드에서는 기다리는 코드가 없다!

 

따라서 a에는 [object Promise]라는 빈 껍데기가 할당된 채로 fruit = a + '🍌'가 실행되고 만다.

그리고 10초 후에 a에는 정상적으로 🍎가 저장되지만, 함수는 이미 리턴값을 반환했기에 의미가 없다.

그렇게 10초가 지나서야 프로그램이 종료된다.

 

여기서 위 코드의 주석처리된 console.log(a)를 실행하면 'Promise { <pending> }'라고 출력된다!

대기(pending)상태의 Promise 객체라는 것이다.

 

 

필자는 이렇게 async, await을 이해했다.

 

간단하게 정리하자면,

async는 promise를 반환하는 함수에 붙이기만 하면 되고,

await은 promise를 반환하는 함수를 사용할 때 그것을 기다려야하면(비동기적으로 처리해야하면) 앞에 붙인다.

 

 

다음에서는 .all() - (await 병렬 처리 후 동시 반환), .race() - (병렬 처리 후 우선 처리된 promise 반환)

이렇게 2개의 메서드에 대해 알아보자.

 

 

await 병렬 처리(동시 반환, 우선 처리된 Promise 반환)

const delay = (ms) => {
    return new Promise(resolve => setTimeout(resolve, ms));
}

const getApple = async () => {
    await delay(4000);
    return '🍎';
}

const getBanana = async () => {
    await delay(2000);
    return '🍌';
}

// .all() 메서드
const pickAllFruits = () => {
    return Promise.all([getApple(), getBanana()]).then(fruits =>
        fruits.join(' + ')
    );
}

pickAllFruits().then(console.log);

위 코드는 .all() 메서드를 활용해 두 개의 Promise 객체를 동시에 호출한 것이다.

위에서 예로 든 'mbti 받아오기', '해당 mbti 특성 받아오기'는 순차적으로 실행되어야 한다.

 

하지만, 'mbti 받아오기'와 '성별 받아오기'는 순차적으로 실행될 필요가 없다!

이런 경우 위와 같이 .all()메서드를 활용하면 반환된 Promise 객체들이 배열에 저장된다.

(당연한 얘기지만 await이 아니라 그냥 Promise 코드에도 사용할 수 있다.)

 

const pickOnlyOne = () => {
    return Promise.race([getApple(), getBanana()]);
}

pickOnlyOne().then(console.log);

.race() 메서드는 .all() 메서드와 동일하게 사용되는데, 가장 먼저 반환된 Promise 객체 한 개만 반환한다!

위 코드에서는 getApple()이 실행되는데 4초, getBanana()가 2초 걸리므로

🍌가 반환되어 출력된다.

 

 

Typescript로의 변환은 너무 간단해서 일부만 올려두겠다.

const delay = (ms: number): Promise<void> => {
    return new Promise(resolve => setTimeout(resolve, ms));
}

const getApple = async (): Promise<string> => {
    await delay(4000);
    return '🍎';
}

const pickFruits = async (): Promise<string> => {
    const apple: string = await getApple();
    const banana: string = await getBanana();
    return `${apple} + ${banana}`;
}

위 코드로의 변환이면, 본 포스팅에 쓰인 코드를 모두 변환할 수 있다.

 

 

 


 

이렇게 3편에 걸친 동기/비동기 포스팅이 끝이 났다.

 

1도 이해 안 가던 것들이 수많은 아티클을 정독하며 결국엔 이해되어 굉장히 뿌듯하고,

나만의 방식으로 이해한 내용을 정리해두었다는 점에 든든한 마음도 든다.

 

더군다가 정이 가지 않던 JS, TS와도 굉장히 친해져서 좋았다!

 

질문이나 태클 언제든 환영입니다.

감사합니다.

 

 

 

반응형

+ Recent posts