모바일 앱 API 보안: 디컴파일을 전제로 한 서버 설계

모바일 앱 API 보안을 '앱은 결국 분석된다'는 전제에서 정리했습니다. 프록시로 드러나는 정보, 피닝·난독화의 한계, 가격·등급 서버 검증, 토큰 저장 위치, 강제 업데이트 설계와 점검표를 담았습니다.

보안 점검 의뢰 팀 13분 분량

"우리 API는 앱에서만 호출하니까 외부에서는 못 쓴다." 진단 현장에서 가장 자주 듣는 말인데, 결론부터 말하면 믿으면 안 됩니다. 사용자 기기에 설치된 앱은 공격자 손에 들어간 코드입니다. APK나 IPA는 내려받아 디컴파일할 수 있고, 테스트 기기에 프록시 인증서를 깔면 앱이 보내는 요청의 URL, 헤더, 바디를 그대로 볼 수 있습니다. 그렇게 본 요청은 curl이나 스크립트로 똑같이 다시 보낼 수 있습니다.

그러니 서버는 "이 요청이 우리 앱에서 왔는가"가 아니라 "이 요청을 보낸 사용자에게 이 작업을 할 권한이 있는가, 이 값은 서버가 아는 값과 맞는가"를 검증해야 합니다. 인증서 피닝, 난독화, 루팅·탈옥 탐지, 앱 위변조 탐지는 분석 비용을 올려 줄 뿐이고, 서버 측 인가 검증을 대신하지 못합니다. OWASP MASTG도 인증서 피닝을 우회하는 기법을 테스트 절차의 하나로 정리해 두고 있습니다. 앱 보호 기능은 '당연히 뚫릴 수 있는 것'으로 보고 설계하라는 뜻입니다.

이 글에서는 이 전제를 출발점으로 프록시로 무엇이 드러나는지, 서버가 무엇을 다시 검증해야 하는지, 그리고 토큰 저장, 강제 업데이트, 무결성 증명처럼 모바일에서만 나오는 설계 결정을 정리합니다.

프록시로 트래픽을 보면 무엇이 드러나는가

모바일 앱 점검의 첫 단계는 보통 테스트 기기 트래픽을 프록시로 관찰하는 일입니다. 웹은 브라우저 개발자 도구로 늘 보이니 다들 조심하지만, 앱은 "안 보인다"고 생각해서 오히려 더 많이 드러나는 경우가 흔합니다. 점검에서 자주 확인되는 항목은 다음과 같습니다.

  • 숨겨진 엔드포인트: 화면에는 없지만 앱 코드에 남아 있는 관리자용, 테스트용, 구버전 API 경로
  • 과도한 응답 필드: 화면에는 닉네임만 나오는데 응답 JSON에는 전화번호, 이메일, 내부 등급 코드, 다른 사용자 ID까지 들어 있는 경우
  • 클라이언트가 결정하는 값: price, discount, grade, isPremium, role 같은 값이 요청 바디에 실려 가는 구조
  • 순차적인 식별자: /orders/10231처럼 숫자만 바꾸면 다른 사람 리소스에 닿을 수 있는 ID
  • 앱 안에 박힌 비밀값: 서드파티 API 키, 내부 서명용 HMAC 키, 디버그 플래그

특히 응답 필드 과다 노출과 ID 기반 조회는 앱에서 유독 많이 발견됩니다. 앱 개발자는 "화면에서 안 쓰니 괜찮다"고 생각하지만 서버 입장에서는 이미 전송한 데이터입니다. 리소스 ID를 바꿔 다른 사용자 데이터에 접근하는 문제는 IDOR 취약점 글에서 주문·예약 API 예시로 자세히 다뤘습니다.

피닝·난독화·위변조 탐지가 해주는 것과 못 해주는 것

클라이언트 보호 기능을 쓰지 말라는 뜻은 아닙니다. 역할을 정확히 이해하고 쓰자는 것입니다.

기능실제로 해주는 것해주지 못하는 것
인증서 피닝공용 Wi-Fi 같은 환경에서 제3자의 중간자 공격 방어, 가벼운 분석 차단기기 소유자가 런타임 후킹으로 피닝을 끄는 것을 막지 못함
난독화정적 분석 시간 증가, 로직 파악 지연네트워크로 나가는 요청 자체는 숨기지 못함
루팅·탈옥·위변조 탐지위험 기기 식별, 리스크 신호 수집탐지 로직도 앱 안에 있으므로 우회 가능
무결성 증명(Play Integrity, App Attest)서버가 "정상 앱·정상 기기에서 온 요청일 가능성"을 판단할 근거정상 앱으로 정상 로그인한 사용자가 권한 밖 요청을 보내는 것은 막지 못함

마지막 줄이 핵심입니다. 무결성 검증을 완벽하게 통과한 정품 앱이라도, 그 안의 사용자가 남의 주문 번호를 넣어 보내면 서버는 막아야 합니다. 앱 보호 기능은 "누가 보냈는가"의 신뢰도를 조금 올리는 장치이지, "이 요청이 허용되는가"를 판단하는 장치가 아닙니다. OWASP MASVS가 인증·인가(MASVS-AUTH)와 리버스 엔지니어링 저항성(MASVS-RESILIENCE)을 별도 영역으로 나눈 것도 같은 맥락입니다.

클라이언트가 보낸 가격·등급 값은 신뢰하지 않는다

모바일 API에서 가장 실무적인 원칙입니다. 앱은 "사용자가 무엇을 원하는지"만 보내고, "그게 얼마인지, 받을 자격이 있는지"는 서버가 계산합니다.

취약한 예: 앱이 계산한 금액과 등급을 그대로 저장

// POST /api/orders  (가상의 Express 코드)
app.post('/api/orders', auth, async (req, res) => {
  const { productId, quantity, price, couponDiscount, memberGrade } = req.body;

  // 앱 화면에서 계산한 값을 그대로 신뢰
  const total = price * quantity - couponDiscount;
  const order = await Order.create({
    userId: req.user.id,
    productId,
    quantity,
    total,
    gradeDiscountApplied: memberGrade === 'VIP',
  });
  res.json(order);
});

프록시로 이 요청을 본 사람이라면 price를 1로, memberGrade를 VIP로 바꿔 보낼 수 있습니다. 앱 화면에서는 그런 입력이 불가능했다는 사실은 아무 의미가 없습니다.

고친 예: 서버가 아는 값으로 다시 계산

app.post('/api/orders', auth, async (req, res) => {
  const { productId, quantity, couponId } = req.body;

  if (!Number.isInteger(quantity) || quantity < 1 || quantity > 100) {
    return res.status(400).json({ error: 'invalid_quantity' });
  }

  const product = await Product.findActiveById(productId);
  if (!product) return res.status(404).json({ error: 'not_found' });

  // 등급은 요청이 아니라 DB의 사용자 정보에서
  const user = await User.findById(req.user.id);

  // 쿠폰은 소유자·만료·사용 여부를 서버에서 확인
  const coupon = couponId
    ? await Coupon.findUsable({ id: couponId, ownerId: user.id })
    : null;

  const total = pricing.calculate({ product, quantity, user, coupon });

  const order = await Order.create({
    userId: user.id, productId, quantity, total, couponId: coupon?.id ?? null,
  });
  res.json({ orderId: order.id, total });
});

원칙을 정리하면 이렇습니다.

  • 요청에서 받는 것은 식별자와 수량 같은 의도뿐입니다. 가격, 할인액, 등급, 권한은 받지 않습니다.
  • 앱이 보낸 금액은 받더라도 비교 용도로만 씁니다. 화면에 표시된 금액과 서버 계산값이 다르면 결제를 진행하지 않고 다시 확인하게 합니다.
  • 사용자 정보 수정 API에서 role, grade 같은 필드가 함께 저장되지 않도록 허용 필드를 명시합니다.
  • 결제는 PG 승인 금액을 서버 주문 금액과 대조합니다. 단계별 설계는 결제 금액 변조 취약점 글을 참고하세요.

인앱 결제도 마찬가지입니다. "결제 성공" 콜백을 앱이 서버에 알려 주는 구조라면, 서버는 반드시 스토어 측 영수증·구매 정보를 서버 간 통신으로 다시 확인한 뒤 아이템을 지급해야 합니다.

무결성 증명과 강제 업데이트는 '신호'와 '운영 도구'로 쓴다

무결성 증명: 판단 근거 하나를 추가하는 것

Android의 Play Integrity API와 iOS의 App Attest는 서버가 요청의 출처를 판단할 근거를 줍니다. 쓸 때 지킬 점은 다음과 같습니다.

  • 판정은 서버에서 합니다. Play Integrity 표준 요청은 앱이 받은 토큰을 백엔드로 보내고, 백엔드가 Google 서버를 통해 복호화·검증한 결과를 받는 구조입니다. 앱이 "검증 통과"라는 불리언을 보내는 방식은 의미가 없습니다.
  • 요청과 묶습니다. Play Integrity는 요청 파라미터의 해시를 requestHash로 넣고 서버에서 다시 계산해 비교하도록 권장합니다. App Attest도 서버가 발급한 일회성 챌린지를 포함해 재전송을 어렵게 합니다.
  • 차단보다 단계적 대응을 씁니다. 판정 실패를 곧바로 전면 차단하면 정상 사용자 오탐이 생깁니다. 로그인, 결제, 포인트 전환 같은 민감 작업에서 추가 인증을 요구하거나 한도를 낮추는 식으로 리스크 점수에 반영하는 편이 운영에 유리합니다.

강제 업데이트: 보안 경계가 아니라 구버전 정리 수단

앱 버전 강제 업데이트는 모바일에만 있는 고민입니다. 웹은 배포하면 바로 반영되지만, 앱은 취약한 구버전이 몇 달씩 남습니다. 흔한 구현은 앱이 X-App-Version 헤더를 보내고 서버가 최소 버전보다 낮으면 업데이트를 요구하는 방식입니다.

const MIN_VERSION = { ios: '4.2.0', android: '4.2.0' };

function requireMinVersion(req, res, next) {
  const platform = req.get('X-App-Platform');
  const version = req.get('X-App-Version');
  if (!platform || !version || semver.lt(version, MIN_VERSION[platform] ?? '0.0.0')) {
    return res.status(426).json({ error: 'update_required', storeUrl: 'https://example.com/app' });
  }
  next();
}

여기서 착각하기 쉬운 점이 있습니다. 버전 헤더는 클라이언트가 보내는 값이라 얼마든지 바꿀 수 있습니다. 따라서 강제 업데이트는 정상 사용자를 새 버전으로 옮기는 운영 도구일 뿐, 공격자를 막는 수단이 아닙니다. 구버전 앱이 쓰던 취약한 엔드포인트가 있다면 헤더 검사로 가리지 말고 서버에서 그 API 자체를 고치거나 제거해야 합니다. 실무에서는 다음처럼 운영합니다.

  • 최소 지원 버전을 앱 출시와 별도로 서버 설정에서 바꿀 수 있게 둡니다.
  • API를 /v1, /v2로 나눴다면 구버전 경로의 폐기 일정을 정하고 실제로 내립니다.
  • 보안 수정이 서버 쪽 변경으로 해결되는지, 앱 업데이트가 필요한지 구분해 기록합니다.

토큰 저장 위치와 앱 안에 두면 안 되는 비밀

토큰은 플랫폼 보안 저장소에, 수명은 짧게

액세스 토큰과 리프레시 토큰을 일반 SharedPreferences, UserDefaults, 평문 파일, 로컬 DB에 두는 경우를 자주 봅니다. 루팅·탈옥 기기나 백업 파일에서 그대로 읽힐 수 있습니다.

  • iOS: Keychain에 저장하고, 기기 잠금 상태와 연동되는 접근 속성을 고릅니다.
  • Android: Android Keystore로 관리하는 키로 암호화해 저장합니다. 한동안 많이 쓰던 Jetpack EncryptedSharedPreferences(security-crypto)는 공식 레퍼런스에서 지원 중단(deprecated)으로 표시되어 있으니, 새로 만든다면 Keystore 기반 키와 별도 암호화 라이브러리 조합을 검토하세요. 민감 파일이 클라우드 백업에 포함되지 않도록 백업 설정도 함께 확인합니다.

다만 저장소를 바꾸는 것만으로 끝나지 않습니다. 기기를 장악당하면 저장소도 결국 읽힐 수 있다는 전제에서 서버 측 설계가 함께 가야 합니다.

  • 액세스 토큰 수명은 짧게, 리프레시 토큰은 회전(사용 시 재발급)하고 재사용이 감지되면 해당 세션 전체를 무효화합니다.
  • 서버에 세션 목록을 두고 로그아웃, 비밀번호 변경, 기기 분실 신고 시 즉시 폐기할 수 있게 합니다.
  • 결제, 비밀번호 변경 같은 민감 작업은 토큰만으로 허용하지 않고 재인증을 요구합니다.

앱 바이너리는 공개 문서라고 생각한다

앱에 들어간 문자열은 디컴파일하면 보입니다. 난독화나 문자열 암호화를 해도 앱이 실행 중에 복호화해야 하므로 결국 메모리에 나타납니다. 그래서 다음은 앱에 넣지 않습니다.

  • 서버 간 통신용 비밀 키, 관리자 API 키, 클라우드 액세스 키
  • "이 서명이 맞으면 우리 앱"이라고 판단하는 HMAC 공유 비밀 (키가 앱에 있으면 공격자도 서명할 수 있습니다)
  • 외부 유료 API 키 (서버에서 프록시하고 사용자별 호출 한도를 둡니다)

지도 SDK 키처럼 앱에 넣을 수밖에 없는 키는 콘솔에서 패키지명·번들 ID 제한과 사용량 한도를 걸어 둡니다. 저장소, 앱, 로그별 점검 방법은 API 키 유출 방지 체크리스트에 정리해 두었습니다.

출시 전 모바일 API 점검표

앱 출시나 대규모 업데이트 전에 아래 항목을 확인해 보세요. 테스트 기기와 프록시만 있으면 개발팀이 직접 확인할 수 있는 것들입니다.

  1. 앱에서 쓰는 모든 API가 서버에서 인증과 리소스 단위 인가를 확인한다(내 주문만, 내 쿠폰만).
  2. 요청 바디에 가격, 할인액, 등급, 권한, 포인트 잔액 같은 값이 없다. 있다면 서버가 무시하거나 비교만 한다.
  3. 응답 JSON에 화면에서 쓰지 않는 개인정보·내부 필드가 없다.
  4. 앱 코드에 남은 테스트·관리자·구버전 엔드포인트를 서버에서 제거했거나 접근을 막았다.
  5. 앱 바이너리에서 문자열을 뽑아 봤을 때 서버용 비밀 키가 나오지 않는다.
  6. 토큰은 Keychain·Keystore 기반으로 저장하고, 리프레시 토큰 회전과 서버 측 폐기가 동작한다.
  7. 인앱 결제·포인트 지급은 스토어 또는 PG 정보를 서버 간 통신으로 재확인한다.
  8. 무결성 증명을 쓴다면 판정은 서버에서 하고, 요청 해시나 챌린지로 재사용을 막는다.
  9. 최소 지원 버전을 서버 설정으로 바꿀 수 있고, 구버전 API 폐기 일정이 있다.
  10. 로그인, OTP, 쿠폰 등록 등 반복 시도가 가능한 API에 사용자·기기·IP 기준 호출 제한이 있다.

자주 묻는 질문

인증서 피닝을 적용하면 프록시로 트래픽을 볼 수 없지 않나요?

일반 사용자 환경의 중간자 공격은 막아 주지만, 기기 소유자가 런타임 후킹 도구로 피닝 검사를 끄는 것까지는 막지 못합니다. OWASP MASTG에도 우회 기법이 테스트 절차로 정리돼 있습니다. 피닝은 적용하되 트래픽이 결국 보인다는 전제로 서버를 설계하세요.

Play Integrity나 App Attest를 쓰면 API를 앱 전용으로 만들 수 있나요?

"정상 앱에서 온 요청일 가능성"을 높게 판단할 수는 있지만, 정상 앱을 쓰는 사용자가 권한 밖 요청을 보내는 것은 막지 못합니다. 무결성 판정은 리스크 신호로 쓰고, 인가 검증은 별도로 반드시 두어야 합니다.

웹과 앱이 같은 API를 쓰는데 앱만 따로 신경 써야 하나요?

서버 검증 원칙은 같습니다. 다만 앱은 구버전이 오래 남고, 토큰을 기기에 오래 보관하며, 바이너리가 배포된다는 점이 다릅니다. 강제 업데이트 정책, 토큰 저장소와 수명, 앱 내 비밀값 세 가지는 앱 기준으로 따로 점검하세요.

정리

  • 앱 전용 API도 외부에서 호출할 수 있습니다. 앱은 디컴파일되고 트래픽은 관찰된다는 전제에서 시작하세요.
  • 피닝·난독화·위변조 탐지·무결성 증명은 분석 비용을 올리는 장치이고, 인가 판단은 언제나 서버 몫입니다.
  • 가격·등급·권한은 서버가 계산하고, 토큰은 플랫폼 보안 저장소와 짧은 수명·서버 측 폐기로 관리하며, 강제 업데이트는 운영 도구로만 쓰세요.
  • 위 점검표를 직접 확인하기 어렵다면 무료 분석 신청으로 외부 시각의 점검을 받아 보는 것도 방법입니다.

참고 자료

함께 읽으면 좋은 글