본문 바로가기
프로젝트/DIP 스위치 계산기

오목 대각선 테스트가 NPE로 깨졌다, 범인은 규칙이 아니라 테스트였다

by iwbap 2026. 10. 21.
728x90
반응형

DIP 스위치 계산기 앱에 넣은 오목·체스·장기 규칙을 테스트하던 중 오목 대각선 테스트에서 NPE가 났다. 원인은 규칙 엔진이 아니라 같은 칸을 두 번 쓰는 테스트 데이터였다. 첫 테스트부터 CI 수정, 연습 AI 추가까지 약 22분을 정리했다.

오목판 위 흑 돌 대각선 줄 중간 칸에 백 돌이 겹쳐 빨간 원으로 표시되고, 옆에 오목 대각선 테스트가 NPE로 깨졌다는 제목과 play() null에서 NullPointerException으로 이어지는 흐름을 적은 대표 이미지
깨진 건 규칙 엔진이 아니라, 같은 칸에 돌을 두려던 테스트였다

규칙 엔진에 기본 테스트 여섯 개를 추가했다

계산기 앱의 게임센터에는 오목(15×15), 장기(9×10), 체스(8×8)가 있었다. 세 게임의 규칙은 BoardRules가 판정했다.

판은 문자열 리스트로 저장하고 칸 번호는 행 × 너비 + 열이다. 체스에서 0번은 a8, 52번은 e2다. play()는 잘못된 수에 null을 반환한다.

10월 19일 12시 08분 55초에 첫 테스트 6개를 추가했다. 기본 규칙과 차례 제한을 확인하는 내용이었다.

  • 오목: 가로 다섯 개면 흑 승리, 이미 돌이 있는 칸은 거부
  • 체스: 첫 수 합법수 20개, 흑 선공 거부, 막힌 킹 이동 거부
  • 장기: 병은 앞과 옆으로만, 뒤로는 못 감

CI는 sdkmanager를 못 찾아 두 번 실패했다

16초 뒤 단위 테스트와 디버그 APK 빌드용 GitHub Actions를 추가했다. 첫 실행은 Android SDK 설정 단계에서 21초 만에 실패했다.

Warning: Failed to find package 'tools'
Error: The process '/usr/local/lib/android/sdk/cmdline-tools/16.0/bin/sdkmanager' failed with exit code 1

56초 뒤 설정 액션을 빼고 러너의 SDK를 사용했다. 하지만 sdkmanager: command not found와 종료 코드 127로 또 실패했다.

47초 뒤 SDK 경로를 지정하고 cmdline-tools에서 sdkmanager를 찾아 실행하도록 바꿨다.

env:
  ANDROID_HOME: /usr/local/lib/android/sdk
  ANDROID_SDK_ROOT: /usr/local/lib/android/sdk
steps:
  - name: Prepare Android SDK
    run: |
      SDKMANAGER=$(find "$ANDROID_HOME/cmdline-tools" -name sdkmanager -type f | head -1)
      echo "Using $SDKMANAGER"
      yes | "$SDKMANAGER" --licenses > /dev/null || true
      "$SDKMANAGER" "platforms;android-36" "build-tools;36.0.0"

이 실행은 12시 15분 28초에 성공했다. 워크플로를 추가하고 6분 17초, 세 번째 시도였다.

10월 19일 12시 09분부터 12시 30분까지 게임센터 CI 실행 일곱 번을 시간순으로 놓고, 워크플로 추가와 SDK 설정 액션 제거는 실패, SDK 경로 지정 후 성공, 대각선 오목 테스트를 넣은 실행은 NPE 1건으로 실패, 백 돌 위치 수정과 연습 AI 추가는 성공한 결과를 정리한 타임라인

체크메이트·앙파상·승격까지 테스트를 늘렸다

12시 18분 54초에는 장기 패스와 체스 캐슬링 검사를 더했다. 장기 패스 뒤 차례만 바뀌고 판은 그대로인지 확인했다.

chessCanCastleAfterClearingHomeRow는 이름과 실제 검사가 다르다. 초기 배치에서 62 !in legal(60)만 확인하며 캐슬링 성공은 검사하지 않는다.

12시 24분 11초에는 체크메이트와 특수 이동 등 테스트 4개를 추가했다.

  • 바보 메이트: f3, e5, g4, Qh4#로 흑 승리
  • 앙파상: exd6 후 d5의 흑 폰이 사라짐
  • 승격: a7 폰을 나이트로 승격
  • 오목: ↘ 대각선 다섯 개로 흑 승리

대각선 오목 테스트가 NPE로 깨졌다

CI는 약 6분 뒤 실패했다. 전체 13개 중 1개 실패였지만 BoardRulesTest는 12개뿐이었다. 다른 1개의 테스트 클래스는 확인하지 못했다.

BoardRulesTest > omokDiagonalFiveWins FAILED
    java.lang.NullPointerException at BoardRulesTest.kt:101

13 tests completed, 1 failed
> Task :app:testDebugUnitTest FAILED
BUILD FAILED in 5m 42s

101번째 줄은 백이 두는 줄이었다. 흑은 0, 16, 32번으로 대각선을 내려가고, 백은 14, 15, 16번에 둔다. 세 번째 백 돌이 흑이 이미 둔 16번 칸에 겹친다.

15×15 오목판 두 개를 나란히 그려, 수정 전에는 흑이 0번과 16번에 둔 뒤 세 번째 백 돌이 같은 16번 칸에 겹쳐 play()가 null을 돌려주고 수정 후에는 백 돌을 200~203번으로 옮겨 흑이 대각선 다섯 개로 이기는 모습을 비교한 개념 도식

로그에는 NPE와 줄 번호만 있었고, 원인은 코드의 착수 순서에서 추론했다. 점유한 칸에 두면 play()가 null을 내고, 테스트의 !!가 NPE를 일으킨다.

     p = BoardRules.play(p, BoardMove(-1, n * 16))!!
-    if (n < 4) p = BoardRules.play(p, BoardMove(-1, 14 + n))!!
+    if (n < 4) p = BoardRules.play(p, BoardMove(-1, 200 + n))!!

백 돌을 200~203번으로 옮긴 뒤 CI는 성공했다. 수정 커밋은 앞 커밋 16초 뒤인 12시 24분 27초로, 실패 로그보다 먼저였다. 어떻게 버그를 발견했는지는 기록에 없다.

가벼운 연습 AI를 추가했지만 화면 연결은 아직이다

12시 30분 52초에는 오프라인 연습 AI BoardAi를 추가했다. 대회 수준이 아닌 가벼운 연습용으로, CPU는 1번 진영을 맡는다.

  • 오목: 바로 이기는 칸, 상대가 이기는 칸 막기, 빈 판이면 가운데 순으로 본다
  • 오목 나머지: 기존 돌 2칸 이내 칸 중 이어진 돌과 무작위 점수로 고른다
  • 체스·장기: 합법수마다 잡는 기물 가치 ×10, 가운데, 전진 점수와 무작위 점수를 더한다
return moves.maxByOrNull { move ->
    val target = position.at(move.to)
    val captureScore = pieceValue(target) * 10
    val middleScore = 4 - abs(center - destinationColumn)
    val forwardScore = if (position.game == BoardGame.CHESS) destinationRow else -destinationRow
    captureScore + middleScore + forwardScore + Random.nextInt(5)
}

한 수 앞만 보는 탐욕 방식이다. 자기 킹을 위험에 두는 수는 legal()이 미리 거른다. 다만 상대의 다음 수는 읽지 않는다.

연습 AI가 오목에서는 바로 이기는 칸, 상대 승리 칸 막기, 빈 판이면 가운데, 주변 후보 점수 순으로 고르고, 체스와 장기에서는 합법수마다 잡는 기물 가치와 가운데·전진·무작위 점수를 더해 최고 점수 수 하나를 고르는 흐름을 정리한 개념 도식

첫 테스트부터 AI 추가까지 21분 57초였다. BoardAi 전용 테스트가 없고, 확인한 게임 파일 5개에도 호출부가 없어 화면 연결 전으로 판단했다.

이번에 배운 것

  • 실패한 수를 null로 돌려주는 API는 테스트의 !!에서 NPE로 드러난다.
  • 테스트가 깨지면 규칙보다 테스트 데이터부터 의심할 만하다.
  • 테스트 이름은 실제 검사 내용과 맞춰야 나중에 덜 헷갈린다.

다음에는 캐슬링 검사를 이름에 맞게 보강할 생각이다. 연습 AI의 테스트와 화면 연결도 남았다.

자주 묻는 질문

Q. 테스트에서 !! 대신 무엇을 쓰면 원인이 잘 보이나?
A. assertNotNull로 먼저 확인하면 실패 메시지가 더 분명해진다. 이번 테스트 코드는 !!를 그대로 썼다.

Q. GitHub Actions에서 sdkmanager를 못 찾을 때는?
A. 이번에는 ANDROID_HOME을 지정하고 cmdline-tools 아래에서 sdkmanager를 찾아 실행해 해결했다.

관련 글: 블루투스 대국 Android 12 권한 방어와 메시지 순서 수정까지 10분
참고: Android Developers: 로컬 단위 테스트 빌드

728x90
반응형