Releases: hkjang/umm
Release list
umm v0.74.0
v0.74.0 — 공간을 여는 비용이 제곱으로 메모리를 먹지 않습니다
v0.72.0 은 남의 서버를 기다리는 문제를 걷어냈습니다. 이번에는 남은 하나, 메모가 많아질
때의 문제입니다.
무엇이 비쌌나
캔버스를 열면 메모마다 "이것과 닮은 생각이 몇 개"라는 숫자를 붙입니다. 그 숫자는
PerNoteCounts 가 계산합니다.
재 봤습니다. 32코어, 192차원 로컬 임베딩:
메모 수 시간 메모리
2,000 54ms 17MB
4,000 233ms — (시간만 쟀습니다)
8,000 968ms 260MB
260MB. 요청 하나가, 끝나면 버릴 숫자 배열에.
원인은 모든 쌍의 점수를 표로 만들어 들고 있었던 것입니다. 8천 개면 쌍이 3,200만 개고
float64 로 260MB 입니다. 메모 수의 제곱으로 자랍니다.
고친 것
표를 만들지 않습니다. 한 줄씩 계산하고 버립니다. 한 줄은 메모 수만큼의 float 입니다.
그리고 줄끼리는 서로를 참조하지 않습니다 — 표를 쓸 때는 참조했지만, 이제는 아닙니다. 그래서
코어에 나눠 돌립니다.
메모 수 전 후
2,000 54ms/17MB → 14.7ms/2.1MB
4,000 233ms → 52.6ms
8,000 968ms/260MB → 211ms/5.7MB
숫자는 하나도 바뀌지 않았습니다
이건 반올림 운이 아닙니다. 짚어야 할 두 가지가 있었습니다.
첫째, 한 쌍을 어느 쪽에서 곱하는가. 전에는 대각선 아래쪽 절반을 반대편에서 읽었습니다
(sparse[j]·dense[i]). 지금은 항상 그 줄의 주인에서 곱합니다 (sparse[i]·dense[j]). 두
값이 정확히 같은 이유는, 양쪽 다 0 이 아닌 차원을 오름차순으로 훑고, 상대가 0 인 항은
진짜 0 을 더하기 때문입니다 — float64 누산기는 0 을 더해도 흔들리지 않습니다.
둘째, 합의 순서. 부동소수점 덧셈은 결합법칙이 성립하지 않습니다. 그래서 한 줄의 합은
절대 goroutine 사이에서 합치지 않습니다. 줄 하나는 통째로 한 goroutine 안에서, 예전과
같은 순서로 더합니다. 나누는 것은 줄 단위뿐입니다.
기존 동치성 테스트는 n≤200 까지만 돌고 있었습니다. 분할이 시작되는 크기를 넘지 않아서, 새
경로를 전혀 지나지 않았습니다. n=600 을 추가하고, 그 크기에서 실제로 분할이 일어나는지를
테스트가 직접 단언하게 했습니다 — 임계값을 올리면 이 커버리지가 조용히 사라지기 때문입니다.
CI 에 없던 것
이 변경으로 이 저장소에 동시성 코드가 사람의 경로 위에 처음 올라왔습니다. 그런데 CI 는
-race 를 돌고 있지 않았습니다.
확인 방법은 변이였습니다. 줄을 나누는 보폭을 i += workers 에서 i++ 로 바꾸면, 모든
worker 가 모든 줄을 계산합니다 — 답은 매번 정확히 맞습니다. 같은 칸에 같은 값을 쓰기
때문입니다. 일반 테스트로는 잡히지 않고, -race 만 잡습니다.
그래서 CI 에 넣었습니다 (13초).
경계선
점수가 커트라인에 정확히 걸리면 세는가. 무작위 벡터로는 영원히 안 걸리는 경우라, 손으로
만들었습니다 — 생각 3개면 각 줄의 표본이 2개뿐이라 상대 척도가 안 서고 커트라인이 상수로
떨어지므로, 첫 쌍이 그 상수와 같은 점수가 나오도록 벡터를 짰습니다. 카드에 찍히는 숫자를
가르는 경계라서, 어느 부등호를 썼는지에 맡기지 않고 못 박았습니다.
여전히 제곱입니다
각 생각이 긋는 선은 그 생각의 전체 분포에서 나옵니다. 평균을 알려면 모든 쌍이 필요하고,
그래서 건너뛸 수 있는 쌍이 없습니다. 빨라지고 가벼워졌을 뿐, 차수는 그대로입니다.
손대지 않은 것
Clusters는Cutoff를 거치며 여전히 쌍의 표를 만듭니다. 캔버스를 여는 경로가 아니라
별도 화면이고, 정확히 같은 숫자를 유지하면서 표를 없애려면 점곱을 두 번 하게 되어
느려집니다. 바꾸지 않았습니다.onlyRenderVisibleElements— 여전히 꺼져 있습니다. PNG·PDF 내보내기가 캔버스 DOM 을
그대로 찍기 때문에 화면 밖 메모가 조용히 빠질 수 있고, 확인하지 않았습니다.
What's Changed
Full Changelog: v0.73.0...v0.74.0
umm v0.73.0
v0.73.0 — 보던 자리와, 원할 때만 붙잡는 격자
같은 부류 제품들(Heptabase · Obsidian Canvas · Scrintal · Miro · tldraw)의 UX 패턴을
훑고 umm에 없는 것만 골랐습니다. 명령 팔레트(Ctrl+K)와 단축키 안내(?)는 이미
있었고, 여러 개 선택해서 지우는 것도 있었습니다. 없던 것은 둘입니다.
공간 안에서 자리를 잃었습니다
v0.72.0에서 공간을 잃는 문제를 고쳤는데, 공간 안에서 자리를 잃는 문제가 남아
있었습니다.
캔버스는 열 때마다 모든 생각을 한눈에 맞췄습니다. 큰 캔버스의 한 귀퉁이를 파고 있다가
오늘의 리뷰를 잠깐 보고 오면, 다시 멀찍이서 시작해야 했습니다.
이제 마지막으로 보던 위치와 배율로 돌아옵니다. 공간마다 따로 기억하고, 브라우저에
둡니다 — 생각에 대한 사실이 아니라 어디를 보고 있었는가이기 때문입니다.
- 처음 여는 공간은 예전처럼 전체를 맞춥니다
- 되감기 중에는 적용하지 않습니다. 과거는 다른 그림이고, 오늘 두고 온 자리를 물려받을
이유가 없습니다
React Flow에게 맡길 수 없었습니다
defaultViewport 는 캔버스가 마운트될 때 읽힙니다. 그 시점에는 어느 공간인지 아직
모릅니다. fitView 는 생각을 다 재고 나서 맞추는데, 그게 복원 효과보다 늦게 도착해서
조용히 덮어썼습니다.
그래서 여는 시야를 우리가 정합니다 — 기억된 게 있으면 그리로, 없으면 우리가 직접
맞춤을 호출합니다. fitView 속성은 뗐습니다.
시험이 이걸 잡았습니다. 처음 두 번은 제 시험이 틀렸습니다 — 합성 휠 이벤트는 캔버스를
움직이지 않았고, 격자 색은 CSS color 가 아니라 SVG의 커스텀 속성으로 들어갑니다. 화면이
실제로 칠하는 값을 읽도록 고친 뒤에야 진짜 결함이 보였습니다.
격자는 원할 때만
끌어 놓은 생각이 격자에 붙습니다. 기본은 꺼져 있습니다.
umm의 태도는 정리는 나중에. 생각부터 붙입니다. 입니다. 늘 붙잡는 격자는 그 태도와
다툽니다. 그래서 켜고 끄는 것이고, 선택은 기억됩니다 — 정리는 한동안 머무는 모드니까요.
켜져 있을 때만 격자 점이 보입니다. 보이는데 아무것도 안 붙으면 장식이고, 안 보이는데
붙으면 조준할 수 없습니다. 그려지는 간격과 붙는 간격은 같은 상수 하나에서 나옵니다.
확인한 것
✓ 나갔다 오면 저장된 위치·배율로 돌아오고, 새 맞춤이 아님
✓ 화면에 있던 값이 그대로 저장됨
✓ 아무도 안 연 공간은 여전히 생각을 찾아 맞춤 — 화면 안에 들어오는지까지
✓ 격자 끄면 끌어 놓은 자리 그대로, 켜면 20의 배수로 붙음
✓ 켜야만 점이 보임
✓ 선택이 새로 고침을 넘어 남음
| 바꾼 것 | 잡은 것 |
|---|---|
| 시야를 저장하지 않음 | 돌아올 자리가 없음 |
| 저장된 시야를 무시 | 매번 전체 맞춤 |
| 첫 열기의 맞춤을 제거 | 새 공간이 빈 땅을 봄 |
| 격자를 항상 켬 | 안 켰는데 붙잡음 |
| 점을 항상 그림 | 안 붙는 격자가 보임 |
| 선택을 기억하지 않음 | 켤 때마다 다시 켜야 함 |
여섯 개 전부 검출.
하지 않은 것
onlyRenderVisibleElements. React Flow는 지금 화면 밖 노드까지 전부 마운트하고,
그게 주석에 적힌 "2,000개 5.5초"의 출처입니다. 켜면 큰 폭으로 빨라질 가능성이 있습니다.
다만 PNG·PDF 내보내기가 화면 밖 메모를 조용히 빠뜨릴 위험이 있습니다 — 내보내기는
캔버스 DOM을 그대로 그림으로 찍기 때문입니다. 확인하지 않았으므로 켜지 않았습니다.
가능성으로만 적어 둡니다.
전체 검증: go test -p 1 ./internal/... ./cmd/... · go vet · gofmt ·
변형 6종 전부 검출 · vitest 158개 · e2e 128개(실제 바이너리·실제 번들, 2건은 전체
실행 부하에서 flaky — 격리 시 통과) · oxlint(종료 코드) · Prettier · tsc · i18n 1000키 ·
PWA precache
What's Changed
Full Changelog: v0.72.0...v0.73.0
umm v0.72.0
v0.72.0 — 내 생각을 여는 데 남의 서버를 기다리지 않습니다
"스페이스 로딩이 너무 느려. 메모가 별로 없어도 느려."
재 봤습니다.
임베딩이 사람의 경로 위에 있었습니다
공간을 연다 → 그동안 바뀐 메모를 전부 색인 → 게이트웨이 왕복 → 화면
메모를 쓴다 → 그 자리에서 벡터 생성 → 게이트웨이 왕복 → 저장 완료
ensureEmbeddings 의 호출자는 listNotes 하나뿐이었습니다. 공간을 여는 것이 색인의
계기였습니다. 그리고 UpsertEmbedding 이 메모 생성·수정에서 동기로 불렸습니다.
게이트웨이가 실패하면 더 나빴습니다 — 같은 읽기가 그 공간의 모든 메모를 로컬로 다시
썼습니다. 여전히 사람이 기다리는 채로.
메모 수와 무관합니다. 메모 세 개짜리 공간도 왕복 한 번은 그대로 냅니다.
실측
호출당 2초 걸리는 임베딩 게이트웨이, 메모 3개짜리 공간:
전 후
쓰기 3 + 읽기 1 6.39s → 0.18s
고친 것
읽기도 쓰기도 sweep에 알리고 즉시 돌아옵니다. 색인은 5초 주기 배경 작업이 하고, 공간을
열거나 메모를 쓰면 그 공간을 먼저 보라고 알립니다. 알림은 막지 않는 힌트라 버퍼가 차면
그냥 버립니다 — 다음 주기가 어차피 같은 메모를 찾기 때문입니다.
안 바뀐 것: AI 제외
제외된 생각은 별도 묶음으로 넘깁니다. 한 묶음에 섞으면 ensureEmbeddings 가 묶음
전체를 로컬로 떨어뜨려서, 제외되지 않은 남의 생각까지 설정한 모델을 못 쓰게 됩니다.
변형 시험에서 이걸 처음엔 못 잡았습니다 — 두 생각을 각각 다른 sweep에서 색인하면 섞일
일이 없어서, 어느 쪽이든 통과했습니다. 둘 다 색인되지 않은 상태에서 한 번에 돌리는
시험으로 바꾸니 잡힙니다.
임베딩 타임아웃을 채팅과 분리했습니다
둘은 기다리는 대상이 다릅니다. Dream을 쓰는 채팅 모델에는 몇 분을 줘도 되지만, 문장 하나를
임베딩하는 건 네트워크 한 홉 뒤의 1밀리초짜리 일이고, 그걸 기다리는 사람은 검색 중입니다.
하나로 묶여 있어서 채팅에 넉넉히 준 시간이 곧 검색이 기다리는 시간이었습니다.
embedding_timeout_seconds, 기본 10초. 안 정하면 채팅 값으로 떨어지지 않습니다 —
그러면 분리한 의미가 없습니다.
일하던 공간으로 돌아옵니다
같이 나온 별개 문제입니다.
내비게이션의 My Space 는 /canvas 를 가리키고, 그 주소는 공간을 지정하지 않습니다.
그래서 누를 때마다 이름 알파벳순 첫 공간이 열렸고, 주소도 /canvas 그대로라 그다음
새로 고침이 또 처음부터 판단했습니다.
ZZZ 공간에서 일하는 중
→ "My Space" 클릭
→ AAA 공간이 열림
→ 주소는 /canvas
→ 새로 고침 → 또 판단
- 주소가 공간을 가리키면 그게 이깁니다
- 아니면 마지막에 보던 공간
- 그것도 없으면 첫 공간
- 그리고 주소를 항상 고쳐 써서 다음 새로 고침이 다시 판단하지 않게 합니다
못 여는 공간이면 그렇다고 말합니다
주소가 가리키는 공간이 지워졌거나 공유가 회수됐으면, 예전에는 조용히 다른 공간을 보여
주면서 주소는 그대로 뒀습니다 — 주소와 화면이 서로 다른 것을 가리켰습니다. 이제 말합니다.
벡터가 최신이어야 하는 기능들
색인이 비동기가 되자 시험이 잡았습니다 — 방금 쓴 메모에 벡터가 없어서 연결 추천 · 아침
브리핑 · 공간 추천이 그 메모를 못 봤습니다. 조용히 빠지는 것이라 더 나쁩니다.
그 셋은 이제 스스로 sweep을 한 번 돌리고 시작합니다. 사람이 의도해서 누르는 기능이고,
정상 상태에선 아무것도 못 찾는 질의 한 번입니다.
확인한 것
✓ 느린 게이트웨이(2s)에서 읽기·쓰기 모두 1초 미만
✓ 읽기가 게이트웨이를 한 번도 부르지 않음
✓ sweep이 색인하고, 두 번째 패스는 빈손 (매번 다시 하지 않음)
✓ 고친 메모를 sweep이 다시 집음
✓ 제외된 생각은 게이트웨이에 안 가고, 로컬 벡터는 받음
✓ 제외된 생각이 옆 생각까지 로컬로 끌어내리지 않음
✓ 임베딩 타임아웃이 채팅 값을 따라가지 않고, 미설정 시 10초
✓ 브라우저: 내비 링크가 일하던 공간으로, 주소가 그 공간을 가리킴
✓ 브라우저: 두 번 새로 고쳐도 같은 공간
✓ 브라우저: 못 여는 공간이면 그렇다고 말함
| 바꾼 것 | 잡은 것 |
|---|---|
| 읽기가 다시 색인 | 읽기가 게이트웨이를 기다림 |
| 쓰기가 다시 동기 색인 | 쓰기가 게이트웨이를 기다림 |
| sweep이 오래된 것을 못 봄 | 고친 메모가 영영 안 잡힘 |
| 제외된 생각을 한 묶음에 | (처음엔 못 잡음 → 시험을 고침) |
| 임베딩 타임아웃이 채팅을 따라감 | 검색이 채팅 시간만큼 기다림 |
| 마지막 공간을 기억 안 함 | 링크가 엉뚱한 공간을 엶 |
| 주소를 안 고쳐 씀 | 새로 고침이 다시 판단 |
| 못 여는 공간을 조용히 교체 | 주소와 화면이 다른 곳을 가리킴 |
| Suspense를 앱 바깥으로 되돌림 | 화면 전체가 사라짐 |
| 목록을 다시 숫자 줄 안으로 | 읽는 순서가 엉킴 |
열 개 전부 검출(하나는 시험을 고친 뒤).
화면이 통째로 사라지지 않습니다
Suspense 가 라우트 트리 바깥에 있었습니다. 그래서 페이지 코드를 불러오는 동안
헤더와 내비게이션까지 언마운트됐습니다 — 화면 전체가 어두워지고 가운데 점이 돌다가,
다시 전부 그려졌습니다. 틀은 바뀐 게 하나도 없는데요.
앱이 재시작하는 것처럼 읽힙니다. 모든 요청이 10ms에 답하는데도 느리게 느껴진 큰
이유였습니다.
안으로 옮겼습니다. 이제 본문 영역만 기다리고, 누르는 순간 내비에서 가려는 곳이 이미
표시되므로 어디로 가는지 보입니다.
오늘의 리뷰 요약이 엉켜 있었습니다
지난밤 umm이 살펴본 것 의 숫자 블록과 그 아래 목록이 같은 Group 안의 형제였습니다.
Group 은 한 줄에 늘어놓고 감싸므로, 키 큰 목록 옆에 다음 숫자가 밀려 붙습니다.
전: 1 아직 정리하지 않은 5 답을 못 찾은 [질문 목록...] 1 기록해 둔 상충
↑ 상관없는 목록 오른쪽에 혼자
후: 1 아직 정리하지 않은 5 답을 못 찾은 1 기록해 둔 상충
질문으로 표시해 둔 것
...
글자는 어느 쪽이나 같아서, 시험은 기하로 확인합니다 — 숫자 라벨들이 같은 세로 띠에
있는지, 목록이 그 전부보다 아래에서 시작하는지.
아직 남은 것
메모가 많아지면 별도로 느려집니다. 카드의 "관련 N" 은 열 때마다 모든 쌍을 비교합니다.
| 메모 | 정상 상태 |
|---|---|
| 2,000 | 0.13s |
| 5,000 | 0.79s |
| 10,000 | 2.9s (그중 2.81s가 이 계산) |
이번 릴리스와는 다른 문제이고, 다음에 따로 갑니다. 조용히 두지 않고 적어 둡니다.
전체 검증: go test -p 1 ./internal/... ./cmd/...(통합 포함) · go vet · gofmt ·
변형 10종 전부 검출 · vitest 158개 · e2e 127개 전부(실제 바이너리·실제 번들) ·
oxlint(종료 코드) · Prettier · tsc · i18n 999키 · OpenAPI 드리프트 · PWA precache
What's Changed
Full Changelog: v0.71.7...v0.72.0
umm v0.71.7
v0.71.7 — 지금 화면으로 다시 쓴 사용자·관리자 가이드
코드는 바뀌지 않았습니다. 이번 릴리즈는 문서입니다.
사용자 가이드와 관리자 가이드에 실려 있던 화면은 2026-08-21에 v0.6.0을 찍은 것
이었습니다. 그 사이 릴리즈가 120번 넘게 나갔습니다 — 오늘의 리뷰, Dream 검토, 갈래,
발표 자료 만들기, 되돌리기, 첨부, 문서 내보내기, 웹훅 상태 화면. 가이드는 그 어느
것도 보여 주지 못했고, 보여 주는 것은 지금 없는 화면이었습니다.
가이드가 보여 주던 것 지금 화면
━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━
v0.6.0 (2026-08-21 캡처) v0.71.6 (1440x900, 가짜 데이터)
23장, 번호 이름 27장, 화면 이름
Dream 은 가짜 응답을 끼워 찍음 실제로 만들어진 Dream
관리자 비밀번호가 스크립트에 박힘 캡처 전용 환경 변수, 없으면 거부
두 가이드를 처음부터 다시 썼습니다
docs/user-guide.md · docs/admin-guide.md가 docs/USER_GUIDE.md ·
docs/ADMIN_GUIDE.md로 바뀝니다. 이름만 바뀐 것이 아니라 차례가 바뀝니다.
aidev의 공통 가이드 규격(GUIDE-STANDARD.md)을 따르므로, 다른 프로젝트의 가이드를
읽어 본 사람은 같은 자리에서 같은 것을 찾습니다.
사용자 가이드 — 이 제품이 하는 일 · 처음 5분(로그인 → 오늘의 리뷰 → 첫 생각
→ 첫 연결) · 화면별 사용법(오늘의 리뷰, 캔버스와 단축키, Dreams, 결정 기록,
검토·승인, 개인 설정) · 자주 하는 작업(발표 자료 만들기, 백업과 되돌리기, 갈래,
의견만 받기, 오프라인, API·MCP 키) · 막혔을 때 · 용어.
관리자 가이드 — 구성 요소 · 릴리즈 자산에서 설치(umm-v*.tar.gz 반입,
compose, 최초 관리자) · 환경 변수 전수 표 · 계정과 권한 · 운영(상태 점검, 로그,
백업과 복구, 업그레이드와 되돌리기, 감사 로그, 웹훅) · 장애 대응 · 보안.
Keycloak, Dream Layer, AI Gateway, 임베딩 품질, Ptium처럼 전에 있던 화면별 상세는
버리지 않고 부록으로 내려갑니다.
표는 코드에서 읽었습니다
환경 변수 표는 기억이 아니라 internal/config/config.go와
internal/observability/observability.go가 실제로 읽는 이름 전부입니다. 아홉 개 —
필수 넷(POSTGRES_DSN BOOTSTRAP_ADMIN BOOTSTRAP_ADMIN_PASSWORD ENCRYPTION_KEY)과
선택 다섯. 표에 없는 이름은 umm이 읽지 않는 이름입니다.
장애 대응 표의 증상은 로그가 실제로 찍는 문자열입니다.
로그에 보이는 것 확인할 곳
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━━━━━━
invalid startup configuration 필수 환경 변수 넷
database unavailable POSTGRES_DSN · /readyz
migration failed extension 생성 권한
collaboration listener disconnected pool_max_conns
dream generation failed AI Gateway 연결 테스트
grep으로 로그를 잡고 그 줄로 표를 찾으면 됩니다.
캡처 스크립트가 남의 서버를 건드리지 못하게 했습니다
화면을 찍는 scripts/capture_all.js는 목록이 비지 않도록 가짜 데이터를 심습니다.
그래서 버려도 되는 배포에만 겨눠야 합니다. 전에는 주소가 기본값으로 있었고 관리자
비밀번호가 스크립트 안에 적혀 있었습니다.
const BASE_URL = required('UMM_CAPTURE_BASE_URL');
const ADMIN = required('UMM_CAPTURE_ADMIN');
const ADMIN_PASSWORD = required('UMM_CAPTURE_ADMIN_PASSWORD');- 대상과 계정은 캡처 전용 환경 변수에서만 옵니다. 하나라도 비면 시작하지 않습니다.
- 대상이 loopback이 아니면
UMM_CAPTURE_ALLOW_REMOTE=1을 따로 요구합니다. - 아무것도 흉내 내지 않습니다. 전에는 Dream 화면을
page.route로 가짜 응답을
끼워 찍었습니다. 지금은 실제로 만들어진 것을 찍습니다. - 관리자 화면은 열기만 하고 저장하지 않습니다. 전역 설정이 바뀌는 일이 없습니다.
따라온 것
- PDF는 공통 md2pdf 도구로 만듭니다.
generate_pdf.js는 두 가이드를 따로 그리지 않고
종합 매뉴얼에만 넣습니다. - README, 문서 허브(
docs/README.md), 쇼케이스 페이지,compose.yaml의 주석이 새
파일을 가리킵니다.docs/pdf/아래 중복으로 있던 두 PDF도 치웠습니다. - 문서 허브의 "현재 문서 기준"이 v0.8.1에서 v0.71.6으로 올라갑니다. 가이드의
화면은 v0.71.6을 띄워 찍은 것이라 그 버전을 그대로 적습니다.
검증
바뀐 것은 문서·화면·캡처 스크립트뿐이고 서버와 웹 코드는 그대로입니다.
go vet ./... · go test -p 1 ./...(실제 PostgreSQL 17에 대한 통합 시험 포함) ·
gofmt · tsc · oxlint/Prettier · i18n 994키 · vitest 158개 ·
scripts/check-version.sh 통과.
What's Changed
- auto-improve: Let a thought keep a name the export also uses by @hkjang in #148
- auto-improve: Rewrite the user and admin guides around real screens of v0.71.6 by @hkjang in #151
Full Changelog: v0.71.5...v0.71.7
umm v0.71.5
v0.71.5 — 이름 속 줄바꿈 하나에 통째로 날아가던 백업
v0.69.0이 공간을 문서로 내보내는 길을 냈고, v0.71.3은 그 옆에 오래 있던 Markdown
내보내기가 문서가 아니라 백업이라고 적었습니다. 백업인 이유는 v0.65.0부터
umm이 자기가 쓴 파일을 다시 읽어 들이기 때문입니다.
그 파일은 사람이 쓴 말을 뜻이 있는 줄 위에 올려놓습니다. 공간 이름은 배너 제목,
생각 제목은 ## 제목, 갈래는 목록 항목, 연결의 이유는 화살표 뒤. 이 모양들은
줄에서 끝납니다.
그런데 들어오는 쪽에서 줄바꿈을 막는 곳이 하나도 없습니다. 공간 이름은 트림하고
글자 수만 세고, 생각 제목은 온 대로 저장되고, 웹의 입력칸은 <input>이라 우연히
한 줄일 뿐입니다 — API나 MCP 클라이언트는 무엇이든 씁니다.
공간 이름: 우리 팀↵회고
전 후
━━━━━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━━━━
# 우리 팀 # 우리 팀 회고
(빈 줄)
회고 Exported from umm at ...
Exported from umm at ...
↑ 배너 절의 본문이
"회고"로 시작함
배너를 놓치면 백업이 아닙니다
가져오기가 어떤 파일을 umm 자신의 것으로 인정하는 조건은 하나입니다: 배너가
그 절의 본문 전부일 것. 두 줄짜리 이름의 나머지가 그 본문에 끼어들면 선언을
놓치고, 복원은 남의 Markdown을 읽는 길로 떨어집니다.
선언을 놓친 뒤 복원되는 것
id (사라짐) canvas 좌표 (사라짐)
type · color (사라짐) 갈래 (사라짐)
연결 (사라짐) 제목 없는 생각 → 전부 "Thought"
실제로 vitest로 확인했습니다 — 두 줄 이름이면 isExport:false에 sourceId가 전부
undefined, 한 줄이면 그대로 복원됩니다. 복원할 수 없는 백업은 백업이 아니고,
이것은 이름에 든 줄바꿈 하나로 그렇게 되고 있었습니다.
같은 줄바꿈이 생각 제목에 들면 생각 하나가 둘로 열리고 제목의 절반이 이름으로
남습니다. 갈래 이름에 들면 - line: 키와 Lines of thinking 항목이 — 가져오기가
한 줄씩 읽는 자리 — 한 줄씩 어긋납니다.
규칙은 하나입니다
연결의 Reason은 이미 이 일을 하고 있었습니다(\n을 공백으로). 그 처리를 oneLine
하나로 모으고, 줄 위에 올라가는 모든 자리 — 공간 이름, 생각 제목, 갈래 이름, 갈래의
결론, 연결의 이유 — 가 같은 함수를 지나가게 했습니다. 말은 남기고 줄바꿈만 공백이
됩니다.
var newlines = strings.NewReplacer("\r\n", " ", "\r", " ", "\n", " ")
func oneLine(text string) string {
return strings.TrimSpace(newlines.Replace(text))
}형식은 바뀌지 않습니다. 이미 누군가의 디스크에 있는 내보내기 파일은 전과 똑같이
읽힙니다.
검증
시험 2개를 새로 넣었습니다(단위 1 + 통합 1). helper를 무력화해 줄바꿈을 그대로 두면
통합 시험이 배너 제목·생각 제목·갈래 두 줄에서 실패하는 것을 확인했습니다.
TestOneLineKeepsTheWordsAndDropsTheBreak—\n·\r\n·\r, 앞뒤 공백,
이미 한 줄인 말, 빈 말TestMarkdownExportKeepsNamesOnOneLineIntegration— 두 줄 이름의 공간을 실제로
내보내 배너가 절의 본문 전부인지, 생각 제목이 하나인지, 갈래와 그 결론이 한 항목인지
go vet ./... · go test -p 1 ./...(실제 PostgreSQL 17에 대한 통합 시험 포함) ·
gofmt · tsc · oxlint/Prettier · i18n 994키 · vitest 156개 ·
scripts/check-version.sh 통과.
What's Changed
Full Changelog: v0.71.4...v0.71.5
umm v0.71.4
v0.71.4 — uuid라는 이름으로 저장되던 사진
v0.71.0이 생각에 그림을 붙일 수 있게 했습니다. v0.71.1은 그 그림의 딱지를 —
사람이 붙인 파일 이름을 — 글자 한복판에서 자르지 않도록 고쳤습니다. 딱지는 그때부터
공들여 지켜졌습니다. 그런데 umm은 그 이름을 브라우저에는 한 번도 말하지 않았습니다.
첨부는 /api/v1/attachments/{uuid}에서 옵니다. 주소가 uuid입니다. 그리고 응답이
이름에 대해 한 말은 이것뿐이었습니다.
w.Header().Set("Content-Disposition", "inline") 붙인 파일: 2026-09-09 화이트보드.png
전 후
━━━━━━━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━━━━━━
캔버스에 잘 그려짐 ✔ 캔버스에 잘 그려짐 ✔
↓ 그림을 저장하면 ↓ 그림을 저장하면
b03d7110-43a5-4355- 2026-09-09 화이트보드.png ✔
8858-26c385227b32
어느 회의의 화이트보드인지
알 방법이 없음
이름은 disposition이 아니라 매개변수입니다
inline은 바꿀 수 없습니다. 캔버스는 이 그림을 <img>로 그리고, attachment로
바꾸면 그림이 있어야 할 자리에 내려받기가 옵니다.
바꿀 필요도 없습니다. 이름은 disposition이 아니라 헤더의 매개변수이기 때문입니다.
보여 주는 방식은 그대로 두고 이름만 실을 수 있습니다.
inline; filename="2026-09-09 .png"; filename*=UTF-8''2026-09-09%20%ED%99%94...
v0.71.3이 내보내기 파일 이름을 위해 쓴 attachmentDisposition이 이미 이 일을
합니다 — RFC 6266으로 이름을 두 번 적고, filename*에 진짜 이름을 싣고, 따옴표 안에
ASCII 대체 이름을 남기고, 120바이트를 글자 경계에서 자릅니다. 그래서 그 함수를
disposition으로 나누고 attachmentDisposition과 inlineDisposition이 각각
attachment와 inline으로 부르게 했습니다. 호출자가 정하는 것은 disposition과,
적을 글자가 하나도 남지 않았을 때의 대체 이름뿐입니다(umm-space ↔ umm-picture).
확장자는 딱지가 정할 몫이 아닙니다
umm은 그림의 종류를 바이트를 읽어서 정합니다. 업로드가 뭐라고 말하든 믿지 않는
것이 이 파일 전체가 서 있는 규칙입니다. 그렇다면 그 규칙은 이름의 끝에도 적용됩니다.
딱지 바이트 내려받는 이름
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
diagram.svg PNG diagram.svg.png
거짓말한 끝을 잘라 내지도, 믿지도 않고 — 사람의 말은 남기고 참말을 덧붙임
사진.JPEG JPEG 사진.JPEG ← 이미 맞으니 쓰인 대로
2026.09.09 회의 PNG 2026.09.09 회의.png
점이 들었다고 `.09 회의`가 확장자인 것은 아님
딱지에서 잘라 내면 사람이 쓴 말이 사라집니다. 그대로 믿으면 PNG가 .svg로 디스크에
앉습니다. 그래서 말은 남기고, 바이트가 말하는 끝을 붙입니다. 이미 그 형식의
끝으로 끝나는 딱지는 대소문자까지 쓰인 대로 둡니다 — 사진.JPEG가 사진.JPEG.jpg로
돌아오지 않습니다.
검증
시험 3개를 새로 넣었습니다(단위 2 + 통합 1). 옛 "inline" 한 줄로 되돌리면 통합
시험이 filename = "", want the label with the format umm read ("inline")로
실패하는 것을 확인했습니다.
TestPictureName— 딱지와 바이트가 어긋나는 경우들에서 이름과 끝이 어떻게
갈라지는지TestInlineDispositionNamesAPictureWithoutDownloadingIt— 헤더가inline으로
남으면서 이름을 싣는지TestAttachmentKeepsItsNameWhenSavedIntegration— 실제로 올린 그림을 다시 받아
클라이언트가 붙인 이름을 읽어 내는지
go vet ./... · go test -p 1 ./...(실제 PostgreSQL 17에 대한 통합 시험 포함) ·
gofmt · tsc · oxlint/Prettier · i18n 994키 · vitest 156개 ·
scripts/check-version.sh 통과.
What's Changed
Full Changelog: v0.71.3...v0.71.4
umm v0.71.3
v0.71.3 — 이름 지은 사람이 남의 백업 파일 이름을 정하던 헤더
v0.69.0은 공간을 문서로 내보내는 길을 냈습니다. 그 옆에 오래 있던 Markdown
내보내기는 문서가 아니라 백업입니다. 백업 파일에서 이름은 장식이 아닙니다 —
6개월 뒤 폴더를 열었을 때 어느 공간의 무엇인지 말해 주는 유일한 것입니다.
그 이름은 공간의 이름에서 나옵니다. 그리고 공간의 이름은 사람이 쓴 말입니다.
따옴표도, 한글도, 다른 무엇이든 들어갑니다. 그것이 이 한 줄에 그대로 들어가고
있었습니다.
fmt.Sprintf(`attachment; filename="umm-%s.md"`, filename) 공간 이름: "9월" 회의
전 후
━━━━━━━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━━━━━━
filename="umm-"9월" 회의.md" filename="umm-9-.md"
따옴표가 문자열을 먼저 닫고 filename*=UTF-8''umm-9%EC%9B%94...
나머지가 헤더 문법이 됨
↓ ↓
mime.ParseMediaType: umm-9월 회의.md ✔
invalid media parameter
↓
쓸 수 있는 이름이 하나도 없음
이름을 지은 사람이 남의 파일 이름을 정합니다
" 하나가 인용부호를 먼저 닫고, 그 뒤의 글자는 이름이 아니라 헤더 문법으로 읽힙니다.
mime.ParseMediaType은 그 결과를 통째로 거절합니다. 공유 공간에서는 이것이 이름을
지은 한 사람이 나머지 모두의 백업 파일 이름을 흔드는 일이 됩니다.
한글은 다른 방향으로 어긋났습니다. 따옴표 안 filename에 ASCII 밖의 바이트를 넣는
것은 정의된 적이 없어서, 이름은 날바이트로 나가고 클라이언트마다 다른 인코딩으로
짐작했습니다. 같은 파일을 받아도 사람마다 다른 글자가 적혀 있었습니다.
왜 지금까지 아무도 못 봤는가
웹 캔버스는 anchor.download로 자기가 이름을 붙입니다. 그래서 이 헤더는 화면에서
내보낸 사람에게는 한 번도 보이지 않았습니다.
이 헤더가 유일한 이름인 쪽은 API 클라이언트입니다 — 밤에 도는 백업 스크립트, curl -OJ.
파일이 무엇인지 알아낼 다른 방법이 없는 바로 그 호출자입니다.
RFC 6266이 둘 다 답합니다
attachmentDisposition이 이름을 두 번 적습니다.
attachment; filename="umm-9-.md"; filename*=UTF-8''umm-9%EC%9B%94%20%ED%9A%8C%EC%9D%98.md
filename*은 진짜 이름을 UTF-8로 퍼센트 인코딩해 싣습니다. 둘 다 이해하는 클라이언트는
filename*을 골라야 한다고 규격이 정해 두었으므로, 사람이 실제로 받는 이름은
공간의 이름 그대로입니다.
따옴표 안 filename은 그 형식을 모르는 옛 클라이언트를 위한 ASCII 대체 이름으로
남습니다. 적을 수 없는 글자의 연속은 대시 하나로 줄입니다 — 통째로 사라지면 이름이
어디까지였는지도 알 수 없지만, 대시가 하나 남으면 말이 있던 자리가 보입니다. 공백도
그 연속에 합류시켰습니다. 그러지 않으면 반쯤 한글인 이름이 대시들 사이에 단어 간격만
남은 모양으로 나옵니다.
딱지는 언젠가 경로가 됩니다
여기서 지우는 글자(/ \ " 제어문자)는 store.safeFilename이 첨부 파일 딱지에서
지우는 것과 같습니다. 이유도 같습니다: 한 화면에서만 딱지인 이름도 누군가 디스크에
쓰는 순간 경로가 됩니다.
길이도 같은 120바이트입니다. 이제 이름이 한 헤더 안에 두 번 적히니 상한이 없으면
헤더 자체가 읽을 수 없게 자랍니다. 자르는 자리는 v0.71.1이 첨부 딱지에서 고친 것과
같은 textutil.LimitUTF8Bytes — 글자 한복판이 아니라 글자 경계입니다.
검증
시험 3개를 새로 넣었습니다. 옛 헤더 형식으로 되돌리면 통합 시험이
the download name is not a readable header: mime: invalid media parameter로
실패하는 것을 확인했습니다.
TestAttachmentDisposition— 한글·따옴표·구분자·빈 이름 여섯 가지를 실제로
mime.ParseMediaType에 통과시키고, 파서가 고른 이름이 공간의 이름인지TestAttachmentDispositionBoundsTheName— 한글 200자 이름이 40자(120바이트)에서
글자 경계로 끊기는지TestMarkdownExportNamesTheFileIntegration—"9월" 회의공간을 실제로 내보내
클라이언트가umm-9월 회의.md를 읽어 내는지
go vet ./... · go test -p 1 ./...(실제 PostgreSQL 17에 대한 통합 시험 포함) ·
gofmt · tsc · oxlint/Prettier · i18n 994키 · vitest 156개 ·
scripts/check-version.sh 통과.
What's Changed
Full Changelog: v0.71.2...v0.71.3
umm v0.71.2
v0.71.2 — 6월의 캔버스에 걸려 있던 8월의 사진
v0.70.0의 되감기는 공간을 그때 모습으로 되돌려 보여 줍니다. 글자는 그때 글자로,
자리는 그때 자리로 돌아옵니다. 지워진 연결은 되살릴 수 없으니 개수를 말합니다 —
말없이 빠뜨리면 그런 연결이 아예 없었던 것처럼 보이기 때문입니다.
v0.71.0이 그림을 붙일 수 있게 했습니다. 그런데 그림만 되감기를 지나가지 않았습니다.
6월 3일 생각을 씀 + 화이트보드 사진을 붙임
8월 21일 같은 생각에 캡처 한 장을 더 붙임
9월 5일 "6월 10일의 공간" 을 열어 봄
전 후
━━━━━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━━━━
화이트보드 사진 ✔ 화이트보드 사진 ✔
8월의 캡처 ✔ ← 8월의 캡처 (없음)
6월에 존재하지 않던 것이
6월의 캔버스에 걸림
왜 그림만 티가 안 나는가
되감은 캔버스에서 문장이 오늘 것이면 읽는 사람이 알아챕니다 — 그때 쓰지 않은 말이
적혀 있으니까요. 자리도 마찬가지입니다. 그림은 그렇지 않습니다. 사진 한 장이
6월에 붙은 것인지 어제 붙은 것인지는 그림을 아무리 들여다봐도 알 수 없습니다.
그러니 스냅샷이 그림의 날짜를 지켜 주지 않으면 아무도 지켜 주지 않습니다.
원인은 단순했습니다. 캔버스는 그림 목록을 GET /spaces/{id}/attachments 한 곳에서만
받아 왔고, 그 목록은 언제나 지금의 목록입니다. 되감기는 생각과 연결만 스냅샷으로
바꿔치우고, 그림은 건드리지 않은 채 그대로 뒀습니다.
스냅샷이 그림도 함께 가져옵니다
SpaceAt이 그 시점까지 붙어 있던 그림을 attachments로 함께 돌려주고, 되감은
캔버스는 그것을 씁니다. 그 시점 이후에 붙은 그림은 그때 쓰지 않은 문장과 똑같이
없는 것입니다.
FROM note_attachments a
JOIN notes n ON n.id=a.note_id
WHERE a.space_id=$1 AND a.created_at<=$2 AND n.deleted_at IS NULL지금으로 를 누르면 오늘의 목록을 다시 받아 옵니다. 되돌아온 캔버스가 6월의 그림을
들고 있으면 그것은 같은 결함의 반대 방향입니다.
그릴 수 없는 그림은 개수를 말합니다
그때 있었지만 지금은 그릴 수 없는 그림이 한 종류 있습니다. 그 뒤에 지워진 생각에
붙어 있던 그림입니다. 생각 자체는 되감기에서 돌아옵니다 — 그때 캔버스에 있었으니까요
— 하지만 그림의 바이트는 지워지지 않은 생각에 대해서만 내어 줍니다. 목록에 넣으면
깨진 이미지가 되고, 빼면 그 생각이 원래 그림이 없었던 것처럼 보입니다.
지워진 연결과 같은 답을 골랐습니다: removedAttachments로 세어서 말합니다.
그 뒤 지워진 생각에 붙어 있던 그림 2장은 되살릴 수 없어 빠져 있습니다.
연결과 그림이 둘 다 빠진 경우에는 한 문장으로 둘 다 말합니다. 사람이 직접 뗀 그림은
행이 남지 않아 여기서도 셀 수 없습니다 — 지워진 연결이 무엇을 이었는지 말할 수 없는
것과 같은 한계이고, 주석에 그대로 적어 뒀습니다.
검증
되감기 통합 시험 2개를 새로 넣었습니다. 옛 동작으로 되돌리면 둘 다 실패합니다.
TestSpaceAtLeavesOutAPictureAddedSinceIntegration— 나중에 붙인 그림이 과거의
캔버스에 없는지, 그리고 오늘의 캔버스에는 둘 다 있는지
(the past canvas is holding a picture from the future)TestSpaceAtCountsThePicturesItCannotDrawIntegration— 지워진 생각의 그림이
깨진 이미지 대신 개수로 돌아오는지
(the thought comes back looking as though it never had a picture)
go vet ./... · go test ./...(실제 PostgreSQL 17에 대한 통합 시험 포함) · gofmt ·
tsc · oxlint/Prettier · i18n 994키 · vitest 156개 · scripts/check-version.sh 통과.
What's Changed
Full Changelog: v0.71.1...v0.71.2
umm v0.71.1
v0.71.1 — 이름이 길다는 이유로 거절당한 사진
v0.71.0은 생각에 그림을 붙일 수 있게 했습니다. 파일 이름은 이 기능에서 아무것도
결정하지 않습니다 — 종류는 바이트를 읽어서 정하고, 이름은 그저 나중에 사람이 알아볼
딱지입니다.
그런데 그 딱지 하나가 사진을 통째로 떨어뜨리고 있었습니다.
파일: 4x4 PNG, 82바이트 — 규칙을 하나도 어기지 않음
이름: "2026 화이트보드화이트보드…화이트보드.png" (144바이트)
전 후
━━━━━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━━━━
이름을 120바이트에서 자름 이름을 120바이트 안에서
→ 글자 한복판에서 끊김 글자 경계까지만 자름
↓ ↓
PostgreSQL: 붙음 ✔
invalid byte sequence 딱지는 118바이트로 남음
for encoding "UTF8"
↓
500 "그림을 저장하지 못했습니다" ✘
왜 한글에서만 터지는가
safeFilename은 이름을 120바이트로 잘랐습니다. cleaned[:120] — 바이트 자르기입니다.
ASCII 이름은 한 글자가 1바이트라 이 자르기가 언제나 글자 경계에 떨어집니다. 한글은 한
글자가 3바이트입니다. 120번째 바이트가 글자의 두 번째 바이트인 순간, 잘린 결과는 더
이상 글자가 아닙니다. PostgreSQL의 text는 UTF-8이 아닌 바이트열을 받지 않으므로
INSERT 전체가 거절되고, 화면에는 그림에 문제가 있다는 말만 남습니다.
40글자가 넘는 한글 파일 이름은 드물지 않습니다. 회의 사진에 날짜와 안건을 그대로 적어
두는 사람에게는 그것이 보통의 이름입니다.
이미 있던 규칙을 쓰게 했습니다
이 저장소에는 textutil.LimitUTF8Bytes가 있고, 주석에 용도가 그대로 적혀 있습니다 —
"PostgreSQL이나 외부 시스템 경계를 넘는 문자열을 위한 것". Ptium 응답, 웹훅 메시지,
User-Agent가 이미 이것을 지납니다. 첨부 파일 이름만 지나지 않았습니다.
return textutil.LimitUTF8Bytes(cleaned, 120)길이 상한(120바이트)은 그대로입니다. 바뀐 것은 어디서 끊느냐뿐이라 ASCII 이름의
동작은 한 글자도 달라지지 않습니다.
빈 파일은 큰 파일이 아닙니다
같은 함수의 첫 줄에서 len(data) == 0을 "너무 큽니다"로 취급하고 있었습니다. 0바이트
파일을 올린 사람은 413과 함께 한 장에 5MB까지 붙일 수 있습니다를 받고, 근처에도 가지
않은 한계를 찾아보게 됩니다. 이제 빈 업로드는 종류 판별로 내려가 PNG · JPEG · GIF · WebP만 붙일 수 있습니다라고 답합니다 — 빈 것은 그림이 아니고, 그 문장이 사실입니다.
검증
시험 3개를 새로 넣었습니다. 옛 동작으로 되돌리면 셋 다 실패합니다.
TestSafeFilenameCutsBetweenCharactersNotBytes— 자른 결과가 여전히 글자인지
(a cut name is not text any more: "…화이트\xeb")TestAttachmentAcceptsALongNonASCIIFilenameIntegration— 진짜 PostgreSQL에 실제로
붙는지 (invalid byte sequence for encoding "UTF8")TestAttachmentEmptyUploadIsNotAnImageIntegration— 빈 업로드가 무엇이라고 답하는지
go vet ./... · go test ./...(실제 PostgreSQL에 대한 통합 시험 포함) · gofmt · tsc ·
oxlint/Prettier · i18n 992키 · vitest 156개 · scripts/check-version.sh 통과.
What's Changed
Full Changelog: v0.71.0...v0.71.1
umm v0.71.0
v0.71.0 — 문장이 아닌 부분
결정의 일부는 문장이 아닙니다. 화이트보드 사진, 지금 이야기 중인 화면의 캡처, 손으로 그린
그림. 그동안 그것들은 나머지 전부를 담고 있는 공간 밖에 있었습니다.
이 기능의 위험은 저장이 아니라 돌려주는 쪽입니다
이 바이트는 사람이 올린 것이고 앱과 같은 출처에서 돌려줍니다. 브라우저가 이걸
그림이 아니라 문서로 렌더링하기로 하면, 그건 저장형 XSS입니다.
세 가지가 막습니다. 어느 것도 업로드가 자기에 대해 한 말이 아닙니다.
1. 형식은 저장할 때 바이트를 읽어서 정합니다
→ PNG · JPEG · GIF · WebP 만 저장 (SVG는 XML이고 스크립트를 담습니다)
2. 응답에 자기 몫의 CSP: default-src 'none'; sandbox
→ 문서로 취급되도록 유도되더라도 그 문서가 할 수 있는 게 없습니다
3. nosniff + inline 이미지로만, 페이지로는 절대
파일 이름은 이름표일 뿐입니다. 형식을 정하지 않고, 경로로도 쓰지 않습니다.
시험이 엉뚱한 이유로 통과하고 있었습니다
권한 시험은 이랬습니다 — 낯선 사람이 남의 생각에 그림을 붙이려 하면 거절돼야 한다.
통과했습니다. 변형으로 권한 조건을 통째로 지웠는데 여전히 통과했습니다.
낯선 사람의 uuid가 실재하지 않는 계정이라, 권한이 아니라 uploaded_by 외래키가 막고
있었습니다. 권한 검사가 아예 없어도 통과하는 시험이었습니다.
접근 권한만 없는 진짜 계정으로 바꾸니 변형이 잡힙니다.
전역 1MB 상한에 걸릴 뻔했습니다
모든 요청 본문이 미들웨어에서 1MB로 잘립니다. 사진은 그보다 큽니다.
바깥에서 이미 감싼 리더를 안에서 더 크게 감싸도 상한은 안 올라갑니다. 핸들러에서
고칠 수 있는 문제가 아니라 미들웨어에서 결정해야 했습니다. 그대로 뒀다면 1MB에서
자기가 한 일과 무관한 오류를 받았을 겁니다.
시험이 실제로 1MB를 넘는 PNG를 만들어 확인합니다. 처음엔 규칙적인 픽셀로 만들었더니
압축돼서 165KB가 됐고 — 시험이 스스로 그렇다고 말하고 멈췄습니다. 진짜 난수로
바꿨습니다.
왜 PostgreSQL인가
umm은 바이너리 하나와 PostgreSQL 하나로 배포되고, 오프라인 이미지가 곧 제품입니다.
버킷을 더하면 가볍게 쓸 대부분의 설치에까지 운영 의존성이 하나 늘어납니다.
대신 이렇게 됩니다: 백업 하나에 전부 들어가고, 생각과 그림이 한 트랜잭션에서 지워지고,
메모에 이미 걸린 접근 규칙이 그림에도 두 번째 구현 없이 적용됩니다.
비용은 실재하고 일부러 묶었습니다 — 그림만, 한 장 5MB, 생각 하나에 8장. 캔버스가
조용히 파일 서버가 되지 않도록.
확인한 것
✓ 붙이고 · 목록에 나오고 · 바이트가 그대로 돌아옴
✓ HTML을 .png로 위장 → 거절, SVG → 거절, 진짜 PNG를 .txt로 → 통과
✓ JPEG · GIF 도 실제로 저장됨 (그림 시험이지 "PNG만 되는" 시험이 아님)
✓ 5MB 초과 거절, 생각당 8장 초과 거절 — 자르지 않고 한도를 알려 줌
✓ 접근 없는 실재 계정은 붙이지도 읽지도 지우지도 못함
✓ 생각을 지우면 그림도 목록에서 사라지고 읽히지 않음
✓ 1MB 넘는 진짜 사진이 업로드됨 (전역 상한을 실제로 넘김)
✓ 응답: image/png · nosniff · default-src 'none'; sandbox · inline
✓ 브라우저: 고르고 · 붙고 · 새로 고쳐도 있고 · 그림으로 돌아옴
| 바꾼 것 | 잡은 것 |
|---|---|
| 업로드가 말한 형식을 믿음 | HTML·SVG가 그림으로 저장됨 |
| SVG 허용 | 스크립트를 담은 "그림" |
| 크기 상한 제거 | 상한이 없어짐 |
| 생각당 장수 제한 제거 | 제한이 없어짐 |
| 붙이기 권한 검사 제거 | (처음엔 못 잡음 → 시험을 고침) |
| 읽기 권한 검사 제거 | 남의 공간 그림을 읽음 |
| 그림 응답의 CSP 제거 | 페이지 정책으로 서빙됨 |
| 업로드 경로의 상한 예외 제거 | 1MB에서 실패 |
여덟 개 전부 검출(하나는 시험을 고친 뒤). nosniff 를 핸들러에서 빼는 변형은 잡히지
않았고, 그건 전역 미들웨어가 이미 넣기 때문입니다 — 핸들러의 그 줄은 이중 방어라고
주석에 적었습니다.
안 한 것
내보내기에는 그림이 실리지 않습니다. 마크다운 백업에 base64로 넣으면 파일이 감당 못 할
크기가 됩니다. 조용히 빼지 않고 여기 적어 둡니다 — 자기 백업으로 공간을 되돌리면 글자와
자리와 연결은 돌아오고 그림은 돌아오지 않습니다.
전체 검증: go test -p 1 ./internal/... ./cmd/...(통합 포함) · go vet · gofmt ·
변형 8종 검출(store 6 · http 2, 하나는 시험을 고친 뒤) · vitest 156개 ·
attachments e2e 2개(실제 바이너리·실제 번들) · oxlint(종료 코드) · Prettier · tsc ·
i18n 992키 · OpenAPI 드리프트 · PWA precache
What's Changed
Full Changelog: v0.70.0...v0.71.0