크레딧은 먼저 받고, 실패하면 이유에 따라 돌려주기
DropThePitch는 파일을 올릴 때 크레딧을 차감하고, 분석이 실패하거나 거절되면 돌려줘요. 영상 길이로 요금을 정하는 방법부터 실패 이유마다 환불 금액을 다르게 한 기준까지 정리했어요.
DropThePitch는 AI를 부를 때마다 비용이 들어서 크레딧을 받아요. 크레딧을 다루는 코드는 돈을 다루는 코드와 같아요. 한 번이라도 잘못 빠지면 사용자는 바로 알아채고, 신뢰를 잃어요. 그래서 언제 받고, 언제 돌려주고, 얼마나 돌려줄지를 하나씩 정했어요.
요금은 자료에 따라 달라요
문서와 이미지는 3크레딧이에요. 영상은 길수록 AI가 읽어야 할 양이 많아져서 길이에 따라 다르게 받아요.
| 자료 | 크레딧 |
|---|---|
| 문서, 이미지 | 3 |
| 영상 3분 이하 | 6 |
| 영상 5분 이하 | 9 |
| 영상 10분 이하 | 17 |
| 영상 30분 이하 | 46 |
이 표를 그대로 코드로 옮겼어요.
public enum FileAnalysisPrice {
DOCUMENT_IMAGE(0, 3),
VIDEO_UNDER_3MIN(180, 6),
VIDEO_UNDER_5MIN(300, 9),
VIDEO_UNDER_10MIN(600, 17),
VIDEO_UNDER_30MIN(1800, 46);
public static int of(Integer videoSeconds) {
if (videoSeconds == null) {
return DOCUMENT_IMAGE.credit;
}
return Arrays.stream(values())
.filter(price -> price != DOCUMENT_IMAGE && videoSeconds <= price.maxSeconds)
.findFirst()
.orElse(VIDEO_UNDER_30MIN)
.credit;
}
}
요금표를 한 곳에 모아 두니, 가격을 바꿀 때 이 파일만 고치면 돼요.
영상 길이는 올리는 순간 읽어요
영상 요금을 정하려면 길이를 알아야 해요. 그래서 업로드 요청을 받자마자 FFmpeg로 영상의 길이를 읽어요. 파일을 저장하기 전, 요청을 처리하는 그 자리에서 읽어요.
길이를 읽지 못하면 받지 않아요. 길이를 모르면 요금을 정할 수 없고, 짐작으로 받으면 누군가는 손해를 봐요. 30분을 넘는 영상도 여기서 돌려보내요.
if (seconds == null) {
throw new VideoNotSupportedException("영상 길이를 확인할 수 없습니다. 다른 파일로 올려주세요.");
}
if (seconds > FileAnalysisPrice.MAX_VIDEO_SECONDS) {
throw new VideoNotSupportedException("영상은 30분을 넘을 수 없습니다.");
}
파일을 저장하기 전에 잔액부터 봐요
영상은 100MB까지 올릴 수 있어요. 파일을 다 저장한 뒤에 "크레딧이 부족해요"라고 하면, 저장 공간과 사용자의 시간을 모두 낭비해요. 그래서 요금을 정하자마자 잔액을 먼저 확인하고, 모자라면 저장하기 전에 돌려보내요.
Integer videoSeconds = type == InputType.MP4 ? readVideoSeconds(file) : null;
int price = FileAnalysisPrice.of(videoSeconds);
validateEnoughCredit(email, price);
String key = s3Service.upload(file);
이 확인은 잠금 없이 잔액을 읽기만 해요. 실제로 크레딧을 빼는 것은 프로젝트를 만든 뒤 한 번 더 해요.
실제 차감은 잠그고 해요
잔액을 미리 확인했어도 그사이 다른 요청이 크레딧을 썼을 수 있어요. 같은 사람이 탭 두 개에서 동시에 파일을 올리거나, 파일을 올리면서 의견 수집을 누르는 경우예요.
그래서 실제로 뺄 때는 크레딧 행을 잠그고 다시 읽어요.
private void useCredit(String email, Project project, int price) {
Credit credit = creditRepository.findWithLockByUserEmail(email)
.orElseThrow(InsufficientCreditException::new);
FileAnalysisCredit fileAnalysisCredit = FileAnalysisCredit.builder().build();
fileAnalysisCredit.use(credit, project, price);
fileAnalysisCreditRepository.save(fileAnalysisCredit);
}
잠근 행은 이 트랜잭션이 끝날 때까지 다른 요청이 고칠 수 없어요. 두 요청이 동시에 와도 하나씩 차례로 빼게 되고, 두 번째 요청은 줄어든 잔액을 보고 판단해요.
크레딧을 뺄 때는 잔액만 줄이지 않고 어느 프로젝트에 얼마를 썼는지 기록을 남겨요. 이 기록이 있어야 나중에 환불할 금액을 알 수 있고, 사용자에게 내역을 보여줄 수 있어요.
실패하면 돌려줘요, 단 이유에 따라 다르게
분석이 실패하거나 거절되면 크레딧을 돌려줘요. 처음에는 실패하면 전부 돌려주려고 했어요. 그런데 그러면 문제가 생겨요.
분석 거절은 AI가 자료를 실제로 읽어 본 뒤에 내리는 판단이에요. 이미 AI 비용이 들었어요. 그리고 분석을 조작하려는 문장을 넣거나, 일부러 성인 자료를 올리는 경우는 몇 번이고 다시 올려도 손해가 없으면 계속 시도할 수 있어요.
그래서 실패 이유마다 "남겨 둘 크레딧"을 정했어요.
| 판정 | 남기는 크레딧 | 이유 |
|---|---|---|
| 내용 없음 | 0 | 빈 파일은 실수로 올린 경우가 많아요 |
| 열 수 없는 파일 | 0 | 암호가 걸렸거나 손상된 파일이에요 |
| 요청 한도 초과 | 0 | AI 쪽 사정이에요 |
| 분석 오류 | 0 | 저희 쪽 사정이에요 |
| 분석 대상 아님 | 1 | AI가 읽고 판단했어요 |
| 불법, 성인 | 1 | AI가 읽고 판단했어요 |
| 분석 조작 시도 | 1 | AI가 읽고 판단했어요 |
| 개인정보 포함 | 1 | AI가 읽고 판단했어요 |
사용자 잘못이 아니거나 실수에 가까운 경우는 전부 돌려주고, AI가 내용을 보고 거절한 경우는 1크레딧만 남기고 돌려줘요. 벌을 주려는 것이 아니라, AI가 자료를 읽느라 이미 쓴 비용의 일부를 남기는 것이에요.
판정과 남길 크레딧을 같은 곳에 적어 두었어요.
NO_CONTENT("내용 없음", "평가할 내용이 없는 파일입니다.", true, 0),
NOT_TARGET("분석 대상 아님", "출시 전 자료가 아니어서 분석할 수 없습니다.", true, 1),
MANIPULATION("분석 조작 시도", "분석 결과를 조작하려는 문장이 있어 분석할 수 없습니다. 해당 문장을 지우고 새 작업으로 올려주세요.", true, 1),
AI_BUSY("요청 한도 초과", "요청이 많아 분석하지 못했습니다. 잠시 후 새 작업으로 다시 시도해주세요.", false, 0),
환불은 쓴 기록을 보고 계산해요
환불할 금액은 "이 프로젝트에 실제로 쓴 크레딧"에서 "남길 크레딧"을 뺀 값이에요.
public void refundFileAnalysis(Long projectId, int keepCredit) {
fileAnalysisCreditRepository.findByProjectId(projectId).ifPresent(used -> {
int refund = used.getUseCredit() - keepCredit;
if (refund <= 0) {
return;
}
Credit credit = creditRepository.findWithLockById(used.getCredit().getId())
.orElseThrow(InsufficientCreditException::new);
Payment payment = Payment.builder().build();
payment.addAmount(credit, refund, PaymentReason.REFUND);
paymentRepository.save(payment);
});
}
요금표를 다시 계산하지 않고 그때 쓴 기록을 읽는 것이 중요해요. 그사이 가격이 바뀌었어도 사용자가 낸 만큼을 기준으로 돌려줘요.
환불도 잔액 행을 잠그고, 잔액만 늘리지 않고 환불 기록을 남겨요. 사용자의 크레딧 내역에는 "크레딧 환불"로 나와서 언제 무엇 때문에 돌려받았는지 볼 수 있어요.
정리
- 요금표는 한 곳에 모아 두고, 영상은 올리는 순간 길이를 읽어 요금을 정해요.
- 큰 파일을 저장하기 전에 잔액을 먼저 확인해요.
- 실제 차감과 환불은 잔액 행을 잠그고, 어디에 얼마를 썼는지 기록을 남겨요.
- 실패 이유마다 남길 크레딧을 정하면, 사용자 잘못이 아닌 실패는 전부 돌려주고 반복 시도는 막을 수 있어요.
- 환불 금액은 요금표가 아니라 실제로 쓴 기록에서 계산해요.