본문 바로가기
내일배움캠프

내일배움캠프 Node트랙 심화 프로젝트 역할 및 진행사항 2

by 코드스니펫 2024. 1. 9.
반응형

내일배움캠프 Node트랙 심화 프로젝트 역할 및 진행사항 2

 

내일배움캠프 로고

 

이번 프로젝트는 팀 프로젝트로 Node트랙 심화 프로젝트를 진행하게 되었습니다. 프로젝트를 시작하며 팀에서 맡은 역할과 현재 진행사항에 대해 소개하겠습니다.

 

 

프로젝트 발표일까지  D-2

1/5(금) 1/6(토) 1/7(일) 1/8(월) 1/9(화) 1/10(수) 1/11(목)
시작 ▶ 🏃 🏃 🏃 🏃   완료 🚩

 

 

내일배움캠프 Node트랙 심화 프로젝트 역할 및 진행사항

내일배움캠프 Node트랙 심화 프로젝트 역할 및 진행사항 이번 프로젝트는 팀 프로젝트로 Node트랙 심화 프로젝트를 진행하게 되었습니다. 프로젝트를 시작하며 팀에서 맡은 역할과 현재 진행사항

lemonlog.tistory.com

전날 프로젝트 기록입니다

 

 

내일배움캠프 Node트랙 심화 프로젝트

 

🎯 프로젝트 주제 및 목표

이번 프로젝트는 "프로젝트 협업 도구"를 만드는 것입니다. 프로젝트 협업 도구 중 Trello 라는 서비스를 고르게 되었습니다. Trello는 칸반 보드 기반 서비스로 유명한 프로젝트 협업 도구로, Trello에서 제공하는 다양한 기능을 구현하는 것이 이번 프로젝트의 목표 입니다.

 

trello 화면
trello 화면

 

 

 

✅ 프로젝트 필수 구현 기능

Trello를 참고하여 프로젝트에 필수로 구현될 기능은 다음과 같습니다.

 

  • 사용자 관리 기능 (로그인 / 회원가입 / 사용자 정보 수정 및 삭제)
  • 보드 관리 기능 (보드 생성 / 보드 수정 / 보드 삭제 / 보드 초대)
  • 컬럼 관리 기능 (컬럼 생성 / 컬럼 이름 수정 / 컬럼 삭제 / 컬럼 순서 이동)
  • 카드 관리 기능 (카드 생성 / 카드 수정 / 카드 삭제 / 카드 이동)
  • 카드 상세 기능 (댓글 달기 / 날짜 지정)

 

위 기능 중 필자는 보드 관리 기능 (팀에서는 보드 대신 워크스페이스라고 명칭을 바꿔 사용)을 담당하였습니다.

 

 

GitHub - Beardevelope/collabotools-Project

Contribute to Beardevelope/collabotools-Project development by creating an account on GitHub.

github.com

팀프로젝트 소스코드 확인시 위 링크를 참고바랍니다

 

 

👩‍💻 기술스택

  • 프로그래밍 언어: TypeScript, JavaScript (Node.js)
  • 프레임워크: Nest.js
  • 데이터베이스: TypeORM, AWS RDS
  • 버전 관리 시스템: Git
  • 개발 도구: Visual Studio Code
  • 배포 환경: GitHub
  • 테스트 도구: Thunder Client

 

🗺️ 와이어프레임

프로젝트 와이어프레임

 

 

📜 API 명세서

API의 경우 필자가 담당한 API 명세서만 넣었습니다.

워크스페이스 생성 API
워크스페이스 생성 API
워크스페이스 조회API
워크스페이스 조회 API

 

워크스페이스 삭제 API
워크스페이스 삭제 API

 

워크스페이스 멤버 추가 API
워크스페이스 멤버추가 API

 

 

📌 ERD

프로젝트 erd

 

 

✍ 프로젝트 진행사항

 

내일배움캠프 Node트랙 심화 프로젝트 역할 및 진행사항

내일배움캠프 Node트랙 심화 프로젝트 역할 및 진행사항 이번 프로젝트는 팀 프로젝트로 Node트랙 심화 프로젝트를 진행하게 되었습니다. 프로젝트를 시작하며 팀에서 맡은 역할과 현재 진행사항

lemonlog.tistory.com

전날 작업내용은 위 페이지에 있습니다

 

금일 작업한 내용은 필자가 맡은 워크스페이스 API 1차 구현 완료된 코드에서 사용자 인증에 따른 인가기능 추가와 삭제시 관련 테이블 삭제 부분 수정 및 멤버추가시 중복 데이터 체크 로직까지 구현하였습니다. 

 

 

작업 1. 워크스페이스 CRUD에 인가 기능 추가

워크스페이스 관련 API를 다루기 위해서는 우선적으로 '로그인'한 사용자에 한해서 가능하도록 구현해야 했습니다. 그러기 위해서는 사용자 인증을 담당한 팀원의 구현이 된 후에 작업할 수 있었는데 전날엔 구현이 덜되서 하지 못하였고, 오늘 팀원이 올린 코드를 다시 받아 추가할 수 있었습니다. 

 

API 접근시 인가 확인을 위해 사용된 기술은 NestJS 제공 기능인 Guards입니다. 이 Guards의 역할은 아래 그림과 같이 클라이언트에서 api 요청 후 cotroller로 들어가기 전 guards 안 정의된 로직에 따른 검증을 반드시 거치고 나서 다음 단으로 진행할 수 있게 합니다.

 

nestjs guards 원리

Express.js의 미들웨어랑 유사한 역할을 하는 기능이라 볼 수 있습니다.  이렇게 함으로써 사용자의 요청으로부터 안전하지 않은 부분에 대해 효과적으로 차단할 수 있게 됩니다. 또한 필요에 따라 사용자 인증이나 접근 제어 구현도 가능하게 됩니다.

 

여기서 guards의 사용자 인증 기능을 활용하여 프로젝트에 반영하였습니다. 

 

controller 의 워크스페이스 생성 코드를 예시로 보여드리겠습니다. 기존엔 guards 없이 바로 post로 접근 가능하도록 만들었다면

  @Post()
  async createWorkspace(@Body() createworkspaceDto: CreateWorkspaceDto) {
    return await this.workspacesService.createWorkspace(createworkspaceDto);
  }

 

 

수정된 코드에는 @Post() 밑에 @UseGuards를 추가하여 guard 작업이 완료 된 후 api 동작하도록 하였습니다.

  @Post()
  @UseGuards(BearerTokenGuard)
  async createWorkspace(@Req() req: Request,@Body() createworkspaceDto: CreateWorkspaceDto) {
    return await this.workspacesService.createWorkspace(req['userId'],createworkspaceDto);
  }

 

여기서UseGuards는 작업자가 직접 decorator 가능한 guards로 위에 설정된 UseGuards는 다음과 같이 작성되었습니다.

 

 

bearer.guards.ts

import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
import { AuthService } from '../auth.service';
import { NOT_MATCH_TOKEN_TYPE } from '../const/auth-excption-message';

@Injectable()
export class BearerTokenGuard implements CanActivate {
    constructor(private readonly authService: AuthService) {}

    async canActivate(context: ExecutionContext): Promise<boolean> {
        const req = context.switchToHttp().getRequest();
        const authorizationToken = req.headers.authorization;

        const extractToken = this.authService.extractToken(authorizationToken, true);
        const userInfo = this.authService.verifyToken(extractToken);

        req.userId = userInfo.id;
        req.type = userInfo.type;
        req.token = extractToken;

        return true;
    }
}

@Injectable()
export class AccessTokenGuard extends BearerTokenGuard {
    async canActivate(context: ExecutionContext): Promise<boolean> {
        await super.canActivate(context);

        const req = context.switchToHttp().getRequest();

        if (req.type !== 'access') {
            throw new UnauthorizedException(NOT_MATCH_TOKEN_TYPE);
        }

        return true;
    }
}

@Injectable()
export class RefreshTokenGuard extends BearerTokenGuard {
    async canActivate(context: ExecutionContext): Promise<boolean> {
        await super.canActivate(context);

        const req = context.switchToHttp().getRequest();

        if (req.type !== 'refresh') {
            throw new UnauthorizedException(NOT_MATCH_TOKEN_TYPE);
        }

        return true;
    }
}

 

 

작업 2. API 접근 시 제한 설정, 멤버 추가 중복 방지 로직 추가

워크스페이스 CRUD와 워크스페이스 멤버 추가에 적용한 제한 조건은 다음과 같습니다.

 

  • 워크스페이스 생성 : 로그인한 사용자만 가능
  • 워크스페이스 조회 : 멤버로 추가되어 있는 워크스페이스만 조회
  • 워크스페이스 수정 : 워크스페이스 운영자만 가능
  • 워크스페이스 삭제 : 워크스페이스 운영자만 가능
  • 워크스페이스 멤버 추가 : 워크스페이스 운영자만 가능

 

가입한 사용자라면 누구나 워크스페이스 생성이 가능하도록 하고, 조회는 워크스페이스를 직접 만들거나 어느 워크스페이스의 멤버로 포함될 시 해당 워크스페이스만 조회되도록 했습니다. 또한, 수정과 삭제 그리고 멤버추가는 워크스페이스 운영자만 가능하도록 조건을 주기로 했습니다. 

 

또한, 멤버 추가시 기존 워크스페이스에 등록된 멤버가 있을 시 중복 insert가 되지 않도록 하였고, 입력한 멤버 중 회원가입 안된 사용자인 경우에도 멤버 추가가 안되도록 코드 수정하였습니다. 

 

위 사항 고려하여 작업한 코드는 아래에 있습니다.

 

 

구현한 API 소스

 

workspaces.controller.ts

import { Body, Controller, Delete, Get, Param, Post, Put, Req, UseGuards } from '@nestjs/common';
import { WorkspacesService } from './workspaces.service';
import { CreateWorkspaceDto } from './dto/create-workspace.dto';
import { MembersDto } from './dto/invite-member.dto';
import { BearerTokenGuard } from 'src/auth/guard/bearer.guard';

@Controller('workspaces')
export class WorkspacesController {
  constructor(private readonly workspacesService: WorkspacesService) {}

    /**
     * 워크스페이스 생성
     * (로그인 한 사용자 가능)
     */
  @Post()
  @UseGuards(BearerTokenGuard)
  async createWorkspace(@Req() req: Request,@Body() createworkspaceDto: CreateWorkspaceDto) {
    return await this.workspacesService.createWorkspace(req['userId'],createworkspaceDto);
  }

    /**
     * 워크스페이스 조회
     * (멤버 추가된 워크스페이스만 조회)
     */
  @Get()
  @UseGuards(BearerTokenGuard)
  async findAllWorkspace(@Req() req: Request) {
    return await this.workspacesService.findAllWorkspace(req['userId']);
  }

    /**
     * 워크스페이스 수정
     * (워크스페이스 운영자만 가능)
     */
  @Put(':workspaceId')
  @UseGuards(BearerTokenGuard)
    async updateWorkspace(@Req() req: Request,@Param('workspaceId') workspaceId: number,@Body() createworkspaceDto: CreateWorkspaceDto) {
      return await this.workspacesService.updateWorkspace(req['userId'],workspaceId,createworkspaceDto);
  }
  
    /**
     * 워크스페이스 삭제
     * (워크스페이스 운영자만 가능)
     */
  @Delete(':workspaceId')
  @UseGuards(BearerTokenGuard)
  async deleteWorkspace(@Req() req: Request, @Param('workspaceId') workspaceId: number) {
    return await this.workspacesService.deleteWorkspace(req['userId'],workspaceId);
  }

    /**
     * 워크스페이스 멤버 추가
     * (워크스페이스 운영자만 가능)
     */
  @Post(':workspaceId/invite')
  @UseGuards(BearerTokenGuard)
  async inviteMember(@Req() req: Request, @Body() membersDto: MembersDto, @Param('workspaceId') workspaceId: number) {
    return await this.workspacesService.inviteMembers(req['userId'],workspaceId,membersDto);
  }
}

 

 

workspaces.service.ts

import { HttpException, Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common';
import { DataSource, Repository } from 'typeorm';
import { InjectRepository } from '@nestjs/typeorm';
import { WorkspacesModel } from './entities/workspaces.entity';
import { CreateWorkspaceDto } from './dto/create-workspace.dto';
import { ListsModel } from 'src/lists/entities/lists.entity';
import { UsersModel } from 'src/users/entities/users.entity';
import { MembersDto } from './dto/invite-member.dto';

@Injectable()
export class WorkspacesService {
    constructor(
        @InjectRepository(WorkspacesModel)
        private workspaceRepository: Repository<WorkspacesModel>,

        @InjectRepository(UsersModel)
        private userRepository: Repository<UsersModel>,

        private readonly dataSource: DataSource,
      ) {}
    
      /**
       * 워크스페이스 생성
       * 
       */
      async createWorkspace(userId:number,createworkspaceDto:CreateWorkspaceDto) {

        const queryRunner = this.dataSource.createQueryRunner();

        await queryRunner.connect();
        await queryRunner.startTransaction();

        try{

          const workspace = this.workspaceRepository.create({
            name:createworkspaceDto.name,
            description:createworkspaceDto.description,
            color:createworkspaceDto.color,
            ownerId:userId
          });

          // workspace 정보 저장
          await queryRunner.manager.save(WorkspacesModel,workspace);

          const newWorkSapceId = await queryRunner.manager
          .createQueryBuilder()
          .select([
            'MAX(workspaces_model.id) AS max_id',
          ])
          .from(WorkspacesModel, 'workspaces_model')
          .where('workspaces_model.ownerId = :userId', { userId })
          .getRawMany();

          // users_model_workspaces_workspaces_model에도 멤버 정보 저장
          await queryRunner.manager
          .createQueryBuilder()
          .insert()
          .into('users_model_workspaces_workspaces_model')
          .values(
              {
                usersModelId:userId,
                workspacesModelId:newWorkSapceId[0].max_id
              }
            )
          .execute();

          await queryRunner.commitTransaction();

          return {
            "message":"워크스페이스를 생성했습니다.",
            "data": workspace
          }

        }catch(error){

          await queryRunner.rollbackTransaction();
          console.log(`error : ${error}`)
          if (error instanceof HttpException) {
            // HttpException을 상속한 경우(statusCode 속성이 있는 경우)
            throw error;
          } else {
            // 그 외의 예외
            throw new InternalServerErrorException('서버 에러가 발생했습니다.');
          }

        }finally{
          await queryRunner.release();
        }
    
    }

    /**
     * 워크스페이스 조회
     * 
     */
    async findAllWorkspace(userId:number) {
        const workspaces = await this.workspaceRepository.createQueryBuilder('workspaces_model')
        .select(['workspaces_model.id', 'workspaces_model.name'])
        .where('workspaces_model.id IN (SELECT uww.workspacesModelId FROM users_model_workspaces_workspaces_model uww WHERE uww.usersModelId = :userId)', { userId })
        .getMany();
      
        return {
          "message":"워크스페이스를 조회했습니다.",
          "data":workspaces
        }
    }

    /**
     * 워크스페이스 수정
     * 
     */
    async updateWorkspace(userId:number,workspaceId: number, createworkspaceDto:CreateWorkspaceDto) {

      await this.isOwner(userId,workspaceId);

        const workspace = await this.verifyWorkSpaceById(workspaceId);
      
          await this.workspaceRepository.update({ id:workspaceId }, 
            {
                name:createworkspaceDto.name,
                description: createworkspaceDto.description,
                color:createworkspaceDto.color
            });
      
          return {
            "message": "워크스페이스 정보를 수정했습니다."
          }

    }

    /**
     * 워크스페이스 삭제
     * 
     */
    async deleteWorkspace(userId:number,workspaceId: number) {

      await this.isOwner(userId,workspaceId);

      const queryRunner = this.dataSource.createQueryRunner();

      await queryRunner.connect();
      await queryRunner.startTransaction();

      try{
        await this.verifyWorkSpaceById(workspaceId);

        await queryRunner.manager.delete('users_model_workspaces_workspaces_model', { workspacesModelId: workspaceId });

        await queryRunner.manager.delete(WorkspacesModel,{id:workspaceId});

        await queryRunner.commitTransaction();

        return {
          "message":"해당 워크스페이스 삭제되었습니다."
        }

      }catch(error){
        await queryRunner.rollbackTransaction();
        console.log(`error : ${error}`)
        if (error instanceof HttpException) {
          // HttpException을 상속한 경우(statusCode 속성이 있는 경우)
          throw error;
        } else {
          // 그 외의 예외
          throw new InternalServerErrorException('서버 에러가 발생했습니다.');
        }
      }finally{
        await queryRunner.release();
      }
    }

    /**
     * 워크스페이스 멤버 추가
     * 
     */
    async inviteMembers(userId:number,workspaceId:number,membersDto:MembersDto) {

      await this.isOwner(userId,workspaceId);

      const members = membersDto.members;
      const isSpaceMembers = await this.listWorkSpaceMember(workspaceId);

      const values = await Promise.all(members.map(async (memberId) => {
        await this.findByUserId(memberId);
        
        // workSpaceMembers 배열에서 user_id가 memberId와 일치하는 사용자 찾기
        const isUserInWorkSpaceMembers = isSpaceMembers.some(member => member.user_id === memberId);

        if (isUserInWorkSpaceMembers) {
          // 사용자가 workSpaceMembers 배열에 존재하지 않는 경우에 대한 처리
          throw new NotFoundException(`사용자 ID ${memberId} (이)가 워크스페이스 멤버로 이미 있습니다.`);
        }
      
        return {
          usersModelId: memberId,
          workspacesModelId: workspaceId,
        };
      }));

      const queryRunner = this.dataSource.createQueryRunner();

      await queryRunner.connect();
      await queryRunner.startTransaction();

      try{

        console.log(`values : ${values}`);

        await queryRunner.manager
        .createQueryBuilder()
        .insert()
        .into('users_model_workspaces_workspaces_model')
        .values(values)
        .execute();
      
        await queryRunner.commitTransaction();

        return {
          "message":"워크스페이스 멤버 추가했습니다."
        }

      }catch(error){
        await queryRunner.rollbackTransaction();

        console.log(`error : ${error.name}`);

        if (error instanceof HttpException) {
          // HttpException을 상속한 경우(statusCode 속성이 있는 경우)
          throw error;
        } else {
          // 그 외의 예외
          new InternalServerErrorException('서버 에러가 발생했습니다.');
        }
      }finally{
        await queryRunner.release();
      }
  
  } 

    /**
     * 워크스페이스 존재여부 확인
     * 
     */
    private async verifyWorkSpaceById(id: number) {
        const workspace = await this.workspaceRepository.findOneBy({ id });
        if (!workspace) {
          throw new NotFoundException('존재하지 않는 워크스페이스입니다.');
        }
    
        return workspace;
    }

    /**
     * 사용자 ID 검증 (회원가입된 사용자 인지 확인)
     * 
     */
    private async findByUserId(userId: number) {

      const queryRunner = this.dataSource.createQueryRunner();

      await queryRunner.connect();
      await queryRunner.startTransaction();

      try{

        const user = await queryRunner.manager
        .createQueryBuilder()
        .select([
          'id AS user_id',
          'email AS email',
          'name AS name',
        ])
        .from(UsersModel, 'users_model')
        .where('users_model.id = :userId', { userId })
        .getRawMany();

        console.log(`user:${user[0].user_id}`)
        if (!user[0].user_id) {
          throw new NotFoundException('존재하지 않는 사용자입니다.');
        }
  
        await queryRunner.commitTransaction();

        return user;

      }catch(error){

        console.log(`error22222 : ${error}`);

        await queryRunner.rollbackTransaction();

        if (error instanceof HttpException) {
          // HttpException을 상속한 경우(statusCode 속성이 있는 경우)
          throw error;
        } else {
          // 그 외의 예외
          if(error.name==='TypeError') throw new NotFoundException(`가입 안된 사용자 ID (userId:${userId})가 있습니다.`);
          else throw new InternalServerErrorException('서버 에러가 발생했습니다.');
        }

      }finally{
        await queryRunner.release();
      }
    }

    /**
     * 워크스페이스 운영자 검증
     * 
     */
    private async isOwner(userId:number,workspaceId:number){

      const owner = await this.workspaceRepository.findOneBy({
        ownerId:userId,
        id:workspaceId
      })

      if(!owner){
        throw new NotFoundException('워크스페이스 운영자가 아닙니다.');
      }

      return true;

    }

    /**
     * 워크스페이스 멤버 검증
     * 
     */
    private async listWorkSpaceMember(workspaceId:number){

      const queryRunner = this.dataSource.createQueryRunner();

      await queryRunner.connect();
      await queryRunner.startTransaction();

      try{

        const user = await queryRunner.manager
        .createQueryBuilder()
        .select([
          'usersModelId AS user_id'
        ])
        .from('users_model_workspaces_workspaces_model', 'users_model')
        .where('users_model.workspacesModelId = :workspaceId', { workspaceId })
        .getRawMany();
  
        await queryRunner.commitTransaction();

        return user;

      }catch(error){

        await queryRunner.rollbackTransaction();

        if (error instanceof HttpException) {
          // HttpException을 상속한 경우(statusCode 속성이 있는 경우)
          throw error;
        } else {
          // 그 외의 예외
          throw new InternalServerErrorException('서버 에러가 발생했습니다.');
        }

      }finally{
        await queryRunner.release();
      }

    }

}

 

 

create-workspace.dto.ts

import { IsNotEmpty, IsString } from 'class-validator';

export class CreateWorkspaceDto {
  @IsString()
  @IsNotEmpty({ message: '워크스페이스명을 입력해주세요.' })
  name: string;

  @IsString()
  @IsNotEmpty({ message: '워크스페이스 내용를 입력해주세요.' })
  description: string;

  @IsString()
  @IsNotEmpty({ message: '색상을 입력해주세요.' })
  color: string;
}

 

 

invite-member.dto.ts

import { ArrayMinSize, ArrayNotEmpty, IsArray, IsInt, IsNotEmpty, IsObject, IsString } from 'class-validator';

export class MembersDto {
  @IsArray()
  @ArrayNotEmpty()
  @ArrayMinSize(1)
  members: MemberDto[];
}

export class MemberDto {
  @IsObject()
  @IsInt()
  userId: number;

  @IsObject()
  @IsInt()
  workspaceId: number;
}

 

 

workspaces.entity.ts

import { IsNumber, IsString } from 'class-validator';
import { BaseModel } from 'src/common/entities/base.entity';
import { ListsModel } from 'src/lists/entities/lists.entity';
import { UsersModel } from 'src/users/entities/users.entity';
import { Column, Entity, JoinColumn, ManyToMany, ManyToOne, OneToMany } from 'typeorm';

@Entity()
export class WorkspacesModel extends BaseModel {
    @Column()
    @IsString()
    name: string;

    @Column()
    @IsString()
    description: string;

    @Column()
    @IsString()
    color: string;

    @Column()
    @IsNumber()
    ownerId:number;

    /**
     * admin
     */
    @ManyToOne(() => UsersModel, (user) => user.myWorkspaces)
    @JoinColumn({ name: 'ownerId' })
    owner: UsersModel;

    /**
     * 멤버
     */
    @ManyToMany(() => UsersModel, (user) => user.workspaces)
    users: UsersModel;

    /**
     * 리스트
     */
    @OneToMany(() => ListsModel, (list) => list.workspace,{
        cascade: true
    })
    lists: ListsModel[];
}

 

workspaces.entity.ts의 경우 전날 리스트 @oneToMany에 onDelete:'CASCADE'로 지정하였는데, 팀원의 코드리뷰 시간 cascade:true로 바꿔야 한다는 걸 알게 되었습니다. workspace 테이블의 경우 1대다 관계에서 1의 위치이므로 이 경우 cascade가 일어나는 곳이 아닌 일어나게 하는 곳이라 cascade를 실질적으로 허용할지 안 할지만 정하면 된다는 걸 알게 되었습니다. 

 

 

앞으로의 계획

 

팀회의 간 프론트엔드 작업은 따로 진행하지 않고, 백엔드 작업만 하기로 해서 지금 구현한 기능과 팀원이 작업한 기능을 내일 합쳐보고 발생하는 오류에 대해서 대처하는 방향으로 진행하기로 했습니다. 사실 오류가 없어야 정상이지만 여러 프로젝트를 하고 보니 여러 팀원 작업이 합쳐지만 꼭 어딘가에서 오류가 발생했기에 이번엔 최소한의 오류만 나타나길 바랄뿐입니다.

 

 

▼ 이전 진행한 프로젝트 ▼

 

 

내일배움캠프 NestJS 프로젝트 코드리뷰 - 온라인 공연 예매 서비스

내일배움캠프 NestJS 프로젝트 코드리뷰 - 온라인 공연 예매 서비스 내일배움캠프를 진행한지도 벌써 3개월 정도로 접어들고 있습니다. 이 글에서는 내일배움캠프에서 필자가 진행한 NestJS 개인

lemonlog.tistory.com

 

 

내일배움캠프 백오피스 프로젝트 - 펫시터 매칭 사이트 후기, 소감

내일배움캠프 백오피스 프로젝트 - 펫시터 매칭 사이트 후기, 소감 일주일간 팀원과 작업한 펫시터 매칭 사이트가 끝났습니다. 여러 우여곡절이 있었지만 목표한 대로 마쳤기에 만족하고 있습

lemonlog.tistory.com