0. 하지만 Redis는 과하다고 판단했다
초기 설계는 꽤 그럴듯해 보였지만, 실제 구현 관점에서 다시 검토했을 때 최근 본 상품 조회를 위해 Redis를 도입하는 것은 과하다고 판단했습니다.
그 이유는 크게 세 가지였습니다.
1) 최근 본 상품은 생각보다 복잡한 실시간 캐시가 아니었다
최근 본 상품 기능은 “매우 짧은 시간 안에 수천 TPS를 처리해야 하는 초고속 캐시”라기보다는, 사용자별로 최근 본 몇 개의 상품을 조회하는 기능이었습니다.
즉, Redis를 써야만 할 정도로 극단적인 성능 요구사항은 아니라고 판단했습니다.
2) 추천 시스템 가중치 활용도 PostgreSQL로도 충분하다고 보았다
초기에는 최근 본 상품과 태그 선호도를 Redis에 두고, LLM 추천 시 바로 가져와 가중치로 반영하는 방안을 생각했습니다.
하지만 다시 보니, 이 정보는 결국
- 최근 본 상품 목록
- 최근 관심 태그
- 사용자 행동 feature
형태로 PostgreSQL에 적재해도 충분히 활용 가능하다고 판단했습니다.
즉, 추천 시스템에서 “실시간 Redis 조회”가 아니어도, PostgreSQL 기반 context 조회만으로도 충분히 서비스 요구사항을 만족할 수 있다고 본 것입니다.
3) 운영 복잡도가 증가한다
Redis를 추가하면 다음을 함께 관리해야 합니다.
- Redis 키 설계
- TTL 관리
- 캐시 만료 정책
- DB와 Redis 간 정합성
- 장애 복구 시 재구축 전략
반면 최근 본 상품 기능은 PostgreSQL만으로도 충분히 구현 가능했고, 이미 Kafka Consumer와 RDS를 운영하고 있었기 때문에 시스템을 단순하게 가져가는 편이 더 낫다고 판단했습니다.
2. 최종 결정: Kafka Consumer가 PostgreSQL에 직접 적재
최종적으로는 Redis를 사용하지 않고, Kafka Consumer가 최근 본 상품 로그만 필터링하여 PostgreSQL에 직접 적재하는 방식으로 결정했습니다.
핵심 전략은 다음과 같습니다.
- 전체 로그를 다 적재하지 않는다
click_product_detail이벤트만 선별한다member_id + product_id를 복합키로 사용한다- 동일 상품을 여러 번 클릭해도 중복 row가 생기지 않도록 한다
- 최근 시각 기준 정렬이 빠르도록 인덱스를 설계한다
즉, 최근 본 상품 기능은 “이벤트 전체 저장”이 아니라 “최근 상태 저장” 문제로 보았습니다.
3. 최종 적재 대상 이벤트
최종적으로 PostgreSQL에 적재하는 이벤트는 click_product_detail만 선택했습니다.
로그 템플릿은 다음과 같습니다.
{
"event_id": "uuid-1234-5678",
"timestamp": "2026-03-02T16:30:00.000Z",
"event": "click",
"event_name": "click_product_detail",
"member_id": 45,
"event_properties": {
"page_url": "https://api.holliverse.site/api/v1/customer/plans?category=mobile&page=0&size=50&bestCount=5>",
"product_id": 45,
"product_name": "5G 요금제",
"product_type": "mobile",
"tags": ["영상OTT", "구독결제", "인기"]
}
}
이 이벤트에서 필요한 필드만 추출하여 PostgreSQL 테이블에 적재합니다.
4. 최종 테이블 설계: product_view_history
최종적으로 설계한 테이블은 아래와 같습니다.
CREATE TABLE product_view_history (
member_id BIGINT NOT NULL,
product_id BIGINT NOT NULL,
product_name VARCHAR(100) NOT NULL,
product_type VARCHAR(50) NOT NULL,
tags JSONB,
viewed_at TIMESTAMPTZ NOT NULL,
last_event_id BIGINT NOT NULL,
CONSTRAINT pk_member_product PRIMARY KEY (member_id, product_id)
);
CREATE INDEX idx_pvh_member_viewed
ON product_view_history (member_id, viewed_at DESC);
컬럼 의미
| 컬럼명 | 의미 |
|---|---|
member_id |
로그의 member_id |
product_id |
event_properties.product_id |
product_name |
event_properties.product_name |
product_type |
event_properties.product_type |
tags |
event_properties.tags |
viewed_at |
로그의 timestamp |
last_event_id |
로그의 event_id |
5. 왜 복합키와 Upsert 전략을 썼는가
가장 중요한 설계 포인트는
member_id + product_id를 **복합 기본키(Composite PK)** 로 사용한 것입니다.
이렇게 한 이유는 분명했습니다.
1) 동일 상품 중복 클릭을 원천적으로 차단
사용자가 같은 상품을 여러 번 클릭하더라도 테이블에 row가 계속 쌓이는 것이 아니라, 기존 row가 업데이트되도록 만들고 싶었습니다.
즉, “최근 본 상품 목록”에서 중요한 것은 클릭 이력 전체가 아니라 지금 시점의 최신 상태였습니다.
그래서 INSERT가 아니라 UPSERT 전략을 사용했습니다.
2) 최신 시각만 유지하면 된다
최근 본 상품은 사용자가 가장 최근에 본 순서대로 보여주면 되므로,
동일 상품을 다시 클릭하면 viewed_at만 갱신하면 충분합니다.
즉, 이 기능은 “이력 로그 테이블”이 아니라 최신 상태 테이블로 보는 것이 더 적합했습니다.
3) 조회 성능을 고려한 인덱스
member_id, viewed_at DESC 인덱스를 둔 이유는
사용자별 최근 본 상품을 최신순으로 빠르게 가져오기 위해서입니다.
예를 들어 아래와 같은 조회가 자주 발생합니다.
SELECT *
FROM product_view_history
WHERE member_id = :memberId
ORDER BY viewed_at DESC
LIMIT 3;
이 쿼리는 마이페이지 “최근 본 상품” 기능과, 추천 시스템의 “최근 본 상품 가중치” 계산에도 활용될 수 있습니다.
6. 최종 구조 정리
최종적으로 최근 본 상품 처리 구조는 아래와 같이 정리할 수 있습니다.
초기안
- Kafka Consumer → Redis(ZSET)
- 프론트는 Redis 조회
- 태그 점수도 Redis에 누적
최종안
- Kafka Consumer가
click_product_detail만 필터링 - PostgreSQL
product_view_history에 Upsert - 프론트는 PostgreSQL 기반 최근 본 상품 조회
- 추천 시스템도 동일 데이터를 활용 가능
즉, 최근 본 상품 기능은
“Redis 기반 실시간 캐시”에서 “PostgreSQL 기반 최신 상태 저장” 구조로 단순화되었습니다.
7. 이번 의사결정의 핵심 요약
이번 설계에서의 핵심 의사결정은 다음과 같습니다.
- 최근 본 상품 기능은 실시간 조회가 필요하지만, Redis까지 도입할 정도로 고성능 캐시 요구사항은 아니었다.
- 추천 시스템에서 최근 본 상품과 태그를 활용하는 것도 PostgreSQL 기반으로 충분히 가능하다고 판단했다.
- Redis를 추가하면 운영 복잡도와 정합성 이슈가 늘어나므로, 구조를 단순하게 유지하는 편이 더 적절했다.
- 따라서 Kafka Consumer가
click_product_detail이벤트만 필터링해서 PostgreSQL에 Upsert하는 구조를 최종 채택했다.
9. 결론
최근 본 상품 기능은 처음에는 Redis 기반 실시간 캐시 구조를 검토했지만, 최종적으로는 Kafka Consumer + PostgreSQL Upsert 방식이 요구사항 대비 더 단순하고 충분히 빠르다고 판단하여 채택했습니다.