v0.18.0 S04 — 전송 중 요청 보호: 행 단위 claim·fence 로 늦은 답장을 막고, 전송 중인 행 위의 편집을 후속 행으로 보존 (2026-09-08)
- 기간: 2026-09-07 ~ 2026-09-08 (커밋 해시는 main
e860d7b0위로 마지막 리베이스한 뒤 브랜치의 것 — PR 은 squash 머지. 세션 2개 —d201873b착수·분석·Phase 계획 게시·Phase 0~2, 세션 한도로 중단 →14f87ec1이어받아 Phase 3~5) - 랜딩: PR #1369 → main
f8334135(2026-09-08, Phase 0~5 한 PR, HQ §4-2 자동 랜딩 큐 요청 a6c566e6 → run 34142011762 이 squash 머지 + staging 확인: deploy.yml run 34142049591 success) — 마이그레이션·엣지 함수·서버 변경 없음. 앱 코드 변경은src/react/persistence/outbox/{claimPlan(신규),replacePlan,index}.ts+ 전송기src/react/services/pendingWorkoutSaves.ts+ 포트 배선src/react/controllers/pendingWorkoutSavesStore.ts(S05 경계 직렬 합류, 수십 줄) +pendingWorkoutSavesController.ts·workoutWriteController.ts몇 줄 +package.json스크립트 한 줄. 저장 형식 v1, 새 값은 전부 추가 필드. 총괄 S04 카드 · 계획 ID S04 · Phase 2 스텝 2-2 - 설계서: 없음 — 분석·Phase 계획·예상 효과는 이슈 #1334 착수 댓글
- 정본:
docs/contracts/outbox-claim-successor.md(유저 이야기·claim·ACK 적용·후속 행·의도 행·조합 표·실측·인계) ·src/react/persistence/outbox/claimPlan.ts· ADR §3-1 행별 claim·fence·durable successor 행 · 대기열 행 codec §5·§6 활성화 조건·인계 표 · 도메인 계약 §12·§17 · 쓰기 파이프라인 §3·§11 · 원자 교체 계약 §1·§6 - 도구:
npm run test:persistence-browser에tests/react/outboxClaim.browser.mjs추가(esbuild 번들 + 실제 Chromium 페이지 둘, 페이지별 주입 시계). e2eerror-cases/CASE-040(신규) ·CASE-039옛 태그 기본값v0.17.1(BARBELIC_LEGACY_TAG로 변경) - 게이트: 단위 —
tests/react/outboxClaimSuccessor.test.mjs8(결함 재현 5 → 통과) ·outboxClaimPlan.test.mjs5 · 기존 대기열 테스트 8개 파일 기대값을 새 계약으로 갱신(manifest 등재 파일 없음). 실제 Chromium —outboxClaim.browser.mjs4/4. e2e — CASE-040 3회 연속 통과 · CASE-039 v0.17.1 통과. G05 규칙 1줄 + 장부 행 2개 갱신 - 버그리포트: 없음(구조 트랙, 감사 보고서 F01·F16·F17 의 수리)
- 계약:
outbox-claim-successor.md신설 · ADR §3-1 표 S04 행 3개에 구현 링크 · codec 계약 §5 활성화 선언·§6 S04 행 완료 · 도메인 계약 §12(활성)·§17 OutboxPort 행 · 쓰기 파이프라인 §3·§11 · 원자 교체 계약 §1 조건표 행 분할·§6 S04 행 완료
Phase 현황
| Phase | 내용 | 상태 |
|---|---|---|
| Phase 0 | 후속 명령 × 선행 상태 × 소유자 조합 표(계약 §6) + 지금 구조의 결함을 먼저 실패하는 테스트 6건(5건 실패 확인) | ✅ 77379689 |
| Phase 1 | persistence/outbox/claimPlan.ts 순수 계획기 — 전송 중 판정(후퇴 규칙)·claim 획득 CAS·ACK 적용 4종·전송 가능 판정 + 테스트 5건 | ✅ a4c9d870 |
| Phase 2 | 교체 규칙의 successor·의도 행 분기, 전송기 claim → 전송 → ACK(전부 비교 후 교체), 선행 확정 뒤 후속, prepareSuccessor 포트·시계·tabId 주입, 스토어 배선, 대기 중 카드 | ✅ 35ffbd94 |
| Phase 3 | 실제 Chromium 두 탭 4 시나리오 + test:persistence-browser 연결 · e2e CASE-040 신설(실제 v0.17.1 탭 공존) · CASE-039 v0.17.1 재실측 | ✅ c10cc75f |
| Phase 4 | 계약 §7 실측·§8 인계 · ADR·codec·도메인·쓰기 파이프라인·원자 교체 계약 갱신 · G05 규칙·장부 · 이 기록 · 등록 2곳 | ✅ 18773a7a |
| Phase 5 | npm run ci:local --full · PR · CI 1회 · 랜딩 큐 · 이슈 [v0.18.0 스테이징] | ✅ (ci:local full 13분 58초 + 브라우저 레인 재실행 7분 43초 → PR #1369 → 리베이스 2회 · CI 2회(2차 run 34141431432 전 잡 success) → 랜딩 큐 → 머지 f8334135 · staging run 34142049591 success) |
1. 배경
유저 A 가 지하철에서 어제 기록의 무게를 60 → 80 으로 고쳤다. 앱은 그 수정을 기기 대기열(IndexedDB pendingSaves)에 행 하나로 남기고, 연결이 돌아오자 서버로 보내기 시작했다(전송 중). 답장이 오기 전(느린 망, 몇 초) A 가 같은 기록의 메모를 하나 더 고쳤다. v0.18.0 ADR §3-1은 이 상황을 "행 단위 claim·fence·후속 행" 으로 닫기로 했고(S04), S02(#1325)가 그 아래층인 원자 교체를, S01(#1285)이 행 형식과 실제 옛 번들 공존 실측(CASE-039)을 끝내 놓았다. ADR 은 "새 writer 는 S01 이 실제 옛 번들 + 다중 탭 공존 실험을 통과하기 전에는 켜지 않는다" 고 못 박았다 — 이 트랙이 그 조건을 확인하고 켰다.
2. 문제 제기
전송 중인 행이 지워졌다
두 번째 수정은 "같은 기록의 행은 마지막 하나" 규칙(원자 교체 계약 §1)으로 전송 중인 첫 행을 저장소에서 지우고 새 행을 썼다. 첫 요청은 이미 서버로 날아간 상태라 기기에는 그 요청이 있었다는 흔적이 없다.
옛 요청이 부활했다
첫 전송의 답장이 일시 실패 면 전송기가 첫 행을 "다시 대기" 로 되돌려 쓰는데, 저장소에 그 행이 없으므로 새로 만들어졌다. 첫 수정(무게 80, 메모 없음)과 두 번째 수정(무게 80 + 메모)이 둘 다 대기열에 남아 오래된 것이 먼저 올라가고 새 것이 개정번호 충돌로 다시 보내졌다. 첫 요청이 성공하고 새 요청이 영구 거부되면 A 의 마지막 의도(메모)가 사라진다.
두 탭이 같은 행을 같이 보냈다
탭을 두 개 열어 두면 둘 다 온라인 복귀 때 같은 행을 "전송 중" 으로 표시하고(비교 없는 덮어쓰기) 둘 다 보냈다. 서버 멱등 장부가 중복 반영은 막지만, 한 탭의 늦은 실패 답장이 다른 탭이 보내는 중인 상태를 "다시 대기" 로 덮어 한 번 더 보냈다.
새 기록의 후속 편집은 기기에 남지 않았다
아직 서버 id 가 없는 새 기록을 보내는 중에 그 기록을 수정하거나 삭제하면 앱은 아무것도 저장하지 않고 메모리에서 답장을 기다렸다(wait-for-create). 그 사이 탭이 닫히면 수정은 사라지고, 다른 탭이 먼저 올려 답장이 이 탭에 오지 않으면 "수정할 세션을 대기열에 넣지 못했습니다" 오류가 떴다.
어떤 구조라서 가능했나(§22): ⓐ 행에 "누가 언제까지 보내는 중인지" 가 없었다 — state:"sending" 과 보낸 시각뿐이라 전송기의 상태 변경(전송 시작·성공 삭제·실패 되돌림)이 비교 없는 덮어쓰기였다. ⓑ 합치기 규칙이 전송 중 행을 예외 없이 지웠다. ⓒ 전송 중 생성 기록의 후속 편집을 영속화할 자리가 없었다.
3. 해결 방안
원칙
- 오너 결정 없음 — 계획의 전제로 진행: ① "진행해줘" 를 착수 go 로 ② lease 는 종전 10초 유지 ③ 옛 탭이 생성 행을 먼저 지운 창의 고아 의도 행은 보존만(RPC 신설 없음) ④ CASE-040 신설·CASE-039 는 v0.17.1 태그로 재실측.
- §22(근본 구조): "sending 이면 건드리지 않기" 조건 하나로 모면하지 않고, 행에 소유·만료·번호를 두고 전송기의 모든 상태 변경을 "내 것이 그대로일 때만" 으로 바꿨다. 전역 리더 선출은 두지 않는다(ADR §3-1).
접근
| 대안 | 판정 |
|---|---|
A. 행 단위 claim(탭 id·만료·fence)을 S02 원자 교체의 재검사로 기록. 전송기의 상태 변경 전부(전송 시작·성공 삭제·실패 되돌림·보류·격리)는 "내 claim 이 지금도 그대로일 때만". 전송 중 행은 지우지 않고 후속 행(successorOf)을 먼저 영속화, 선행 확정 뒤 후속 전송. 새 기록의 후속은 의도 행으로 영속화하고 생성 영수증이 오면 준비된 행으로 변환 | 채택 |
| B. 전송기의 삭제·되돌림에 "행이 sending 이면 건드리지 않기" 조건만 추가 | 기각 — 어느 탭의 sending 인지 구분할 수 없고 늦은 답장과 현재 답장을 가를 수 없다 |
| C. 전역 리더 탭 선출 | 기각 — 이슈·ADR §3-1 이 금지. 리더 탭이 죽으면 전체가 멈춘다 |
| D. 새 기록의 후속은 지금처럼 메모리 대기 유지 | 기각 — 탭 종료 시 유실, 다른 탭이 먼저 올리면 오류 |
4. 적용한 내용
Phase 0 — 조합 표·결함 재현 (77379689, 세션 d201873b)
후속 명령(수정·삭제·계획 저장·계획 삭제) × 선행 상태(대기·전송 중·지연·보류·격리·옛 탭 sending) × 소유자(내 탭·다른 탭·다른 사용자) 조합 표를 계약 §6 으로 적고, 메모리 스토어 + 고정 시계 + 탭 둘로 지금 구조의 결함을 테스트 6건으로 고정했다. 현재 코드에서 5건 실패(전송 중 행 삭제·두 탭 동시 전송·lease 인계 없음·새 기록 후속 미보존 2건), 1건(다른 사용자 행 보호) 통과.
Phase 1 — 순수 계획기 (a4c9d870)
src/react/persistence/outbox/claimPlan.ts: 전송 중 판정(writer 가 있고 유효한 claim 이면 그 탭 소유, 아니면 종전 sentAt 10초 규칙으로 후퇴 — codec 계약 §5 조건 2·3), claim 획득(fence 이전+1·sending·evidence·writer, S02 원자 교체의 재검사로 커밋 — 새 트랜잭션 종류 없음), ACK 적용 4종(성공 삭제·일시 실패 대기·로그인 만료 보류·영구 거부 격리 — 내 탭·내 fence 일 때만, 아니면 stale 무변경; claim 은 지우지 않고 만료 처리만 해서 fence 가 오르기만 한다), 전송 가능 판정(savedAt 순, 격리·보류·전송 중·의도 행·선행 살아 있는 후속 제외).
Phase 2 — 교체 규칙·전송기·포트 배선 (35ffbd94)
교체 규칙(replacePlan.ts)이 전송 중인 이전 행을 지우지 않고 새 행에 successorOf 를 적고, 결과 미상(전송 중·지연·보류)인 새 기록의 수정·삭제는 의도 행(pending:<생성 id> 겨냥, 추정 서버 id 없음)으로 남긴다. 전송기(retryPendingWorkoutSaves)는 전송 시작·성공 삭제·실패 되돌림·보류·격리 전부를 "내 claim 이 그대로일 때만" 원자 교체로 쓰고(늦은 답장·다른 탭·옛 fence 는 무변경), 선행이 확정된 뒤 같은 플러시에서 후속을 이어 보낸다. 생성 영수증이 오면 의도 행을 prepareSave/prepareDelete 로 준비된 행으로 바꾼 뒤 생성 행을 지운다(pendingWorkoutSavesStore 포트 배선). 대기 중 카드는 수정 의도 내용을 보이고 삭제 의도면 숨긴다. 시계·tabId 주입. Phase 0 테스트 6건 → 통과 + 스토어 배선·카드 2건. npm run check 통과(단위 3,066 중 3,043 통과·건너뜀 23·실패 0).
작업 중 드러난 것
- 기존 테스트 8개 파일의 기대값이 옛 계약(전송 중 행도 지운다·
wait-for-create)이라 새 계약으로 고쳤다 — manifest 등재 파일 없음. - 컨트롤러
workoutWriteController의wait-for-create분기는 더 이상 도달하지 않는 죽은 코드 → S05 인계. - 대기열 적재가 시계를 주입받지 못해 테스트가 실제 시각을 탔다 →
now주입 추가.
Phase 3 — 실제 Chromium·실제 옛 번들 (c10cc75f, 세션 14f87ec1)
tests/react/outboxClaim.browser.mjs: esbuild 로 묶은 실제 전송기 코드를 같은 컨텍스트의 페이지 둘(같은 IndexedDB)에서 돌리고 페이지마다 시계를 주입한다. A 두 탭 동시 claim → 한 탭만 전송·이긴 탭의 답장이 삭제 · B lease 만료 뒤 인계(fence 2) → 이전 탭의 늦은 실패·성공 답장 무변경 · C 전송 중 탭 종료 → lease 안에는 손대지 않고 만료 뒤 인계, 재시작 뒤 행 0 · D 재시작 뒤 claim·writer·evidence 보존, 만료 claim 은 fence 올려 인계 — 4/4.npm run test:persistence-browser에 연결.- CASE-040(신규,
fault-injection-full-stack): 실제 v0.17.1 번들 탭 A 가 수정을 보내는 중(요청을 붙듦)일 때 새 번들 탭 B 가 같은 기록을 고친다 → 옛 행은 한 글자도 안 바뀐 채 남고 B 의 편집은successorOf후속 행, B 는 보내지 않음 → 붙든 요청을 풀면 옛 탭이 영수증으로 자기 행만 지움(서버 80kg·rev 2), 후속 생존 → B 가 후속을 보내 개정번호 충돌(LG409400) 뒤 최신 개정번호로 재전송(서버 90kg·rev 3, 대기열 0), 두 탭 새로고침 뒤 0행, 오류 표면 0. 3회 연속 통과(25.2s·26.1s·39.4s). - CASE-039 를 v0.17.1 태그로 재실측(codec 계약 §5 조건 5) — 통과(1.4분): 옛 writer 는 새 필드를 행·미러에 보존하고(R4) 다른 탭의 claim 을 무시한다(R5), v0.17.0 과 같음. 기본 태그를
v0.17.1로 올리고BARBELIC_LEGACY_TAG로 바꿀 수 있게 했다.
작업 중 드러난 것
- 충돌 재전송은 새 요청 id·새 지문으로 나간다: 기대 개정번호가 지문에 들어 있어 같은 id 를 다시 쓸 수 없다(서버 멱등 장부의 재사용 거부, #1199 규칙). 본문은 서버 세부 행 id 만 뗀 같은 내용. 이 재전송을 기기 행으로 먼저 영속화하는 것은 S06(ADR §3-3) 몫 — 계약 §4·§8 에 적었다.
- PostgREST 는 SQLSTATE 40001/40P01 원형을 재시도한다: 로컬 샌드박스(v14.16)에서 그대로 raise 하면 45초 넘게 응답이 없었다(P0001 은 400 · 14ms).
save_session_v5·delete_session_v5가 엔진의 40001 을LG409로 번역하는 이유가 이것이다 — 새 RPC 도 같은 규칙을 따라야 한다. - 장애 주입 프로필의 제약: 선언 장애는 RPC 경로마다 하나뿐이고 연결 상태 이벤트를 1개 이상 선언해야 한다. 후속의 첫 전송에 503 을 넣고 충돌 400 을 같은 경로에 두는 첫 설계는 레지스트리가 거부했다 → 연결 상태 이벤트는 새 탭 부팅의 준비 조회 503(CASE-012/014 기법)으로 만들고,
save_session_v5의 선언 장애는 실제 충돌 400 하나로 했다. 옛 탭이 살아 있는 동안 대기열에 503 을 넣으면 옛 탭도 회복 때 같은 행을 보내 시도 번호가 비결정적이 되므로(옛 탭은successorOf를 모른다) 이 편이 맞다. - headless Chromium 에서는
bringToFront만으로visibilitychange가 오지 않아 e2e 가online·visibilitychange를 직접 준다. B 탭이 기록 생성 전에 열려 있으면 달력이 옛 데이터를 보여 새로고침이 필요했다. - 세션 한도: 이전 세션이 CASE-040 문서 작성 중 한도로 끊겨 이 세션이 이어받았다(인수 댓글 §21).
Phase 4 — 문서·인계 ((같은 PR))
계약 §7 실측·§8 인계(S05·S06·S07·R02) · ADR §3-1 표 S04 행 3개 구현 링크 · codec 계약 §5 "S04 가 켰다" + §6 S04 행 완료 · 도메인 계약 §12(활성)·§17 OutboxPort · 쓰기 파이프라인 §3·§11 · 원자 교체 계약 §1 조건표 행 분할·§6 · G05 규칙 1줄(docs/contracts/outbox-claim-successor.md) + durable 장부 controller/service 행 갱신 + 재생성 · 이 기록 + 등록 2곳.
Phase 5 — 검증·PR·랜딩
ci:local --full 13분 58초 — verify(8단계)·db reset·schema 스냅샷·pgTAP 117파일/2036 assert·동시 저장 세대·e2e-local 11/11·empty 7/7·cardio 6/6·persistence 15/15·viewport 14/14 통과, e2e-browser 38/39. 실패 1건은 CASE-015 가 대기열 행의 필드 집합을 정확히 고정한 단언이라 새 writer 의 claim·evidence·writer 를 몰랐고, 시도마다 바뀌는 attempt 메타까지 identity 로 비교하던 것 — 결함이 아니라 계약 변경. 단언을 새 계약으로 갱신(재시도 시 fence·attemptCount +1, 실패 뒤 claim 만료 처리 단언 추가, 82f45ba1) → 브라우저 레인 재실행 39/39(7분 43초). 리베이스 2회(main 0775e6b2 A07·S03·B02·D02 합류 — codec 계약 §6 표·장부 규칙 JSON·장부 md 충돌 3건 해결, 코드 충돌 없음 · main e860d7b0 문서 2개, 충돌 없음) → PR #1369 → CI 1차(run 34140643136) unit-tests 2건 실패 → 수리(2abca52c) → CI 2차(run 34141431432) 전 잡 success → npm run landing:request -- --pr 1369(요청 a6c566e6, run 34142011762) → squash 머지 f8334135 · staging deploy run 34142049591 success.
작업 중 드러난 것
- 사전 검증 누락 2건(로컬로 재현 가능했음): ① CASE-015 스펙 소스 검사(
tests/react/case015WriteSurvivalContract.test.mjs)가 "단언 완화 표현 금지" 규칙(toBeGreaterThanOrEqual등)을 두는데, CASE-015 를 고친 뒤npm run check를 다시 돌리지 않고 e2e 만 돌렸다 → CI 에서 잡힘. 정확한 값(fence 1·attemptCount 1·lastErrorCode)으로 바꿨다. ② 삭제 컷오버 테스트 ④가 생성 행과 의도 행이 같은 밀리초에 적히면 savedAt 순서가 정해지지 않아 CI 에서만 뒤집혔다 → 순서 무관 비교로. - main 소유 플레이키: 로컬
npm run check병렬 실행에서tests/auth/accountDeleteApi.test.mjs의 시간 초과(504) 검사가 1회 실패, 단독 3회 통과·이 브랜치에서 무변경(B01 #1331 소유). CI 에서는 통과. - 랜딩 큐는 PR CI 초록 + merge-base = 현재 main 을 요구한다 — 요청 직전 fetch 로 확인했다.
5. 적용 결과
| 항목 | 전 | 후 |
|---|---|---|
| 전송 중 행 위의 새 편집 | 전송 중 행 삭제 → 늦은 실패 답장이 부활시킴 | 전송 중 행 보존 + 후속 행(successorOf), 부활 0(메모리·실제 Chromium·CASE-040) |
| 두 새 탭이 같은 행을 동시에 보냄 | 2회 | 1회(claim 하나만 커밋, 진 탭은 stale) |
| 다른 탭·옛 fence·다른 소유자의 늦은 답장 | 현재 상태를 덮음 | 무변경(ACK 4종 전부 "내 탭·내 fence" 검증) |
| 전송 중 탭 종료 | 10초 뒤 다른 탭이 되돌림(옛 규칙) | 같음(lease 10초) + 인계 시 fence 상승·재시작 뒤 행 하나 |
| 새 기록 전송 중 수정·삭제 | 메모리 대기, 탭 종료 시 유실·다른 탭 답장 시 오류 | 의도 행으로 기기 보존, 생성 영수증 뒤 준비된 행으로 자동 변환 |
| 답장 미상 선행과 후속의 순서 | 후속이 먼저 나갈 수 있음 | 선행 먼저(같은 id 재확인), 후속은 선행 확정 뒤 |
| 옛 탭(v0.17.1)과 새 탭 공존 | CASE-039 v0.17.0 실측 | CASE-039 v0.17.1 재실측 통과 + CASE-040 3/3 |
| 저장 형식·서버 | v1 | v1, 추가 필드만(claim·successorOf·evidence·writer), 서버 무변경 |
- 자동 검증:
npm run checkPhase 마다 통과 ·npm run test:persistence-browser4/4 · CASE-040 3회 연속·CASE-039 v0.17.1 (로컬 샌드박스6fe690a7, 2026-09-08) ·ci:local --full통과(브라우저 레인 재실행 39/39) · CI run 34141431432 전 잡 success · 랜딩 run 34142011762 · staging deploy run 34142049591 success - 미검증: 옛 탭이 생성 행을 먼저 지운 창(옛 탭은 의도 행 규칙을 모른다)의 고아 의도 행은
blocked(LG_PREDECESSOR_RECEIPT_UNKNOWN)로 보존만 한다 — 화면 표시(U02/U03)와 receipt query RPC(S05·서버 트랙)는 이 트랙 밖. 실제 기기(iOS WKWebView·Android WebView)의 IndexedDB·BroadcastChannel 의미론은 Chromium 과 같다고 가정한다.
6. 이번 개선으로 향상된 것
연달아 고쳐도 마지막 의도가 살아남는다
전송 중인 첫 요청은 지워지지 않고, 두 번째 편집은 후속 행으로 남아 첫 요청의 결과가 확정된 뒤에 나간다. 옛 요청의 늦은 답장은 아무것도 되살리지 못한다.
탭을 여러 개 열어도 같은 행을 두 번 보내지 않는다
행을 집은 탭만 보내고, 그 탭이 죽으면 10초 뒤 다른 탭이 번호를 올려 이어받는다. 죽은 탭의 늦은 답장은 이어받은 탭의 상태를 바꾸지 못한다.
새 기록을 바로 고쳐도 기기에 남는다
서버 id 가 없는 기록의 수정·삭제 의도가 기기에 저장되고, 영수증이 오면 자동으로 서버 id 요청으로 바뀐다.
구조적으로 남는 것
- claim·ACK 계획기와 전송 가능 판정(
claimPlan.ts) — S05 dispatcher 가 그대로 부른다.prepareSuccessor·findCreateReceipt포트 형태. - fence 는 행이 살아 있는 동안 오르기만 한다 — S07 index/mirror 가 보존해야 할 의미(종결 tombstone 필요).
- 실제 옛 번들 탭 공존 e2e 두 개(CASE-039·CASE-040)와 실제 Chromium 두 탭 스위트 — 앞으로의 대기열 변경은 같은 방식으로 실측된다.
- 조합 표(계약 §6)가 문서로 있다.
남은 것
- 릴리스 v0.18.0 머지 뒤 이슈
[v0.18.0 반영완료]+ 닫기. - 총괄 S04 카드 상태 갱신(HQ 몫 — 이슈 마지막 댓글에 갱신안).
- S05:
wait-for-create죽은 분기 정리 · 영수증으로 후속을 미리 재준비(충돌 왕복 1회 절감) · receipt query 포트 구현. S06: 충돌 재전송을 후속 행으로 영속화. S07: fence·successorOf·종결 의미 보존. U02/U03: 고아 의도 행 표시.