[REAL Python – Flask] – “인스타그램 클론코딩 – Instagram Clone (8)”
[REAL Python – Flask] – “인스타그램 클론코딩 – Instagram Clone (8)”
게시물 좋아요 API 구현하기
이번에는 게시물 좋아요 API 를 구현하겠습니다. 좋아요 기능에는 좋아요, 좋아요 취소가 있죠? 한 사람은 여러 게시물에 좋아요를 누를 수 있고, 한 게시물에 여러 사람이 좋아요를 누를 수 있습니다. 게시물과 사용자는 다대다 관계입니다.
그러면 자연스럽게도 현재 데이터베이스 구조의 수정이 있어야 하고 -> 그러면 모델 코드를 작성해야겠네! 라는 생각의 흐름이 생깁니다.
다대다 관계의 구현은 위와 같이 매개 테이블을 하나 더 놓아줌으로서 할 수 있습니다. ORM 을 이용한 코드를 사용한다면 아래와 같겠습니다.
api/models/post.py
PostModel 클래스 아래에는, 아래와 같이 Relationship 을 추가해 주겠습니다.
이후, 모델에 변화가 생겼으므로 flask db migrate
후 flask db upgrade
를 수행합니다. 아래와 같이 테이블이 하나 생겼다면, 성공입니다!
우리가 한, 일련의 작업은 무엇을 의미하는 걸까요? 새로운 테이블이 생겼고, 그것은 사용자의 id, 게시물의 id를 참조하고 있습니다.
위의 관계를 보시면 이해가 쉬울 것입니다. 한 명의 사용자는 여러 개의 게시물을 가질 수 있도록, 그리고 한 개의 게시물은 여러 명의 사용자를 가질 수 있는 구조를 살펴볼 수 있습니다. 예를 들면, 빨간색 화살표를 살펴본다면 id가 1인 사용자, “코딩싫어1092” 는 두 개의 게시물 “1 게시물”, “2 게시물” 에 좋아요를 눌렀습니다. 한 명의 유저는 여러 개의 게시물에 좋아요를 누를 수 있습니다. 또 id가 2인 게시물에는, 사용자 1번 “코딩싫어1092”, 사용자 2번 “코딩좋아1092” 가 좋아요를 누를 수 있네요. 한 개의 게시물에는 여러 명이 좋아요를 누를 수 있습니다.
데이터베이스 테이블까지 구현되었으므로, 이제 API 를 작성해 볼까요? 우리가 구현해야 할 API 는 이러한 방식일 겁니다.
PUT /posts/<int:id>/likes/ -> id 로 특정되는 게시물에, 좋아요를 추가합니다.
DELETE /posts/<int:id>/likes/ -> id 로 특정되는 게시물에, 좋아요를 취소합니다.
응답 방식은, 성공 시 204 no content 를 사용하는 것이 좋을 것 같습니다. 클라이언트의 요청을 서버에서 성공적으로 처리했으나, 빈 응답을 준다는 것입니다. 상세한 명세는 아래에 나와 있습니다.
좋아요. 그렇다면 하나 생각해 봅시다. “사용자가 1번 게시물에 좋아요를 누른다” 는 것은 “/posts/1/likes/ 에 PUT 요청을 보내는 것” 을 의미하고, “그것은 “1번 게시물에 좋아요를 누른 사람에, 현재 로그인한 사람을 추가한다” 가 될 겁니다. “좋아요 취소” 는 그 반대일 거고요. 이를 위해서, 모델 단에서 필요한 몇 가지의 메서드를 정의하겠습니다. PostModel 클래스 아래에 정의해 주세요.
def do_like(self, user):
"""
특정 게시물에 좋아요를 누름
"""
if not self.is_like(user):
self.liker.append(user)
db.session.commit()
return self
def cancel_like(self, user):
"""
특정 게시물에 좋아요를 취소함
"""
if self.is_like(user):
self.liker.remove(user)
db.session.commit()
return self
def is_like(self, user):
"""
특정 게시물에 좋아요를 눌렀는지에 대한 여부 반환
"""
return (
self.liker.filter(post_to_liker.c.user_id == user.id).count() > 0
)
def get_liker_count(self):
"""
특정 게시물의 좋아요 숫자를 반환
"""
return self.liker.count()
좋아요. 좋아요를 누르는 함수, 좋아요를 취소하는 함수, 좋아요를 눌렀는지의 여부를 반환하는 함수, 좋아요 개수를 반환하는 함수를 정의했으므로, 우리는 Resource
단에서 그것을 적극 이용하여 로직을 구현하겠습니다.
api/__init__.py 에 위처럼 리소스를 먼저 등록해 주겠습니다. 저 위의 URL 로 PUT, DELETE 메서드를 구현하면 되겠네요! 틀은 아래와 같겠네요.
class PostLike(Resource):
@classmethod
@jwt_required()
def put(cls, id):
"""
id 로 특정되는 게시물에 좋아요를 누릅니다.
"""
# 사용자, 게시물을 특정
user = UserModel.find_by_username(get_jwt_identity())
post = PostModel.find_by_id(id)
if not user or not post:
return {"Error": "잘못된 요청입니다."}, 400
post.do_like(user)
return post.get_liker_count(), 200
@classmethod
@jwt_required()
def delete(cls, id):
"""
id 로 특정되는 게시물에 좋아요를 취소합니다.
"""
# 사용자, 게시물을 특정
user = UserModel.find_by_username(get_jwt_identity())
post = PostModel.find_by_id(id)
if not user or not post:
return {"Error": "잘못된 요청입니다."}, 400
post.cancel_like(user)
return post.get_liker_count(), 200
각각의 메서드의 구현은 위와 같겠습니다. 모델 단에서 좋아요를 추가하는, 그리고 취소하는 메서드를 구현해 두었으므로 Resource 에서 이용하기만 하면 되겠네요.
테스트를 해 볼까요? 저는 “민수” 의 토큰을 담아 1번 게시물에 “좋아요 누르기” 요청을 보내겠습니다.
성공적으로 204 응답이 온 걸 볼 수 있네요.
그리고 post_liker
테이블을 확인해 보면, 2번째 열처럼 “4번 유저(민수) 가, 1번 게시물에 좋아요를 눌렀다” 가 기록되어 있네요! (1번째 열은 테스트 하며 생긴 것이므로.. 관계없습니다.)
그리고 이번에는 “좋아요 취소” 요청을 위와 같이 보내겠습니다. 마찬가지로, 민수의 토큰을 담아 요청을 보내면 되겠죠?
그러면, 원래 있었던 2번째 줄이 없어져 있는 것을 확인할 수 있네요!
좋네요. 이렇게 대부분의 좋아요 관련 API 를 구현했습니다. 이제 게시물 API 에 관련 응답을 추가하겠습니다. 개선 사항은 아래와 같습니다.
- “이 게시물에 좋아요가 몇 개가 눌렸는가?”
- “현재 로그인한 사람이, 게시물에 좋아요를 눌렀는가, 혹은 누르지 않았는가?”
1.번은 쉽게 구현할 수 있을 것 같네요. 모델 단에 메서드가 구현되어 있고, 그것을 사용하기만 하면 됩니다.
api/schemas/PostSchema
클래스에 아래의 코드를 추가합니다. Method 필드를 사용하고, get_liker_count()
에서 그 값을 얻어오는 코드입니다.
“현재 로그인한 사람이 좋아요를 눌렀는가?” 에 대한 코드는 약간 복잡합니다. 모델 단에서 그것을 True or False 로 판단을 바로 할 수 없기 때문입니다. 대신, PostModel 에서 is_like 메서드를 작성함으로서, “사용자가 특정되었을 때에, 해당 사용자가 게시물에 좋아요를 눌렀는지, 혹은 누르지 않았는지에 대한 여부” 는 알 수 있었죠?
우리가 작성해 두었던 위의 메서드는 user 를 받습니다. 그리고 user를 특정할 수 있는 곳은 Resource 였습니다. Resource 에서 HTTP 요청을 받아 처리하기 때문입니다.
그러면, 드는 생각은 “Resource 에서 현재 로그인한 사용자의 정보를 context 로 Schema 에 넘겨준 후, Schema 에서 Method 를 사용해 현재 로그인한 사람이, 이 게시물에 좋아요를 눌렀는지에 대한 여부를 판단해 반환하자~” 가 되겠습니다.
# api/resources/post.py 의 Post 클래스 안의 get 메서드를 아래와 같이 수정!
@classmethod
@jwt_required()
def get(cls, id):
post = PostModel.find_by_id(id)
if post:
user = UserModel.find_by_username(get_jwt_identity())
_post_schema = PostSchema(context={"user": user})
return _post_schema.dump(post), 200
else:
return {"Error": "게시물을 찾을 수 없습니다."}, 404
# api/resources/post.py 의 PostList 클래스 안의 get 메서드를 아래와 같이 수정!
@classmethod
@jwt_required()
def get(cls):
user = UserModel.find_by_username(get_jwt_identity())
page = request.args.get("page", type=int, default=1)
ordered_posts = PostModel.query.order_by(PostModel.id.desc())
pagination = ordered_posts.paginate(
page=page, per_page=10, error_out=False
)
user = UserModel.find_by_username(get_jwt_identity())
_post_list_schema = PostSchema(context={"user": user}, many=True)
result = _post_list_schema.dump(pagination.items)
return result
위의 코드를 작성했다면(설명은 곧 나옵니다!), api/schemas/post 의 PostSchema 에서 코드를 아래와 같이 수정합니다.
위에서 작업한 것이 대체 무엇인지를 한번 알아보겠습니다.
먼저, Resource 단에서 위와 같이 스키마에 user 객체를 하나 넘겨주었습니다. context={} 처럼, 딕셔너리를 담아서 넘겨줬네요. 이렇게 user 객체를 넘겨준 이유는, 스키마 단에서 모델에 정의해 두었던 is_like 메서드를 사용해서 좋아요를 눌렀는지, 안 눌렀는지를 판단할 수 있게끔 하기 위함입니다.
넘겨준 PostSchema 에서 18번째 줄처럼 Method 필드를 하나 정의했죠? 그리고 23, 24번 줄에서 self.context[] 로 우리가 resource 에서 넘겨주었던 context 에 접근하였습니다. self.context 에는 “현재 로그인한 유저” 객체가 담기고, 그것을 사용해서 “좋아요를 눌렀나? 안 눌렀나?” 를 판단하게 되는 것입니다.
그럼 테스트를 해 볼까요? 저는 54번째 게시물에 좋아요를 누르겠습니다. “민수” 로 로그인한 상태에서요!
그리고 54번 게시물을 “민수” 로 로그인한 상태에서, 상세조회 해 볼까요?
게시물 목록 페이지에서도, 좋아요를 누르지 않은 55번 게시물에는 false 로, 좋아요를 누른 54번 게시물에서는 true 로 응답해오는 것을 확인할 수 있네요!
그리고, “미미” 의 토큰을 담아 54번 게시물에 요청을 보내면, is_like 는 false 로 바뀌어 있어야 합니다.
게시물 검색 API 구현하기
이번에는 게시물 검색 api 를 구현해 보겠습니다. 스펙은, 아래와 같을 겁니다.
/posts/?search=안녕하세요 -> 게시물, 제목에 "안녕하세요" 가 포함되어 있는 게시물을 찾아, 10개로 나누어 페이지네이션
위와 같이, URL 뒤에 ?무언가=무언가
처럼 매개변수에 할당되는 문자열을 쿼리스트링이라고 합니다. 이번에는, 쿼리스트링을 통하여 사용자로부터 검색어를 입력받아 그게 알맞는 검색결과를 반환하는 게시물 검색 API 를 구현해 볼 겁니다.
그렇다면 어떤 것부터 시작해야 할까요? 위의 경우라면 “안녕하세요” 라는 문자열을 포함하는 게시물은 100개가 될 수도 있고, 1000개가 될 수도 있습니다. 기본적으로, 서버에서는 “여러 개의 게시물” 을 반환해야 하겠네요. 그렇다면, “게시물 목록 API” 를 건드려야겠다는 생각이 흐르게 됩니다.
구현의 시작은 flask 에서 querystring 을 받는 것으로 시작합니다. api/resources/post.py 의 PostList 클래스의 get() 메서드에 아래의 코드를 추가해보겠습니다.
그리고, 아래와 같이 POSTMAN 에서 토큰 정보를 담아 게시물 목록 페이지에 GET 요청을 보내 보겠습니다.
그러면, 콘솔에 아래와 같이 우리가 URL 에 작성한 something=스터디 가 딕셔너리로 변환되어 찍히는 것을 확인할 수 있을 겁니다.
이처럼, Flask에서는 request.args.to_dict 코드를 사용해서 쿼리스트링으로 들어온 데이터를 파이썬의 dictionary 로 바꾸어 사용할 수 있습니다. 이게 1단계가 되겠네요!
두 번째로는, 우리는 “검색어가 제목이나 내용에 포함된 모든 게시물의 목록을 페이지네이션해서 반환” 해야 하므로 그것을 골라내는 작업을 수행해야 합니다. 검색에 대한 결과를 반환하는 로직은, 아래의 순서로 이뤄질 겁니다.
- 클라이언트는 “안녕하세요?” 를 검색어로 하여 검색 요청을 보냅니다.
posts/?search=안녕하세요?
와 같이 URL 에 querystring 을 담아 GET 요청을 보낼 겁니다. - 서버는, URL로부터 쿼리스트링 “안녕하세요?” 를 얻습니다.
- 그리고, 모델로부터 데이터베이스에 “제목에 ‘안녕하세요?’ 가 담겨 있는 모든 게시물, 내용에 ‘안녕하세요?’ 가 담겨 있는 모든 게시물을 얻어옵니다.
- 그러면 “제목에 ‘안녕하세요?’ 가 담겨 있는 모든 게시물을 취합,
- “내용에 ‘안녕하세요?’ 가 담겨 있는 모든 게시물을 취합,
- 위의 두 개의 결과를 중복 제거한, id의 역순 (최신순) 으로 정렬한 결과가 반환되겠죠?
- 우리는 “모델로부터” 조건에 맞는 모든 게시물을 가져올 것이므로, 3번의 결과는 모델 객체가 될 겁니다. 정확히는, PostModel 클래스의 인스턴스가 되겠네요.
- 그러면, 3번의 ‘조건에 맞는 게시물들’ 을 적절하게 10페이지씩 끊어 사용자에게 응답하면 됩니다.
좋아요. 자연스러운 위의 생각의 흐름을 코드로 옮기면 됩니다. 한번 해 보죠!
@classmethod
@jwt_required()
def get(cls):
user = UserModel.find_by_username(get_jwt_identity())
page = request.args.get("page", type=int, default=1)
_post_list_schema = PostSchema(context={"user": user}, many=True)
# 클라이언트로부터 검색어 얻어오기
search_querystring = f'%%{request.args.to_dict().get("search")}%%'
# 검색어가 존재한다면, ordered_posts 재할당
if request.args.to_dict().get("search"):
ordered_posts = PostModel.filter_by_string(
search_querystring
).order_by(PostModel.id.desc())
pagination = ordered_posts.paginate(
page=page, per_page=10, error_out=False
)
return _post_list_schema.dump(pagination.items)
저의 경우에는, 위와 같이 구현하였습니다.
위의 이미지가 검색의 핵심 구현입니다. 클라이언트가 /?search=가나다라 처럼 “가나다라” 를 검색했다면, 그에 맞는 게시물을 찾아서 ordered_posts 에 재할당해주고 있네요.
그러면 한번 검색이 잘 작동하나 확인해 볼까요? 저는 제목에 “우하하”, “내용에는 “플라스크 재미있다!” 라는 글을 작성하겠습니다.
그리고, 아래와 같이 url에 쿼리스트링을 담아 GET 요청을 보내 보세요. 우리가 작성한 하나의 게시물만 필터링되어야 합니다.
또, 우리는 “제목” 으로도 검색할 수 있도록 하였으므로 “우하하” 를 입력한다면 하나의 게시물이 나와야 합니다. 백엔드 단에서 사용할 검색 API는 모두 완료되었네요 :)
팔로우 & 언팔로우 API 구현하기
이번에는 팔로우, 언팔로우 API 를 구현해 보겠습니다. 잠시 생각해 본다면, 한 사람은 여러 사람을 팔로우할 수 있고, 한 사람은 여러 사람으로부터 팔로우당할 수 있습니다. 마치 “좋아요한 사람” 과 “게시물” 과 비슷하네요.
비슷하지만, 다른 점은 “유저와 유저” 가 다대다 관계라는 겁니다. 그리고 우리는, 그것을 위해서 맨 처음에 “팔로워” 테이블을 작성했었습니다. 아래와 같았습니다!
그리고, 모델 단에서 UserModel 클래스 아래에 팔로우하는 메서드, 언팔하는 메서드도 작성해 두었습니다.
그런데, 실제로 위의 메서드들은 팔로우/언팔로우에 대해서 클래스 단에서만 작업을 수행하고, 실제로 데이터베이스에는 반영되지 않도록 되어 있네요. 팔로우를 완료하고, “모든 트랜잭션을 종료하고, 다른 사용자에게 모든 변경된 사항을 보여줄게!” 를 코드로 옮겨서 작성해 주겠습니다.
깨알 공부 1,
깨알 공부 2!
아무튼, 팔로우, 언팔로우를 위한 로직이 미리 구현되어 있으므로, 그것을 활용해서 코드를 작성하겠습니다. 팔로우, 언팔로우에 대한 API 호출 스펙은 아래와 같이 정의하겠습니다.
PUT /users/<int:id>/followers/ --> id 로 특정되는 사용자를 팔로우
DELETE /users/<int:id>/followers/ --> id 로 특정되는 사용자를 언팔로우
URL 스펙이 정해졌으므로, 아래와 같이 api/__init__.py 에 리소스를 등록합니다.
이렇게 import도 해 주어야겠죠?
그러면, 나름의 논리를 resources/user.py 에 작성해 주면 됩니다.
class Follow(Resource):
"""
특정한 사용자를 팔로우/언팔로우합니다.
"""
@classmethod
@jwt_required()
def put(cls, id):
request_user = UserModel.find_by_username(get_jwt_identity())
user_to_follow = UserModel.find_by_id(id)
if not request_user:
return {"error": "사용자를 찾을 수 없습니다."}, 400
if id == get_jwt().get("user_id"):
return {"error": "스스로를 팔로우할 수 없습니다."}, 400
request_user.follow(user_to_follow)
return "", 204
@classmethod
@jwt_required()
def delete(cls, id):
request_user = UserModel.find_by_username(get_jwt_identity())
user_to_unfollow = UserModel.find_by_id(id)
if not request_user:
return {"error": "사용자를 찾을 수 없습니다."}, 400
if id == get_jwt().get("user_id"):
return {"error": "스스로를 팔로우할 수 없습니다."}, 400
request_user.unfollow(user_to_unfollow)
return "", 204
꽤나 직관적인 코드이지만, 위의 코드가 대체 무엇을 수행하는지를 한번 알아봅시다.
팔로우 / 언팔로우 모두 위의 로직을 대부분 따릅니다. 팔로우를 수행하는 put 메서드를 한번 알아볼까요?
- 27번 줄에서, “팔로우를 요청하는 사람” 을 특정합니다.
- 28번 줄에서, “팔로우를 할 사람” 을 특정합니다. 그것은 요청 URL 에서 가져오겠죠?
- 그리고, 여러 가지 예외를 처리한 다음 33번 줄에서 팔로우를 수행합니다.
언팔로우도 같습니다. :) 다른 점은, 33번 줄에서 .follow() 메서드 대신 .unfollow() 를 수행한다는 것이죠!
그럼 우리의 API가 잘 작동하는지 한번 확인해 볼까요? 먼저 터미널에 아래와 같이 “미미” 가 팔로우하고 있는 사람들을 확인해 봅시다. 지금은 아무도 팔로우하고 있지 않으므로, 빈 리스트[] 가 나와야 할 겁니다.
먼저 위와 같이 미미를 특정하고, (각자 데이터베이스 상황에 맞게, 맞는 유저를 가져오시면 됩니다.)
미미가 팔로우한 사람을 찾아보면, 아무도 있지 않습니다.
위의 사람들을 한번 팔로우 처리해봅시다!
먼저, “미미” 의 액세스 토큰을 발급받겠습니다.
그리고 그 액세스 토큰을 담아, 팔로우 요청을 해 봅시다!
요청을 성공했다면, 204 no content 이므로, 응답 컨텐츠가 없는 응답이 올 겁니다.
저는 추가로 2번 유저도 팔로우를 해 보겠습니다!
그리고 다시 flask shell 을 열어서 코드를 입력해 보세요. “미미”가 팔로우한 사람이 정성적으로 잘 처리된 것을 확인할 수 있습니다!
마찬가지로, 언팔로우 요청을 아래와 같이 보내고, (DELETE 메서드 사용, 1번 유저와 2번 유저 언팔로우)
확인해 본다면, 미미가 성공적으로 두 명을 다시 언팔한 것을 확인할 수 있네요. :)
팔로우한 사람의 게시물만 게시물 목록에 나타나도록 하기
좋아요. API 구현의 거의 막바지가 되었습니다. 실제 인스타그램에서는 “내가 팔로우한 사람의 글만 목록엔 나타난다” 가 구현되어 있죠? 그것을 같이 구현해보겠습니다.
기존의 “게시물 목록 API” 의 논리는 아래와 같이 구현되어 있었습니다.
중요한 부분은, 위의 ordered_posts 변수 부분입니다. 그것에는 PostModel.query.order_by(PostModel.id.desc()) 의 결과가 담기는데, 아시다시피 그 코드는 “모든 게시물들을 id의 역순으로 정렬해라” 가 됩니다.
이제 우리가 해야 할 것은 명확하게도 “모든 게시물들을 id의 역순으로 정렬해라” 가 아니라,
- 내가 팔로우한 사람들을 알아내고,
- 그 사람들이 작성한 게시물들을 모두 가져온 다음,
- 그것들을 id의 역순으로 정렬해줘! 가 되겠네요.
좋아요. 그렇다면 그것을 한번 구현해 봅시다!
먼저 우리의 모델 역참조 관계를 잠깐 수정해 줍시다. api.models.post.py 의 PostModel 클래스의 내용 중, 아래의 부분을 수정합니다.
그리고 해야 할 것은 “내가 팔로우한 사람들을 알아내는 것” 입니다. 여기서 “나” 라는 것은 “JWT를 보낸 사람” 을 의미합니다. 아래의 코드로 쉽게 확인할 수 있습니다!
현재 로그인한 사용자 “미미” 는 “코딩좋아1092”, “파이썬좋아”, “자바스크립트싫어” 라는 사용자를 팔로우하고 있습니다. 그리고 위에서 잘 찍히는 걸 확인할 수 있죠? 그리고 그것의 형태는, 파이썬의 리스트네요!
그러면 “사용자들이 담겨 있는 사용자 리스트” 를 받아서 해당 사용자들이 작성한 게시물들을 모두 리턴해주는 필터링 메서드를 위와 같이 PostModel 아래에 정의해 줍시다. (api/models/post 의 PostModel)
그러면 모델 단에서 작성한 필터링 메서드를 사용하는 코드를 작성합니다. 사용자가 검색어를 입력했다면 그것에 대한 응답을, 그렇지 않았다면 자신이 팔로우하고 있는 사람들이 작성한 게시물만 리턴하도록 하고 있습니다.
현재유저 “미미” 로 테스트를 위해서 3명을 팔로우하고 있는 상태이고, (코딩좋아1092, 파이썬좋아, 자바스크립트싫어) 그 중 2명은 게시물을 한 개씩 작성하였습니다. 만약 게시물 목록 API 요청을 – “미미”의 JWT와 함께 보낸다면, “파이썬좋아”, “자바스크립트싫어”가 작성한 2개의 게시물이 id의 역순으로 정렬되어 응답되어야 하겠네요.
[
{
"image": "자바스크립트.png",
"created_at": "2022-12-04,14:10:08",
"updated_at": "2022-12-04,14:10:58",
"author": {
"username": "자바스크립트싫어",
"image": null,
"id": 6
},
"liker_count": 0,
"is_like": false,
"id": 69,
"title": "자바스크립트 싫어!",
"content": "진짜 싫엉"
},
{
"image": "post/2022/11/19/ronnie.jpg",
"created_at": "2022-12-04,13:52:44",
"updated_at": "2022-12-04,13:52:44",
"author": {
"username": "파이썬좋아",
"image": null,
"id": 5
},
"liker_count": 0,
"is_like": false,
"id": 68,
"title": "파이썬 좋아!",
"content": "파이썬 짱!"
}
]
결과적으로, 위와 같이 성공적으로 응답을 주는 것을 확인할 수 있습니다.
검색어를 입력하였을 때에도 잘 동작하는 것을 확인할 수 있네요!
이제 인스타그램을 위한 대부분의 API 구현이 완료된 것 같습니다. :)