Wecode 2차 프로젝트

OURSPACE - spacecloud clone project

Posted by Juri on August 31, 2021

구현 웹사이트 : 스페이스클라우드
프론트엔드 : 심택준, 이수정, 이현준, 최호정 (4명)
백엔드 : 임종성, 장이주 (2명)
링크 : 유튜브, 깃허브

백엔드 구현 기능 목록

사용기술 : Python, Django, Mysql, AWS EC2, RDS, S3,Docker, POSTMAN

  1. 카카오 API를 이용한 소셜 로그인 API
  2. 공간목록 필터링 및 정렬 API
  3. AWS S3 를 통한 이미지 호스팅 관리
  4. 공간등록 API
  5. 공간상세 API
  6. 예약 API
  7. Unit Test / 성능 최적화 / Docker를 이용한 배포 과정

모델링

image

기능

1. 카카오 API를 이용한 소셜 로그인 API

참고 : KakaoDevelopers 로그인

▶️ 전체적인 흐름

카카오 로그인 API를 이용한 소셜로그인 기능은 크게 세가지의 단계로 이루어진다.

  1. 인증코드 받기
  2. 인증코드로 토큰 받기
  3. 받은 토큰으로 사용자정보 받기

보통 프론트엔드에서 1번과 2번을 수행한 후 카카오 서버로부터 받은 토큰을 백엔드 서버로 보내고, 백엔드에서 그 토큰으로 카카오로부터 사용자 정보를 받아온다.
위의 모든 과정은 백엔드에서 처리할 수 있다. 하지만 이렇게 할 경우엔 가끔 발생하는 오류가 있어서 적당히 프론트와 분업 후 하는 것이 안정적이라는 조언을 얻었다.

▶️ CODE

1
access_token = request.headers.get('Authorization')
  1. 프론트엔드에서 요청메세지의 헤더에 카카오로부터 받은 토큰을 담아 백엔드 서버로 보내준다.
1
2
3
url           = 'https://kapi.kakao.com/v2/user/me'
response      = requests.get(url, headers={'Authorization': f'Bearer {access_token}'})
user_response = response.json()
  1. 프론트로부터 받은 헤더를 카카오서버로 보내 카카오서버에 저장된 사용자의 정보를 받아온다.
1
2
3
4
5
6
7
8
9
10
11
user,created = User.objects.get_or_create(kakao_id=user_response['id'],nickname = user_response['properties']['nickname'])

if created: 
    user.email    = user_response['kakao_account']['email']
    user.nickname = user_response['properties']['nickname']
    user.point    = random.randrange(0,10000)
    user.save()

token = jwt.encode({'id':user.id}, SECRET_KEY, algorithm= 'HS256')

return JsonResponse({'access_token':token}, status=200)
  1. DB에 저장된 사용자의 정보와 비교해 이미 정보가 저장된(이미 회원가입을 한) 사용자라면 토큰을 생성 후 프론트엔드로 반환, 정보가 없는 사용자라면 회원가입을 진행한 후(DB에 사용자 정보를 저장) 토큰을 생성해 프론트엔드로 반환한다.

2-1. 공간목록 필터링 및 정렬 API

▶️ 딕셔너리를 이용해 공간 정렬하기

1
2
3
4
5
prefix = {
        "desc"  : "-day_price",
        "aesc"  : "day_price",
        "best"  : "-like" 
    }

▶️ Q객체를 이용해서 조건에 맞는 공간 필터링하기

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
q = Q()

if district_id:
    q &= Q(district_id = district_id)

if category_id:
    q &= Q(category_id = category_id)

if man_count:
    q &= Q(min_count__lte = man_count) & Q(max_count__gte = man_count)

if date:
    q_exclude = Q(order__status_id=OrderStatus.Status.COMPLETED.value) & Q(order__date=date)
    q_exclude &= ((Q(order__option__option="all") | Q(count_option = 2)))
    q &= ~q_exclude

조건에 맞는 공간만 필터링 후 반환한다.

  1. 지역 (district)
  2. 공간유형 (category)
  3. 인원수 (man_count) 최소인원은 2명으로 고정, 사용자가 입력한 인원수를 최대인원으로 설정한다.
  4. 예약날짜 (date) q_exclude
    • 지정한 날짜에 있는 예약 중에서 결제가 완료된 (Status.COMPLETED) 주문을 저장한다.
    • 종일예약을 한 주문(option=”all”)과 오전, 오후 모두 예약한 주문(count_option = 2)을 저장한다.
1
2
3
spaces = Space.objects.prefetch_related("image_set").select_related('district')\
                .annotate(day_price=Min('option__price'), count_option = Count('order', distinct=True))\
                .filter(q).order_by(prefix.get(order, "-like"))

반환할 공간 객체를 space에 저장한다.

  1. 공간 이미지와 지역에 대한 정보는 미리 가져와서 캐싱해두어 DB를 hit 횟수를 줄인다.
  2. 공간 가격을 최소 가격으로 지정하고 해당 공간의 주문 횟수를 중복없이 카운팅한다.
  3. q에 저장한 조건을 이용해 필터링한다.
  4. 좋아요 수에 대해 오름차순으로 정렬한다.

2-2. 공간에 대한 지역과 카테고리 정보

프론트단에서 지역카테고리, 편의시설에 대한 아이콘과 텍스트를 하드코딩하지 않도록 서버에서 정보를 보내주기로 결정했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class DistrictView(View):
    def get(self, request):
        result = [{
                    "id"        : district.id,
                    "name"      : district.name,
                    "lattitude" : district.lattitude,
                    "longitude" : district.longitude 
                } for district in District.objects.all()]

        return JsonResponse({'RESULT':result}, status=200)

class CategoryView(View):
    def get(self, request):
        result = [{
            "id"    : category.id, 
            "name"  : category.name,
            "image" : category.image
        } for category in Category.objects.all()]

        return JsonResponse({'RESULT':result}, status=200)

class FacilityView(View):
    def get(self, request):
        results = [{
            'id'    : facility.id,
            'name'  : facility.name,
            'image' : facility.image
        }for facility in Facility.objects.all()]
        
        return JsonResponse({'results':results}, status=200)

3. AWS S3 를 통한 이미지 호스팅 관리

3-1. uuid를 사용해 파일명 중복 방지하기

1
signs  = [{'key':'image/' + str(uuid.uuid4()) + image.name, 'image' : image} for image in images]

AWS S3에 파일명이 중복된 채로 업로드되면 새로운 이미지가 기존에 저장된 이미지를 대체한다. 이와 같은 데이터 유실을 막기위해 uuid를 사용해 경로에 임의의 문자열을 붙여 업로드한다.

경로 : image/’uuid문자열’+’파일명’

3-2. S3 업로드 로직의 모듈화 - boto3

boto3는 AWS SDK(Software Development Kit)로 이를 이용해 S3뿐만 아니라 AWS의 많은 서비스들을 활용할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# storage.py

class S3Client():
    def __init__(self, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY):
        self.s3 = boto3.client(
        's3',
        aws_access_key_id = AWS_ACCESS_KEY_ID,
        aws_secret_access_key = AWS_SECRET_ACCESS_KEY
        )
        self.base = 'static/'
    
    def __del__(self):
        return None

    def upload(self, file, file_name, bucket_name):
        self.s3.upload_fileobj(
            file,
            bucket_name,
            self.base + file_name,
            ExtraArgs = {
                'ContentType' : file.content_type
            }
        )
    
    def delete(self, file_name, bucket_name):
        self.s3.delete_object(Bucket=bucket_name, Key = self.base + file_name)

boto3를 이용해 S3에 사진을 호스팅하기 위해 S3Client 클래스를 생성했다. AWS ACCESS KEY는 노출되면 안되므로 my_settings 파일에 숨긴다. 메소드로 uploaddelete를 정의했다.

1
2
3
4
5
6
7
8
s3_client = S3Client(AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY) 

Image.objects.bulk_create([
        Image(space=space, image = AWS_S3_DIRS + sign['key'])
        for sign in signs
])
for sign in signs: 
        s3_client.upload(sign['image'], sign['key'], AWS_STORAGE_BUCKET_NAME)

로컬 DB에 AWS S3에 저장되는 경로(AWS_S3_DIRS + sign[‘key’])를 저장한다
S3Client의 메소드 upload이미지파일(sign[‘image’])를 AWS BUCKET에 지정한 경로(sign[‘key’])에 올린다.

4. 공간등록 API

4-1. Q객체를 이용해서 조건에 맞는 공간 필터링하기

1
2
3
4
5
6
7
user       = request.user
data       = request.POST
min_count  = request.POST.get('min_count', 2)
like       = request.POST.get('like', 0)
images     = request.FILES.getlist('image')
facilities = request.POST.get('facility', None).split(',')
signs      = [{'key':'image/' + str(uuid.uuid4()) + image.name, 'image' : image} for image in images]
  • request에 담아보낸 정보를 변수에 저장한다.
  • 모든 데이터는 formData로 받았으며 min_countlike는 request에 포함되지 않는 값으로 default 처리를 했다.
  • imagesgetlist를 사용해 복수의 이미지를 저장했다.
  • facilities는 formdata로 복수의 facility_id를 저장한다. 프론트에서 가공되지 않은 형태로 넘어와 백단에서 split을 이용해 가공했다.

4-2. Transaction

DB의 데이터를 수정하는 로직에선 트랜잭션을 이용해서 중간에 에러가 발생했을 때 rollback되도록 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
with transaction.atomic():
    space = Space.objects.create(
            user        = user,
            category_id = data['category'],
            district_id = data['district'],
            title       = data['title'],
            sub_title   = data['sub_title'],
            min_count   = min_count,
            max_count   = data['max_count'],
            address     = data['address'],
            like        = like
        )
    Image.objects.bulk_create([
        Image(space=space, image = AWS_S3_DIRS + sign['key'])
        for sign in signs
    ])
    Option.objects.bulk_create([
        Option(space=space, option="day", price=data['price_day']),
        Option(space=space, option='night', price=data['price_night']),
        Option(space=space, option='all', price=data['price_all'])
    ])
    [space.facility.add(Facility.objects.get(id=facility))for facility in facilities]
        
    for sign in signs: 
        s3_client.upload(sign['image'], sign['key'], AWS_STORAGE_BUCKET_NAME)

5. 공간상세 API

▶️ 요청받은 공간에 대한 데이터 가공 후 반환

url parameter로 전달받은 space_id로 해당 공간 객체의 정보를 가져온 후, 원하는 형태로 가공한다.
space와 foreign key로 연결된 categorydistrictselect_related를 이용해 미리 가져와 쿼리 수를 줄일 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
space = Space.objects.select_related("category", "district").get(id=space_id)

results = [{
    'id'         : space.id,
    'category_id': space.category.id,
    'district_id': space.district.id,
    'image'      : [image.image for image in space.image_set.all()],
    'title'      : space.title,
    'sub_title'  : space.sub_title,
    'min_count'  : space.min_count,
    'max_count'  : space.max_count,
    'address'    : space.address,
    'like'       : space.like,
    'price'      : [[option.id, option.option, option.price ]for option in space.option_set.all()],
    'facility'   : [{
        'id'   : facility.id,
        'name' : facility.name,
        'image': facility.image
        }for facility in space.facility.all()]
}]

6. 예약 API

6-1. 예약가능한 날짜인지 유효성 검사

1
2
date   = request.GET.get('date')
option = request.GET.get('option')

쿼리파라미터로 dateoption에 대한 정보를 받아온 후 이에 대한 예약정보를 확인한다.

1
2
if not Order.objects.filter(space_id=space_id, date=date, status_id=OrderStatus.Status.COMPLETED.value).exists():
    return JsonResponse({'message':'OK'}, status=200)

case1. 해당 날짜에 예약이 아에 없을 경우 ok

1
2
3
4
key = Option.objects.get(id=option).option

if key == 'all' :
    return JsonResponse({'message':'DENIED'}, status=400)

case2. 해당 날짜에 예약이 있음 -> 종일(option=all) 예약할 수 없음 Denied

1
2
3
4
orders = Order.objects.filter(space_id=space_id, date=date, status_id=OrderStatus.Status.COMPLETED.value)

if orders.filter(Q(option__option='all') | Q(option__option=key)).exists():
    return JsonResponse({'message':'DENIED'}, status=400)

case3. 해당날짜에 종일예약이 있거나, 일치하는 예약이 있으면 Denied

6-2. 예약 API

1
space_id = request.GET.get("space_id", None)

url parameter로 예약하고자 하는 공간의 id를 전달받는다.

1
2
if Order.objects.filter(user_id = request.user.id, status_id = OrderStatus.Status.COMPLETED.value).exists():
    Order.objects.filter(user_id = request.user.id, status_id = OrderStatus.Status.COMPLETED.value).delete()

이전에 해당 공간에 대한 예약 정보가 있으면 (이미 결제해서 상태가 COMPLETED인 예약) 삭제한다.

1
2
3
4
5
6
7
8
9
order = Order.objects.create(
    space_id  = space_id,
    user_id   = request.user.id, 
    status_id = OrderStatus.Status.WAITING.value, 
    date      = data['date'],
    count     = data['count'],
    option_id = data['option'],
    booker    = None
    )

상태를 WAITING으로 예약을 저장한다.

query debugger로 API 성능 테스트하기

쿼리문을 작성할 때 DB를 얼마나 효율적으로 hit하느냐 . . 가 중요하다. 예를 들어, 외래키 관계의 테이블을 사용할 때 이 데이터와 관련있는 데이터를 불러오기 위해서 매번 DB를 hit하기보다는 미리 외래키 관계의 데이터를 전부 불러와 캐싱해둔 뒤 다시는 DB를 hit할 필요가 없게 하는 방법이 있다. 이젠 기능만 뚝딱 만들고 끝나는 게 아니라 그 기능이 얼마나 효율적으로 굴러가게 할 지 한번 더 생각해야하는 단계로 진입했다. (내가?)

이번엔 프로젝트 중이라서 얕게 이해하고 끝냈지만 언젠가는 데이터베이스에 대한 심도있는 학습을 해야할 필요가 있겠다는 생각이 들었다. (할 게 너무 많아요 ㅠㅠ)

AWS와 Docker 로 프로젝트 배포하기

S3와 씨름하며 그나마 익숙해진 AWS와 완전 새로 접한 개념인 도커
1차 프로젝트 때 하지 못한 배포의 한을 이번에 풀었다. 서버를 켜놓지 않아도 통신할 수 있는 기쁨.. !
현업에서 도커를 사용하는 경우가 많다고 하니 이 또한 심도있게 학습을 해야만 할 것 같다.. ^^

✉️ Review

BLOCKER

일주일 내내 하루에 4시간씩 자며 코드와 대전쟁 . . ! 내가 팀 진도를 못 쫓아가서 민폐 끼치는 상황이 올까봐 하루하루가 두려웠다. 그래서 아침 10시부터 저녁 10시까지 하루종일 코딩하고 집가서 12시부터 새벽3시까지 더 하고 잤다 (ㅠㅠ) 대학생 때 공부를 이렇게 했다면 서울대에 가지 않았을까 싶을 정도로 내 인생에서 역대급으로 열심히 한 일주일이었다. 조급한 감정 자체는 잘 컨트롤하면 엄청난 동기부여가 될 수도 있겠지만 나의 경우엔 이 감정때문에 심신이 너무 불안정해지고 몸이 부서지는 기분이었닼ㅋㅋㅋ 중간점검때도 생각했지만 체력이 절대 무시 못할 blocker인 건 평생 안 바뀔 것 같다.

그럼에도 팀 프로젝트이기 때문에 팀원들끼리 이끌어주고 의지하며 어떻게 잘 버틸 수 있었다. 혼자였다면 절대 완주 못했을 레이스였다. 정말 힘들고 지치는 시간들이었지만 팀원들과 함께해서 행복하고 즐거웠던 2주였다. 시간이 지나도 기억에 길이 남을 좋은 추억을 만들 수 있어서 참 행운이었다 !