Day97

강태훈·2026년 5월 20일

nbcamp TIL

목록 보기
97/97

Character / Share Backend TODO

기준일: 2026-05-20
범위: 캐릭터 도메인, 공유 도메인, 관련 user/item 도메인 연동 지점


1. 현재 정리된 방향

1.1 공유 카드 생성

현재 공유 카드 생성은 프론트엔드가 이미지를 먼저 생성하고 업로드한 뒤, 공개 이미지 URL을 백엔드에 전달하는 방식이다.

컴포넌트 렌더링
-> 이미지 캡처
-> GET /api/share/v1/presigned-url
-> presignedUrl로 R2/S3 직접 PUT 업로드
-> POST /api/share/v1/share-cards { characterId, imageUrl }
-> POST /api/share/v1/share-events

POST /api/share/v1/share-cards 요청에는 현재 headline을 포함하지 않는다.

{
  "characterId": 10,
  "imageUrl": "https://cdn.polaris.app/share-cards/UUID.png"
}

1.2 공유 보상

공유 보상은 API 명세 기준 하루 1회, 10 별조각이다.

{
  "rewardPaid": true,
  "rewardStarPiece": 10
}

1.3 공유 headline

현재 백엔드 구현은 사용자 입력 headline을 받지 않고, 서버 고정 문구를 내려준다.

private static final String PLACEHOLDER_HEADLINE = "Today, I shone a little.";

단기적으로는 서버 기본 문구를 유지할 수 있다. 다만 공유 카드가 반복 노출되는 기능이므로, 추후에는 headline 후보 문구를 테이블로 관리하고 공유 카드 생성 시 하나를 선택해 저장하는 구조가 적합하다.


2. P0: 바로 해결해야 할 문제

2.1 공유 보상 wallet 적립 연동

상태: 일부 수정완료

현재 ShareService#createShareEventrewardPaidrewardStarPiece를 계산하고 share_logs에 저장하지만, 실제 wallet 적립을 호출하지 않는다.

완료된 부분:

  • user 도메인 WalletService#earnStarPiece 메서드는 존재한다.
  • proto/src/main/proto/user/v1/wallet_service.protoEarnStarPiece RPC가 추가되어 있다.
  • WalletGrpcController#earnStarPiece가 구현되어 있다.

남은 문제:

  • character 도메인 ShareService에 wallet 연동 TODO가 남아 있다.
  • ShareService#createShareEvent에서 아직 user wallet gRPC를 호출하지 않는다.
  • 공유 보상이 실제 wallet에 적립되지 않는다.
  • ShareEventResult.walletStarPiece는 여전히 0을 반환한다.

남은 작업:

  1. character/share 도메인에서 user wallet gRPC client 연결
  2. 공유 보상 지급 시 EarnStarPiece 호출
  3. reason=SHARE_REWARD, refType=SHARE, refId=shareLogId 또는 shareCardId, idempotencyKey 전달
  4. 응답의 wallet.starPiece에 최신 잔액 반영
  5. wallet 적립 실패 시 share_logs 저장과 보상 지급 상태를 어떻게 맞출지 정책 결정

2.2 공유 보상 일일 1회 멱등성 강화

상태: 개선 필요

현재 공유 이벤트는 클라이언트가 body로 전달한 idempotencyKey를 그대로 사용한다.

문제:

  • 클라이언트가 매번 다른 idempotencyKey를 보내면 같은 날 중복 요청을 완전히 막기 어렵다.
  • existsByUserIdAndShareDateAndRewardPaidTrue 확인 후 저장하는 방식만으로는 race condition 방어가 부족하다.

권장 방향:

  • 보상 지급용 멱등키는 서버가 생성한다.
SHARE_REWARD:{userId}:{yyyy-MM-dd}
  • 클라이언트 body의 idempotencyKey는 공유 이벤트 요청 재시도 식별용으로만 쓸지, 아예 서버 생성 키로 통일할지 정책을 정한다.
  • DB 레벨에서 일일 보상 중복 지급을 막는다.

가능한 DB 방어:

-- reward_paid=true인 공유 보상은 유저/날짜당 1개만 허용
CREATE UNIQUE INDEX uq_share_logs_daily_reward
ON share_logs (user_id, share_date)
WHERE reward_paid = TRUE;

완료된 부분:

  • 보상 지급용 멱등키를 서버에서 생성하는 로직이 추가되었다.
SHARE_REWARD:{userId}:{yyyy-MM-dd}
  • share_logs에 일일 보상 중복 방지용 partial unique index가 추가되었다.
CREATE UNIQUE INDEX IF NOT EXISTS uq_share_logs_daily_reward_paid
ON share_logs (user_id, share_date)
WHERE reward_paid = TRUE;

남은 개선점:

  • 동시 요청에서 unique index 충돌이 발생했을 때 예외를 잡아 기존 보상 로그를 조회하고 동일 응답으로 replay하는 처리가 필요하다.
  • 클라이언트가 보낸 idempotencyKey를 보상용 키와 이벤트 기록용 키 중 어떤 의미로 유지할지 정책을 더 명확히 해야 한다.
  • wallet 적립 연동 후에는 wallet transaction의 idempotencyKeySHARE_REWARD:{userId}:{yyyy-MM-dd}와 일관되게 맞춰야 한다.

2.3 공유 이벤트 소유자 검증

상태: 수정됨

현재 createShareEvent(userId, shareCardId, ...)shareCardId로 카드를 조회하지만, 해당 카드가 요청 사용자 소유인지 확인하지 않는다.

필요 작업:

  • ShareCard card 조회 후 card.getUserId().equals(userId) 검증
  • 불일치 시 NOT_SHARE_CARD_OWNER 또는 적절한 권한 에러 반환

이 검증이 없으면 다른 사용자의 shareCardId로 내 공유 보상을 받을 수 있다.

2.4 오늘 공유 보상 상태 조회 API 구현

상태: 수정됨

API 명세에는 아래 endpoint가 있다.

GET /api/share/v1/share-events/today

하지만 현재 gateway ShareController에는 해당 endpoint가 없다.

필요 작업:

  • character proto에 today status RPC 추가
  • character ShareService에 오늘 보상 수령 여부 조회 구현
  • gateway ShareController에 endpoint 추가

응답 예시:

{
  "rewardClaimed": true,
  "lastSharedAt": "2026-05-19T09:22:24.400Z"
}

2.5 공유 링크 OG HTML 라우트 구현

상태: 개선 필요

현재 구현된 공개 공유 링크 API는 JSON이다.

GET /api/share/v1/share-links/{shareId}

하지만 카카오톡, 디스코드, X 등 외부 서비스의 미리보기는 JS를 실행하지 않고 HTML <head>의 OG 태그를 읽는다.

필요 작업:

  • /share/{shareId} HTML 라우트 제공
  • 서버에서 동적으로 OG 태그 렌더링
  • gateway 모듈에 공유 링크 전용 HTML 컨트롤러를 추가하는 방식을 우선 검토한다.

현재 gateway는 spring-boot-starter-web만 사용하고 Thymeleaf 의존성이 없다. 따라서 MVP에서는 별도 템플릿 엔진을 추가하지 않고, @Controller 또는 @RestController에서 text/html 응답을 직접 반환하는 경량 구현이 적합하다.

예상 흐름:

GET /share/{shareId}
-> gateway ShareHtmlController
-> ShareGatewayService#getShareLink(shareId)
-> character/share 도메인에서 imageUrl, headline, characterName, signupUrl 조회
-> gateway가 OG meta tag 포함 HTML 반환

필수 OG 태그:

<meta property="og:title" content="..." />
<meta property="og:description" content="..." />
<meta property="og:image" content="https://..." />
<meta property="og:url" content="https://polaris.app/share/{shareId}" />

주의:

  • og:image는 반드시 https://... 절대 URL이어야 한다.
  • 권장 이미지 크기는 1200 x 630px이다.
  • 프론트 SPA에서 동적으로 meta tag를 바꾸는 방식은 메신저 미리보기를 보장하지 못한다.

권장 구현 형태:

@Controller
@RequiredArgsConstructor
public class ShareHtmlController {

    private final ShareGatewayService shareGatewayService;

    @GetMapping(value = "/share/{shareId}", produces = MediaType.TEXT_HTML_VALUE)
    @ResponseBody
    public String getShareHtmlPage(@PathVariable String shareId) {
        var shareLink = shareGatewayService.getShareLink(shareId);
        // HTML escape, URL validation 후 HTML 문자열 반환
    }
}

구현 시 필수 보완:

  • characterName, headline, imageUrl, signupUrl 등 HTML에 삽입되는 값은 반드시 escape한다.
  • og:image와 redirect URL은 https://... 절대 URL인지 검증한다.
  • imageUrl이 비어 있거나 올바르지 않으면 기본 공유 이미지로 fallback한다.
  • og:url의 base URL은 코드에 고정하지 말고 설정값으로 분리한다. 예: app.public-base-url=https://polaris.app
  • 없는 shareId는 JSON 에러가 아니라 404 HTML 또는 기본 OG HTML로 응답할지 정책을 정한다.
  • 실제 사용자를 위해 JavaScript redirect만 두지 말고 body에 기본 링크와 <noscript> fallback을 함께 둔다.
  • X/Twitter 대응을 위해 twitter:card, twitter:title, twitter:description, twitter:image도 함께 추가한다.
  • 공유 카드 이미지가 갱신될 수 있으므로 초기 cache 정책은 짧게 둔다. 예: Cache-Control: public, max-age=300

주의할 점:

  • ShareController@RestController/api/share 전용으로 유지한다.
  • /share/{shareId}는 공개 endpoint이므로 인증 인터셉터 대상에 포함하지 않는다. 현재 gateway AuthInterceptor/api/**에만 적용되므로 구조상 공개 접근이 가능하다.
  • 문자열 HTML을 직접 조립하더라도 escape와 URL 검증을 생략하면 안 된다.

완료된 부분:

  • gateway 모듈에 /share/{shareId} HTML 응답 컨트롤러가 추가되었다.
  • ShareGatewayService#getShareLink(shareId)를 사용해 공유 링크 정보를 조회한다.
  • OG 태그와 X/Twitter 카드 태그를 반환한다.
  • HTML escape, https://... URL 검증, 기본 이미지 fallback, cache header가 포함되어 있다.
  • JavaScript redirect와 body fallback link가 포함되어 있다.

남은 개선점:

  • 없는 shareId 또는 character/share 서비스 오류 발생 시 JSON 에러가 아닌 HTML 404/fallback 페이지로 응답하도록 예외 처리가 필요하다.
  • og:title이 현재 고정 Polaris라면, 캐릭터 이름이나 headline template 정책에 맞춰 더 풍부하게 구성할지 결정해야 한다.
  • app.public-base-url, app.default-share-image-url 설정값이 운영/스테이징 환경별로 제대로 주입되는지 확인해야 한다.
  • 공유 카드 imageUrl 갱신 정책과 cache TTL이 충돌하지 않는지 실제 공유 플랫폼 캐시 특성을 기준으로 검증해야 한다.

3. P1: 도메인 연동 정합성

3.1 돌봄 액션 소모품 차감

상태: 수정 필요

현재 POST /api/character/v1/characters/{characterId}/care-logs에서 itemId를 보내도 item 도메인 수량 차감이 일어나지 않는다.

필요 작업:

  • item 도메인에 소모품 사용/차감 RPC 추가
  • userId, itemId, quantity, actionType, idempotencyKey 전달
  • 보유 수량 부족 시 ITEM_QUANTITY_NOT_ENOUGH 반환
  • character 상태 회복과 item 수량 차감의 실패 처리 정책 결정

주의:

  • 분산 트랜잭션이 아니므로, item 차감 성공 후 character 저장 실패 같은 보상/재시도 정책을 정해야 한다.
  • MVP에서는 character 도메인 처리 전에 item 차감을 먼저 시도하고, 실패하면 돌봄 액션을 진행하지 않는 방향이 단순하다.

3.2 스킨 장착 소유 검증

상태: 수정 필요

현재 PUT /api/character/v1/characters/{characterId}/equipped-skin은 사용자가 해당 스킨을 보유했는지 검증하지 않는다.

필요 작업:

  • item 도메인에 user item 보유 검증 RPC 추가
  • itemType=SKIN 확인
  • 보유하지 않은 경우 ITEM_NOT_OWNED 반환
  • 장착 성공 시 item 도메인의 equipped 상태와 character 도메인의 equippedSkinId 동기화 정책 결정

3.3 스킨 이름 mock 제거

상태: 수정 필요

현재 gateway character 응답에서 스킨 이름을 item 도메인에서 조회하지 않고 placeholder로 만든다.

예시:

스킨 {id}
Skin {id}

필요 작업:

  • item 도메인에서 item 상세 조회 또는 user item 상세 조회 RPC 제공
  • characters/me, equipped-skin 응답에서 실제 스킨 이름 반환

3.4 공유 카드 imageUrl 갱신 처리

상태: 수정됨

현재 ShareService#createShareCard는 같은 (userId, characterId) 카드가 있으면 기존 카드를 재사용한다. 코드상으로는 새 uploadedImageUrl을 반환값에 반영하지만, DB의 share_cards.image_url은 갱신하지 않는다.

필요 작업:

  • ShareCard#updateImageUrl(String imageUrl) 메서드 추가
  • 기존 공유 카드를 재생성할 때 DB imageUrl 갱신
  • 갱신 시점에 기존 공유 URL을 유지할지 새 shareId를 발급할지 정책 결정

권장:

  • 같은 캐릭터의 공유 카드는 shareId를 유지하되, 새 이미지 업로드 시 imageUrl만 갱신한다.

4. P2: 품질 및 안정성 개선

4.1 캐릭터 생성 이름 validation

상태: 수정됨

캐릭터 이름 수정은 1~10자 검증이 있지만, 생성 시에는 명시적 도메인 검증이 약하다.

필요 작업:

  • 생성 시에도 null, blank, 10자 초과를 CHARACTER_NAME_INVALID로 반환
  • DB 길이 오류가 API 밖으로 새지 않도록 한다.

4.2 돌봄 액션 메시지 한국어/Polaris 톤 정리

상태: 수정됨

현재 돌봄 액션 메시지는 영어 고정 문구다.

예시:

Mmm... light has a taste too.
...zz. Thanks.
That was fun. Let's do it again sometime.

필요 작업:

  • Polaris 톤에 맞는 한국어 문구로 변경
  • 캐릭터 타입별 말투 분기는 추후 확장으로 둔다.

4.3 care log idempotency migration 안전화

상태: 수정됨

현재 migration은 기존 character_care_logs에 바로 NOT NULL UNIQUE 컬럼을 추가한다.

ALTER TABLE character_care_logs
ADD COLUMN idempotency_key VARCHAR(100) NOT NULL UNIQUE;

기존 데이터가 있는 DB에서는 실패할 수 있다.

권장 순서:

  1. nullable 컬럼 추가
  2. 기존 row backfill
  3. unique index 추가
  4. not null 제약 추가

4.4 R2 presigned URL extension 검증

상태: 수정됨

현재 extension 값을 파일명과 content type에 그대로 사용한다.

필요 작업:

  • allowlist 적용
png, jpg, jpeg, webp
  • 허용되지 않은 값은 기본 png로 대체하거나 validation error 반환

5. Headline Template 확장안

상태: 수정 필요

5.1 배경

현재 공유 링크 응답의 headline은 서버 고정 문구다. 사용자 입력 headline을 받지 않기 때문에 프론트 구현은 단순하지만, 모든 공유 카드가 같은 문구를 쓰면 반복감이 생긴다.

Polaris 톤을 일관되게 유지하려면 프론트에서 임의 문구를 만들기보다 서버가 headline 후보 풀을 관리하는 편이 좋다.

5.2 권장 모델

새 테이블을 추가한다.

share_headline_templates
- id
- text
- character_type_code nullable
- tag nullable
- weight int
- active boolean
- created_at
- updated_at

예시 문구:

오늘도 조금 반짝였어요.
작은 일을 하나 해냈어요.
내 별친구가 오늘을 기록했어요.
느리지만 분명히 움직였어요.

5.3 선택 시점

권장 방식은 공유 카드 생성 시 headline을 하나 선택하고 share_cards.headline에 저장하는 것이다.

POST /api/share/v1/share-cards
-> imageUrl 저장
-> active headline template 중 하나 선택
-> share_cards.headline 저장
-> share link 조회/OG HTML에서 동일 headline 사용

이 방식의 장점:

  • 같은 shareId를 열 때마다 문구가 바뀌지 않는다.
  • OG 미리보기와 앱 내부 공유 링크 화면이 같은 문구를 보여준다.
  • 추후 캐릭터 타입별 말투 분기가 쉽다.

5.4 선택 정책

우선순위:

  1. active=true
  2. 요청 캐릭터의 character_type_code와 일치하는 후보
  3. 후보가 없으면 공통 후보(character_type_code is null) fallback
  4. weight 기반 랜덤 선택 또는 단순 랜덤 선택

5.5 API 영향

단기:

  • POST /api/share/v1/share-cards request는 계속 { characterId, imageUrl } 유지
  • 서버가 headline을 선택해 저장
  • response에 headline을 추가할지 여부는 선택

권장 response:

{
  "shareCardId": 800,
  "shareId": "sh_abc123",
  "imageUrl": "https://cdn.polaris.app/share-cards/800.png",
  "headline": "오늘도 조금 반짝였어요.",
  "shareUrl": "https://polaris.app/share/sh_abc123"
}

공유 링크 조회:

{
  "shareId": "sh_abc123",
  "characterName": "노바별",
  "imageUrl": "https://cdn.polaris.app/share-cards/800.png",
  "headline": "오늘도 조금 반짝였어요.",
  "signupUrl": "https://polaris.app/signup?shareId=sh_abc123"
}

0개의 댓글