반응형

Strike Log Flutter 볼링 점수 입력 화면

1편에서는 Strike Log가 어떤 앱인지, 그리고 Flutter 앱과 NestJS API를 어떤 구조로 나눴는지 소개했습니다. 이번 2편에서는 앱의 가장 핵심 기능인 볼링 점수 입력과 계산 로직을 정리해보겠습니다.

볼링 점수 앱에서 어려운 부분은 단순히 숫자를 입력받는 것이 아닙니다. 스트라이크와 스페어는 다음 투구 결과에 따라 점수가 나중에 확정되고, 10프레임은 조건에 따라 세 번째 투구까지 생깁니다. 그래서 UI 안에 계산 규칙을 섞어두면 화면 코드가 금방 복잡해집니다.

Strike Log에서는 이 문제를 해결하기 위해 점수 계산을 BowlingScorer라는 순수 계산 모듈로 분리했습니다.

점수 입력 화면과 계산 로직을 분리한 이유

Flutter의 점수 입력 화면은 사용자가 프레임별로 핀 수를 입력하고, 현재 점수와 누적 점수를 즉시 보여줘야 합니다. 클럽 경기에서는 입력할 때마다 Socket.IO로 현재 점수도 보내야 합니다.

하지만 화면이 직접 모든 계산을 처리하면 문제가 생깁니다. 스트라이크인지, 스페어인지, 프레임이 완료됐는지, 다음 프레임의 보너스 투구가 충분히 들어왔는지, 10프레임에서 세 번째 투구가 필요한지 같은 규칙이 모두 UI 코드에 섞입니다. 그러면 게임 상세 화면, 결과 화면, 클럽 경기 요약 화면에서도 같은 계산을 다시 구현하게 됩니다.

그래서 Strike Log는 frames 데이터를 기준으로 점수를 계산합니다. frames는 길이 10의 리스트이고, 각 프레임에는 실제 투구한 핀 수가 들어갑니다. 예를 들어 스트라이크는 [10], 스페어는 [5, 5], 10프레임 퍼펙트는 [10, 10, 10]처럼 표현합니다.

스트라이크와 스페어는 바로 점수가 확정되지 않는다

볼링 점수 계산에서 가장 중요한 점은 모든 프레임의 점수가 즉시 확정되지 않는다는 것입니다.

스트라이크는 다음 두 번의 투구가 보너스로 붙고, 스페어는 다음 한 번의 투구가 보너스로 붙습니다. 그래서 1프레임에 스트라이크를 쳤다고 해서 바로 10점으로 표시하면 안 됩니다. 다음 두 투구가 들어와야 그 프레임의 누적 점수를 계산할 수 있습니다.

Strike Log의 cumulativeScores는 이런 경우 아직 계산할 수 없는 프레임을 null로 둡니다. 예를 들어 스트라이크만 입력된 상태라면 누적 점수는 아직 비워두고, 다음 투구가 충분히 들어온 뒤에 계산합니다.

이 방식 덕분에 화면에서는 “아직 확정되지 않은 점수”와 “확정된 누적 점수”를 자연스럽게 구분할 수 있습니다.

10프레임은 따로 생각해야 한다

볼링 점수 계산에서 10프레임은 예외가 많습니다.

1~9프레임에서는 첫 투구가 10이면 스트라이크이고 프레임이 바로 끝납니다. 하지만 10프레임에서는 첫 투구가 스트라이크면 두 번 더 던질 수 있습니다. 첫 두 투구가 스페어여도 세 번째 투구가 생깁니다.

예를 들어 10프레임이 [10, 3, 5]이면 18점입니다. [5, 5, 10]이면 스페어 보너스까지 포함해 20점입니다. [10, 10, 10]이면 10프레임만 30점입니다.

Strike Log는 isFrameComplete에서 10프레임 완료 조건을 따로 처리합니다. 10프레임이 오픈이면 2투로 끝나고, 스트라이크나 스페어가 있으면 3투까지 입력되어야 완료된 것으로 봅니다.

통계도 같은 계산 모듈에서 만든다

점수만 계산한다고 끝은 아닙니다. Strike Log는 대시보드와 결과 화면에서 스트라이크 수, 스페어 수, 오픈 프레임 수, 최장 연속 스트라이크도 보여줍니다.

이를 위해 BowlingScorer에는 다음 계산도 함께 들어갑니다.

  • totalScore: 현재까지 확정된 총점
  • cumulativeScores: 프레임별 누적 점수
  • computeStats: 스트라이크, 스페어, 오픈 개수
  • longestStrikeStreak: 한 게임의 최장 연속 스트라이크

특히 10프레임 통계는 조심해야 합니다. 예를 들어 [3, 7, 3]은 첫 두 투구가 스페어입니다. 그런데 2구와 3구를 더해도 10이 되기 때문에 단순 합산으로 처리하면 스페어를 두 번 센 것처럼 보일 수 있습니다. 실제로는 같은 세트의 잔여 핀이므로 스페어는 한 번만 카운트해야 합니다.

이런 회귀를 막기 위해 테스트에도 10프레임 예외 케이스를 따로 넣었습니다.

입력할 때마다 점수와 실시간 경기 상태를 갱신한다

점수 입력 화면에서는 키패드를 누를 때마다 현재 프레임 데이터가 바뀝니다. 그러면 화면은 BowlingScorer.totalScore로 총점을 다시 계산하고, BowlingScorer.cumulativeScores로 프레임별 누적 점수를 갱신합니다.

클럽 경기라면 여기서 한 단계가 더 있습니다. 현재 점수와 스트라이크·스페어·오픈 통계를 Socket.IO로 전송합니다. 서버는 같은 방에 있는 참가자들에게 방 상태를 다시 보내고, 앱은 실시간 순위표를 갱신합니다.

즉 점수 계산 모듈은 단순히 결과 화면에만 쓰이지 않습니다. 입력 화면, 클럽 경기 실시간 순위표, 게임 상세 화면, 결과 공유 화면까지 여러 곳에서 같은 기준으로 사용됩니다.

테스트로 계산 규칙을 고정했다

볼링 점수 계산은 눈으로 보기엔 단순해 보여도 예외가 많습니다. 그래서 Strike Log에는 bowling_scorer_test.dart로 계산 규칙을 테스트합니다.

테스트에는 퍼펙트 게임 300점, 전부 거터 0점, 모든 프레임 스페어 190점, 오픈 프레임 90점 같은 기본 케이스가 들어갑니다. 여기에 미완성 스트라이크, 미완성 스페어, 10프레임 스트라이크와 스페어, 최장 연속 스트라이크까지 검증합니다.

이 테스트가 있으면 이후 화면을 고치거나 통계 기능을 추가할 때도 점수 계산 규칙이 깨졌는지 빠르게 확인할 수 있습니다.

마무리

Strike Log의 점수 입력 기능은 단순한 숫자 입력 화면이 아니라 볼링 규칙을 앱 안에 옮기는 작업이었습니다. 그래서 화면은 입력과 표시를 담당하고, 계산은 BowlingScorer로 분리했습니다.

이 구조 덕분에 같은 계산 로직을 입력 화면, 결과 화면, 상세 화면, 클럽 실시간 경기에서 재사용할 수 있었습니다. 그리고 테스트로 10프레임 예외와 스트라이크·스페어 보너스 규칙을 고정해두면서 이후 기능을 더 안전하게 확장할 수 있었습니다.

다음 3편에서는 이렇게 입력한 게임 기록을 NestJS API로 저장하고, 실패 상황에서도 사용자 경험이 무너지지 않도록 Dio, JWT, 저장 재시도, 에러 분류를 어떻게 처리했는지 정리해보겠습니다.

반응형