<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0">
    <channel>
        <title><![CDATA[shj.rip]]></title>
        <description><![CDATA[shj.rip blog]]></description>
        <link>https://shj.rip</link>
        <image>
            <url>https://shj.rip/images/og-image-index.png</url>
            <title>shj.rip</title>
            <link>https://shj.rip</link>
        </image>
        <generator>shj.rip blog</generator>
        <lastBuildDate>Sun, 05 Apr 2026 08:05:39 GMT</lastBuildDate>
        <atom:link href="https://shj.rip/rss.xml" rel="self" type="application/rss+xml"/>
        <pubDate>Sun, 05 Apr 2026 08:05:39 GMT</pubDate>
        <language><![CDATA[ko]]></language>
        <item>
            <title><![CDATA[clarify codex skill]]></title>
            <link>https://shj.rip/article/auwui-clarify-skill.html</link>
            <guid isPermaLink="false">auwui-clarify-skill</guid>
            <dc:creator><![CDATA[shj]]></dc:creator>
            <pubDate>Sat, 04 Apr 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://shj.rip/article/auwui-clarify-skill.png" length="0" type="image/png"/>
            <content:encoded>## 시작애매한 요청으로 구현하게 되면 planning 단계와 AskUserQuestion을 거친다 해도 디테일을 놓칠 때가 종종 있다. 이를 보완하고자 cc에서는 https://github.com/team-attention/plugins-for-claude-natives?tab=readme-ov-file#clarify `clarify` plugin 을 사용했다. 이름 그대로 모호한 요구사항을 질문으로 쪼개서 실제로 작업 가능한 형태로 바꾸는 역할이다. 이게 codex 용으로는 없었다. 그래서 codex 에게 분석 및 포팅해달라고 했다.특히 openclaw 사용할 때 유용하다. 일반적으로 아래와 같은 흐름으로 진행되는데:- 원문 요구사항을 먼저 그대로 적는다- ambiguity 를 찾는다- 한 번에 하나씩, 선택지 중심으로 질문한다 (예: 선택지 주고 내가 거기에 1, 2, 3 번호로 답하기)- 마지막엔 Before/After 로 정리한다- 원하면 파일로 저장한다질문 방식도 규칙이 있다.- specific over general- options over open-ended- one concern at a time- neutral framing아무튼 planning 단계에서 빠진 결정들이나 디테일을 모두 챙겨준다. skill 원문에도 보면 `No assumptions`, `Preserve intent`, `Minimal questions` 같은 규칙이 들어가 있음을 볼 수 있다.## Codex Skill현재 local codex global `clarify` skill 은 아래와 같다.&lt;details&gt;&lt;summary&gt;clarify&lt;/summary&gt;````md---name: clarifydescription: This skill should be used when the user asks to &quot;clarify requirements&quot;, &quot;refine requirements&quot;, &quot;specify requirements&quot;, &quot;what do I mean&quot;, &quot;make this clearer&quot;, or when the user&apos;s request is ambiguous and needs iterative questioning to become actionable. Also trigger when user says &quot;clarify&quot;, &quot;/clarify&quot;, or mentions unclear/vague requirements.---# ClarifyTransform vague or ambiguous requirements into precise, actionable specifications through iterative questioning.## PurposeWhen requirements are unclear, incomplete, or open to multiple interpretations, use structured questioning to extract the user&apos;s true intent before any implementation begins.## Protocol### Phase 1: Capture Original RequirementRecord the original requirement exactly as stated:```markdown## Original Requirement&quot;{user&apos;s original request verbatim}&quot;```Identify ambiguities:- What is unclear or underspecified?- What assumptions would need to be made?- What decisions are left to interpretation?### Phase 2: Iterative ClarificationAsk the user iteratively to resolve each ambiguity. Continue until ALL aspects are clear.**Question Design Principles:**- **Specific over general**: Ask about concrete details, not abstract preferences- **Options over open-ended**: Provide 2-4 choices (recognition &gt; recall)- **One concern at a time**: Avoid bundling multiple questions- **Neutral framing**: Present options without bias**Loop Structure:**```while ambiguities_remain:    identify_most_critical_ambiguity()    ask_the_user_a_clarifying_question()    update_requirement_understanding()    check_for_new_ambiguities()```**Question Format:**Ask the user with a clear question and provide labeled options with descriptions. For example:&gt; **Question**: What authentication method should be used?&gt;&gt; Options:&gt;&gt; 1. **Username/Password** - Traditional email/password login&gt; 2. **OAuth** - Google, GitHub, etc. social login&gt; 3. **Magic Link** - Passwordless email link### Phase 3: Before/After ComparisonAfter clarification is complete, present the transformation:```markdown## Requirement Clarification Summary### Before (Original)&quot;{original request verbatim}&quot;### After (Clarified)**Goal**: [precise description of what user wants]**Scope**: [what&apos;s included and excluded]**Constraints**: [limitations, requirements, preferences]**Success Criteria**: [how to know when done]**Decisions Made**:| Question | Decision ||----------|----------|| [ambiguity 1] | [chosen option] || [ambiguity 2] | [chosen option] |```### Phase 4: Save OptionAsk the user if they want to save the clarified requirement:&gt; **Question**: Save this requirement specification to a file?&gt;&gt; Options:&gt;&gt; 1. **Yes, save to file** - Save to requirements/ directory&gt; 2. **No, proceed** - Continue without savingIf saving:- Default location: `requirements/` or project-appropriate directory- Filename: descriptive, based on requirement topic (e.g., `auth-feature-requirements.md`)- Format: Markdown with Before/After structure## Ambiguity CategoriesCommon types to probe:| Category        | Example Questions                       || --------------- | --------------------------------------- || **Scope**       | What&apos;s included? What&apos;s explicitly out? || **Behavior**    | Edge cases? Error scenarios?            || **Interface**   | Who/what interacts? How?                || **Data**        | Inputs? Outputs? Format?                || **Constraints** | Performance? Compatibility?             || **Priority**    | Must-have vs nice-to-have?              |## Examples### Example 1: Vague Feature Request**Original**: &quot;Add a login feature&quot;**Clarifying questions (ask the user iteratively)**:1. Authentication method? -&gt; Username/Password2. Registration included? -&gt; Yes, self-signup3. Session duration? -&gt; 24 hours4. Password requirements? -&gt; Min 8 chars, mixed case**Clarified**:- Goal: Add username/password login with self-registration- Scope: Login, logout, registration, password reset- Constraints: 24h session, bcrypt, rate limit 5 attempts- Success: User can register, login, logout, reset password### Example 2: Bug Report**Original**: &quot;The export is broken&quot;**Clarifying questions**:1. Which export? -&gt; CSV2. What happens? -&gt; Empty file3. When did it start? -&gt; After v2.1 update4. Steps to reproduce? -&gt; Export any report**Clarified**:- Goal: Fix CSV export producing empty files- Scope: CSV only, other formats work- Constraint: Regression from v2.1- Success: CSV contains correct data matching UI## Rules1. **No assumptions**: Ask, don&apos;t assume2. **Preserve intent**: Refine, don&apos;t redirect3. **Minimal questions**: Only what&apos;s needed4. **Respect answers**: Accept user decisions5. **Track changes**: Always show before/after````&lt;/details&gt;</content:encoded>
            <category>auwui</category>
        </item>
        <item>
            <title><![CDATA[OpenClaw 살아있게 만들기]]></title>
            <link>https://shj.rip/article/auwui-keep-openclaw-alive.html</link>
            <guid isPermaLink="false">auwui-keep-openclaw-alive</guid>
            <dc:creator><![CDATA[shj]]></dc:creator>
            <pubDate>Sat, 28 Mar 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://shj.rip/article/auwui-keep-openclaw-alive.png" length="0" type="image/png"/>
            <content:encoded>밖에서 PC 접속이 어렵기에 나는 OpenClaw가 영원히 살아있기를 원한다. 그리고 몇 가지 시행착오를 겪었다. 여기에 계속 업데이트 할 예쩡.### KeepAliveOpenClaw가 스스로 죽이는 멍청한 행동을 할 때가 있다. 그래서 죽어도 계속 살아있도록 launchd KeepAlive 이용해 OpenClaw 를 실행했다.### Auto Restart (Gateway, Telegram)그런데 또 어느날 동작을 안했다. 보니까 Telegram -&gt; OpenClaw 전달 경로 자체가 불안정한 것이 원인. Telegram 에서 메시지 보내도 응답이 없었다. 집가서 확인해보니 OpenClaw 프로세스 자체는 살아있었다. 그러나 telegram 쪽에서는 fetch fallback 이 반복되거나 polling 경로가 불안정한 로그가 존재했다.저번에 OpenClaw Gateway가 멋대로 죽었기도 했어서- OpenClaw Gateway 통신(rpc) 실패 시 즉시 restart- telegram degraded 연속적으로 실패할 때 restart두 곳에 대해 Watchdog 같은 것으로 Auto Restart 하도록 구성했다.### SSH, Control UI하지만 이것도 좀 불안하다. 내가 마주하지 못한 어떤 상황이 있을지 모르고 그래서 원격으로도 접근할 수 있도록 구성했다.Tailscale을 사용했다. Mesh VPN(P2P) 기반으로 동작한다고 한다. ssh 및 OpenClaw Control UI를 tailnet(vpn) oply로 열어, 외부에서도 상태 확인이나 직접 restart 가능하게 구성했다.### OpenAI Token Expired```⚠️ Agent failed before reply: OAuth token refresh failed for openai-codex: Failed to refresh OAuth token for openai-codex. Please try again or re-authenticate.Logs: openclaw logs --follow```그렇게 마음놓고 있었는데, 어느날 메시지 보내니 이런 응답이 계속 전달되었다. 이건 해당 PC에서 `openclaw models auth login --provider openai-codex` 명령으로 OpenAI 재로그인해 토큰 새로 발급받으면 해결된다. 문제는 그 로그인이 브라우저를 이용해야 한다는 것.그래서 이것도 결국 집에 와서야 고쳤다. 이건 조금 우회하는 방식을 이용했는데, TTL이 10일정도밖에 안되어서 Refresh Token 이용해 만료 3일 전부터 아래와 같은 시도 &amp; 메시지 전달되게 했다.```md### 1. 알림 기준- 만료 3일 초과: 알림 없음- 만료 3일 전 ~ 1일 전: 24시간마다 알림- 만료 1일 이내: 6시간마다 알림- 이미 만료 후: 6시간마다 알림### 2. 알림 시각에 하는 일알림 시각이 오면, 순서는 항상:1. 현재 access token 만료 상태 확인2. refresh token으로 1회 재발급 시도3. 성공/실패와 상관없이 알림 발송4. 알림 본문에 refresh 결과 포함즉, **&quot;알림 타이밍 = refresh 시도 타이밍&quot;**### 3. refresh 결과 처리- 3일~1일: 하루 1번- 1일 이하/만료 후: 6시간마다성공- 알림은 반드시 1회 발송- 알림에:  - 재발급 성공  - 이전 만료시각  - 새 만료시각- 다음 알림은 새 만료시각 기준으로 재계산됨- 이후에는 새 expiry 기준으로 다시 3일 전까지 조용히 대기실패- 알림은 반드시 1회 발송- 알림에:  - 재발급 실패  - 가능하면 실패 사유/코드  - 현재 만료시각 또는 이미 만료 여부  - 재로그인 액션 안내  - 이후 cadence는 현재 상태 기준으로 계속```전달되는 메시지```[openclaw] Codex access token alert (1 day or less remaining)Model: openai-codex/gpt-5.4Profile: openai-codex:defaultChecked (KST): 2026-03-28, 3:15:50 p.m.Previous expiry (KST): 2026-03-28, 9:15:50 p.m.Previous remaining: 0d 12h 0mRefresh result: skippedRefresh detail: Dry run: refresh request not sent.Current expiry (KST): 2026-03-28, 9:15:50 p.m.Current remaining: 0d 12h 0mCurrent status: 1 day or less remaining```동작 성공도 확인했다 -&gt; [OpenClaw 사용기(지속적인 업데이트)](/article/auwui-openclaw-review.html#260405)</content:encoded>
            <category>auwui</category>
        </item>
        <item>
            <title><![CDATA[AI 토큰 사용량을 확인해보니]]></title>
            <link>https://shj.rip/article/auwui-ai-token-usage-report-260320.html</link>
            <guid isPermaLink="false">auwui-ai-token-usage-report-260320</guid>
            <dc:creator><![CDATA[shj]]></dc:creator>
            <pubDate>Fri, 20 Mar 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://shj.rip/article/auwui-ai-token-usage-report-260320.png" length="0" type="image/png"/>
            <content:encoded>회사 Team 계정과 이전에 사두었던 카카오 OpenAI Pro 이용권 둘 모두 정말 잘 사용하고 있다.. 특히 카카오는 당시엔 이게 맞나 싶었는데 사두길 정말 잘했다. 보니까 달 토큰 사용량이 엄청나다.- codex: 1,113,853,377 + 2,018,202,308 + 152,085,882 + 224,970,824 = 3,509,112,391- claude: 1,295,991,757 + 1,043,799,657 = 2,339,791,414합쳐서 약 58억 개.. 시행착오가 많아지니 좀 더 어떻게 프롬프트 넣어야 하거나 어떻게 사용하는지 조금 감이 생기는 것 같다.</content:encoded>
            <category>auwui</category>
        </item>
        <item>
            <title><![CDATA[OpenClaw 사용기 (지속적 업데이트)]]></title>
            <link>https://shj.rip/article/auwui-openclaw-review.html</link>
            <guid isPermaLink="false">auwui-openclaw-review</guid>
            <dc:creator><![CDATA[shj]]></dc:creator>
            <pubDate>Fri, 20 Mar 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://shj.rip/article/auwui-openclaw-review.png" length="0" type="image/png"/>
            <content:encoded># 260320OpenClaw 라는 것을 사용한지 4주정도 지났다. Telegram에 붙여 사용하고 있다. 처음에는 이걸로 뭘 해야할지 막막했는데 쓰다보니 조금 감이 잡힌다. 일단 실제 컴퓨터에서 돌아가는 Codex를 어디서든 사용할 수 있다는게 가장 편하다. 지금까지 아래 작업을 했다.- 일정 시간마다 CCTV 캡처해 고양이 어디에 있나 알림- OpenClaw 수리: 직접 사용해보니 예상과 다르게 동작한다거나, 지침이나, 망가졌을때 대비해서 계속 살려두거나, Codex Auth 토큰 만료전 알림이나, Telegram Topic으로 Chat 하기나, Codex CLI Local Skills 사용하기나, ... 그런 자잘한 손봐줘야 하는 부분들이 많다- 오픈소스, 툴 유지보수 (번역 관리 오픈소스 yuki-no, 기술 팟캐스트봇, url 요약봇 등)- 개인 프로젝트: 마음에 드는 기프티콘 관리 서비스가 없어 만들어보고 있다 / 이거 말고도 자신이 사용하는 AI 코딩 에이전트 환경 최신 가이드 잘 따르는지 확인/가이드/구성해주는 CLI 툴도 만들어보는 중- 그 외: 뭔가 있었는데 기억이 잘 안남출퇴근 할 때 chat 보내고 말해보카 하고 그러는데 뭔가 밀려드는 작업 쳐내는 느낌이라 뭔가 재밌다. 잘 쓰고있던 codex clarify skill 도 구성해뒀어서 애매한 질문해도 알아서 명확화해주니 편하기도 하다.슬슬 tts-stt 붙여서 좀 더 편하게 하고싶다는 마음도 있다. 가능성이 많은 툴이다. 가령 Telegram 으로 블로그 글 이렇게 쓸테니 그대로 업로드해달라거나..# 260405![codex-auth-rt](/images/auwui-260320/codex-auth-rt.png)Codex Auth Token 만료된다는 알람과, 실제 RT로 자동 갱신하는 로직이 잘 동작한다. 야호!</content:encoded>
            <category>auwui</category>
        </item>
        <item>
            <title><![CDATA[e2e codex skill w/ chrome-devtools-mcp]]></title>
            <link>https://shj.rip/article/diff-aware-web-e2e-codex-skill.html</link>
            <guid isPermaLink="false">diff-aware-web-e2e-codex-skill</guid>
            <dc:creator><![CDATA[shj]]></dc:creator>
            <pubDate>Wed, 18 Mar 2026 00:00:00 GMT</pubDate>
            <enclosure url="https://shj.rip/article/diff-aware-web-e2e-codex-skill.png" length="0" type="image/png"/>
            <content:encoded>## 시작요즘 구현 끝낸 뒤 chrome-devtools-mcp로 e2e 테스트를 진행하고 있다.```md/clarifychrome-devtools-mcp 를 이용한 e2e 테스트 계획을 세우자.대상은 refs/origin/develop 대비 현재 브랜치에서 변경된 모든 커밋/변경사항이야.어떤 방향으로 무엇(UI)을 테스트해야하고, 그것이 어떤 결과를 가져야 하는지 먼저 happy-path 를 구성해보자.진행 시 interrupts req + mock data 이용하는데, 절대로 기존 서버 req/res 구조를 변경해서는 안돼.서버 API 문서는 https://.../ 여기를 참고할 수 있어.또한 절대로 임의 추측/판단하지 말고, 반드시 실제 데이터/코드/문서/조사결과만을 바탕으로 진행하자.```## Codex Skills이 claude-code 프롬프트에서 시작해 지금은 codex 로 아래와 같이 스킬을 구성했다. ctx가 1M 이기도 하고, 5h/1w 한도가 널널해서 codex로 하기로 했다.&lt;details&gt;&lt;summary&gt;diff-aware-web-e2e&lt;/summary&gt;```md---name: diff-aware-web-e2edescription: Plan all impacted web E2E paths for current branch changes against a user-provided base ref, then execute only the user-selected paths with chrome-devtools-mcp. By default, use code-derived page-level request and response interception plus mock data unless the user asks for real API behavior. Use when the user wants evidence-based UI test planning and execution without changing server request or response contracts.---# Diff-Aware Web E2EUse this skill when the user wants to turn current branch changes into concrete, evidence-based web E2E checks.## What This Skill Does- Reads `&lt;base_ref&gt;...HEAD` changes to find impacted UI areas.- Produces a plan-level user product intent summary before scenario planning output.- Builds a full scenario inventory covering directly impacted user paths plus diff-backed edge-case and regression-focus checks.- Derives request and response shapes from code and docs before planning default interception and mock data.- States the planned mock target request, mock target response, and mock verification approach in plan output when API behavior matters.- Writes scenarios with step-by-step actions and step-by-step expected UI states.- Shows the full planned inventory, marks a recommended set with reasons, then asks the user which scenario IDs or recommended set to execute.- Optionally executes only the selected scenarios with `chrome-devtools-mcp`.- Automatically cleans up run-owned Chrome and `chrome-devtools-mcp` OS processes before any relaunch, after execution work, and after QA handoff markdown writing.- Renders QA handoff screenshots inline with Markdown image syntax `![]()` instead of plain file links.- Reports only evidence-backed results.## Default ModeStart in **plan-only** mode and do not execute until the user chooses which planned scenarios to run.## InputsCollect only what is missing:- `mode`: `plan` or `execute`- `base_ref`: required; compare `&lt;base_ref&gt;...HEAD`- `change_focus`: optional user concern or priority area- `target_area`: optional specific feature or page- `api_docs_url`: optional; use when provided- `mock_mode`: `auto` (default; use interception plus mock data unless the user says otherwise), `required`, or `off`## Core Rules- Never invent affected pages, API behavior, or expected results.- If `base_ref` is missing, ask the user for it and stop until it is provided.- Before the scenario inventory, provide a plan-level user product intent summary.- Build the user product intent summary from user input first, then supplement with defensible diff or related code evidence when needed.- Structure the user product intent summary using `Confirmed` and `Inferred`.- If product intent remains partially unclear, include `Open Questions` or `Unclear Intent` and continue planning when the scenarios are still defensible.- Keep the user product intent summary informative only; do not change scenario priority or recommended-set logic just because of it.- Derive every scenario from at least one concrete source:  - changed code  - tests or stories  - API docs  - observed browser or network evidence- Unless the user explicitly asks for real API behavior or `mock_mode: off`, treat chrome-devtools-mcp-based page-level request and response interception plus mock data as the default strategy.- Derive mock request and response shapes from code and docs before planning or executing page-level interception and mocking.- Plan the full impacted scenario inventory before suggesting execution.- Cover directly impacted user paths plus same-screen or same-flow paths, and include diff-backed edge-case and regression-focus scenarios when they are defensibly tied to the change.- Expand each planned path until a clear completion state is reached.- Include step-by-step user actions and step-by-step expected UI states.- When API behavior matters, say which request and response will be intercepted or mocked and how mock verification will be checked for each scenario.- Provide scenario IDs, priority, and a recommended set with reasons for planned paths.- Never auto-select or auto-execute the recommended set.- Do not change server request or response contracts.- Before any run-owned Chrome or `chrome-devtools-mcp` launch, briefly say that this skill will automatically remove the run-owned browser and MCP processes after work.- Cleanup targets are limited to run-owned Chrome, remote-debugging Chrome, and `chrome-devtools-mcp` OS processes started during the current chat and current skill invocation.- Before launching a replacement run-owned browser or MCP for recovery or QA capture work, clean up the current run-owned processes first.- After execution ends in `pass`, `fail`, or `block`, clean up run-owned Chrome and `chrome-devtools-mcp` OS processes before any follow-up result or QA handoff message.- If QA handoff writing needs additional captures, a new run-owned launch is allowed only after the current run-owned processes are cleaned up first.- After the QA handoff markdown file is written, clean up any run-owned Chrome and `chrome-devtools-mcp` OS processes before the follow-up message.- Treat cleanup as complete only when the owned OS processes are actually gone.- Briefly report cleanup success. If cleanup fails, briefly report that the run-owned browser or MCP process may still remain, then continue.- Never terminate `chrome-devtools-mcp`, Chrome, or remote-debugging processes that this run did not start.- If evidence is insufficient, ask or stop.## Evidence Order1. Changed code and tests2. Provided API docs3. Runtime DOM or network evidence4. User confirmationSee [evidence-rules.md](references/evidence-rules.md).## Planning Workflow1. If `base_ref` is missing, ask the user for it and stop.2. Read the diff against `&lt;base_ref&gt;...HEAD`.3. Find directly impacted UI entry points and related routes.4. Read only the minimal supporting code, tests, and docs needed to map the full impacted scenario inventory.5. Derive request and response shapes from code first by tracing the changed UI trigger, API caller, request builder, shared client, and response consumer.6. If `api_docs_url` is provided, inspect it to confirm endpoint purpose and response shapes.7. Before the scenario inventory, produce a plan-level user product intent summary that includes:   - `Confirmed`: user-stated intent or intent made explicit in provided product context   - `Inferred`: defensible intent inferred from the diff or related code   - `Open Questions` or `Unclear Intent` when intent remains partially unresolved   - brief evidence notes showing why each item is defensible8. For each scenario, produce:   - scenario ID   - coverage relation: `direct impact`, `same-screen branch`, or `same-flow regression`   - scenario objective: `primary`, `edge-case`, or `regression-focus`   - target UI or flow   - why it is tied to the diff   - ordered user actions   - step-by-step expected UI states   - request and response derivation evidence when API behavior matters   - default execution strategy: mocked or real API fallback   - mock target request and response when API behavior matters   - mock verification plan   - injection approach only when it is needed to explain feasibility   - priority   - recommended-set status and reason9. Show the user product intent summary, show the full scenario inventory, show the recommended set, and ask the user which scenario IDs or recommended set should move to execution.See [planning-rules.md](references/planning-rules.md).## Execution WorkflowUse this only after the user chooses which planned scenario IDs or recommended set to execute.1. Before any run-owned launch, briefly say that this skill will automatically remove the run-owned browser and MCP processes after work.2. If this same chat and skill invocation already owns run-owned Chrome or `chrome-devtools-mcp` OS processes from a prior attempt, clean them up before starting a replacement launch.3. Start a run-owned isolated Chrome and `chrome-devtools-mcp` context.4. Check that the current MCP runtime supports isolated execution, timeout tuning, and log capture. If that is clearly missing, report `block` with the missing preconditions.5. Do not attach cleanup behavior to any Chrome or MCP process not started by this run.6. Load the planned request and response derivation evidence together with the planned mock targets and verification checks.7. Unless the user opted out or the selected scenario was explicitly planned as a real API fallback, apply the planned page-level request and response interception and mock data with `initScript` or `evaluate_script` using only code-derived or doc-derived payload shapes.8. If needed, navigate to the selected target path.9. Wait for stable UI evidence before judging results.10. Use snapshots for structure and screenshots for reporting.11. Inspect console and network activity for corroborating evidence.12. On connection failure, retry in-place, then clean up the current run-owned browser and MCP processes, then start a replacement isolated run-owned instance, then collect logs and report `block` if recovery still fails.13. Determine `pass`, `fail`, or `block`.14. Clean up the run-owned Chrome and `chrome-devtools-mcp` OS processes before any follow-up result or QA handoff message.15. Briefly report the selected path result together with the cleanup status.Use these `chrome-devtools-mcp` capabilities when relevant:- `list_pages`- `select_page`- `new_page`- `navigate_page`- `wait_for`- `take_snapshot`- `take_screenshot`- `list_network_requests`- `get_network_request`- `list_console_messages`- `evaluate_script`See [execution-rules.md](references/execution-rules.md).## Mocking Policy- Unless the user explicitly asks for real API behavior or `mock_mode: off`, treat chrome-devtools-mcp-based page-level request and response interception plus mock data as the default strategy.- Planning should assume mocked execution first and describe the target request, target response, and mock verification approach for each scenario when API behavior matters.- Use code-first request and response derivation before deciding whether page-level mocking is safe.- First check whether page-level mocking is sufficient.- Page-level mocking may use `initScript` or `evaluate_script` to patch `fetch` or `XMLHttpRequest`.- For mocked flows, do not require a real network request as mandatory evidence.- Verify that mocking actually took effect using observable DOM evidence, explicit mock-hit evidence, or both.- Do not claim this is full browser-level interception.- If page-level mocking is unreliable or the scenario needs broader interception than it can safely cover, fall back to real API behavior or report `block`.## Output Format### Plan Mode- User product intent summary shown before the scenario inventory- `Confirmed`, `Inferred`, and `Open Questions` or `Unclear Intent` when needed- Evidence notes for the user product intent summary- Full impacted scenario inventory- Scenario ID, coverage relation, scenario objective, priority, and recommended-set status for each scenario- Recommended set with a short reason for each included scenario- Evidence for each scenario- Step-by-step actions- Step-by-step expected UI states- Request and response derivation evidence when API behavior matters- Default execution strategy- Mock target request and response when API behavior matters- Mock verification plan- Injection approach only when it is needed to explain feasibility- A final clarify step asking which scenario IDs or recommended set should move to execution### Execute Mode- Selected path result: `pass`, `fail`, or `block`- Run-owned cleanup status note- Screenshot- Brief evidence summary- Relevant network notes, or mock-hit notes for mocked flows- Mock verification notes- Recovery notes if connection handling was needed- A concise summary of the execution steps used to get to the result## Completion Wrap-Up- After all planned or selected execution work is complete, summarize the execution steps used:  - diff basis  - path selection logic  - request and response derivation basis  - execution setup  - mocking approach  - evidence collected  - blockers or recoveries- If execution was performed, always ask whether to create a concise QA handoff document in Korean aimed at QA or planners.- If the user says yes, do not draft the QA handoff document yet.- First produce a concise QA handoff plan and ask the user to approve that plan before writing any file.- The QA handoff plan must include:  - proposed `.md` save path with a single recommended location for user confirmation  - intended audience  - document section outline  - scenario coverage to include  - existing screenshots that are good enough to reuse  - additional screenshots that must be captured or recaptured  - device context for each screenshot: desktop or mobile  - why each screenshot is needed for fast QA understanding- Do not draft the QA handoff document unless the user approves that QA handoff plan.- After the user approves the QA handoff plan, write the QA handoff as a `.md` file.- If QA handoff writing needs additional captures, start a new run-owned Chrome and `chrome-devtools-mcp` context only after cleaning up any current run-owned browser or MCP processes from this same chat and skill invocation.- After the QA handoff markdown file is written, clean up any run-owned Chrome and `chrome-devtools-mcp` OS processes before the follow-up message, and briefly report the cleanup status.- If the user asks for the QA handoff document, include:  - write it in Korean for QA or planning audiences  - scenario ID and title  - goal  - scope or covered user path in product terms  - setup and mock strategy in audience-friendly terms  - steps  - expected UI  - actual evidence  - inline screenshots rendered with Markdown image syntax `![]()` and short captions, using element-focused captures with minimal surrounding noise when possible  - network or mock verification notes  - blockers or open risks- Exclude development implementation details, code-level explanations, internal reasoning, and backend contract discussion unless the audience explicitly asks for them.- Do not use plain file links as the primary screenshot presentation in QA handoff markdown.- Use full-page screenshots only when the element-focused capture would hide necessary product context.- Prefer screenshots that center the changed UI with only the surrounding context needed to understand the state.- Match screenshot device context to the product path being documented and say which captures are desktop or mobile.- If the existing screenshots are too broad, show the wrong device context, or do not make the changed UI easy to understand, take additional screenshots before drafting the QA handoff.## When To Stop And Ask- `base_ref` is missing- No reliable UI candidate can be tied to the diff- A completion path cannot be justified from code, docs, or observed behavior- Request or response shapes cannot be derived from code or docs without guessing- Code-derived request or response shapes conflict with observed runtime evidence- The planned scenario inventory is ready and user path selection is required- Mocking is required but safe scope is unclear- Authentication or setup prevents reliable execution- The user approved QA handoff creation and the QA handoff plan still needs approval- The proposed QA handoff save path needs user confirmation- Existing screenshots are not sufficient and additional capture scope still needs confirmation## Non-Goals- Branch-to-branch visual diff systems- App-wide route crawling unrelated to directly impacted paths- Full browser-level request interception guarantees- Mobile WebView-specific flows- Changing backend contracts to make tests easier- Killing externally managed Chrome or MCP processes```&lt;/details&gt;`diff base 전달 - E2E 테스트 계획 - 시나리오 수립 - e2e 테스트 실행(ralph-loop) - QA 가이드 제안 - QA 가이드 작성(옵션, ralph-loop)` 이런 흐름으로 진행한다. diff 사이즈에 따라 다르지만, 시나리오 수립 후 실행하면 opus-4.6(thinking)/gpt-5.4(xhigh+fast) 기준 약 15~40분 정도 작업을 수행한다. 실행 단계에서는 끝까지 수행할 수 있도록 ralph-loop 를 잘 확용하면 좋다.그런데 따로 말해주지 않으면 claude-code 대비 findings(scenarios) 를 조금 덜 찾아준다.. 프롬프트를 좀 조정해 세부적으로 시나리오를 제공하도록 했다.chrome-devtools-mcp는 chrome bin 을 이용한다. 그래서 그런가 조금 자주 실패하기에(특히 `transport closed`), 실패 시 `재시도 - 현재 run-owned 정리 - replacement launch - 그래도 안 되면 로그 남기고 block &amp; 알림` 이 순서로 상황 접근하도록 했다. 참고로 `transport closed` 는 단순히 mcp-브라우저 통신이 닫힌 상황.그래서 병렬 실행을 위해서도 조금 손봐야 한다. Shared browser 가 아닌 `isolated launch` 혹은 그에 준하는 ownership model을 사용하도록 해서 환경 자체를 분리해야 한다. 실행 끝나면 run-owned browser/MCP 정리까지 같이 가져가도록 한 건 그 연장선.`wait_for`는 페이지 진입 후 콘텐츠가 바로 보이지 않을 수 있기에(가령 SPA) 구성해줬다. Puppeteer 에도 이런 API가 있다.req/res는 기본적으로 가로채서 mocking한다. 특히 로그인 필요한 기능이 많아서 종종 핸드오프되기에... 계정(토큰)을 .env나 직접 전달하는건 조금 이상하기도 하고. 물론 이것도 계획에서 사용자에게 물어본다.재밌었던 점은 QA 가이드를 상당히 잘 만들어 준다는 점. QA 뿐만 아니라 기획자 등 비개발자에게 관련 내용을 전달할 때 매우 편했다. 어떤 점이 어떻게 변경되었고, 이를 어떻게 테스트할 수 있는지와 같은... 물론 이 역시 프롬프트에 존재한다. Execution 끝나면 QA 가이드 만들어줄지 물어보고, Yes 하면 세부적으로 계획을 세운다. `목적, 예상 범위, 예상 스크린샷` 중심으로 말해주고, 스크린샷도 markdown inline image로 정리하게 했다.### References docsreferences 디렉터리를 이용해 planning+evidence, execute 시 어떻게 접근해야 하는지도 문서화했다.&lt;details&gt;&lt;summary&gt;Planning Rules&lt;/summary&gt;```md# Planning Rules## ScopePlan the full impacted scenario inventory for changes in the current branch against `&lt;base_ref&gt;...HEAD`.Before the scenario inventory, provide a plan-level user product intent summary.Unless the user explicitly asks for real API behavior or `mock_mode: off`, planning should assume page-level request and response interception plus mock data as the default execution strategy.## User Product Intent SummaryCreate a single plan-level summary before the scenario list.Use these fields:- `Confirmed`: intent explicitly stated by the user or made explicit in provided product context- `Inferred`: defensible intent inferred from the diff or closely related code- `Open Questions` or `Unclear Intent`: unresolved gaps that do not block a defensible scenario planIntent evidence should prefer user input first and use diff or related code only as supporting evidence.Do not change scenario priority or recommended-set logic based on this summary alone.## Candidate UI SignalsUse these signals to infer impacted UI:- route or page files- router config- changed components imported by pages- button, heading, link, or `data-testid` strings- tests, stories, or Playwright specs## Scenario TaxonomyEvery scenario must include both labels:- `coverage relation`: `direct impact`, `same-screen branch`, or `same-flow regression`- `scenario objective`: `primary`, `edge-case`, or `regression-focus`Use defensible pairings:- `primary` usually covers the main changed path and commonly pairs with `direct impact`- `edge-case` covers alternate, boundary, empty, error, or validation states that are defensibly tied to the diff and commonly pairs with `direct impact` or `same-screen branch`- `regression-focus` covers behavior that should remain stable around the changed path and commonly pairs with `same-screen branch` or `same-flow regression`If a scenario needs an unusual pairing, explain why that pairing is justified by the diff or supporting code.## Code-Based Request And Response DerivationWhen API behavior matters, derive request and response shapes from code first:1. identify the changed UI trigger2. find the action handler or event path3. trace the API caller or shared client4. inspect request builders, params, payload keys, and headers5. inspect response consumers, branch conditions, and rendered UI states6. use API docs only to confirm or supplement what code already supports7. use runtime network evidence only to verify or compare against the code-derived understandingIf code and docs do not support a request or response shape, do not invent one.## Scenario ConstructionFor each scenario, include:- scenario ID- coverage relation- scenario objective- target page or flow- changed code evidence proving why the scenario is tied to the diff- start point- completion condition- step-by-step user actions- step-by-step expected UI states- request and response derivation evidence when API behavior matters- default execution strategy: mocked or real API fallback- mock target request and response when API behavior matters- mock verification plan- injection approach only when it is needed to explain feasibility- priority- recommended-set status and reason- confidence and any assumptions that still need user confirmation## Path Expansion- Expand each directly impacted path until the user reaches a clear completion state.- Do not stop at the first changed screen if the affected flow continues.- Include same-screen branches and same-flow regression checks when they are defensibly tied to the changed path.- Include separate diff-backed `edge-case` and `regression-focus` scenarios when the changed path exposes them.- Do not broaden into unrelated feature-wide regression coverage.## Documentation Use- If `api_docs_url` is available, use it to confirm endpoint purpose and response shape after tracing the code path.- If no docs are available, rely on code first and runtime evidence only as supporting verification.- If neither code nor docs support an expected API outcome, do not invent one.## Question PolicyAsk only when a missing fact blocks a defensible plan.If `base_ref` is missing, ask for it before reading the diff.If request or response shapes cannot be derived from code or docs without guessing, ask before planning mocks.If code-derived request or response shapes conflict with runtime evidence in a way that changes the scenario expectation, ask before continuing.If mocked execution looks unsafe or unsupported for the scenario and no defensible real API fallback is available, ask or plan the scenario as `block`.If user product intent is only partially clear but the impacted scenarios are still defensible, continue planning and record the gaps under `Open Questions` or `Unclear Intent`.After presenting the full planned scenario inventory and recommended set, ask the user which scenario IDs or recommended set should move to execution.```&lt;/details&gt;&lt;details&gt;&lt;summary&gt;Evidence Rules&lt;/summary&gt;```md# Evidence Rules## Allowed Evidence- changed source files- tests and stories- official API docs when provided- observed DOM state- observed network requests and responses- observed console output- explicit mock-hit markers exposed by the patched page layer## Path Coverage Standard- The planning result should account for all directly impacted user paths that can be justified from the diff and supporting code.- The planning result should also account for same-screen branch and same-flow regression scenarios when they are defensibly tied to those directly impacted paths.- The planning result should include separate diff-backed `edge-case` and `regression-focus` scenarios when the changed path exposes them.- Each planned path should explain why it is tied to the change and why its coverage relation and scenario objective are justified.- When API behavior matters, each planned path should also say which request and response will be mocked by default and how mock confirmation will be checked.## Disallowed Behavior- inventing routes or user flows- inventing response fields not supported by code or docs- inventing mock payloads not supported by code or docs- claiming pass or fail without a visible or observable signal- changing backend request or response contracts- claiming mocking worked without observable confirmation- requiring a real network request as mandatory proof for a page-level mocked flow## Reporting StandardEach conclusion should be traceable to one or more concrete observations.Each step-level expected UI state should be traceable to code, docs, tests, or observed behavior.When planning a mocked flow, the report should name the intended mock target request, intended mock target response, and mock verification approach in concise scenario-level terms.For page-level mocked flows, acceptable confirmation may come from DOM state, explicit mock-hit markers, console markers, or a real network request when one still occurs.For QA handoff screenshots, prefer relevant element-focused captures with minimal surrounding noise. Use broader page captures only when the focused capture would hide necessary product context.For QA handoff screenshots, the chosen capture should make the changed UI easy to understand quickly for QA readers, not just prove that the page existed.For QA handoff screenshots, match the capture to the documented device context, including desktop versus mobile.If an existing screenshot is too broad, lacks the changed UI focus, or shows the wrong device context, treat it as insufficient and capture a better one before drafting the QA handoff.If code-derived request or response shapes conflict with runtime evidence, say so and stop or report `block` instead of guessing.The final report should also include a short execution-step summary describing how the result was reached.If confidence is low, say so and explain what evidence is missing.```&lt;/details&gt;&lt;details&gt;&lt;summary&gt;Execution Rules&lt;/summary&gt;```md# Execution Rules## Browser Strategy- Each execution owns its own isolated Chrome and `chrome-devtools-mcp` context.- Never terminate Chrome, remote-debugging Chrome, or `chrome-devtools-mcp` processes that this run did not start.- If the user needs to log in manually inside the isolated run-owned browser, pause and resume after the handoff.## Run-Owned Cleanup- Before any run-owned Chrome or `chrome-devtools-mcp` launch, briefly tell the user that this skill will automatically remove the run-owned browser and MCP processes after work.- Cleanup scope is limited to run-owned Chrome, remote-debugging Chrome, and `chrome-devtools-mcp` OS processes started in the current chat and current skill invocation.- Before any replacement launch for recovery or QA captures, clean up the current run-owned browser and MCP processes first.- Attempt cleanup on every execution end state: `pass`, `fail`, or `block`.- After the QA handoff markdown file is written, clean up any remaining run-owned browser or MCP OS processes before the follow-up message.- Treat cleanup as complete only when the owned OS processes are actually gone.- On cleanup success, say briefly that the run-owned browser and MCP processes were removed.- On cleanup failure, say briefly that cleanup failed and the run-owned browser or MCP process may still remain, then continue.## Preflight- Before execution, verify that the active MCP runtime is configured for isolated launches or another equally safe ownership model.- If the runtime clearly lacks isolation, timeout tuning, or log capture, report `block` and name the missing preconditions.## MCP Tool Usage- Use `take_snapshot` to inspect page structure before acting.- Use `wait_for` for stable UI evidence instead of fixed sleeps when possible.- Use `take_screenshot` for final reporting artifacts.- Use `list_network_requests` and `get_network_request` to confirm real API activity or compare against the code-derived understanding when real requests occur.- Use `list_console_messages` to spot frontend regressions or collect explicit mock-hit markers when available.- For QA handoff screenshots, prefer element-focused captures with minimal surrounding noise and use full-page captures only when broader product context is necessary.- For QA handoff markdown, render screenshots inline with Markdown image syntax `![]()` instead of plain file links.- Before drafting a QA handoff document, review whether the existing screenshots clearly show the changed UI state for the intended audience.- If an existing screenshot is too broad, hides the changed UI, or uses the wrong device context, recapture it.- Match the screenshot viewport and framing to the documented product context, including desktop versus mobile.## Connection Recovery- On `transport closed` or similar MCP connection failures, retry once or twice in the current run-owned context.- If retry fails, clean up the current run-owned browser and MCP processes, then recreate the isolated run-owned Chrome and MCP context and continue.- If recovery still fails, enable log capture, preserve the failure evidence, and report `block`.- Prefer explicit logging and timeout tuning over silent retries.- When the client configuration allows it, raise `startup_timeout_ms` and capture `--log-file` output for failed runs.- Prefer isolated run-owned launches over auto-connecting to external browsers for parallel execution.## Result Labels- `pass`: expected UI and supporting evidence match- `fail`: expected UI or supporting evidence clearly diverge- `block`: missing auth, missing data, unsupported mocking, insufficient evidence, or unresolved code versus runtime conflicts## Mocking- Unless the user explicitly asks for real API behavior or `mock_mode: off`, request and response interception with mock data is the default strategy.- Follow the planned mock targets and mock verification checks from plan mode unless runtime evidence forces a safer fallback.- Derive request and response shapes from code and docs before writing any mock payload.- Page-level mocking may patch `fetch` and `XMLHttpRequest`.- For mocked flows, a real network request is optional evidence, not mandatory evidence.- Confirm that mocking took effect with observable DOM evidence, explicit mock-hit evidence, or both.- Treat explicit mock-hit evidence as something the patched layer exposes and can be checked with the page or console state.- Do not present page-level mocking as complete interception coverage.- If the flow depends on navigation requests, service workers, or subresource control, treat that as unsupported unless the project already provides a safe mechanism.- If the planned mock target cannot be intercepted safely at the page level, fall back to real API behavior only when the selected scenario still stays evidence-based; otherwise report `block`.- If request or response shapes cannot be derived without guessing, stop and ask instead of inventing payloads.- If code-derived request or response shapes conflict with observed runtime behavior in a way that changes the selected scenario, stop and ask or report `block`.```&lt;/details&gt;### `agents/openai.yaml`implicit invocation 시 어떤 prompt로 진입할지, 그리고 어떤 순서로 정리할지 명시된 파일. `base_ref` 요구, full scenario inventory, cleanup, QA handoff plan 선행, inline screenshot 규칙까지 이 파일에 같이 녹여뒀다.&lt;details&gt;&lt;summary&gt;openai.yaml&lt;/summary&gt;```yamlinterface:  display_name: &apos;Diff-Aware Web E2E&apos;  short_description: &apos;Plan all impacted paths, then execute chosen ones&apos;  default_prompt: &apos;Use $diff-aware-web-e2e to ask me for the required base_ref, plan the full impacted web E2E scenario inventory for current branch changes against &lt;base_ref&gt;...HEAD, derive request and response shapes from code before proposing mocks, include diff-backed edge-case and regression-focus scenarios, recommend a scenario set, then help me choose which scenarios to execute. Before any run-owned Chrome or chrome-devtools-mcp launch, briefly say that this skill will automatically remove its run-owned browser and MCP processes after work. If a relaunch is needed for recovery or QA captures, clean up any current run-owned browser and MCP processes first. After execution finishes in any result state, clean up the run-owned browser and MCP before follow-up messages or QA handoff questions. If I say yes to QA handoff, do not draft it yet. First propose a concise QA handoff plan with a recommended markdown save path, section outline, screenshot reuse versus recapture plan, and desktop or mobile capture context, then wait for my approval before writing the file. When you write the QA markdown file, render screenshots inline with Markdown image syntax ![]() instead of plain file links, and after the file is written, clean up any run-owned browser and MCP processes again before the follow-up message.&apos;policy:  allow_implicit_invocation: true```&lt;/details&gt;## 팀&gt; 이 스킬 만들 때는 나오지 않았었는데, Codex 도 3월 28일부터인가 Marketplace 를 지원해서 이쪽으로 이동해야 한다...혼자 사용하면 재미없고 발전이 없기에 팀 ai 활용사례? 리포지토리에도 올렸다. 가이드와 함께 설치/검증 스크립트를 제공했다.&lt;details&gt;&lt;summary&gt;Installation&lt;/summary&gt;```sh#!/usr/bin/env bashset -euo pipefailSCRIPT_DIR=&quot;$(cd &quot;$(dirname &quot;${BASH_SOURCE[0]}&quot;)&quot; &amp;&amp; pwd)&quot;REPO_ROOT=&quot;$(cd &quot;${SCRIPT_DIR}/..&quot; &amp;&amp; pwd)&quot;SOURCE_DIR=&quot;${REPO_ROOT}/skills/diff-aware-web-e2e&quot;TARGET_DIR=&quot;${HOME}/.codex/skills/diff-aware-web-e2e&quot;if [[ ! -f &quot;${SOURCE_DIR}/SKILL.md&quot; ]]then  echo &quot;Error: source skill file not found: ${SOURCE_DIR}/SKILL.md&quot; &gt;&amp;2  exit 1fimkdir -p &quot;$(dirname &quot;${TARGET_DIR}&quot;)&quot;if [[ -d &quot;${TARGET_DIR}&quot; ]]then  BACKUP_DIR=&quot;$(mktemp -d &quot;${TARGET_DIR}.bak.XXXXXX&quot;)&quot;  rmdir &quot;${BACKUP_DIR}&quot;  mv &quot;${TARGET_DIR}&quot; &quot;${BACKUP_DIR}&quot;  echo &quot;Backed up existing skill directory: ${BACKUP_DIR}&quot;ficp -R &quot;${SOURCE_DIR}&quot; &quot;${TARGET_DIR}&quot;echo &quot;Installed diff-aware-web-e2e skill: ${TARGET_DIR}&quot;echo &quot;Next: run ./scripts/verify-diff-aware-web-e2e-skill.sh&quot;```&lt;/details&gt;&lt;details&gt;&lt;summary&gt;Verification&lt;/summary&gt;```sh#!/usr/bin/env bashset -euo pipefailSCRIPT_DIR=&quot;$(cd &quot;$(dirname &quot;${BASH_SOURCE[0]}&quot;)&quot; &amp;&amp; pwd)&quot;REPO_ROOT=&quot;$(cd &quot;${SCRIPT_DIR}/..&quot; &amp;&amp; pwd)&quot;SOURCE_DIR=&quot;${REPO_ROOT}/skills/diff-aware-web-e2e&quot;TARGET_DIR=&quot;${HOME}/.codex/skills/diff-aware-web-e2e&quot;hash_file() {  local file_path  file_path=&quot;$1&quot;  if command -v shasum &gt;/dev/null 2&gt;&amp;1  then    shasum -a 256 &quot;${file_path}&quot; | awk &apos;{print $1}&apos;    return  fi  if command -v sha256sum &gt;/dev/null 2&gt;&amp;1  then    sha256sum &quot;${file_path}&quot; | awk &apos;{print $1}&apos;    return  fi  echo &quot;Error: neither shasum nor sha256sum is available.&quot; &gt;&amp;2  exit 1}hash_stdin() {  if command -v shasum &gt;/dev/null 2&gt;&amp;1  then    shasum -a 256 | awk &apos;{print $1}&apos;    return  fi  if command -v sha256sum &gt;/dev/null 2&gt;&amp;1  then    sha256sum | awk &apos;{print $1}&apos;    return  fi  echo &quot;Error: neither shasum nor sha256sum is available.&quot; &gt;&amp;2  exit 1}dir_manifest_hash() {  local dir_path  dir_path=&quot;$1&quot;  (    cd &quot;${dir_path}&quot;    find . -type f | LC_ALL=C sort | while read -r relative_path    do      local_hash=&quot;$(hash_file &quot;${dir_path}/${relative_path#./}&quot;)&quot;      printf &apos;%s  %s\n&apos; &quot;${local_hash}&quot; &quot;${relative_path}&quot;    done  ) | hash_stdin}if [[ ! -f &quot;${SOURCE_DIR}/SKILL.md&quot; ]]then  echo &quot;Error: source skill file not found: ${SOURCE_DIR}/SKILL.md&quot; &gt;&amp;2  exit 1fiif [[ ! -d &quot;${TARGET_DIR}&quot; ]]then  echo &quot;Error: target skill directory not found: ${TARGET_DIR}&quot; &gt;&amp;2  echo &quot;Run: ./scripts/install-diff-aware-web-e2e-skill.sh&quot;  exit 1fiSOURCE_HASH=&quot;$(dir_manifest_hash &quot;${SOURCE_DIR}&quot;)&quot;TARGET_HASH=&quot;$(dir_manifest_hash &quot;${TARGET_DIR}&quot;)&quot;echo &quot;Source: ${SOURCE_DIR}&quot;echo &quot;Target: ${TARGET_DIR}&quot;echo &quot;Source manifest SHA-256: ${SOURCE_HASH}&quot;echo &quot;Target manifest SHA-256: ${TARGET_HASH}&quot;if [[ &quot;${SOURCE_HASH}&quot; == &quot;${TARGET_HASH}&quot; ]]then  echo &quot;Status: synchronized&quot;  echo &quot;Standard usage: \$diff-aware-web-e2e &lt;your request&gt;&quot;  exit 0fiecho &quot;Status: mismatch&quot;echo &quot;Run: ./scripts/install-diff-aware-web-e2e-skill.sh&quot;exit 2```&lt;/details&gt;</content:encoded>
            <category>auwui</category>
        </item>
        <item>
            <title><![CDATA[Yuki-no 플러그인과 AI 에이전트를 이용해 2800 라인 Diff 하루만에 번역하기]]></title>
            <link>https://shj.rip/article/boosting-translation-productivity-with-yuki-no-ai-agents.html</link>
            <guid isPermaLink="false">boosting-translation-productivity-with-yuki-no-ai-agents</guid>
            <dc:creator><![CDATA[shj]]></dc:creator>
            <pubDate>Fri, 08 Aug 2025 00:00:00 GMT</pubDate>
            <enclosure url="https://shj.rip/article/boosting-translation-productivity-with-yuki-no-ai-agents.png" length="0" type="image/png"/>
            <content:encoded># TL;DR- .## 1. 번역 프로젝트와 딜레마- 나는 Vite에 [한국어 문서 번역 프로젝트](https://github.com/vitejs/docs-ko/) 메인테이너로 기여하고 있다  - 이를 진행하며 다양한 상황을 맞닥뜨렸고, 하나씩 해결해 갔었다  - 여기서는 기존 번역 방식에 대한 한계 및 해결에 대해 다뤄본다- 앞서 [Cursor 이용한 번역 경험](./translate-to-korean-with-cursor.html)이나 [Yuki-no 개발기](./starting-technical-documentation-translation-project-with-github)에서도 언급했듯  - 빠르게 변화하는 오픈소스 문서를 번역한다는 것은 전문가가 아닌 이상 적지 않은 리소스를 꾸준하게 투자하는 건 쉽지 않은 일이다### 기존 워크플로우 문제점### 왜 자동화가 필요한가## 2. Yuki-no 플러그인 시스템 &amp; [@yuki-no/plugin-batch-pr](https://npmjs.com/package/@yuki-no/plugin-batch-pr)### 플러그인 아키텍처### [@yuki-no/plugin-batch-pr](https://npmjs.com/package/@yuki-no/plugin-batch-pr)## 통합 워크플로우### 4단계 자동화 프로세스### 단계별 도구 및 역할## 4. 결과 분석### Before/After### ROI## 5. In Action### 설정 방법### 템플릿## 6. 마치며---S:- 나는 Vite에 한국어 문서 번역 메인테이너로 기여중- [Cursor로 Vite 한국어 문서 번역 진행한 경험](./translate-to-korean-with-cursor.html)이나 [GitHub으로 기술 문서 번역 프로젝트 시작하기](./starting-technical-documentation-translation-project-with-github)에서도 언급했지만  - 번역은 지속적으로 리소스 부어야 하고  - 상대적으로 은근히 꾸준하게 리소스가 들어가는 작업  - 왜? Vite는 많이 바뀌는데 이걸 처리하는 인원은 상대적으로 많지는 않으니- 저 이후 번역할 때 AI를 많이 사용하는데 그래도 뭔가 비효율적이더라- 프로세스를 설명하자면 이런데  - 이슈에서 번역 관련 변경사항 확인  - 번역 리포지토리에 해당 내용 번역해 집어넣음  - 커밋 &amp; push  - 좀 별로임 이거..T:- 이게 문제점이 있음  - 순차적으로 해야하는데, 이러다보니 이후 변경사항에서 삭제되거나 내용이 바뀌는 경우가 있음    - 근데 왜 순차적이냐, 히스토리를 따라가야 변경사항 제대로 반영되기에    - 그래서 무의미한 리소스 소모가 많았음  - 그렇다고 한번에 모두 반영하고 번역돌리자니 귀찮았음    - 양이 적은것도 아니고 얼마나 걸릴지 모르는데 로컬에서 작업하고 있다가 외부에서 기여가 들어오면?    - assign 해두면 되긴 하지만 이런 과정들이 번거롭고 귀찮았음  - 일부만 하면 된다, 한번에 날 잡고 하면 되지 않냐 다양한 방법이 떠오를 수 있겠지만    - 그냥 그런 과정 모두가 귀찮았음 무의미한 리소스 쏟는 느낌이었고- 그렇게 번역하다 어느순간 여기서 벗어나야겠다는 생각이 듦  - 그렇게 [Yuki-no](https://github.com/Gumball12/yuki-no/) 다음 버전을 만들어야겠다 생각함  - 6월 25일부터 작업 진행했고, 8월 7일 어제 작업이 어느정도 완료되어서  - 이를 이용해 약 2800 라인 Diff를 번역 관련 번경 사항을 하루만에 작업 완료했음- 여기서는 내가 뭘 만들었고 어떻게 번역 작업했는지 소개하고자 함  - 이걸 바탕으로 다른 번역 리포지토리에도 소개할건데 바로 In Action 하고자 한다면 [이 섹션](#)으로 이동하길 바람A(yuki-no):- 먼저 Yuki-no 자동화임  - 자동화는 이전부터 생각해왔었음  - 다만 어떻게 접근하면 좋을지 고민하다 이전에 개발한 Yuki-no를 확장해보면 어떨까 하는 생각이 듦- 이를 위해 먼저 확장성을 다시 생각해보자 생각했음  - 어차피 취미로 하는 프로젝트고 이전부터 생각했던 GitHub Actions에서 플러그인 시스템을 도입해보고자 생각도 했어서  - 앞으로도 새로운 기능 도입될텐데 플러그인으로 서로 독립적으로 구성할 수 있으면 좋겠다 생각햇음  - 또 GitHub Actions에서 플러그인 시스템을 구축한 사례를 못봐서    - 왜 없었을까?- 깊이 다루지는 않겠지만 간단히 다음과 같은 시스템으로 구성함  - (그림)  - Yuki-no 라이프사이클을 만들고 각 지점마다 호출되는 훅에 대한 인터페이스 정의  - 그리고 플러그인 목록을 action으로 전달받아 이를 Yuki-no 시작 전 npm 레지스트리에서 설치해 사용- 덕분에 핵심 로직만 core로 남길 수 있었고  - 릴리스 추적이나 이번에 개발한 배치 PR 기능을 플러그인으로 성공적으로 분리할 수 있게 됨  - 이후 AI 번역도 추가할 계획이라 플러그인 분리가 적절했다고 생각되었음  - 이를 쉽게 배포하기 위해 [cd 파이프라인](https://github.com/Gumball12/yuki-no/blob/next/.github/workflows/publish.yml)도 조금 손봤고- 릴리스 추적은 [GitHub으로 기술 문서 번역 프로젝트 시작하기](./starting-technical-documentation-translation-project-with-github) 블로그 글에서 설명하고 있음  - 이건 [@yuki-no/plugin-release-tracking](https://npmjs.com/package/@yuki-no/plugin-release-tacking) 이라는 이름으로 npm 배포했고  - 하위호환성 지키는 로직도 구성함- 그럼 배치 PR은 뭐냐  - 앞서 번역 프로세스에서도 언급했는데    - 번역 관련 변경 사항을 번역 리포지토리에 모두 적용시키는 기능만을 담당하는 플러그인임    - 리포지토리 A에 존재하는 커밋 중 일부(번역 관련)만 취해서 이를 리포지토리 B에 적용시키는 것  - 단순히 변경사항을 이동시키는건 아니고 몇 가지 고려사항이 있음    - 루트 디렉터리가 다를 수 있음      - 실제로 vite 한국어 문서가 그랬는데      - 원본 리포지토리에서는 문서 관련 내용이 docs 디렉터리 아래에 있었는데([vite/docs/](https://github.com/vitejs/vite/tree/main/docs/))      - 번역 리포지토리에서는 루트에 문서 관련 내용이 있었음([docs-ko/](https://github.com/vitejs-docs-ko/))      - 그래서 이를 지정할 수 있도록 해야 함    - 또한 수동으로 적용해야 하는 변경 사항이 있을 수 있음      - package.json이나 관련 설정이나 의도적으로 수동으로 관리하는 문서 등    - 마지막으로 &quot;실제로 무엇이 변경되었는지&quot; diff를 확인해서 이동시켜야 함      - 단순히 파일 단위로 이동시키면 기존 번역 내용이 모두 날아가기 때문  - 이것도 간단하게 설명하자면    - 수동으로 적용해야 하는 변경 사항은 기존 picomatch 기반 작성한 함수 있어서 이를 그대로 이용    - 변경사항 적용은 로직을 구현해야 했는데 크게 2가지로 분리해서 진행함      - (생각해보니 이것도 npm 라이브러리로 만들어도 좋을듯, 아니면 이미 있으려나?)      - git diff에서 변경사항을 추출해 `FileChange`라는 데이터 구조로 만드는 `extractFileChanges`      - `FileChange` 배열을 전달받아 이를 통해 실제 파일을 수정하는 `applyFileChanges`  - 아무튼 이렇게 구현했고 [@yuki-no/plugin-batch-pr](https://npmjs.com/package/@yuki-no/plugin-batch-pr) 이라는 플러그인으로 만들어 npm 배포 완료R(yuki-no):- 그렇게 지금 vite 한국어 번역 문서 프로젝트를 alpha test 필드로 삼아 해봤는데 잘 굴러가고 있는 것 같다  - 몇 가지 개선사항이 있긴 하지만    - 수동으로 관리하고자 하는 목록을 pr body에 보여준다던가    - pr 안만들어도 되는 상황에서는 만들지 않는다던가 등  - 그건 차차 추가하기로 하고, 가장 핵심 기능인 변경사항 모두 취합해 PR 하나로 만들어주기 기능은 잘 동작하는 것으로 보임- vsc + cline ext + claude code  - claude max 플랜 구독해 claude code 바탕으로 sonnet-4와 opus-4 번갈아 사용    - cursor 쓰다가 너무 사용량이 부족해 claude max 플랜에 vsc에 [cline](https://cline.bot/) 이라는 오픈소스 익스텐션 붙여서 사용중      - 여기에 sequential thinking, tavily, file system, context7 정도만 mcp 붙이고      - 커뮤니티와 ai 도움받아 작성한 cline rules 바탕으로 사용중    - 물론 cursor가 20 플랜이긴 해도 좀 많이 심각하게 부족했음      - 한동안은 이 형태로 가지 않을까  - conductor도 써봤는데 토큰과 cost를 너무 많이 소모해서 별로    - 동일한 mcp 붙여서 사용했는데 내부 프롬프트가 좀 다른듯- 근데 sonnet-4 위주로 사용하긴 했지만 완벽하진 않더라  - 특히 코드베이스를 결국 이해해야 한다는 건 마찬가지였음  - 케이스 찾아보기 어려운 특정 버그는 거의 도움을 받기 어렵더라  - 대신 코딩&amp;디버깅 시간보다 리뷰 시간이 이전에는 9:1이었다면 지금은 2.5:7.5정도가 된 느낌- claude code pr review  - cc에서 first-party로 제공하는 기능인데 꽤 좋았음  - 로컬에서 사용중인 cline rules 바탕으로 pr review rule 만들고    - github action 이용해 pr review 하도록 [파이프라인](https://github.com/Gumball12/yuki-no/blob/next/.github/workflows/claude.yml) 구성해서 가능한데    - 나는 추가로 `@claude &lt;prompt&gt;` 라는 코멘트 붙었을 때만 pr review 하도록 구성함  - 아무튼 상당히 구체적이고 다각적으로 코드 분석해줘서    - 실제로 구현한 코드에서 놓쳤던 부분(로컬에서 한번 cc로 검증 거쳤음에도 있었다)도    - 얘가 잡아주기도 했음A(translation):- 그래서 이렇게 개발한 batch-pr 플러그인으로 번역 필요한 모든 변경사항을 하나의 PR로 자동으로 모을 수 있게 되었음  - 아직 정식 배포는 아니고 wip 버전으로 지금 vite 한국어 번역 프로젝트에서 사용중이지만  - 한 달 정도 보고 정식 배포 진행할 예정- 이렇게 만든 pr은 https://github.com/vitejs/docs-ko/pull/1268 에서 확인할 수 있음  - batch-pr 결과: https://github.com/vitejs/docs-ko/compare/a34c8d7bd4beb9e68d3b3349ffed94c96b2b11c1..68afebcd27172dd65b557069a3d6e3b26f166889  - missing contents: https://github.com/vitejs/docs-ko/pull/1268/commits/cc017a35ce02306622336182a5f2231dcc2a98d9    - 지금은 해결된 버그로 인해 누락된 .vitepress 디렉터리 아래 변경사항과    - 의도적으로 포함시키지 않도록 설정한 파일을 반영- 이는 git history에 존재하고, 이 diff를 바탕으로 cc로 1차 번역 해달라고 했음  - https://github.com/vitejs/docs-ko/pull/1268/commits/6dee54f88f17fc86a7317c3fe5f748225e10ef1a  - 당연히 이게 완벽하진 않아서 내가 리뷰 한번 거치긴 했다만  - 룰을 좀 더 상세하게 정해서 번역하도록 하면 리뷰를 조금 덜 수 있을 것 같음    - (프롬프트 파일)    - 이를 위한 ai 에이전트나 특정 툴에서 독립적인 룰 파일도 만들었다    - 이걸 참고해서 번역하라 할건데 잘 동작하는지는 지켜볼 예정- 이후 리뷰 내용을 바탕으로 AI &amp; 수동으로 수정했고  - 엄청 많지는 않아서(작은 범위 단위 53개 정도), 5시간정도 소모해 번역 모두 완료했다  - 대부분 번역이 잘 되어서 그냥 원문과 함께 한번 읽어보고 이상하게 다가오는 부분만 교정해줌- 총 5~6시간 정도 걸려서 엄청 많은 변경사항(81개, 2800라인/68파일 Diff)에 대한 번역 완료했다  - 결과물도 꽤 만족스러웠음R(translation):- 처음보다 조금 길어지기는 했지만 프로토타입 버전은 잘 만들어졌다 생각함  - 기존 리소스를 많이 차지하는 부분을 자동화한 부분이 상당히 컸음  - 원래 방식대로 진행했다면 1주일 조금 더 걸리지 않았을까  - 하는것도 재미없었을거고 스트레스만 되었을거고- 이후 1차 초벌 번역 플러그인도 만들 예정이다  - 이것도 자동화 가능해보임  - 다만 먼저 홍보부터 하고.. (ai 번역 플러그인 넣는다 해도 ai 구독 필요한데, 이건 일단 구현없이도 로컬이지만 가능해서)- 최종적으로는 내가 리뷰만 하고 리뷰 바탕으로 AI가 추가 수정하거나 내가 직접 수동으로 수정해서  - 리소스를 최소화할 예정  - 이로 인해 외부 기여는 많이 줄어들지 않을까 (리소스 필요한 부분이 많이 덜어졌으니)- 또한 더 나아가 비슷하게 번역 프로젝트 관리하는 데 힘 부치는 사람들 위해서 이거 도입을 도와주고 싶다  - 분명 나처럼 매너리즘 빠졌거나 힘들텐데 도움이 될 수 있으면 좋겠다  - 도울 수 있는게 있을지 모르니 필요하면 언제든 편하게 연락주라R(conclusion):- 그 외로 개발에 cc를 많이 사용했다  - (사진)  - 큰 의미는 없지만 claude &amp;dollar;100 max 사용중인데 만약 api로 같은 작업 했다면 약 &amp;dollar;1000 이상 사용했을거라고 찍힌다  - 상당히 도움이 되었고- 그리고 몇 주 전에 cc subagents 나왔다고 하던데 cc pr review 비슷한건가?  - 사용 예시를 보았지만 크게 다가오지는 않고 완전 100% vive 코딩을 하는건 아니어서(결국 리뷰해야하는) conductor와 함께 사용 안하는 툴이 되긴 했지만  - 이렇게 빨리 ai 툴 나오고 그런거 보면 사용 예시 보면서 무엇을 할 수 있는지 간단하게라도 계속 보는게 중요한 듯- 퇴사 후 8개월, 이제 모아둔 돈도 슬슬 떨어지고 어쨌든 수입이 필요한 상황임  - 아마 회사를 들어가게 될 것 같은데, 백수생활 청산하면 이런 경험을 바탕으로 생산성 많이 끌어올려보고싶다</content:encoded>
            <category>Experience</category>
            <category>Project</category>
        </item>
        <item>
            <title><![CDATA[GitHub으로 기술 문서 번역 프로젝트 시작하기]]></title>
            <link>https://shj.rip/article/starting-technical-documentation-translation-project-with-github.html</link>
            <guid isPermaLink="false">starting-technical-documentation-translation-project-with-github</guid>
            <dc:creator><![CDATA[shj]]></dc:creator>
            <pubDate>Fri, 14 Feb 2025 00:00:00 GMT</pubDate>
            <enclosure url="https://shj.rip/article/starting-technical-documentation-translation-project-with-github.png" length="0" type="image/png"/>
            <content:encoded># GitHub으로 기술 문서 번역 프로젝트 시작하기기술 문서 번역은 더 많은 사람들이 접근할 수 있도록 돕는 중요한 일이다. 해당 언어에 익숙하지 않은 사용자들이 새로운 기술을 배우고 적용할 수 있도록 많은 도움을 준다. 특히 오픈소스 프로젝트에 대해서는 기술 생태계를 확장하고, 더 많은 개발자들이 프로젝트에 기여할 수 있는 기회를 제공한다.나는 현재 [Vite](https://vitejs.dev)의 [한국어 문서 번역 프로젝트](https://github.com/vitejs/docs-ko) 메인테이너로 활동하고 있다. Vite는 매우 빠른 속도로 발전하는 차세대 프런트엔드 빌드 툴로, 매일 수많은 업데이트가 이루어진다. 이 글은 활발하게 개발되는 프로젝트에 대한 문서를 번역할 때 발생할 수 있는 문제와, 이를 해결하기 위해 개발한 [Yuki-no](https://github.com/Gumball12/yuki-no)라는 오픈소스를 소개하기 위해 작성했다.# The Problems번역 프로젝트는 여러 형태로 구성이 가능하다. [Crowdin](https://crowdin.com/)과 같은 상용 서비스를 이용하거나, [VitePress](https://vitepress.dev/)나 [Docusaurus](https://docusaurus.io/)와 같은 i18n을 지원하는 오픈소스 문서화 솔루션을 이용할 수 있다. 어떤 방법을 선택하든, 번역 작업은 원본 문서 변경에 대한 추적이 매우 중요하다. 그렇지 않으면 잘못된 부분을 번역하거나, 번역이 필요한 내용 중 일부를 빠트릴 수 있다.Vite를 막 번역하기 시작했을 때 나는 이에 대한 중요성을 잘 알지 못했다. 원본 문서 특정 버전(커밋)을 목표로 번역을 진행했었는데, 절반 정도 진행하니 몇 문제들이 발목을 잡았다.- **원본 문서와 차이점이 발생**: 원본 문서는 계속 업데이트된다. 새로운 기능 설명이 추가되거나 API가 변경되면서 이미 번역한 내용이 종종 구버전이 되어버렸다.- **필요하지 않은 부분을 번역**: 최신 문서에서는 이미 삭제되었거나 변경되었는데, 이를 모르고 번역해버려 시간을 낭비했다.- **번역 참여자들에게 설득이 어려움**: &quot;왜 이 버전에 대해 번역을 진행해야 하나요?&quot;라는 질문에 적절한 답변을 제공하기 어려웠다. 어떻게 답변을 했지만, 스스로 납득되지 않았다.모두 원본 문서와 동기화를 수행해주지 않아 발생한 문제다.## 수동으로 관리하기![수동으로 관리하기](/images/250214/with-manual.png) _번역 프로젝트 작업 흐름_처음에는 수동으로 동기화 작업을 진행했는데, 상당히 비효율적이었다:1. **시간 소모적인 확인 작업**: 주기적으로 원본 저장소에 변경 사항이 있는지 수동으로 확인해야 했다.2. **릴리스 상태 파악**: 변경된 내용이 실제 릴리스된 기능인지, 아직 개발 중인 베타 기능인지 일일이 확인해줘야 했다.3. **이슈 생성과 추적**: 번역이 필요한 부분을 찾아 이슈로 생성하고, 해당 이슈를 추적하는 과정도 모두 수동으로 진행했다.이러한 변경 사항을 확인하고 이슈로 만드는 작업이 단순해 보이지만 실제로는 상당히 귀찮고 시간을 소모하는 일이다. 실제로 번역 자체보다 프로세스 관리에 더 많은 시간을 쏟게 되어 원활한 진행을 어렵게 만드는 요소 중 하나였다. 게다가 수동 작업 중 실수로 변경 사항을 놓치는 경우도 종종 발생했는데, 이럴 때는 정말 지루하고 의욕이 떨어졌다.이러한 문제들을 효율적으로 해결하기 위해서는 동기화 과정을 자동화할 필요가 있었다.# Ryu-Cho 도입![Ryu-Cho 도입](/images/250214/with-ryu-cho.png) _Ryu-Cho는 원본 저장소에 대한 새로운 변경 사항을 이슈로 만들어준다_한계를 느낀 나는 (그제서야)다른 번역 프로젝트를 둘러보았다. React 진영은 직접 [스크립트를 작성](https://github.com/reactjs/translations.react.dev)해 중앙 집중식으로 관리하고 있었고, Vue 진영은 오픈소스를 이용해 각 번역 프로젝트에서 동기화를 수행하고 있었다. 최종적으로는 Vue 및 다른 Vite 번역 프로젝트에서 사용하는 [Ryu-Cho](https://github.com/vuejs-translations/ryu-cho)라는 오픈소스를 도입했다. 제공하는 기본적인 기능은 다음과 같다:- 원본 저장소 커밋 모니터링- 새로운 커밋에 대한 GitHub 이슈 생성간단하지만 꽤나 강력하고 쓸모가 있었다. Vue.js 및 한국어 외 다른 언어를 대상으로 하는 Vite 번역 프로젝트에서도 사용중이어서, 신뢰도도 낮지 않았다. 그렇게 Ryu-Cho를 2년 가까이 사용했고, 아쉬운 부분이 눈에 보였다.## Ryu-Cho 문제점다음과 같은 문제점을 느꼈다:- **릴리스 상태 추적 자동화 불가능**  - 번역 전 릴리즈 여부를 확인해야 하는 번거로움이 그대로 남아있음  - 릴리스되지 않은 기능의 문서를 번역하는 경우 발생  - 이를 일일이 추적한다면 수동 관리와 큰 차이가 없음- **이슈 관리의 한계**  - 이슈 라벨링 기능 부재로 번역 이슈와 일반 이슈가 섞임  - 작업 상태 추적이 어려워짐- **설정과 유지보수의 어려움**  - 문제 발생 시 디버깅을 위한 로그 부족  - 설정 옵션의 유연성 부족새로운 도구의 필요성을 느꼈다. 그렇게 Yuki-no를 개발했다.# Yuki-no&lt;p&gt;  &lt;a    href=&quot;https://asciinema.org/a/ZmSCwDTR7p2uPCc9QaW44Shb1&quot;    class=&quot;md-linkify-link&quot;    target=&quot;_blank&quot;    style=&quot;position: relative;&quot;  &gt;    &lt;img      src=&quot;/images/250214/yuki-no-logo.webp&quot;      style=&quot;position: absolute; right: 8px; bottom: 12px; opacity: 0.5; width: 35px;&quot;&gt;    &lt;img      src=&quot;https://asciinema.org/a/ZmSCwDTR7p2uPCc9QaW44Shb1.svg&quot;      alt=&quot;asciicast&quot;      loading=&quot;lazy&quot;      decoding=&quot;async&quot;&gt;  &lt;/a&gt;    &lt;em&gt;    &lt;span&gt;Yuki-no 스크립트 실행 모습 / &lt;/span&gt;    &lt;a      href=&quot;https://github.com/Gumball12/yuki-no&quot;      target=&quot;_blank&quot;      rel=&quot;noopener noreferrer&quot;&gt;GitHub&lt;/a&gt;  &lt;/em&gt;&lt;/p&gt;Ryu-Cho 한계를 극복하기 위해 개발된 [Yuki-no](https://github.com/Gumball12/yuki-no/)는 현재 [Vite 한국어 문서 번역 프로젝트](https://github.com/vitejs/docs-ko), [Vue 한국어 문서 번역 프로젝트](https://github.com/vuejs-translations/docs-ko), 그리고 [Vite 번역 템플릿](https://github.com/tony19/vite-docs-template)에서 실제로 사용중이다.## 주요 이점![Yuki-no 도입](/images/250214/with-ryu-cho.png) _Yuki-no 동작 방식_1. **자동화된 변경 사항 추적**   ![Automated Change Tracking](/images/250214/automated-change-tracking.webp) _Yuki-no로 생성된 [Vite 한국어 번역 프로젝트 이슈 목록](https://github.com/vitejs/docs-ko/issues?q=is%3Aissue%20label%3Async) 중 일부_   - 원본 저장소 내 필요한 부분에 대한 변경 사항만을 모니터링   - 번역이 필요한 부분을 GitHub 이슈로 생성 및 라벨링2. **릴리스 상태 기반 번역 관리**   ![Automated Release Tracking](/images/250214/automated-release-tracking.webp =450x) _Yuki-no로 관리되는 [릴리스 코멘트 및 라벨](https://github.com/vitejs/docs-ko/issues/1126)_   - 정식 릴리스된 내용과 프리 릴리스 버전 구분   - 릴리스 상태에 따른 라벨링   - 불필요한 번역 작업 방지3. **효율적인 작업 관리**   - 번역이 필요한 부분만 한눈에 파악 가능   - GitHub 기반 이슈 관리 프로세스 그대로 사용 가능   - Octokit 플러그인([retry](https://github.com/octokit/plugin-retry.js), [throttling](https://github.com/octokit/plugin-throttling.js/)) 을 이용해 효율적인 GitHub API 사용# Yuki-no 사용하기아래 내용은 [리드미](https://github.com/Gumball12/yuki-no/blob/main/README.md#usage)에서도 확인할 수 있다. 만약 Ryu-Cho 또는 이와 유사한 방식으로 번역 프로세스가 구성되어 있다면 [마이그레이션 문서](https://github.com/Gumball12/yuki-no/blob/main/MIGRATION.md)를 참고하자.## 1. GitHub Actions 설정먼저 저장소에 GitHub Actions 권한을 설정해야 한다. 이는 Actions에서 Issues 및 Issue Comments를 생성하기 위해 필요하다. 참고로 Ryu-Cho와 같은 GitHub Issues 기반 번역 프로세스를 구축하는 모든 Actions에서 이를 요구한다:![settings](/images/250214/settings.webp) _GitHub Actions 권한 설정을 해주지 않으면 에러가 발생한다_- 리포지토리 Settings 탭으로 이동 &gt; Actions &gt; General &gt; Workflow permissions 항목- &quot;Read and write permissions&quot; 선택- 변경 사항 저장## 2. Yuki-no 설정 파일 작성다음으로 `.github/workflows/yuki-no.yml` 파일을 생성해야 한다. 자세한 내용은 [Yuki-no 문서](https://github.com/Gumball12/yuki-no/blob/main/README.md#configuration)를 참고하자. 가령 Vite 번역 프로젝트를 시작한다고 했을 때, 아래와 같이 구성할 수 있다:```yml# .github/workflows/yuki-no.ymlname: yuki-noon:  schedule:    - cron: &apos;0 * * * *&apos; # 매시간 실행  workflow_dispatch: # 수동 실행 옵션jobs:  yuki-no:    runs-on: ubuntu-latest    steps:      - uses: Gumball12/yuki-no@v1        with:          access-token: ${{ secrets.GITHUB_TOKEN }}          head-repo: https://github.com/vitejs/vite.git          track-from: abcd1234 # 추적을 시작할 커밋 해시          include: |            docs/**          release-tracking: true```## 3. 번역 프로젝트 시작하기이제 번역 작업은 이렇게 진행하면 된다:1. **자동 이슈 생성**   - `track-from` 다음 커밋부터 시작해, 원본 문서가 업데이트되면 자동으로 이슈 생성   - 릴리스 상태도 자동으로 추적   - 커밋에 대한 정보 포함2. **작업 현황 관리**   - 라벨로 작업 상태 관리 (예: 번역 이슈는 `sync`이 붙음)   - 릴리스 추적 라벨로 우선순위 파악 (예: 정식 릴리스되지 않은 변경 사항에는 `pending`이 붙음)   - 작업자 배정 및 진행 상황 추적3. **번역 및 리뷰**   - PR 생성 및 리뷰 진행   - 릴리스 상태에 따른 번역 및 배포 시점 조절## 효율적인 관리를 위한 팁1. **파일 패턴 활용**   ```yml   include: |     docs/**/*.md        # 문서 파일만 추적     .vitepress/config.ts # 설정 파일도 포함   exclude: |     docs/**/*.test.ts   # 테스트 파일 제외     docs/internal/**    # 내부 문서 제외   ```   - `include`로 번역이 필요한 파일만 선택적으로 추적   - `exclude`로 불필요한 파일 제외   - [Glob 패턴](https://github.com/micromatch/picomatch?tab=readme-ov-file#advanced-globbing)을 사용해 유연한 파일 선택 가능2. **릴리스 추적 시스템 활용**   ```yml   release-tracking: true   release-tracking-labels: |     pending     pre-release     released   ```   - `release-tracking`: 릴리스 상태 자동 추적 활성화   - `release-tracking-labels`: 릴리스 상태별 라벨 지정   - 라벨 및 이슈 코멘트를 통한 릴리스 상태 확인   **동작 방식:**   - 새 커밋이 감지되면 `sync` 라벨로 시작 (`labels` 옵션을 통해 지정, 기본값 `sync`)   - 정식 릴리스 이전 변경 사항은 `pending` 라벨이 붙음 (`release-tracking-labels` 옵션을 통해 지정, 기본값 `pending`)   - 릴리스 상태 변경 시 이슈에 코멘트 자동 추가   - 정식 릴리스 시 `pending` 라벨 제거됨# 마무리Yuki-no를 도입한 후, Vite 한국어 문서 번역 프로젝트는 이런 개선을 이루었다.- 불필요한 번역 작업을 피할 수 있게 됨- 번역 작업 만족도 향상- 번역 진행 방식 간결화 및 진입 장벽 낮춤현재 Vite 한국어 문서 번역 프로젝트는 Yuki-no를 통해 안정적이고 효율적인 번역 프로세스를 운영하고 있으며, 이는 다른 번역 프로젝트에도 좋은 참고 사례가 될 수 있을 것이라 생각한다.더 자세한 내용은 [Yuki-no 문서](https://github.com/Gumball12/yuki-no)를 참고하자. 질문이나 제안이 있다면 [GitHub Issues](https://github.com/Gumball12/yuki-no/issues)에 남겨주기를 바란다.</content:encoded>
            <category>Experience</category>
            <category>Project</category>
        </item>
        <item>
            <title><![CDATA[Cursor와 함께한 Vite 문서 번역 이야기]]></title>
            <link>https://shj.rip/article/translate-to-korean-with-cursor.html</link>
            <guid isPermaLink="false">translate-to-korean-with-cursor</guid>
            <dc:creator><![CDATA[shj]]></dc:creator>
            <pubDate>Fri, 10 Jan 2025 00:00:00 GMT</pubDate>
            <enclosure url="https://shj.rip/article/translate-to-korean-with-cursor.png" length="0" type="image/png"/>
            <content:encoded># Cursor와 함께한 Vite 문서 번역 이야기![번역 완료](/images/250110/trans-comp.webp) _Cursor로 번역한 결과_오픈소스 번역은 누구나 시작할 수 있는 기여 활동이다. 하지만 꾸준한 관리는 조금 다른 이야기다. 특히 [Vite](https://vite.dev)처럼 빠르게 발전하는 프로젝트 문서는 더욱 그렇다. 새로운 기능이 추가되고 API가 변경될 때마다 문서도 함께 업데이트해야 하니까.최근 [Vite 6.0](https://vite.dev/blog/announcing-vite6) 릴리스로 한글 번역이 필요한 문서가 무려 27개나 쌓였다. 평소 3배가 넘는 분량이었다. 내용도 만만치 않았다. 빌드 시스템과 프런트엔드 생태계에 대한 깊은 이해가 필요했다. 하지만 놀랍게도 [Cursor](https://cursor.com)라는 AI 도구 도움으로 3일 만에 모든 번역을 완료했다.참고로 나는 프롬프트 엔지니어링을 전문적으로 배우거나, AI를 깊이 있게 다루는 사람이 아니다. 오히려 한동안 [GitHub Copilot](https://github.com/features/copilot)같은 AI 코딩 도구를 멀리하기도 했다. 그런데 어떻게 이런 성과를 낼 수 있었을까? Cursor(정확히는 [Claude](https://www.anthropic.com/claude)) 도움을 받으면 생각보다 어렵지 않다. 여기서는 AI 도움을 받아 효율적으로 번역한 경험을 공유하려 한다.# CursorVite 문서 번역은 내가 꾸준히 참여하는 오픈소스 활동 중 하나다. 양이 그렇게 많지는 않아서 보통 2주에 한 번 정도 [ChatGPT](https://chat.openai.com)와 [DeepL](https://www.deepl.com)을 활용해 번역하는데, 이번에는 상황이 달랐다. Vite 6 릴리스와 함께 27개나 되는 새로운 문서가 추가됐다. 게다가 내용을 살펴보니 빌드 환경(Environment) 등 기술적으로 깊이 있는 내용이 많았다. 부담스러워서 일주일 정도 미뤄버렸고, 아무 진전도 없었다.한동안 멀리했던 GitHub Copilot을 다시 써볼까 고민했다. 월 10달러로 Cursor보다 저렴하지만, 내가 필요한 건 단순 코드 자동완성이 아닌 여러 파일을 동시에 수정하고 문맥을 이해하는 도구였다. 그러던 중 Cursor를 알게 됐다.![Cursor](/images/250110/cursor.webp) _Cursor - The AI Code Editor_[Cursor](https://cursor.sh)는 샌프란시스코 AI 스타트업이 개발한 AI 코딩 어시스턴트다. 여러 파일을 AI가 직접 수정할 수 있고, 코드베이스 전체를 이해하며 제안한다는 점이 매력적이었다. 특히 번역할 때는 기존 번역을 참고해 일관된 톤과 용어를 유지해야 하는데, Cursor는 이런 니즈를 완벽하게 충족시켜 줬다. 결과적으로 27개 문서를 단 3일 만에 번역하고 검수까지 마쳤다.# 진행 방식Cursor 최고 장점은 여러 파일을 동시에 수정할 수 있다는 점이다. [Composer](https://docs.cursor.com/composer/overview)(Agent) 기능을 활용하면 여러 파일 또는 디렉터리 단위로 번역을 진행하면서, 번역 내용에 대한 질문이나 피드백도 주고받을 수 있다. 현재 작업과 직접 관련 없는 프롬프트 생성 같은 요청은 Chat 기능을 활용했다.작업 흐름은 다음과 같았다:1. **번역 규칙 설정**: 먼저 Chat으로 Cursor가 일관된 규칙을 따르도록 프롬프트를 만들어달라 요청했다. 기존에 내가 사용하던 번역 규칙을 전달했고, 프롬프트 초안을 다듬어 최종 버전을 완성했다.![프롬프트 생성 요청](/images/250110/gen-prompt.webp) _프롬프트 생성 요청_&lt;details&gt;&lt;summary&gt; 열어서 프롬프트 보기 &lt;/summary&gt;```md# 번역 가이드라인당신은 영어로 된 Vite 문서를 한글로 번역하는 전문 번역가입니다. 아래 규칙을 따라 문서를 번역해주세요.1. **문장 구성 원칙**   - 불필요한 조사와 중복 표현 제거   - 자연스럽고 간결한 문장 구성   - 문장 간 연결성을 위한 적절한 접속사 사용   - 원본 내용은 생략하지 않음2. **기술 용어 처리**   - 일관성 있는 기술 용어 사용   - 직역을 피하고 문맥에 맞는 적절한 표현 사용     - &quot;import&quot; → &quot;의존성&quot; 또는 &quot;모듈을 가져다 쓰는&quot; 등     - &quot;resolve&quot;, &quot;evaluation&quot; 등도 문맥에 맞게 번역   - 용어 변경 시 원본 표현을 답변에 포함   - @TERMINOLOGY.md 파일 참고3. **코드 관련 규칙**   - 코드 예제는 주석만 번역   - twoslash 주석 고려하여 실제 주석만 번역   - 코드 자체는 수정하지 않음4. **문서 구조**   - 원본 문서의 라인 단위 구조 유지   - 마크다운 heading에 앵커 지정 필수     - 형식: `{#slugified-heading}`     - 예시: &quot;## HMR `hotUpdate` plugin hook&quot; -&gt; &quot;## HMR `hotUpdate` 플러그인 훅 {#hmr-hotupdate-plugin-hook}&quot;     - 예시: &quot;## `myExportedAPI`&quot; -&gt; &quot;## `myExportedAPI` {#myexportedapi}&quot;5. **품질 관리**   - 번역 전 정보의 정확성 확인   - 레퍼런스 정보 포함   - 기존 코드베이스의 어투 유지   - 한국어 독자가 이해하기 쉽게 표현   - 항상 한국어 맞춤법을 따라야 함6. **작업 방식**   - 파일 단위로 번역 진행   - 번역 결과는 답변하지 말고, &quot;요청한 파일을 반드시 수정해서 직접 적용&quot;   - 요청 문서 중 한국어로 대부분 번역된 문서가 있을 수 있으며, 그럼에도 파일 내용을 꼼꼼히 확인해 한국어 번역이 필요한 부분을 찾아야 함   - 이미 번역이 되어있더라도 단 한 줄도 생략하지 않고 한줄씩 처음부터 끝까지, 하나 하나 꼼꼼히 확인   - 파일 크기가 크더라도 중간에 멈춰서 계속 진행할지 물어보지 말고 끝까지 진행```&lt;/details&gt;이 프롬프트는 [Cursor Notepad](https://docs.cursor.com/features/beta/notepads)에 저장해두고, 번역을 요청할 때마다 참조했다.2. **파일 또는 디렉터리 단위로 번역 요청**: Composer로 파일이나 디렉터리 단위 번역을 요청한다. 이때 앞서 만든 프롬프트를 참조해 기존 번역과 일관된 톤과 용어를 유지했다.![파일 번역 요청](/images/250110/trans-a-file.mp4 t=0.01)모든 파일에 대해 번역을 요청할 수는 있지만, 나는 보통 파일 단위로 작게 나눠서 요청한다. 검수 과정에서 이해가 안 되는 부분이 있을 때 질문하거나, 수정을 요청하기가 더 수월하기 때문이다. 또한 너무 큰 단위로 번역을 요청하면 AI가 내용을 생략하거나 예상치 못한 파일을 생성하는 경우가 있어서, 작은 단위로 나누는 편이 여러 방면에서 더 효율적이었다.![비동기적으로 작업](/images/250110/process-with-trans.mp4 t=0.01)Cursor에 익숙해진 후에는 여러 파일을 동시에 작업하며 번역과 검수를 병행했다. 이렇게 작업 효율을 더욱 높일 수 있었다.3. **검수 및 번역 피드백을 통한 개선**: AI 번역은 완벽하지 않다. 문서 신뢰도와 품질을 위해 모든 내용을 꼼꼼히 검토하고, 이해가 안 되는 부분은 반드시 확인해야 한다. 이전에는 이 과정에서 많은 시간이 걸렸는데, Cursor가 큰 도움이 됐다.![번역 개선 요청](/images/250110/revise.mp4 t=0.01)예를 들어 &quot;Target&quot;이라는 단어는 문맥에 따라 다르게 번역해야 한다. 단순히 &quot;대상&quot;으로만 번역하면 의미가 모호해진다. 특히 Vite 6에서 새로 도입된 Environment API 관련 문서는 더 까다로웠다. 빌드 환경과 관련된 복잡한 개념을 다루고 있어서 이해하기 어려웠는데, Cursor는 문맥을 파악해 적절한 번역을 제안하고 관련 문서까지 찾아줘 큰 도움이 됐다. 물론 AI가 기술적 맥락을 잘못 이해한 경우도 있어서 이는 바로잡아야 했지만, 대부분 정확한 방향을 제시해줬다.이후 다음 번역 대상으로 넘어가 2번 과정부터 반복했다.# 유의 사항1. **질문과 피드백을 모아서 요청하기**: 기본적으로 Cursor는 월 500 크레딧을 제공한다. 많아 보일 수 있지만, 무분별하게 사용하면 금방 소진된다. 질문과 피드백을 모아서 한 번에 요청하면 크레딧을 더 효율적으로 사용할 수 있다.2. **정확한 컨텍스트 제공하기**: 이미 번역된 문서 중에서 새로 추가된 문장을 번역할 때, AI가 이를 놓치고 넘어가는 경우가 있었다. 이런 경우에는 해당 부분을 명확하게 지정해서 컨텍스트로 전달해야 한다.# 마치며이러한 과정을 거쳐 27개 파일, 약 500줄에 달하는 문서를 3일 만에 번역하고 검수했다. 130개 크레딧(약 &amp;#36;5.2)을 사용했지만, 투자 대비 효율이 매우 높았다. 하지만 단순히 속도가 빨라진 점보다 더 중요한 건, 번역에 대한 심리적 부담이 크게 줄었다는 점이다.최근 [GeekNews에서 본 글](https://news.hada.io/topic?id=18506)처럼, 기술 문서 번역은 여전히 많은 과제를 안고 있다. 특히 &quot;새로 업데이트되는 문서가 많으나, 커뮤니티 관심이 적다보니 빠른 반영이 어렵다&quot;는 지적이 가슴에 와닿았다. AI가 이러한 간극을 메우는 데 도움이 될 수 있지 않을까?[Vite 한글 문서](https://ko.vitejs.dev)는 아직 발전 여지가 많다. 커뮤니티 논의도 적고, 놓친 부분도 많다. 하지만 이번 경험으로 AI 기반 번역 워크플로우 가능성을 확인했다. 앞으로 이 경험을 바탕으로 더 체계적인 번역 프로세스를 만들고, 다른 오픈소스 번역 커뮤니티에도 기여하고 싶다. 기술 문서 번역 진입 장벽을 낮추고, 더 많은 한국 개발자가 최신 기술 문서를 쉽게 접할 수 있길 바라며.</content:encoded>
            <category>Experience</category>
            <category>Guide</category>
        </item>
        <item>
            <title><![CDATA[미국에서 만난 사용자 중심 개발]]></title>
            <link>https://shj.rip/article/three-weeks-in-san-mateo.html</link>
            <guid isPermaLink="false">three-weeks-in-san-mateo</guid>
            <dc:creator><![CDATA[shj]]></dc:creator>
            <pubDate>Sun, 24 Nov 2024 00:00:00 GMT</pubDate>
            <enclosure url="https://shj.rip/article/three-weeks-in-san-mateo.png" length="0" type="image/png"/>
            <content:encoded>![공항 사진](/images/241104/airport.webp) _인천국제공항 어느 창문 앞_&gt; 이제 한국 땅을 벗어나는구나. 어떤 경험을 마주할까. 돌아오면 어떤 마음을 갖게 될까. 달리(고양이)는 괜찮을까. 미국까지 갔는데 프로젝트가 망하면 어떡하지.2024년 9월 20일. 눈오는 겨울 새벽 밤 계획에 없던 외출을 할 때처럼 두근거리는 마음을 품고 나는 공항으로 향했다. 그 곳에는 함께 출발하는 글로벌 부트스트랩 팀 동료들이 나를 기다리고 있었다.# 작은 스타트업처럼 일하기우리 회사는 AI 음성 합성(TTS, Text-to-Speech) 기반 콘텐츠 제작 도구를 개발한다. 더빙, 영상 편집, 목소리 복제 등 다양한 기능을 제공하고 있는데, 핵심은 AI TTS 에디터다. 이 에디터는 국내 크리에이터들에게 이미 호평을 받았지만, 해외 시장에서는 아쉬운 부분이 많았다. 이러한 상황을 타파하고자 했다.&quot;이번에는 정말 다르게 해봐야 해요.&quot;처음 출장을 논의할 때 대표가 했던 말이다. 우리 제품은 이제 글로벌 시장에서 경쟁해야 했고, 기존 TTS 에디터는 사용성에 문제가 있었다. 지금까지 접근 방식으로는 살아남기 어려울 것이다. 하지만 무엇이 문제인지는 감을 잡기 어려웠다.그래서 미국 캘리포니아 San Mateo에 있는 미국 지사로 향했다. 목표는 단순했다. 마치 초기 스타트업처럼 빠르게 움직이며, 실제 영어권 사용자 피드백을 바탕으로 제품을 개선하는 것. 그렇게 총 6명으로 된 글로벌 부트스트랩 팀이 만들어졌다(한국: 개발자 3명 + 디자이너 1명, 현지: 대표 + PM).![Concar 400 Dr에 있는 WeWork 오피스](/images/241104/concar-office.webp) _Concar 400 Dr에 있는 WeWork 오피스__&quot;한국에서 수백 번 회의하기보다, 현지에서 직접 사용자를 만나는 30분이 더 가치 있다.&quot;_첫날 미팅에서 나온 이 말은 남은 여정을 관통하는 키워드가 되었다.## 데일리 프로토타입과 인터뷰 사이클우리는 3주 동안 16번 프로토타입을 만들었고, 이를 바탕으로 사용자 인터뷰를 진행했다. 일정은 대략 이런 흐름이었다:1. 오전: 사용자 인터뷰 진행2. 오후: 인터뷰 결과 분석 및 토론 (평균 2~3시간)3. 저녁~밤: 다음 날 인터뷰를 위한 프로토타입 개발4. 다음 날 아침: 마지막 테스트 및 디버깅 후 다시 인터뷰마치 정밀한 기계처럼 돌아가는 이 사이클이 처음에는 버거웠다. 개발자인 나는 코드를 깔끔하게 작성하고 싶었고, 버그 없이 완벽한 프로토타입을 만들고 싶었다. 하지만 동료가 해준 말을 듣고 생각이 달라졌다._&quot;완벽한 코드보다 사용자 반응을 빨리 보는게 중요해요. 무엇이 시장에서 성공하는지부터 알아야 합니다. 지금은 스피드가 생명입니다.&quot;_그리고 이내 이 방식이 얼마나 강력한지 경험하게 되었다. 우리는 2~3일에 한 번씩 실제 사용자에 대한 반응을 볼 수 있었고, 그 때마다 제품은 시장이 요구하는 방향으로 발전했다.![프로토타입 중 일부](/images/241104/the-prototype.webp) _프로토타입 중 일부_특히 사용자가 프로토타입을 사용하다가 당혹스러운 표정을 짓는 순간이 기억에 남았다. 개발자인 내가 당연하게 생각했던 UX 패턴이 사용자에겐 전혀 직관적이지 않았다. 이런 순간들이 쌓이면서 &quot;공급자 관점&quot;과 &quot;사용자 관점&quot;이 얼마나 다른지 피부로 느낄 수 있었다.# 인사이트: 시장에서 선택받기 위한 조건## 1. 사용자와 거리를 좁혀야 한다미국 출장에서 얻은 가장 큰 인사이트는 &quot;공급자 생각&quot;과 &quot;사용자(시장)가 원하는 것&quot; 사이 간극이 생각보다 훨씬 크다는 사실이었다.한 가지 예를 들어보자. 우리는 TTS 에디터에 음성, 캐릭터, 배경 등 다양한 편집 기능을 담았었는데, 이는 &quot;더 많은 기능&quot;이 &quot;더 좋은 제품&quot;이라 생각해서였다. 그러나 대다수 사용자들은 텍스트를 잘 편집할 수 있고 재생 버튼을 눌렀을 때 음성이 &quot;잘&quot; 흘러나오면 충분히 만족했다.&quot;왜 이 기능이 필요한가요?&quot; &quot;제가 원하는 기능을 찾을 수 없어요.&quot;이 질문은 마치 우리가 만든 프로덕트가 쓸모없고, 실패했다고 말하는 것처럼 들렸다. 하지만 실제로는 그렇지 않았다. 그저 방향이 달랐을 뿐이다. 사용자가 무엇을 요구하는지 다시 생각하게 만들었고, 불필요한 복잡성을 제거하는 데 큰 도움이 되었다.&gt; &quot;왜(Why)&quot;라는 질문은 기획, 디자인, 개발 모든 단계에서 가장 중요하다.매일같이 사용자를 만났고, 회의에서는 이 &quot;왜&quot;에 집중했다. 왜 그런 행동을 했는지, 왜 그런 표정을 지었는지, 왜 다른 버튼을 찾았는지. 이런 질문들이 방향을 잡아주는 나침반이 되었다. 모두 머리 속 상상이 아닌, 실제 사용자를 만나기 전까지는 알지 못했었다.## 2. 의사결정 구조: 대표는 실무와 가깝게지금 근무중인 회사는 한국과 미국 지사가 있고, 대표는 미국 지사에서 글로벌 대상으로 세일즈, 미팅, 인터뷰 등을 수행하고 있었다. 한국에 있던 나는 출장 전 &apos;대표와 함께 있으면 일할 때 좀 어렵지 않을까&apos; 하는 생각이 있었는데, 오산이었다. 현장에서 경험해보니 여러 이점이 있었다:1. **의사결정 속도**: &quot;이 기능 넣어볼까요?&quot; 라는 질문에 대한 답변이 상호 즉각적으로 이루어진다.2. **컨텍스트 공유**: 대표와 함께 직접 사용자 반응을 보면서, 모두 같은 경험을 공유할 수 있다.3. **방향성 정렬**: 대표가 생각하는 비전과 팀이 관찰한 결과가 자연스럽게 조율된다.&quot;이거 정말 좋은데요! 다음 버전에서는 이렇게 해봅시다.&quot;대표가 직접 테스트하고 즉석에서 피드백을 주는 모습은 마치 초기 스타트업에서 창업자가 제품을 손수 다듬는 모습과 같았다. 빠르고, 즉각적이었으며, 결정에 힘이 실렸다.&gt; 회사가 커지더라도 의사결정권자는 현장과 멀어지면 안 된다.이번 경험은 회사 규모와 관계없이, 핵심 결정권자가 사용자와 가까이 있어야 한다는 교훈을 주었다. 특히 글로벌과 경쟁하며 빠르게 움직여야 하는 상황에서는 이런 구조가 훨씬 효과적이라 생각한다.## 3. 빠른 검증 사이클: 논의보다 프로토타입출장 전에는 기획과 디자인을 꼼꼼히 검토하며 많은 논의를 거친 후에야 개발을 시작했다. 하지만 미국에서는 거의 반대로 일했다.&quot;이게 좋을 것 같아요. 일단 가장 빠르고 쉽게 만들어 보고 반응을 지켜봅시다.&quot;긴 논의로 모든 상황을 예측하려고 노력하는 대신, 빠르게 프로토타입을 만들고 실제 사용자 반응을 확인하는 방식이다. 이런 접근 방식은 처음에는 무모해 보였지만, 실제로는 놀라울 정도로 효율적이었다. 그리고 순서가 뒤로 이동하기는 했지만, 오히려 더 깊이 있는 논의가 이루어졌다:1. 간략한 아이디어 미팅 (30분~1시간)2. 프로토타입 개발 (3~4시간)3. 사용자 테스트4. 결과를 바탕으로 한 심층 논의 (2~3시간)이러한 방식이 갖는 장점은 실제 데이터를 기반으로 논의가 이루어진다는 점이다. &quot;이 버튼을 여기에 놓으면 어떨까요?&quot;라는 추상적인 질문 대신, &quot;어제 인터뷰를 진행했던 사용자 중 n%가 이 버튼을 찾지 못했습니다. 이는 우리 예상치보다 낮으니 개선해야 합니다.&quot;와 같은 구체적인 사실과 숫자를 바탕으로 대화할 수 있었다.&gt; 불확실성이 높을수록 빠른 검증이 중요하다.특히 새로운 시장이나 기능을 개발할 때는 아무리 경험 많은 전문가라도 모두 예측하기는 어렵다. 이런 상황에서는 빠르게 만들고 검증하는 사이클이 훨씬 효과적이다.&lt;!-- # 기술적 도전과 성과![코드 작성 중인 모습](/images/241104/at-night.webp) _밤 늦게까지 개발해도 모자랐지만, 그래도 재밌었다_출장 기간 동안 개발자로서 여러 기술적 도전을 마주했다. 가장 큰 도전은 (휴일을 포함해)매일 새로운 프로토타입을 개발하면서도 코드 품질을 유지하는 것이었다.## 문서 정규화(Normalizing) 로직 개발우리는 텍스트 편집과 음성 합성을 매끄럽게 통합하는 에디터를 만들어야 했다. 이를 위해 [Slate.js](https://docs.slatejs.org/)를 기반으로 에디터를 개발했다.### Slate.js란?Slate.js는 리액트 기반 리치 텍스트 에디터 라이브러리다. 일반적인 WYSIWYG 에디터와 달리, Slate는 문서를 중첩된 노드 트리 구조로 관리한다.![Slate 문서 구조](https://mermaid.ink/img/pako:eNptkEFLwzAUx79KeCeFtdBrDx5GL4KC4E42PYQ2a8vSpMQElDEQdB6GntxJ1oPg0Q_Qw75Rsu-wtNVN0BweeY_f70_y5pCKjEIIuSR1gSYR5sidSKS6olzF5qu1TxvkHSYJ8rwzNGYinQWxadfmo0H2fW2fXxx0RSTpg5IhZqgD3HvnnJWcBrFtWtNsXT26FyWfJX-ECb1TQbxbvtrV527VIrNszdvGcQP5nfcruxP-44_P6SOR5zvlkkj3Cwwd_Phgmy06oX7uo7Fg2SkGp8EIKiorUmZuR_MuAIMqaEUxhO6a0SnRTGHAfOFQopW4vucphEpqOgIpdF5AOCXs1nW6zoiiUdmtqDpMa8JvhPjpF3vdKpkG?type=png) _Slate 문서 구조_- **문서(Document)**: 전체 에디터 콘텐츠- **블록 요소(Block Elements)**: 문단, 제목 등- **인라인 요소(Inline Elements)**: 링크, 강조 등- **텍스트 노드(Text Nodes)**: 실제 텍스트와 스타일 정보우리는 이 구조를 기반으로 음성 합성과 재생을 위한 메타데이터(성우, 음성 스타일, 속도 등)를 추가로 관리해야 했다.### 정규화 로직 구현정규화 로직은 문서 구조를 일관되게 유지하는 역할을 한다. 예를 들어, 텍스트를 붙여넣거나 음성 스타일을 변경할 때 필요한 속성들이 항상 존재하도록 보장하는 예시를 들어보자면:```jsconst withTTSNormalizing = editor =&gt; {  const { normalizeNode } = editor;  editor.normalizeNode = entry =&gt; {    const [node, path] = entry;    // 텍스트 노드에 음성 관련 속성이 없다면 기본값 적용    if (Text.isText(node) &amp;&amp; !node.voice) {      Transforms.setNodes(editor, { voice: DEFAULT_VOICE }, { at: path });      return;    }    // 기존 정규화 로직 실행    normalizeNode(entry);  };  return editor;};```이러한 정규화 로직은 다음과 같은 상황에서 문서 구조를 안정적으로 유지할 수 있게 한다:1. 에디터 외부/내부에서 텍스트를 복사해 붙여넣을 때2. 음성 스타일(피치, 속도 등)을 변경할 때3. AI 성우를 변경할 때## 사용자 경험 개선사용자 경험 측면에서 주요 개선 사항은 다음과 같았다:1. **단어 단위 스타일 편집**: 사용자가 텍스트를 선택하고 스타일을 변경하면 자동으로 단어 단위로 선택 영역을 확장한다. 이는 자연스러운 음성 편집을 위해 필수적이다.![단어 단위 스타일 편집](/images/241104/expand-selection-to-word.mp4 t=0.01)```jsfunction expandSelectionToWord(editor, selection) {  if (!selection) return;  // 현재 선택된 텍스트의 시작과 끝 위치  const start = selection.anchor.offset;  const end = selection.focus.offset;  // 단어 단위로 선택 영역 확장  const expandedStart = findWordBoundary(start, &apos;backward&apos;);  const expandedEnd = findWordBoundary(end, &apos;forward&apos;);  return { start: expandedStart, end: expandedEnd };}```2. **현재 재생 중인 텍스트 하이라이트**: 오디오가 재생될 때 해당 텍스트 부분을 강조 표시한다. [Slate Decorator](https://docs.slatejs.org/concepts/09-rendering#decorations)를 이용했다.```jsfunction useHighlightPlayingTextDecorator() {  const { isPlaying, currentTrack } = usePlayer();  // 재생 중인 텍스트에 하이라이트를 적용하는 데코레이터  const highlightDecorator = useCallback(    ([node, path]) =&gt; {      // 재생 중이 아니거나 재생할 트랙이 없으면 하이라이트 없음      if (!isPlaying || !currentTrack) return [];      // 현재 재생 중인 텍스트 노드 경로와 일치하는지 확인      const trackPath = [        0,        currentTrack.paragraphIndex,        currentTrack.nodeIndex,      ];      if (!Path.equals(path, trackPath)) return [];      // 현재 재생 중인 텍스트를 하이라이트      return [        {          anchor: { path: trackPath, offset: 0 },          focus: { path: trackPath, offset: node.text.length },          playingHighlight: true,        },      ];    },    [isPlaying, currentTrack],  );  return highlightDecorator;}```3. **모바일 환경 최적화**: 모바일에서 가상 키보드가 불필요하게 표시되는 문제를 해결했다.```jsfunction handleMobileInteraction(editor) {  // 모바일 환경에서만 적용  if (!isMobile()) return;  // 텍스트 선택 시 가상 키보드 제어  editor.on(&apos;select&apos;, () =&gt; {    // 선택 영역이 있을 때만 키보드 표시    if (!editor.selection) {      document.activeElement?.blur();    }  });}```4. **성능 최적화**: React Query를 활용해 서버 요청을 최적화하고 사용자 경험을 개선했다.```jsfunction useVoiceDataCaching(scriptId, actorId) {  const queryClient = useQueryClient();  // 음성 데이터 요청 및 캐싱  const { data, isLoading } = useQuery(    [&apos;AUDIO_DATA&apos;, scriptId, actorId],    async () =&gt; {      // 서버에서 음성 데이터 가져오기      return await generateVoice(scriptId, actorId);    },    {      // 캐시 생명주기 설정      staleTime: 1000 * 60 * 10, // 10분      cacheTime: 1000 * 60 * 30, // 30분      // 이미 캐시된 데이터가 있다면 사용      enabled: Boolean(scriptId &amp;&amp; actorId),    },  );  return { audioData: data, isLoading };}``` --&gt;&lt;!-- ## 글로벌 시장은 중요하다![해밀턴 산](/images/241104/hamilton-mountain.webp) _Apple Park 뒤로 보이는 해밀턴 산_&gt; 결국 SaaS 기업은 글로벌 시장으로 나아가야 한다. 한국은 시장이 제한적이다.미국 출장을 통해 또다른 알게된 것 중 하나는 글로벌 시장에 대한 중요성이다. --&gt;&lt;!--  --&gt;&lt;!-- 미국 출장을 통해 글로벌 시장에 대한 중요성을 피부로 느꼈다. 당연하지만 한국 시장만으로는 성장에 한계가 있다. --&gt;&lt;!-- 우리 제품은 더 큰 성장을 위해 글로벌 시장 진출이 필수적이었다. 실제로 새로운 프로토타입을 기반으로 한 제품을 릴리스한 후 1주일 만에 놀라운 변화가 있었다:실제로 개발한 프로토타입을 기반으로 한 제품을 릴리스한 후 1주일 동안의 글로벌 유저 수 추이는 놀라웠다:- 총 사용자 수 51.2% 증가 (4,535명 -&gt; 6,859명)- 신규 참여 사용자 46.3% 증가 (2,508명 -&gt; 3,668명)- 신규 사용자에 대한 가입 전환율 71.4% 향상 (18.9% -&gt; 32.4%)- 활성 로그인 사용자 수 136.9% 증가 (549명 -&gt; 1,301명)- 사용자 활성 비율 57.0% 증가 (12.1% -&gt; 19.0%)이런 급격한 성장은 이 방향이 옳았음을 증명했다. 특히 영어권 사용자들의 높은 참여도는 글로벌 시장에서의 가능성을 보여주었다. --&gt;# 개인적인 성장![해밀턴 산](/images/241104/hamilton-mountain.webp) _Apple Park 뒤로 보이는 해밀턴 산_스스로도 많은 성장을 했다고 생각한다. 새로운 기술을 빠르게 학습하고 적용하는 방법, 팀원들과 효율적으로 소통하는 방법, 그리고 무엇보다 사용자 중심으로 생각하는 방법을 배웠다.## 기술 학습새로운 기술을 접할 때 가장 효과적이었던 방법은 다음과 같다:1. 공식 문서나 신뢰할 수 있는 예제 코드를 먼저 살펴본다2. 핵심 개념이나 패턴을 파악한다3. 실제 필요한 부분에 바로 적용해본다4. 문제가 발생하면 다시 문서로 돌아가 깊이 있게 이해한다이 과정을 반복해 Slate.js도 짧은 시간 내에 파악해 프로토타입에 적용할 수 있었다.## 효율적인 소통 방식빠른 개발 사이클에서 발견한 효과적인 소통 방식은 다음과 같다:1. 짧고 명확한 미팅 (5~10분)2. 논의 사항을 미리 정리3. 결과와 다음 할 일을 문서화특히 컨텍스트 스위칭이 많은 상황에서는 짧게 자주 소통하는 방식이 더 효과적이었다.## 돌아온 후출장을 마치고 한국으로 돌아온 후, 우리 팀이 일하는 방식에는 적지 않은 변화가 생겼다:1. **프로토타입 중심 개발**: 긴 논의보다는 빠른 검증과 실제 데이터를 우선한다2. **사용자 중심 사고**: 모든 기능에 &quot;왜?&quot;라는 질문을 먼저 던진다또한 출장 중 개발한 프로토타입이 실제 제품 로드맵에 반영되어 개발이 진행되고 있다는 점은 큰 성취감을 주었다.# 마치며![떠나는 길](/images/241104/flight.webp) _떠나는 길 비행기 안에서_&apos;그래, 다녀오면 무언가 얻어오겠지&apos;라는 막연한 생각으로 비행기에 올랐다. 그런데 예상보다 훨씬 많은 배움을 얻었다. 가장 중요한 교휸은 &quot;사용자(시장)와 가까이 있어야 한다&quot;는 점이다. 뛰어난 개발자도 상상 속에서는 좋은 제품을 만들 수 없다. 그렇게 시장에서 검증되면 그 방향으로 &quot;빠르게 움직이고, 자주 검증&quot;해야 한다. 글로벌 시장 진출도 필수적이다.이 접근 방식은 실제 성과로 이어졌다. 프로토타입 기반 제품 릴리스 후 1주일 만에 이런 성과를 얻었다:- 총 사용자 수 51.2% 증가 (4,535명 -&gt; 6,859명)- 신규 참여 사용자 46.3% 증가 (2,508명 -&gt; 3,668명)- 신규 사용자에 대한 가입 전환율 71.4% 향상 (18.9% -&gt; 32.4%)- 활성 로그인 사용자 수 136.9% 증가 (549명 -&gt; 1,301명)- 사용자 활성 비율 57.0% 증가 (12.1% -&gt; 19.0%)이는 우리가 선택한 방향이 시장에서 어느정도 검증되었음을 의미한다. 특히 영어권에서 높은 반응을 보여 글로벌 시장에서 가능성을 보여주었다.이 경험을 바탕으로 더 나은 제품을 위한 도전을 계속하고 싶다. 다음에 이런 기회가 한번 더 생긴다면 이번에 배웠던 내용을 바탕으로 더 큰 성과를 내보고 싶다.&gt; &quot;The best way to predict the future is to create it.&quot; - Abraham Lincoln우리가 만드는 제품이 미래를 만들어가는 작은 조각이 되길 바라며 글을 마친다.</content:encoded>
            <category>Experience</category>
        </item>
        <item>
            <title><![CDATA[웹에서 비디오와 음성을 동기화하는 방법]]></title>
            <link>https://shj.rip/article/sync-media-elements-on-web.html</link>
            <guid isPermaLink="false">sync-media-elements-on-web</guid>
            <dc:creator><![CDATA[shj]]></dc:creator>
            <pubDate>Thu, 03 Oct 2024 00:00:00 GMT</pubDate>
            <enclosure url="https://shj.rip/article/sync-media-elements-on-web.png" length="0" type="image/png"/>
            <content:encoded>&lt;!-- https://chatgpt.com/c/68ee01f3-3af4-8324-823b-48c76892e226 이용해서 왜 canplay 필요한지 설명해야 함 --&gt;# TL;DR- `HTMLMediaElement` 이벤트 중 `canplay`, `waiting`, `loadstart`를 이용해 버퍼링 상태 감지가 가능하다.- 모든 미디어 요소가 재생 준비될 때까지 재생을 지연시켜 동기화가 가능하다.- [이 링크](https://codepen.io/Gumball12/pen/EaxdqOV?editors=1010)에서 Chrome DevTools와 네트워크 시뮬레이션을 이용해 실제 동작을 테스트할 수 있다.# 들어가며웹 애플리케이션에서 여러 미디어 요소를 동시에 재생하기는 조금 까다롭다. 그냥 `video.play()`와 `audio.play()`를 같이 호출하는 것으로는 부족하다. 네트워크 상황이나 리소스 크기에 따라 각 요소가 다른 시점에 준비되기 때문이다. 이 글에서는 [`HTMLMediaElement` 이벤트](https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement#events)로 여러 미디어를 함께 재생하는 방법을 소개한다. 말 그대로 모든 요소가 준비될 때까지 기다렸다가 동시에 재생하는 방식이다.# 미디어 동기화미디어 재생 시점은 네트워크 상태, 리소스 크기, 디바이스 성능 등 다양한 요인에 영향받는다. 이런 이유로 여러 미디어 요소를 동시에 재생하게 되면 각기 다른 타이밍에 버퍼링되어 동기화가 깨지는 상황이 발생하기도 한다. 특히 네트워크가 안정적이지 않은 모바일 환경에서 이러한 현상이 더욱 두드러진다.## 버퍼링 이벤트`HTMLMediaElement`는 미디어 상태 파악을 위한 몇 가지 이벤트를 제공한다. 이 중 버퍼링과 관련된 이벤트를 살펴보면 다음과 같다:```tsconst BUFFERING_EVENT_NAME_LIST = [  &apos;canplay&apos;, // 재생할 수 있을 정도로 데이터가 로드됨  &apos;waiting&apos;, // 재생하려면 더 많은 데이터 로드가 필요함  &apos;loadstart&apos;, // 미디어 로드 시작됨];```앞서 말했듯 동기화 핵심 아이디어는 간단하다. 모든 미디어 요소가 준비될 때까지 재생을 지연시키면 된다. 구현 방법을 단계별로 살펴보자.## 동기화 구현### 1. 컨트롤러 인터페이스 정의먼저 미디어 요소를 전반적으로 재생/일시정지하는 컨트롤러가 필요하다. 다음은 이해를 돕기 위한 인터페이스 예시이다:```tsinterface Controller {  isPlay: boolean;  play(): void;  pause(): void;}```### 2. 버퍼링 감지이제 버퍼링을 감지하는 로직을 구현해보자.```ts// 버퍼링 중인 미디어 요소 추적let buffered: HTMLMediaElement[] = [];let bufferCheckIntervalId = -1;// 중복 핸들링 방지const bufferingSymbol = Symbol(&apos;withBuffering&apos;);const withBuffering = (controller: Controller, mediaContainer: HTMLElement) =&gt; {  const alreadyHandled = mediaContainer[bufferingSymbol];  if (alreadyHandled) {    return;  }  mediaContainer[bufferingSymbol] = true;  // 컨테이너 내 모든 미디어 요소에 대해 이벤트 등록  BUFFERING_EVENT_NAME_LIST.forEach(evtName =&gt;    mediaContainer.addEventListener(      evtName,      ({ target }) =&gt;        isMediaElement(target) &amp;&amp; tryBuffering(controller, target),      { capture: true },    ),  );};```이 코드는 미디어 컨테이너 내 모든 미디어 요소에 대해 앞서 언급했던 버퍼링 관련 이벤트 핸들러를 등록한다. 유의해야 할 사항은 `capture: true` 옵션으로 이벤트 캡처 단계에서 핸들링해야 한다는 점인데, `canplay`와 `waiting` 이벤트는 버블링되지 않기 떄문이다[^1].[^1]: MDN 문서([canplay](https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/canplay_event), [waiting](https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/waiting_event))를 보면 &quot;This event is not cancelable and does not bubble.&quot; 라고 언급된다.### 3. 버퍼링 처리```tsconst tryBuffering = (  controller: Controller,  mediaElement: HTMLMediaElement,) =&gt; {  const noNeedBuffering =    !controller.isPlay ||    isMediaPlayable(mediaElement) ||    buffered.includes(mediaElement);  if (noNeedBuffering) {    return;  }  const preventPlay = () =&gt; controller.pause();  preventPlay();  mediaElement.addEventListener(&apos;playing&apos;, preventPlay);  buffered.push(mediaElement);  startCheckMediaIsPlayable(() =&gt; {    clearBuffer(preventPlay);    controller.play();  });};const clearBuffer = (preventPlay: () =&gt; void) =&gt; {  window.clearTimeout(bufferCheckIntervalId);  bufferCheckIntervalId = -1;  buffered.forEach(element =&gt;    element.removeEventListener(&apos;playing&apos;, preventPlay),  );  buffered = [];};````tryBuffering` 함수는 버퍼링이 필요하면 모든 재생을 멈추고(`preventPlay`) 모든 미디어 요소가 준비될 때까지 대기하는 동작을 한다.`clearBuffer`는 말 그대로 버퍼링 완료 시점에 리소스를 정리하는 함수이다.### 4. 재생 가능 상태 확인```tsconst CHECK_INTERVAL_MS = 3000; // 3000ms마다 미디어 상태를 확인const startCheckMediaIsPlayable = (onPlayable: () =&gt; void) =&gt; {  // 중복 체크 방지  if (bufferCheckIntervalId !== -1) {    return;  }  const executeOnPlayableWhenEveryPlayable = () =&gt; {    const everyPlayable = buffered.every(isMediaPlayable);    if (everyPlayable) {      onPlayable();      return;    }    bufferCheckIntervalId = setTimeout(      executeOnPlayableWhenEveryPlayable,      CHECK_INTERVAL_MS,    );  };  executeOnPlayableWhenEveryPlayable();};```이 함수는 모든 미디어 요소가 재생 가능한지 지속적으로 확인하는 동작을 수행한다.### 5. 재생 가능 여부 판단 및 버퍼 초기화```tsconst isMediaPlayable = (mediaElement: HTMLMediaElement) =&gt; {  if (mediaElement.networkState !== mediaElement.NETWORK_LOADING) {    return true;  }  if (mediaElement.readyState &gt;= mediaElement.HAVE_ENOUGH_DATA) {    return true;  }  return false;};```이 함수는 `networkState` 및 `readyState`를 이용해 재생 가능 여부를 확인한다. 각 프로퍼티에 대한 의미를 보자면 다음과 같다.`networkState`:- `NETWORK_EMPTY` (0): 미디어 요소 최초 생성 상태, 아직 데이터가 없다- `NETWORK_IDLE` (1): 활성 네트워크 요청은 없으며, 데이터가 존재한다- `NETWORK_LOADING` (2): 브라우저가 데이터를 다운로드 중이다- `NETWORK_NO_SOURCE` (3): 미디어 리소스를 찾을 수 없다코드에서는 `!== NETWORK_LOADING`을 사용했는데, 이는 &quot;이미 충분한 데이터가 있거나 로드가 완료되어 더 이상 로드할 데이터가 없음&quot;을 의미한다. 즉, &quot;재생 가능함&quot;을 의미한다.`readyState`:- `HAVE_NOTHING` (0): 사용 가능한 정보가 없다- `HAVE_METADATA` (1): 메타데이터는 있으나, 미디어 재생을 위한 데이터는 부족하다- `HAVE_CURRENT_DATA` (2): 현재 재생 위치에 대한 데이터는 있으나, 그 이후는 충분하지 않다- `HAVE_FEATURE_DATA` (3): 현재 재생 위치와 그 다음 일부에 대한 재생 데이터가 존재한다- `HAVE_ENOUGH_DATA` (4): 버퍼링 없이 재생할 수 있을 정도로 데이터가 충분하다코드에서는 `&gt;= HAVE_ENOUGH_DATA`를 사용했는데, 이는 &quot;버퍼링 없이 재생 가능한 충분한 데이터가 있음&quot;을 의미한다. 이 상태도 마찬가지로 &quot;재생 가능함&quot;을 의미한다.# 사용하기`withBuffering` 함수는 다음과 같이 사용할 수 있다.```html&lt;div class=&quot;media-container&quot;&gt;  &lt;video    src=&quot;https://storage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4&quot;    controls  &gt;&lt;/video&gt;  &lt;audio    src=&quot;https://github.com/rafaelreis-hotmart/Audio-Sample-files/raw/master/sample.mp3&quot;    controls  &gt;&lt;/audio&gt;&lt;/div&gt;&lt;button class=&quot;play&quot;&gt;play&lt;/button&gt;&lt;button class=&quot;stop&quot;&gt;clear&lt;/button&gt;``````tsdocument.addEventListener(&apos;DOMContentLoaded&apos;, () =&gt; {  const mediaContainer = document.querySelector(&apos;.media-container&apos;);  const videoElement = document.querySelector(&apos;video&apos;);  const audioElement = document.querySelector(&apos;audio&apos;);  const controller = {    isPlay: false,    play() {      this.isPlay = true;      videoElement.play();      audioElement.play();    },    stop() {      this.pause();      clearMedia(videoElement);      clearMedia(audioElement);    },    pause() {      this.isPlay = false;      videoElement.pause();      audioElement.pause();    },  };  withBuffering(controller, mediaContainer);  document    .querySelector(&apos;button.play&apos;)    .addEventListener(&apos;click&apos;, () =&gt; controller.play());  document    .querySelector(&apos;button.stop&apos;)    .addEventListener(&apos;click&apos;, () =&gt; controller.stop());});const clearMedia = (mediaElement: HTMLMediaElement) =&gt; {  mediaElement.currentTime = 0;  const originSrc = mediaElement.src;  mediaElement.src = &apos;&apos;;  mediaElement.load();  mediaElement.src = originSrc;};```[이 링크](https://codepen.io/Gumball12/pen/EaxdqOV?editors=1010)에서 실제 동작을 테스트할 수도 있다. 접속 후 Chrome DevTools와 같은 도구를 이용해 네트워크를 3G와 같이 느린 환경으로 시뮬레이트 해보자. 모든 미디어가 충분히 버퍼링된 후 함께 재생되는 모습을 볼 수 있을 것이다.## 프로덕트 적용 시 유의점실제 환경에서는 다음 사항도 고려해야 한다:- **타임아웃:** 어떤 네트워크 환경일지 알 수 없기에, 장시간 버퍼링 시 적절하게 알려줘야 한다.- **버퍼링 시각화:** 현재 버퍼링 상태임을 적절하게 알려줘야 혼동이 없다.- **폴백(Fallback) 처리:** 잘못된 링크 등 동기화 자체가 불가능한 상황도 있다.# 맺으며웹에서 여러 미디어를 동시에 재생한다는 것은 겉보기보다 복잡하다. 그러나 이는 단순한 UX 향상을 넘어 사용자가 버퍼링으로 불편함 없이 콘텐츠를 안정적으로 경험할 수 있도록 하는 중요한 문제이다.여기서는 이벤트 기반 버퍼링 상태를 관리해 모든 미디어가 준비된 후 재생하는 방법을 이용했다. 어떤 접근법을 사용하든 무엇보다 중요한 것은 사용자 환경과 요구사항에 맞는 최적 접근법을 선택해야 한다. 웹 플랫폼은 계속 발전하고 있으며, 미디어 동기화를 위한 더 나은 API 및 툴이 앞으로도 등장할 것이라 기대한다.</content:encoded>
            <category>Guide</category>
        </item>
        <item>
            <title><![CDATA[iOS Safari에서 올바르게 비디오 프레임 추출하기 🔍]]></title>
            <link>https://shj.rip/article/extracting-video-frames-correctly-in-ios-safari-ko.html</link>
            <guid isPermaLink="false">extracting-video-frames-correctly-in-ios-safari-ko</guid>
            <dc:creator><![CDATA[shj]]></dc:creator>
            <pubDate>Wed, 17 Jul 2024 00:00:00 GMT</pubDate>
            <enclosure url="https://shj.rip/article/extracting-video-frames-correctly-in-ios-safari-ko.png" length="0" type="image/png"/>
            <content:encoded>[English](./extracting-video-frames-correctly-in-ios-safari.html)&gt; 참고: iOS Safari 14 ~ 17.4.1 버전에서 동작을 확인했습니다.&gt; 비디오 프레임 추출에 대한 NPM 패키지를 찾으신다면, [generate-video-dumbnail](https://npmjs.com/package/generate-video-dumbnail)은 어떠신가요? [여기서 온라인 데모를 확인해 보세요](https://gumball12.github.io/generate-video-dumbnail/).&lt;a id=&quot;thumbnail-issue&quot;&gt;&lt;/a&gt;![프레임 추출 이슈](/images/240528/existing-issue.png) _iOS에서 비디오 프레임 추출은 예상과 다르게 동작하는 경우가 있습니다_최근 비디오 프레임 추출 작업을 수행했었습니다. 어렵지는 않았습니다. 약간의 추론과, ChatGPT, 그리고 구글링을 통해 빠르게 코드를 구성해 나갔습니다.```jsconst canvas = document.createElement(&apos;canvas&apos;);const ctx = canvas.getContext(&apos;2d&apos;);video.addEventListener(&apos;loadeddata&apos;, () =&gt; {  canvas.width = video.videoWidth;  canvas.height = video.videoHeight;  ctx.drawImage(video, 0, 0, canvas.width, canvas.height);  const thumbnailDataUrl = canvas.toDataURL(&apos;image/png&apos;);  document.querySelector(&apos;img&apos;).src = thumbnailDataUrl;});```그렇게 Canvas API를 이용해 완성한 코드는 macOS Safari와 Chrome 브라우저에서 예상과 같이 동작했습니다. 생각보다 일찍 끝날 것 같아 조금 들떠 있기도 했습니다. 그리고, iOS Safari에서 비디오 프레임을 추출해 봤는데... **아무것도 나타나지 않았습니다!** 무언가 잘못되었죠. 🤯![브라우저 스펙 테스트 실패 그래프](/images/240528/browser-failures.png) _브라우저 스펙 테스트 실패 그래프 ([데이터 소스](https://wpt.fyi/results/?label=master&amp;label=experimental&amp;aligned))_이와 비슷한 상황을 겪어보신 적이 있나요? iOS Safari는 매끄럽고 우아하지만, 종종 웹 프런트엔드 개발자에게 큰 고통을 주기도 합니다. 이 글에서는 **iOS Safari에서 비디오 프레임을 추출하는 방법과 그 이유** 에 대해 정리한 내용을 공유하고자 합니다. 또한 실제 기기에서 테스트할 수 있도록 온라인 데모도 함께 제공합니다.프레임 추출 문제가 얼마나 오래 지속될지는 모르겠지만, 적어도 지금은 이 가이드를 통해 의도한 대로 추출할 수 있습니다. 이 내용이 도움이 되기를 바랍니다.&gt; [!NOTE] 들어가기 전에&gt; 이 글은 순서가 없습니다! 위에서부터 순서대로 읽는 대신, 필요한 내용에 바로 접근해 읽을 수 있습니다. 돋보기(🔍)가 없는 제목은 각 상황을 의미합니다. 내용은 해결 방법(코드)을 먼저 보여주고, 이에 대한 이유를 그다음에 간략하게 제공합니다. 이유 앞에는 돋보기(🔍)가 있는데, 이를 클릭하면 상세한 설명을 볼 수 있습니다. 또한 iOS Safari를 대상으로만 설명합니다.# 비디오 포스터 보여주기## w/ 정적 비디오 요소```html&lt;video  src=&quot;https://cdn.jsdelivr.net/gh/Gumball12/public-timer-mp4/timer.mp4#t=0.001&quot;  preload=&quot;metadata&quot;&gt;&lt;/video&gt;```[CodePen](https://codepen.io/Gumball12/pen/eYaBjOK?editors=1100)- [🔍](#media-fragments-uri &apos;Move to Reason&apos;) `src` 애트리뷰트 마지막에 `#t=0.001`을 붙여 영상 첫 프레임을 표시할 수 있습니다.- [🔍](#preload-애트리뷰트 &apos;Move to Reason&apos;) `preload` 애트리뷰트 값을 `metadata`로 설정할 필요는 없지만, 프레임 추출이 지연될 수 있습니다.- 비디오는 HTTP 또는 HTTPS로 제공할 수 있습니다.### 🔍 Media Fragments URIiOS Safari는 비디오 요소에 대해 **기본적으로 첫 번째 프레임을 보여주지 않습니다!** `preload=&quot;metadata&quot;` 또는 `preload=&quot;auto&quot;`로 설정해도 마찬가지입니다. 이 대신, [Media Fragments URI](https://www.w3.org/TR/media-frags/#introduction)를 사용해야 합니다. ([Can I Use](https://caniuse.com/media-fragments))`#t=&lt;time&gt;`은 Media Fragments 요소 중 하나입니다. 이를 이용해 비디오 특정 시간을 지정할 수 있습니다(초 단위). iOS Safari에서는 이 방식을 이용해 비디오 프레임(포스터)을 보여줄 수 있습니다. 가령 `#t=0.001`로 설정한다면(0.001초), 첫 번째 비디오 프레임이 포스터로 나타납니다. 참고로 `#t=0`은 동작하지 않는다는 점을 유의하세요.### 🔍 Preload 애트리뷰트메타데이터 로드 시, [비디오 프레임 일부가 전달될 수 있습니다](https://www.w3.org/html/wiki/Elements/video#HTML_Attributes). 따라서 `preload` 속성을 `&apos;auto&apos;` 대신 `&apos;metadata&apos;`로 설정할 수 있습니다. 참고로 iOS Safari에서 `preload` 속성 기본값은 `&apos;auto&apos;` 이지만, 디바이스 상태에 따라(예: 배터리 절약 모드) 달라질 수 있습니다. 이러한 이유로, 속성값을 명시적으로 설정하는 것이 좋습니다.## w/ 동적 비디오 요소 (HTTP URL)```jsconst video = document.createElement(&apos;video&apos;);document.body.appendChild(video);video.preload = &apos;metadata&apos;;video.src =  &apos;https://cdn.jsdelivr.net/gh/Gumball12/public-timer-mp4/timer.mp4#t=0.001&apos;;```[CodePen](https://codepen.io/Gumball12/pen/wvboxGd?editors=0110)- [🔍](#media-fragments-uri &apos;Move to Reason&apos;) `src` 애트리뷰트 마지막에 `#t=0.001`을 붙여 영상 첫 프레임을 표시할 수 있습니다.- [🔍](#preload-애트리뷰트 &apos;Move to Reason&apos;) `preload` 애트리뷰트 값을 `metadata`로 설정할 필요는 없지만, 프레임 추출이 지연될 수 있습니다.- **비디오 요소는 항상 HTTPS로 제공해야 합니다.** 보안상 이유로, 동적으로 비디오 요소를 만드는 경우 `src` 애트리뷰트는 항상 HTTPS를 사용해야 합니다.## w/ 동적 비디오 요소 (Blob URL)```jsconst input = document.createElement(&apos;input&apos;);input.type = &apos;file&apos;;input.accept = &apos;video/*&apos;;document.body.appendChild(input);input.addEventListener(&apos;change&apos;, ({ target }) =&gt; {  const file = target.files[0];  const url = URL.createObjectURL(file);  const video = document.createElement(&apos;video&apos;);  document.body.appendChild(video);  video.src = url;  video.preload = &apos;metadata&apos;;  video.currentTime = 0.001;});```[CodePen](https://codepen.io/Gumball12/pen/eYaBjzL?editors=0110)- [🔍](#blob-url-사용-시-preload-metadata로-설정 &apos;Move to Reason&apos;) Blob URL 사용 시, `preload`는 반드시 `&apos;metadata&apos;`여야 합니다. `&apos;auto&apos;`는 동작하지 않습니다.- [🔍](#blob-url은-media-fragments-uri를-사용할-수-없음 &apos;Move to Reason&apos;) Media Fragments URI(`#t=&lt;time&gt;`)는 Blob URL에 대해 동작하지 않습니다. 이 대신 `&apos;loadedmetadata&apos;` 이벤트를 이용해 `currentTime`을 `0.001`로 설정하는 방식을 이용해 주세요.### 🔍 Blob URL 사용 시 `preload=&apos;metadata&apos;`로 설정![Preload 비교 - metadata](/images/240528/preload-metadata.png) ![Preload 비교 - auto](/images/240528/preload-auto.png) _`preload = &apos;metadata&apos;`와 `preload = &apos;auto&apos;` 네트워크 요청 비교_Blob URL을 사용해 비디오 요소를 생성하는 경우, **`preload`를 `&apos;auto&apos;`로 설정하면 지정된 시간에 대한 프레임 데이터를 가져오지 않습니다.** 실제로 네트워크 요청(위 이미지)을 확인해 보면, `preload=&quot;metadata&quot;`는 첫 번째 프레임을 나타내기 위해 48-1541 범위만큼 데이터(Bytes)를 가져오는 것을 볼 수 있습니다. 반면 `preload=&quot;auto&quot;`는 데이터를 가져오지 않았습니다.안타깝게도 이러한 차이에 대한 기술적인 이유는 찾지 못했습니다. 테스트는 iOS Safari 17.4.1에서 [이 코드](https://codepen.io/Gumball12/pen/PovpONw?editors=0010)를 통해 진행했습니다.### 🔍 Blob URL은 Media Fragments URI를 사용할 수 없음현재 최신 WebKit(Safari 17.4.1)에서는 **Blob URL에 Media Fragments URI(예: `#t=&lt;time&gt;`)을 사용할 수 없습니다.** 관련 변경 사항은 [이 커밋](https://github.com/WebKit/WebKit/commit/7f2ea8fcf41a68add90efab89609218407e1a824#diff-015bfbdb65247b7b4cc8319b4798e660d9c6b9df998610f579376a6db3f28f24L274-L275)[^1]에서 확인할 수 있습니다. 아래는 커밋 내용 중 일부입니다:```diffBlobData* BlobRegistryImpl::getBlobDataFromURL(const URL&amp; url, const std::optional&lt;SecurityOriginData&gt;&amp; topOrigin) const{    ASSERT(isMainThread());-    if (url.hasFragmentIdentifier())-        return m_blobs.get&lt;StringViewHashTranslator&gt;(url.viewWithoutFragmentIdentifier());-    return m_blobs.get(url.string());+    auto urlKey = url.stringWithoutFragmentIdentifier();+    auto* blobData = m_blobs.get(urlKey);+    if (m_allowedBlobURLTopOrigins &amp;&amp; topOrigin &amp;&amp; topOrigin != m_allowedBlobURLTopOrigins-&gt;get(urlKey)) {+        RELEASE_LOG_ERROR(Network, &quot;BlobRegistryImpl::getBlobDataFromURL: (%p) Requested blob URL with incorrect top origin.&quot;, this);+        return nullptr;+    }+    return blobData;}```Media Fragment URI 사용 여부를 확인하는 `URL::hasFragmentIdentifier` 함수가 제거되었습니다. 대신, Media Fragment URI를 제외한 문자열을 반환하는 `URL::stringWithoutFragmentIdentifier` 함수가 사용됩니다. 따라서 Media Fragment URI 대신 `currentTime`을 사용해야 합니다.코드 히스토리도 살펴보면, 이전에는 Media Fragment URI가 Blob URL에 대해서도 지원되었음을 확인할 수 있습니다. 이는 [Safari Technology Preview 118 릴리즈 노트](https://webkit.org/blog/11439/release-notes-for-safari-technology-preview-118)와 [관련 커밋](https://github.com/WebKit/WebKit/commit/cce9f0c257eca20126d3c3d22e97e339d5718264#diff-015bfbdb65247b7b4cc8319b4798e660d9c6b9df998610f579376a6db3f28f24)에서 확인할 수 있습니다.[^1]: 이 커밋은 Blob 파티셔닝과 관련된 문제를 해결하는 것으로 보입니다. Blob 파티셔닝은 큰 데이터를 효율적으로 처리하고 사용자에게 빠른 응답을 제공하기 위해 Blob을 작은 청크로 나누는 기술입니다. 이 기능은 [Safari 17.2(19671.1.17)](https://developer.apple.com/documentation/safari-release-notes/safari-17_2-release-notes#New-Features)에서 소개되었습니다.# 비디오 프레임 하나 추출하기## w/ HTTP URL```jsconst THUMBNAIL_POSITION = 5.3;const video = document.createElement(&apos;video&apos;);video.crossOrigin = &apos;anonymous&apos;;video.preload = &apos;metadata&apos;;video.src = `https://cdn.jsdelivr.net/gh/Gumball12/public-timer-mp4/timer.mp4#t=${THUMBNAIL_POSITION}`;video.addEventListener(&apos;seeked&apos;, () =&gt; {  const canvas = document.createElement(&apos;canvas&apos;);  canvas.width = video.videoWidth;  canvas.height = video.videoHeight;  const ctx = canvas.getContext(&apos;2d&apos;);  ctx.drawImage(video, 0, 0, canvas.width, canvas.height);  canvas.toBlob(blob =&gt; {    const img = document.createElement(&apos;img&apos;);    img.src = URL.createObjectURL(blob);    document.body.appendChild(img);  });});```[CodePen](https://codepen.io/Gumball12/pen/qBGqyKd?editors=0110)- [🔍](#crossorigin-anonymous로-설정해-캔버스-요소에-대한-securityerror-피하기 &apos;Move to Reason&apos;) `crossOrigin` 애트리뷰트는 `&apos;anonymous&apos;`로 설정해야 합니다.- [🔍](#preload-애트리뷰트 &apos;Move to Reason&apos;) `preload` 애트리뷰트 값을 `metadata`로 설정할 필요는 없지만, 프레임 추출이 지연될 수 있습니다.- [🔍](&lt;#비디오-프레임-데이터는-seeked-또는-timeupdate-이벤트-이후에-사용-가능-(http-url)&gt; &apos;Move to Reason&apos;) 비디오 프레임 데이터는 `&apos;seeked&apos;` 또는 `&apos;timeupdate&apos;` 이벤트 이후에 사용할 수 있습니다.### 🔍 `crossOrigin=&apos;anonymous&apos;`로 설정해 캔버스 요소에 대한 SecurityError 피하기브라우저는 기본적으로 &quot;동일한 출처&quot;를 가진 리소스만 허용합니다([SOP, Same Origin Policy](https://developer.mozilla.org/en-US/docs/Web/Security/Same-origin_policy)). 출처가 다르다면 [CORS(Cross-Origin Resource Sharing)](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS)를 통해 접근을 허용받아야 합니다.미디어 리소스 요청 시 [`crossOrigin` 애트리뷰트](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/crossorigin)를 이용해 CORS 정책을 어떻게 처리할지 결정할 수 있습니다. 물론 아무런 설정 없이도 다른 출처(Origin)에 존재하는 미디어 리소스를 요청할 수는 있습니다. 하지만 **브라우저는 무결성이 보장되지 않는다고 간주합니다**. 그리고 이를 캔버스에 그리게 되면, **오염된(Tainted) 캔버스** 가 됩니다[^2].[^2]: 출처가 동일하다면 SOP에 의해 캔버스가 오염되지 않습니다!![오염된 캔버스](/images/240528/tainted-canvas.webp)오염된 캔버스는 웹사이트가 리소스 공유를 허용받지 못했음을 의미합니다. 이 상태에서는 픽셀 데이터에 접근할 수 없습니다. 그렇지 않으면 허용되지 않은 출처에서 이미지 내 픽셀 데이터를 임의로 유출할 수 있게 됩니다.캔버스 픽셀 데이터는 `getImageData`, `toDataURL`, `toBlob` 등으로 접근할 수 있습니다. 위에서 언급한 보안 문제를 방지하기 위해, **이러한 메서드는 오염된 캔버스에 대해 [`SecurityError`](https://developer.mozilla.org/en-US/docs/Web/API/DOMException#securityerror)를 던집니다**. 그렇기에 `crossOrigin` 을 설정해야 합니다.옵션은 두 가지가 있습니다. 그 중 `&apos;anonymous&apos;`(또는 빈 문자열 `&apos;&apos;`)는 CORS를 검증하지만, 요청에 자격 증명(쿠키, HTTP Authentication 헤더, SSL 인증서 등)을 포함하지 않도록 합니다. 이는 민감한 사용자 정보가 노출되는 상황을 방지하는 데 도움이 됩니다. 따라서 여기서는 `&apos;anonymous&apos;`를 사용했습니다. 다만 필요한 경우 `&apos;use-credential&apos;` 옵션을 통해 자격 증명을 포함할 수 있습니다.### 🔍 비디오 프레임 데이터는 `&apos;seeked&apos;` 또는 `&apos;timeupdate&apos;` 이벤트 이후 사용 가능 (HTTP URL)HTTP URL을 사용해 비디오 데이터를 요청하는 경우, 프레임 데이터는 `&apos;seeked&apos;` 또는 `&apos;timeupdate&apos;` 이벤트 이후에 접근이 가능합니다. ([⚠️ Blob URL은 다르게 동작합니다.](&lt;#비디오-프레임-데이터는-seeked-또는-timeupdate-이후-일정-시간이-지나야-사용-가능-(blob-url)&gt;))## w/ Blob URL```jsconst THUMBNAIL_POSITION = 5.3;const input = document.createElement(&apos;input&apos;);input.type = &apos;file&apos;;input.accept = &apos;video/*&apos;;document.body.appendChild(input);input.addEventListener(&apos;change&apos;, async ({ target }) =&gt; {  const file = target.files[0];  const src = URL.createObjectURL(file);  const video = document.createElement(&apos;video&apos;);  video.src = src;  video.preload = &apos;metadata&apos;;  video.addEventListener(&apos;seeked&apos;, async () =&gt; {    await new Promise(resolve =&gt; setTimeout(resolve, 100));    const canvas = document.createElement(&apos;canvas&apos;);    canvas.width = video.videoWidth;    canvas.height = video.videoHeight;    const ctx = canvas.getContext(&apos;2d&apos;);    ctx.drawImage(video, 0, 0, canvas.width, canvas.height);    canvas.toBlob(blob =&gt; {      const img = document.createElement(&apos;img&apos;);      img.src = URL.createObjectURL(blob);      document.body.appendChild(img);    });  });  video.addEventListener(    &apos;loadedmetadata&apos;,    () =&gt; (video.currentTime = THUMBNAIL_POSITION),  );});```[CodePen](https://codepen.io/Gumball12/pen/pomNGbb?editors=0110)- [🔍](#blob-url-사용-시-preload-metadata로-설정 &apos;Move to Reason&apos;) Blob URL 사용 시, `preload`는 반드시 `&apos;metadata&apos;`여야 합니다. `&apos;auto&apos;`는 동작하지 않습니다.- [🔍](#blob-url은-media-fragments-uri를-사용할-수-없음 &apos;Move to Reason&apos;) Media Fragments URI(`#t=&lt;time&gt;`)는 Blob URL에 대해 동작하지 않습니다. 이 대신 `&apos;loadedmetadata&apos;` 이벤트를 이용해 `currentTime`을 설정해 주세요.- [🔍](&lt;#비디오-프레임-데이터는-seeked-또는-timeupdate-이후-일정-시간이-지나야-사용-가능-(blob-url)&gt;) 비디오 프레임 데이터는 `&apos;seeked&apos;` 또는 `&apos;timeupdate&apos;` 이벤트 이후 일정 시간이 지나야 사용할 수 있습니다.### 🔍 비디오 프레임 데이터는 `&apos;seeked&apos;` 또는 `&apos;timeupdate&apos;` 이후 일정 시간이 지나야 사용 가능 (Blob URL)&lt;!-- createImageBitmap 사용하면 바로 불러올 수 있음 --&gt;&lt;!-- https://codepen.io/Gumball12/pen/jEWGqPa (close 해야할지도) --&gt;Blob URL에 대해서도 `&apos;seeked&apos;` 및 `&apos;timeupdate&apos;` 이벤트를 사용할 수 있습니다. 다만 **비디오 프레임 데이터를 사용한다면 일정 시간을 기다려야 합니다**. 경험적으로 80~100ms 이후 접근이 가능했습니다.HTTP URL과 달리, Blob URL은 이벤트가 트리거 된 직후 프레임 데이터가 없을 수 있습니다. 심지어 [`readyState`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/readyState)가 &quot;HAVE_ENOUGH_DATA&quot;를 의미하는 `4` 인 경우에도 말이죠! 이는 Blob URL이 로컬 파일을 참조하기 때문으로 추정됩니다[^3]. 비디오 데이터가 디코딩되어 메모리에 로드될 때 지연이 발생할 수 있습니다.[^3]: 예제 코드에서는 `File` 객체를 사용해 Blob URL을 생성합니다. 그리고 `File` 객체는 로컬 파일 시스템의 파일을 참조합니다. (&quot;`File` 인터페이스는 파일에 대한 정보를 제공하고, 웹 페이지에서 JavaScript를 통해 해당 콘텐츠에 접근할 수 있도록 도와줍니다.&quot; - [MDN](https://developer.mozilla.org/en-US/docs/Web/API/File))# 비디오 프레임 여러 개 추출하기## w/ HTTP URL```jsconst THUMBNAIL_POSITION_LIST = [  0, 1.1, 2.2, 3.3, 4.4, 5.5, 4.6, 3.7, 2.8, 1.9, 0,];const main = async () =&gt; {  for (const thumbnailPosition of THUMBNAIL_POSITION_LIST) {    const correctedThumbnailPosition = thumbnailPosition || 0.001;    const video = document.createElement(&apos;video&apos;);    video.crossOrigin = &apos;anonymous&apos;;    video.preload = &apos;metadata&apos;;    video.src = `https://cdn.jsdelivr.net/gh/Gumball12/public-timer-mp4/timer.mp4#t=${correctedThumbnailPosition}`;    generateThumbnail(video, thumbnailPosition);  }};const generateThumbnail = (video, thumbnailPosition) =&gt; {  const img = document.createElement(&apos;img&apos;);  document.body.appendChild(img);  video.addEventListener(&apos;seeked&apos;, () =&gt; {    const canvas = document.createElement(&apos;canvas&apos;);    canvas.width = video.videoWidth;    canvas.height = video.videoHeight;    const ctx = canvas.getContext(&apos;2d&apos;);    ctx.drawImage(video, 0, 0, canvas.width, canvas.height);    canvas.toBlob(blob =&gt; (img.src = URL.createObjectURL(blob)));  });  video.currentTime = thumbnailPosition;};main();```[CodePen](https://codepen.io/Gumball12/pen/pomNYya?editors=0110)- [🔍](#crossorigin-anonymous로-설정해-캔버스-요소에-대한-securityerror-피하기 &apos;Move to Reason&apos;) `crossOrigin` 애트리뷰트는 `&apos;anonymous&apos;`로 설정해야 합니다.- [🔍](#preload-애트리뷰트 &apos;Move to Reason&apos;) `preload` 애트리뷰트 값을 `metadata`로 설정할 필요는 없지만, 프레임 추출이 지연될 수 있습니다.- [🔍](#media-fragments-uri &apos;Move to Reason&apos;) `#t=${time}` 을 이용해 프레임 위치를 지정할 수 있습니다.- [🔍](&lt;#비디오-프레임-데이터는-seeked-또는-timeupdate-이벤트-이후에-사용-가능-(http-url)&gt; &apos;Move to Reason&apos;) 비디오 프레임 데이터는 `&apos;seeked&apos;` 또는 `&apos;timeupdate&apos;` 이벤트 이후에 사용할 수 있습니다.## w/ Blob URL```jsconst input = document.createElement(&apos;input&apos;);input.type = &apos;file&apos;;input.accept = &apos;video/*&apos;;document.body.appendChild(input);input.addEventListener(&apos;change&apos;, async ({ target }) =&gt; {  const file = target.files[0];  const src = URL.createObjectURL(file);  const THUMBNAIL_POSITION_LIST = [    0, 1.1, 2.2, 3.3, 4.4, 5.5, 4.6, 3.7, 2.8, 1.9, 0,  ];  for (const thumbnailPosition of THUMBNAIL_POSITION_LIST) {    const video = document.createElement(&apos;video&apos;);    video.preload = &apos;metadata&apos;;    video.src = src;    await generateThumbnail(video, thumbnailPosition);  }});const generateThumbnail = (video, thumbnailPosition) =&gt;  new Promise(resolve =&gt; {    video.addEventListener(&apos;seeked&apos;, async () =&gt; {      await new Promise(resolve =&gt; setTimeout(resolve, 100));      const canvas = document.createElement(&apos;canvas&apos;);      canvas.width = video.videoWidth;      canvas.height = video.videoHeight;      const ctx = canvas.getContext(&apos;2d&apos;);      ctx.drawImage(video, 0, 0, canvas.width, canvas.height);      canvas.toBlob(blob =&gt; {        const img = document.createElement(&apos;img&apos;);        img.src = URL.createObjectURL(blob);        document.body.appendChild(img);        resolve();      });    });    video.addEventListener(&apos;loadedmetadata&apos;, () =&gt;      setTimeout(() =&gt; (video.currentTime = thumbnailPosition), 1),    );  });```[CodePen](https://codepen.io/Gumball12/pen/jOoVJMY?editors=0110)- [🔍](#blob-url-사용-시-preload-metadata로-설정 &apos;Move to Reason&apos;) Blob URL 사용 시, `preload`는 반드시 `&apos;metadata&apos;`여야 합니다. `&apos;auto&apos;`는 동작하지 않습니다.- [🔍](#스레드-블로킹으로-인해-잘못된-프레임-추출-가능 &apos;Move to Reason&apos;) 스레드 블로킹을 피하기 위해, `Promise`를 사용해 프레임을 순차적으로 추출해야 합니다.- [🔍](&lt;#비디오-프레임-데이터는-seeked-또는-timeupdate-이후-일정-시간이-지나야-사용-가능-(blob-url)&gt;) 비디오 프레임 데이터는 `&apos;seeked&apos;` 또는 `&apos;timeupdate&apos;` 이벤트 이후 일정 시간이 지나야 사용할 수 있습니다.### 🔍 스레드 블로킹으로 인해 잘못된 프레임 추출 가능프레임 여러 개를 추출하는 경우 스레드 블로킹이 발생할 수 있습니다. 그리고 만약 스레드 블로킹이 발생한다면, **프레임이 제대로 추출되지 않을 수 있습니다**. 이 경우 [투명 이미지](#thumbnail-issue)가 생성됩니다.![프레임 일괄 추출 시도](/images/240528/attempt-to-generate-thumbnails-all-at-once.png) _프레임 일괄 추출 시도 ([타임라인 원본 데이터](/images/240528/attempt-to-generate-thumbnails-all-at-once.json))_위 이미지는 영상 내 모든 프레임을 한 번에 추출하려고 시도한 결과입니다. 특정 범위에 네트워크 요청이 집중되어 있고, 메인 스레드 역시 장시간 지속적으로 사용되고 있습니다. 그 결과 스레드 블로킹이 발생되어, 투명한 이미지가 생성되었습니다.이를 방지하기 위해 여기서는 `Promise`를 사용해 순차적으로 프레임을 추출했습니다. `generateThumbnail` 함수는 `Promise`를 반환하고, `await`을 사용해 각 프레임이 추출될 때까지 기다렸습니다.![순차적으로 프레임 추출 시도](/images/240528/generate-thumbnails-sequentially.png) _순차적으로 프레임 추출 시도 ([타임라인 원본 데이터](/images/240528/generate-thumbnails-sequentially.json))_위 이미지는 프레임을 순차적으로 추출한 결과입니다. 네트워크 요청이 고르게 분산되고, 메인 스레드가 짧은 시간 동안만 사용되었습니다. 따라서 스레드 블로킹은 발생되지 않았고, 프레임을 올바르게 추출할 수 있었습니다.# 그 외## `requestVideoFrameCallback`이벤트 대신 [`requestVideoFrameCallback`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLVideoElement/requestVideoFrameCallback)을 사용할 수도 있습니다. 다만 이 함수는 [FireFox에서 지원하지 않기 때문에](https://developer.mozilla.org/en-US/docs/Web/API/HTMLVideoElement/requestVideoFrameCallback#browser_compatibility), 고려하지 않았습니다.이 함수에 대해 더 알고 싶다면 [web.dev](https://web.dev/articles/requestvideoframecallback-rvfc?hl=en)를 참고해 주세요.# 맺으며저는 아이폰을 사용하고, 꽤 매력적인 기기라고 생각합니다. 그러나 독특한 iOS Safari 구현은 가끔씩 예상치 못한 도전과제를 안겨주곤 합니다. 표준과 다른 동작 방식은 다소 낯설고 어려울 수 있지만, 플랫폼이 갖는 미묘한 차이점을 이해하고 극복하는 것은 사용자 겸험을 한층 더 향상시키는 중요한 요소입니다. 그리고 이를 해결해 나가는 과정에서 얻는 성취감은 그만큼 값집니다.이번 글에서는 &quot;iOS Safari에서 비디오 프레임을 올바르게 추출하는 방법&quot;에 대해 다루어 보았습니다. 다양한 접근 방식과 그에 따른 이유를 살펴보며, 실제로 적용 가능한 코드를 통해 문제를 해결하는 과정을 공유했습니다. 저는 이 글이 여러분과 여러분이 만드는 서비스에 도움이 되기를 진심으로 바랍니다. 여러분이 직면한 유사한 문제들을 해결하는 데 조금이나마 도움이 되기를 바랍니다. 읽어주셔서 감사합니다!</content:encoded>
            <category>Guide</category>
            <category>Project</category>
        </item>
        <item>
            <title><![CDATA[How to Extract Video Frames Correctly in iOS Safari 🔍]]></title>
            <link>https://shj.rip/article/extracting-video-frames-correctly-in-ios-safari.html</link>
            <guid isPermaLink="false">extracting-video-frames-correctly-in-ios-safari</guid>
            <dc:creator><![CDATA[shj]]></dc:creator>
            <pubDate>Thu, 13 Jun 2024 00:00:00 GMT</pubDate>
            <enclosure url="https://shj.rip/article/extracting-video-frames-correctly-in-ios-safari.png" length="0" type="image/png"/>
            <content:encoded>[Korean](./extracting-video-frames-correctly-in-ios-safari-ko.html)&gt; NOTE: These methods have been tested on iOS Safari versions 14+ (up to 17.4.1).&gt; Looking for an NPM package? Check out the [generate-video-dumbnail](https://npmjs.com/package/generate-video-dumbnail)!&lt;a id=&quot;thumbnail-issue&quot;&gt;&lt;/a&gt;![Frame Extraction Issue](/images/240528/existing-issue.png) _Frame extraction in iOS did not work as expected_I recently worked on extracting video frames. It wasn&apos;t too difficult. I quickly put together the code with a bit of inference, ChatGPT, and Googling.```jsconst canvas = document.createElement(&apos;canvas&apos;);const ctx = canvas.getContext(&apos;2d&apos;);video.addEventListener(&apos;loadeddata&apos;, () =&gt; {  canvas.width = video.videoWidth;  canvas.height = video.videoHeight;  ctx.drawImage(video, 0, 0, canvas.width, canvas.height);  const thumbnailDataUrl = canvas.toDataURL(&apos;image/png&apos;);  document.querySelector(&apos;img&apos;).src = thumbnailDataUrl;});```The frame extraction code using the Canvas API worked well in macOS Safari and Chrome. I thought I could finish the task earlier than expected. However, when I checked it in iOS Safari... **NOTHING APPEARED!** Something was wrong. 🤯![Browser Specific Failures graph](/images/240528/browser-failures.png) _Browser Specific Failures Graph ([Source](https://wpt.fyi/results/?label=master&amp;label=experimental&amp;aligned))_Have you ever faced a similar situation? iOS Safari is sleek and sophisticated, but it can be a pain for web frontend developers. In this article, **I&apos;ll explain how to extract video frames in iOS Safari and Why it&apos;s necessary**. I also provide online demos for you to test.I don&apos;t know how long the frame extraction issue will last, but at least for now, this guide will help you extract frames as intended. I hope this content is helpful.&gt; [!NOTE] Before you begin&gt; This article is in no particular order! Instead of reading from the top to the bottom, you can jump right in and read what you need.&gt; The titles without the magnifying glass (🔍) stand for each situation. The content shows the solution (code) first, followed by a brief reason for it. The reasons are preceded by a magnifying glass (🔍), which you can click to see a more detailed explanation.# Display Video Poster## w/ Static Video Element```html&lt;video  src=&quot;https://cdn.jsdelivr.net/gh/Gumball12/public-timer-mp4/timer.mp4#t=0.001&quot;  preload=&quot;metadata&quot;&gt;&lt;/video&gt;```[CodePen](https://codepen.io/Gumball12/pen/eYaBjOK?editors=1100)- [🔍](#media-fragments-uri &apos;Move to Reason&apos;) Append `#t=0.001` to the end of the `src` attribute to show the first frame of the video.- [🔍](#preload-attribute &apos;Move to Reason&apos;) `preload` attribute does not need to be set to `&apos;metadata&apos;`, but this may delay frame extraction.- Video can be delivered via HTTP or HTTPS.### 🔍 Media Fragments URIiOS Safari **does not display the first frame** of a video element by default! Even setting the `preload=&quot;metadata&quot;` or `preload=&quot;auto&quot;` attribute does not work. Instead, you must use a [Media Fragments URI](https://www.w3.org/TR/media-frags/#introduction). ([Can I Use](https://caniuse.com/media-fragments))By using one of the Media Fragments, `#t=&lt;time&gt;`, you can specify a particular time in the video (in seconds). In iOS Safari, you can use this approach to show video frame(poster). For example, if you specify 0.001 seconds(`#t=0.001`), it will show the first frame. Note that setting it to `#t=0` does not load the frame.### 🔍 Preload AttributeWhile loading metadata, [some video frame data can be retrieved](https://www.w3.org/html/wiki/Elements/video#HTML_Attributes). Therefore, the `preload` attribute can be set to `&apos;metadata&apos;` instead of `&apos;auto&apos;`. Note that the default value of `preload` in iOS Safari is `&apos;auto&apos;`, but it may vary depending on the device state(for example, in battery saving mode). For this reason, it is recommended to set it explicitly.## w/ Dynamic Video Element (HTTP URL)```jsconst video = document.createElement(&apos;video&apos;);document.body.appendChild(video);video.preload = &apos;metadata&apos;;video.src =  &apos;https://cdn.jsdelivr.net/gh/Gumball12/public-timer-mp4/timer.mp4#t=0.001&apos;;```[CodePen](https://codepen.io/Gumball12/pen/wvboxGd?editors=0110)- [🔍](#media-fragments-uri &apos;Move to Reason&apos;) Append `#t=0.001` to the end of the `src` attribute to show the first frame of the video.- [🔍](#preload-attribute &apos;Move to Reason&apos;) `preload` attribute does not need to be set to `&apos;metadata&apos;`, but this may delay frame extraction.- **Video data must be delivered via HTTPS**. For security reasons, the `src` attribute of dynamically created video elements must always use HTTPS.## w/ Dynamic Video Element (Blob URL)```jsconst input = document.createElement(&apos;input&apos;);input.type = &apos;file&apos;;input.accept = &apos;video/*&apos;;document.body.appendChild(input);input.addEventListener(&apos;change&apos;, ({ target }) =&gt; {  const file = target.files[0];  const url = URL.createObjectURL(file);  const video = document.createElement(&apos;video&apos;);  document.body.appendChild(video);  video.src = url;  video.preload = &apos;metadata&apos;;  video.currentTime = 0.001;});```[CodePen](https://codepen.io/Gumball12/pen/eYaBjzL?editors=0110)- [🔍](#set-preload-metadata-for-blob-url &apos;Move to Reason&apos;) When using Blob URL, the `preload` must be set to `&apos;metadata&apos;`. `&apos;auto&apos;` does not work.- [🔍](#media-fragments-uri-cannot-be-used-with-blob-url &apos;Move to Reason&apos;) Media Fragments URI(`#t=&lt;time&gt;`) cannot be used with Blob URL. Use the `&apos;loadedmetadata&apos;` event to set `currentTime` to `0.001`.### 🔍 Set `preload=&apos;metadata&apos;` for Blob URL![Preload Comparison - metadata](/images/240528/preload-metadata.png) ![Preload Comparison - auto](/images/240528/preload-auto.png) _Comparison of `preload = &apos;metadata&apos;` vs. `preload = &apos;auto&apos;` network requests_When creating a video element using Blob URL, **setting `preload` to `&apos;auto&apos;` does not retrieve frame data** at the specified time. In fact, checking the network requests(image above), you can see that `preload=&quot;metadata&quot;` retrieves data (Bytes) in the range 48-1541 to display the first frame. However, `preload=&quot;auto&quot;` does not retrieve data.Unfortunately, I could not find a technical reason for these differences. I used [this code](https://codepen.io/Gumball12/pen/PovpONw?editors=0010) and tested it on iOS Safari 17.4.1.### 🔍 Media Fragments URI cannot be used with Blob URLIn the current latest WebKit (Safari 17.4.1), **Media Fragments URI (e.g., `#t=&lt;time&gt;`) cannot be used with Blob URL.** The relevant changes can be found in [this commit](https://github.com/WebKit/WebKit/commit/7f2ea8fcf41a68add90efab89609218407e1a824#diff-015bfbdb65247b7b4cc8319b4798e660d9c6b9df998610f579376a6db3f28f24L274-L275)[^1]. Below is part of the commit:```diffBlobData* BlobRegistryImpl::getBlobDataFromURL(const URL&amp; url, const std::optional&lt;SecurityOriginData&gt;&amp; topOrigin) const{    ASSERT(isMainThread());-    if (url.hasFragmentIdentifier())-        return m_blobs.get&lt;StringViewHashTranslator&gt;(url.viewWithoutFragmentIdentifier());-    return m_blobs.get(url.string());+    auto urlKey = url.stringWithoutFragmentIdentifier();+    auto* blobData = m_blobs.get(urlKey);+    if (m_allowedBlobURLTopOrigins &amp;&amp; topOrigin &amp;&amp; topOrigin != m_allowedBlobURLTopOrigins-&gt;get(urlKey)) {+        RELEASE_LOG_ERROR(Network, &quot;BlobRegistryImpl::getBlobDataFromURL: (%p) Requested blob URL with incorrect top origin.&quot;, this);+        return nullptr;+    }+    return blobData;}```The `URL::hasFragmentIdentifier` function, which checks for the use of Media Fragment URI, has been removed. Instead, the `URL::stringWithoutFragmentIdentifier` function, which returns a string without the Media Fragment URI, is used. Therefore, you need to use `currentTime` instead of the Media Fragment URI.From the code history, it appears that Media Fragment URI was previously supported for Blob URL. This can be confirmed in the [Safari Technology Preview 118 release notes](https://webkit.org/blog/11439/release-notes-for-safari-technology-preview-118) and the [related commit](https://github.com/WebKit/WebKit/commit/cce9f0c257eca20126d3c3d22e97e339d5718264#diff-015bfbdb65247b7b4cc8319b4798e660d9c6b9df998610f579376a6db3f28f24).[^1]: This commit appears to resolve issues related to Blob Partitioning. Blob Partitioning is a technology that divides Blobs into smaller chunks to efficiently process large data and provide fast responses to users. This feature was introduced in [Safari 17.2(19671.1.17)](https://developer.apple.com/documentation/safari-release-notes/safari-17_2-release-notes#New-Features).# Extract Single Video Frame## w/ HTTP URL```jsconst THUMBNAIL_POSITION = 5.3;const video = document.createElement(&apos;video&apos;);video.crossOrigin = &apos;anonymous&apos;;video.preload = &apos;metadata&apos;;video.src = `https://cdn.jsdelivr.net/gh/Gumball12/public-timer-mp4/timer.mp4#t=${THUMBNAIL_POSITION}`;video.addEventListener(&apos;seeked&apos;, () =&gt; {  const canvas = document.createElement(&apos;canvas&apos;);  canvas.width = video.videoWidth;  canvas.height = video.videoHeight;  const ctx = canvas.getContext(&apos;2d&apos;);  ctx.drawImage(video, 0, 0, canvas.width, canvas.height);  canvas.toBlob(blob =&gt; {    const img = document.createElement(&apos;img&apos;);    img.src = URL.createObjectURL(blob);    document.body.appendChild(img);  });});```[CodePen](https://codepen.io/Gumball12/pen/qBGqyKd?editors=0110)- [🔍](#set-crossorigin-anonymous-to-avoid-securityerror-with-canvas-element &apos;Move to Reason&apos;) `crossOrigin` attribute must be set to `&apos;anonymous&apos;`.- [🔍](#preload-attribute &apos;Move to Reason&apos;) `preload` attribute does not need to be set to `&apos;metadata&apos;`, but this may delay frame extraction.- [🔍](&lt;#video-frame-data-is-available-after-the-seeked-or-timeupdate-event-(http-url)&gt; &apos;Move to Reason&apos;) Video frame data is available after the `&apos;seeked&apos;` or `&apos;timeupdate&apos;` event.### 🔍 Set `crossOrigin=&apos;anonymous&apos;` to Avoid SecurityError with Canvas ElementBrowsers, by default, only allow resources from the &quot;same origin&quot; ([SOP, Same Origin Policy](https://developer.mozilla.org/en-US/docs/Web/Security/Same-origin_policy)). If the origin is different, you need to request access through [CORS (Cross-Origin Resource Sharing)](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS).When requesting media resources, you can set the [`crossOrigin` attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/crossorigin) to determine how to handle CORS policy. Of course, you can request media resources that exist in different origins without setting the `crossOrigin` attribute. However, **the browser consider the integrity is not guaranteed**. And when you draw this on a Canvas, it becomes a **Tainted Canvas**[^2].[^2]: If the origin is the same, the Canvas is not tainted by the SOP!![Tainted Canvas](/images/240528/tainted-canvas.webp)A tainted canvas means that the website is not allowed to share resources. In this state, pixel data cannot be accessed. Otherwise, unauthorized origins could arbitrarily leak pixel data within the image.Canvas pixel data can be accessed with `getImageData`, `toDataURL`, `toBlob`, etc. To prevent the security issues mentioned above, **these methods throw a [`SecurityError`](https://developer.mozilla.org/en-US/docs/Web/API/DOMException#securityerror) for a tainted canvas**. That&apos;s why you need to set `crossOrigin`.There are two options available. `&apos;anonymous&apos;` (or an empty string `&apos;&apos;`), which validates CORS, but does not include any credentials (cookies, HTTP Authentication headers, SSL certificates, etc.) in the request. This helps to avoid situations where sensitive user information exposed. Therefore, I used `&apos;anonymous&apos;` here. However, you can include credentials via the `use-credential` option if needed.### 🔍 Video frame data is available after the `&apos;seeked&apos;` or `&apos;timeupdate&apos;` event (HTTP URL)When requesting video data using an HTTP URL, frame data is available after the `&apos;seeked&apos;` or `&apos;timeupdate&apos;` event. ([⚠️ Note that Blob URL behave differently.](&lt;#video-frame-data-is-available-a-certain-time-after-the-seeked-or-timeupdate-event-(blob-url)&gt;))## w/ Blob URL```jsconst THUMBNAIL_POSITION = 5.3;const input = document.createElement(&apos;input&apos;);input.type = &apos;file&apos;;input.accept = &apos;video/*&apos;;document.body.appendChild(input);input.addEventListener(&apos;change&apos;, async ({ target }) =&gt; {  const file = target.files[0];  const src = URL.createObjectURL(file);  const video = document.createElement(&apos;video&apos;);  video.src = src;  video.preload = &apos;metadata&apos;;  video.addEventListener(&apos;seeked&apos;, async () =&gt; {    await new Promise(resolve =&gt; setTimeout(resolve, 100));    const canvas = document.createElement(&apos;canvas&apos;);    canvas.width = video.videoWidth;    canvas.height = video.videoHeight;    const ctx = canvas.getContext(&apos;2d&apos;);    ctx.drawImage(video, 0, 0, canvas.width, canvas.height);    canvas.toBlob(blob =&gt; {      const img = document.createElement(&apos;img&apos;);      img.src = URL.createObjectURL(blob);      document.body.appendChild(img);    });  });  video.addEventListener(    &apos;loadedmetadata&apos;,    () =&gt; (video.currentTime = THUMBNAIL_POSITION),  );});```[CodePen](https://codepen.io/Gumball12/pen/pomNGbb?editors=0110)- [🔍](#set-preload-metadata-for-blob-url &apos;Move to Reason&apos;) When using Blob URL, the `preload` must be set to `&apos;metadata&apos;`. `&apos;auto&apos;` does not work.- [🔍](#media-fragments-uri-cannot-be-used-with-blob-url &apos;Move to Reason&apos;) Media Fragments URI(`#t=&lt;time&gt;`) cannot be used with Blob URL. Use the `&apos;loadedmetadata&apos;` event to set `currentTime`.- [🔍](&lt;#video-frame-data-is-available-a-certain-time-after-the-seeked-or-timeupdate-event-(blob-url)&gt; &apos;Move to Reason&apos;) Video frame data is available a certain time after the `&apos;seeked&apos;` or `&apos;timeupdate&apos;` event.### 🔍 Video frame data is available a certain time after the `&apos;seeked&apos;` or `&apos;timeupdate&apos;` event (Blob URL)Blob URL also support the `&apos;seeked&apos;` and `&apos;timeupdate&apos;` events. However, **if you are using the video frame data, you will need to wait a certain time**. In my experience, I&apos;ve been able to access it after 80-100ms.Unlike HTTP URL, Blob URL may not have frame data immediately after the event is triggered (even when `readyState` is `4`!). This is likely because Blob URL reference local files[^3]. Delays can occur as video data is decoded and loaded into memory.[^3]: Example code generates Blob URL using a `File` object. The `File` object references a file from the local file system. (&quot;The `File` interface provides information about files and allows JavaScript in a web page to access their content.&quot; - [MDN](https://developer.mozilla.org/en-US/docs/Web/API/File))# Extract Multiple Video Frames## w/ HTTP URL```jsconst THUMBNAIL_POSITION_LIST = [  0, 1.1, 2.2, 3.3, 4.4, 5.5, 4.6, 3.7, 2.8, 1.9, 0,];const main = async () =&gt; {  for (const thumbnailPosition of THUMBNAIL_POSITION_LIST) {    const correctedThumbnailPosition = thumbnailPosition || 0.001;    const video = document.createElement(&apos;video&apos;);    video.crossOrigin = &apos;anonymous&apos;;    video.preload = &apos;metadata&apos;;    video.src = `https://cdn.jsdelivr.net/gh/Gumball12/public-timer-mp4/timer.mp4#t=${correctedThumbnailPosition}`;    generateThumbnail(video, thumbnailPosition);  }};const generateThumbnail = (video, thumbnailPosition) =&gt; {  const img = document.createElement(&apos;img&apos;);  document.body.appendChild(img);  video.addEventListener(&apos;seeked&apos;, () =&gt; {    const canvas = document.createElement(&apos;canvas&apos;);    canvas.width = video.videoWidth;    canvas.height = video.videoHeight;    const ctx = canvas.getContext(&apos;2d&apos;);    ctx.drawImage(video, 0, 0, canvas.width, canvas.height);    canvas.toBlob(blob =&gt; (img.src = URL.createObjectURL(blob)));  });  video.currentTime = thumbnailPosition;};main();```[CodePen](https://codepen.io/Gumball12/pen/pomNYya?editors=0110)- [🔍](#set-crossorigin-anonymous-to-avoid-securityerror-with-canvas-element &apos;Move to Reason&apos;) `crossOrigin` attribute must be set to `&apos;anonymous&apos;`.- [🔍](#preload-attribute &apos;Move to Reason&apos;) `preload` attribute does not need to be set to `&apos;metadata&apos;`, but this may delay frame extraction.- [🔍](#media-fragments-uri &apos;Move to Reason&apos;) Use `#t=${time}` to specify the frame position.- [🔍](&lt;#video-frame-data-is-available-after-the-seeked-or-timeupdate-event-(http-url)&gt; &apos;Move to Reason&apos;) Video frame data is available after the `&apos;seeked&apos;` or `&apos;timeupdate&apos;` event.## w/ Blob URL```jsconst input = document.createElement(&apos;input&apos;);input.type = &apos;file&apos;;input.accept = &apos;video/*&apos;;document.body.appendChild(input);input.addEventListener(&apos;change&apos;, async ({ target }) =&gt; {  const file = target.files[0];  const src = URL.createObjectURL(file);  const THUMBNAIL_POSITION_LIST = [    0, 1.1, 2.2, 3.3, 4.4, 5.5, 4.6, 3.7, 2.8, 1.9, 0,  ];  for (const thumbnailPosition of THUMBNAIL_POSITION_LIST) {    const video = document.createElement(&apos;video&apos;);    video.preload = &apos;metadata&apos;;    video.src = src;    await generateThumbnail(video, thumbnailPosition);  }});const generateThumbnail = (video, thumbnailPosition) =&gt;  new Promise(resolve =&gt; {    video.addEventListener(&apos;seeked&apos;, async () =&gt; {      await new Promise(resolve =&gt; setTimeout(resolve, 100));      const canvas = document.createElement(&apos;canvas&apos;);      canvas.width = video.videoWidth;      canvas.height = video.videoHeight;      const ctx = canvas.getContext(&apos;2d&apos;);      ctx.drawImage(video, 0, 0, canvas.width, canvas.height);      canvas.toBlob(blob =&gt; {        const img = document.createElement(&apos;img&apos;);        img.src = URL.createObjectURL(blob);        document.body.appendChild(img);        resolve();      });    });    video.addEventListener(&apos;loadedmetadata&apos;, () =&gt;      setTimeout(() =&gt; (video.currentTime = thumbnailPosition), 1),    );  });```[CodePen](https://codepen.io/Gumball12/pen/jOoVJMY?editors=0110)- [🔍](#set-preload-metadata-for-blob-url &apos;Move to Reason&apos;) When using Blob URL, the `preload` must be set to `&apos;metadata&apos;`. `&apos;auto&apos;` does not work.- [🔍](#incorrect-frames-may-be-extracted-due-to-thread-blocking &apos;Move to Reason&apos;) To prevent thread blocking, use `Promise` to extract frames sequentially.- [🔍](&lt;#video-frame-data-is-available-a-certain-time-after-the-seeked-or-timeupdate-event-(blob-url)&gt; &apos;Move to Reason&apos;) Video frame data is available a certain time after the `&apos;seeked&apos;` or `&apos;timeupdate&apos;` event.### 🔍 Incorrect frames may be extracted due to thread blockingThread blocking can occur when extracting multiple frames. If thread blocking occurs, **frames may not be extracted correctly**. In other words, [transparent images](#thumbnail-issue) are generated.![Attempting to extract frames all at once](/images/240528/attempt-to-generate-thumbnails-all-at-once.png) _Attempting to extract frames all at once ([Timeline Raw Data](/images/240528/attempt-to-generate-thumbnails-all-at-once.json))_The above image shows the result of attempting to extract all frames at once. Frequent network requests are occurring, with a concentration in a specific range. Additionally, the main thread is being used continuously for an extended period. As a result, thread blocking occurs, and transparent images are generated.To avoid this, I used `Promise` to extract frames sequentially. The `generateThumbnail` function returns a `Promise`, and I used `await` to wait for each frame to be extracted.![Extracting frames sequentially](/images/240528/generate-thumbnails-sequentially.png) _Extracting frames sequentially ([Timeline Raw Data](/images/240528/generate-thumbnails-sequentially.json))_Extracting frames sequentially ensures that network requests are evenly distributed, and the main thread is used only for a short period. This prevents thread blocking and ensures that frames are extracted correctly.# Miscellaneous## `requestVideoFrameCallback`Instead of events, you can also use [`requestVideoFrameCallback`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLVideoElement/requestVideoFrameCallback). However, this function [is not supported by FireFox](https://developer.mozilla.org/en-US/docs/Web/API/HTMLVideoElement/requestVideoFrameCallback#browser_compatibility), so it was not considered.To learn more about this function, please refer to [web.dev](https://web.dev/articles/requestvideoframecallback-rvfc?hl=en).# ConclusionI use an iPhone and find it to be a truly fascinating device. However, the unique implementation of iOS Safari can sometimes present unexpected challenges. The non-standard behavior may feel unfamiliar and daunting, but understanding and overcoming the subtle differences of the platform is a crucial aspect of enhancing user experience. Moreover, the sense of accomplishment that comes with solving these issues is invaluable.In this article, we&apos;ve covered &quot;How to Correctly Extract Video Frames in iOS Safari&quot;. We explored various approaches and their underlying reasons, sharing the process of solving the problem with practical, applicable code. I sincerely hope that this article is helpful to you and the services you create. May it assist you in resolving similar issues you may encounter. Thank you for reading!&gt; This article was translated from Korean using ChatGPT and DeepL.</content:encoded>
            <category>En</category>
            <category>Guide</category>
            <category>Project</category>
        </item>
        <item>
            <title><![CDATA[웹 컴포넌트 튜토리얼]]></title>
            <link>https://shj.rip/article/web-components-tutorial.html</link>
            <guid isPermaLink="false">web-components-tutorial</guid>
            <dc:creator><![CDATA[shj]]></dc:creator>
            <pubDate>Sat, 11 Nov 2023 00:00:00 GMT</pubDate>
            <enclosure url="https://shj.rip/article/web-components-tutorial.png" length="0" type="image/png"/>
            <content:encoded>&gt; 이 글은 [MDN 웹 컴포넌트 튜토리얼](https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_custom_elements)을 많은 부분 참고해 작성한 글이다. 바로 실행할 수 있는 예제와 함께 조금 더 친절하고 이해하기 쉽도록 설명하고자 했다.# TL;DR- 웹 컴포넌트는 재사용할 수 있는 사용자 인터페이스 요소를 만드는 기술이다.- 웹 컴포넌트는 [커스텀 엘리먼트](https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_custom_elements)와 [Shadow DOM](https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_shadow_DOM)을 이용해 구현된다.- 커스텀 엘리먼트는 HTML 태그처럼 사용할 수 있는 커스텀 DOM 엘리먼트이다.- Shadow DOM은 DOM과 CSS를 캡슐화하는 기술이다.# 커스텀 엘리먼트 만들어 보기웹 컴포넌트는 재사용할 수 있는 커스텀 엘리먼트를 만들 수 있게 해준다. 커스텀 엘리먼트는 일반적인 HTML 태그처럼, 또는 JavaScript을 이용해 사용할 수 있다. 이는 [`CustomElementRegistry.define`](https://developer.mozilla.org/en-US/docs/Web/API/CustomElementRegistry/define) 메서드를 이용하는데, `window.customElements` 객체를 통해 접근할 수 있다.```js// customElements.define() 으로도 접근이 가능window.customElements.define(/* ... */);```이 메서드는 인자 세 개를 전달받아 커스텀 엘리먼트를 등록(Registry)한다:- `name`: 커스텀 엘리먼트 이름이며, [커스텀 엘리먼트 이름 규칙](https://developer.mozilla.org/en-US/docs/Web/API/CustomElementRegistry/define#valid_custom_element_names)을 따라야 한다. 가령 반드시 하이픈(`-`)을 포함해야 한다.- `constructor`: 커스텀 엘리먼트 행동을 정의하는 클래스. 이 클래스는 [`HTMLElement`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement)를 상속받아야 한다.- `options`: 커스텀 엘리먼트 기능을 확장하는 객체. 현재(2024.03)는 `extends` 옵션만을 지원한다. 커스텀 엘리먼트가 상속받을 [빌트인 HTML 엘리먼트](https://developer.mozilla.org/en-US/docs/Web/HTML/Element)를 확장할 수 있다.가령 다음과 같이 `WordCount` 라는 커스텀 엘리먼트 정의가 가능하다.```javascriptclass WordCount extends HTMLParagraphElement {  constructor() {    super(); // 반드시 호출    // 커스텀 엘리먼트 기능  }}customElements.define(&apos;word-count&apos;, WordCount, { extends: &apos;p&apos; });```등록 이후에는 `WordCount` 커스텀 엘리먼트를 사용할 수 있다.```html&lt;word-count&gt;&lt;/word-count&gt;&lt;!-- or --&gt;&lt;p is=&quot;word-count&quot;&gt;&lt;/p&gt;``````jsdocument.createElement(&apos;word-count&apos;);// ordocument.createElement(&apos;p&apos;, { is: &apos;word-count&apos; });```이 외에도 커스텀 엘리먼트 생성, 제거, 애트리뷰트 변경 등 이벤트를 감지할 수 있는 [커스텀 엘리먼트 라이프사이클 콜백](https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_custom_elements#custom_element_lifecycle_callbacks)이 있다. 자세한 내용은 아래에서 다루도록 한다.## 예제를 통해 알아보기여기서는 다음 기능을 수행하는 커스텀 엘리먼트를 만들어 보도록 한다. 이름은 `popup-info`로 하자.- 이미지 아이콘과 텍스트로 구성- 텍스트는 기본적으로 숨겨져 있고, 아이콘은 항상 보임- 아이콘에 마우스를 올리면 텍스트가 보임어렵지 않다. `HTMLElement`를 상속받는 `PopupInfo` 클래스를 만들고, `constructor`에 필요한 기능을 구현하면 된다. 항상 `super()`를 호출해야 한다는 점을 잊지 말자.```jsclass PopupInfo extends HTMLElement {  constructor() {    super();    // Shadow DOM 생성    this.attachShadow({ mode: &apos;open&apos; });    // 아이콘 및 텍스트 엘리먼트 생성    const wrapper = document.createElement(&apos;div&apos;);    wrapper.classList.add(&apos;wrapper&apos;);    const icon = wrapper.appendChild(document.createElement(&apos;span&apos;));    icon.classList.add(&apos;icon&apos;);    icon.addEventListener(&apos;click&apos;, () =&gt; wrapper.classList.toggle(&apos;popup&apos;));    const img = icon.appendChild(document.createElement(&apos;img&apos;));    img.src = this.getAttribute(&apos;img&apos;) ?? &apos;https://placeholder.com/100x100&apos;;    const info = wrapper.appendChild(document.createElement(&apos;span&apos;));    info.classList.add(&apos;info&apos;);    info.textContent = this.getAttribute(&apos;data-info&apos;) ?? &apos;&apos;;    const style = document.createElement(&apos;style&apos;);    style.textContent = `      .wrapper.popup .info { display: block; }      .info { display: none; }    `;    // 엘리먼트를 Shadow DOM에 추가    this.shadowRoot.append(style, wrapper);  }}// 커스텀 엘리먼트 등록customElements.define(&apos;popup-info&apos;, PopupInfo);```&gt; 💡&gt; Shadow DOM(Shadow Root)은 CSS와 DOM을 캡슐화할 수 있는 독립된 DOM 트리라고 생각하면 된다. 여기서는 `attachShadow` 메서드를 통해 Shadow DOM을 생성하고, `shadowRoot` 프로퍼티를 통해 Shadow DOM에 접근할 수 있다는 점만 알아두자. 자세한 내용은 아래에서 다루도록 한다.`constructor` 내부에서 `this.getAttribute`를 통해 애트리뷰트 값을 가져올 수 있다. `this.hasAttribute`를 통해 애트리뷰트 존재 여부를 확인할 수도 있다. 이를 통해 `img` 애트리뷰트가 존재하지 않을 경우 기본 이미지를 사용하도록 했다.스타일은 `style` 엘리먼트를 생성해 `textContent`에 CSS 문자열을 넣어준 뒤 Shadow DOM에 추가하는 방식을 이용했다. 스타일은 Shadow DOM 내부에서만 적용되며, 외부에는 영향을 주지 않는다.이제 `popup-info` 커스텀 엘리먼트를 사용할 수 있다.```html&lt;popup-info  img=&quot;https://placeholder.com/300x300&quot;  data-info=&quot;Popup info 1&quot;&gt;&lt;/popup-info&gt;```[동작하는 예시 확인하기](https://codepen.io/Gumball12/pen/gOqeLWZ?editors=1010)## `extends` 옵션을 이용한 빌트인 엘리먼트 확장`extends` 옵션을 이용하면 빌트인 HTML 엘리먼트를 확장할 수 있다. 가령 `extends: &apos;ul&apos;` 옵션을 사용해 만들어진 커스텀 엘리먼트는 `&lt;ul&gt;` 엘리먼트 기능을 상속받는다.```js// &lt;ul&gt; 엘리먼트를 확장하는 클래스는 HTMLUListElement를 상속받아야 한다.class ExpandingList extends HTMLUListElement {  constructor() {    super();    // nothing  }}customElements.define(&apos;expanding-list&apos;, ExpandingList, { extends: &apos;ul&apos; });````ExpandingList` 커스텀 엘리먼트에 대한 상세 기능을 작성하지 않았지만, `&lt;ul&gt;` 엘리먼트와 동일하게 동작한다.```html&lt;expanding-list&gt;  &lt;li&gt;Item 1&lt;/li&gt;  &lt;li&gt;Item 2&lt;/li&gt;  &lt;li&gt;Item 3&lt;/li&gt;&lt;/expanding-list&gt;```## 라이프사이클 콜백커스텀 엘리먼트는 [라이프사이클 콜백](https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_custom_elements#custom_element_lifecycle_callbacks)을 이용해 엘리먼트 생성, 제거, 애트리뷰트 변경 등에 대한 이벤트를 감지할 수 있다:- `connectedCallback`: 커스텀 엘리먼트가 DOM에 추가(연결)될 때 호출- `disconnectedCallback`: 커스텀 엘리먼트가 DOM에서 제거(해제)될 때 호출- `adoptedCallback`: 커스텀 엘리먼트가 다른 DOM으로 이동될 때 호출- `attributeChangedCallback`: 커스텀 엘리먼트 애트리뷰트가 추가, 제거, 변경될 때 호출`connectedCallback`은 몇 가지 추가 사항이 존재한다:- 모든 DOM이 파싱되기 전에 호출될 수 있음- DOM 노드가 이동될 때도 호출됨- DOM에서 제거될 때도 호출될 수 있으며, 이는 [`isConnected`](https://developer.mozilla.org/en-US/docs/Web/API/Node/isConnected) 프로퍼티를 이용해 판별이 가능`attributeChangedCallback`을 위해서는 문자열 배열을 반환하는 `static get observedAttributes()` 메서드를 통해 감지할 애트리뷰트를 지정해야 한다.라이프사이클 콜백을 구현해 보면 다음과 같다.```jsclass CustomSquare extends HTMLElement {  constructor() {    super();    this.shadow = this.attachShadow({ mode: &apos;open&apos; });    const div = document.createElement(&apos;div&apos;);    this.shadow.appendChild(div);    console.log(&apos;Create Custom square element&apos;);  }  // DOM에 추가  connectedCallback() {    console.log(      &apos;Custom square element added to page&apos;,      this.parentElement.tagName,    );  }  // DOM에서 제거  disconnectedCallback() {    console.log(&apos;Custom square element removed from page&apos;, {      isConnected: this.isConnected,    });  }  // DOM 이동  adoptedCallback() {    console.log(      &apos;Custom square element moved to new page&apos;,      this.parentElement.tagName,    );  }  // 애트리뷰트 변경  attributeChangedCallback(name, oldValue, newValue) {    console.log(&apos;Custom square element attributes changed&apos;, name);  }  static get observedAttributes() {    // 애트리뷰트 이름을 담은 배열을 반환    return [&apos;c&apos;, &apos;l&apos;]; // &apos;c&apos;와 &apos;l&apos; 애트리뷰트 변경을 감지  }}```[동작하는 예시 확인하기](https://codepen.io/Gumball12/pen/PoVRbEg?editors=0011)# Shadow DOM중요한 웹 컴포넌트 개념 중 하나는 캡슐화(Encapsulation)다. Shadow DOM API는 이에 초점을 맞추어 DOM과 CSS를 캡슐화하는 방법을 제공한다. 여기서는 Shadow DOM 기본 개념과 사용법을 알아보도록 한다.## Shadow DOM이란?```html&lt;!doctype html&gt;&lt;html&gt;  &lt;head&gt;    &lt;meta charset=&quot;utf-8&quot; /&gt;    &lt;title&gt;Simple DOM&lt;/title&gt;  &lt;/head&gt;  &lt;body&gt;    &lt;section&gt;      &lt;img src=&quot;dinosaur.png&quot; alt=&quot;T-Rex&quot; /&gt;      &lt;p&gt;        Here we will add a link to the        &lt;a href=&quot;https://www.mozilla.org/&quot;&gt;Mozilla&lt;/a&gt;      &lt;/p&gt;    &lt;/section&gt;  &lt;/body&gt;&lt;/html&gt;```위 HTML 코드는 다음과 같이 트리 형태로 나타낼 수 있다.![html tree example](/images/231111/html-tree-example.png) _HTML DOM 트리 예시 (출처: [MDN](https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_shadow_DOM))_Shadow DOM 역시 다르지 않다. 그저 하위 DOM 트리에 불과하다. 다만 Shadow DOM은 외부에서 접근할 수 없는 독립된 트리라는 점이 다르다. 즉, DOM과 CSS가 캡슐화된다.![shadow dom](/images/231111/shadow-dom.png) _그림으로 표현한 Shadow DOM (출처: [MDN](https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_shadow_DOM))_- Shadow host: 일반적인 DOM 노드처럼 보이는, Shadow DOM 연결 지점- Shadow tree: Shadow DOM 내부 DOM 트리- Shadow root: Shadow DOM 루트 노드- Shadow boundary: Shadow DOM 시작 노드부터 끝 노드까지 경계## Shadow DOM 사용해 보기앞서 `attachShadow` 메서드를 통해 Shadow DOM을 생성해 보았다. 이 메서드는 `mode` 옵션을 통해 Shadow DOM 접근 가능 여부를 제어할 수 있다:- `open`: Shadow DOM 참조를 외부에서 접근 가능- `closed`: Shadow DOM 참조는 외부에서 접근 불가능Shadow DOM 참조를 얻을 때는 `shadowRoot` 프로퍼티를 이용하는데, 모드에 따라 서로 다른 값을 갖는다.```jselementOpen.attachShadow({ mode: &apos;open&apos; });console.log(elementOpen.shadowRoot); // #shadow-root (open)elementClosed.attachShadow({ mode: &apos;closed&apos; });console.log(elementClosed.shadowRoot); // null```이러한 특성으로 인해 `PopupInfo` 커스텀 엘리먼트에서 `attachShadow` 메서드 호출 시 `mode` 옵션을 `open`으로 설정했었다. 만약 `closed`로 설정했다면, `shadowRoot` 프로퍼티를 통해 Shadow DOM에 접근할 수 없어 에러가 발생한다.```jsclass PopupInfo extends HTMLElement {  constructor() {    super();    // closed 모드로 Shadow DOM 생성    this.attachShadow({ mode: &apos;closed&apos; });    // ...    this.shadowRoot.append(style, wrapper);  }}customElements.define(&apos;popup-info&apos;, PopupInfo);document.createElement(&apos;popup-info&apos;); // TypeError: Cannot read properties of null (reading &apos;append&apos;)```[동작하는 예시 확인하기](https://codepen.io/Gumball12/pen/QWYmGOw?editors=0011)`closed` 모드 동작에 유의하자. `shadowRoot` 프로퍼티를 이용해 Shadow DOM 접근이 불가능할 뿐이다. 자세한 내용은 이어지는 예제를 통해 확인해보자.## Shadow DOM을 이용해 캡슐화된 컴포넌트 만들어 보기`innerHTML` 프로퍼티를 이용해 Shadow DOM에 HTML을 추가할 수 있다. 이를 이용해 `PopupInfo` 커스텀 엘리먼트를 다음과 같이 구현할 수 있다:```jsclass PopupInfo extends HTMLElement {  #html = `    &lt;div class=&quot;wrapper&quot;&gt;      &lt;div class=&quot;icon&quot;&gt;        &lt;img src=&quot;${this.img}&quot; alt=&quot;info icon&quot;&gt;      &lt;/div&gt;      &lt;span class=&quot;info&quot;&gt;        ${this.info}      &lt;/span&gt;    &lt;/div&gt;    &lt;style&gt;    .wrapper.popup .info { display: block; }    .info { display: none; }    &lt;/style&gt;  `;  constructor() {    super();    const shadow = this.attachShadow({ mode: &apos;closed&apos; });    shadow.innerHTML = this.#html;    const wrapper = shadow.querySelector(&apos;.wrapper&apos;);    wrapper.addEventListener(&apos;click&apos;, () =&gt; wrapper.classList.toggle(&apos;popup&apos;));  }  get img() {    return this.getAttribute(&apos;img&apos;) ?? &apos;https://placeholder.com/100x100&apos;;  }  get info() {    return this.getAttribute(&apos;data-info&apos;) ?? &apos;&apos;;  }}```[동작하는 예시 확인하기](https://codepen.io/Gumball12/pen/OJdvbjG?editors=1010)여기서는 `closed` 모드로 생성했으나 `innerHTML` 프로퍼티를 이용해 Shadow DOM에 HTML을 추가했다. 어떻게 가능했을까? 앞서 언급했듯이 `closed` 모드는 `shadowRoot` 프로퍼티를 이용해 Shadow DOM에 접근할 수 없을 뿐이지, Shadow DOM 접근 자체가 불가능하지는 않기 때문이다. 따라서 Shadow DOM 내부 HTML을 외부에서 수정할 수 있었다.# Template 그리고 Slot 태그재사용할 수 있는 컴포넌트를 만드는 방법은 여러 가지가 있다. 여기서는 그들 중 [`&lt;template&gt;`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/template)과 [`&lt;slot&gt;`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/slot)을 소개한다.## Template 태그`&lt;template&gt;`은 HTML 요소 일부를 정의해두고 재사용할 수 있게 해준다.```html&lt;template id=&quot;my-paragraph&quot;&gt;  &lt;p&gt;My Paragraph&lt;/p&gt;  &lt;style&gt;    p {      font-size: 30px;    }  &lt;/style&gt;&lt;/template&gt;```&gt; 💡&gt; Template 내부에서 작성된 스타일은 전역 스타일로 적용됨을 유의하자.템플릿은 `content` 프로퍼티를 통해 접근할 수 있다. 이 프로퍼티는 `DocumentFragment`를 반환하는데, 이를 통해 템플릿 내부 DOM에 접근할 수 있다.```jsconst template = document.querySelector(&apos;#my-paragraph&apos;);const templateContent = template.content;console.log(templateContent); // #document-fragmentdocument.body.appendChild(templateContent);```[동작하는 예시 확인하기](https://codepen.io/Gumball12/pen/BaMrQBG?editors=1000)웹 컴포넌트와 함께 사용할 때는 `content` 프로퍼티를 이용해 템플릿 내부 DOM에 접근한 뒤, 이를 Shadow DOM에 추가하면 된다.```jsclass MyParagraph extends HTMLElement {  constructor() {    super();    const template = document.getElementById(&apos;my-paragraph&apos;);    this.attachShadow({ mode: &apos;open&apos; });    this.shadowRoot.appendChild(template.content.cloneNode(true));  }}customElements.define(&apos;my-paragraph&apos;, MyParagraph);```[동작하는 예시 확인하기](https://codepen.io/Gumball12/pen/VwgXmjX?editors=1000)참고로 `template` 태그를 여러 곳에서 사용할 때는 `cloneNode` 메서드를 이용해야 하는데, 이는 `appendChild` 메서드가 DOM을 이동시키는 동작을 수행하기 때문이다. 자세한 내용은 [MDN `appendChild` 문서](https://developer.mozilla.org/en-US/docs/Web/API/Node/appendChild#description)를 참고.## Slot 태그`&lt;slot&gt;`은 템플릿 내 특정 위치에 콘텐츠를 삽입할 수 있게 해준다.```html&lt;template id=&quot;my-paragraph&quot;&gt;  &lt;p&gt;&lt;slot&gt;&lt;/slot&gt;&lt;/p&gt;  &lt;style&gt;    p {      font-size: 30px;    }  &lt;/style&gt;&lt;/template&gt;````my-paragraph` 템플릿은 다음과 같이 사용할 수 있다.```html&lt;my-paragraph&gt;Hello from slots!&lt;/my-paragraph&gt;```슬롯 여럿 필요하다면 `name` 애트리뷰트를 이용하자.```html&lt;template id=&quot;my-paragraph&quot;&gt;  &lt;p class=&quot;title&quot;&gt;&lt;slot name=&quot;title&quot;&gt;&lt;/slot&gt;&lt;/p&gt;  &lt;p&gt;&lt;slot&gt;&lt;/slot&gt;&lt;/p&gt;  &lt;style&gt;    p {      font-size: 30px;    }    p.title {      font-size: 50px;    }  &lt;/style&gt;&lt;/template&gt;``````html&lt;my-paragraph&gt;  &lt;span slot=&quot;title&quot;&gt;Title&lt;/span&gt;  Hello from slots!&lt;/my-paragraph&gt;```슬롯에는 기본값을 지정할 수도 있다. `&lt;slot&gt;` 태그 내부에 기본값을 작성하면 된다.```html&lt;template&gt;  &lt;p&gt;&lt;slot&gt;default slot contents&lt;/slot&gt;&lt;/p&gt;&lt;/template&gt;```[동작하는 예시 확인하기](https://codepen.io/Gumball12/pen/jOdzVVO?editors=1000)# 마치며이 글에서는 웹 컴포넌트 기본 개념과 사용법을 알아보았다. 이를 통해 재사용할 수 있는 컴포넌트를 만들 수 있게 되었고, 코드 재사용성과 유지보수성을 높일 수 있었다. 또한 Shadow DOM은 DOM과 CSS를 캡슐화를 통해 컴포넌트 독립성을 보장해주는데, 이를 통해 컴포넌트 간 충돌을 방지할 수 있다.개인적으로는 Vue나 React를 통해 무의식적으로 사용하던 컴포넌트를 외부 라이브러리 없이 JavaScript 만으로도 구현이 가능하다는 점이 놀라웠다. Shadow DOM 특성 또한 흥미로웠다. [Lit](https://lit.dev/)이나 [Stencil](https://stenciljs.com/) 같은 라이브러리를 이용하면 더욱 쉽게 웹 컴포넌트를 구현할 수 있다고 한다. 기회가 된다면 한 번 사용해보고 싶다.</content:encoded>
            <category>Guide</category>
        </item>
        <item>
            <title><![CDATA[모노리포 마이그레이션]]></title>
            <link>https://shj.rip/article/monorepo-migration.html</link>
            <guid isPermaLink="false">monorepo-migration</guid>
            <dc:creator><![CDATA[shj]]></dc:creator>
            <pubDate>Tue, 20 Jun 2023 00:00:00 GMT</pubDate>
            <enclosure url="https://shj.rip/article/monorepo-migration.png" length="0" type="image/png"/>
            <content:encoded># TL;DR- 모노리포는 코드 재사용, 개발 생산성, 협업 등 멀티리포 대비 여러 이점을 제공한다.- 여기서는 여러 프로젝트를 리포지토리 하나로 관리하는 모노리포로 마이그레이션 하는 과정을 다룬다.- 멀티리포에서 발생했던 문제 대부분이 해결되었으나, 새로이 고려해야 할 부분이 생겼다.# 서론모노리포란 여러 프로젝트를 한 리포지토리로 관리하는 방식을 말한다. 이는 코드 중복을 줄이고, 재사용하기 쉽게 만들어준다. 또한 프로젝트를 한 번에 빌드하고 배포할 수 있어 개발 생산성을 높여준다.지금 다니는 회사는 멀티리포로 관리하고 있었다. 멀티리포는 모노리포와 반대되는 개념으로, 각 프로젝트를 서로 다른 리포지토리로 관리하는 방식이다. 이로 인해 공통으로 사용하는 코드를 재사용하기 어려웠다. 반복되는 구성도 번거로웠다. 그래서 나는 모노리포로 마이그레이션 하고자 했다. 다들 피로감을 느끼고 있었기 때문에 공감을 얻어내기는 쉬웠다. 계획도 어려움은 없었다. 그러나 모든 일이 그렇듯 생각만큼 잘되지는 않았다.하지만 포기하고 싶지 않았다. 무엇보다 지금 코드베이스에서 일하기 정말 싫었다. 그러니 어쩌겠는가. 힘닿는 데까지 부딪혔다. 여기서는 모노리포를 도입한 배경과 과정을 이야기한다. 이 내용이 모노리포 도입을 고려하는 사람에게 도움이 되기를 바란다.# 왜 일을 벌였을까![chimp](/images/230620/chimp.jpeg)특정 애플리케이션에서만 사용할 패키지가 있다고 하자. 상식적으로, 해당 패키지는 애플리케이션과 같은 리포지토리에서 관리해야 맞다. 그러나 우리는 이를 분리했다. 이유는 재사용과 생산성이었다. 특히 메인 애플리케이션은 HMR에 수 초 이상 소요되었기에, 이러한 제약에서 벗어나고자 했다. 당시에는 적절한 판단이라 생각했다.이러다 보니 서로 다른 기술 스택을 가져버렸다. 가령 영상 편집을 위한 패키지는 Preact 기반 TypeScript + Vite 프로젝트였다. 디자인 시스템 패키지는 JavaScript와 Webpack 5를 사용했다. 메인 애플리케이션은 Vue 2 기반 JavaScript + Webpack 4 프로젝트였다. 따라서 &quot;어떤 올바른 순서&quot;로 빌드를 수행해야 한다. 이는 로컬 개발 서버 실행부터 프로덕션 배포까지 모든 과정을 복잡하고 번거롭게 만든다. 의존성이 복잡해져 충돌이 발생하기도 했다.도커는 완벽한 해결책이 되지 못했다. 빌드만이 문제가 아니었다. 협업 시에도 정말 패키지가 최신 버전인지 항상 확인이 필요했다. 일일이 사람이 직접 확인해 주는 수밖에 없었다.패키지 전달 방식도 부적절했다. 우리는 보통 [Private NPM Package](https://docs.npmjs.com/about-private-packages)를 이용했다. 레거시한 경우 [Git Submodule](https://git-scm.com/book/en/v2/Git-Tools-Submodules)로 다른 패키지를 가져와 [Link 하거나](https://classic.yarnpkg.com/lang/en/docs/cli/link/), 빌드된 파일을 직접 가져와 사용했다.세 가지 방식 다 문제가 있다. NPM은 패키지를 업데이트하고 배포한 다음 이를 사용하는 모든 프로덕트에서 패키지를 업데이트해야 하므로 번거롭다. Git Submodule은 일일이 커밋 버전을 확인해야 하기에 실수할 가능성이 높다. 빌드 파일은 항상 최신 버전을 사용하면 된다지만, 빌드 결과물이 Git으로 관리되기에 협업이 어렵다. 무엇보다 모두 변경 사항 추적이 어렵다.수년째 이러고 있었다. 팀원도 나도 무언가 잘못되었다는 생각이 들었다. 그래서 방법을 찾아보았고, 모노리포가 가장 이상적인 해결책이라는 결론을 얻었다.왜 모노리포였을까? 각 패키지와 애플리케이션이 완전히 다른 기술 스택을 갖고 있었기 때문에, 단순히 모든 코드를 애플리케이션 리포지토리 하나로 이동하는 것만으로는 문제를 해결할 수 없다. 패키지에서 사용하는 빌드 시스템을 모두 통일하려면 대규모 리팩토링이 필요한데, 이는 현실적으로 비용이 낮지 않았다. 반면 모노리포는 패키지가 자신의 기존 빌드 방식을 유지하면서도 하나의 리포지토리에서 관리될 수 있게 해준다. 이를 통해 애플리케이션과 패키지에 대한 변경사항을 최소화하면서도 코드 공유와 의존성 관리 문제를 해결할 수 있다.# 모노리포 마이그레이션## 과정 0 - 준비하기프로젝트 구조를 변경하는 작업은 언제나 위험하다. 병렬적으로 수행되는 다른 작업에 미치는 영향을 차치하더라도, 얼마나 시간이 필요할지조차 모른다. 나는 리스크를 최소화하기 위해 이를 두 단계로 나누었다. 그리고 단계마다 팀원들에게 공유했다.1. 패키지 매니저 교체 &amp; 모노리포 구성2. Turborepo 도입[^1][^1]: 꽤 나중에 돌아보니 드는 생각이지만, Turborepo는 조금 오버엔지니어링이지 않았나 싶다. PNPM Workspace만으로도 충분했을 것.과정을 진행하다 문제가 발생하면 Todo-List 형태로 기록해 두었다. 기록으로 남은 아이템만 40개가 넘는다. 이는 마이그레이션 과정이 얼마나 다사다난했는지를 보여준다. 대부분 첫 단계에서 발생했다.## 과정 1 - 패키지 매니저 교체 &amp; 모노리포 구성Yarn Classic(v1)도 모노리포를 위한 패키지 매니저로서 사용할 수 있다. 그러나 [Hoisting](https://www.jonathancreamer.com/inside-the-pain-of-monorepos-and-hoisting/#hoisting)으로 인해 [유령 디펜던시](https://rushjs.io/pages/advanced/phantom_deps/)가 존재하거나 모듈 설치 시 퍼포먼스가 좋지 않다. 따라서 모노리포에서 사용하기에 적합하지 않다. 대신 PNPM을 사용했는데, 이유는 다음과 같다:- Yarn 최신 버전은 node_modules 대신 pnp를 사용하고 있다. 그런데 이는 node_modules를 사용하는 일부 툴에서 호환성 문제가 발생할 수 있다. 그래서 node_modules를 유지하는 PNPM이 더 적합하다고 판단했다.- PNPM은 [Workspace](https://pnpm.io/workspaces)를 통해 모노리포를 지원한다.- 함께 도입하고자 하는 [Turborepo](https://turborepo.dev/)가 PNPM을 권장하고 있다.- PNPM은 원본 디펜던시가 존재하고 이를 심볼릭 링크로 연결하는 방식을 이용한다. 따라서 많은 디펜던시를 사용하는 모노리포에 적합하다.교체 과정에서는 크게 두 가지 문제가 발생했다. Lock 파일과 Link 패키지.Lock 파일 이전은 실패했다. [`pnpm import`](https://pnpm.io/cli/import) 명령으로 yarn.lock 파일을 pnpm-lock.yaml 파일로 변환할 수는 있다. 그러나 Lock 파일 구조가 달라서였을까? 유령 디펜던시와 Link해서 사용하던 디펜던시가 발목을 잡았다. 아예 yarn.lock 파일을 지우고 `pnpm install` 명령으로 pnpm-lock.yaml 파일을 생성해 보기도 했는데, 결과는 마찬가지. 이번에는 [시맨틱 버전](https://semver.org/lang/ko/)을 지키지 않는 디펜던시가 문제였다. 둘 다 빌드부터 실패했다.다른 방법이 떠오르지 않았다. 그래서 처음부터 시작하기로 했다. 새로운 프로젝트를 만들고, 원본 애플리케이션 코드를 하나씩 옮겼다. 성공하면 전진하고, 실패하면 해결했다. 이를 모두 옮길 때까지 반복했다. 나쁘지 않은 방법이었다. 빌드도 잘 수행되고, 시간은 생각만큼 오래 걸리지 않았다. 하지만 애플리케이션 동작이 이상했다. Link해서 사용하던 패키지를 [PNPM Workspace](https://pnpm.io/workspaces) 패키지로 변경했는데, 이게 원인이었다.![link vs workspace](/images/230620/link-vs-workspace.png) _link vs workspace_Link된 패키지는 node_modules에서 심볼릭 링크로 특정 디렉터리와 연결된다. 그리고 Yarn Classic은 디펜던시들이 Hoisting 되어, non-모노리포 환경에서 전이 디펜던시(Sub-dependency)를 포함한 모든 디펜던시가 루트에 설치된다. 따라서 기존에는 모두 Link된 패키지를 가리켜 정상 동작했다.이와 비슷하게, Workspace도 원하는 디렉터리를 패키지처럼 사용할 수 있다. 다만 동작이 조금 다르다. Link는 디펜던시 버전을 신경 쓰지 않고 항상 해당 패키지를 사용한다. 하지만 Workspace는 패키지 버전이 다른 경우, NPM에서 알맞은 버전을 찾아 설치한다. 전이 디펜던시 버전은 내가 관리할 수 없기에, 이는 문제가 될 수 있다.예를 하나 들어보자. Workspace 패키지로 `foo@1.0.0`을 만들었다. 다른 패키지에서 이를 사용하고자 한다면, `foo: &quot;1.0.0&quot;`을 디펜던시로 package.json 파일에 추가하면 된다. 그러나 만약 `foo: &quot;2.0.0&quot;`을 디펜던시로 추가한다면, PNPM은 Workspace 패키지가 아닌 NPM에서 `foo@2.0.0`을 찾아 사용하게 된다. 이 정도면 이름은 같지만 서로 다른 디펜던시라 봐도 무방하다. 동작이 이상해질 수밖에 없다.그렇다고 Link로 회귀하고 싶지는 않았다. 나와 같은 상황을 마주한 사람은 없었을까? 검색해 보니 버전을 덮어쓰는 방법이 있다고 한다. PNPM에서 제공하는 package.json [`pnpm.overrides`](https://pnpm.io/package_json#pnpmoverrides) 필드다.```json// package.json{  &quot;pnpm&quot;: {    &quot;overrides&quot;: {      &quot;foo&quot;: &quot;workspace:*&quot;    }  }}````pnpm.overrides` 필드는 디펜던시 그래프 내 모든 곳에 적용된다. 따라서 모든 `foo` 패키지가 Workspace `foo` 패키지를 가리킨다. 이는 Lock 파일에서도 확인할 수 있다. 참고로 [`workspace:`](https://pnpm.io/workspaces#workspace-protocol-workspace) 프로토콜은 명시적으로 Workspace 패키지를 사용함을 의미한다. 즉, 버전을 상관하지 않고(`*`) `foo` Workspace 패키지를 사용하겠다는 말이다.패키지 문제를 해결하고 나니 애플리케이션이 잘 동작한다. 솔직히 힘들었고 시간도 가장 많이 소요되었지만, 이제는 모두 해결되었다. 야호! 하지만 아직 끝나지는 않았다.## 과정 2 - Turborepo 도입우리는 곧 여러 애플리케이션과 패키지를 리포지토리에서 한 곳에서 관리할 계획이다. 캐시나 병렬 실행과 같은 최적화에 대한 필요성이 느껴졌다. 이왕이면 성능과 확장성 모두 잡을 수 있으면 좋겠다. 물론 구성이 어려우면 안 된다. 이 조건에 부합하는 툴이 하나 있다. 바로 [Turborepo](https://turborepo.dev/).![turbo](/images/230620/turbo.png) _vercel turbo_Turborepo는 Vercel에서 개발한 모노리포용 오픈소스 빌드 시스템이다. 빠르고 확장할 수 있는 모노리포를 지향하며, 빌드 캐싱, 병렬 실행, 증분 빌드 등 다양한 기능을 제공한다. 이를 이용해 빌드와 테스트 속도를 향상할 수 있다. 자세한 도입 방법은 [Turborepo 문서](https://turbo.build/repo/docs/getting-started/add-to-project)를 참고.Lerna나 Nx 같은 다른 선택지도 있었다. 하지만 Lerna는 기능이 아쉬웠고, Nx는 복잡했다. 다른 툴 역시 마음에 들어차지 않았다. 물론 케이스 바이 케이스. 다양한 요구사항들이 있을 테니, 만약 모노리포 빌드 시스템 도입을 고려한다면 [https://monorepo.tools/](https://monorepo.tools/) 를 참고하자. 모노리포 관리를 위한 다양한 툴을 소개하고 각각을 비교해 보여주고 있다.아무튼. 모노리포 구성은 앞서 모두 끝냈으니, Turborepo 도입 자체는 어렵지 않았다. 팀원을 위한 가이드 문서도 작성해 줘야 했는데, 여기서는 Vite 번역 경험이 도움이 되었다. 다만 제공하는 기능 중 캐싱을 활용할 수 있으려면 CI 설정을 조금 손봐야 하는데, 변명이지만 여유가 없어 준비만 해 둔 상태다. 이와 함께 [Remote Caching](https://turbo.build/repo/docs/core-concepts/remote-caching)도 사용해보고 싶다. 둘 다 생산성에 도움이 될 것이다.# 마이그레이션 이후, 마치며![next](/images/230620/next.jpeg)문제를 해결하고자 시작한 마이그레이션이지만 처음에는 확신이 없었다. 그러나 지금은 아무도 이전 방식으로 돌아가고 싶어 하지 않는다. Git Submodules, 파편화된 PR, 도커, 패키지 전달, 변경 사항 추적 등 여럿 개선되었다. 필요하면 분리된 애플리케이션 개발 환경을 구축할 수 있기에, 개발 속도를 잃지도 않았다. 더 이상 불필요한 시간을 낭비하지 않아도 된다. 협업이 자연스레 더 잘 이루어졌다.물론 긍정적인 면만 있지 않다. 미비한 패키지 분리 기준과 높아진 복잡도로 인한 가이드가 필요하다. Alias 설정이 잘못되었는지 다른 파일을 가리킬 때도 있다. 굳이 패키지로 만들지 않아도 될 부분까지 분리하기도 했다. 모두 배움이 부족한 탓이다.그래서 아쉬움이 남는다. 경험 부족으로 시행착오가 많았다. 마이그레이션 이전에는 고려 대상이 아니었지만, 실제로는 고려해야 했던 부분도 있다. 특히 패키지 매니저 교체가 예상 밖이었다. 그렇게나 많은 문제가 발생할 줄이야. 패키지 매니저 교체와 모노리포 작업을 동시에 진행하지 말았어야 했다. Turborepo에서 제공하는 기능도 좀 더 제대로 활용해보고 싶다.그래도 더 나은 방향으로 나아가고 있다는 느낌이 든다. 다른 서비스에서 우리와 같은 스택을 사용하는 모습을 볼 때마다 더욱 그렇다. 애플리케이션 전반에 영향을 끼치는 변경 사항을 어떻게 해야 잘 관리할 수 있을지에 대한 감도 조금 잡았다. 앞으로도 이런 개선을 다양한 부분에서 진행해 볼 생각이다.</content:encoded>
            <category>Experience</category>
            <category>Guide</category>
        </item>
        <item>
            <title><![CDATA[ReferenceError in JavaScript]]></title>
            <link>https://shj.rip/article/referenceerror-in-javascript.html</link>
            <guid isPermaLink="false">referenceerror-in-javascript</guid>
            <dc:creator><![CDATA[shj]]></dc:creator>
            <pubDate>Thu, 12 Jan 2023 00:00:00 GMT</pubDate>
            <enclosure url="https://shj.rip/article/referenceerror-in-javascript.png" length="0" type="image/png"/>
            <content:encoded># TL;DR- `let`과 `const`는 LexicalEnvironment에 바인딩 되며, 블록이 실행되기 전에 생성된다. 이는 LexicalBinding이 평가되기 전까지는 접근할 수 없다.- `var`로 선언된 변수는 VariableEnvironment에 바인딩 되며, 함수가 새로 생성될 때마다 생성된다. 이는 함수가 실행되기 전에 접근할 수 있다. AssignmentExpression(`=`)을 마주할 때 값이 할당된다.- 동일해 보이는 코드라도 결과가 다를 수 있다. 예상치 못한 결과를 초래할 수 있으므로 주의해야 한다.# 서론TDZ(Temporary Dead Zone)는 들어본 적이 있을 것이다. `let`이나 `const`로 선언된 변수를 선언 이전에 접근하려 하면 ReferenceError가 발생한다. 이를 TDZ라고 한다.```jsconst foo = 1;{  console.log(foo); // ReferenceError: Cannot access &apos;foo&apos; before initialization  const foo = 2;}```이제 아래 코드를 보자. 두 코드는 서로 다른 에러를 출력한다. 차이점은 무엇일까? 잠시 생각해 보자.```jsconsole.log(foo);let foo;``````js{  console.log(foo);  let foo;}```&lt;details&gt;&lt;summary&gt;정답&lt;/summary&gt;첫 번째 코드는 `ReferenceError: foo is not defined`가, 두 번째 코드는 `ReferenceError: Cannot access &apos;foo&apos; before initialization`이 발생한다.```jsconsole.log(foo); // ReferenceError: foo is not definedlet foo;``````js{  console.log(foo); // ReferenceError: Cannot access &apos;foo&apos; before initialization  let foo;}```&lt;/details&gt;왜 이런 차이가 존재할까?# Execution Context이를 설명하기 전에, 먼저 Execution Context에 대한 이해가 필요하다. Execution Context는 코드에 대한 평가 및 실행 환경을 제공하며, 실행 중인 코드에 대한 스코프 정보, 변수, 객체, 함수 등을 포함하고 있다. 이 구조를 그래프로 표현하면 아래와 같다.![Diagram](/images/230112/diagram.png)&gt; 💡&gt; Environment Record: 식별자와 그에 해당하는 값을 추적하고 관리하며, 코드가 실행되는 동안 식별자에 접근하거나 값을 변경할 수 있게 함여기서 VariableEnvironment와 LexicalEnvironment는 변수나 함수와 같은 식별자를 관리하는 역할을 한다. 이 차이를 [Ecma TC39 멤버 답변](https://github.com/tc39/ecma262/issues/736#issuecomment-261721158)을 빌려 설명하자면 이렇다.&gt; A LexicalEnvironment is a local lexical scope, e.g., for let-defined variables. If you define a variable with let in a catch block, it is only visible within the catch block, and to implement that in the spec, we use a LexicalEnvironment.&gt;&gt; VariableEnvironment is the scope for things like var-defined variables. vars can be thought of as &quot;hoisting&quot; to the top of the function.VariableEnvironment는 `var`로 정의된 변수에 대한 스코프를, LexicalEnvironment는 `let`과 `const`로 정의된 변수에 대한 스코프를 의미한다. 즉, `let`과 `const`는 LexicalEnvironment에, `var`는 VariableEnvironment에 바인딩 된다.바인딩 되는 위치뿐 아니라, 초기화 과정에서도 차이가 존재한다.`var` ([스펙](https://tc39.es/ecma262/multipage/ecmascript-language-statements-and-declarations.html#sec-variable-statement)):&gt; Var variables are created when their containing Environment Record is instantiated and are initialized to undefined when created.&gt;&gt; A variable defined by a VariableDeclaration with an Initializer is assigned the value of its Initializer&apos;s AssignmentExpression when the VariableDeclaration is executed, not when the variable is created.`var` 키워드로 정의된 변수는 자신이 속한 Environment Record가 초기화될 때 `undefined` 값을 갖고 생성되며, 이후 실제로 값이 할당되는 구문인 AssignmentExpression(`=`)을 마주할 때 값이 변수에 할당된다.`let` 및 `const` ([스펙](https://tc39.es/ecma262/multipage/ecmascript-language-statements-and-declarations.html#sec-let-and-const-declarations)):&gt; The variables are created when their containing Environment Record is instantiated but may not be accessed in any way until the variable&apos;s LexicalBinding is evaluated.&gt;&gt; A variable defined by a LexicalBinding with an Initializer is assigned the value of its Initializer&apos;s AssignmentExpression when the LexicalBinding is evaluated, not when the variable is created.&gt; 💡&gt; LexicalBinding: `let`과 `const` 키워드를 사용하여 변수를 선언`let`과 `const` 키워드로 정의된 변수 역시 자신이 속한 Environment Record가 초기화 될 때 생성됨은 동일하다. 다만 LexicalBinding이 평가되기 전까지는 접근할 수 없으며(**TDZ**), AssignmentExpression을 마주할 때가 아닌 LexicalBinding이 평가될 때 값이 할당된다는 차이가 있다.그렇다면 이제 Environment가 초기화되는 시점을 살펴보자. 이 역시 [Ecma TC39 멤버 답변](https://github.com/tc39/ecma262/issues/736#issuecomment-261721158)에 잘 설명되어 있다.&gt; To implement this in the spec, we give functions a new VariableEnvironment, but say that blocks inherit the enclosing VariableEnvironment.함수는 새로운 VariableEnvironment를 생성하고, 블록은 상위 VariableEnvironment를 상속받는다는 말.LexicalEnvironment는 [스펙](https://tc39.es/ecma262/multipage/ecmascript-language-statements-and-declarations.html#sec-block-runtime-semantics-evaluation)에서 찾을 수 있다.&gt; { StatementList }&gt;&gt; 1. Let oldEnv be the running execution context&apos;s LexicalEnvironment.&gt; 2. Let blockEnv be NewDeclarativeEnvironment(oldEnv).&gt; 3. Perform BlockDeclarationInstantiation(StatementList, blockEnv).&gt; 4. Set the running execution context&apos;s LexicalEnvironment to blockEnv.&gt; 5. Let blockValue be the result of evaluating StatementList.&gt; 6. Set the running execution context&apos;s LexicalEnvironment to oldEnv.&gt; 7. Return blockValue.블록이 평가되기 전에 블록에 대한 LexicalEnvironment를 생성하고, 블록 내 선언문을 평가한 뒤, LexicalEnvironment를 제거한다.# Reasons for ReferenceError이제 ReferenceError가 발생하는 이유를 설명할 수 있다. 먼저 첫 번째 코드를 다시 살펴보자면,```jsconsole.log(foo); // ReferenceError: foo is not definedlet foo;````let`으로 정의된 변수는 Environment Record가 초기화될 때 생성된다고 했다. 그런데 왜 정의가 되지 않았을까? 이는 JavaScript가 위에서부터 한 줄씩 읽어 내려오는 인터프리터 언어이기 때문이다. 따라서 코드는 아래와 같이 해석된다.```jsconsole.log(foo);``````jslet foo;````foo` 라는 변수가 생성조차 되지 않았었다. 따라서 `ReferenceError: foo is not defined`가 발생.이제 두 번째 코드를 살펴보자.```js{  console.log(foo); // ReferenceError: Cannot access &apos;foo&apos; before initialization  let foo;}````foo`는 블록 내에서 선언된 변수이다. 블록 내에서 선언된 변수는 블록이 실행되기 전에 LexicalEnvironment와 함께 생성되지만, LexicalBinding이 평가되기 전까지는 접근할 수 없다. 따라서 `ReferenceError: Cannot access &apos;foo&apos; before initialization`가 발생한다.## 다시 한 번 정리서론에서 언급한 첫 번째 예시 코드도 이제 설명이 가능하다.```jsconst foo = 1;{  console.log(foo); // ReferenceError: Cannot access &apos;foo&apos; before initialization  const foo = 2;}```블록 외부에서 `foo`가 선언되었지만, 블록 내부에서도 `foo`가 선언되었다. 이로 인해 블록 내부에서 `foo`는 LexicalBinding이 평가되기 전까지 접근할 수 없다. 따라서 `ReferenceError: Cannot access &apos;foo&apos; before initialization`가 발생한다.# 마치며`let`과 `const`는 LexicalEnvironment에 바인딩 되며, LexicalEnvironment는 블록이 실행되기 전에 생성된다. 따라서 블록 내에서 선언된 변수는 블록이 실행되기 전에 생성되지만, LexicalBinding이 평가되기 전까지는 접근할 수 없다. 이를 Temporal Dead Zone, 줄여서 TDZ라고 한다.이처럼 JavaScript 특징으로 인해 마치 동일해 보이는 코드라도 결과가 다를 수 있다. 당연한 이야기라 생각할 수도 있겠지만, 이런 특징을 모르고 사용하다가는 예상치 못한 결과를 초래할 수 있으므로 주의해야 한다.</content:encoded>
            <category>Guide</category>
        </item>
        <item>
            <title><![CDATA[AWS DNA 4기 후기]]></title>
            <link>https://shj.rip/article/aws-dna-4th-review.html</link>
            <guid isPermaLink="false">aws-dna-4th-review</guid>
            <dc:creator><![CDATA[shj]]></dc:creator>
            <pubDate>Wed, 17 Aug 2022 00:00:00 GMT</pubDate>
            <enclosure url="https://shj.rip/article/aws-dna-4th-review.png" length="0" type="image/png"/>
            <content:encoded># TL;DR- AWS DNA 4기는 선별된 인원으로 운영되는 AWS 교육 및 네트워킹 프로그램으로, 다양한 요구 및 문제를 실무적으로 해결하는 케이스를 데모와 함께 심화학습으로 제공한다.- AWS DNA 4기 교육 과정은 이론, 실습, 과제 순으로 진행되었으며, Best Use Cases에 대해 다루었다.- AWS DNA 4기 교육을 통해 AWS 사용법을 배우고, 다른 분야에 종사 중인 다양한 사람들을 만나볼 수 있었으며, AWS SAA 자격증을 준비할 수 있는 계기가 되었다.# 서론이번에 클라우드 교육 및 네트워킹 프로그램인 AWS DNA 4기에 참여했다. 근데 왜 뜬금없이 클라우드냐면?1. **학생 때부터 적잖은 관심:** 다만 Serverless 프레임워크와 같은 툴을 이용했기에 사실상 눈 감고 코끼리 뒷다리 만진 격2. **부족한 클라우드 경험:** 향후 1인 스타트업 준비할 때 이 부분에서 걸림돌이 되지 않을까 하는 우려그래서 지원했고, 수료하고 나니 시야가 확실히 넓어졌다.# AWS DNA## AWS DNA란AWS DNA는 한마디로 이런 프로그램이다.&gt; AWS DNA 프로그램은 선별된 인원으로 운영되는 AWS 교육 및 네트워킹 프로그램입니다. 다양한 사업의 요구 및 문제를 실무적으로 해결하는 케이스를 데모와 함께 심화학습으로 제공합니다. - [AWS DNA 4기 온라인 설명회 페이지](https://archive.ph/8I23p)지원 절차는 다음과 같았다:1. 회사 슬랙에 AWS DNA 온라인 설명회 관련 내용이 공유됨2. 지원 의사를 밝히고 온라인 설명회 페이지에 접속해 등록3. 여러 질문에 대한 답을 작성한 뒤 제출, 결과를 기다리기질문은 대략 이러했다:- 자기소개와 지원 동기- AWS 사용 경험- AWS DNA 프로그램에서 배우고 싶은 서비스 및 해보고 싶은 프로젝트- 배운 내용들을 바탕으로 현 회사에서 개선 또는 시도해 보고 싶은 부분어려운 질문은 없었다. _재직 중인 회사에 AWS DNA 프로그램이 얼마나 도움이 되는가?_ 를 초점으로 답을 했다.## 교육 과정![AWS DNA 4기 스케쥴](/images/220817/aws-dna-4-schedule.png) _AWS DNA 4기 스케쥴_세션은 `이론 → 실습 → 과제` 순으로 진행되었으며, 대부분 Best Use Cases에 대해 다루었다. 온라인과 오프라인을 선택할 수 있었는데, 7월 말경 코로나가 다시 확산세로 바뀌어 마지막 두 세션(EKS, Quicksight)은 모두 온라인으로 진행했다.모든 세션이 도움 되었지만 그중에서 Chaos Engineering이 가장 기억에 남았다. 이론적으로 가장 어려웠고, Case 재현도 쉽지 않았고, 그래서 그랬는지 과제도 유일하게 해결하지 못했어서... 그래도 이런 케이스에 대한 경험이 중요하다고 생각한다.# 교육을 듣고## 좋았던 부분**1\. 교육 + 과제를 통해 접해보기 어려웠던 케이스에 대해 공부**과제(Hands-on Lab)를 통해 세션 내용과 Best Cases에 대한 실습이 가장 좋았다. AWS 서비스 사용법을 배우기에 이보다 더 적절한 방법이 있을까 싶다. Hands-on Lab도 그대로 따라 하면 되는 거라 큰 무리는 없었다. 정 이해가 가지 않는다면 슬랙으로 AWS SA 분들이나 다른 교육생분들께 여쭤볼 수도 있다.**2\. 전폭적인 AWS 지원**무료로 진행되는 교육임에도 웰컴키트부터 크레딧, 자격증 비용, 과제별 선물 등 많은 부분을 지원받았다. &apos;이래도 되는 걸까?&apos; 싶을 정도로.**3\. 다른 분야에 종사 중인 다양한 사람들을 만나볼 기회**시리즈 C나 D, 또는 그 이상 시리즈를 밟고 있는 회사에서 근무하는 사람들과 이런저런 이야기를 나눌 기회가 주어진다. 심지어 AWS SA 분들과도 커피챗 같은 느낌으로 격의 없이 이야기를 해볼 수 있었다.## 아쉬웠던 부분**1\. 잘 참여하지 못했던 해커톤**이번 AWS DNA 4기에서는 해커톤을 진행했다. 프로그램 종합 평가가 아닐까 싶다. 3기에서는 이와 유사하게 [AWS JAM](https://youtu.be/V9dXxofCmUw)이 있었다. 다만 업무에 좀 치여 살던 때라 시간 할애가 쉽지 않아 아쉬웠다. 다른 팀 결과물이 생각보다 수준 높아 내심 놀라기도 했다.**2\. 서버리스 세션이 없었다**이번 AWS DNA 4기에는 서버리스 관련 세션이 없었다. 3기에는 있었는데, 그 점이 조금 아쉬웠다.**3\. 생각보다 높았던 일부 세션 난이도**일부 세션에서 고생했었다. 그래도 Builders 200 정도면 여차저차 해낼 수 있는 수준이긴 했지만... 자료가 대부분 세션 시작 바로 전날에 공유되는 형태여서 예습이 어렵기도 했다.## 그 외- 네트워킹을 통해 AWS 회사 생활을 단편적으로나마 엿볼 수 있었던 시간- 너무나도 친절하셨던 AWS SA 분들- 처음 가봤던 센터필드 AWS 한국지사 오피스- 이 교육을 계기로 AWS SAA 자격증을 준비 중# 마치며알다시피 클라우드 네이티브에 대한 중요도는 날이 갈수록 높아지고 있다. 요즘 어느 누가 서비스 개발하겠다고 컴퓨터부터 구매하는가?자신이 백엔드나 인프라 관련 업무에 종사하지 않더라도 이러한 교육 프로그램은 언젠가 반드시 업무에 도움이 될 것이다. 아는 만큼 보인다고 하지 않다던가. 서비스 개발과 클라우드는 이제 서로 떼려야 뗄 수 없는 관계다.혹여 AWS에 익숙지 않다고 겁먹지는 말자. 내가 그랬다. AWS SA 분들은 친절하시고, 모르는 부분이 있다면 언제든지 질문하면 된다. 세션 수준도 상당히 높다. 똑똑한 사람들이 설명해 주어서 그런지 아주 어렵지도 않았다. 그러니 만약 AWS DNA 프로그램에 참여할 기회가 생겼다면 반드시 잡아보도록 하자.</content:encoded>
            <category>Experience</category>
        </item>
        <item>
            <title><![CDATA[Text-Vide; Bionic Reading 오픈소스 구현체]]></title>
            <link>https://shj.rip/article/text-vide.html</link>
            <guid isPermaLink="false">text-vide</guid>
            <dc:creator><![CDATA[shj]]></dc:creator>
            <pubDate>Sun, 01 May 2022 00:00:00 GMT</pubDate>
            <enclosure url="https://shj.rip/article/text-vide.png" length="0" type="image/png"/>
            <content:encoded>&lt;!-- TODO: 내용이 너무 빈약하지 않나, 특히 NPM 패키지-배포 과정이 하나도 들어있지 않은 것 같은데 --&gt;# TL;DR- Bionic Reading은 가독성을 향상시키는 기법이다.- Text-Vide는 Bionic Reading을 ESM, CommonJS, IIFE(CDN)를 통해 사용할 수 있도록 하고, 모든 언어, HTML 태그 무시 등 추가 기능을 제공한다.- Text-Vide는 레딧과 해커뉴스를 통해 해외 홍보를 시도하였고, 레딧에서 반응이 좋았다.- Bionic Reading과 유사한 가독성 향상 기법으로 BeeLine Reader, Orthographic Mapping, Speed Reading 등이 있다.# 서론가독성은 글을 읽는 데 있어 중요한 요소 중 하나다. 특히 웹 페이지는 글이 차지하는 비율이 높아 더욱 중요하다. 이를 향상시키기 위해 다양한 기법들이 제안되고 있다. 그 중 하나가 [Bionic Reading](https://bionic-reading.com/)이다. Bionic Reading은 Fixation Point와 Saccade 라는 값을 이용해 단어 일부분을 강조한다. 이를 통해 글을 읽는 속도와 이해도를 높일 수 있다.![text-vide Sandbox](/images/220501/bionic-reading-sandbox.png) _Bionic Reading을 구현한 [Text-Vide Sandbox](https://tinyurl.com/yckchzky)_며칠 전 나는 HN에서 [관련 게시글](https://news.ycombinator.com/item?id=30787290)을 보았다. 흥미로웠지만 Bionic Reading은 웹 페이지 내에서 SaaS 형태로만 제공하고 있어 아쉬웠다. NPM 패키지로 만들어 사용할 수 있도록 하면 좋겠다는 생각이 들었다. 그래서 이번 기회에 직접 만들어보기로 했다.# Text-Vide![로고](/images/220501/logo.png) _[GitHub](https://github.com/Gumball12/text-vide) / [NPM](https://www.npmjs.com/package/text-vide) / [Sandbox](https://gumball12.github.io/text-vide/)_Text-Vide 패키지는 브라우저와 Node.js 모든 환경에서 사용할 수 있도록 ESM, CommonJS, 그리고 IIFE(CDN) 세 포맷으로 제공한다. 또한 기존 Bionic Reading SaaS에서 지원하지 않는 몇 기능을 추가로 제공해 사용자에게 더 많은 선택지를 제공한다:- **모든 언어 지원:** Bionic Reading은 현재 영어만 지원한다.- **커스텀 구분자(Separator) 지원:** Bionic Reading은 커스텀 구분자를 지원하지 않는다.- **HTML 태그 무시 여부 지원:** Bionic Reading은 HTML 태그를 무시하지 않는다.홍보는 레딧과 해커뉴스를 이용했다.![레딧](/images/220501/reddit.png) _[Reddit](https://www.reddit.com/r/javascript/comments/v07pwx/an_javascript_implementation_of_bionicreading/)_![해커뉴스](/images/220501/hackernews.png) _[Hacker News](https://news.ycombinator.com/item?id=31466319)_처음 시도한 해외 홍보였는데, 생각보다 반응이 좋아 고무적이었다. 사람이 더 많아서인지 바이럴도 잘 퍼졌던 것 같다. 피드백도 많이 받았고, 이를 토대로 개선할 수 있었다.# 이슈## Bionic Reading Legal Issues![Bionic Reading® Legal Stuff (#27)](/images/220501/legal-stuff.png) _[Bionic Reading® Legal Stuff (#27)](https://github.com/Gumball12/text-vide/issues/27)_사실 처음 이름은 Bionic Reading 이었다[^1]. 하지만 이와 관련해 저작권 문제가 있을 수 있다는 이슈를 받았다. 굳이 위험을 안고 갈 필요가 없어 이름을 변경했다. 마침 [좋은 제안](https://github.com/Gumball12/text-vide/issues/27#issuecomment-1145181969)이 하나 있었고, 이를 토대로 선택한 이름이 &quot;Text-Vide&quot;.[^1]: [NPM](https://www.npmjs.com/package/bionic-reading)에 그 흔적이 남아있다## 비효율적인 알고리즘![Performance improvements (#26)](/images/220501/performance.png) _[Performance improvements (#26)](https://github.com/Gumball12/text-vide/issues/26)_초기에는 Bionic Reading 동작을 완벽히 구현하기 위해 비효율적인 알고리즘을 사용했다.1. 텍스트를 단어로 분리2. 분리된 단어 각각에 대해 Regex를 수행해 Fixation-Point를 찾음3. Fixation-Point를 찾은 단어에 대해 Highlight다만 피드백을 통해 굳이 이렇게까지 할 필요가 없음을 깨달았고, 아래와 같이 개선했다.1. 텍스트 전체에 대해 Regex를 수행해 Fixation-Point를 찾음2. Fixation-Point를 찾은 단어에 대해 Highlight[동작 방식을 기술한 문서](https://github.com/Gumball12/text-vide/pull/30/files#diff-2d2c677f4764951fe0d82334c3535d0b64040c574f5ff22384291c02f19af725)를 조금 수정해야 했지만, 결과적으로 더 좋은 선택이었다. 번들 크기 또한 1KB 이하로 줄일 수 있었다.## Bionic Reading 효용성Bionic Reading이 실제로 효용이 있는지에 대한 피드백도 받았다. 다만 나는 Bionic Reading을 구현한 것에 불과하기에 이에 대한 답변을 주기에는 한계가 있었다. 관련 내용은 따로 [문서](https://github.com/Gumball12/text-vide/blob/main/ABOUT_READABILITY.md)로 만들어 사용자들이 더 쉽게 이해할 수 있도록 했다.## 복잡한 프로젝트 구조프로젝트 구조가 복잡하다는 피드백도 있었다. 굳이 모노리포로 구성해 혼란을 가중시켰다는 의견이었다. 맞는 말이지만... Text-Vide는 모노리포를 이해하기 위해 시작한 프로젝트이기도 했다. [컨트리뷰팅 문서](https://github.com/Gumball12/text-vide/blob/main/CONTRIBUTING.md)를 만들어 이를 보완했다.# 마치며해외 커뮤니티 문화를 잘 몰랐기에 조금 어설펐던 점이 아쉬웠다. 댓글 내용을 잘못 이해하기도 했고, 정말 피드백인지 아닌지도 헷갈렸던 글도 있었다. 이런 부분들은 조금 더 경험을 쌓아야 할 것 같다.그래도 이번 프로젝트는 흥미로웠다. Bionic Reading과 같은 기법으로 가독성을 향상시킬 수 있다는 것도 처음 알았다. 단순히 문장 구조나 문단을 언제 나누는지와 같은 방식을 이용해야 한다고만 생각했는데, 새로운 시야가 트인 기분이다.다만 Bionic Reading이 첫 번째는 아니라고 한다. 흥미가 있다면 다른 가독성 향상 기법들도 한 번 살펴보도록 하자.- [BeeLine Reader](https://www.beelinereader.com/)- [Ehri, L.C. (2014). Orthographic Mapping in the Acquisition of Sight Word Reading, Spelling Memory, and Vocabulary Learning. Scientific Studies of Reading, 18, 21 - 5.](https://www.semanticscholar.org/paper/Orthographic-Mapping-in-the-Acquisition-of-Sight-Ehri/156bd9fa294573538a19dc2ef4bd19bdae9cf418)- [Rayner, K., Schotter, E. R., Masson, M. E. J., Potter, M. C., &amp; Treiman, R. (2016). So Much to Read, So Little Time: How Do We Read, and Can Speed Reading Help? Psychological Science in the Public Interest, 17(1), 4–34.](https://doi.org/10.1177/1529100615623267)</content:encoded>
            <category>Experience</category>
            <category>Project</category>
        </item>
        <item>
            <title><![CDATA[낸내; 광고 없이 상업적으로 이용할 수 있는 한글 폰트 모음]]></title>
            <link>https://shj.rip/article/naen-nae.html</link>
            <guid isPermaLink="false">naen-nae</guid>
            <dc:creator><![CDATA[shj]]></dc:creator>
            <pubDate>Sun, 30 Jan 2022 00:00:00 GMT</pubDate>
            <enclosure url="https://shj.rip/article/naen-nae.png" length="0" type="image/png"/>
            <content:encoded># TL;DR- &quot;눈누&quot;라는 한글 폰트 웹 사이트는 광고가 많고, 퍼포먼스가 떨어진다.- &quot;낸내&quot;라는 서비스를 만들어, 광고 없이 운영하고, 퍼포먼스를 개선시켰다.- &quot;낸내&quot;는 GitHub Pages와 jsDelivr을 이용해 서비스를 구현하고, Service Worker와 Cache Storage를 이용해 폰트 파일을 캐싱하고, 가상 스크롤을 이용해 렌더링 속도를 향상했다.# 서론&quot;눈누&quot; 라는 서비스가 있다. 상업적인 용도로 사용이 가능한 무료 한글 폰트를 소개해 주는 웹 사이트인데, 꽤 유용해서 나도 종종 사용했었다. 다만 두 가지 불편한 점이 있었다:- 광고가 너무 많음- 떨어지는 퍼포먼스이 글에서는 위에 대한 접근 및 해결 방법과 함께, 이를 구현한 &quot;낸내&quot;라는 서비스를 소개하고자 한다.# 불편한 점## 광고눈누는 정말 좋은 서비스지만 광고가 너무 많다. 폰트와 관련된 페이지에서만 8개. 그중 하나는 폰트 상세 페이지로 이동하는 중간에 흐름을 막는 광고였다.&lt;details&gt;&lt;summary&gt;광고 이미지&lt;/summary&gt;&lt;p&gt;&lt;img src=&quot;https://i.imgur.com/o8Xl23b.png&quot; alt=&quot;ad-1&quot; loading=&quot;lazy&quot; /&gt;&lt;img src=&quot;https://i.imgur.com/ZObAm5g.png&quot; alt=&quot;ad-2&quot; loading=&quot;lazy&quot; /&gt;&lt;img src=&quot;https://i.imgur.com/6RrBQZw.png&quot; alt=&quot;ad-3&quot; loading=&quot;lazy&quot; /&gt;&lt;img src=&quot;https://i.imgur.com/bhc3Uii.png&quot; alt=&quot;ad-4&quot; loading=&quot;lazy&quot; /&gt;&lt;img src=&quot;https://i.imgur.com/FJxpxhs.png&quot; alt=&quot;ad-5&quot; loading=&quot;lazy&quot; /&gt;&lt;/p&gt;&lt;/details&gt;서비스 운영에 지속적인 비용이 발생하는 것 같다. 그렇지만 이런 방식은 사용자 경험에 좋지 않다고 생각했다. 기술적으로 이를 해결할 수 있지 않을까? 이 생각이 발화점이 되었다. &quot;눈누와 같은 서비스를 만들되, 광고를 넣지 않고 운영해 보자&quot;.어렵지는 않았다. [GitHub Pages](https://pages.github.com/) 같은 서비스를 이용해 정적 웹 사이트를 배포하고, 폰트 파일은 [jsDelivr CDN](https://www.jsdelivr.com/)으로 제공하면 된다. 다만 GitHub Pages 트래픽 제한 정책을 고려해 폰트 서브셋을 이용해 트래픽을 줄여야 한다.![폰트 서브셋을 이용한 트래픽 개선](/images/220130/traffic.png) _폰트 서브셋을 이용한 트래픽 개선_## 퍼포먼스눈누는 크게 두 가지 퍼포먼스 개선점이 있다:- 새로고침 시마다 필요한 폰트 파일을 서버에서 다시 다운로드- 폰트를 다수 렌더링하게 되면 브라우저가 느려짐먼저 폰트 파일은 변경될 일이 거의 없다. 따라서 새로고침 시마다 다운로드하지 않아도 된다. 이를 브라우저에 캐싱하면 데이터 사용량을 줄일 수 있고, 더 빠른 폰트 로딩 속도도 제공할 수 있다.![폰트 캐싱](/images/220130/caching.png) _폰트 캐싱_이는 [Service Worker](https://web.dev/learn/pwa/service-workers?hl=ko)와 [Cache Storage](https://developer.mozilla.org/en-US/docs/Web/API/CacheStorage)를 이용해 구현이 가능하다. Service Worker는 브라우저와 웹 사이트 사이에서 동작하는 프록시 서버로, 브라우저 요청을 대신 처리할 수 있다. Cache Storage는 HTTP 응답을 캐싱할 수 있는 API로, Service Worker와 함께 사용하면 폰트 데이터를 캐싱할 수 있다.다음으로, 폰트를 다수 렌더링하는 문제는 이미 잘 알려진 [가상 스크롤](https://blog.logrocket.com/virtual-scrolling-core-principles-and-basic-implementation-in-react/)을 이용해 해결할 수 있다. 눈누는 이를 적용하지 않아 폰트가 많아질수록 렌더링 속도가 느려졌다. 이는 모바일 환경에서 더욱 두드러진다.# 낸내![낸내](/images/220130/naen-nae-logo.png) _[GitHub](https://github.com/naen-nae/naen-nae/) / [Web](https://naen-nae.shj.rip/)_낸내는 눈누와 유사한 서비스를 제공한다. 폰트를 다운로드 받을 수 있고, 폰트 렌더링 결과물을 미리 볼 수도 있다. 다만 낸내는 폰트 파일을 캐싱해 새로고침 시마다 다운로드하지 않는다. 또한, 폰트를 다수 렌더링할 때 가상 스크롤을 이용해 렌더링 속도를 향상했다.홍보는 디스콰이엇과 GeekNews에서 진행했다. [트위터](https://twitter.com/lqez/status/1412409801448071179)에서도 언급되었다.![디스콰이엇 홍보](/images/220130/disquiet.png) _디스콰이엇 홍보_![GeekNews 홍보](/images/220130/geeknews.png) _GeekNews 홍보_디스콰이엇에서 반응이 나쁘지 않아 흥미로웠다. 배포 1주 차에는 120명 정도 사용자가 접속했다.![배포 1주차](/images/220130/wau-1.png) _배포 1주차_배포 2주 차에는 에브리타임에도 홍보했고, 사용자가 약 300명 접속했다.![배포 2주차](/images/220130/wau-2.png) _배포 2주차_3주 차에는 510명 접속했다. 실제 서비스에 대해 광고를 넣지 않고도 운영이 가능한지 확인하기 위한 목적이 컸기에, 방문자 체크는 여기까지만 했다.# 마치며낸내는 눈누와 유사한 서비스를 제공하되, 광고를 넣지 않고 운영하는 것을 목표로 시작한 프로젝트였다. 이를 위해 GitHub Pages와 jsDelivr을 이용해 서비스를 구현했다. 또한, Service Worker와 Cache Storage를 이용해 폰트 파일을 캐싱하고, 가상 스크롤을 이용해 렌더링 속도를 향상했다.낸내는 3주 동안 천 명 정도 접속했다. 눈누에 비해 사용자가 적었을 터이지만, 목적을 어느정도 이루었기에 만족스러웠다. 기존 서비스를 분석하고 이를 개선하는 과정에서 여럿 배우기도 했다. 이를 바탕으로 더 좋은 서비스를 만들어 보고 싶다.</content:encoded>
            <category>Experience</category>
            <category>Project</category>
        </item>
        <item>
            <title><![CDATA[오픈 소스의 어두운 면]]></title>
            <link>https://shj.rip/article/dark-side-of-open-source.html</link>
            <guid isPermaLink="false">dark-side-of-open-source</guid>
            <dc:creator><![CDATA[shj]]></dc:creator>
            <pubDate>Sun, 16 Jan 2022 00:00:00 GMT</pubDate>
            <enclosure url="https://shj.rip/article/dark-side-of-open-source.png" length="0" type="image/png"/>
            <content:encoded># TL;DR- 오픈 소스는 프로그래밍을 배우고, 연구하고, 일하는 데에 큰 도움이 된다.- 오픈 소스인 faker.js는 테스트나 프로토타입에 필요한 Mock 데이터를 만들 때 많이 사용되었으나, 메인테이너인 Marak으로 인해 현재 존재하지 않는다. 이는 이용만 하고 적절한 대가를 지불하지 않아 발생했다.- 오픈 소스는 공짜가 아니며, 지속 가능한 미래를 위해 결국 헌신적인 노동이 필요하다. 그렇지 않다면 생명이 다 한 것과 마찬가지다.# 서론나는 오픈 소스에 꽤 긍정적이다. 프로그래밍이라는 분야에 처음 발을 들인 이후 지금까지 오픈 소스 도움을 많이 받았다. 무료로 사용할 수 있는 운영체제, 프로그래밍 언어, 라이브러리, 프레임워크, 에디터, IDE, 그리고 다양한 도구들이 내가 프로그래밍을 배우고, 연구하고, 일하는 데에 큰 도움이 되었다.미미한 수준이긴 하지만 몇 가지 오픈 소스 프로젝트에 기여한 적도 있다. 메인테이너로서 이끌어 가는 프로젝트도 있다. 얼굴도 모르는 사람들이 모여 프로젝트를 만들고, 유지하고, 발전시키는 과정은 정말 신기하고, 참 놀라운 일이라 생각한다. 하지만 밝은 면만 있지는 않았다.# faker.js&gt; 여기서 언급하는 faker.js는 [커뮤니티에 의해 Fork 된 faker.js](https://www.npmjs.com/package/@faker-js/faker)가 아닌, Marak에 의해 관리되던 faker.js를 의미한다.JavaScript 진영에는 [faker.js](https://www.npmjs.com/package/faker) 라는 패키지가 하나 있다. 이 패키지는 이름, 이메일, 날짜, 텍스트 등 다양한 카테고리에 대한 더미 데이터를 만들어 주는데, 이러한 특징으로 테스트나 프로토타입에 필요한 Mock 데이터를 만들 때 많이 사용되곤 한다.많은 사람이 애용했고, 그만큼 인기도 높았다. 글을 처음 썼던 22년 1월 기준으로 NPM에서 1주일에 3백만 건에 가까운 다운로드가 이루어지고 있을 정도였다.![Faker GitHub](/images/220116/faker-github.png) _[faker.js GitHub (현재는 404)](https://github.com/marak/Faker.js/)_그리고 이 오픈 소스는 현재 존재하지 않는다[^1].[^1]: [left-pad 사건](https://www.theregister.com/2016/03/23/npm_left_pad_chaos/) 이후 NPM에서 완전히 리포지토리를 삭제할 수는 없기에, 이전 버전을 설치해 사용할 수는 있다.## 전말&lt;iframe style=&quot;width: 100%; aspect-ratio: 16 / 9;&quot; src=&quot;https://www.youtube.com/embed/R6S-b_k-ZKY?si=WkiGufD1wxXPQTQ5&quot; title=&quot;YouTube video player&quot; frameborder=&quot;0&quot; allow=&quot;accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share&quot; referrerpolicy=&quot;strict-origin-when-cross-origin&quot; allowfullscreen&gt;&lt;/iframe&gt;요약하자면 이렇다.1. faker.js 메인테이너인 Marak Squires 라는 사람이 있다.2. Marak은 2020년 9월 경 사제 폭탄을 제조하다 아파트와 함께 재산을 대부분 잃어버렸다.3. 이후 노숙자 생활을 전전하며 돈에 민감해지게 된다.4. 동년 11월, 오픈 소스와 관련해 더 이상 기업에 대가 없이 기술 지원을 하지 않겠다고 선언한다.5. 2022년 1월, Marak은 결국 faker.js를 삭제했고, 이후 GitHub에 의해 계정을 차단당하게 된다.## 내 생각Marak 입장이 이해는 된다. 크든 작든 많은 기업들이 오픈 소스 혜택을 받고 있다. 그러나 그에 대한 대가는 거의 없다시피 한데, 이는 오픈 소스를 관리하는 메인테이너 입장에서 굉장히 불공평한 일이 아닐 수 없다. 심지어 버그가 생겨도 이를 무료로 해결해 주길 기대하는 기업들까지 있다.다만 기업 역시 나름 이유가 있을 것이다. 오픈 소스를 무료로 사용하는 것은 어떻게 보면 당연하다. 괜히 비즈니스 모델 캔버스에서 &quot;비용&quot;과 &quot;수익&quot; 항목이 큰 부분을 차지하는 것이 아니다. 오픈 소스 기여는 기업에 이익이 되지 않을 수 있다. 되려 불필요한 비용이 될 가능성이 높다. 굳이 이런 위험을 감수할 필요가 있을까.레딧을 보니 여러 의견이 엇갈린다. Marak은 극단적이었으나 이해가 불가능하지는 않다. 그러나 이는 오픈 소스 생태계에 큰 타격을 주었다.무엇이 옳고 그른지는 모르겠지만, 적어도 이 사건은 내게 오픈 소스 생태계에 대한 어두운 면을 알려주었다. 오픈 소스는 당연하지 않으며, 또 필요에 따라 무기로 사용될 수도 있다.# 마치며오픈 소스는 공짜가 아니다. 지속 가능한 미래를 위해 결국 누군가 지속적으로 리소스를 투입해야 한다. 우리는 항상 이를 인지하고 있어야 하며, 이를 그저 해프닝으로 취급해 버린다면 오픈 소스 생태계는 결국 무너질 것이다.이러한 상황을 피하고자 우리는 무엇을 할 수 있을까. 난 가장 쉽고 빠른 방법을 선택했다. 후원 말이다. 금액이 크지는 않지만 Evan You와 core-js 팀을 후원하고 있다. 이러한 작은 기여들이 모여 결국 더 나은 오픈 소스 생태계를 만들어 낼 수 있기를 희망한다.</content:encoded>
            <category>Experience</category>
        </item>
    </channel>
</rss>