MyStock - Phase 2, 구조 변경보다 기존 동작 유지가 중요하다

Phase 2에서 MainWindow에 모여 있던 실행 책임은 Account, Refresh, Analysis, Market 쪽으로 하나씩 이동했다. 구조를 기능별 책임으로 나눈다는 방향 자체는 어렵지 않았다. 내가 중간중간 다시 확인했던 것은 리팩토링의 개념이 아니라, Codex가 실제 MyStock 코드에서 어떤 책임을 어디까지 옮겼고 그 객체들이 기존 실행 흐름과 어떻게 연결되는지였다.

 

코드를 직접 수정하면서 따라간 작업이 아니었기 때문에 Step 결과만 보면 pending/latest/stale, Worker retire, shutdown 같은 표현이 계속 등장했다. Before / After로 놓고 나서야 전체 구조는 보이기 시작했다. 그런데 구조를 이해하고 나니 다른 문제가 더 크게 보였다.

 

이 프로그램은 단순히 클래스를 나눠 놓고 끝낼 수 있는 구조가 아니었다. Account와 Market, Analysis는 QThread를 사용하고 있었고 자동 갱신 Timer도 계속 동작한다. 실행 중에는 새 요청이 들어올 수 있고, 이전 요청의 결과가 늦게 도착할 수도 있으며, 그 순간 사용자가 앱을 종료할 수도 있다.

 

결국 Phase 2 후반의 핵심은 “어디로 옮길 것인가”보다 “옮기고도 기존 동작을 그대로 유지할 수 있는가”에 가까웠다.

그래서 첫 작업이 기능 분리가 아니라 회귀 안전망이었다

Phase 2의 첫 Step에서 Codex는 제품 구조를 바로 바꾸지 않았다. 먼저 QThread retire 순서, stale/pending/latest 처리, 종료 중 신규 작업 차단을 보강하고 Account 예외와 복구, Market latest/stale, closeEvent, Trade stale callback 같은 기존 동작을 테스트로 고정했다.

 

이후 각 책임을 Controller나 Coordinator로 옮길 때 “새 구조가 더 좋아 보인다”는 이유만으로 끝내지 않고, 기존 동작이 유지되는지를 계속 같은 테스트로 확인하기 위한 안전망이었다.

요청 시작
   ↓
Worker / QThread 실행
   ↓
그 사이 새로운 요청 발생 가능
   ↓
먼저 시작한 결과가 늦게 도착할 수 있음
   ↓
latest / stale 판단
   ↓
Thread retire
   ↓
종료 중이면 신규 작업 차단

이 흐름을 보고 나서 pending/latest/stale가 왜 Step마다 반복해서 등장하는지도 이해할 수 있었다. 여러 Thread를 사용한다는 사실 자체보다 중요한 것은 “지금 도착한 결과를 화면에 반영해도 되는가”와 “이미 끝나야 할 Thread를 어떤 순서로 정리하는가”였다.

구조를 하나 옮길 때마다 같은 검증이 반복됐다

Account 경계를 정리한 뒤에도 Account 테스트만 확인하고 넘어가지 않았다. ETF lifecycle, GUI, 비-Qt 테스트를 함께 돌렸다. Step마다 테스트 숫자는 달랐지만 작업 방식은 비슷했다.

 

RefreshCoordinator를 도입했을 때도 자동 갱신 주기와 수동 refresh, 초기 refresh/prefetch가 그대로인지 확인했고, Market과 투자현황 회귀 테스트를 다시 수행했다.

 

AnalysisController를 분리했을 때는 history/sentiment 요청뿐 아니라 cache, pending/latest/stale, Worker/QThread retire와 종료 차단까지 함께 검증했다.

 

QuoteStore와 freshness 경계를 추가한 뒤에도 별도 Quote Timer를 만들지 않았기 때문에 기존 Account/Market 자동 갱신 주기와 API 호출량이 바뀌지 않았는지를 확인했다.

 

MarketController까지 옮긴 뒤에는 RefreshCoordinator와 MarketController의 pending/shutdown, 기존 Phase 2 핵심 회귀, Market GUI subset을 다시 확인했다.

책임 이동
   ↓
해당 기능 targeted test
   ↓
관련 Feature 회귀 테스트
   ↓
QThread lifecycle 확인
   ↓
compileall / git diff --check
   ↓
가능한 경우 macOS Cocoa 실제 실행 확인

내가 코드를 직접 보고 수정하지 않았기 때문에 이 검증 결과는 더 중요했다. Codex가 무엇을 바꿨는지 설명하는 것과, 실제 기존 동작이 유지됐다는 것을 확인하는 것은 다른 문제였기 때문이다.

테스트가 실패한다고 모두 이번 리팩토링 문제는 아니었다

검증 과정에서는 전체 pytest를 한 번에 깔끔하게 통과시키지 못했다. PySide6 단일 프로세스 실행에서 Fatal Python error: Aborted와 exit 134가 반복됐고, 일부 broad GUI 테스트는 본문이 통과한 뒤 teardown에서 exit 139가 발생했다. offscreen geometry assertion도 기존 환경 제한으로 남았다.

 

중요했던 것은 실패가 보였다는 이유만으로 이번 변경의 회귀라고 단정하지 않는 것이었다. ETF lifecycle 테스트의 teardown에서 segmentation fault가 발생했을 때는 변경 전 커밋에서도 같은 현상이 재현되는지 확인했다. 기존 상태에서도 동일하게 발생한다면 Phase 2에서 새로 만든 문제와는 분리해서 기록해야 했다.

 

실제 KIS network 조회 역시 token API rate limit과 외부 환경 때문에 성공 여부를 끝까지 검증하지 못한 부분이 있었다. 그래서 결과 보고에는 통과한 테스트뿐 아니라 확인하지 못한 범위와 기존 환경 제한도 계속 남겼다. “테스트를 많이 돌렸다”보다 “어디까지 확인했고 어디부터는 확인하지 못했는가”를 구분하는 것이 더 중요했다.

그래서 macOS Cocoa에서 실제 앱도 반복해서 띄웠다

Qt 테스트 환경에서 abort와 teardown 문제가 계속 보였기 때문에 Codex는 macOS Cocoa 환경에서 샘플 데이터를 이용한 UI smoke도 반복했다.

 

Step에 따라 두 번 또는 세 번씩 실행하면서 앱 시작, Timer 설정, 탭 전환, refresh, 종료를 확인했다. 성공한 smoke에서는 COCOA_EXIT 0을 확인했고 QThread warning, segmentation fault, 종료 hang이 없는지도 함께 봤다. 모든 Step에서 Cocoa 검증이 항상 성공한 것은 아니었다. Quote freshness 작업에서는 PasteBoard 서비스 오류로 성공 결과를 확보하지 못한 경우도 있었다. 이 역시 성공한 것처럼 처리하지 않고 제한 사항으로 남겼다. 최종 Step에서는 짧은 Timer와 탭 전환, refresh, 종료를 포함한 Cocoa sample smoke를 세 번 실행했고 모두 COCOA_EXIT 0이었다.

 

여기까지 왔을 때는 구조 분리도 끝났고 관련 회귀 테스트와 실제 UI smoke도 반복해서 확인한 상태였다. 그래서 Phase 2가 거의 끝났다고 생각했다.

마지막 Connector Review에서 실제 회귀 하나가 나왔다

PR 마무리 단계에서 최신 코드에 다시 @codex review를 요청했다. 이전 review에서는 major issue가 없었지만, 최종 구조가 모두 반영된 뒤의 Connector Review에서 P2 finding 하나가 올라왔다.

 

문제는 시황분석의 startup prefetch와 snapshot thread retire 타이밍 사이에 있었다. startup prefetch 결과인 snapshot_ready가 도착한 뒤 사용자가 시황분석 탭으로 들어가는 특정 순서에서, 기본 market series 시작이 일시적으로 보류될 수 있었다. 그 뒤 snapshot thread가 retire되면서 내부 registration은 정리되지만, MainWindow에는 “이제 기본 series를 다시 시작해도 되는지 확인하라”는 알림이 없었다.

startup prefetch
      ↓
snapshot_ready
      ↓
사용자가 시황분석 탭으로 이동
      ↓
snapshot thread가 아직 등록되어 있어
기본 series 시작 보류
      ↓
snapshot thread retire
      ↓
registration 정리
      ↓
재시도 알림 없음
      ↓
다음 refresh / selection 전까지
기본 차트가 비어 있을 가능성

이건 단순한 코드 스타일 지적이 아니었다. 실제 사용자 화면에 영향을 줄 수 있는 race condition이었다.

리뷰 지적을 테스트로 만들었더니 실제로 실패했다

Connector finding을 바로 수정하는 대신 Codex에 먼저 재현 가능성을 확인하도록 했다. 테스트 시나리오는 startup market prefetch가 시작되고 snapshot 결과가 도착했지만 thread는 아직 retire되기 전인 상태에서 사용자가 Market 탭으로 이동하는 경우였다.

 

이때 기본 series 시작은 잠시 보류되고, snapshot thread가 완전히 retire된 뒤에는 별도 사용자 refresh나 selection 없이 다시 시작되어야 했다. 수정 전 테스트에서는 _load_market_series 호출이 0회로 남아 실제로 실패했다.

수정 전
_load_market_series → 0회  ❌

Connector가 지적한 상황이 실제 코드에서도 재현된 것이다. Codex는 MarketController.snapshot_retired signal을 추가했다. native thread의 wait()와 registry 정리가 모두 끝난 뒤 signal을 발생시키고, MainWindow에서 _start_default_market_series_if_needed()를 다시 호출하도록 수정했다. 종료 중에는 기존 shutdown_requested 흐름만 사용하도록 해 shutdown 동작은 유지했다.

snapshot thread retire
      ↓
native thread wait()
      ↓
registry 정리
      ↓
snapshot_retired signal
      ↓
기본 market series 필요 여부 재평가

수정 후 같은 회귀 테스트는 통과했다.

수정 후
회귀 테스트 → 1 passed  ✅

이후 RefreshCoordinator/MarketController, Step 1 regression, Account/Analysis/Quote, Market GUI subset, Market data 테스트를 다시 확인했고 compileall과 git diff --check도 통과했다. macOS Cocoa smoke도 두 번 다시 실행했고 모두 COCOA_EXIT 0이었다.

마지막에는 내가 직접 실행해보고 Merge했다

Connector finding까지 수정한 뒤 PR에는 다시 review를 요청했다. 여기까지의 구조 분석, 구현, 테스트와 review 대응은 Codex가 진행했고, 나는 결과를 ChatGPT와 확인하면서 이번 변경이 기존 MyStock 사용 흐름을 깨뜨리지 않았는지 마지막으로 직접 실행해봤다. 대시보드와 시황분석, 투자분석을 확인하고 자동 갱신과 탭 전환, 종료까지 평소처럼 사용해봤다. 잘 동작하는 것 같았다.

 

그때 최종적으로 PR을 Merge했다.

Phase 2에서 가장 크게 남은 것은 구조보다 검증 과정이었다

Phase 2의 결과만 보면 AccountController, RefreshCoordinator, AnalysisController, MarketController, QuoteStore 같은 경계가 생겼다. 하지만 두 번째 글에서 더 중요하게 남기고 싶었던 것은 클래스 목록이 아니다.

 

이미 동작하고 있는 프로그램의 책임을 옮길 때는 새 구조가 논리적으로 맞는 것만으로 충분하지 않았다. 요청이 겹치는 순간, 오래된 결과가 늦게 도착하는 순간, Thread가 retire되는 순간, 사용자가 탭을 바꾸는 순간, 앱이 종료되는 순간까지 기존 동작이 유지되어야 했다. 그리고 targeted test와 회귀 테스트, Cocoa smoke까지 반복한 뒤에도 Connector Review가 특정 타이밍에서 차트가 비어 있을 수 있는 문제를 하나 더 찾아냈다.

 

리뷰 지적을 테스트로 옮겼더니 실제로 실패했고, 수정한 뒤 같은 테스트가 통과했다. 이번 Phase에서 내가 직접 Python 코드를 작성하지 않았다는 사실과 별개로, 최종 결과를 판단하려면 “무엇을 바꿨는가”뿐 아니라 “무엇으로 안전하다고 확인했는가”를 봐야 한다는 점은 분명했다.

 

솔직히 리팩토링 과정을 구현 레벨이고, 이 부분은 Codex가 전담하고 있는 영역이라 리팩토링 2시간이상 작업동안 ChatGPT에게 이것저것 물어보긴 했지만, 결론은 Codex가 알아서 잘하겠지라는 결론밖에 내릴수 없었다...

 

구조를 나누는 방향 자체보다 실제 MyStock에서 그 구조가 기존 실행 흐름을 깨뜨리지 않았는지 확인하는 과정이 Phase 2의 절반 이상이었다.