로보 77 구현기 1일 차 - 예상보다 험난했던 프로젝트 세팅과 카드 제출 구현

들어가며

오늘은 드디어 나만의 도전 주제 탐색 및 기획을 마치고 개발을 본격적으로 시작하였다.

👏🏻👏🏻👏🏻로보77 게임 Phase 1 개발 시작👏🏻👏🏻👏🏻

 

프로젝트 세팅부터 쉽지 않았고,,

무엇보다 이번 미션은 기존처럼 “주어진 요구사항을 해석하는 방식”이 아니라,

내가 직접 a~z까지 설계해야 한다는 점에서 막막함을 크게 느꼈다.

그래도 “첫 술에 배부르려 하지 말자. 일단 시작하고, 더 나은 게 떠오르면 고치면 된다.”

라는 마음으로 할 수 있는 작은 것부터 시도하며 첫 발을 내디뎠다.

그리고 그렇게 한 걸음씩 나아가다 보니

개발해야 할 기능 목록도 정리되고, 카드 제출 기능까지 구현할 수 있었다.

 

오늘 한 일

  • 레포 생성 및 프로젝트 환경 설정
  • 도메인 이해 및 기능 목록 작성
  • 카드 지급, 손패 조회, 카드 제출 기능 구현

 

📌 예상 10분 → 실제 1시간: .gitignore와의 전쟁

레포를 생성하고 프로젝트 환경을 설정하는 과정에서 예상치 못한 문제가 생겼다.
자바 프로젝트를 만들고 원격 저장소에 푸시했는데, 필요 없는 IntelliJ 관련 파일들이 계속 올라가는 것이었다.

레포를 새로 만들어 봐도 마찬가지였다.


5번 정도 레포, 프로젝트를 생성&삭제를 반복한 끝에, 기본적으로 적용되어 있는 .gitignore는

.idea/ 전체를 무시하는 것이 아니라 .idea/modules.xml 등 선택적으로 무시하는 파일만 지정되어 있어

나머지 .idea 설정파일들은 모두 Git에 올라갈 수 있었다는 것을 깨달았다.

원인을 파악한 뒤 .idea/로 .gitignore를 수정하니 필요없는 파일이 올라가는 것을 막을 수 있었다.


처음에는 “10분이면 원격 저장소 연결까지 다 끝나겠지?”라고 가볍게 생각했는데,

예상치 못하게 한 시간 이나 붙잡고 있게 되었다..ㅎ

프리코스 미션 제출할 때는 이미 설정되어 있어 '아~그렇구나' 하고 넘어갔던 .gitignore 범위에 대해 고려하며

내가 이해하고 있다고 착각했던 것들을 다시 확인한 의미 있는 시간이었다.

 

📌 처음부터 완벽한 설계는 없다: bottom-up으로 방향을 찾다

지난 미션들은 주어진 요구 사항을 해석하며 프로그램 흐름과 구현해야 할 기능들을 파악했는데,

이번 미션은 내가 직접 게임의 규칙과 흐름을 설계해야 하기 때문에
신경 써야 할 것들이 너무 많아 어디서부터 손대야 할지 몰라 계속 멈칫하게 되었다.

 

그래서 기능 목록을 작성하기 이전에, 나 스스로 비지니스 규칙을 확실히 인지하기 위해

우선 내가 구현하려는 게임의 비즈니스 규칙을 명확하게 정리하는 것부터 시작했다.
카드 구성표, 특수카드 기능, 턴 진행 방식, 점수 계산, 게임 종료 조건 등
게임의 뼈대가 되는 규칙들을 하나하나 문서로 정리했다.

📄 정리한 문서:

https://github.com/Jiihyun/robo77/commit/3eb60dc063c7b1ec30bec0fc43da4bbe7f861815

 

이 작업을 마치고 나니 게임의 전체적인 흐름이 머릿속에 그려졌고,
흐름과 흐름을 구현하기 위해 필요한 기능들을 손으로 작성해 보면서 도메인에 대해 이해하는 시간을 가졌다.

 

이후 기능별 책임을 떠올리며 객체 간 협력을 설계해야 했지만,

생각보다 훨씬 복잡한 요구사항을 만족시키면서 객체들을 적절히 협력시키는 게 너무 어려웠다.

  • 카드 기능 처리는 누가 담당하지?
  • 턴은 어디서 관리해야 할까?
  • 새로운 카드는 어디서 뽑지? 
  • 점수 계산은 어떻게 하지?

이런 고민들이 끝없이 이어졌고, 그때마다 설계가 자꾸 막히면서 좀처럼 방향을 잡기 힘들었다.

 

결국 처음부터 완벽한 구조를 만들려는 top-down 방식은 오히려 진행을 막는다는 걸 깨달았다.

그래서 과감히 설계 없이 일단 작은 기능부터 만들어보고, 이후 더 나은 구조가 떠오르면 리팩토링하는 bottom-up 방식을 택했다.

"생각을 못했다가 나중에 생각이 났다는 건, 그 사이에 내가 성장해서 예전엔 보이지 않던 것이 보이기 시작한 거 아닐까?"

라고 생각하며 처음부터 완벽히 하려는 마음을 내려놓았다.

그렇게 기능 목록을 정리하는 데까지 4시간 가까이 걸렸지만,
이 긴 여정 덕분에 앞으로 나아갈 방향이 조금은 선명해진 느낌이었다. 허허

 

📌 카드 제출 기능 구현 시작: 입력·리스트 처리에서 만난 두 가지 함정

 

▪️ IntelliJ 콘솔은 왜 입력값이 null이 나올까?

콘솔 기반으로 사용자 입력을 받기 위해 자연스럽게 떠올린 방법이 System.console() 이었다.
프리코스에서는 테스트 통과를 위해 우아한테크코스에서 제공한 Console 라이브러리를 사용해야 했지만,
이번 오픈 미션에서는 관련 요구사항이 없으니 자바 내장 라이브러리를 사용해도 문제없었기 때문이다.

그래서 아래처럼 입력을 받으려고 했는데…아무리 입력해도 계속 null만 반환되었다.

System.console().readLine();

 

🤔 코드에는 문제가 없어 보였는데 왜 그럴까?

 

인터넷에 검색을 통해 알게 된 이유를 요약하자면 다음과 같았다.

IntelliJ의 Run/Debug 창은 진짜 OS 콘솔이 아닌 모방한 콘솔이기 때문에
System.console()이 항상 null을 반환한다.
출처: https://zeus2141.tistory.com/122


실제로 JDK 내부 코드를 보면 콘솔 객체를 생성하는 instatiateConsole(istty) 은 다음과 같다:

istty() 여부에 따라 JdkConsoleProvider이 콘솔 생성 또는 null 반환한다.

istty()는 아쉽게도 내부 구현을 직접 확인할 수 없었지만,
현재 프로그램이 실행되고 있는 환경이 ‘진짜 터미널(tty)’인지 판별하는 메서드라고 알려져 있다.

 

따라서, 현재 프로그램이 실행되는 환경이 OS의 실제 터미널(tty) 장치에 연결되어 있을 때만 Console 객체를 만들어주는데,

IntelliJ는 istty() 수행 시 false를 반환하기 때문에 Console 객체 생성 실패하여 null을 반환하는 것이다. 

 

🤔 IntelliJ에서 istty()는 왜 false일까?

왜냐하면 인텔리제이에서 보이는 콘솔은 터미널처럼 보이지만,
이는 OS의 tty 장치와 연결된 진짜 터미널이 아니다.

IntelliJ가 자체적으로 렌더링한 UI이라고 한다. (처음 안 사실!!!)

출처:https://www.jetbrains.com/help/idea/terminal-emulator.html

 

따라서 나는 콘솔 환경 여부와 상관없이 입력을 안정적으로 받을 수 있는 java.util.Scanner(System.in)를 사용하기로 했다.

System.in은 stdin 스트림이므로 IDE, 터미널 등 어떤 환경에서도 항상 존재하며 TTY 여부와 무관하게 작동하기 때문이다.

 

프리코스에서는 이미 세팅된 환경에서 주어진 라이브러리를 그대로 사용하면 되었지만,
이번 오픈 미션에서는 내가 직접 라이브러리를 선택하면서, 예상치 못한 문제의 원인을 찾아가며 해결하였다.

이 과정에서 System.console()은 안다고 생각했지만 실제 동작 원리는 잘 모르고 있었다는 점,
그리고 IntelliJ 콘솔이 실제 OS 터미널(tty)이 아니라는 사실을 처음으로 제대로 이해하게 된 점도 큰 배움이었다.

문제 해결 자체는 단순했지만, JVM 내부 코드까지 살펴보며 원인을 추적하는 경험 자체가 꽤 즐거웠다!

 

▪️ subList 쓰다가 멘붕 온 이유 — 깊은 복사와 clear()의 중요성

로보77 게임에서는 시작할 때 카드 더미(Deck)에서 5장의 카드를 골라 플레이어에게 지급해야 한다.

따라서 나는 Deck의 카드 여러개 중 5개만 잘라서 hand에 넘기고, 덱에서는 해당 카드들을 제거하고 싶었다.
이에 방법을 찾던 중 subList()라는 API에 대해 알게됐고, 직접 사용해 보면서 몇 가지 특성을 새로 배웠다.

 

1. subList는 "새 리스트"가 아니다.

내가 가장 크게 오해하고 있던 부분은 subList가 완전히 분리된 새 리스트를 만드는 메서드가 아니라는 점이었다.

subList는 단순히 원본 리스트의 특정 범위를 얕게 참조한 객체이며,
서브 리스트를 수정하면 원본 리스트도 함께 변경된다.
반대로 원본 리스트를 변경하면 subList도 영향을 받아 ConcurrentModificationException이 발생할 수 있다.

// 실패 코드
List<Card> hand = cards.subList(0, 5);
IntStream.range(0, 5)
        .forEach(cards::remove);
        
System.out.println(hand);// ConcurrentModificationException 발생

원본 리스트(cards) 쪽에서 remove를 반복하면서 subList의 구조가 깨져버렸고,
결국 접근 시 예외가 터지게 되었다.

subList는 원본이 변경되면 접근할 수 없다는 사실을 배웠다.

 

2.  일정 범위의 리스트를 새 객체로 얻고자 한다면 new ArrayList<>(subList)로 깊은 복사하여 사용하자.

// 실패 코드2
List<Card> hand = new ArrayList<>(cards.subList(0, 5));
IntStream.range(0, 5)
        .forEach(cards::remove);

 

subList를 복사했기 때문에 hand는 문제가 없었지만,

이 코드는 정상 동작하지 않았다.
cards.remove(i)를 0 → 4 순서로 반복하면서 삭제하는 방식 때문에
리스트 요소가 하나씩 앞으로 당겨져 예상치 않은 원소들이 삭제되는 문제가 발생했다.

 

3. subList 범위를 삭제할 때는 clear()를 활용하자.

// 성공 코드
List<Card> hand1 = new ArrayList<>(cards.subList(0, 5));
cards.subList(0, 5).clear();
  • subList를 새 ArrayList로 감싸 안전하게 분리
  • 원본 리스트에서는 해당 구간 전체를 한 번에 삭제(clear)

clear()는 내부적으로 한 번에 범위를 처리하기 때문에, 인덱스 밀림 문제가 발생하지 않는다고 한다.

 

subList()를 처음 활용해봤는데, 생각보다 신경쓸 부분이 많은,, 불편한 API라고 느꼈다.

원본 리스트를 참조하고 있는 객체라서
항상 별도로 복사해 안전하게 감싼 뒤 사용해야 한다는 점이 특히 번거로운 것 같다.

여러 번 에러를 겪으며 사용법을 익히긴 했지만,
솔직히 앞으로 이 API를 얼마나 잘 활용하게 될지는 조금 의문스럽다.

 

마치며

오늘은 전반적으로 얕게 알고 있던 것들을 한층 더 깊게 이해하게 된 하루였다.

익숙하다고 생각했던 개념들도 직접 문제를 맞닥뜨리고 원인을 추적해 보는 과정에서 처음 알게 된 사실들이 계속 드러났고,

그때마다 알고 있다고 생각한게, 사실은 아는 게 아니였다는 걸 깨닫게 됐다ㅋㅋㅋ,,,

 

그래도 오늘의 시행착오 덕분에 내 지식의 빈틈이 자연스럽게 드러났고,

그 틈을 채우는 과정에서 많이 배운 것 같아 의미 있는 하루였다.

 

또 어찌저찌 게임 초기화부터 카드 제출 기능까지 구현해냈다는 점도 작은 성과이다.
물론 이제는 카드 종류에 따라 계산 방식을 다르게 적용해야 하는데
현재로선 어떻게 구조를 잡아야 할지 딱 떠오르지는 않는다.

그래도 오늘처럼 목표를 잘게 나누고 하나씩 해결해 나가다 보면
결국 또 해낼 수 있지 않을까 싶다.

화이띵이다 나.