← 목록
기타 2026-07-27 14KB 읽기 13분

크라우니클라우드 대용량 JSON 본문/파일 I/O — 버퍼 기반 전환 (P2-⑧)

개요

cloud.crowny.org(:9611)의 /api/merge·/api/store·/api/files 라우트가 VM 문자열 65535B 하드캡(옛 본문읽기() 내부 64000B 체크, 읽기() FREADALL 고정버퍼)에 걸려 16KB~154KB 본문 POST가 전부 400으로 실패하던 문제를 버퍼 opcode(블롭스트림.한선의 업로드 패턴과 동일 계열) 기반으로 수정. 작업 중 캡 제거 자체보다 훨씬 심각한 두 가지 잠복 O(n²) 버그VM 해시맵의 문자열-값 저장 결함을 실측으로 발견·수정.

무엇을 했는지

1. 본문 수신 — 본문버퍼읽기(연결, 원시, maxBytes) 신설

  • 본문읽기()는 TCP 청크를 누산 = 누산 + 청크 문자열 +(STR_CAT)로 누적 →
crownyc.c case 340이 la+lb >= STR_MAX_LEN*2(131072B)에서 조용히 핸들 -1 반환, 이후 누적이 통째로 소실. 게다가 그 위에 예상길이 > 64000 하드캡이 별도로 걸려 있어 호출자가 넘긴 maxBytes(store 524288, merge 4194304)와 무관하게 64KB 초과 본문을 전부 거부(400)하고 있었다.
  • 신규 함수는 버퍼생성/버퍼추가(399, memcpy+동적 realloc, 캡 없음)로 누적 후
1회만 버퍼문자열(396, str_new 직접 호출 — 로컬 고정버퍼 없음, 풀 동적성장)로 변환. 읽기횟수 상한도 고정 64회 → 예상길이/4096 + 32(크기 비례)로 교체. Content-Length 미달 시 빈 문자열 반환(원자성 보장은 유지).

2. 파일 재읽기 — 파일읽기버퍼(경로) 신설

  • 읽기()(FREADALL, opcode 249)는 char rbuf[STR_MAX_LEN](65536B) 고정 스택버퍼에
fread하는 구현이라 65535B 초과 파일은 에러 없이 조용히 절단됨(merge/store/files GET에서 저장된 JSON을 되읽을 때 대형 파일이 깨진 채 응답).
  • 버퍼파일읽기(874, BUF_FILE_READ — fseek(SEEK_END)로 실제 크기 확보 후 그 크기만큼
fread, 캡 없음) + 버퍼문자열 1회 변환으로 교체. merge GET·POST의 기존내용 읽기, files처리 전부 이걸로 전환.

3. 응답 — JSON200버퍼(연결, 데이터) 신설

  • 기존 JSON200()"HTTP 헤더..." + 데이터로 헤더+본문을 STR_CAT 결합 — 데이터가
~131KB를 넘으면 역시 조용히 붕괴. 헤더/본문을 버퍼로 결합 후 1회 문자열 변환· TCP쓰기(494, write(fd,str_ptr,str_len) 직접 — 이 자체는 캡 없음 확인)로 교체.

4. merge 저장 — 버퍼파일쓰기 직결

  • 저장 JSON({"key":...,"items":...,"deleted":...})도 버퍼로 결합 후
버퍼파일쓰기(857, BUF_FILE_WRITE — 자체 .tmpPID+rename 원자저장 내장) 직결로 변경. 기존의 수동 쓰기(tmp,...) + 체계("mv -f ...") 셸 왕복 제거.

5. (실측 도중 발견) JSON 파싱 헬퍼의 O(n²) — JSON배열추출/항목수집 재작성

  • 위 캡 제거만으로는 3000항목(~283KB) merge POST가 완료되지 않고 무한정 hang
(CPU 100% 근접, 2분+ 경과해도 미종료)했음. 원인: JSON배열추출/항목수집글자(json, i)로 문자 인덱스를 순회하는데, 글자()(STR_GETC)는 매 호출마다 utf8_charcount로 문자열 처음부터 오프셋을 재계산(O(pos), crownyc.c 확인) — 깊이추적 루프가 배열 전체를 훑으므로 전체가 O(n²)로 폭발(283937² ≈ 8×10¹⁰). 이전엔 본문 캡(64KB)이 이 함정을 가려왔던 것.
  • 문자열버퍼(397, BUF_FROMSTR)로 1회 변환 후 버퍼바이트읽기(851, BUF_GETBYTE,
O(1) 오프셋)로 구조 스캔(ASCII 델리미터만 비교하므로 UTF-8 내용이 섞여도 안전 — 연속바이트/리드바이트는 항상 0x80 이상이라 34/92/91/93/123/125와 충돌 없음)하도록 재작성. 원소 슬라이스도 버퍼잘라+버퍼문자열(O(원소길이))로 교체. 전체 O(n)화.

6. (실측 도중 발견) LWW dedup의 O(n²) — 원소인덱스/툼스톤판정 우회

  • 위 수정 후에도 3000항목 요청이 CPU 풀가동 상태로 2분+ 계속됨. 원인: LWW 병합의
dedup 로직이 원소인덱스(cid, 결과id, 결과수)매 항목마다 지금까지 쌓인 결과배열을 처음부터 선형탐색(항목수 n에 대해 O(n²), 3000개면 최대 450만 비교) 하고 있었다. 툼스톤판정도 삭제목록 길이만큼 매번 선형탐색(O(n×nd)).

7. (실측 도중 발견) VM 해시맵의 문자열-값 저장 결함 — 정수 인덱스 맵으로 우회

  • 1차 수정: 맵있나/맵꺼내/맵넣어(751/414/413, 3진 해시 O(1) 평균)로 id별 dedup을
구현하면서 맵 값에 JSON 객체 문자열을 직접 저장(맵넣어(robj맵, id, cobj)). 단독 O(n²) 문제는 해결됐으나(3000항목 1.8초), 3000개 규모 문자열-값 맵에서 드물게(체감 30~50%/요청) 맵있나()==1인데 맵꺼내()가 빈 문자열/손상값을 반환 하는 현상을 반복 실측(응답 JSON에 },,{ 빈 슬롯 → JSON 파싱 실패). 값 유효성 검증(글자수>0) 안전망을 추가해도 재발 — 안전망 자체가 호출하는 맵꺼내도 동일 결함의 영향을 받는 것으로 추정.
  • 격리 테스트로 원인 범위를 좁힘: 단일 맵에 정수값 3000개 왕복 → 0/3000 실패.
  • 2개 맵(정수값) 동시 성장 3000개 → 0/6000 실패. 문자열값(버퍼 경유 생성) 맵 1개 → 0/3000 실패(간단한 반복 조건에서는 재현 안 됨). 실제 merge처리(5개 맵 동시 성장 + 11개 배열 + 무거운 JSON 파싱 병행)에서만 재현되는 것으로 보아 문자열-값 다중 해시맵 동시 성장 경로의 희귀 VM 결함으로 잠정 결론. crownyc.c 자체 디버깅/수정은 30+ 서비스가 공유하는 VM 코어라 이번 작업 범위 밖으로 판단해 보류.
  • 최종 설계: 맵 값은 항상 "정수 인덱스"만 사용(ridx맵/didx맵), 실데이터
  • (ts/obj)는 원래 방식대로 plain 배열(결과id/결과ts/결과obj, dresid/drests/dresobj)에 저장 — 꺼내()/설정()은 O(1)이고 격리 테스트로 안전함이 확인된 경로. dedup 판단(있음/없음, 어느 인덱스인지)만 맵이 O(1)로 대신하고, 실제 값 저장/조회는 전부 배열로 — 원소인덱스()의 O(n) 선형 탐색만 맵 O(1) 조회로 치환한 셈. 안전망(_병합폴백탐색) 함수는 최종안에서 불필요해져 제거.

    검증 결과 (라이브 9611, 원문)

    1. health: {"status":"ok",...} HTTP 200.
    2. 3000항목(283937B) merge POST 왕복 5회 연속: 전부 HTTP:200, items=3000,
    소요시간 1.7~1.8초(안정). (수정 전: 400 즉시거부 → 캡만 제거 시 2분+ hang → O(n²) JSON파싱 수정 후에도 2분+ hang → dedup O(n²) 수정 후 문자열-맵 결함으로 간헐적 손상 → 최종 정수-인덱스 맵 설계로 5/5 안정)
    1. 중간크기 회귀(이전 400): 16KB(153항목)/32KB(306항목)/60KB(574항목)/150KB
    (1435항목) 전부 HTTP:200, item수 일치, 0.1~0.85초.
    1. 대용량: 884KB(3000항목, padding) HTTP:200 4.5초. 1.78MB(3000항목)
    HTTP:200 9.7초. 4.38MB(>4MB 상한) → HTTP:413 {"error":"too large (>4MB)"} 즉시 거부(1ms).
    1. files GET 왕복: merge 3000항목 POST 후 /api/files GET → items=3000
    전량 일치.
    1. merge GET(?key=) 왕복: 3000항목 POST 후 명시적 GET → items=3000, 283901B
    응답, 잘림 없음.
    1. store 회귀: 소용량 POST/GET 정상, 300KB POST→GET 왕복 blob len=300000
    (절단 없음, 이전엔 65535B에서 잘렸을 케이스), 600KB POST → `HTTP:413 {"error":"too large (>512KB)"}`.
    1. 真LWW 병합 의미론: 초기 2항목 저장 → 1항목 갱신+1항목 신규+1항목 삭제
    → 최신 상태 정확 반영 → 구ts(500) 갱신 시도는 신ts(2000)보다 오래돼 정상 거부(값 변경 없음) — 3단계 시나리오 전부 기대값과 일치.
    1. 인증/RCE 화이트리스트 회귀: owner 헤더 없이 POST → 401. key에 셸 메타문자
    ("; touch ...; echo ") 주입 시도 → 400 "bad key", /tmp/PWNED_p28 파일 생성 안 됨. owner 헤더에 동일 주입 → 401(인증 실패로 처리), 파일 생성 안 됨. blob 경로 traversal(../../../etc/passwd) → 라우트 미매치로 랜딩페이지 반환 (파일 미노출).
    1. blob 업로드 회귀: 소용량 업로드 → sha256 id 정상 반환.
    2. 이웃 생존: 9611/9616/9615/9603 전부 /api/health 200 응답.

    배포 사고 및 조치 (발견·즉시수정)

    재기동 시 nohup env SERVICE=cloud PORT=9611 crownyc run 서비스서버.toaucd(서비스 루트) 없이 실행 → 루트 = 현재경로()가 당시 셸의 cwd(빌드 작업 중이던 /Users/ef/CrownyOS/crownyc)로 잘못 고정되어, 첫 6~7회 검증 트래픽의 테스트 데이터가 /Users/ef/CrownyOS/crownyc/data/merge|store/testowner_*에 잘못 기록됨(실제 프로덕션 데이터 /Users/ef/crowny-services/data/는 훼손되지 않음 — 단지 새 쓰기가 엉뚱한 위치로 감). 발견 즉시: ①잘못된 위치의 테스트 디렉토리 전량 삭제 ②cd /Users/ef/crowny-services 후 재기동(cwd 확인: lsof -p <pid> | grep cwd/Users/ef/crowny-services 확인) ③전체 검증 1~11 항목을 올바른 루트에서 재실행해 재확인 ④세션에서 생성한 프로덕션 경로의 테스트 데이터(p28final_*, p28smoke_* 등)도 전부 정리. 최종 PID=81063, SERVICE=cloud PORT=9611, cwd=/Users/ef/crowny-services.

    컴파일/재기동 이력

    • STRICT 컴파일(CROWNY_STRICT=1 hanseonc_high) 매 수정마다 실행 — 신규 경고
    0건 확인(기존 소켓생성 arity 경고 2건만 잔존, 삽입으로 인한 줄번호 이동뿐 내용 불변 — pre-edit 백업본과 diff로 확인).
    • 백업: 서비스서버.한선.bak-대용량IO, 서비스서버.toau.bak-대용량IO
    (/Users/ef/crowny-services/).
    • 최종 PID 81063 (v6, 정수-인덱스 맵 설계), SERVICE=cloud PORT=9611,
    cwd=/Users/ef/crowny-services.

    관련 파일

    • /Users/ef/crowny-services/서비스서버.한선 — 본문버퍼읽기/파일읽기버퍼/
    JSON200버퍼 신설, JSON배열추출/항목수집 버퍼기반 재작성, merge처리 POST의 dedup을 정수-인덱스 맵+배열 조합으로 재작성, store처리/files처리 GET 버퍼 전환.
    • /Users/ef/crowny-services/서비스서버.한선.bak-대용량IO — 작업 전 백업.
    • /Users/ef/crowny-services/블롭스트림.한선 — 참조한 원본 버퍼 업로드/다운로드
    패턴(수정 없음).
    • /Users/ef/CrownyOS/crownyc/crownyc.c — 근거 확인용으로만 읽음(STR_MAX_LEN,
    STR_CAT/BUF_TOSTR/FREADALL/BUF_FILE_READ/BUF_FILE_WRITE/TCP_WRITE/HASH_SET/ HASH_GET/map_block_new 구현). 수정하지 않음(공유 VM 코어, 이번 범위 밖).

    잔여 이슈 / 한계

    1. VM 해시맵 문자열-값 저장 결함 미해결: crownyc.cHASH_SET/HASH_GET
    (opcode 413/414, 3진 해시 선형프로빙+체인성장)이 문자열 핸들을 값으로 저장할 때 대규모(수천 키, 다중 맵 동시성장) 조건에서 드물게 손상된 값을 반환하는 것으로 추정되는 결함을 실측했으나, 근본원인은 미확정(GC 마킹 누락설·체인 경계 버그설 모두 격리 재현 실패 — 재현이 merge처리의 정확한 부하 패턴에서만 발생). 이번 작업은 정수-값 맵만 사용하는 우회 설계로 회피했으며, 향후 다른 서비스가 대규모 문자열-값 해시맵을 새로 도입할 경우 동일 결함을 마주칠 수 있음 — 별도 VM 레벨 조사 티켓 필요(격리 재현 스크립트는 /private/tmp/claude-501/-Users-ef/fb7b6796-b33d-437e-b3dc-49e63bbdcbf6/scratchpad/맵테스트*.한선 에 3종 남겨둠, 재현 실패했지만 출발점으로 참고 가능).
    1. merge 대용량 상한 = 4MB(코드상 본문버퍼읽기(..., 4194304)) — 이 이상은
    413. 병합 로직 자체(JSON 파싱+dedup)는 문자열 값에 의존하지 않는 배열/버퍼 기반이라 이론상 더 큰 상한도 처리 가능하나, 상한을 올리면 처리시간도 선형 증가(1.78MB≈9.7초 실측) — 요청당 처리시간이 게이트웨이/클라이언트 타임아웃과 충돌할 수 있어 4MB 유지. 필요 시 상한 상향은 별도 판단 필요(타임아웃 조정 동반). 2-1. store 상한은 기존 512KB 유지(요청 스펙이 "예 4MB=업로드상한 참고"라 명시했지만 store는 기존 문서화된 512KB 시맨틱을 보존 — merge만 4MB로 확장).
    1. zip처리 함수도 여전히 옛 본문읽기()(64000B 하드캡 포함)를 사용 중 —
    이번 작업 범위(merge/store/files)에는 없었으나 동일 계열 버그가 잠복해 있음이 확인됨. 별도 티켓 권고.
    1. 원소인덱스()/툼스톤판정() 함수는 정의만 남고 미사용(dead code) — 제거하지
    않고 남겨둠(위험 대비 낮은 우선순위, 컴파일/실행에 영향 없음).
    1. dedup 성능은 이제 O(n) 수준이나, 각 항목의 JSON필드/JSON숫자필드
    호출(항목수집 내부)이 여전히 "\"" + 키 + "\":\"" 같은 소규모 STR_CAT을 항목당 2회씩 수행 — 항목이 아주 많아지면(수만 단위) 이 부분도 누적 비용이 된다. 3000~수천 규모까지는 실측 문제 없음(1.7~1.8초).

    크라우니코드 학습

    crownycode-brain.sh learn "크라우니클라우드 대용량 JSON 본문 버퍼IO 캡제거" /Users/ef/crowny-services/서비스서버.한선 실행 완료 (lint 판정: 타/셀코어룰엔진).