티스토리 뷰

728x90
좋아요 등록·취소는 단순한 INSERT/DELETE 같지만, 멱등성을 제대로 보장하려면 다섯 개의 결정을 거친다. DB unique 제약과 hard delete 같은 결정들이 한 트랜잭션 안에서 도메인 정합성을 어떻게 만드는지 정리한다.

 

1. POST /products/{id}/likes 한 줄에서 시작

좋아요는 처음 봤을 때 단순한 기능이었다. 명세상 엔드포인트는 세 개뿐이다.

 

 

(user_id, product_id) 한 행을 INSERT 하거나 DELETE 하는 일이다.

그런데도 설계 문서를 쓰는 동안 결정해야 했던 것이 다섯이었다.

멱등성, 좋아요 수의 위치, soft delete 와 unique 의 충돌, BaseEntity 상속 여부, 그리고 그 모두를 묶는 hard delete 결단.

이 글은 그 다섯을 거치며 본 트레이드오프를 정리한다. 정답을 안다고 말하기보다는, 그 시점에 어떤 옵션이 있었고 왜 그것을 골랐는지에 가깝다.

 

2. 멱등성 — 같은 요청을 N 번 보내도 결과가 같으려면

좋아요는 사용자 입장에서 "한 번 누르면 끝" 인 행위지만, 네트워크 위에서는 그 한 번이 두세 번 도착할 수 있다. 같은 POST 가 두 번 와도 좋아요는 1개여야 하고, 같은 DELETE 가 두 번 와도 결과는 "없음" 으로 같아야 한다.

처음에는 단순하게 봤다. 애플리케이션 단에서 exists 한 번 체크하면 끝날 거라고. 문제는 동시 요청이다. 두 트랜잭션이 동시에 exists  false 로 받고 둘 다 INSERT 를 시도하면 같은 (user_id, product_id) 행이 두 개 들어가는 race condition 이 생긴다.

이걸 막는 가장 단순한 방어선은 DB 의 UNIQUE 제약이다.

 

CREATE UNIQUE INDEX uk_likes_user_product ON likes (user_id, product_id);

 

이 제약이 있으면 두 번째 INSERT 는 무결성 예외로 떨어지고, 애플리케이션은 그 예외를 잡아 "이미 좋아요가 있다" 로 해석하면 그만이다. 두 단계 방어선이 만들어진다.

  • 1차 — 애플리케이션 exists 사전 체크: 흔한 재요청을 SELECT 한 번으로 흡수
  • 2차 — DB unique 제약: 1차에서 못 잡은 race condition 을 막는다

흔한 경우는 빠르게, 드문 경우는 안전하게. 흐름으로 그리면 이렇다.

DELETE 쪽은 더 단순하다. 영향 행 수가 0 이면 "원래 없었음" 으로 흡수한다.

 

3. 좋아요 수, 어디에 두어야 하나

좋아요 수는 두 곳에서 쓰인다 — 상품 상세 페이지의 표시와 상품 목록의 정렬 옵션 (sort=likes_desc). 정렬은 데이터 모델의 선택을 강하게 좁힌다.

 

 

처음에는 ③ (매번 COUNT) 이 가장 깔끔하다고 봤다. 정규화의 교과서적 형태다.

그런데 sort=likes_desc 가 결정을 좁혔다. 모든 상품의 좋아요 수를 알아야 하는데, COUNT 를 매 상품마다 돌리면 인덱스 활용이 거의 불가능하고 좋아요가 늘어날수록 ORDER BY 가 풀스캔에 가까워진다. ② 도 비슷한 사정이지만, 우리가 보관하는 통계가 like_count 하나뿐인데 테이블을 하나 더 두는 건 과해 보였다.

남은 건 ① — Product.like_count 컬럼. 정렬 인덱스가 본문에 그대로 걸리고, 상품 목록 조회가 단일 쿼리로 끝난다.

 

CREATE INDEX ix_products_like_count ON products (like_count);

 

대신 트레이드오프가 분명하다. 인기 상품의 like_count  hot row 가 되고, likes 와의 정합성 책임이 애플리케이션으로 옮겨온다. hot row 는 UPDATE products SET like_count = like_count + 1 같은 원자적 증감으로 안전해지고, 정합성은 같은 @Transactional 안에 묶는 것으로 보장된다.

 

4. 취소된 좋아요가 다시 좋아요가 되는 순간 — soft delete × unique 충돌

 

여기까지는 자연스러웠다. 그런데 다른 도메인의 정책이 한 가지 걸렸다. 이 프로젝트의 BaseEntity  deleted_at 컬럼으로 soft delete 를 한다 — Brand, Product, Order 모두 이 정책을 따른다.

좋아요도 같은 방식으로 가면 이런 상황이 만들어진다.

 

[지난 주]  user=1, product=10, deleted_at=2026-05-15 09:00   ← 취소된 행
[오늘]     user=1, product=10                                  ← 같은 사용자가 다시 좋아요

 

 

새 INSERT 가 (user_id, product_id) unique 제약과 충돌한다. 좋아요는 잠깐 누르고 떼는 행위라 이 시나리오가 드물지 않다.

 

 

A 와 B 는 DB 차원의 영리한 트릭이지만 각자 비용이 있었다. C 는 표준적이지만 짧은 액션에 무거웠다. 남은 건 D — Like 만 hard delete 로 가는 결단이었다.

좋아요는 다른 도메인과 달리 취소가 자주 일어나고, 이력 보존의 가치가 크지 않다. 누군가 좋아요 했다가 취소했다는 사실을 시스템이 굳이 기억할 이유가 없다.

 

5. Like 만 hard delete — 그리고 BaseEntity 한 줄을 안 썼다

BaseEntity  id, createdAt, updatedAt, deletedAt 컬럼을 자동 관리한다. 상속하는 순간 그 엔티티는 자동으로 soft delete 의 인프라를 갖는다. 그런데 Like 는 hard delete 다. deleted_at 도, updated_at 도 필요 없다. 두 옵션이 있었다.

 

 

결국 b 를 골랐다. 컬럼은 거짓말을 하지 않아야 한다. deleted_at 이 있다는 건 "이 엔티티는 soft delete 됩니다" 라는 약속이다. 그 약속을 지키지 않는 컬럼을 남겨두는 게 더 큰 비용이라고 봤다.

Brand 와 Like — 같은 시스템에서 다른 정책

흥미로운 건, Brand 는 정반대 길을 골랐다는 점이다. 브랜드도 이름 unique 제약 때문에 같은 충돌을 겪는데, Brand 는 자동 restore (4 의 옵션 C) 로 풀었다.

 

 

처음에는 두 도메인이 같은 정책을 가져가는 게 "일관성" 이라고 봤다. 그런데 일관성에는 두 종류가 있었다 — 표면 일관성 (모두 같은 패턴) 과 본질 일관성 (각자의 특성에 맞는 패턴). 다른 정책을 가져가도 "왜 그렇게 선택했나" 의 사고가 일관되면 그것이 본질 일관성이다.

 

6. 정리 — 단순한 기능에 숨어 있던 결정들

 

 

이번에 배운 것

  • 요구사항 한 줄 — sort=likes_desc — 이 데이터 모델의 큰 결정을 좌우할 수 있다. API 명세는 단순한 출입구 같지만, 그 안쪽 데이터의 형태를 결정한다.
  • 모든 도메인이 같은 패턴을 가져가는 게 자동으로 옳은 건 아니다. 깰 때는 이유를 명시하고 받아들일 비용을 솔직히 적어두는 것이 더 중요하다.

다음 단계에서 확인할 것

설계는 거기까지였고, 실제 구현은 다음 주의 과제다. 그때 검증할 가설이 몇 개 있다.

  • Product.like_count 의 hot row 경합 — 원자적 UPDATE 로 충분한가, 비관/낙관 락이 필요한가
  • existsBy + DB unique 의 두 방어선이 동시 요청에서 어떻게 작동하는지
  • 트래픽이 커진 가상의 미래에 like_count 를 비동기 집계로 분리하는 게 정말 필요한지

이 글이 만든 답들이 다음 주에 깨질 수도 있다. 그게 깨졌을 때 또 한 번 정리하는 것이 다음 글의 자리다.

728x90