기준일: 2026-05-20
범위: 캐릭터 도메인, 공유 도메인, 관련 user/item 도메인 연동 지점
현재 공유 카드 생성은 프론트엔드가 이미지를 먼저 생성하고 업로드한 뒤, 공개 이미지 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"
}
공유 보상은 API 명세 기준 하루 1회, 10 별조각이다.
{
"rewardPaid": true,
"rewardStarPiece": 10
}
현재 백엔드 구현은 사용자 입력 headline을 받지 않고, 서버 고정 문구를 내려준다.
private static final String PLACEHOLDER_HEADLINE = "Today, I shone a little.";
단기적으로는 서버 기본 문구를 유지할 수 있다. 다만 공유 카드가 반복 노출되는 기능이므로, 추후에는 headline 후보 문구를 테이블로 관리하고 공유 카드 생성 시 하나를 선택해 저장하는 구조가 적합하다.
상태: 일부 수정완료
현재 ShareService#createShareEvent는 rewardPaid와 rewardStarPiece를 계산하고 share_logs에 저장하지만, 실제 wallet 적립을 호출하지 않는다.
완료된 부분:
user 도메인 WalletService#earnStarPiece 메서드는 존재한다.proto/src/main/proto/user/v1/wallet_service.proto에 EarnStarPiece RPC가 추가되어 있다.WalletGrpcController#earnStarPiece가 구현되어 있다.남은 문제:
character 도메인 ShareService에 wallet 연동 TODO가 남아 있다.ShareService#createShareEvent에서 아직 user wallet gRPC를 호출하지 않는다.ShareEventResult.walletStarPiece는 여전히 0을 반환한다.남은 작업:
EarnStarPiece 호출reason=SHARE_REWARD, refType=SHARE, refId=shareLogId 또는 shareCardId, idempotencyKey 전달wallet.starPiece에 최신 잔액 반영share_logs 저장과 보상 지급 상태를 어떻게 맞출지 정책 결정상태: 개선 필요
현재 공유 이벤트는 클라이언트가 body로 전달한 idempotencyKey를 그대로 사용한다.
문제:
idempotencyKey를 보내면 같은 날 중복 요청을 완전히 막기 어렵다.existsByUserIdAndShareDateAndRewardPaidTrue 확인 후 저장하는 방식만으로는 race condition 방어가 부족하다.권장 방향:
SHARE_REWARD:{userId}:{yyyy-MM-dd}
idempotencyKey는 공유 이벤트 요청 재시도 식별용으로만 쓸지, 아예 서버 생성 키로 통일할지 정책을 정한다.가능한 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;
남은 개선점:
idempotencyKey를 보상용 키와 이벤트 기록용 키 중 어떤 의미로 유지할지 정책을 더 명확히 해야 한다.idempotencyKey도 SHARE_REWARD:{userId}:{yyyy-MM-dd}와 일관되게 맞춰야 한다.상태: 수정됨
현재 createShareEvent(userId, shareCardId, ...)는 shareCardId로 카드를 조회하지만, 해당 카드가 요청 사용자 소유인지 확인하지 않는다.
필요 작업:
ShareCard card 조회 후 card.getUserId().equals(userId) 검증NOT_SHARE_CARD_OWNER 또는 적절한 권한 에러 반환이 검증이 없으면 다른 사용자의 shareCardId로 내 공유 보상을 받을 수 있다.
상태: 수정됨
API 명세에는 아래 endpoint가 있다.
GET /api/share/v1/share-events/today
하지만 현재 gateway ShareController에는 해당 endpoint가 없다.
필요 작업:
ShareService에 오늘 보상 수령 여부 조회 구현ShareController에 endpoint 추가응답 예시:
{
"rewardClaimed": true,
"lastSharedAt": "2026-05-19T09:22:24.400Z"
}
상태: 개선 필요
현재 구현된 공개 공유 링크 API는 JSON이다.
GET /api/share/v1/share-links/{shareId}
하지만 카카오톡, 디스코드, X 등 외부 서비스의 미리보기는 JS를 실행하지 않고 HTML <head>의 OG 태그를 읽는다.
필요 작업:
/share/{shareId} 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이다.권장 구현 형태:
@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.appshareId는 JSON 에러가 아니라 404 HTML 또는 기본 OG HTML로 응답할지 정책을 정한다.<noscript> fallback을 함께 둔다.twitter:card, twitter:title, twitter:description, twitter:image도 함께 추가한다.Cache-Control: public, max-age=300주의할 점:
ShareController는 @RestController와 /api/share 전용으로 유지한다./share/{shareId}는 공개 endpoint이므로 인증 인터셉터 대상에 포함하지 않는다. 현재 gateway AuthInterceptor는 /api/**에만 적용되므로 구조상 공개 접근이 가능하다.완료된 부분:
/share/{shareId} HTML 응답 컨트롤러가 추가되었다.ShareGatewayService#getShareLink(shareId)를 사용해 공유 링크 정보를 조회한다.https://... URL 검증, 기본 이미지 fallback, cache header가 포함되어 있다.남은 개선점:
shareId 또는 character/share 서비스 오류 발생 시 JSON 에러가 아닌 HTML 404/fallback 페이지로 응답하도록 예외 처리가 필요하다.og:title이 현재 고정 Polaris라면, 캐릭터 이름이나 headline template 정책에 맞춰 더 풍부하게 구성할지 결정해야 한다.app.public-base-url, app.default-share-image-url 설정값이 운영/스테이징 환경별로 제대로 주입되는지 확인해야 한다.상태: 수정 필요
현재 POST /api/character/v1/characters/{characterId}/care-logs에서 itemId를 보내도 item 도메인 수량 차감이 일어나지 않는다.
필요 작업:
ITEM_QUANTITY_NOT_ENOUGH 반환주의:
상태: 수정 필요
현재 PUT /api/character/v1/characters/{characterId}/equipped-skin은 사용자가 해당 스킨을 보유했는지 검증하지 않는다.
필요 작업:
itemType=SKIN 확인ITEM_NOT_OWNED 반환equipped 상태와 character 도메인의 equippedSkinId 동기화 정책 결정상태: 수정 필요
현재 gateway character 응답에서 스킨 이름을 item 도메인에서 조회하지 않고 placeholder로 만든다.
예시:
스킨 {id}
Skin {id}
필요 작업:
characters/me, equipped-skin 응답에서 실제 스킨 이름 반환상태: 수정됨
현재 ShareService#createShareCard는 같은 (userId, characterId) 카드가 있으면 기존 카드를 재사용한다. 코드상으로는 새 uploadedImageUrl을 반환값에 반영하지만, DB의 share_cards.image_url은 갱신하지 않는다.
필요 작업:
ShareCard#updateImageUrl(String imageUrl) 메서드 추가권장:
상태: 수정됨
캐릭터 이름 수정은 1~10자 검증이 있지만, 생성 시에는 명시적 도메인 검증이 약하다.
필요 작업:
null, blank, 10자 초과를 CHARACTER_NAME_INVALID로 반환상태: 수정됨
현재 돌봄 액션 메시지는 영어 고정 문구다.
예시:
Mmm... light has a taste too.
...zz. Thanks.
That was fun. Let's do it again sometime.
필요 작업:
상태: 수정됨
현재 migration은 기존 character_care_logs에 바로 NOT NULL UNIQUE 컬럼을 추가한다.
ALTER TABLE character_care_logs
ADD COLUMN idempotency_key VARCHAR(100) NOT NULL UNIQUE;
기존 데이터가 있는 DB에서는 실패할 수 있다.
권장 순서:
상태: 수정됨
현재 extension 값을 파일명과 content type에 그대로 사용한다.
필요 작업:
png, jpg, jpeg, webp
png로 대체하거나 validation error 반환상태: 수정 필요
현재 공유 링크 응답의 headline은 서버 고정 문구다. 사용자 입력 headline을 받지 않기 때문에 프론트 구현은 단순하지만, 모든 공유 카드가 같은 문구를 쓰면 반복감이 생긴다.
Polaris 톤을 일관되게 유지하려면 프론트에서 임의 문구를 만들기보다 서버가 headline 후보 풀을 관리하는 편이 좋다.
새 테이블을 추가한다.
share_headline_templates
- id
- text
- character_type_code nullable
- tag nullable
- weight int
- active boolean
- created_at
- updated_at
예시 문구:
오늘도 조금 반짝였어요.
작은 일을 하나 해냈어요.
내 별친구가 오늘을 기록했어요.
느리지만 분명히 움직였어요.
권장 방식은 공유 카드 생성 시 headline을 하나 선택하고 share_cards.headline에 저장하는 것이다.
POST /api/share/v1/share-cards
-> imageUrl 저장
-> active headline template 중 하나 선택
-> share_cards.headline 저장
-> share link 조회/OG HTML에서 동일 headline 사용
이 방식의 장점:
shareId를 열 때마다 문구가 바뀌지 않는다.우선순위:
active=truecharacter_type_code와 일치하는 후보character_type_code is null) fallbackweight 기반 랜덤 선택 또는 단순 랜덤 선택단기:
POST /api/share/v1/share-cards request는 계속 { characterId, imageUrl } 유지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"
}