최종 갱신 (KST)

← 일감 목록
T-085

공용 값의 소유권 정리 — 클라·서버에 이중으로 박힌 상수·이름·한도 검사

진우 In Progress 우선순위 높음 목표 261010

흡수한 일감 — T-077(공용 상수 시트) · T-047(이름 출처) · T-084(채취·치트 한도). 셋은 같은 뿌리라 따로 굴리면 서로를 기다린다 — 이 일감 하나로 합쳤다.


1. 문제 요약

클라와 서버가 같은 값을 각자 자기 코드에 들고 있다. 어느 쪽이 권위인지 정해져 있지 않다.

전수 조사(2026-09-24) 결과 양쪽에 같은 숫자가 두 번 박힌 곳 6건, 서버만 알고 클라가 역산하는 값 6건, enum 원소 수를 손으로 센 곳 4건이 나왔다. 여기에 인벤토리 한도 검사가 획득 경로 전체에 걸리지 않은 것이 겹친다.

한쪽만 고치면 컴파일도 통과하고 로그도 안 남은 채 조용히 어긋난다. 클라가 먼저 막으면 사용자는 영문을 모르고, 서버만 막으면 클라는 보낼 수 없는 요청을 계속 그린다.


2. 재현 방법 · 발생 조건

지금 당장 터지는 버그가 아니라 값을 고치는 순간 터지는 구조다. 셋 다 재현된다.

2-1. 지금도 재현된다 — 인벤토리 가득 참 안내

  1. 캐릭터를 200개 보유한 계정으로 로그인
  2. 캐릭터 뽑기를 누른다
  3. 서버는 StorageFull(103)로 정상 거절하는데, 클라 화면에는 "알 수 없는 오류가 발생했습니다. (코드 103)" 이 뜬다

클라 ResultMessages.cs에 103 문구가 없어서다. 클라 몫이며 T-064가 맡는다.

2-2. 값을 바꾸면 재현된다 — 이중 상수

  1. 서버 User.Cheat.cs:13의 CheatMaxCharacterCount를 10 → 20으로 고친다
  2. 클라는 CheatWindow.cs:25의 10을 그대로 쓴다
  3. 치트창이 11개째부터 막는다. 서버는 20까지 받는데 클라가 먼저 거절한다
  4. 반대로 서버를 10 → 5로 줄이면, 클라는 10까지 보내고 서버가 InvalidCheatArgs로 거절한다

같은 일이 인벤토리 200 · 뽑기 1·10 · 속도 스케일 1000에서도 일어난다.

2-3. 지금은 우연히 맞다 — 인벤토리 프레임 수

씬을 실측하면 칸 프레임이 정확히 200개다(DesktopWindow_Control.unity, Slot (N) 200개). 서버 StorageCapacity = 200과 지금은 일치한다. 그러나 어느 쪽을 바꿔도 맞춰 주는 장치가 없다. 프레임보다 많아지면 StorageGridPresenter.cs:394에서 경고 로그만 남고 뒤가 잘린다 — 사용자에게는 "아이템이 사라졌다"로 보인다.

2-4. 검사 자체가 없다 — 채취·치트

인벤토리가 가득 찬 계정에서 채취를 돌리면 새 자원 종류가 한도를 넘겨 계속 쌓인다. 201번째부터는 클라 격자에 프레임이 없어 보이지 않는다.


3. 영향 범위

영역 무엇이 걸리나
서버 User.Storage · User.Cheat · User.Mail · GachaService · ShopService · WorkStationSlot · Global — 상수 소유와 한도 검사 적용 지점
클라 CheatWindow · GachaPresenter · WorkStationProgress · WorkStationSelectPresenter · IndustryLabel · RarityPalette · ResultMessages · 인벤토리 격자 프레임
기획 데이터 GameDesign/Excel/ — 상수 시트가 없다(16개 파일 중 해당 없음). Enum.xlsx에 표시 이름 컬럼 추가 여부
문서 거래 3.1(판매가 비율) · 게임 UI 2.3(인벤토리 가득) · 우편 1장

4. 근본 원인 분석

네 갈래이고, 해결 방법이 서로 다르다. 한 덩어리로 보면 엉뚱한 곳에 시트를 들이대게 된다.

원인 A — 정책값의 소유자가 정해져 있지 않다

"인벤토리 200칸"은 기획이 정하고 서버가 판정하고 클라가 그리는 값이다. 그런데 지금은 서버 const와 클라 const가 각자 있고, 엑셀에는 자리가 없다.

⚠️ 클라가 엑셀을 직접 고치는 것으로는 풀리지 않는다. 파이프라인이 Excel/*.xlsx → 서버 GameData/*.cs 생성 → Unity 미러 방향이고, GameDesign/CLAUDE.md가 "엑셀만 앞서 나가면 미구현이 아니라 오동작이 된다 — 그 시트를 읽는 서버 코드를 같은 작업 단위로 본다" 로 못 박고 있다. 마커 행(Ref·Default(Null))이 fail-fast라 컬럼을 잘못 두면 서버가 기동에서 터진다. 게다가 클라가 시트 값을 고쳐도 서버 판정은 const를 계속 쓴다 — 어긋남이 한 겹 늘 뿐이다. → 소유는 서버, 클라는 받아서 그린다.

원인 B — enum 원소 수를 손으로 세어 복제했다

EquipSlotCount = 4는 EEquipSlot의 None 제외 개수를, AptitudeLabel.Count = 5는 EIndustryType의 None 제외 개수를 사람이 세어 적은 값이다. enum은 서버가 소유하고 미러되므로, enum에 원소가 늘면 클라 상수가 조용히 틀린다.

이건 시트로 풀 문제가 아니다. 값이 enum에 이미 있으므로 파생시키면 된다 — Enum.GetValues(typeof(EEquipSlot)).Length - 1. 클라 몫이고 서버에 요청할 것이 없다.

원인 C — 단위 스케일을 양쪽이 각자 정의했다

WorkSpeedScale = 1000(서버)과 UnitsPerSecondAtBaseSpeed = 1000f(클라)는 같은 약속이다. BaseCycleSeconds = 30도 클라 진행바가 알아야 한다. 지금은 클라가 역산하고 있는데, T-055 기준으로 2026-09-20부터 이 역산이 실제로 틀린다(속도 특성이 열렸다).

스케일 자체는 코드에 둬도 되지만, 역산 대신 서버가 명시 필드로 내려주는 쪽이 맞다.

원인 D — 한도 검사가 획득 경로 전체를 덮지 않는다

HasStorageFor 호출처는 뽑기·상자 개봉·우편 수령 셋뿐이다. 채취 정산과 치트 지급에는 검사가 없다 — 방침이 정해지지 않아서이지 빠뜨린 것이 아니다.


5. 관련 코드 · 데이터 위치

5-1. 양쪽에 같은 숫자가 두 번 박힌 것 (원인 A)

값 서버 클라
치트 캐릭터 상한 10 Server/WSGameServer/User/User.Cheat.cs:13 Assets/Scripts_Client/Editor/cheat-console/CheatWindow.cs:25 — 주석 "서버와 같다"
치트 정산 판정 100 User.Cheat.cs:16 CheatWindow.cs:26 — 주석 "서버와 같다"
뽑기 허용 횟수 1·10 Gacha/GachaService.cs:16-17 UI/Market/GachaPresenter/GachaPresenter.cs:167
인벤토리 칸 200 User/User.Storage.cs:6 씬 프레임 200개 + 주석 2곳(ResourceSlotSource.cs:18 · StorageGridPresenter.cs:281)

5-2. 서버만 알고 클라가 역산·추정하는 값 (원인 A·C)

값 위치 클라가 왜 필요한가
우편함 상한 100 User/User.Mail.cs:8 우편함 화면의 "가득 참" 표시 (T-083)
상자 최대 개봉 99 Gacha/GachaService.cs:20 개봉 입력 상한 (T-033)
기본 주기 30초 User/WorkStation/WorkStationSlot.cs:11 진행바 역산
속도 스케일 1000 WorkStationSlot.cs:14 WorkStationProgress.cs:19의 1000f
전역 배수 1.0 Common/Global.cs:14 효율 표시 — T-004로 1.0 복귀(2026-10-01) · T-055
판매가 비율 100% Shop/ShopService.cs:15 (SellRatePermille) 판매 예상 금액
시작 캐릭터 TID 1001 User/User.Character.cs:13 온보딩 (T-019)
넘침 우편 템플릿 TID 2 Common/MailCatalog.cs:13 Mail.xlsx의 행을 코드가 손으로 지목

5-3. enum 원소 수를 손으로 센 것 (원인 B — 클라 몫)

값 클라 원본
EquipSlotCount = 4 WorkStationSelectPresenter.cs:271 EEquipSlot(PacketEnum.cs:80) None 제외
EquipFilterCount = 5 WorkStationSelectPresenter.cs:274 EIndustryType None 제외
AptitudeLabel.Count = 5 UI/Shared/AptitudeLabel.cs:12 〃
DefaultIndustryLevel = 1 WorkStationSelectPresenter.cs:295 서버 WorkStationSlot.cs:30

5-4. 표시 이름이 코드 사본인 것 (원인 A — 옛 T-047)

Assets/Scripts_Client/UI/Shared/IndustryLabel.cs · RarityPalette.cs — 엑셀의 한글 이름을 클라가 복사해 들고 있다.

5-5. 한도 검사 (원인 D)

경로 상태 위치
뽑기 ✅ 거절 GachaService.cs:59
상자 개봉 ✅ 넘치면 우편 보관, 우편함도 차면 거절 GachaService.cs:112-118
우편 수령 ✅ 원자적 거절 User.Mail.cs:147
채취 정산 ✅ 새 종류는 버림, 진행은 계속 (2026-09-26 · 가) User.WorkStation.SettleWorkStation
치트 지급 ✅ 거절 (2026-09-26, #40 · 실플레이 확인) User.Cheat.cs

검사 함수 자체는 서 있다 — User/User.Storage.cs:22 HasStorageFor(itemTids, characterCount, equipCount, freedItemTid).


6. 제안하는 해결 방안

확정안 (2026-09-26 구현) — 소유는 서버, 클라는 같은 값을 읽는다

GameDesign/Excel/Constants.xlsx (ConstantsTable)   ← 기획이 값을 정한다
        │  generate-tables.ps1
        ├─→ Server/GameData/Constants.cs          ← 행 하나 = 속성 하나. 서버·Unity(미러) 공용
        ├─→ Server/Shared/Data/ConstantsTable.bytes
        └─→ Assets/StreamingAssets/Data/ConstantsTable.bytes
  • 시트: Constants.xlsx / ConstantsTable / Name · Value · Description — 이름이 곧 키다(TID 없음)
  • 값은 전부 long. 소수는 천분율 정수로 접고 이름 끝에 Permille을 붙인다(판매가 100% → SellRatePermille = 1000)
  • 생성기가 행마다 속성을 만든다 — Constants.StorageCapacity. 행을 지우면 쓰던 코드가 컴파일에서 깨지고, 이름이 PascalCase 식별자가 아니거나 중복이면 파이프라인이 멈춘다(ExcelGenerator/ConstantsGenerator.cs)
  • 서버는 기동 때 모든 상수를 한 번 읽어 본다(ConstantsCheck) — 코드와 .bytes 판이 다르면 거기서 멈춘다
  • 클라도 같은 Constants를 쓴다 — GameTable.LoadAll 뒤에 Constants.Xxx로 읽으면 된다

대안 A — 로그인 응답 패킷에 실어 내린다

S_LoginResponse에 상수 묶음을 넣는다.

  • 👍 계정·서버 환경마다 다른 값(전역 배수 6.0 같은 개발 중 임시값)을 반영할 수 있다
  • 👎 값 하나 늘 때마다 패킷 수정 + 미러 커밋이 필요하다 — 시트보다 비싸다
  • 판단: 계정마다 달라질 수 있는 값만 이쪽, 나머지는 시트. 둘을 섞어 쓰는 게 맞다

대안 B — 지금처럼 코드에 두되 클라만 서버를 따른다

  • 👍 작업량이 가장 적다
  • 👎 기획이 값을 못 만진다. 밸런스 수치가 코드 안에 있으면 매번 서버 빌드가 필요하다
  • 판단: 인벤토리 200처럼 기획이 정할 값에는 부적합. 순수 스케일(ChanceScale 등)에만 남긴다

무엇을 시트에 넣지 않을 것인가

  • 순수 단위 정의 — ChanceScale = 1_000_000 · WorkSpeedScale · MinWorkSpeed. 밸런스가 아니라 약속이다
  • enum 원소 수 — 5-3은 시트가 아니라 코드에서 파생시킨다
  • 밸런스 테이블 — 이미 각자 시트가 있다(IndustryLevelTable 등). 여기 들어갈 것은 "클라·서버 양쪽이 알아야 하는 단일 값" 뿐이다

⚠️ YieldPerJudge = 1(WorkStationSlot.cs:27)은 경계선이다. 지금은 단위처럼 보이지만 T-009(산업별 회당 산출)가 정해지면 산업별 밸런스 값이 된다 → 그때는 Constant가 아니라 산업 시트로 간다.


7. 처리 항목 — 파트별

🔧 서버 파트 (wlsdn2749)

  • [x] 상수 시트 결정 — Constants.xlsx / ConstantsTable / Name · Value · Description, 값은 long (2026-09-26 사용자 확정)
  • [x] 생성기가 Constants 클래스를 만든다(행 = 속성) + 기동 검사 ConstantsCheck
  • [x] 5-1의 4개 값을 시트로 이관 (치트 10 · 치트 100 · 뽑기 1·10 · 인벤토리 200)
  • [x] 5-2 중 기획 수치를 시트로 — 우편함 100 · 상자 99 · 판매가 비율 + 경매·거래소 수치 7개
  • [x] 5-2의 나머지 분류 — 기준 주기 30초·속도 스케일 1000은 시트로 (BaseCycleSeconds · WorkSpeedScale, 2026-09-26 사용자 결정). 전역 배수 6.0은 개발 중 임시값이라 패킷 쪽 — T-055·T-004로 넘긴다
  • [x] 클라 추가 요청(#34 코멘트) 반영 — DefaultIndustryLevel · EnchantBaseLineCount · EnchantMaxLineCount · MarketMaxBuyCount · MarketPriceLevels · DefaultCharacterTid (2026-09-26). YieldPerJudge는 T-009가 정해지면 산업 시트로 가므로 제외, 우편 템플릿 TID(2 · 3~7)는 코드가 구조적으로 지목하는 행이라 코드에 둔다
  • [x] 채취 정산에 HasStorageFor 적용 — (가) 새 종류 산출을 버린다 (2026-09-26)
  • [x] 치트 지급의 한도 적용 여부 결정 + 구현 — 3종 모두 StorageFull로 막고 우회 명령은 두지 않는다 (2026-09-26 · 이슈 #40)
  • [ ] 산업·희귀도 한글 표시 이름을 엑셀로 (옛 T-047)
  • [x] 테스트(server-tdd) + 프로토콜/GameData 미러 커밋 (T-025 사고 재발 방지)

📋 기획 파트 (Gyubin-Han)

  • [x] 인벤토리가 가득 찼을 때 채취를 어떻게 할 것인가 — (가)로 결정 (2026-09-26, 서버 wlsdn2749) — 셋 중 택1 - (가) 그 판정의 산출을 버린다 (진행은 계속) - (나) 그 슬롯을 멈춘다 — 게임 UI 2.3의 "인벤토리 가득 → 위젯 표시(진행이 멈추므로)" 와 맞는다 - (다) 한도를 넘겨도 그대로 쌓는다 (클라에서 안 보이는 것은 감수) > 우편 넘침 보관은 채취를 이미 대상에서 뺐다 (우편 1장 13번) > — 판정마다 우편이 쌓이기 때문이다. 그래서 "우편으로"는 선택지가 아니다
  • [ ] 인벤토리 한도 200을 확정값으로 볼 것인가 — 늘릴 계획이 있으면 시트가 더 급해진다
  • [ ] 판매가 비율 100% 를 유지할지 (거래 3.1은 "서버 상수 한 곳"으로 확정된 상태)
  • [ ] 상수 시트에 기획이 직접 만질 값의 범위를 정한다 (어디까지 기획 소관인가)

🎨 클라 파트 (JeongTaeWoong99 — 이 일감의 요청자)

서버·기획 결정을 기다리지 않고 지금 한다.

  • [x] enum 원소 수를 파생으로 교체 (5-3의 4곳) — 서버에 요청할 것이 없다 (2026-09-25) EquipSlotCount · EquipFilterCount · AptitudeLabel.Count(→ SlotView.AptitudeCount)를 Enum.GetValues(...).Length - 1로. ⚠️ DefaultIndustryLevel = 1은 enum이 아니라 파생할 원본이 없다 — 서버 상수라 5-1·5-2처럼 시트·패킷 쪽으로 간다
  • [x] 인벤토리 프레임 수 ↔ 서버 한도 불일치 검사를 둔다 (2026-09-25 · StorageGridPresenter.CacheFrames — 서버 한도는 아직 클라 사본 ServerStorageCapacity와 대조한다) (프레임은 씬 오브젝트라 시트로 옮길 수 없다. 지금 200 : 200으로 일치)
  • [x] 시트가 서서 CheatWindow · GachaPresenter · StorageGridPresenter를 Constants 조회로 교체 (2026-09-26) 치트창은 Play 전(테이블 적재 전)에도 그려져 그때는 슬라이더 상한을 1로 둔다
  • [x] 속도 스케일 1000 · DefaultIndustryLevel → Constants.WorkSpeedScale · Constants.DefaultIndustryLevel로 교체 (2026-09-27) 배율 표시 3곳(WorkStationSlotView · CharacterSlotSource · 효율 계산 FormatSpeed·기대 속도)이 스케일이었다. ⚠️ WorkStationProgress의 1000은 스케일이 아니었다 — 작업량 ÷ 속도 = 밀리초라 스케일은 이미 지워지고, 남은 1000은 ms→초다. MillisecondsPerSecond로 이름만 바꿨다
  • [x] StorageFull(103) 안내 문구 → T-064에서 따로 한다 (2026-09-25 문구 추가)

8. 검증 방법

무엇 어떻게
이중 상수가 사라졌나 시트에서 값 하나를 고치고 파이프라인을 돌린다 → 서버·클라 둘 다 바뀐다. 코드에 같은 숫자가 두 번 남아 있지 않다
없는 키를 막나 시트에서 키 한 줄을 지우고 서버를 띄운다 → 기동에서 즉시 실패해야 한다(런타임 0이 아니라)
채취 한도 자원 200종 계정으로 채취를 돌린다 → 정한 방침대로 동작 (server-tdd 테스트 + 치트 정산으로 재현)
치트 한도 인벤토리를 채운 뒤 GiveItem/GiveCharacter/GiveEquip — ✅ 2026-09-26 캐릭터 200명 초과 계정에서 GiveCharacter 거절 확인
프레임 불일치 서버 한도를 201로 두고 클라를 띄운다 → 경고가 뜬다(지금은 조용히 잘린다)
enum 파생 EEquipSlot에 원소를 하나 더한 뒤 클라를 띄운다 → 칸 수가 따라 늘어난다
이름 출처 엑셀에서 산업 이름을 바꾼다 → 클라 화면이 따라 바뀐다. IndustryLabel.cs가 삭제돼 있다

9. 예상 리스크

리스크 내용 완화
🔴 파이프라인 실패 마커 행(Ref·Default(Null))이 fail-fast라 컬럼을 잘못 두면 generate-tables.ps1이 통째로 실패한다 — 다른 테이블 생성까지 막힌다 시트 구조를 먼저 확정하고, 값 한 줄로 파이프라인을 먼저 통과시킨 뒤 나머지를 채운다
🔴 미러 누락 서버만 고치고 Assets/Scripts_Server/ 미러를 안 올리면 클라 컴파일이 깨진다 (T-025 전례) 미러 커밋을 완료 조건에 넣었다 (7장 서버 마지막 항목)
🟡 문자열 키 확산 Get("StorageCapacity")가 코드 곳곳에 흩어지면 오타가 런타임까지 산다 enum·static readonly로 잠근다 (6장)
🟡 시트 범위 비대 "공용 값"의 선이 흐려지면 밸런스 테이블이 전부 흘러든다 8장 기획 항목에서 범위를 먼저 긋는다
🟡 전역 배수 6.0 이 값이 시트로 가면 배포 전 1.0 복귀(T-004)를 잊기 쉬워진다 — 코드에 있으면 눈에 띈다 시트로 옮기더라도 T-004를 배포 게이트로 유지. 패킷 쪽이 나을 수 있다
🟢 채취 (나) 선택 시 슬롯이 멈추면 방치형의 전제가 깨진다 — 자리를 비운 사이 아무것도 안 돈다 클라 위젯 알림이 함께 가야 한다 (T-071)

완료 조건

  1. 클라·서버가 공유하는 값이 한 곳에 있고 양쪽이 거기서 읽는다. 값을 하나 고치고 파이프라인을 돌리면 둘 다 바뀐다.
  2. 인벤토리가 가득 찬 계정에서 채취·치트가 정한 대로 동작하고 테스트가 그것을 확인한다.
  3. 클라에 IndustryLabel.cs·RarityPalette.cs의 이름 사본이 없다.
  4. 클라 프레임 수와 서버 한도가 어긋나면 소리가 난다.

막고 있는 것 / 선행 일감

  • 없다. 다만 이 일감이 정해지기 전에는 T-063의 "한도 값의 거처"가 미정으로 남고, T-083은 우편함 상한 100을 클라가 다시 손으로 적게 된다.

관련 일감

일감 관계
T-064 StorageFull 문구 — 클라 몫. 이 일감을 기다리지 않는다
T-004 전역 배수 6.0 → 1.0 (배포 게이트)
T-055 속도 보정을 명시 필드로 — 원인 C와 같은 뿌리
T-083 우편함 상한 100이 필요하다
T-009 YieldPerJudge의 종착지
T-077 · T-047 · T-084 이 일감으로 흡수됨

관련 이슈

  • 이슈 #40 — 치트 지급 3종의 한도 검사 (2026-09-25 · 서버 · 2026-09-26 종료). 원인 D의 치트 쪽만 떼어 냈다 — 채취 쪽은 기획 결정을 기다리지만 치트는 아니다
  • 이슈 #34 — 상위 이슈 (2026-09-24 · 서버 wlsdn2749 · 기획 Gyubin-Han). 본문은 짧게 두고 상세는 이 문서를 가리킨다
  • 이슈 #32 — StorageFull(103) 안내는 T-064에서 따로 한다

관련 커밋

  • 31540c3 fix: 치트 지급이 인벤토리 한도를 넘지 않게 막는다 (#40)