Appium과 LLM 에이전트로 구현하는 모바일 게임 튜토리얼 E2E 무인 탐색 테스트

Appium 2와 LLM 에이전트를 결합해 모바일 게임 튜토리얼을 사람 없이 탐색하는 E2E 테스트를 구축합니다. 상태 관측, 액션 실행, 크래시와 소프트락 판정까지 단계별로 정리합니다.

✓ 복사 완료!
챕터게임 UI/UX 디자인
난이도중급
상태● 검토 완료
대상 엔진
Engine Agnostic
최종 업데이트
Appium과 LLM 에이전트로 구현하는 모바일 게임 튜토리얼 E2E 무인 탐색 테스트

핵심 요약

Appium 2.x는 모바일 게임의 설치, 실행, 탭, 스와이프, 백그라운드 전환을 자동화하고 LLM 에이전트는 스크린샷과 게임 상태를 바탕으로 다음 탐색 행동을 선택합니다. 게임 화면이 OpenGL, Vulkan, Unity UI처럼 접근성 트리로 노출되지 않는 경우에는 테스트 전용 상태 브리지와 시맨틱 오버레이를 함께 제공해야 안정적인 E2E 테스트가 가능합니다. 핵심은 LLM이 직접 기기를 제어하지 않고 허용된 JSON 액션만 제안하며 결정론적 실행기가 이를 검증한 뒤 Appium으로 실행하도록 분리하는 것입니다.

Appium과 LLM 에이전트를 결합하는 이유는 무엇인가?

고정된 스크립트 기반 E2E 테스트는 미리 정의한 경로를 검증하는 데 적합합니다. 하지만 모바일 게임 튜토리얼은 다음과 같은 변형이 자주 발생합니다.

  • 보상 팝업과 공지 팝업이 실행 순서에 따라 다르게 나타남
  • 네트워크 지연으로 버튼이 늦게 활성화됨
  • 튜토리얼 중간에 뒤로 가기나 앱 재시작이 발생함
  • 특정 해상도에서 버튼이 겹치거나 화면 밖으로 밀림
  • 플레이어가 선택할 수 있는 분기와 선택지가 업데이트마다 달라짐

LLM 에이전트는 화면의 의미와 현재 게임 상태를 해석해 고정되지 않은 경로를 탐색할 수 있습니다. 다만 LLM의 판단만으로 테스트를 구성하면 좌표 오류, 존재하지 않는 액션, 무한 반복이 발생할 수 있습니다. 따라서 역할을 다음처럼 나누는 것이 안전합니다.

구성 요소책임
Appium 2.x앱 설치, 세션 생성, 탭·스와이프·대기·뒤로 가기 실행
UiAutomator2Android 네이티브 UI와 제스처 제어
테스트 전용 상태 브리지현재 씬, 튜토리얼 단계, 팝업, 플레이어 상태 제공
LLM 에이전트관측값을 해석하고 다음 액션 선택
액션 검증기JSON 스키마, 좌표 범위, 시간 제한 검증
탐색 컨트롤러중복 상태 감지, 재시작, 종료, 결과 판정
로그 수집기스크린샷, 상태, 액션, logcat, 크래시 정보를 증거 파일로 저장

Appium과 LLM 에이전트가 모바일 게임 튜토리얼을 탐색하는 테스트 파이프라인

모바일 게임을 Appium이 제대로 관찰하지 못하는 이유는 무엇인가?

Appium은 기본적으로 운영체제의 UI 자동화 계층을 사용합니다. Android 네이티브 버튼에는 resource-id, text, content-desc 같은 정보가 있지만 게임 엔진이 하나의 SurfaceView 또는 OpenGL 렌더링 영역에 모든 화면을 그리면 Appium의 page_source에는 의미 있는 버튼이 나타나지 않을 수 있습니다.

이 문제는 세 가지 방식으로 보완할 수 있습니다.

방식장점주의점
테스트 전용 시맨틱 오버레이버튼 이름과 상태를 Appium이 읽을 수 있음실제 렌더링과 좌표가 어긋나지 않아야 함
테스트 전용 상태 브리지씬, 튜토리얼 단계, 보상 상태를 정확히 확인 가능운영 빌드에 포함하지 않도록 분리 필요
스크린샷과 좌표 제스처대부분의 게임 화면에서 동작해상도와 노치에 민감하고 오탐 가능성이 큼

가장 실용적인 조합은 상태 브리지와 시맨틱 오버레이를 기본으로 사용하고 좌표 제스처를 최후의 수단으로 사용하는 것입니다. 상태 브리지에는 비밀값이나 실제 결제 데이터가 아니라 테스트 판정에 필요한 최소 정보만 노출합니다.

예를 들어 테스트 빌드에서 다음과 같은 상태를 반환하도록 만들 수 있습니다.

{
  "scene": "tutorial_battle",
  "tutorial_step": "open_skill_panel",
  "modal": null,
  "player_hp": 100,
  "enemy_hp": 60,
  "available_targets": ["skill_button", "auto_button"],
  "progress": 0.42,
  "build_version": "1.8.0-test.42"
}

Android 에뮬레이터나 테스트 디바이스에서는 adb forward로 앱의 테스트 포트를 호스트에 연결할 수 있습니다.

adb -s emulator-5554 forward tcp:18080 tcp:8080

게임 엔진 내부의 HTTP 서버는 테스트 빌드에서만 활성화하고 외부 네트워크 인터페이스가 아닌 로컬 연결만 허용합니다.

Appium 2와 Android 테스트 환경은 어떻게 구성할까?

이 예제의 기준 환경은 다음과 같습니다.

  • Appium 2.x
  • Android SDK Platform 34
  • Android 13 또는 Android 14 에뮬레이터
  • UiAutomator2 드라이버
  • Python 3.11
  • Appium Python Client와 Pydantic 2.x

먼저 Appium 서버와 Android 드라이버를 설치합니다.

npm install -g appium@2
appium driver install [email protected]
python -m pip install Appium-Python-Client httpx pydantic

이 명령은 Appium 2와 호환되는 UiAutomator2 드라이버 4.2.0을 설치합니다. 재현 가능한 CI에서는 Appium과 Python 패키지의 정확한 버전도 고정합니다.

Appium 서버는 별도 터미널에서 실행합니다.

appium --address 127.0.0.1 --port 4723

세션 생성 코드는 다음과 같이 작성할 수 있습니다.

from appium import webdriver
from appium.options.android import UiAutomator2Options

options = UiAutomator2Options()
options.platform_name = 'Android'
options.automation_name = 'UiAutomator2'
options.device_name = 'Android'
options.app = 'artifacts/game-debug.apk'
options.set_capability('noReset', False)
options.set_capability('newCommandTimeout', 120)
options.set_capability('autoGrantPermissions', True)

driver = webdriver.Remote('http://127.0.0.1:4723', options=options)

세션을 시작할 때 앱을 재시작하려면 noReset을 False로 둡니다. 이 설정만으로 앱의 저장 데이터가 삭제되지는 않으므로, 매번 깨끗한 데이터가 필요하면 별도의 초기화 API나 전체 재설치 단계를 둡니다. 반대로 로그인 플로우가 매우 길다면 계정 시드와 초기화 API를 사용해 테스트 시간을 줄일 수 있습니다.

Step 1. 게임 상태와 화면을 하나의 관측값으로 묶기

LLM에 스크린샷만 전달하면 버튼의 의미는 추론할 수 있지만 현재 튜토리얼 단계나 성공 조건을 정확히 판단하기 어렵습니다. 스크린샷, 접근성 트리, 테스트 상태 브리지, 최근 로그를 하나의 관측 객체로 묶습니다.

import json
from datetime import datetime, timezone

import httpx


def read_game_state():
    response = httpx.get('http://127.0.0.1:18080/state', timeout=1.0)
    response.raise_for_status()
    return response.json()


def collect_observation(driver, recent_logs):
    window = driver.get_window_size()
    return {
        'timestamp': datetime.now(timezone.utc).isoformat(),
        'screen_size': window,
        'screenshot_base64': driver.get_screenshot_as_base64(),
        'accessibility_tree': driver.page_source,
        'game_state': read_game_state(),
        'recent_logs': recent_logs[-40:]
    }

page_source가 게임 화면 전체를 설명하지 못해도 시맨틱 오버레이가 제공하는 content-desc는 남을 수 있습니다. 게임 상태 브리지는 최종 성공 판정과 중복 상태 감지에 사용하고 스크린샷은 시각적 이상과 예기치 않은 팝업을 발견하는 데 사용합니다.

Step 2. LLM 에이전트의 액션 계약을 어떻게 설계할까?

LLM의 출력은 자연어가 아니라 검증 가능한 구조화 데이터로 제한합니다. 다음은 최소 액션 계약의 예입니다.

{
  "action": "tap",
  "target": "skill_button",
  "x": 0.82,
  "y": 0.88,
  "duration_ms": 500,
  "reason": "현재 튜토리얼 단계가 스킬 패널 열기이므로 스킬 버튼을 선택한다.",
  "expected_state": "skill_panel_open"
}

허용 액션은 처음부터 작게 유지하는 편이 좋습니다.

액션용도제한 예시
tap버튼 또는 게임 영역 선택정규화 좌표 0~1
swipe목록 스크롤, 카메라 이동방향과 거리 제한
back팝업 닫기, 이전 화면 이동전투 중 사용 금지 가능
wait애니메이션과 네트워크 대기최대 3초
restart소프트락 복구테스트당 횟수 제한
stop성공 또는 복구 불가 실패 종료종료 사유 필수

LLM 시스템 지시에는 현재 상태에서 가능한 목표와 금지 행동을 함께 전달합니다. 예를 들어 결제 버튼, 계정 삭제, 외부 링크, 광고 클릭은 테스트 대상이 아니므로 액션 후보에서 제거합니다. 모델이 이유를 작성하더라도 판정 근거로 사용하지 말고 상태 변화와 로그를 우선합니다.

프롬프트에서 후보를 제외하는 것만으로 실행이 차단되지는 않습니다. 운영 실행기에서는 target 허용목록과 좌표별 실제 hitbox·금지 영역을 따로 검증해야 합니다. 아래 코드는 스키마와 좌표 범위 검증을 보여 주는 최소 예시이며, 이 허용목록 검사는 생략했습니다.

Step 3. 액션을 검증한 뒤 Appium으로 실행하기

Pydantic으로 출력 형식을 검증하고 좌표와 대기 시간을 제한합니다.

from typing import Literal
from math import hypot
from pydantic import BaseModel, ConfigDict, Field, model_validator

APP_PACKAGE = 'com.example.game'  # 테스트 앱의 실제 applicationId로 바꿉니다.


class Action(BaseModel):
    model_config = ConfigDict(extra='forbid')

    action: Literal['tap', 'swipe', 'back', 'wait', 'restart', 'stop']
    target: str | None = None
    x: float | None = Field(default=None, ge=0.0, le=1.0)
    y: float | None = Field(default=None, ge=0.0, le=1.0)
    x2: float | None = Field(default=None, ge=0.0, le=1.0)
    y2: float | None = Field(default=None, ge=0.0, le=1.0)
    duration_ms: int = Field(default=500, ge=100, le=3000)
    reason: str = ''
    expected_state: str | None = None

    @model_validator(mode='after')
    def validate_action_fields(self):
        required_fields = {
            'tap': (self.x, self.y),
            'swipe': (self.x, self.y, self.x2, self.y2),
        }.get(self.action, ())
        if any(value is None for value in required_fields):
            raise ValueError(f'{self.action} action is missing required coordinates')
        if self.action == 'stop' and not self.reason.strip():
            raise ValueError('stop action requires a reason')
        return self


def required(value, name):
    if value is None:
        raise ValueError(f'{name} is required')
    return value


def execute_action(driver, plan: Action):
    width = driver.get_window_size()['width']
    height = driver.get_window_size()['height']

    if plan.action == 'tap':
        x = int(required(plan.x, 'x') * (width - 1))
        y = int(required(plan.y, 'y') * (height - 1))
        driver.execute_script('mobile: clickGesture', {'x': x, 'y': y})

    elif plan.action == 'swipe':
        start_x = int(required(plan.x, 'x') * (width - 1))
        start_y = int(required(plan.y, 'y') * (height - 1))
        end_x = int(required(plan.x2, 'x2') * (width - 1))
        end_y = int(required(plan.y2, 'y2') * (height - 1))
        distance = hypot(end_x - start_x, end_y - start_y)
        if distance == 0:
            raise ValueError('swipe start and end coordinates must differ')
        speed = max(1, int(distance * 1000 / plan.duration_ms))
        driver.execute_script('mobile: dragGesture', {
            'startX': start_x,
            'startY': start_y,
            'endX': end_x,
            'endY': end_y,
            'speed': speed
        })

    elif plan.action == 'back':
        driver.back()

    elif plan.action == 'wait':
        import time
        time.sleep(plan.duration_ms / 1000)

    elif plan.action == 'restart':
        driver.terminate_app(APP_PACKAGE)
        driver.activate_app(APP_PACKAGE)

좌표는 실제 픽셀이 아니라 화면 너비와 높이에 대한 비율로 기록합니다. 그러면 1080x1920과 1440x2560 같은 해상도에서 동일한 액션을 재사용할 수 있습니다. 단, 노치와 안전 영역이 있는 디바이스에서는 게임이 사용하는 실제 콘텐츠 영역을 기준으로 좌표를 변환해야 합니다.

Step 4. 무인 탐색 루프를 어떻게 구현할까?

LLM 호출 함수는 사용하는 모델이나 사내 게이트웨이에 따라 달라지므로 액션 계약만 맞추는 어댑터로 분리합니다. 모델에는 관측값 전체를 전달하되 screenshot_base64의 업로드 방식은 사용 중인 API에 맞게 구현합니다.

def llm_decide(observation: dict) -> Action:
    prompt = {
        'goal': '모바일 게임 튜토리얼을 성공 상태까지 탐색한다.',
        'rules': [
            '허용된 액션만 반환한다.',
            '현재 상태에서 실제로 가능한 버튼을 우선한다.',
            '같은 상태가 반복되면 wait 대신 다른 안전한 액션을 선택한다.',
            '결제, 계정 삭제, 외부 링크 액션은 선택하지 않는다.'
        ],
        'observation': observation
    }
    raw = call_structured_model(prompt, schema=Action.model_json_schema())
    return Action.model_validate(raw)

이 최소 예시에는 잘못된 응답의 재질의와 재시도 횟수 제한이 구현되어 있지 않습니다. Action.model_validate가 실패하면 예외가 발생하므로, 실제 루프에서는 검증 예외를 잡아 invalid_action으로 기록하고 제한된 횟수만큼 다시 요청해야 합니다.

핵심 루프에서는 실행한 액션 전후의 상태와 증거를 저장합니다. stop 액션은 실행 전에 반환하므로, 운영 로그에는 종료 판정도 별도로 기록해야 합니다.

import hashlib
import json
from pathlib import Path


def state_key(state: dict) -> str:
    stable = {
        'scene': state.get('scene'),
        'tutorial_step': state.get('tutorial_step'),
        'modal': state.get('modal'),
        'progress': state.get('progress')
    }
    encoded = json.dumps(stable, sort_keys=True, ensure_ascii=False).encode()
    return hashlib.sha256(encoded).hexdigest()[:16]


def run_exploration(driver, log_reader, max_steps=80):
    evidence_dir = Path('artifacts/exploration')
    evidence_dir.mkdir(parents=True, exist_ok=True)
    repeated = 0

    for step in range(max_steps):
        observation = collect_observation(driver, recent_logs=log_reader()[-40:])
        before = observation['game_state']
        before_key = state_key(before)

        action = llm_decide(observation)
        if action.action == 'stop':
            return {'result': 'stopped', 'step': step, 'reason': action.reason}

        execute_action(driver, action)
        after = read_game_state()
        after_key = state_key(after)

        record = {
            'step': step,
            'before': before,
            'action': action.model_dump(),
            'after': after,
            'before_key': before_key,
            'after_key': after_key
        }
        (evidence_dir / f'{step:04d}.json').write_text(
            json.dumps(record, ensure_ascii=False, indent=2),
            encoding='utf-8'
        )
        driver.get_screenshot_as_file(str(evidence_dir / f'{step:04d}.png'))

        repeated = repeated + 1 if before_key == after_key else 0
        if after.get('tutorial_complete') is True:
            return {'result': 'passed', 'step': step + 1}
        if repeated >= 4:
            return {'result': 'soft_lock', 'step': step + 1, 'state': after}

    return {'result': 'timeout', 'step': max_steps}

log_reader는 별도 logcat 수집기의 최근 로그를 반환하는 프로젝트 어댑터입니다. 네트워크·모델 호출·logcat 연결은 최소 예시에서 생략했으므로 실제 CI에서는 시간 제한과 오류 처리를 추가해야 합니다. 또한 이 예시는 상태 변화가 없는 연속 4회 뒤 바로 soft_lock을 반환합니다. 운영 판정에서는 먼저 대기 후 상태를 다시 확인하고, 안전하게 시도할 수 있는 미실행 액션이 남았는지도 검사해야 합니다.

실제 운영에서는 상태가 같더라도 애니메이션이나 네트워크 요청이 진행 중일 수 있으므로 즉시 실패로 판정하지 않습니다. wait 후에도 동일한 상태가 유지되고 액션 후보가 소진되었을 때 소프트락으로 분류하는 방식이 안전합니다.

탐색 커버리지는 어떻게 측정할까?

무인 탐색의 성공 여부를 단순히 테스트 종료 여부로 판단하면 안 됩니다. 최소한 씬, 튜토리얼 단계, 팝업 상태를 조합한 상태 키를 저장하고 새 상태를 얼마나 발견했는지 측정합니다.

coverage=uniquestates/reachablestatescoverage = unique_states / reachable_states

전체 도달 가능 상태를 미리 알 수 없다면 절대 커버리지 대신 다음 지표를 빌드 간 비교에 사용합니다.

  • 고유 상태 수
  • 튜토리얼 단계별 방문 횟수
  • 액션 종류별 실행 횟수
  • 재시작 횟수와 소프트락 비율
  • 크래시가 발생한 상태 키
  • 마지막으로 성공한 튜토리얼 단계
  • 동일 상태 반복까지 걸린 평균 액션 수

LLM이 매번 다른 문장을 생성하는지는 품질 지표가 아닙니다. 같은 상태에서 같은 액션을 반복하는지와 테스트 빌드 간 새롭게 발견한 상태가 있는지가 더 중요합니다. 탐색 범위를 넓히려면 상태별로 아직 실행하지 않은 액션을 후보에 포함하고 새 상태를 만드는 액션에 novelty 점수를 부여할 수 있습니다.

크래시와 소프트락은 어떻게 판정할까?

크래시는 Appium 명령 실패만으로 판정하지 말고 앱 프로세스 상태와 logcat을 함께 확인합니다. 반대로 소프트락은 프로세스가 살아 있지만 목표 상태로 진행하지 못하는 상황입니다.

판정관찰 조건수집할 증거
크래시세션 명령 실패, 앱 프로세스 종료, fatal 로그마지막 스크린샷, 상태 JSON, logcat, 액션
ANR 의심명령 응답 지연, 입력 무반응, 시스템 ANR 로그타임스탬프, 응답 시간, 시스템 로그
소프트락동일 상태 반복, 진행 플래그 미변경상태 키 시퀀스, 반복 액션, 화면 캡처
테스트 실패제한 시간 내 tutorial_complete 미도달마지막 상태와 종료 사유

Android에서는 테스트 시작 전에 다음과 같이 로그 수집을 초기화하고 실행 후 파일로 저장할 수 있습니다.

mkdir -p artifacts/exploration
adb -s emulator-5554 logcat -c
adb -s emulator-5554 logcat -v threadtime > artifacts/exploration/logcat.txt

CI에서는 로그 수집 프로세스가 종료되도록 관리해야 합니다. 크래시가 발생한 경우에는 단순히 앱이 종료됨이라고 기록하지 말고 마지막 상태 키, 액션의 target, 디바이스 모델, OS 버전, 게임 빌드 버전을 함께 저장해야 재현 가능성이 높아집니다.

CI에서 무인 탐색 테스트를 운영할 때 지켜야 할 점

LLM을 포함한 테스트는 네트워크와 모델 응답에 영향을 받으므로 일반적인 결정론적 회귀 테스트와 같은 방식으로 운영하면 안 됩니다.

  1. 테스트 전용 계정과 초기화 가능한 저장 데이터를 사용합니다.
  2. 결제, 광고, 외부 링크, 친구 초대 같은 외부 효과는 테스트 빌드에서 차단합니다.
  3. 모델 입력에서 계정 토큰과 개인정보를 제거합니다.
  4. 최대 액션 수, 최대 실행 시간, 재시작 횟수를 제한합니다.
  5. 모델이 반환한 액션과 실제 실행된 액션을 모두 기록합니다.
  6. 실패 시 스크린샷, 상태 브리지 응답, Appium 로그, logcat을 CI 아티팩트로 보관합니다.
  7. 중요한 릴리스 게이트는 고정 스크립트 테스트로 유지하고 LLM 탐색은 보완적인 발견 테스트로 운영합니다.

모델 응답이 달라질 수 있으므로 CI 결과에는 passed, crash, anr_suspected, soft_lock, timeout, invalid_action 같은 명확한 종료 상태를 사용합니다. invalid_action이 발생하면 실행하지 않고 모델에 재질의를 보내며 재질의 횟수도 제한합니다.

자주 묻는 질문 FAQ

Appium만으로 Unity나 Unreal 게임의 버튼을 찾을 수 있나요?

게임 UI가 Android 접근성 노드로 노출되면 가능합니다. 전체 화면이 하나의 렌더링 SurfaceView라면 버튼을 직접 찾기 어렵기 때문에 테스트 전용 시맨틱 오버레이, 상태 브리지 또는 정규화 좌표 제스처가 필요합니다.

LLM이 좌표를 직접 결정하도록 해도 되나요?

가능하지만 좌표를 그대로 실행하지 않는 것이 좋습니다. 화면 크기와 안전 영역을 확인하고 좌표를 0~1 범위로 제한한 뒤 허용된 영역과 금지 영역을 검증해야 합니다. 가능하면 target을 먼저 선택하게 하고 좌표는 실행기가 계산하도록 구성합니다.

이 테스트를 일반적인 회귀 테스트를 대체하는 용도로 사용할 수 있나요?

대체보다는 보완에 적합합니다. 고정 스크립트는 동일한 조건에서 빠르고 재현성 높은 검증을 제공하고 LLM 탐색은 예상하지 못한 팝업, 순서, 분기와 소프트락을 발견하는 데 유리합니다.

정리

Appium과 LLM 에이전트를 결합한 모바일 게임 E2E 테스트의 핵심은 모델의 자율성보다 경계 설계입니다. Appium은 기기 제어를 담당하고 테스트 빌드는 게임 상태를 설명하며 LLM은 다음 탐색 액션만 제안해야 합니다. 구조화된 액션 계약, 상태 키 기반 중복 감지, 제한된 실행기, 증거 중심 로그를 갖추면 튜토리얼 업데이트 이후의 팝업 오류, 진행 불가 상태, 크래시와 ANR 의심 상황을 사람의 수동 플레이 없이도 지속적으로 발견할 수 있습니다.