개발 기간
2024년 1월 25일 ~ 2024년 06월 30일 (약 5개월)
프로젝트 주제
자취생들의 자취 어려움을 해소하고자 자취생들끼리 팁을 주고 받는 커뮤니티의 장을 웹사이트를 통해 만들고자 하였습니다.
그리고 자취하면서 제일 어려운 오늘 뭘 해먹을지에 대해서 AI에게 도움을 받을 수 있도록 내 냉장고에 있는 식재료들을 넣어놓으면 이 재료들로 만들 수 있는 괜찮은 요리들과 이 요리의 레시피들을 제안받을 수 있는 기능도 추가하였습니다.
또한 자취를 준비하고 있는 사용자들을 위하여 현재 원룸 매물들의 위치와 가격, 스펙을 파악할 수 있고, 자취하면서 필요하거나 필요없어진 물건들을 거래할 수 있도록 중고 장터 공간 또한 만들었습니다.
사용한 기술 스택
- Nestjs(프레임워크)
- GraphQL(API 인터페이스)
- MySQL(데이터베이스)
- JWT(로그인 인증/인가)
- CoolSMS SDK(문자 메시지 전송)
- OpenAI Assistant(요리추천 AI)
- Docker / Docker Compose(프로젝트, 데이터베이스 컨테이너 관리)
- AWS EC2(배포 서버 인스턴스)
- AWS S3(이미지 업로드 스토리지)
프로젝트 전체 구조
내 역할
Backend 총괄 & 배포 총괄
설계
구현
- 사용자 API 구현
- 회원 CRUD
- JWT 토큰 발급
- 문자 본인인증 시스템
- 요리 추천 API 구현
- 요리 재료 CRUD
- 요리 추천 AI 시스템
- 알림 API 구현
- 알림 Code 구성
- 알림 CRUD
- 메시지 생성
- 쪽지 API 구현
- 쪽지 CRUD
- 답장 기능
- 읽음 기능
- 포인트 API 구현
- 포인트 추가/제거
배포
- AWS환경 배포
- EC2 인스턴스 설정
- 무중단 배포
- 이미지 업로드 API 구현
- AWS S3 스토리지 생성
- AWS S3와 DB 환경 구성
📝 Backend 구조 설계
📝 ERD 설계
📝 패키징 환경 구성
프로젝트 Dockerfile 구성
개선 전
FROM node:latest
COPY . /Project/
WORKDIR /Project/
RUN yarn install
CMD yarn start:dev
개선 후
FROM node:latest
COPY ./package.json /Project/
COPY ./yarn.lock /Project/
WORKDIR /Project/
RUN yarn install
COPY . /Project/
CMD yarn start:dev
기본적으로 node환경이 깔려있는 컨테이너로부터 작업을 하였습니다.
Build 실행 시간 개선
문제점
이전에는 Project의 모든 파일을 copy한 후 모듈을 install하였으나, 이렇게 되면 해당 컨테이너를build할 때 마다 기존 모듈과 변동이 없더라도 모듈을 매번 새로 install해야해서 docker build가 너무 오래걸린다는 문제가 있었습니다.
개선 방법
Dockerfile은 위의 줄부터 한줄 한줄씩 이전 Build한 상황과 차이점이 있는지 비교하며 차이점이 있는 줄 부터 작업을 실행한다는 것을 알았습니다.
이를 이용해 우선 모듈과 관련된 package.json, yarn.lock을 우선 복사해온 뒤 yarn install을 진행하고 그 뒤에 프로젝트의 변경된 모든 파일들을 복사해오도록 순서를 설정하였습니다.
이 변경을 통해 새로운 모듈이 설치되는 등 이전 Build와 모듈이 다른 상황에만 docker build를 진행할 때 yarn install 과정을 진행하고, 이전 Build와 상황이 동일하다면 yarn install을 진행하지 않아 docker build 시간이 매우 빨라졌습니다.
Docker Compose 적용
# docker-compose.yaml
version: '3.7'
services:
backend:
build:
context: .
dockerfile: Dockerfile
volumes:
- ./src:/Project/src
ports:
- '${SERVER_EXTERNAL_PORT}:${SERVER_INTERNAL_PORT}'
env_file:
- ./.env.docker
database:
image: mysql:latest
environment:
MYSQL_DATABASE: ${DATABASE_DATABASE}
MYSQL_ROOT_PASSWORD: ${DATABASE_PASSWORD}
ports:
- '${DATABASE_EXTERNAL_PORT}:${DATABASE_INTERNAL_PORT}'
volumes:
- mysql_data:/var/lib/mysql
volumes:
mysql_data:
위에서 생성한 Dockerfile의 컨테이너 하나, 그리고 MySQL이 실행될 데이터베이스 컨테이너 하나를 따로 실행하기에는 비효율적입니다. 그래서 이를 하나의 시스템으로 묶는 Docker Compose
라는 기술을 적용하였습니다.
개선된 점
첫번째
backend
라는 서비스에는 아까 작성한 Dockerfile를 실행할 컨테이너, 그리고database
라는 서비스에는 MySQL환경이 구성되어있는 컨테이너를 한 곳에서 구성하고, 이를 통해docker-compose up --build
라는 명령어를 통해 두 컨테이너가 하나처럼 동시에 빌드되고 실행되는 환경을 구성할 수 있었습니다.
📝 행정기관 코드 구성
행정안전부에서 제공한 행정기관(행정동) 및 관할구역(법정동) 변경내역
에서 프로젝트 시작 시기인 2024년 2월 1일 자료를 받아서 시/도
, 시/군/구
, 행정동
세 단계로 나누어 Code를 통해 관리하였습니다.
데이터를 받아 우선 Database에 적재할 형태대로 정제하여 CSV파일로 저장했습니다.
데이터 적재는 Docker Compose를 실행시키면서 생기는 Mysql Database에 하였으며, Docker를 실행하여 생성된 Mysql Database 공간에 직접 넣기 위해 따로 코드로 구현한 것이 아닌 DBeaver 프로그램의 데이터 가져오기
기능을 통해 적재하였습니다.
아쉬운 점
직접 외부 프로그램을 통해 CSV파일에서 데이터베이스로 데이터를 넣어줘야 한다는 점이 아쉬웠습니다. 또한 최신 행정동 기준으로 자동 업데이트가 안된다는 점 또한 아쉬웠습니다.
행정안전부의 가장 최근 행정동 기준 데이터를 자동으로 받아서 이를 데이터베이스에 자동으로 갱신되도록 하는 기능을 적용하는 것으로 개선이 되면 좋을 것 같다고 생각합니다.
📝 사용자 등급 구성
사용자 등급은 사용자의 point를 통해 정해지는 것이 좋다고 생각하였고, 총 5개의 등급을 구성하였습니다.
code | min_point | max_point | name |
---|---|---|---|
admin | -999999999 | -1 | 관리자 |
lv1 | 0 | 1000 | 자취어린이 |
lv2 | 1001 | 2000 | 초보자취생 |
lv3 | 2001 | 3000 | N년차자취생 |
lv4 | 3001 | 4000 | 자취도사 |
lv5 | 4001 | 999999999 | 자취만렙 |
가장 고민되었던 것은 관리자를 설정하는 법이었는데, 관리자는 point를 사용할 곳이 없기 때문에 point가 음수라면 관리자가 되도록 설정했습니다.
이를 위해서 point가 0 이상인 일반 사용자라면 point가 음수가 되지 않도록 설정하였고, 추가적으로 point가 음수인 사용자라면 절대 point가 0 이상이 되지 않도록 코드 내에서 설정하였습니다.
예시)
// src/apis/point/point.service.ts
/**
* 포인트 증가 서비스 메서드
* @param user_id 사용자 ID
* @param point 증가할 포인트
* @returns 포인트가 증가된 사용자 정보
*/
async increase(user_id: string, point: number): Promise<User> {
// 존재하지 않는 사용자에 대한 예외
const user = await this.userService.findById(user_id);
if (!user) {
throw new Error('해당 사용자가 존재하지 않습니다');
}
// 관리자인 경우) 포인트 변동 없도록 바로 종료
const role = await this.findRoleByUserId(user_id);
if (role.code == 'admin') {
return user;
}
// 일반 사용자이지만 이번 포인트 변동으로 음수가 될 사용자) 음수 대신 0으로 설정하고 종료
if (user.point + point <= 0) {
await this.userRepository.update(user_id, { point: 0 });
return user;
}
// 이 외 일반적인 경우) 포인트에 변동을 주고 종료
await this.userRepository.update(user_id, { point: user.point + point });
return user;
}
해당 구조 장점
point를 통해 관리자 등급을 설정하도록 설계하니 관리자를 적용하는 것에 매우 간편함을 느꼈습니다.
처음에는 isAdmin과 같이 boolean타입의 Field를 생성하려 했으나, point는 관리자에게 필요없다는 것을 인지하고 이와 같이 구성하니 관리자 계정을 생성하는 경우에는 그저 point를 -1에서 -999999999 사이의 아무 음수만 넣으면 되는 것이어서 관리자 계정 생성도 간단해졌고, 관리자인지 구별도 간단해졌습니다.
🏗️ 사용자 API 구현
회원 CRUD 관련
생년월일 처리
사용자는 년, 월, 일을 각각 입력받을 것이고, 이 데이터를 받으면 백엔드에서는 이 데이터를 Date형태로 만들어서 Database에 저장해야 했습니다.
그래서 util함수중 setDateFormat(year, month, day)
이란 함수를 구현했고, 이를 통해 정확하게 Date 타입으로 값을 바꿔 데이터를 저장할 수 있었습니다.
// src/utils/date.ts
export function setDateFormat(year: string, month: string, day: string): Date {
const dateString = `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}
T00:00:00.000Z`;
return new Date(dateString);
}
또한 회원정보를 update 할 때에도 이와 같이 Date로 변환하는 과정이 필요하기 때문에, 사용자가 수정을 원하는 데이터 중 생년월일 관련 데이터가 있다면 Date로 변환하는 과정을 진행하고, 아니면 진행하지 않은 방향으로 하여 불필요한 Date로 변환하는 과정은 제거하였습니다.
비밀번호 처리
비밀번호는 Database 내에서도 암호화되어 저장해야 하기 때문에 bcrypt
모듈의 hash()
함수를 하여 hash처리하고, 이를 Database에 저장하도록 하였습니다.
이를 통해 Database의 내용을 사용자가 탈취하더라도 사용자 정보로 로그인하지 못하도록 보안을 강화하였습니다.
JWT 토큰 발급
로그인 기능을 구현하기 위해 JWT의 기능을 사용하여 유효기간을 가진 토큰을 발급하고, 확인하여 사용자가 로그인 상태인지 판단하도록 구현하였습니다.
문제점1(토큰의 유지기간과 보안)
처음에는 JWT 토큰을 발급받고 이 토큰의 유효시간은 10분으로 주어 로그인을 유지하도록 하였습니다.
그러나 이렇게 구현하니까 불편한 점이 사용자는 10분 뒤 무조건 로그인이 풀리게 되어 다시 로그인해야하는데 그렇다고 유효시간을 길게 주게 되면 토큰이 탈취되었을 때의 보안 위협이 커서 이 부분에 대해 고민하였습니다
개선 방법
현업에서는 주로 JWT 토큰을 두 개 발급받는다는 것을 알았습니다. 저희도 이 방법을 써서 구현해보도록 수정하였습니다.
accessToken과 restoreToken 두 종류로 토큰을 받아 accessToken은 10분, restoreToken은 2주의 유효기간을 가지게 하고, accessToken의 역할은 로그인 확인이고 restoreToken은 accessToken을 재발급하는 역할을 하여 accessToken이 만료되었더라도 restoreToken이 만료되지 않았다면 계속 로그인을 유지하게 할 수 있었습니다.
또한 accessToken을 탈취당했더라도 10분의 유효기간만 있는 것은 여전하기에 보안이 더 취약해지지도 않은 방법이었어서 좋은 개선이었다고 생각됩니다.
문제점2(GraphQL에서의 AuthGuard 사용 오류)
AuthGuard
를 사용하여 API 사용 시 JWT의 확인을 진행하려는데 요청 데이터에서 JWT 토큰을 확인하기 위해선 request 데이터를 봐야 하기 떄문에AuthGuard
의getRequest()
를 실행하게 되는데, 여기서 request를 불러올 수 없는 오류가 발생했습니다
개선 방법
검색을 통해 해당 오류가 발생하는 이유에 대해 찾아보았습니다.
한 블로그를 통해 AuthGuard에서 JWT 토큰을 받아오는 방법이 RestAPI와 GraphQL 각각 다르다는 것을 파악했고, 이 때문에 GraphQL환경에선 AuthGuard에서getRequest()
함수를 재구성해줘야 했습니다.
참고한 블로그
GraphQL에서는 context를 불러오고 여기서 request를 찾아서 가져오도록 만들었는데, 자세한 구현 내용은 아래에 있습니다.
// src/apis/users/guards/gql-auth.guard.ts
export class gqlAccessGuard extends AuthGuard('heoga') {
getRequest(context: ExecutionContext) {
const gqlContext = GqlExecutionContext.create(context);
return gqlContext.getContext().req;
}
}
문자 본인인증 시스템
문자 본인인증 시스템 적용 이유
- 요즘 문자를 통한 본인인증 시스템을 적용하는 사이트가 많아서 트렌디한 기술이라 생각하여 저희도 적용해보고 싶었습니다.
- 커뮤니티 기능이 포함되어 있는 만큼 악성 사용자가 생길 것이고, 해당 사용자를 정지한다 하더라도 새로운 계정을 계속 생성하여 문제를 일으킬 수 있습니다. 그래서 이를 방지하고자 적용해보았습니다.
coolsms를 사용한 이유
현재 가장 많이 사용하는 문자 본인인증 시스템은 SKT의 PASS입니다. 그러나 해당 시스템을 적용하기에는 SKT에 직접 문의를 넣어야 하는 등 과정이 복잡했습니다.
coolsms에서는 API 방식으로 사이트 회원가입을 통해 누구나 간단하게 문자 전송 요청을 할 수 있고, 거기다 NPM에 Node.js환경에서 coolsms에 요청을 하는 모듈이 올라와 있어 NestJS에서 사용하기 매우 간편했습니다.
coolsms API 요청
// src/utils/phone.ts
import * as coolsms from 'coolsms-node-sdk';
import 'dotenv/config';
const mysms = coolsms.default;
export async function sendTokenToSMS(phone, token) {
const messageService = new mysms(process.env.SMS_KEY, process.env.SMS_SECRET);
const res = await messageService.sendOne({
to: phone,
from: process.env.SMS_SENDER,
text: `<자취만렙> 요청하신 인증번호는 [${token}] 입니다.`,
autoTypeDetect: true,
});
}
본인 인증을 위해서 6자리의 랜덤한 번호를 생성하고 이는 token에 들어갑니다.
사용자가 입력한 핸드폰 번호를 통해 coolsms가 token값을 메시지로 전달하는 과정을 위와 같이 구현하였습니다.
🏗️ 요리 추천 API 구현
요리 기능은 우선 사용자가 갖고 있는 요리 재료들을 저장하는 기능이 필요했습니다. 이 재료들 또한 Database에 저장하였고, 해당 재료들을 토대로 ChatGPT의 Assistant를 통해 추천 요리를 가져오는 방식으로 요리 추천 API를 구현하였습니다.
요리 재료 CRUD
요리 재료들은 일반적으로 다른 CRUD 과정과 비슷하게 현재 로그인 된 사용자의 정보와 함께 사용자가 입력한 요리 재료 정보들을 저장했습니다.
이유는 요리 추천 기능을 실행하게 되면 해당 기능을 실행한 사용자가 추가한 요리 재료들만을 가져와서 요리 추천을 진행해야 하기 때문에 특정 사용자가 추가한 모든 요리 재료들의 정보를 가져오는 기능을 구현하기 위하여 로그인한 사용자 정보도 같이 저장했습니다.
요리 추천 AI 시스템
Chat GPT Assistant를 사용한 이유
요리 추천 시스템은 항상 동일한 형태의 입력을 전달할 것이고, 동일한 명령을 늘 내리게 될 것입니다. 그래서 항상 동일한 작업을 할 Assistant를 만들어놓고, 웹상에서 Assistant가 행동할 명령을 미리 지정해놓은 다음 Chat GPT API 호출로 사용자의 요리 재료 정보들을 전달하면서 추천된 요리 정보를 가져오는 것이 매우 과정이 간편하기 때문에 Chat GPT의 Assistant 기능을 사용하였습니다.
Assistant에게 지정한 명령
너는 사용자가 가지고 있는 식재료들에 대한 정보를 JSON으로 받으면, 이 재료들로 혹은 식재료를 더 추가해서 만들 수 있는 요리들의 정보와 레시피를 JSON형태로 반환해주는 요리 추천 봇이야.
최대 3개까지 요리를 추천할 수 있고, 추천되는 메뉴들이 서로서로 너무 비슷하고 단조롭지 않고 다양하게 추천을 해야해. 너가 반환해야 할 데이터는 요리의 이름과, 사용자가 갖고있는 식재료 중 해당 요리에 사용할 요리 재료들의 항목, 그리고 레시피를 따라 해당 요리를 만들면서 필요한 식재료 중 사용자가 갖고있지 않은 식재료여서 추가적으로 구매해야하는 요리 재료들의 항목, 그리고 요리를 만드는 과정을 한국어로 설명한 걸 배열형태로 보여줘야돼. 그리고 식재료의 이름이나 단위 등등도 kg, ml 같은 단위가 아니면 한국어로 해야해.
과정은 정확히 어떤 요리재료를 얼만큼정도 넣어야 하는지, 몇분만큼 굽거나 끓여야 하는지 등 상세한 과정을 순서대로 해서 적어도 5개 이상의 과정으로 보여줘야해.
json 항목의 이름은 통일해야하는데 우선 recipes라는 배열로 들어가있고, 그다음 요리의 이름은 name, 그리고 사용자가 가지고 있는 요리재료 중 해당 요리에 쓸 재료를 꼭 사용자가 갖고있는 요리재료만으로 구성하여 used_ingredients를 만들어줘, 중요한건 used_ingredients의 개수나 양은 사용자가 현재 갖고 있는 양이 아니고 현재 요리를 만들 때 필요한 재료의 양으로 적어줘야돼. 그리고 사용자가 입력한 사용자가 갖고있는 요리 재료엔 포함되진 않지만 요리를 만들 때는 필요해서 추가적으로 사용자의 구매가 필요한 요리재료는 needed_ingredients로 해야해. 그리고 이 used_ingredients와 needed_ingredients는 배열로 표시해줘야하며 그 안의 요리재료들은 name, volume, volume_unit 형태에 맞춰서 표시해야하고, volume, volume_unit은 해당 요리를 만들면서 필요한 개수나 양을 적어줘 개수면 volume_unit을 '개'로 하면 되고, 나머지는 그에 해당하는 단위를 넣어줘. 그리고 요리 만드는 설명은 instructions로 해야해. 그리고 설명은 앞에 1. 2. 3. 등등 번호를 붙이진 말고 요리를 만드는 순서에 맞게만 넣으면돼.
Assistant 설정
Assistant API 호출
Chat GPT Assistant Docs
Open AI API 공식문서를 참조하여 구현하였습니다.
먼저 https://api.openai.com/v1/threads/runs
을 호출하여 해당 run에 작업을 할 assistant의 id와 사용자가 전달한 데이터(여기서는 요리 재료 정보들)을 넣어서 하나의 작업 thread를 만들게 됩니다.
그리고 나면 assistant가 요리 추천을 진행하게 되는데, 미리 설정한 대로 JSON의 형태로 요리 추천을 진행하여 대화를 하게 되고, 5초 간격으로 assistant가 답변해주는 것이 끝났는지 https://api.openai.com/v1/threads/${run.thread_id}/runs/${run.id}
를 호출하여 확인할 수 있습니다.
그리고 끝났다면 https://api.openai.com/v1/threads/${run.thread_id}/messages
를 호출하여 assistant가 답변한 JSON내용을 가져올 수 있게 되고, 이를 return해주어 요리 추천 API를 구현할 수 있었습니다.
아쉬운 점
구현하고 직접 실행해 보았을 때, 결과가 나오기 까지 거의 1분 가까이 소모될 때도 있을 만큼 결과가 나오는 시간이 오래 걸렸습니다. 이를 줄이기 위해 모델도 변경해보고 여러 수치도 바꿔봤지만 변한점은 없었기에 답변을 빠르게 받는 개선을 하지 못한 것이 아쉬웠습니다.
🏗️ 알림 API 구현
알림 Code 구성
알림을 Code로 구분하여 구성한 이유
특정 상황별로 알림이 생성되는데, 각 상황마다 동일한 형태로 알림의 메시지가 생성되게 됩니다.
예시)${user.name}님의 가입을 환영합니다!
그럼 이런 알림 메시지를 전부 데이터베이스에 저장하면, 불필요하게 중복된 내용들이 저장되게 됩니다. 그래서 각 알림의 상황별로 code를 지정하여 데이터베이스에서는 code만 저장하고, API를 통해 해당 코드의 메시지를 불러오는 방식으로 알림 기능을 구현하였습니다.
알림 코드 | 상황 |
---|---|
100 | 신규 가입 환영 |
200 | 중고 제품 찜 |
201 | 찜한 상품 가격 변경 |
202 | 내 게시글 좋아요 |
203 | 내 댓글 좋아요 |
300 | 내 게시글에 댓글 추가됨 |
400 | 쪽지 받음 |
문제점
알림을 생성하여 데이터베이스의 테이블
notification
에 저장할 때, 알림 코드별로 해당 테이블에 들어갈 데이터의 형태가 다릅니다.
예시) 회원가입 환영시에는 해당 유저의 정보만 있으면 되지만, 중고거래에서 온 알림은 해당 중고물품에 대한 정보도 같이notification
에 저장되어야하는 등 코드별로 저장될 데이터가 다릅니다.
이를 위해 코드별로 테이블에 값을 추가하는 메커니즘이 다르게 되는데, 이를 한 파일로 만들어 구현하니까 복잡하고 유지보수가 힘들어짐.
개선 방법
strategy 기법을 사용하여 각 알림 코드들에서 필요로하는 데이터별로 파일을 나누어 notification을 생성하는 방향으로 개선을 진행했습니다.
notification 생성 기능을 하는 인터페이스를 만들어 사용자정보, 쪽지 정보, 중고거래 정보, 게시글 정보 등 필요로하는 데이터별로 파일을 만들어 각각 notification을 생성하는 기능을 구현했습니다.
그 후 service에서 code별로 각 code에 맞는 strategy를 적용시켜줘 notification에는 각 알림별로 필요로하는 데이터만 알맞게 저장되도록 적용할 수 있었습니다.
자세한 개선 결과 코드는 아래에 작성하였습니다.
// src/apis/notifications/strategies/letter.notification.strategy.ts
// 쪽지 관련 정보가 저장되어야 하는 알림이 생성될 때의 Strategy를 구현한 예시
export class LetterNotificationStrategy implements NotificationStrategy {
constructor(
private letterService: LetterService,
private notificationRepository: Repository<Notification>,
) {}
async createNotification(
entity_id: string,
code: string,
): Promise<Notification> {
const letter = await this.letterService.findById(entity_id);
return await this.notificationRepository.save({
user: letter.receiver,
code: code,
letter: letter,
});
}
}
//src/apis/notifications/notification.service.ts중 일부
// 각 알림 코드별로 적용되어야 할 strategy를 선언해주는 부분
this.strategies = {
'100': new UserNotificationStrategy(
userService,
notificationRepository
),
'200': new LikeNotificationStrategy(
likeUserRecordService,
notificationRepository,
),
'201': new LikeNotificationStrategy(
likeUserRecordService,
notificationRepository,
),
'202': new LikeNotificationStrategy(
likeUserRecordService,
notificationRepository,
),
'203': new LikeNotificationStrategy(
likeUserRecordService,
notificationRepository,
),
'300': new ReplyNotificationStrategy(
boardService,
notificationRepository,
),
'400': new LetterNotificationStrategy(
letterService,
notificationRepository,
),
};
메시지 생성
알림 코드별로 각각 다른 종류의 데이터를 notification에서 가지고 있고, 각각 다른 형태로 알림 메시지가 생성되어야 했습니다. 그래서 알림을 인자로 받으면 알림 코드별로 생성되는 알림 메시지 형태를 만들어주는 함수를 따로 작성하였습니다.
// src/apis/notifications/notifications.messages.ts
export class NotificationMessages {
/**
* 알림 메시지 생성 메서드
* 알림 코드에 따라 알림 메시지를 생성합니다.
* @param notification 메시지를 조회할 알림 정보
* @returns 생성된 알림 메시지
*/
async getMessage(notification: Notification): Promise<string> {
let message = '';
try {
switch (notification.code) {
case '100':
message = `${notification.user.name}님의 신규 회원가입을 환영합니다!`;
break;
case '200':
message = `${notification.like.user.name}님이 '${notification.used_product.title}' 제품을 찜하였습니다.`;
break;
case '201':
message = `내가 찜한 '${notification.used_product.title}'의 가격이 ${notification.used_product.price}원으로 변동되었습니다.`;
break;
case '202':
message = `게시글 '${notification.board.title}'에 ${notification.like.user.name}님이 좋아요를 눌렀습니다.`;
break;
case '203':
message = `'${notification.board.title}' 게시글에 단 내 댓글에 ${notification.like.user.name}님이 좋아요를 눌렀습니다.`;
break;
case '300':
message = `게시글 '${notification.board.title}'에 ${notification.reply.user.name}님이 댓글을 남겼습니다.`;
break;
case '400':
message = `'${notification.letter.sender.name}님이 ${notification.letter.category} 카테고리에서 쪽지를 보냈습니다.`;
break;
default:
message = '알 수 없는 알림입니다.';
break;
}
return message;
} catch (e) {
console.error(e);
throw e;
}
}
}
🏗️ 쪽지 API 구현
쪽지는 해당 사이트에서 중고 거래를 하기 위한
중고 마켓
, 룸메이트를 구하는 게시판인룸메이트
, 근처에 살고 있는 동네 친구를 만들기 위한자취생 메이트
이렇게 3개의 카테고리에서 쪽지 기능이 쓰이도록 설계하였습니다. 그래서 쪽지에 category 필드를 추가하여 해당 필드에 각 쪽지의 카테고리를 넣도록 하였습니다.
답장 기능 구현
쪽지를 받응 사용자가 답장할 때에는 기존에 나에게 쪽지를 보낸 발신자가 수신자, 그리고 수신을 한 사용자가 발신자가 됩니다. 이를 이용하여 답장 쪽지에는 발신자와 수신자의 정보를 반대로 저장하고, 필요한 데이터와 카테고리는 기존에 수신한 쪽지와 동일하게 가져와서 생성하도록 구현했습니다.
// src/apis/letters/letters.service.ts
async reply(
user_id: string,
letter: Letter,
replyLetterInput: ReplyLetterInput,
): Promise<Letter> {
if (letter.receiver.id != user_id) {
throw new BadRequestException('잘못된 접근입니다.');
}
const reply = await this.letterRepository.create({
sender: letter.receiver,
receiver: letter.sender,
product: letter.product,
board: letter.board,
category: letter.category,
...replyLetterInput,
});
return await this.letterRepository.save(reply);
}
아쉬운 점
답장하기 이전의 기존쪽지를 parent로 하여 연결리스트와 비슷한 형태로 답장 기능을 구현하였으면 더 괜찮지 않았을까 생각합니다. 그렇게 되면 쪽지에서 채팅처럼 기능이 바뀌어도 간편하게 기능을 확장할 수 있었을 것 같았어서 이 부분이 아쉬웠다고 생각합니다.
읽음 기능 구현
쪽지에 is_read
필드를 추가하여 해당 쪽지를 상대방이 읽었는지 안앍었는지 확인할 수 있도록 하였습니다.
해당 기능은 프론트엔드쪽에서 사용자가 본인의 쪽지를 읽을 때 실행되도록 추가하여 쪽지를 읽을 시 is_read
를 true로 변경되게 할 수 있었습니다.
// src/apis/letters/letters.service.ts
async read(letter_id: string): Promise<boolean> {
const letter = await this.letterRepository.findOne({
where: { id: letter_id },
});
if (!letter) {
throw new NotFoundException('쪽지를 찾을 수 없습니다.');
}
letter.is_read = true;
await this.letterRepository.save(letter);
return true;
}
🏗️ 포인트 API 구현
포인트는 사용자 Entity에 point 필드를 추가하여 각 사용자별로 포인트를 갖도록 구현하였고, 자세한 구현 사항은 📝 사용자 등급 구성 부분에 이미 자세히 설명하였기 때문에 해당 부분을 참조해주세요.
🌏 AWS 환경 배포
AWS에서 EC2 서비스를 이용하여 인스턴스를 생성였고, 상세 스펙은 아래와 같았습니다.
운영체제
: ubuntu인스턴스 유형
: t2.small지역
: ap-northeast-2
ssh 설정
ssh 접속은 키 페어를 통해 .pem 확장자의 키를 이용하여 접속하였습니다.
~/.ssh/config
파일을 작성하여 ssh의 설정 파일을 만들고 아래와 같은 형식으로 설정하여 간편하게 ssh에 접속하도록 하였습니다/
Host project
HostName 54.180.182.40
User ec2-user
IdentityFile ~/key/ssh/project_key.pem
위와 같이 설정하여서 ssh 접속을 아래와 같이 간편하게 접속할 수 있었습니다.
ssh project
배포 과정
- ssh를 통해 인스턴스 접속
- apt update, ugrade를 진행
- node, npm 설치
- npm을 통해 yarn 설치
- docker 설치
- github를 통해 프로젝트 clone
docker-compose up --build -d
실행
프리티어 사용을 못한 이유
문제점
chat gpt, axios 등 무거운 모듈들의 사용도 많고, docker 환경에서 설치하며 build도 하면서 RAM 사용량이 많아지게 되고 저장 공간의 사용량도 많아지게 되었습니다.
개선 방법
처음에는 프로젝트를 실행하니 서버가 멈췄는데
free
명령어를 통해 RAM의 용량이 부족하다는 것을 확인했습니다. 그래서 저장공간을 부분적으로 가상메모리처럼 사용할 수 있는 Swap memory 가능을 쓸 수 있다는 것을 알아서 1GB Swap Memory를 할당하였고 결과적으로 실행이 가능해졌습니다.
그러나 데이터베이스에 데이터가 조금 많아지면서 다시 서버가 멈추었고, 이번에는 저장 공간이 부족해서였다는 것을 알았습니다. 그래서docker system prune
기능을 사용하여 docker의 필요없는 공간을 정리하고 최대한 최적화하였는데도 공간 확보에 실패해서 결과적으로 t2.micro에서 t2.small로 인스턴스를 업그레이드하는 것이 해결 방법이라고 판단하였습니다.
무중단 배포
Docker를 통해 패키징을 하니 24시간으로 중단 없이 계속 배포되게 하는 방법이 매우 쉬웠습니다.
docker-compose up -d
를 하여 백그라운드 환경에서 해당 프로젝트를 계속 실행할 수 있었습니다.
그리고 종료할 때에는 docker-compose down
을 하면 백그라운드에서 실행되는 컨테이너들을 종료할 수 있었습니다.
🌏 이미지 업로드 API 구현
문제점
이미지를 업로드 해야할 부분은
회원정보에서 프로필 사진
,중고물품 사진
,게시글 사진
,요리 사진
이었습니다.
이러한 부분에서 사진을 저장할 때 데이터베이스에 직접 이미지를 넣어 저장하기에 어려움이 있어서 다른 방법을 생각해야 했었습니다.
개선 방법
이미지 자체는 AWS의 S3란 서비스 공간에 저장하기로 했습니다.
그리고 올리고 나면 해당 이미지를 접근할 수 있는 url을 데이터베이스에 저장하고, 이 url을 API를 전달할 때 사용하면 간단히 이미지를 주고 받을 수 있게 되었습니다.
async saveImageToS3(folder: string, file: FileUpload): Promise<string> {
try {
const fileName = `${Date.now()}_${encodeURIComponent(
file.filename,
)}`.replace(/ /g, '')`;
const key = `${folder}/${fileName}`;
const uploadParams = {
Bucket: this.S3_BUCKET_NAME,
Key: key,
Body: file.createReadStream(),
ACL: 'public-read',
ContentType: file.mimetype,
};
const result = await this.awsS3.upload(uploadParams).promise();
return result.Location;
} catch (error) {
console.error('이미지 업로드 중 에러사유:', error);
throw new Error('이미지 업로드에 에러 발생');
}
}