sungyup's.

PostgreSQL / Database Structure Design Patterns / 3.2 "Like" System

3.2"Like" System

좋아요 시스템 추가하기

Rules of Likes: 좋아요 기능 설계하기

  • 한 유저는 한 게시물에 한 번만 좋아요를 누를 수 있어야 한다.
  • 좋아요를 취소(=unlike)할 수 있어야 한다.
  • 특정 게시물이 얼마나 많은 좋아요를 받았는지 집계할 수 있어야 한다.
  • 어떤 유저들이 특정 게시물을 좋아하는지 알 수 있어야 한다.
  • 좋아요 대상이 꼭 포스트에 국한되지 않을 수도 있다(예: 댓글도 좋아요 가능)
  • 리액션 기능(예: 좋아요 뿐 아니라 슬퍼요, 놀랐어요 반응 등)이 나중에 필요한지 생각해봐야 한다.

잘못된 설계 예시

posts 테이블에 likes 컬럼을 추가하는 방식

sql
ALTER TABLE posts ADD COLUMN likes INTEGER;
  • 어떤 유저가 어떤 포스트에 좋아요를 눌렀는지 알 수 없다.
  • 좋아요 중복 방지 불가능
  • 좋아요 취소 처리가 어려움
  • 유저가 탈퇴하면 관련된 좋아요 데이터 처리가 어려움

정석적인 방식: 별도 likes 테이블 만들기

likes 테이블을 추가하고, id, user_id, post_id로 구성된 likes 테이블에 데이터를 추가하고 제거한다.

iduser_idpost_id
135
211
342
  • 여기에 user_id, post_id 조합에 대해 UNIQUE 제약을 걸어야 한다.
sql
ALTER TABLE likes ADD CONSTRAINT unique_user_post_like UNIQUE(user_id, post_id);

이렇게 하면, 특정 포스트의 좋아요 개수 계산이라던가, 특정 포스트를 좋아요한 유저 리스트라던가, 가장 인기 있는 포스트 5개라던가, 특정 유저가 좋아한 모든 포스트의 리스트 등 다양한 데이터를 쿼리로 쉽게 가져올 수 있다.

  • 좋아요 개수 계산
sql
SELECT COUNT(*) FROM likes WHERE post_id = 3;
  • 좋아요한 유저 리스트
sql
SELECT users.username FROM likes JOIN users ON likes.user_id = users.id WHERE post_id = 3;

이 패턴은 북마크, 즐겨찾기, 팔로우 같은 기능에도 쓸 수 있다.

다만, 이렇게 하면 아래와 같은 한계가 있다.

  • facebook 처럼 리액션(😢, 🤣 등)을 지원하진 못한다.
  • 댓글에 대한 like는 지원하지 못한다.

이 한계들을 해결해보자.

리액션 시스템으로 확장하기

예시 reactions 테이블

iduser_idpost_idtype
135like
211love
342sad
  • 이 경우엔 type 열이 몇 가지 데이터로 제한되어야 하므로 Enum 타입으로 제한할 수 있다.

Polymorphic Association 방식

추천되는 방법은 아니지만, 실제로 쓰이는 기법이다.

likes 테이블에 liked_type이라는 열을 쓰고, 여기서 post에 대한 좋아요인지 comment에 대한 좋아요인지를 표시한다. 어떤 포스트/커멘트에 대한 것인지는 liked_id로 관리한다.

예시 polymorphic likes 테이블

iduser_idliked_idliked_type
132post
211comment
342comment
433post

예를 들어, 1번 id는 user_id가 3인 유저가 posts 테이블의 id가 2인 포스트에 대해 좋아요를 누른 것이고, 2번은 user_id가 1인 유저가 comments 테이블의 id가 1인 커멘트에 좋아요를 누른 것이다.

하지만, 이 방식은 liked_idforeign key로 제약될 수 없다. post_id인지, comment_id인지가 liked_type와 조합되어야만 알 수 있기 때문에 개발자들은 알 수 있으나 PostgreSQL은 foreign key 제약을 걸 수 없어 유지보수 측면에 혼란을 유발할 수 있고, 최적의 방식이라고 할 수 없다.

대안: post_id, comment_id를 나란히 넣는 방식

foreign_key를 쓰기 위해, likes 테이블을 liked_id, liked_type 대신 post_id, comment_id를 쓰고 해당되는 컬럼에 id를, 아닌 컬럼에는 NULL을 적용하는 방식이다.

예시 likes 테이블: post_id와 comment_id를 나란히 넣기

iduser_idpost_idcomment_type
131NULL
21NULL3
342NULL
433NULL
sql
ALTER TABLE likes ADD CHECK( (post_id IS NOT NULL AND comment_id IS NULL) OR (post_id IS NULL AND comment_id IS NOT NULL) )

위와 같이 둘 중 한 열은 반드시 NULL이고, 다른 하나는 반드시 NULL이 아니라는 제약 조건이 필요하다.

PostgreSQL에는 아래처럼 boolean 캐스팅으로도 구현이 가능하다.

sql
ADD CHECK of ( COALESCE((post_id)::BOOLEAN::INTEGER,0) + COALESCE(((comment_id)::BOOLEAN::INTEGER,0)) )

COALESCE는 (무서워보이지만, 다행히 단순하게도) null이 아닌 첫 값을 반환하는 키워드이다.

::BOOLEAN::INTEGER는, 만약 null값에 대한 것이면 null을 반환하고 아니면 숫자를 반환한다.

즉, 위의 체크는 post_id와 comment_id 중 하나만이 NULL이고, 다른 하나는 NULL이 아니어야 한다는 의미다.

하지만 이렇게 하면 포스트, 커멘트, 메시지, 영상...등 다양한 것들을 좋아하게 한다면 테이블에 많은 열들을 추가해야 한다는 단점이 있다.

가장 단순한 대안: 테이블 분리하기

그냥 posts_likes와 comments_likes 테이블을 따로 만드는 방법도 있다.

예시 posts_likes 테이블

iduser_idpost_id
131
213
342

예시 comments_likes 테이블

iduser_idcomment_id
133
211
342

어떤 방식을 선택해야 할까?

이번 스키마 디자인 예시에서는 두번째 방식, 즉 하나의 likes 테이블에 post_id와 comment_id 열을 모두 저장하는 방식을 택한다. 첫번째 방식은 foreign key 제약을 걸 수 없기 때문에 배제한다. 세번째 방식은 post를 like할 때나 comment를 like할 때나 특별히 구분해야 할 만한 다른 점이 없기 때문에 굳이 테이블을 추가로 만들 필요가 없기 때문에 배제한다.(특별히 구분할 필요 없는데 테이블을 나눌 이유도 없다)

sql
Table likes { id SERIAL [pk, increment] created_at TIMESTAMP user_id INTEGER [ref: > users.id] comment_id INTEGER [ref: > comments.id] post_id INTEGER [ref: > posts.id] }
like 테이블 추가
like table을 추가한 모습이다. 화살표들이 벌써부터 꽤 복잡하게 보이는데, 화살표들이 복잡하게 이어진다고 나쁜 데이터 스키마 디자인인것은 아니다.