블로그 개발기 10 (좋아요 편)

글과 댓글에 좋아요를 붙이며 중복 요청과 사용자별 상태를 정리한 기록
calendar icon

2026년 9월 28일

조회 3

tag icon

이 블로그에는 조회수와 댓글이 있었다. 글을 읽었다는 숫자는 남고, 하고 싶은 말이 있으면 댓글을 쓸 수 있었다. 그 사이에 가볍게 반응하는 방법은 없었다.

그래서 글과 댓글에 좋아요를 붙이기로 했다. 하트와 숫자를 보여주는 건 작은 일이었지만, 중복 요청을 처리하고 계정이 바뀌었을 때 선택을 지우는 일까지 따라왔다.

"버튼 하나인데, 버튼만 만들면 끝나는 게 아니었다."


숫자보다 먼저 정한 것

누를 수 있는 대상부터 좁혔다

먼저 누가 무엇에 반응할 수 있는지를 정했다. 기존 GitHub 로그인을 쓰고, 공개된 글과 삭제되지 않은 댓글에만 좋아요를 허용했다.

상황동작
로그인하지 않은 독자개수는 보고, 누르면 로그인 화면으로 이동
로그인한 독자다른 사람이 쓴 공개 글·댓글에 등록과 취소
본인이 쓴 글·댓글개수만 표시
초안이나 삭제된 댓글좋아요 대상에서 제외
초안으로 바뀐 글의 댓글댓글이 남아 있어도 좋아요 대상에서 제외

댓글이 남아 있어도 부모 글이 초안이면 좋아요를 누를 수 없게 했다. 댓글만 확인하지 않고 그 글의 공개 여부까지 함께 검사했다.

본인 콘텐츠에는 숫자만 보여줬다. 누를 수 없는 것을 버튼처럼 그리지 않으려 했다. 서버에서도 작성자를 비교해 요청을 거절하고, 초안과 삭제된 댓글도 걸렀다.

클릭 이력 대신 현재 선택을 남겼다

저장할 것은 모든 클릭의 기록이 아니라 누가 무엇에 좋아요를 남겨둔 상태인지였다. post_likes와 comment_likes 테이블을 따로 만들었다. 글이나 댓글의 ID와 사용자 ID를 묶어 기본 키로 삼았다.

CREATE TABLE "post_likes" (
    "post_id" TEXT NOT NULL,
    "user_id" UUID NOT NULL,
    CONSTRAINT "post_likes_pkey"
        PRIMARY KEY ("post_id", "user_id"),
    CONSTRAINT "post_likes_post_id_fkey"
        FOREIGN KEY ("post_id") REFERENCES "posts"("id")
        ON DELETE CASCADE ON UPDATE CASCADE,
    CONSTRAINT "post_likes_user_id_fkey"
        FOREIGN KEY ("user_id") REFERENCES "profiles"("id")
        ON DELETE CASCADE ON UPDATE CASCADE
);

한 사용자가 같은 글에 두 행을 만들 수 없는 구조다. 댓글도 같은 방식으로 저장했다. 취소하면 해당 행을 지우므로 누르고 취소한 횟수만큼 이력이 쌓이지 않는다.

글·댓글·프로필의 행을 삭제하면 연결된 좋아요도 함께 지워지도록 외래 키를 걸었다. 댓글의 숨김 처리처럼 행이 남는 경우는 조회와 변경 로직에서 따로 제외했다.

새 테이블에도 기존 DB 규칙을 그대로 적용했다. RLS는 켜고 공개 정책은 만들지 않았다. 브라우저에서 테이블을 직접 조작하는 대신, 서버가 인증과 대상 조건을 확인한 뒤 Prisma로 접근하게 했다.


한 번 누른 결과를 지키기

같은 요청은 같은 뜻이어야 했다

화면에서는 좋아요를 한 번 더 누르면 취소된다. 그렇다고 서버 API까지 무조건 현재 값을 뒤집는 방식으로 만들지는 않았다. 같은 요청이 중복으로 들어왔을 때 등록하려던 좋아요가 취소되는 건 원한 결과가 아니었다.

요청의미반복해서 받았을 때
PUT좋아요가 있는 상태로 만든다한 행만 남는다
DELETE좋아요가 없는 상태로 만든다없는 상태를 유지한다

클라이언트는 현재 선택을 보고 보낼 요청을 고른다. 서버는 그 요청이 뜻하는 최종 상태를 만든다. 등록에는 createMany의 skipDuplicates, 취소에는 deleteMany를 썼다.

대상 글을 확인하고 잠근 트랜잭션 안에서 다음 코드를 실행한다.

if (liked) {
    await tx.postLike.createMany({
        data: [{ postId: post.id, userId }],
        skipDuplicates: true,
    });
} else {
    await tx.postLike.deleteMany({
        where: { postId: post.id, userId },
    });
}

없는 좋아요를 취소하는 것도 오류로 만들지 않았다. 이미 원하는 상태이기 때문이다.

공개 여부는 잠근 뒤에 확인했다

중복 방지만으로 끝나지는 않았다. 좋아요를 눌러도 되는지 확인한 뒤 실제로 저장하기 전까지, 대상이 바뀔 수 있었다. 글이 초안으로 바뀌거나 댓글이 삭제되는 경우다.

글에 좋아요를 바꿀 때는 SELECT ... FOR UPDATE로 대상 글을 잠근 뒤 공개 여부와 작성자를 확인했다. 댓글은 댓글 행을 잠그고, 부모 글에는 FOR SHARE를 걸어 공개 상태를 확인했다. 확인과 저장을 하나의 트랜잭션 안에 뒀다.

대상 확인과 저장 사이에 틈을 두지 않는 것이 요점이었다. 같은 대상의 등록·취소도 이 잠금에 맞춰 처리하고, 끝나면 개수와 내 선택 상태를 함께 돌려준다.

글의 수정일도 지켜야 했다. post.update로 개수를 올리면 updatedAt까지 바뀌어 하트를 누른 날이 글을 고친 날처럼 보인다. 그래서 글 자체를 갱신하지 않고 좋아요 테이블의 행을 세도록 했다.

변경 API에는 로그인 확인과 요청 출처 확인, 사용자별 요청 제한도 붙였다. 다만 요청 제한은 기존 인메모리 방식이다. 서버리스 인스턴스마다 따로 세므로 서비스 전체를 통틀어 같은 상한이 보장된다고 보지는 않았다.


개수는 같고 선택은 다르다

내 하트를 공유 캐시에 넣지 않았다

계정을 바꿨는데 이전 사람이 누른 하트가 그대로 남으면 안 된다. 같은 글을 보고 있어도 내 선택은 새로 확인해야 했다. 좋아요 총개수는 모두에게 같지만, 내가 눌렀는지와 내가 쓴 글인지는 사용자마다 달랐다.

"숫자는 같이 봐도 되는데, 내가 눌렀다는 표시까지 같이 보면 안 된다."

그래서 목록용 집계와 본문용 상태를 나눴다. 카드와 검색 결과는 공개 글의 개수만 조회한다. 본문에서는 글과 댓글의 개수에 likedByMe, isMine을 함께 받아온다.

데이터쓰는 화면처리
공개 글별 좋아요 개수카드·검색 결과공통 집계 쿼리 공유
글·댓글의 개수와 내 선택본문·댓글사용자 ID를 포함한 쿼리 키
인증이 바뀌기 전의 내 선택로그인·로그아웃 전환기존 좋아요 쿼리 취소·제거

개인 상태가 든 API에는 Cache-Control: private, no-store를 붙이고, 브라우저 요청에도 cache: "no-store"를 지정했다. 그런데 화면의 React Query도 데이터를 기억하고 있었다. 여기에 쓰는 쿼리 키를 ['likes', slug, userId]로 만들어 사용자별로 구분했다.

로그인·로그아웃 알림을 받으면 진행 중인 좋아요 조회를 취소하고 기존 쿼리를 지웠다. 이어서 현재 사용자 정보를 다시 확인하게 했다.

요청을 보낸 뒤 계정이 바뀌는 경우도 고려했다. 늦게 도착한 응답은 요청 당시 사용자와 현재 사용자가 같을 때만 반영했다. 실패해서 이전 값으로 돌릴 때도 같은 확인을 거쳤다.

댓글 수만큼 API를 부르지 않았다

글 본문과 댓글 버튼은 같은 쿼리를 쓰게 했다. 글 하나를 기준으로 본문과 댓글별 상태를 묶어 받아와, 댓글마다 별도 API를 호출하지 않도록 했다.

서버에서도 댓글마다 내 선택을 다시 묻지 않았다. 댓글들의 개수를 가져오고, 내가 좋아요를 누른 댓글 ID를 모아 Set으로 만든 뒤 응답을 조립했다. 여러 조회가 들어가는 이 묶음에는 RepeatableRead 트랜잭션을 사용했다.

목록의 카드들도 집계 쿼리를 공유한다. 글 좋아요를 바꾸면 해당 글의 집계 캐시를 갱신하고 다시 조회했다. 다른 독자의 반응을 실시간으로 구독하지는 않고, 화면에 다시 포커스가 왔을 때 재조회하도록 했다.

실패한 버튼만 되돌렸다

누른 뒤 서버 응답을 기다리는 동안 아무것도 바뀌지 않으면 반응이 둔해 보인다. 그래서 좋아요를 누르면 선택 상태와 숫자를 먼저 바꾸는 낙관적 갱신을 넣었다. 실패하면 이전 값으로 돌리고 오류를 알려준다.

문제는 글과 댓글이 하나의 캐시를 공유한다는 점이었다. 댓글 하나의 요청이 실패했다고 캐시 전체를 예전 상태로 돌리면, 그사이 바뀐 다른 댓글의 결과까지 지워버릴 수 있었다.

그래서 복구용으로 기억하는 값도 이번에 누른 대상 한 건으로 한정했다. 본문 버튼이면 글 상태만, 댓글 버튼이면 그 댓글 상태만 바꿨다. 같은 글에서 다른 변경이 진행 중이면 마지막 요청이 끝난 뒤 묶음을 다시 조회하도록 했다.

중복 클릭은 ref 잠금과 요청 중 상태로 막았다. 버튼에는 aria-pressed로 선택 여부를, aria-busy로 처리 중임을 표시했다. 색만 바뀌는 버튼으로 남기지 않으려 했다.

불러오는 중에는 숫자 대신 —를 보여줬다. 아직 모르는 개수를 0이라고 표시하면 실제로 반응이 없는 글과 구분이 안 된다. 조회가 실패했을 때는 다시 불러오는 동작도 따로 뒀다.


마지막에 남은 빈자리

로딩 화면도 기능의 일부였다

좋아요를 합치면서 댓글 스켈레톤에 빠진 자리가 보였다. 실제 행에는 시각 뒤에 좋아요 N이 붙는데, 로딩 화면은 이전 구성 그대로였다. 그 줄에 좋아요 막대를 추가했다.

같은 배포 과정에서 관리 필터와 로딩 툴바의 모양도 맞췄다. 완성된 화면만 보고 끝냈더니, 기다리는 동안에는 옛 화면이 남아 있었다.

"다 뜬 화면만 보고 끝났다고 하면, 뜨는 중인 화면은 누가 보나."

댓글 관리 주소로 바로 들어갔을 때 포스트 스켈레톤이 먼저 나오는 문제도 따로 고쳤다. 포스트의 페이지와 로딩 파일을 (posts) 라우트 그룹으로 옮겨 범위를 좁혔다. 좋아요와 별개인 수정이었지만, 탭 전환만 봐서는 놓치는 화면이 있다는 걸 확인한 일이었다.

검사한 것과 아직 증명하지 않은 것을 나눴다

회귀 검사에는 반복 등록·취소와 사용자별 반응, 본인·초안·삭제된 대상의 거절, 수정일 유지를 넣었다. API의 인증·요청 출처·요청 제한과 캐시 헤더, 마이그레이션의 RLS 설정도 확인한다.

여기서 선을 하나 그었다. 이 검사는 트랜잭션을 순서대로 실행하는 모의 DB 검사다. 실제 PostgreSQL에 동시 요청을 던져 잠금 경합이나 교착 상황까지 검증한 것은 아니다. 스크립트 출력에도 그 한계를 적었다.

배포 과정에서는 lint와 타입 검사를 통과했고, 마이그레이션 적용 상태와 RLS를 확인했다. 배포된 화면의 좋아요 표시와 API 응답, 관리 목록의 집계도 확인했다. 마지막 등록·취소 동작은 직접 눌러 확인했다.

댓글 스켈레톤 막대의 정확한 위치와 여러 화면 폭에서의 배치까지 확인한 것은 아니다. 그 부분은 추가 확인할 일로 남겼다.


결과

이제 댓글을 쓰지 않고도 글에 반응을 남길 수 있다. 본문에서 누른 좋아요는 목록의 집계에도 반영된다. 좋아요가 보이는 자리를 정리하면 이렇다.

화면추가한 것
글 본문·댓글·답글좋아요 등록·취소, 본인 콘텐츠는 개수만 표시
카드·검색 결과공개 글의 좋아요 개수
관리 목록글·댓글의 좋아요 집계
댓글 로딩 화면좋아요가 들어갈 자리

좋아요는 새 테이블에서 0개로 시작하므로 기존 콘텐츠를 옮기는 백필은 필요하지 않았다. 배포 전에는 테이블과 RLS가 이미 적용돼 있는지 확인하고 코드를 합쳤다.


느낀점

  • 작은 버튼도 상태의 주인은 분명해야 한다. 총개수와 내 선택은 같은 응답에 들어 있어도 공유할 수 있는 범위가 달랐다. API 헤더와 화면 캐시를 함께 봐야 했다.
  • 중복 요청을 막는 것과 같은 결과를 만드는 것은 다르다. 화면의 연속 클릭을 막아도 서버에는 요청이 다시 올 수 있다. 등록과 취소의 뜻을 분리하고 DB 기본 키로 한 번 더 지켰다.
  • 낙관적 갱신은 실패했을 때의 범위가 중요했다. 먼저 숫자를 바꾸는 것보다 어디까지 되돌릴지 정하는 일이 더 조심스러웠다. 공유 캐시 전체를 복원하지 않고 대상 한 건만 복구했다.
  • 로딩 화면도 현재 기능을 보여줘야 한다. 완성된 화면만 고치면 기다리는 순간에 옛 UI가 남는다. 직접 주소로 들어가는 흐름까지 봐야 스켈레톤의 범위 문제도 드러났다.

작은 기능을 붙이며 로그인, 캐시, 로딩 화면까지 다시 보게 됐다. 하트가 나타나는 것에서 멈추지 않고, 계정을 바꾸거나 요청이 실패했을 때의 동작까지 정리한 작업이었다.

여기까지 읽어주신 분들께 감사 인사 올리고, 이번 개발기는 여기서 마치겠다(넙죽).

좋아요 —
hamburger icon

무링의 개발 블로그

FDE를 향하는 개발자


댓글

GitHub 계정으로 로그인하면 댓글을 남길 수 있습니다.

로그인

연관된 글