Articles

크레딧은 먼저 받고, 실패하면 이유에 따라 돌려주기

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);
    });
}

요금표를 다시 계산하지 않고 그때 쓴 기록을 읽는 것이 중요해요. 그사이 가격이 바뀌었어도 사용자가 낸 만큼을 기준으로 돌려줘요.

환불도 잔액 행을 잠그고, 잔액만 늘리지 않고 환불 기록을 남겨요. 사용자의 크레딧 내역에는 "크레딧 환불"로 나와서 언제 무엇 때문에 돌려받았는지 볼 수 있어요.

정리

  • 요금표는 한 곳에 모아 두고, 영상은 올리는 순간 길이를 읽어 요금을 정해요.
  • 큰 파일을 저장하기 전에 잔액을 먼저 확인해요.
  • 실제 차감과 환불은 잔액 행을 잠그고, 어디에 얼마를 썼는지 기록을 남겨요.
  • 실패 이유마다 남길 크레딧을 정하면, 사용자 잘못이 아닌 실패는 전부 돌려주고 반복 시도는 막을 수 있어요.
  • 환불 금액은 요금표가 아니라 실제로 쓴 기록에서 계산해요.
다음 글 AI가 돌려준 값을 저장하기 전에 확인하는 것들