게임 플랫폼과 같은 실시간 서비스에서는 단순히 API가 정상적으로 응답하는지만 확인해서는 안 된다. 여러 요청이 동시에 발생하거나 네트워크 오류로 요청이 반복되거나 인증 토큰이 탈취되는 상황에서도 데이터의 정합성과 시스템의 보안이 유지되는지 검증해야 한다.
대표적인 테스트 대상은 다음과 같다.
- 동시 요청에 의한 데이터 불일치
- 동일 요청의 반복 처리로 인한 중복 실행
- 중복 구매 및 보상 지급
- Refresh Token Rotation 과정의 토큰 관리
- 폐기된 토큰의 재사용 탐지
- 클라이언트가 전달한 위치 정보의 위변조
이러한 테스트는 정상적인 사용 흐름뿐 아니라 경쟁 상태, 실패 상황, 악의적인 요청까지 재현해야 한다. 특히 게임 경제 시스템과 인증 시스템은 작은 오류도 재화 손실, 부정 사용, 계정 탈취로 이어질 수 있으므로 데이터베이스 제약 조건과 서버의 검증 로직을 함께 확인해야 한다.
동시성 테스트
동시성 문제란?
동시성 문제는 여러 요청이 같은 데이터에 거의 동시에 접근하면서 발생하는 문제다. 각 요청을 개별적으로 처리할 때는 정상적으로 동작하지만, 실행 순서가 겹치면 잘못된 결과가 발생할 수 있다.
예를 들어 사용자의 포인트 잔액이 100이라고 가정하자. 두 요청이 동시에 80포인트를 사용하려고 하면 다음과 같은 상황이 발생할 수 있다.
- 요청 A가 잔액 100을 조회한다.
- 요청 B도 잔액 100을 조회한다.
- 요청 A가 사용 가능하다고 판단한다.
- 요청 B도 사용 가능하다고 판단한다.
- 두 요청이 각각 80포인트를 차감한다.
애플리케이션이 조회와 갱신을 별도로 수행하고 적절한 동시성 제어를 적용하지 않았다면, 실제 잔액보다 많은 포인트가 사용되거나 한 요청의 갱신이 다른 요청에 의해 덮어써질 수 있다.
테스트 방법
동일한 계정과 자원에 여러 요청을 동시에 발생시킨다.
검증 대상은 다음과 같다.
- 잔액이 음수가 되지 않는가?
- 재고 수량이 실제 보유량보다 많이 감소하지 않는가?
- 같은 아이템이 초과 판매되지 않는가?
- 랭킹이나 통계 데이터가 잘못 갱신되지 않는가?
- 모든 트랜잭션이 완료된 후 데이터가 일관된 상태인가?
단순히 여러 요청을 순차적으로 보내는 것으로는 경쟁 상태를 충분히 재현할 수 없다. 여러 요청을 동시에 시작하거나 실행 타이밍을 제어해 충돌이 발생하기 쉬운 조건을 만들어야 한다.
Jest를 활용한 동시 요청 테스트
test("동시 포인트 차감 시 잔액 정합성을 유지한다", async () => {
await resetUserPoints("user-a", 100);
const results = await Promise.allSettled([
spendPoints("user-a", 80),
spendPoints("user-a", 80),
]);
const balance = await getUserPoints("user-a");
expect(balance).toBe(20);
const succeeded = results.filter(
result => result.status === "fulfilled"
);
expect(succeeded).toHaveLength(1);
});
이 테스트는 두 요청 중 하나만 성공하고 나머지는 실패해야 한다는 조건을 검증한다. spendPoints와 getUserPoints는 실제 데이터베이스를 대상으로 하는 테스트 함수라고 가정한다.
단, Promise.allSettled를 사용했다고 해서 데이터베이스 작업이 반드시 동시에 실행되는 것은 아니다. 실제 동시성 문제를 검증하려면 트랜잭션 격리 수준, 행 잠금, 원자적 갱신 등의 동작을 확인할 수 있는 통합 테스트가 필요하다.
동시성 제어 방식 검증
동시성 문제를 방지하는 방법은 여러 가지다.
- 원자적 갱신: 조건을 포함한 단일 SQL 문으로 잔액 차감과 유효성 검사를 함께 처리한다.
- 비관적 잠금: 트랜잭션에서 대상 행을 잠가 다른 트랜잭션의 충돌을 방지한다.
- 낙관적 잠금: 버전이나 갱신 조건을 이용해 충돌을 감지하고 실패한 요청을 처리한다.
- 유니크 제약 조건: 같은 작업이 중복 생성되는 것을 데이터베이스 수준에서 차단한다.
테스트는 특정 구현 방식 자체보다, 동시 요청이 발생해도 비즈니스 규칙이 유지되는지를 중심으로 설계해야 한다.
멱등성 테스트
멱등성이란?
멱등성(Idempotency)은 같은 요청을 여러 번 실행해도 최종적인 비즈니스 결과가 한 번 실행한 것과 동일하게 유지되는 성질이다.
게임 플랫폼에서는 네트워크 지연이나 연결 종료로 인해 클라이언트가 요청 결과를 받지 못하는 경우가 발생한다. 클라이언트는 요청이 실패했다고 판단해 재시도할 수 있지만, 서버에서는 이미 해당 작업을 처리했을 수도 있다.
예를 들어 포인트 충전 요청이 정상적으로 처리됐지만 응답이 유실되었다면, 클라이언트가 같은 요청을 다시 보낼 수 있다. 이때 중복 충전이 발생하면 안 된다.
Idempotency-Key 검증
일반적으로 클라이언트는 하나의 논리적 작업에 고유한 Idempotency-Key를 부여한다.
서버는 해당 키를 기준으로 요청의 처리 상태와 결과를 저장한다. 동일한 키로 같은 요청이 다시 들어오면 새로운 작업을 수행하지 않고 기존 결과를 반환한다.
POST /api/points/charge
Idempotency-Key: charge-request-a
Content-Type: application/json
{
"amount": 100
}
테스트에서는 동일한 키와 동일한 요청 본문을 반복 전송한다.
test("같은 멱등성 키로 요청해도 중복 충전되지 않는다", async () => {
const key = "charge-request-a";
await Promise.all([
chargePoints({
userId: "user-a",
amount: 100,
idempotencyKey: key,
}),
chargePoints({
userId: "user-a",
amount: 100,
idempotencyKey: key,
}),
]);
const balance = await getUserPoints("user-a");
expect(balance).toBe(100);
});
주요 검증 항목
- 동일한 키로 요청하면 비즈니스 작업이 중복 실행되지 않는가?
- 재요청 시 기존 처리 결과를 반환하는가?
- 동일한 키에 다른 요청 본문을 전달하면 거부하는가?
- 같은 키를 이용한 동시 요청에도 중복 처리가 발생하지 않는가?
- 서버가 작업을 처리하는 도중 장애가 발생해도 상태를 복구할 수 있는가?
- 멱등성 기록의 보존 기간과 만료 정책이 적절한가?
멱등성 키는 애플리케이션 메모리에만 저장해서는 안 된다. 서버가 여러 대라면 서로 다른 서버가 동일한 요청을 처리할 수 있기 때문이다. 공유 데이터베이스의 유니크 제약 조건이나 원자적인 상태 관리 등을 이용해야 한다.
또한 멱등성 키가 존재한다는 사실만으로 안전성이 보장되지는 않는다. 작업의 실제 효과와 멱등성 기록을 어떻게 원자적으로 관리하는지도 검증해야 한다.
중복 구매 및 보상 지급 테스트
중복 처리가 발생하는 원인
게임 경제 시스템에서는 아이템 구매, 경기 보상, 출석 보상, 퀘스트 보상 등 재화와 아이템을 지급하는 작업이 자주 발생한다.
다음과 같은 상황에서 중복 처리가 발생할 수 있다.
- 구매 버튼을 빠르게 여러 번 누르는 경우
- 클라이언트가 타임아웃 이후 요청을 재전송하는 경우
- 같은 경기 결과 이벤트가 여러 번 전달되는 경우
- 메시지 큐가 장애 복구 과정에서 메시지를 재전달하는 경우
- 서로 다른 서버가 동일한 보상 작업을 동시에 처리하는 경우
클라이언트의 버튼을 비활성화하는 것은 사용자 경험을 개선할 수 있지만, 서버의 중복 처리를 방지하는 보안 장치로는 충분하지 않다.
중복 구매 테스트
아이템 구매 테스트에서는 동일한 구매 요청을 반복하거나, 같은 사용자가 동일한 상품을 동시에 구매하도록 만든다.
검증해야 할 사항은 다음과 같다.
- 포인트가 구매 비용만큼 정확히 차감되는가?
- 아이템이 의도한 수량만큼만 지급되는가?
- 포인트가 부족하면 구매가 실패하는가?
- 실패한 구매에서 포인트만 차감되거나 아이템만 지급되는 부분 성공이 발생하지 않는가?
- 동일한 구매 요청의 재전송으로 추가 구매가 발생하지 않는가?
test("동일한 구매 요청은 한 번만 처리된다", async () => {
await resetUserPoints("user-a", 500);
const request = {
userId: "user-a",
itemId: "sword-a",
price: 200,
idempotencyKey: "purchase-request-a",
};
await Promise.all([
purchaseItem(request),
purchaseItem(request),
]);
const balance = await getUserPoints("user-a");
const inventory = await getInventory("user-a");
expect(balance).toBe(300);
expect(inventory["sword-a"]).toBe(1);
});
이 예시는 동일한 논리적 구매 요청의 중복 실행을 검증한다. 게임에서 같은 아이템을 여러 번 구매할 수 있다면, 서로 다른 구매 요청은 정상적으로 처리되어야 하므로 구매 요청 식별자와 아이템 식별자를 구분해야 한다.
중복 보상 지급 테스트
보상 지급은 경기 결과나 퀘스트 완료 이벤트를 기준으로 처리할 수 있다.
예를 들어 같은 경기 결과 이벤트가 여러 번 전달되더라도 해당 경기의 보상은 한 번만 지급되어야 한다.
이를 위해 경기 결과 ID, 보상 지급 ID 또는 이벤트 ID를 기준으로 중복 여부를 확인한다. 데이터베이스의 유니크 제약 조건을 활용하면 여러 서버가 동시에 같은 보상 작업을 처리하더라도 중복 지급을 차단할 수 있다.
테스트에서는 다음을 검증한다.
- 같은 경기 결과 이벤트를 반복 전달해도 보상이 중복 지급되지 않는가?
- 서로 다른 경기의 보상은 각각 정상 지급되는가?
- 보상 지급 도중 장애가 발생한 뒤 재처리해도 결과가 일관적인가?
- 보상 기록과 실제 재화 지급 내역이 일치하는가?
- 보상 지급 이벤트가 중복 전달되어도 최종 잔액이 정확한가?
원장 기반 검증
포인트나 게임 재화처럼 정합성이 중요한 데이터는 현재 잔액만 확인해서는 부족하다. 거래 원장(Ledger)이나 지급 이력을 함께 검증하는 것이 좋다.
예를 들어 보상 지급 내역에는 보상 ID, 사용자 ID, 지급량, 지급 사유, 처리 상태, 처리 시각 등을 기록할 수 있다.
테스트는 최종 잔액과 함께 거래 원장에 동일한 보상 기록이 중복 생성되지 않았는지도 확인해야 한다.
구매 처리에서 포인트 차감과 아이템 지급이 동일한 데이터베이스에 있다면 하나의 트랜잭션으로 묶는 것이 일반적이다. 외부 시스템이나 메시지 브로커가 관여한다면 Transactional Outbox, 중복 이벤트 처리, 재시도 및 보상 작업 복구 정책까지 검증해야 한다.
Refresh Token Rotation 테스트
Refresh Token Rotation이란?
Refresh Token Rotation은 액세스 토큰을 갱신할 때 기존 Refresh Token을 폐기하고 새로운 Refresh Token을 발급하는 방식이다.
기존 Refresh Token이 계속 유효하게 남아 있다면 토큰이 탈취되었을 때 공격자가 장기간 계정을 사용할 수 있다. Rotation은 사용된 토큰을 추적하고 재사용 여부를 확인할 수 있도록 해 이러한 위험을 줄인다.
일반적인 처리 흐름은 다음과 같다.
- 클라이언트가 Refresh Token을 서버에 전달한다.
- 서버가 토큰의 유효성, 만료 여부, 폐기 여부를 검증한다.
- 서버가 새로운 Access Token과 Refresh Token을 발급한다.
- 기존 Refresh Token을 더 이상 사용할 수 없도록 처리한다.
- 클라이언트는 새 토큰을 저장하고 이후 갱신에 사용한다.
테스트 방법
정상 갱신뿐 아니라 만료, 동시 갱신, 저장 실패, 폐기된 토큰 제출 등을 검증해야 한다.
test("Refresh Token 갱신 후 기존 토큰은 재사용할 수 없다", async () => {
const oldToken = await issueRefreshToken("user-a");
const result = await rotateRefreshToken(oldToken);
expect(result.refreshToken).toBeDefined();
expect(result.refreshToken).not.toBe(oldToken);
await expect(
rotateRefreshToken(oldToken)
).rejects.toThrow();
});
이 테스트는 기존 토큰을 사용한 후속 갱신이 거부되는지를 확인한다. 실제 구현에서는 토큰 원문 대신 해시를 저장할 수 있으며, 테스트 환경에서는 토큰 저장소의 폐기 상태도 확인하는 것이 좋다.
주요 검증 항목
- 유효한 Refresh Token은 정상적으로 갱신되는가?
- 갱신된 토큰은 새로운 토큰으로 발급되는가?
- 기존 토큰은 정책에 따라 폐기되는가?
- 만료되거나 변조된 토큰은 거부되는가?
- 폐기된 토큰으로 갱신을 시도하면 거부되는가?
- 토큰 갱신이 동시에 요청되어도 토큰 상태가 일관적인가?
- 데이터베이스 장애나 응답 유실 상황에서 토큰 상태가 복구 가능한가?
특히 동시 갱신은 중요한 테스트 대상이다. 같은 Refresh Token으로 여러 요청이 동시에 들어오면 하나의 요청만 성공하도록 할지, 짧은 재시도 유예 정책을 둘지 명확히 결정해야 한다.
단순히 기존 토큰을 폐기하는 것만으로는 충분하지 않다. 토큰 검증과 폐기 처리가 원자적으로 이루어져야 하며, 동시에 도착한 요청이 모두 유효한 것으로 판단되지 않도록 해야 한다.
토큰 재사용 탐지 테스트
토큰 재사용 탐지란?
Refresh Token Rotation을 적용하면 이미 사용되어 폐기된 토큰이 다시 제출되는 상황을 탐지할 수 있다. 이는 토큰 탈취 가능성을 판단하는 보안 신호가 된다.
예를 들어 사용자가 Refresh Token을 갱신한 뒤 기존 토큰을 다시 제출한다면, 공격자가 탈취한 토큰을 사용하고 있을 가능성이 있다. 다만 클라이언트의 동시 요청이나 네트워크 재시도로도 같은 현상이 발생할 수 있으므로 모든 재사용을 무조건 공격으로 단정해서는 안 된다.
테스트 시나리오
- 정상 갱신 후 기존 토큰을 다시 제출한다.
- 만료된 토큰을 제출한다.
- 폐기된 토큰을 다른 클라이언트 환경에서 제출한다.
- 같은 토큰을 여러 요청에서 동시에 사용한다.
- 탈취된 토큰의 재사용을 가정하고 토큰 패밀리의 폐기 정책을 검증한다.
토큰 패밀리 관리
Refresh Token Rotation을 체계적으로 구현하려면 토큰 패밀리(Token Family)를 관리할 수 있다. 토큰 패밀리는 같은 인증 세션에서 연속적으로 발급된 토큰들의 관계를 나타낸다.
토큰 재사용이 탐지되었을 때 적용할 대응 정책은 서비스의 보안 요구사항에 따라 달라진다.
- 재사용 시도를 보안 이벤트로 기록한다.
- 해당 토큰을 즉시 거부한다.
- 정책에 따라 관련 토큰 패밀리나 세션 전체를 폐기한다.
- 사용자에게 재인증을 요구한다.
- 반복적인 이상 행위에 대해 추가적인 보안 조치를 적용한다.
테스트 검증 항목
- 폐기된 토큰의 재사용을 정확히 탐지하는가?
- 재사용 이벤트가 보안 로그에 기록되는가?
- 대응 정책이 의도한 범위에 적용되는가?
- 정상적인 동시 갱신과 실제 재사용을 구분할 수 있는가?
- 토큰 폐기 과정에서 다른 사용자의 세션까지 영향을 받지 않는가?
- 여러 서버에서 동시에 토큰 검증을 수행해도 탐지 결과가 일관적인가?
토큰 재사용 탐지 테스트에서는 단순히 요청이 실패했는지만 확인해서는 안 된다. 탐지 기록, 세션 폐기 상태, 후속 인증 요청의 결과까지 함께 검증해야 한다.
위치 정보 검증 테스트
위치 정보 검증이 필요한 이유
게임 플랫폼에서 위치 정보는 지역 기반 매칭, 위치 기반 보상, 출석 이벤트, 지리적 제한, 부정 이용 탐지 등에 활용될 수 있다.
그러나 클라이언트가 전달하는 위도와 경도는 사용자가 임의로 변경할 수 있는 입력값이다. 클라이언트에서 GPS를 사용한다는 사실만으로 위치 정보의 신뢰성이 보장되지는 않는다.
따라서 위치 정보 검증은 입력값의 유효성 검사와 실제 위치의 신뢰성 평가를 구분해서 설계해야 한다.
입력값 유효성 테스트
위도와 경도의 허용 범위는 다음과 같다.
- 위도: -90부터 90까지
- 경도: -180부터 180까지
범위를 벗어난 값, 문자열, 누락된 값, NaN, 무한대, 비정상적인 자료형 등을 전달했을 때 서버가 적절히 거부하는지 검증해야 한다.
function isValidLocation(latitude, longitude) {
return (
typeof latitude === "number" &&
typeof longitude === "number" &&
Number.isFinite(latitude) &&
Number.isFinite(longitude) &&
latitude >= -90 &&
latitude <= 90 &&
longitude >= -180 &&
longitude <= 180
);
}
test("유효하지 않은 위치 좌표를 거부한다", () => {
expect(isValidLocation(37.5, 127.0)).toBe(true);
expect(isValidLocation(91, 127.0)).toBe(false);
expect(isValidLocation(37.5, 181)).toBe(false);
expect(isValidLocation(NaN, 127.0)).toBe(false);
expect(isValidLocation("37.5", 127.0)).toBe(false);
});
이 검사는 좌표 형식과 범위만 검증한다. 사용자가 실제로 해당 위치에 있는지까지 증명하지는 않는다.
위치 위변조 및 부정 이용 테스트
위치 기반 보상처럼 악용 가능성이 높은 기능은 다음 상황도 검증해야 한다.
- 클라이언트가 보상 지역 밖의 좌표를 제출한다.
- 좌표를 조작해 특정 지역에 있는 것처럼 요청한다.
- 짧은 시간 안에 현실적으로 이동하기 어려운 거리만큼 좌표가 변경된다.
- 같은 위치 기반 보상을 여러 계정이나 요청으로 반복 획득한다.
- GPS 정확도가 낮거나 위치 정보가 오래된 상태에서 보상을 요청한다.
- 위치 정보가 누락되거나 위치 권한이 거부된 상태에서 요청한다.
서버 측 검증과 위험 평가
위치 기반 기능의 보안 수준은 기능의 중요도에 맞춰 설계해야 한다.
일반적인 주변 검색 기능이라면 좌표 유효성 검사와 거리 계산만으로 충분할 수 있다. 반면 위치 기반 재화 지급이나 이벤트 참여처럼 경제적 이익이 발생하는 기능은 더 강한 검증이 필요하다.
가능한 대응 방식은 다음과 같다.
- 서버에서 허용된 지역과 실제 좌표 간 거리를 계산한다.
- 위치 정보의 측정 시각과 정확도 정보를 함께 검토한다.
- 짧은 시간 내 비정상적인 이동이 발생하는지 확인한다.
- 요청 빈도와 보상 획득 이력을 분석한다.
- 필요한 경우 플랫폼에서 제공하는 위치 무결성 또는 기기 무결성 신호를 활용한다.
다만 GPS 좌표와 이동 속도만으로 위치 위변조를 완벽하게 차단할 수는 없다. 기기에서 전달하는 정보는 조작될 수 있으며, 위치 무결성 신호도 모든 공격을 방지하지는 못한다. 따라서 위치 검증 결과를 위험 점수, 요청 빈도, 계정 이력 등과 함께 판단하는 것이 현실적이다.
테스트 자동화 전략
정합성과 보안 테스트는 개별 함수의 단위 테스트만으로 완성되지 않는다. 실제 데이터베이스, 인증 저장소, 메시지 전달 과정에서 발생하는 문제까지 검증해야 한다.
단위 테스트
입력값 검증, 멱등성 키 비교, 좌표 범위 검사, 토큰 상태 판정 등 독립적인 로직을 검증한다.
통합 테스트
실제 데이터베이스와 Redis 등의 저장소를 사용해 트랜잭션, 유니크 제약 조건, 토큰 폐기 상태, 동시 요청 처리 등을 검증한다.
부하 및 경쟁 상태 테스트
다수의 요청을 동시에 발생시켜 경합 상황에서도 정합성이 유지되는지 확인한다. 단순히 요청 처리량을 측정하는 것뿐 아니라 중복 지급, 초과 차감, 토큰 재사용 허용 등의 오류가 발생하지 않는지 확인해야 한다.
장애 복구 테스트
작업 처리 중 연결이 끊기거나 데이터베이스 오류가 발생하는 상황을 재현한다. 재시도 이후에도 데이터가 일관되게 유지되는지, 부분 성공이 발생하지 않는지, 복구 작업이 중복 효과를 만들지 않는지 검증한다.
보안 회귀 테스트
한 번 발견한 취약점은 재현 가능한 테스트 케이스로 남긴다. 이후 인증 구조나 데이터베이스 로직이 변경되더라도 기존 보안 규칙이 유지되는지 자동으로 확인할 수 있어야 한다.
정합성 및 보안 테스트 체크리스트
- 동시에 요청해도 잔액과 재고의 정합성이 유지된다.
- 같은 멱등성 키로 요청해도 작업이 중복 실행되지 않는다.
- 동일한 키에 다른 요청 본문을 전달하면 정책에 따라 거부된다.
- 중복 구매로 인해 재화가 초과 차감되거나 아이템이 중복 지급되지 않는다.
- 같은 경기 결과 이벤트가 반복 전달되어도 보상은 한 번만 지급된다.
- Refresh Token 갱신 후 기존 토큰의 사용이 차단된다.
- 폐기된 토큰의 재사용이 탐지되고 적절한 대응이 수행된다.
- 동시 토큰 갱신 시 토큰 상태가 일관적으로 유지된다.
- 잘못된 위치 좌표와 비정상적인 입력값이 거부된다.
- 위치 기반 보상에서 반복 획득 및 비정상적인 이동 패턴을 검증한다.
- 장애와 재시도 이후에도 데이터와 처리 기록이 일치한다.
- 보안 이벤트와 실패 원인이 추적 가능한 형태로 기록된다.