Skip to content

[의사결정] BE 좋아요 백엔드 설계 #87

@wlgns12370

Description

@wlgns12370

요구사항

  • 비로그인 사용자는 동아리에 좋아요를 1초마다 클릭할 수 있다.

식별 전략

식별자 사용 시 문제 조합 시 역할
IP 학교 WiFi는 수천 명이 같은 IP → 정상 사용자 차단 매크로/VPN 없는 일반 공격 차단
Device Id 값을 바꿔서 전송하면 서버가 검증 불가 기기 단위 식별
IP + Device Id 값을 바꿔서 전송하면 서버가 검증 불가 둘 다 바꿔야 우회 가능 → 공격 비용 상승

결정

IP + Device ID 조합이 보안적으로 가장 강력하다고 판단하여 결정했습니다.

Device ID 생성 전략

서버가 발급할 ID의 생성 방식으로 아래 후보를 검토했습니다.

전략 구조 DB 저장/인덱싱 시간순 정렬 생성 시점 노출 외부 의존성
Auto Increment 순번 정수 최적 가능 노출 (순번으로 추측 가능) 없음 (DB 필요)
UUID 128bit 완전 랜덤 비효율 (랜덤 삽입) 불가 없음 없음
ULID 48bit 타임스탬프 + 80bit 랜덤 효율적 가능 노출 (타임스탬프 포함) 별도 라이브러리
TSID (Snowflake) 타임스탬프 + 노드 ID + 시퀀스 최적 가능 노출 (타임스탬프 + 노드 정보) 별도 라이브러리

Auto Increment
→ id를 DB가 아닌 WAS에서 생성해야 하고, Device ID는 DB에 저장하지 않으므로 순번 생성 자체가 불가능 하여 기각 했습니다.

ULID / TSID

  • 타임스탬프가 ID에 인코딩되어 있어 쿠키 값만 봐도 발급 시점을 역산할 수 있습니다.
  • 서버 노드 정보까지 포함되는 TSID는 인프라 정보 노출 위험이 있습니다.

✔ UUID
→ 122bit 완전 랜덤으로 생성 시점, 순서, 서버 정보가 ID에 포함되지 않습니다.
→ Device ID는 DB에 저장하지 않고 Redis rate limit 키의 일부로만 사용되므로 인덱스 삽입 성능 문제가 없다고 생각했습니다.
UUID.randomUUID() 한 줄로 외부 라이브러리 없이 구현하면 간편하게 구현할 수 있다고 생각했습니다.
→ 충돌 확률은 122bit 랜덤 기준으로 무시 가능한 정도라고 판단했습니다.

결정

저는 위 이유로 인해 UUID 방법으로 id 생성 전략으로 구현하면 좋겠습니다.

API 명세

엔드포인트 메서드 설명 인증
/api/v1/device-id GET Device ID 발급 (최초 1회) 불필요
/api/v1/booths/{booth-id}/likes POST 좋아요 Cookie: deviceId 필요
/api/v1/booths/{booth-id}/likes GET 좋아요 수 조회 불필요

좋아요 요청 시 쿠키가 없으면 서버에서 400을 반환합니다.
좋아요 요청을 1초 단위로 검증하는데 만약 더 빠르게 요청한다면 서버에서 429를 반환합니다.


서버 파이프라인 시퀀스 다이어그램

sequenceDiagram
    participant B as Browser
    participant S as Spring Server
    participant R as Redis

    Note over B,S: 앱 최초 진입
    B->>S: GET /api/v1/device-id
    alt 쿠키 없음
        S-->>B: Set-Cookie: deviceId=UUID&#59; HttpOnly&#59; Path=/&#59; MaxAge=1년
    else 쿠키 있음
        S-->>B: 기존 deviceId 반환 (쿠키 재발급 없음)
    end

    Note over B,S: 좋아요 클릭
    B->>S: POST /api/v1/booths/{booth-id}/likes (Cookie: deviceId=UUID 자동 전송)
    S->>R: SET like:rate:{deviceId}:{boothId} 1 EX 1 NX
    alt 1초 이내 재요청
        R-->>S: nil (key 존재)
        S-->>B: 429 Too Many Requests
    else 정상 요청
        R-->>S: OK
        S->>R: INCR like:count:{boothId}
        R-->>S: 증가된 카운트
        S-->>B: 200 OK (누적 좋아요 수)
    end

    Note over B,S: 좋아요 수 조회
    B->>S: GET /api/v1/booths/{booth-id}/likes
    S->>R: GET like:count:{boothId}
    R-->>S: 카운트 값
    S-->>B: 200 OK (좋아요 수)
Loading
순서 시점 행동 비고
1 앱 최초 진입 GET /api/v1/device-id 호출 쿠키 없으면 서버가 UUID 발급 및 Set-Cookie
2 이후 재방문 GET /api/v1/device-id 동일하게 호출 쿠키 있으면 서버가 기존 값 반환, 재발급 없음
3 모든 API 요청 credentials: 'include' 옵션 설정 브라우저가 쿠키 자동 전송
4 좋아요 버튼 클릭 POST /api/v1/booths/{booth-id}/likes 호출 X-Device-Id 헤더 전송 불필요 (쿠키 활용)
5 좋아요 수 표시 GET /api/v1/booths/{booth-id}/likes 호출 쿠키 불필요 (공개 데이터)
6 1초 내 재클릭 서버에서 429 Too Many Requests 응답 프론트에서 버튼 비활성화 처리 권장

논의 — 좋아요 랭킹 자료구조 선택

랭킹 기능 요구사항: 전체 부스를 좋아요 수 기준으로 내림차순 정렬해 부스 ID, 이름, 좋아요 수를 조회합니다.

자료구조 비교

항목 String INCR 유지 ZSet 전환
랭킹 조회 N번 GET 후 애플리케이션에서 정렬 ZREVRANGE 1번으로 정렬된 결과 반환
좋아요 등록 INCR 1번 ZINCRBY 1번 (동일)
단건 카운트 조회 GET 1번 ZSCORE 1번
부스 이름 포함 Redis에 없어 DB 조회 필요 동일

[선택] ZSet

Redis Sorted Set은 score 기준 정렬을 자료구조 레벨에서 지원합니다. 랭킹 조회가 요구사항으로 존재하기 때문에 애플리케이션 레벨에서 N번 GET 후 정렬하는 것보다 ZREVRANGE 1번으로 처리하는 것이 구조적으로 효율적이라고 판단했습니다. 또한 등록 성능에서 ZINCRBY 명령어의 시간복잡도는 O(log N)으로 기존 INCR과 실질적 차이가 없습니다.

조금 추상적으로 정리 하자면 ZSet 자료구조가 String과 비교했을때, 등록 성능은 동일한데 조회 성능이 더 좋기 때문에 ZSet을 선택했습니다.

[참고 레퍼런스] : https://redis.io/docs/latest/develop/data-types/sorted-sets/
[같이 읽으면 좋은 이슈] : #85

해당 부분에 의견 부탁드립니다.

Metadata

Metadata

Labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions