AfterTrip: 개발 최종 정리 [백엔드]

정현·2023년 5월 16일
0

Capstone

목록 보기
2/2
post-thumbnail

주제 소개

AfterTrip : 얼굴 인식, 객체 인식 AI를 이용한 여행 후 그룹별 사진 공유 웹서비스

여러 명이 여행을 다녀온 후 서비스에 사진을 업로드하면 얼굴 인식 AI가 얼굴별로 인물별 폴더를 만들고, 객체 인식 AI가 객체별 폴더를 만들어 사용자들이 원하는 사진을 쉽게 공유할 수 있는 서비스이다.

모바일 웹 서비스(웹앱)로 기획한 이유는

  • 안드로이드 유저와 ios 유저가 모두 이용할 수 있어야 하므로 한가지 플랫폼만 개발하면 안되고,
  • 여행 후에만 잠깐 이용할 서비스이기 때문에 앱보다 웹이 적절하다고 생각했다.
  • 또 사진 업로드 및 다운로드에는 모바일이 편리하므로 모바일 웹이 되었다.

System Architecture

나는 백엔드 개발을 맡았다. Django, Nginx, gunicorn, Flask, Docker, Github Actions 등을 사용했고,
AWS의 EC2, RDS, S3, AWS Certificate Manager, Elastic Load Balancer를 사용한 웹 서버 배포 및 Tencent Cloud 서버에 AI 코드 배포도 하였다!

ERD

서비스의 Database의 구조이다.

회원과 그룹은 다대다 참조 관계인데 중간 테이블에 초대수락여부를 추가하면서 다대일, 다대일로 풀어썼다.

사진과 Face태그, 사진과 Yolo태그 역시 다대다 참조관계인데 여긴 중간테이블에 넣을 내용이 없어서 Django의 ManyToManyField를 이용하였다.
(참고로 Django는 ManyToManyField를 이용해도 through를 이용해 중간 테이블에 속성을 넣을 수 있다! 직접 풀어쓰는 것과 크게 다르지는 않다.)

사진은 S3에 따로 저장하고 DB에는 S3의 url만 저장한다.

DRF (Django REST Framework)

백엔드 프레임워크로 Django를 사용했다.

프로젝트 구조

이 명령어로 django 프로젝트를 시작할 수 있다. (프로젝트 제목은 AfterTrip이지만, 여기는 바뀌기 전 제목인 tripfriend로 되어있다)

django-admin startproject tripfriend

그리고 프로젝트 안의 유닛인 app을 이 명령어를 통해 생성할 수 있다.

python manage.py startapp (앱 이름)

django app을 잘 이용하면 프로젝트의 재사용성을 높일 수 있다. 또 모든 코드가 한 곳에 들어가면 복잡해지므로, 적절하게 나누는 것이 가독성이나 유지 보수 측면 등에서 좋다.
사실 처음에는 api라는 이름의 앱 안에 모든 로직이 들어있었는데.. 역시나 복잡하길래 리팩토링을 거쳤다.

공통적으로 사용하는 것들은 base 앱에 넣고,
나머지는 작동하는 로직에 따라 세 개의 앱으로 나누었다.

  • base
  • accounts
  • trips
  • photos

모든 파일을 다 표시하기엔 너무 많아서..
전체적인 디렉토리 구조 +
django의 startproject와 startapp 명령어로 자동 생성되지 않은 파일들만 표시하겠다.
migrations 파일, 배포 관련 파일 등등은 여기서는 생략!

tripfriend
   ├─ tripfriend
   │  └─  settings          # 개발환경과 배포환경의
   │     ├─ __init__.py     # 설정을 분리할 수 있도록
   │     ├─ base.py         # settings.py 파일을 분리했다
   │     ├─ dev.py
   │     └─ prod.py
   │ 
   ├─ base/  
   │  ├─  permissions.py      # custom permissions
   │  └─ mys3client.py        # s3 이용할 때 사용
   │
   ├─ accounts/
   │  ├─ views                 # 하나의 파일이 너무 길어지지
   │  │  ├─ user_views.py      # 않았으면 좋겠어서 views.py
   │  │  └─ group_views.py     # 파일을 분리했다
   │  ├─ serializers.py        # (관련 view들끼리 묶음)
   │  └─ urls.py
   │
   ├─ trips/
   │  ├─ serializers.py   
   │  └─ urls.py
   │
   ├─ photos/
   │  ├─ views                  
   │  │   ├─ photo_views          # 여기는 view가 많아서
   │  │   │    ├─ __init__.py     # views 경로 안에 패키지를 
   │  │   │    ├─ photo_views     # 넣어 한번 더 구분했다
   │  │   │    ├─ photo_face_views
   │  │   │    ├─ photo_yolo_views
   │  │   │    └─ photo_uploader_views
   │  │   └─ (chatgpt_views.py) 
   │  ├─ serializers.py   
   │  ├─ urls.py
   │  ├─ auto_functions.py    # 자동으로 실행되는 함수
   │  ├─ operator.py          # auto_fuctions 실행 관련
   │  └─ requests.py          # AI 서버와의 소통
   └─ manage.py

각 파일과 기능 구현 관련 자세한 내용은 뒤에 더 나올 예정이다.

API 문서로 소통하기

프론트엔드가 API를 연결할 수 있도록 request, response 형식을 정리해둔 API 문서를 만들었다. 노션을 이용했다.

url은 화면에는 나오지 않은 Base url 뒤에 붙는다. 도메인을 구입해 사용했다!

개발하기

.env로 환경 변수 관리하기

github에 코드와 함께 Database 비밀번호나 secret key 등 민감한 정보들이 올라가면 보안에 좋지 않다. 노출을 막기 위해 .env 파일을 통해 환경 변수를 읽어오도록 했고 .gitignore을 이용해 .env 파일은 github에 올라가지 않도록 막았다.

.env 파일에 담은 변수:

- DJANGO_ALLOWED_HOSTS
- DJANGO_SECRET_KEY
- DATABASE_NAME
- DATABASE_USER
- DATABASE_PASSWORD
- DATABASE_HOST
- DATABASE_PORT
- AWS_ACCESS_KEY_ID        # S3 버킷 연결
- AWS_SECRET_ACCESS_KEY
- AWS_STORAGE_BUCKET_NAME
- OPEN_AI_KEY    # chatGPT API 이용 (최종 기능에서 빠짐)
- FLASK_HOST  # AI 서버

아 들어가는 값은 당연히 개발할 때와 배포할 때가 다르다!
배포 관련 내용은 뒤에서 이야기할 거지만 Github Actions를 이용한 자동배포를 했는데, .env는 github에 올라가지 않는 파일이므로 배포용 .env(내용 관리를 위해 로컬에 .env.prod라는 파일을 만들어 저장해두었다. 역시 .gitignore에 추가)의 파일 내용을 github secrets에 등록해 읽어오도록 한다.

API는 RESTful하게

REST API는 HTTP 프로토콜을 이용해 클라이언트와 서버가 효율적으로 소통할 수 있도록 하는 API이다. REST 설계규칙을 잘 따르는 API를 RESTful하다고 한다.

REST 설계규칙을 잘 따른다는 것은 뭘까?
우선 적절한 HTTP 메소드(GET, POST, PUT 등)를 사용해야 한다.
또, URI는 resource를 담고 있어야 하지만, 행위(ex: post 역할을 하는 uri에 /insert)는 담지 않아야 한다.

그밖에도 URI는 resouce는 복수형으로 작성한다든지, 언더바(_) 대신 하이픈(-)을 쓴다던지의 규칙이 존재한다.

또 원래 규칙에서는 trailing slash(마지막에 오는 /)도 없는 것으로 알고 있는데, Django의 url 설계 규칙에는 trailing slash를 붙이는 것으로 되어 있는 것으로 알고 있기 때문에 내 url들에는 trailing slash가 존재한다.

이런 규칙을 잘 지키면서 API를 RESTful하게 만들려고 노력을 해봤다...

Django의 Class Based View 방식으로 개발을 했는데 자세한 내용을 적기엔 이미 여기 저기 많이 있는 내용이기도 하고 너무 길어질 것 같아서 DRF API 개발 방법은 pass하겠다.

로그인, 권한 관리

Django의 simple jwt를 사용해 jwt 로그인을 구현했다.
또 permission_classes를 이용해 로그인 여부에 따른 권한 관리까지 했다.

그런데 그 밖에도 본인이 속한 그룹의 object들에 대해서만 접근을 할 수 있어야 하므로 이를 커스텀 permission을 이용해 관리하면 편할 것 같았다.

그래서 정의했다.

from rest_framework.permissions import BasePermission
from rest_framework.exceptions import PermissionDenied, NotAuthenticated
from django.shortcuts import get_object_or_404
from accounts.models import Group


class GroupMembersOnly(BasePermission):

    def has_permission(self, request, view):
        if request.user.is_authenticated:
            return True
        return False

    def has_object_permission(self, request, view, obj):  # trip object 전달
        if request.user.is_authenticated:
            user_groups = Group.objects.filter(usergroup__user_id=request.user.id, usergroup__is_confirmed=True)
            trip_group = get_object_or_404(Group, id=obj.group_id)
            if trip_group in user_groups:
                return True
            raise PermissionDenied()
        raise NotAuthenticated()

참고로 object permissions는 permission_classes = [GroupMembersOnly]를 적고 끝이 아니라 self.check_object_permissions(self.request, obj=trip) 같은 부분을 넣어 직접 확인해야 한다.

Soft Delete

class BaseModel(models.Model):
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)
    deleted_at = models.DateTimeField(null=True, default=None)

    class Meta:
        abstract = True

    def delete(self, using=None, keep_parents=False):
        self.deleted_at = now().strftime("%Y-%m-%d %H:%M:%S")
        self.save(update_fields=['deleted_at'])

base 앱 안에 BaseModel을 정의하고 다른 곳에서 임포트해 사용하였다.
이 BaseModel에는 created_at, updated_at, deleted_at이 존재하는데 각각 생성 시간, 수정 시간, 삭제 시간을 뜻한다.

Soft delete는 삭제 요청에 대해 DB에서 레코드를 직접 삭제하지 않고, deleted_at 또는 is_deleted 등 삭제를 판별할 수 있는 필드 값을 통해 삭제 여부를 업데이트하는 방식이다.
(deleted_at으로 삭제 시간을 저장하면 null 이 아닌 경우 삭제되었다고 판단하면 되고, is_deleted를 이용하면 boolean 값을 통해 판단하게 된다)

TagFace, TagYolo 모델을 제외하고 BaseModel을 상속받도록 했다. 따라서 부모 클래스인 BaseModel의 delete 메소드를 사용하게 되고, 이를 통해 soft delete를 구현할 수 있다!

ex) 그룹 초대 요청 거절

class GroupInviteView(APIView):
    def delete(self, request, usergroup):
        user_group = get_object_or_404(UserGroup, id=usergroup, is_confirmed=False)
        user_group.delete()  # soft delete
        return Response({"초대가 삭제되었습니다"}, status=status.HTTP_204_NO_CONTENT)

사진이 쌓이면 Database 관리는 어떻게 하나요?

우리가 기획한 서비스는 기록이 아니라 공유용 서비스이다. 따라서 사진은 업로드 후 14일 뒤 삭제된다.

DB, S3 용량을 관리하기 위해(+개인 정보인 사진을 짧은 시간만 보관하기 위해) 이런 로직을 추가한 것이므로 Photo 모델에 대해서는 soft delete가 아니라 hard delete를 할 것이다.

잠깐, 같은 BaseModel을 상속하는 것 아닌가?

맞다. 그런데 BaseModel에서 오버라이딩한 delete 메소드는 Model(from django.db import models의 models.Model)의 delete()이다.

따라서 soft delete를 하기 위해서는 Django ORM의 get()을 이용해 object를 받아와야 한다.
(나는 에러처리까지 한번에 하기 위해 get_object_or_404() 함수를 이용했다. 바로 위의 그룹 초대 요청 거절 코드에서 확인할 수 있다.)

ORM의 filter()를 이용해 queryset을 받아온다면 delete 메소드 사용시 Queryset(from django.db import models의 models.Queryset)의 delete()를 이용하게 된다.
오버라이딩한 메소드가 아닌 다른 메소드를 쓰게 되는 것이다.

그래서 사진 자동 삭제 코드에서는 Queryset의 delete를 이용해 hard delete가 되도록 했다.

함수 자동 실행

또, 이 기능을 구현하기 위해 django-apscheduler를 이용했다.

pip install django-apscheduler

Photo 모델 관련 로직이므로 관련 파일들은 photos 앱 안에 넣었다.

우선 apps.py 안에 코드를 추가했다.

# photos/apps.py
from django.apps import AppConfig
from django.conf import settings


class PhotosConfig(AppConfig):
    default_auto_field = 'django.db.models.BigAutoField'
    name = 'photos'

    def ready(self):
        if settings.SCHEDULER_DEFAULT:
            from . import operator
            operator.start()

operator.py에는 언제 어떤 함수를 자동으로 실행할지를 담았다.

from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
from django.conf import settings
from photos.auto_functions import DeletePhotos


def start():
    scheduler = BackgroundScheduler(timezone=settings.TIME_ZONE)
    delete_photos = DeletePhotos()
    scheduler.add_job(delete_photos.s3_delete, CronTrigger.from_crontab('0 0 * * *'))
    scheduler.add_job(delete_photos.db_delete, CronTrigger.from_crontab('0 0 * * *'))
    scheduler.start()

실행은 매일 자정이다.
Cron Expressions를 사용할 수 있다.

실행할 함수는 auto_fuctions.py로 파일을 분리했다.

from photos.mys3client import MyS3Client
from tripfriend.settings import AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_STORAGE_BUCKET_NAME
from .models import *
from datetime import timedelta
from django.utils.timezone import now

class DeletePhotos:
    photos = Photo.objects.filter(created_at__lt=now() - timedelta(days=14))
    s3_client = MyS3Client(AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_STORAGE_BUCKET_NAME)

    def s3_delete(self):
        for photo in self.photos:
            self.s3_client.delete(photo)

    def db_delete(self):
        self.photos.delete()  # hard delete

S3와 db 모두에서 사진을 삭제한다.


파일 업로드

S3와 boto3을 이용해서 구현했다.

사실 지금 생각해보니 request에 file object를 직접 보내지 않아도 되는 Presigned Url 방식을 했으면 좋았을 것 같은데, 이건 시간이 더 있을 때 리팩토링하면 좋을 것 같다.

지금 방식은 프론트엔드에서 file을 업로드하고 파일 객체들이 전송되면 서버에서 s3에 업로드하고, 그 url을 DB에 저장하는 방식이다.

import boto3
import uuid

class MyS3Client:
    def __init__(self, access_key, secret_key, bucket_name):
        boto3_s3 = boto3.client(
            's3',
            aws_access_key_id=access_key,
            aws_secret_access_key=secret_key
        )
        self.s3_client = boto3_s3
        self.bucket_name = bucket_name

    def upload(self, file):
        try:
            file_id = str(uuid.uuid4())
            extra_args = {'ContentType': file.content_type}

            self.s3_client.upload_fileobj(
                    file,
                    self.bucket_name,  # bucket
                    file_id,  # key
                    ExtraArgs=extra_args
                )
            return file_id, f'https://{self.bucket_name}.s3.ap-northeast-2.amazonaws.com/{file_id}'
        except Exception as e:
            print(e)
            return None

    def delete(self, file):
        self.s3_client.delete_object(self.bucket_name, str(file.file_key))

MyS3Client Class를 정의해 여기저기서 활용할 수 있게 만들었다.
코드는 위와 같다.
base 앱 안에 작성해 새로운 trip 생성시 썸네일 업로드, photo 업로드, 그리고 위에서 언급한 사진 자동 삭제시 delete를 하는 데에도 이용했다.

배포하기

백엔드 서버 배포는 Docker와 docker-compose, nginx, gunicorn, AWS, Github Actions 등을 이용하였다.

처음에는 AI 코드를 django 프로젝트 안에 바로 넣고 실행시켰다.
근데 하나의 서버 안에 두니까 여러 단점이 보였다.

서버 분리

결국 서버 분리를 하게 되었다.
위에 소개한 service architecture에도 볼 수 있듯이 웹코드와 AI 코드를 분리했고, AI를 실행시키는 코드를 Flask API로 만들었으며 gunicorn을 사용해 배포했다.

AI 실행 후 결과를 이용해 DB에 값을 저장해야 해서 프론트와 바로 연결하지 않고 웹 서버와 연결을 했는데 지금 생각해보니 Flask와 RDS를 연결하고 Flask를 프론트엔드와 바로 연결하는 것도 가능했을 것 같다. 그런데 지금 방식도 괜찮은 것 같다~

Github Actions를 이용한 자동배포

Github Actions를 이용해 CD(Continuous Delivery)를 했다.
지속적인 배포라는 뜻으로, 변경사항이 있을 때 바로바로 반영되도록 하는 것!

깃허브 레포의 최상단에 .github를 만들고 그 안의 workflows 폴더 안에 다음 파일을 넣었다.

# deploy.yml
name: Deploy to EC2

on:
  push:
    branches:
      - main

jobs:
  build:
    name: Build
    runs-on: ubuntu-latest
    steps:

    - name: checkout
      uses: actions/checkout@master

    - name: create env file
      run: |
        touch .env
        echo "${{ secrets.ENV_VARS }}" >> .env

    - name: create remote directory
      uses: appleboy/ssh-action@master
      with:
        host: ${{ secrets.HOST }}
        username: ubuntu
        key: ${{ secrets.KEY }}
        script: mkdir -p /home/ubuntu/srv/ubuntu

    - name: copy source via ssh key
      uses: burnett01/rsync-deployments@4.1
      with:
        switches: -avzr --delete
        remote_path: /home/ubuntu/srv/ubuntu/
        remote_host: ${{ secrets.HOST }}
        remote_user: ubuntu
        remote_key: ${{ secrets.KEY }}

    - name: executing remote ssh commands using password
      uses: appleboy/ssh-action@master
      with:
        host: ${{ secrets.HOST }}
        username: ubuntu
        key: ${{ secrets.KEY }}
        script: |
          sh /home/ubuntu/srv/ubuntu/config/scripts/deploy.sh

secrets.ENV_VARS, secrets.HOST, secrets.KEY는 노출되면 안되는 값이므로 github secrets에 따로 등록했다.
(ENV_VARS에 아까 언급했던 .env.prod 파일의 내용이 들어간다. 위에서 정보를 읽어와 서버에 .env라는 이름으로 파일을 생성하는 것을 볼 수 있다.)

최종적으로는 deploy.sh가 실행되는데, 그 파일 안에는 docker-compose를 수행하는 명령어가 들어있다!

# deploy.sh
#!/bin/bash

# Installing docker engine if not exists
if ! type docker > /dev/null
then
  echo "docker does not exist"
  echo "Start installing docker"
  sudo apt-get update
  sudo apt install -y apt-transport-https ca-certificates curl software-properties-common
  curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add -
  sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu bionic stable"
  sudo apt update
  apt-cache policy docker-ce
  sudo apt install -y docker-ce
fi

# Installing docker-compose if not exists
if ! type docker-compose > /dev/null
then
  echo "docker-compose does not exist"
  echo "Start installing docker-compose"
  sudo curl -L "https://github.com/docker/compose/releases/download/1.27.3/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
  sudo chmod +x /usr/local/bin/docker-compose
fi

# 용량이 부족해지지 않도록 사용하지 않는 컨테이너 및 이미지들을 새 배포 전 정리한다
echo "docker system prune"
sudo docker stop $(sudo docker ps -a -q)
sudo docker system prune -f

echo "start docker-compose up: ubuntu"
sudo docker-compose -f /home/ubuntu/srv/ubuntu/docker-compose.prod.yml up --build -d

main 브랜치에 push시 Actions가 실행되도록 했다.
깃허브의 Actions 탭에서 다음과 같이 확인할 수 있다!

Docker의 중요성

배포할 때 Docker를 사용하면 진짜진짜 편하다.
컨테이너, 즉 별도의 공간에 환경을 만들어주는 것이므로 간편하고 효율적이다.

또 os과 상관없이 어디에서든 실행이 가능한 이미지를 생성해주는데, 윈도우 노트북으로 개발을 한 나는 도커 아니었으면 로컬에서 AI 코드도 못 돌려볼 뻔했다!

로컬 Docker와 배포환경의 Docker는 환경과 용도에 맞추어 파일을 다르게 작성하는 것이 좋다.
그래서 나는 Dockerfile, docker-compose.yml을 로컬에서 사용했고, 실환경에서는 Dockerfile.prod, docker-compose.prod.yml을 별도로 작성해 실행시켰다.
(docker-compose를 사용하면 다중 컨테이너 빌드도 용이하고, 옵션도 편하게 관리할 수 있다!)

참고로 내 docker-compose.prod.yml과 Dockerfile.prod를 첨부하겠다.

version: '3'
services:

  web:
    container_name: web
    build:
      context: ./
      dockerfile: Dockerfile.prod
    command: gunicorn tripfriend.wsgi:application --bind 0.0.0.0:8000 --timeout=300
    environment:
      DJANGO_SETTINGS_MODULE: tripfriend.settings.prod
    env_file:
      - .env
    volumes:
      - static:/home/app/web/static
      - media:/home/app/web/media
    expose:
      - 8000
    entrypoint:
      - sh
      - config/docker/entrypoint.prod.sh

  nginx:
    container_name: nginx
    build: ./config/nginx
    volumes:
      - static:/home/app/web/static
      - media:/home/app/web/media
    ports:
      - "80:80"
    depends_on:
      - web

volumes:
  static:
  media:
# BUILDER #


# pull official base image
FROM python:3.8-slim-buster as builder

# set environment variables
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1

# set work directory
WORKDIR /usr/src/app

RUN pip install --upgrade pip
COPY ./requirements.txt .
RUN pip wheel --no-cache-dir --no-deps --wheel-dir /usr/src/app/wheels -r requirements.txt

# FINAL #


# pull official base image
FROM python:3.8-slim-buster

ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1

RUN mkdir -p /home/app
RUN adduser --system --group app
ENV HOME=/home/app
ENV APP_HOME=/home/app/web
RUN mkdir $APP_HOME
RUN mkdir $APP_HOME/static
RUN mkdir $APP_HOME/media
WORKDIR $APP_HOME

COPY --from=builder /usr/src/app/wheels /wheels
COPY --from=builder /usr/src/app/requirements.txt .
RUN apt-get update -y && apt-get install -y build-essential libmariadb-dev
RUN pip install --upgrade pip
RUN pip install mysqlclient && \
    pip install --no-cache /wheels/*

COPY ./config/docker/entrypoint.prod.sh $APP_HOME
COPY . $APP_HOME
RUN chown -R app:app $APP_HOME
USER app

AI 서버를 배포할 때는 처음에 ec2의 프리티어 인스턴스에 배포했다가 학교에서 지원해주는 gpu 서버인 tencent cloud에 옮겼는데 그래서 Dockerfile, Dockerfile.prod, Dockerfile.gpu 등을 만들었다.

HTTPS

가장 기본적인 보안을 적용할 수 있는 방법이다.
AWS의 ALB를 이용해 HTTPS 인증을 적용했다.

도메인 연결까지 했기 때문에 Route53 도메인의 레코드에 로드밸런서 주소를 등록했더니 적용이 완료되었다!

그 밖에도 하고 싶은 말이 너무너무 많은데 길이상, 시간상 일단 여기서 마치겠다. 졸프 진짜 너무너무 수고했다!

Github

Organization (프로젝트 소개)

https://github.com/JeongHyoYeon

백엔드 소스코드

https://github.com/JeongHyoYeon/Capstone-BE

시연영상

https://www.youtube.com/watch?v=cbFtGsFWJLc&t=7s

0개의 댓글