흡수한 일감 — T-077(공용 상수 시트) · T-047(이름 출처) · T-084(채취·치트 한도). 셋은 같은 뿌리라 따로 굴리면 서로를 기다린다 — 이 일감 하나로 합쳤다.
1. 문제 요약
클라와 서버가 같은 값을 각자 자기 코드에 들고 있다. 어느 쪽이 권위인지 정해져 있지 않다.
전수 조사(2026-09-24) 결과 양쪽에 같은 숫자가 두 번 박힌 곳 6건, 서버만 알고 클라가 역산하는 값 6건, enum 원소 수를 손으로 센 곳 4건이 나왔다. 여기에 인벤토리 한도 검사가 획득 경로 전체에 걸리지 않은 것이 겹친다.
한쪽만 고치면 컴파일도 통과하고 로그도 안 남은 채 조용히 어긋난다. 클라가 먼저 막으면 사용자는 영문을 모르고, 서버만 막으면 클라는 보낼 수 없는 요청을 계속 그린다.
2. 재현 방법 · 발생 조건
지금 당장 터지는 버그가 아니라 값을 고치는 순간 터지는 구조다. 셋 다 재현된다.
2-1. 지금도 재현된다 — 인벤토리 가득 참 안내
- 캐릭터를 200개 보유한 계정으로 로그인
- 캐릭터 뽑기를 누른다
- 서버는
StorageFull(103)로 정상 거절하는데, 클라 화면에는 "알 수 없는 오류가 발생했습니다. (코드 103)" 이 뜬다
클라
ResultMessages.cs에 103 문구가 없어서다. 클라 몫이며 T-064가 맡는다.
2-2. 값을 바꾸면 재현된다 — 이중 상수
- 서버
User.Cheat.cs:13의CheatMaxCharacterCount를 10 → 20으로 고친다 - 클라는
CheatWindow.cs:25의10을 그대로 쓴다 - 치트창이 11개째부터 막는다. 서버는 20까지 받는데 클라가 먼저 거절한다
- 반대로 서버를 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) |
완료 조건
- 클라·서버가 공유하는 값이 한 곳에 있고 양쪽이 거기서 읽는다. 값을 하나 고치고 파이프라인을 돌리면 둘 다 바뀐다.
- 인벤토리가 가득 찬 계정에서 채취·치트가 정한 대로 동작하고 테스트가 그것을 확인한다.
- 클라에
IndustryLabel.cs·RarityPalette.cs의 이름 사본이 없다. - 클라 프레임 수와 서버 한도가 어긋나면 소리가 난다.
막고 있는 것 / 선행 일감
관련 일감
| 일감 | 관계 |
|---|---|
| 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에서 따로 한다
관련 커밋
31540c3fix: 치트 지급이 인벤토리 한도를 넘지 않게 막는다 (#40)