Sponsors

최신 채용 공고를 확인하려면 LinkedIn에서 팔로우하고, 매일 알림을 받으려면 Discord 커뮤니티에 참여하세요.

추천 시스템 악용(부정행위)에 대한 기술적 포스트모템: 7.7만 사용자 규모의 시험 준비 앱에서 탐지부터 디바이스 단위 차단 지속까지

148명의 사용자가 추천 시스템을 악용해 750일의 무료 프리미엄을 얻은 방법 — 그리고 이를 어떻게 막았는지

This article is also available in English.Read in English →

우리는 Spiko라는 AI 기반 영어 시험 대비 앱을 운영하고 있습니다. 약 7.7만 명의 사용자를 보유하고 있으며, 대부분은 우즈베키스탄의 학생들입니다.

2026년 초, 자연 유입 성장을 위해 추천 시스템을 도입했습니다.
결과는 매우 좋았습니다. 397건의 추천이 발생했고, 추천 유입 사용자의 리텐션은 일반 유입 대비 2.4배 높았으며, 그중 91%는 실제 사용자였습니다.

Spiko

하지만 동시에, 148명의 악용 사용자에게 총 750일의 프리미엄이 유출되었습니다.

이 글에서는 그들이 어떤 방식으로 시스템을 악용했는지, 우리가 무엇을 놓쳤는지, 그리고 이후 어떤 다층 방어 구조를 구축했는지를 모두 공유합니다.

추천 시스템

구조는 단순합니다. 자신의 코드를 공유하고, 친구가 그 코드로 가입하면 양쪽 모두 보상을 받는 방식입니다. 보상 구조는 다음과 같았습니다:

TierRewardFarming incentive
13 streak freezes없음 (조작할 가치 없음)
27일 Silver 프리미엄매우 높은 보상
37일 모듈 잠금 해제더 높은 보상
4추천 챔피언 배지장식용

이후 구조를 단순화하여 추천 1건당 양쪽 모두 1일 프리미엄 제공으로 변경했습니다. 횟수 제한은 없었습니다.

탐지 (그리고 우리가 놓친 점)

초기에는 users 테이블의 referral_banned 컬럼을 통해 114명의 사용자를 차단했습니다.

하지만 이 방식은 추천 코드 사용만 막을 뿐, 이미 획득한 프리미엄은 그대로 사용할 수 있었고, 앱 이용에도 제한이 없었습니다.

더 큰 문제는, 차단된 사용자가 계정을 삭제한 뒤 다른 Gmail로 다시 가입하면 차단 상태가 초기화된다는 점이었습니다 (referral_banned = 0).

추천을 조작하기 위해 만들어진 가짜 계정들은 전혀 영향을 받지 않았습니다.

우리는 ‘행위’를 막았을 뿐, ‘사용자’를 막지 못했습니다.

해결 방법: 다층 방어 전략

우리는 한 번의 업데이트로 모든 대응을 적용했습니다. 인증 차단, 삭제 후에도 유지되는 차단, 다계정 탐지, 프리미엄 회수, 그리고 약관 업데이트까지 포함됩니다.

Layer 1: 인증 단계 차단

모든 인증 진입 지점에 단 하나의 체크를 추가했습니다:

export class AccountSuspendedError extends Error {
  constructor() {
    super("Account suspended for violating Terms of Service.");
    this.name = "AccountSuspendedError";
  }
}
// In AuthService
private assertNotBanned(user: User): void {
  if (user.referral_banned === 1) {
    throw new AccountSuspendedError();
  }
}

이 체크는 buildAuthResponse() 이전에 실행됩니다:

  • googleSignIn() — Google 로그인 차단
  • telegramLogin() — Telegram 봇 로그인 차단
  • telegramWidgetLogin() — 웹 위젯 로그인 차단
  • refresh() — 토큰 갱신 시 차단 (최대 1시간 내 강제 로그아웃)

서버는 errorCode: "ACCOUNT_SUSPENDED"와 함께 403을 반환합니다:

function authErrorResponse(c: AppContext, e: unknown): any {
  if (e instanceof AccountSuspendedError) {
    return c.json(
      { message: e.message, errorCode: "ACCOUNT_SUSPENDED" },
      403
    );
  }
  return c.json({ message: getErrorMessage(e) }, 401);
}

403이 중요한 이유는 Android AuthInterceptor가 401을 자동으로 재시도하기 때문입니다.
403은 재시도를 막고 즉시 로그아웃을 유도합니다.

// AuthInterceptor.kt
if (response.code == 403 && !isPublicRoute) {
    Log.w("AuthInterceptor", "Account suspended (403). Logging out.")
    runBlocking { sessionManager.logout() }
    return response
}

401을 사용할 경우 토큰 만료로 오인되어 무한 재시도가 발생할 수 있습니다. 403은 클라이언트에게 “인증 문제가 아니라 접근이 차단된 상태”임을 명확히 전달합니다.

Layer 2: 계정 삭제 이후에도 유지되는 차단

사용자가 계정을 삭제할 때, 해당 사용자의 식별 정보를 deleted_accounts 테이블에 저장합니다:

CREATE TABLE deleted_accounts (
    id TEXT PRIMARY KEY,
    googleId TEXT,
    email TEXT,
    telegramId TEXT,
    fcmTokens TEXT,  -- JSON array of device tokens
    referral_banned INTEGER NOT NULL DEFAULT 0,
    deletedAt TEXT NOT NULL DEFAULT (datetime('now'))
);

재가입 시 동일한 Google ID, 이메일, Telegram ID, 또는 FCM 토큰이 발견되면 MAX()를 사용해 차단 상태를 복구합니다:

UPDATE users SET
  referral_banned = MAX(referral_banned, ?)
WHERE id = ?;

MAX()는 차단 상태가 절대 해제되지 않도록 보장합니다.
이전 계정이 차단 상태였다면, 새 계정도 동일하게 차단됩니다.

Layer 3: 추천 그래프 기반 다계정 탐지

초기 차단된 114명은 대부분 가짜 계정이었습니다.
하지만 실제로 프리미엄을 획득하던 본계정들은 그대로 남아 있었습니다.

그래서 우리는 추천 관계 그래프를 추적했습니다:

SELECT DISTINCT CASE
    WHEN u_referrer.referral_banned = 1 THEN r.referredUserId
    WHEN u_referred.referral_banned = 1 THEN r.referrerId
    END as alt_id
FROM referrals r
JOIN users u_referrer ON r.referrerId = u_referrer.id
JOIN users u_referred ON r.referredUserId = u_referred.id
WHERE (u_referrer.referral_banned = 1 AND u_referred.referral_banned = 0)
   OR (u_referred.referral_banned = 1 AND u_referrer.referral_banned = 0);
  • 1차: 30개 계정
  • 2차: 4개 계정
  • 3차: 추가 없음

최종적으로 148명을 차단했습니다.

Layer 4: 디바이스 기반 차단 (FCM)

FCM 토큰은 디바이스 단위로 고유합니다.
새로운 계정이 동일한 디바이스에서 생성되면 자동으로 차단됩니다.

// In updateFcmToken()
const bannedDevice = await this.repo.findBannedUserByFcmToken(token, userId);
if (bannedDevice) {
  console.warn(
    `[Auth] Device shared with banned user: userId=${userId}, bannedUserId=${bannedDevice.id}`
  );
  await this.repo.banUser(userId);
}
SELECT u.id FROM devices d
JOIN users u ON d.userId = u.id
WHERE d.fcmToken = ? AND u.referral_banned = 1 AND u.id != ?
LIMIT 1;

Layer 5: 프리미엄 회수

추천 보상으로 획득된 프리미엄을 모두 제거했습니다:

UPDATE users SET
  active_modules = '[]',
  module_speaking_expiresAt = NULL,
  module_writing_expiresAt = NULL,
  module_reading_expiresAt = NULL,
  module_listening_expiresAt = NULL,
  subscription_tier = 'free',
  subscription_expiresAt = NULL
WHERE referral_banned = 1
  AND active_modules IS NOT NULL AND active_modules != '[]';

Layer 6: 약관 및 정책 업데이트

  • 디바이스 식별 정보 사용 명시
  • 계정 삭제 이후에도 차단 유지
  • 다계정 생성 및 추천 악용 금지 명시

데이터 분석: 추천 시스템은 가치가 있었을까?

실제 vs 부정

TierCount%
Genuine36391%
Fraud349%

추천의 91%는 실제 사용자였습니다.

리텐션

Cohort7-day30-day
All users8.8%28.5%
Referred20.7%37.8%

추천 유입은 2.4배 더 높은 리텐션을 보였습니다.

프리미엄 유출

총 749일 중 693일(93%)이 부정 사용자에게 돌아갔습니다.

핵심 원인

문제는 보상의 크기가 아니라 계단형 구조였습니다.
Tier 2에서 보상 대비 비용이 급격히 낮아지며 악용이 발생했습니다.

우리가 배운 것

  1. 행위가 아니라 사용자를 차단해야 한다
  2. 계정 삭제는 탈출 수단이 된다
  3. 그래프를 따라가라
  4. 계단형 보상은 악용을 만든다
  5. 403 vs 401은 중요하다
  6. 데이터를 먼저 확인하라
  7. 작은 악용 때문에 전체 시스템을 없애지 마라

Connect with Azizbek

Built by MilliyTechnology. 우리는 중앙아시아 학생들을 위한 AI 기반 시험 준비 도구를 만들고 있습니다. 비슷한 문제를 다루고 있다면 언제든지 연락 주세요.

다음 인터뷰 대상자가 되어 기여하고 싶다면 florian@dev-korea.com으로 이메일을 보내 주세요.


다음 행보를 탐색할 준비가 되셨나요? 최신 채용 공고는 Dev Korea에서 확인하세요: 채용공고 보기. 채용 담당자라면 공고 올리기를 통해 한국의 혁신 생태계에 기여하고자 하는 활기찬 테크 인재들과 연결해 보세요.