구글 안티그래비티(agy) CLI를 써보다 마주친 오류 7가지를 직접 재현해서 원인과 조치를 정리한 글입니다. command not found 는 PATH에 ~/.local/bin 이 빠진 문제였고, 로그인 전에 헤드리스로 돌리면 authentication timed out 이 나서 대화형으로 먼저 로그인해야 했습니다. 모델을 고를 때 --effort 값을 안 주면 거부당하고, 503은 20~30초 후 재시도하면 풀렸습니다. 가장 까다로웠던 건 파일 쓰기 권한 거부인데, settings.json 에 허용 규칙을 넣어도 경로가 심볼릭 링크로 풀리는 형태(/tmp vs /private/tmp)와 안 맞으면 조용히 거부되면서 종료 코드는 0이 나왔습니다. 성공과 거부를 가르는 신호가 종료 코드가 아니라 출력 문구와 실제 파일 생성 여부라는 게 핵심입니다. 오류 7가지 한눈에 증상 | 원문 문구(일부) | 종료코드 | 조치 command not found | zsh:1: command not found: agy | 127 | PATH 추가 로그인 타임아웃 | Error: authentication timed out | 1 | 대화형 로그인 먼저 not logged into 로그 | You are not logged into... | 0 | 로그가 아니라 출력으로 판정 모델 선택 거부 | requires --effort | 1 | --effort high/low 지정 서버 503 | UNAVAILABLE (code 503) | 1 | 20~30초 후 재실행 파일 쓰기 거부 | so it was auto-denied | 0 | 허용 규칙 경로를 실경로로 한국어 응답 안 됨 | 영어 질문에 영어로 답 | - | AGENTS.md로 지시 권한 거부는 종료 코드로 못 잡는다 agy -p "현재 폴더에 hello.txt 파일을 만들고 hi 라고 써줘" # 출력: ...so it was auto-denied... # 종료 코드 0, hello.txt는 생성되지 않음 settings.json 에 /tmp/agy-try2 를 허용해도 실제로는 /private/tmp/agy-try2 로 풀려 있어 계속 거부됐고, 실경로로 바꾸고서야 파일이 만들어졌습니다. 전체 과정과 실패 사례는 원글에 정리했습니다: https://blog.wonizz.com/2026/10/03/antigravity-cli-errors/?utm_source=velog&utm_medium=community&utm_campaign=antigravity-cli-errors&utm_content=daily
0. 들어가며 지난 글에서는 여러 오픈소스 Agent Framework의 실제 실행 코드를 따라가며 Agent Loop가 어떤 단위로 반복되고, 각 프레임워크가 그 실행 결과를 어떻게 표현하는지 살펴봤습니다. 이번 글에서는 여기서 한 단계 더 들어가 누가 Agent의 Control Flow를 소유하는지 살펴보겠습니다. Model이 Tool을 호출하거나 다른 Agent를 선택하면 그 결정이 그대로 다음 실행으로 이어지는지, Framework의 Runner가 별도의 상태 전이를 결정하는지, 또는 LangGraph처럼 처음부터 Graph Runtime이 실행 가능한 Node와 경로를 관리하는지 실제 코드를 따라가 보겠습니다. 특히 OpenAI Agents SDK의 Agent Loop와 LangGraph의 Graph Runtime을 중심으로 비교 하고, Google ADK와 PydanticAI처럼 두 접근이 겹쳐 있는 구조도 함께 살펴보겠습니다. 이번 글에서는 OpenAI Agents SDK만 먼저 보겠습니다. 전체 구조 요약 Model + Runtime + Runner 구조: Control Flow의 각 단계를 역할별로 분담 Control Flow 단계 주체 무엇을 결정하는가 제품에서의 의미 Action Decision Model 현재 Context를 바탕으로 Tool 호출, Handoff 요청, 응답 생성 중 다음 행동을 선택 Agent가 현재 상황에 맞는 다음 행동을 선택 Output Interpretation Runtime Model Output을 현재 실행 환경에서 어떤 작업으로 처리할지 해석 Model Output을 바로 실행하지 않고 Approval, Guardrail, 현재 실행 상태 등의 정책을 적용 Transition Resolution Runtime Tool과 Side Effect 처리 결과를 바탕으로 이번 Turn을 어떤 상태로 끝낼지 결정 승인 대기, 실행 중단, Final Output, Agent 전환 등의 상태를 제품에서 명시적으로 관리 Loop Advancement AgentRunner 확정된 Control State를 실제 다음 실행에 반영 같은 Agent로 다음 Turn을 진행하거나, Agent를 교체하거나, 실행을 중단·재개·종료 1. Control Flow가 무엇인가요? 먼저 이 글에서 말하는 Control Flow 가 무엇인지부터 정리해보겠습니다. Control Flow는 Agent 분야에서 새로 등장한 개념이 아니라 프로그래밍 언어와 컴퓨터과학에서 오래전부터 사용해 온 개념이라고 하는데요. NIST의 Dictionary for Information Systems는 ISO의 정의를 인용해 Control Flow를 다음과 같이 설명합니다. “an abstraction of all possible paths that an execution sequence may take through a program.” 프로그램의 실행 시퀀스가 따를 수 있는 모든 가능한 경로의 추상화 가장 단순한 프로그램은 위에서 아래로 순서대로 실행되지만, 실제 프로그램에는 조건문과 반복문, 함수 호출, 예외 처리 같은 구조가 들어갑니다. 예를 들어 다음 코드에서는 is_valid 값에 따라 실행할 코드가 달라집니다. if is_valid: process() else: reject() 여기서 중요한 것은 process() 와 reject() 가 무엇을 하는지가 아니라, 둘 중 무엇을 다음에 실행할지를 결정하는 구조 입니다. 이것이 Control Flow입니다. Agent에서도 같은 질문을 할 수 있습니다. Model이 응답을 생성한 뒤 Agent는 다음에 무엇을 해야 할까요? Tool 실행 같은 Model을 다시 호출 다른 Agent에게 실행을 넘기기 (Handoff) Human Approval을 기다리며 멈추기 (HITL) 충분한 결과가 만들어졌다면 실행 종료 즉 Agent의 Control Flow는 현재 실행 상태에서 다음에 어떤 작업을 실행할지 결정하고, 그 실행을 다음 단계로 연결하는 방식 입니다. 예를 들어 전형적인 Agent Loop에서는 Model이 Tool Call을 생성하면 Tool을 실행하고, 그 결과를 다시 Model에 전달합니다. 반면 Graph 기반 Runtime에서는 미리 정의된 Node와 Edge, 현재 State를 바탕으로 다음에 실행할 Node를 결정할 수 있습니다. 이 두 구조는 모두 Model과 Tool을 사용할 수 있지만, 다음 실행 경로를 결정하는 방식이 다릅니다. 그래서 이번 글에서 Control Flow를 살펴볼 때에는 크게 세 가지 축에서 나눠서 살펴보고자 합니다. Action Decision 현재 상황에서 Tool을 호출할지, 답변할지, 다른 Agent에게 넘길지 누가 선택하는가 Transition Resolution Model이나 Tool의 실행 결과를 보고 다음 상태를 누가 확정하는가 Execution Topology 애초에 어떤 실행 경로가 존재할 수 있는지를 어디에서 정의하는가 1. Agent Loop의 Control Flow — OpenAI Agents SDK 이제 실제 코드 수준에서 Control Flow를 살펴보겠습니다. 지난 글에서는 OpenAI Agents SDK의 한 Turn이 끝나면 결과가 NextStepRunAgain, NextStepHandoff, NextStepInterruption, NextStepFinalOutput 중 하나로 정리된다는 것까지 살펴봤습니다. OpenAI Agents SDK는 Control Flow를 어떻게 구현하고 있을까요? AgentRunner : Loop를 돌리는 주체 먼저 가장 바깥쪽인 src/agents/run.py 부터 보겠습니다. Runner.run() 의 설명에는 Agent를 Final Output이 만들어질 때까지 Loop로 실행한다고 되어 있습니다. """ Run a workflow starting at the given agent. The agent will run in a loop until a final output is generated. The loop runs like so: 1. The agent is invoked with the given input. 2. If there is a final output, the loop terminates. 3. If there's a handoff, we run the loop again, with the new agent. 4. Else, we run tool calls (if any), and re-run the loop. """ Source: OpenAI Agents SDK · src/agents/run.py · Runner.run() · https://raw.githubusercontent.com/openai/openai-agents-python/main/src/agents/run.py Handoff가 발생하면 새 Agent로 다시 Loop를 실행하고, 그 외 Tool Call이 있다면 Tool을 실행한 뒤 다시 Loop를 돈다는 구조입니다. 한 번의 Turn은 하나의 AI invocation과 그 과정에서 발생하는 Tool Call을 묶은 단위 입니다. 실제 실행부를 보면, AgentRunner 가 Turn을 직접 세면서 run_single_turn() 을 호출합니다. current_turn += 1 ... turn_result = await run_single_turn(...) Source: OpenAI Agents SDK · src/agents/run.py · AgentRunner.run() 간단히 도식화해볼까요? 하지만 AgentRunner 가 직접 Model을 호출하고 결과를 보고 Tool 실행 여부까지 판단하는 것은 아닙니다. Runner는 말 그대로 '실행시키는 역할'이죠. 실제 처리는 run_single_turn() 아래에서 여러 단계로 나뉩니다. run_single_turn() - 호출된 결과 처리 src/agents/run_internal/run_loop.py 의 run_single_turn() 으로 내려가 보겠습니다. 이 함수는 먼저 현재 Agent를 기준으로 System Prompt, Tool, Handoff, Output Schema 등을 준비합니다. 우선 현재 execution_agent에서 System Prompt와 Prompt 설정을 가져오고, 사용할 Handoff와 Output Schema를 결정합니다. system_prompt, prompt_config = await gather_with_cancel( execution_agent.get_system_prompt(context_wrapper), execution_agent.get_prompt(context_wrapper), ) handoffs = await get_handoffs(execution_agent, context_wrapper) output_schema = get_output_schema(execution_agent) Source: OpenAI Agents SDK · src/agents/run_internal/run_loop.py · run_single_turn() · 2026-10-02 · line 1941-1958 중간에 resolve_tool_name_collisions() 도 호출합니다. 현재 Turn에서 사용할 Tool과 Handoff가 Model 입장에서 같은 이름으로 노출될 수 있기 때문에, run_config.tool_name_collision_policy에 따라 이름 충돌을 먼저 정리하는 역할입니다. 이 단계가 끝나면 이번 Model Call에 전달할 Tool / Handoff 목록이 확정됩니다. 이후 현재까지의 입력과 생성된 Item을 Provider Input으로 만든 다음 get_new_response() 를 호출합니다. new_response = await get_new_response(...) ... return await get_single_step_result_from_response(...) Source: OpenAI Agents SDK · src/agents/run_internal/run_loop.py · run_single_turn() get_new_response() 안에서는 실제 model.get_response() 가 호출됩니다. 이 단계에서 다음 값들이 전달됩니다. system_instructions input model_settings tools output_schema handoffs Model은 이 정보를 보고 Text Response를 만들거나 Function Tool을 호출하고, Handoff에 해당하는 Tool을 호출하는 등의 선택을 할 수 있습니다. 여기까지 오면 Model이 ModelResponse 를 반환합니다. ModelResponse 는 먼저 ProcessedResponse 로 변환된다 그런데 Model 호출이 끝나면 run_single_turn() 은 ModelResponse 를 그대로 실행하지 않고, ProcessedResponse 라는 최종 결과물을 만듭니다. 다음 단계인 get_single_step_result_from_response() 에 넘기고, 여기서 가장 먼저 process_model_response() 를 호출합니다. processed_response = process_model_response( agent=item_agent, all_tools=all_tools, response=new_response, output_schema=output_schema, handoffs=handoffs, existing_items=pre_step_items, run_config=run_config, server_manages_conversation=server_manages_conversation, ... ) ... return await execute_tools_and_side_effects( ... new_response=new_response, processed_response=processed_response, ... ) Source: OpenAI Agents SDK · src/agents/run_internal/turn_resolution.py · get_single_step_result_from_response() · 2026-10-02 · line 2852-2909 이 함수가 하는 일은 Model Provider가 반환한 출력을 Agent Runtime이 이해하고 실행할 수 있는 의미 단위로 해석하는 것 이고 그 결과물이 바로 ProcessedResponse 라고 이해할 수 있습니다. Model Response + 추가 입력 실제 함수 signature부터 살펴보겠습니다. def process_model_response( *, agent: Agent[Any], all_tools: list[Tool], response: ModelResponse, output_schema: AgentOutputSchemaBase | None, handoffs: list[Handoff], existing_items: Sequence[RunItem] | None = None, run_config: RunConfig | None = None, server_manages_conversation: bool = False, server_managed_input_items: Sequence[Any] | None = None, allow_apply_patch_function_fallback: bool = True, ) -> ProcessedResponse: Source: OpenAI Agents SDK · src/agents/run_internal/turn_resolution.py · process_model_response() · 2026-10-02 · line 2138-2150 response 하나만 받는 것이 아니라 현재 Agent, 사용할 수 있는 Tool 전체, Handoff 목록, Output Schema, 이전에 생성한 Item, RunConfig까지 함께 받습니다. 그래야 Model이 출력한 ModelResponse 항목이 Runtime에서 무엇을 의미하는지 결정할 수 있기 때문 입니다. 예를 들어 Model이 다음 Function Call을 반환했다고 해보겠습니다. transfer_to_support(...) 얼핏 보면 그냥 함수 호출이지만, 실제로는 일반 Function Tool이 아닐 수 있습니다. 예를 들어 현재 Agent에 transfer_to_support 라는 Handoff가 등록되어 있다면 이 호출은 Tool 실행이 아니라 Agent를 교체하기 위한 Handoff 요청 으로 해석해야 합니다. 그래서 process_model_response() 는 먼저 현재 실행 환경을 바탕으로 lookup map을 만듭니다 (이외에도 Shell, Apply Patch, Code Interpreter, Programmatic Tool, Hosted MCP 등을 각각 찾아둡니다). handoff_map = {handoff.tool_name: handoff for handoff in handoffs} function_map = build_function_tool_lookup_map( [tool for tool in all_tools if isinstance(tool, FunctionTool)] ) custom_tool_map = { tool.name: tool for tool in all_tools if isinstance(tool, CustomTool) } computer_tool = next( (tool for tool in all_tools if isinstance(tool, ComputerTool)), None, ) Source: OpenAI Agents SDK · src/agents/run_internal/turn_resolution.py · process_model_response() · 2026-10-02 · line 2151-2184 그리고 response.output 을 하나씩 순회하면서 각 Output Item이 실제로 무엇인지 판별합니다. for output in response.output: output_type = get_mapping_or_attr(output, "type") ... Source: OpenAI Agents SDK · src/agents/run_internal/turn_resolution.py · process_model_response() · 2026-10-02 · line 2202-2210 특히 Function Call 처리 부분을 보면 이 함수의 역할이 명확하게 드러납니다. if is_handoff_tool_call(output, handoff_map): ... items.append(HandoffCallItem(raw_item=output, agent=agent)) handoff = ToolRunHandoff( tool_call=output, handoff=handoff_map[output.name], ) run_handoffs.append(handoff) else: lookup_key = get_function_tool_lookup_key_for_call(output) func_tool = function_map.get(lookup_key) if lookup_key is not None else None ... items.append( ToolCallItem( raw_item=output, agent=agent, ... ) ) functions.append( ToolRunFunction( tool_call=output, function_tool=func_tool, ) ) Source: OpenAI Agents SDK · src/agents/run_internal/turn_resolution.py · process_model_response() · 2026-10-02 · line 2755-2836 같은 ResponseFunctionToolCall 이라도 현재 Runtime에 어떤 Handoff와 Tool이 등록되어 있는지를 확인한 뒤 서로 다른 의미로 바꾸고 있습니다. Handoff 이름과 일치하면 ToolRunHandoff Function Tool과 일치하면 ToolRunFunction Tool을 찾지 못하면 설정에 따라 Error 처리 또는 ToolRunFunctionNotFound Apply Patch와 같은 특수 Tool이면 별도의 실행 타입 즉 ModelResponse 는 Model이 무엇을 출력했는지 를 나타내는 객체이고, ProcessedResponse 는 그 출력이 현재 Agent Runtime에서 무엇을 의미하는지 해석한 결과 라고 볼 수 있습니다. ProcessedResponse 정의 더 뜯어보기 ProcessedResponse 의 class 정의를 조금 더 보겠습니다. @dataclass class ProcessedResponse: new_items: list[RunItem] handoffs: list[ToolRunHandoff] functions: list[ToolRunFunction] computer_actions: list[ToolRunComputerAction] local_shell_calls: list[ToolRunLocalShellCall] shell_calls: list[ToolRunShellCall] apply_patch_calls: list[ToolRunApplyPatchCall] tools_used: list[str] mcp_approval_requests: list[ToolRunMCPApprovalRequest] interruptions: list[ToolApprovalItem] function_tools_not_found: list[ToolRunFunctionNotFound] = ... custom_tool_calls: list[ToolRunCustom] = ... Source: OpenAI Agents SDK · src/agents/run_internal/run_steps.py · ProcessedResponse · 2026-10-02 · line 110-124 보면 크게 중요한 두 부류가 있는데요. 첫 번째는 new_items 입니다. MessageOutp
Mutation Arcade의 다음 버전을 생각하면서 가장 먼저 바뀐 것은 게임의 구조였습니다. Web Beta에서는 여러 개의 Arcade를 메뉴에서 선택하면 됐습니다. 하지만 다음 버전에서는 플레이어 캐릭터가 직접 공간을 돌아다니고, 무언가를 발견하고, 상호작용한 뒤 Arcade를 플레이하는 구조를 만들고 싶었습니다. 그러자 바로 문제가 생겼습니다. 이 미니게임들은 왜 같은 공간에 존재하는가? 단순히 여러 Arcade를 하나의 맵 안에 배치하는 것만으로는 하나의 게임처럼 느껴지기 어려웠습니다. 그래서 저는 미니게임을 먼저 이어붙이는 대신, 그 미니게임들이 존재할 수 있는 하나의 세계부터 만들기로 했습니다. 단순한 연결로는 부족했습니다 처음에는 이렇게 만들 수도 있었습니다. AREA A → ARCADE 1 → AREA B → ARCADE 2 → AREA C 플레이어가 이동하면서 차례대로 여러 미니게임을 플레이하는 방식입니다. 기능적으로는 충분히 가능합니다. 하지만 이 구조에는 한 가지 문제가 있었습니다. 왜 플레이어가 이 게임들을 해야 하는지 설명되지 않습니다. 단순히 다음 구역으로 가기 위해 미니게임 하나를 플레이하고, 또 다음 구역에서 다른 미니게임을 플레이하는 방식이라면, 결국 Web Beta의 메뉴를 공간 안으로 옮겨놓은 것과 크게 다르지 않을 수도 있었습니다. 메뉴 버튼 대신 맵 안의 장치를 누르는 것뿐이라면 굳이 캐릭터와 공간을 만든 의미가 약해집니다. 공간을 만들면 이유도 필요해집니다 플레이어가 직접 움직이는 순간부터 게임은 계속 질문을 만들었습니다. 왜 이곳에 있는가. 이곳은 어떤 장소인가. 왜 Arcade 장치가 존재하는가. 왜 정상적인 Arcade가 아니라 Mutation이 발생하는가. 다음 공간으로 이동하는 이유는 무엇인가. 이전에는 각 Arcade 내부의 규칙만 만들면 됐습니다. 하지만 이제는 Arcade 밖의 상황까지 설명해야 했습니다. WEB BETA ARCADE ARCADE ARCADE 각 게임이 독립적 ↓ NEXT VERSION WORLD ├─ PLACE ├─ PLAYER ├─ EVENT ├─ ARCADE └─ MUTATION 모든 요소가 연결됨 이때부터 Mutation Arcade는 단순한 미니게임 컬렉션과는 다른 문제를 풀기 시작했습니다. 먼저 이야기를 만들어보기로 했습니다 그렇다고 처음부터 게임 안에 긴 스토리를 넣으려 했던 것은 아닙니다. 먼저 필요했던 것은 게임 전체를 설명할 수 있는 기준이었습니다. 플레이어가 누구인지, 어떤 상황에서 게임이 시작되는지, 어떤 공간을 지나게 되는지, 그 안에서 무엇이 벌어졌는지, Mutation이 이 세계와 어떤 관계를 가지는지. 이런 것들이 정해져야 맵도 만들 수 있고, 각 Stage의 역할도 정할 수 있다고 생각했습니다. 그래서 바로 Unity에서 맵을 만들기 시작하는 대신, 먼저 전체 이야기를 정리하기 시작했습니다. 이번에는 이야기를 꽤 길게 만들었습니다 여기서 선택한 방식은 단순한 게임 시놉시스를 작성하는 것보다 조금 더 길었습니다. 저는 Mutation Arcade의 전체 이야기를 하나의 소설처럼 먼저 만들어보기로 했습니다. 게임을 만들기 전에 내부 원작에 가까운 이야기를 하나 만들어두는 방식입니다. 물론 이 이야기를 실제로 출간하려는 것은 아니었습니다. 사용자에게 그대로 보여줄 텍스트를 먼저 쓰는 것도 아니었습니다. 목적은 따로 있었습니다. 게임을 만들 때 계속 참고할 수 있는 하나의 원본 이야기를 확보하는 것 이었습니다. 왜 굳이 소설 형태였을까 간단한 설정 문서만으로도 세계관은 만들 수 있습니다. 하지만 공간과 사건을 조금 더 구체적으로 생각하려면 장면 단위로 상상해볼 필요가 있었습니다. 누가 어디에서 무엇을 보고, 어떤 사건이 먼저 발생하고, 그다음에 어디로 이동하며, 그 공간에는 어떤 흔적이 남아 있는지. 이런 내용을 이야기 형태로 풀어놓으면 단순한 설정 목록보다 게임 장면을 떠올리기가 쉬웠습니다. SETTING + CHARACTER + EVENT + PLACE + SEQUENCE ↓ INTERNAL STORY 그리고 이 이야기는 나중에 게임으로 다시 분해할 수 있습니다. 이야기를 만든 뒤 다시 게임 구조로 바꾸면 됩니다 소설 형태로 만든다고 해서 게임을 텍스트 중심으로 만들려는 것은 아니었습니다. 오히려 반대였습니다. 먼저 이야기 전체를 만들어놓고, 그 안에서 게임에 필요한 부분만 다시 추출하려고 했습니다. 예를 들면, 어떤 사건이 발생하는가 어느 공간에서 발생하는가 플레이어는 무엇을 발견하는가 어떤 상호작용이 필요한가 어디에서 Arcade가 등장하는가 어떤 상태가 다음 구간으로 이어지는가 같은 요소들입니다. 즉, STORY → GAME STRUCTURE 로 변환하는 방식입니다. 이렇게 하면 각 Stage가 단순히 다음 미니게임으로 이동하는 통로가 아니라, 전체 이야기 안에서 각자의 역할을 가질 수 있다고 생각했습니다. Arcade에도 이유를 만들고 싶었습니다 이전 Web Beta에서 Arcade는 그 자체로 목적이었습니다. 게임을 선택하고, 게임을 플레이하면 됩니다. 하지만 새로운 구조에서는 Arcade도 세계 안에 존재해야 했습니다. 그렇다면 왜 이 Arcade를 만나게 되는가? 왜 지금 플레이해야 하는가? 플레이 결과가 무엇과 연결되는가? 같은 질문이 필요합니다. Arcade를 단순한 콘텐츠 하나가 아니라 이야기 속 사건이나 상황과 연결하면, 플레이어 입장에서도 미니게임 하나를 선택해서 플레이하는 것이 아니라 게임 세계 안에서 어떤 상황에 마주쳤다는 느낌 을 만들 수 있을 것이라고 생각했습니다. Mutation도 다시 설명해야 했습니다 세계관을 만들기 시작하자 Mutation 역시 다시 보게 됐습니다. Web Beta에서 Mutation은 게임 플레이를 바꾸는 시스템이었습니다. Blocks의 형태가 변하고, Breaker의 반사가 달라지고, Snake의 조작이 흔들리는 식입니다. 그런데 하나의 세계를 만들려면 여기서 질문이 하나 더 생깁니다. 왜 Mutation이 발생하는가? 게임 시스템 관점에서는 그냥 재미를 위한 규칙 변화라고 말할 수 있습니다. 하지만 게임 세계 안에서는 다른 설명이 필요합니다. 이 질문 때문에 Mutation도 단순한 게임 시스템을 넘어 세계관 안에서 다시 정의할 필요가 생겼습니다. 다만 이 부분은 이후 플레이 경험과 직접 연결되는 내용이기 때문에 여기서는 구체적으로 공개하지 않겠습니다. 먼저 세계를 만들고, 그 안에 게임을 넣었습니다 결과적으로 새로운 Mutation Arcade를 준비하는 순서는 Web Beta 때와 완전히 달라졌습니다. 처음에는: IDEA → CODE → PLAY 에 가까웠습니다. 이번에는: WORLD ↓ STORY ↓ PLACE ↓ EVENT ↓ GAME STRUCTURE ↓ IMPLEMENTATION 순서로 접근하기 시작했습니다. 미니게임들을 먼저 만들고 나중에 연결하는 것이 아니라, 먼저 하나의 세계를 만든 뒤 그 안에서 각각의 Arcade가 어떤 역할을 해야 하는지를 정하는 방식입니다. 세계를 만들고 나니 다음 질문은 공간이었습니다 이야기의 큰 흐름이 생기자 다음에는 그것을 실제 플레이 공간으로 바꿔야 했습니다. 어떤 사건이 어디에서 일어나는지, 플레이어는 어떤 순서로 이동하는지, 어떤 공간에서 무엇을 발견하는지, Arcade는 어느 지점에 등장하는지. 이제 이야기를 실제 게임의 Stage와 Map으로 변환하는 작업이 필요해졌습니다. 다음 글에서는 전체 이야기를 내부 원작처럼 먼저 만들어둔 이유와, 그 이야기를 어떤 방식으로 Mutation Arcade의 기준 자료로 사용했는지 조금 더 정리해보려고 합니다.
(출처: arXiv:2609.36651, Figure 1 — 저자 공개 리포지토리 docs/assets/focusvtc_introduction.png) 논문 제목 : FocusVTC: Efficient and High-Performance Visual Text Compression with Adaptive Resolution 저자 : FangZhi Zhong, Xuerui Qiu, Yuqi Pan, Ya Liu, Shaowei Gu, Bo Xu, Guoqi Li (소속은 arXiv 초록 페이지·HF 페이퍼 카드·GitHub README 어디에도 표기되어 있지 않아 생략합니다.) 공개일 : 2026년 9월 29일 (arXiv 제출), Hugging Face Papers 등재 9월 30일 arXiv : https://arxiv.org/abs/2609.36651 (cs.CV) 코드 : https://github.com/fangzhi-zhong/FoucsVTC (리포지토리 이름의 Foucs 오타는 원본 그대로입니다) 모델 : https://huggingface.co/zfz04/FocusVTC (9B, BF16, base: Qwen/Qwen3.5-9B) 데이터셋 : REL-CoT — https://www.modelscope.cn/datasets/zhongfangzhi/REL-CoT 분류 태그 : Visual Text Compression · Document Understanding · Long-Context · Vision-Language · Tool Use 🔖 TL;DR (한눈에) 롱컨텍스트를 이미지로 압축하는 VTC(Visual Text Compression)의 고질적 딜레마 를 정면으로 다룹니다. 텍스트를 페이지 이미지로 렌더링하면 입력 토큰이 줄지만, 해상도를 고정해 두면 "저DPI = 토큰 절약 + 판독 불가" vs "고DPI = 판독 가능 + 무관 영역에 토큰 낭비"의 상충에서 벗어날 수 없습니다. FocusVTC의 답은 적응형 해상도(adaptive resolution) 입니다. 문서 전체는 저DPI 축소 페이지로 훑고, 근거가 있을 것 같은 영역만 zoom_region 도구로 고DPI 원본에서 잘라 와 추론 도중에 새로운 시각 관측으로 투입 합니다. 학습은 2단계입니다. 1 추론 과정을 페이지 번호·바운딩 박스에 묶은 REL-CoT 29.4K 데이터로 다해상도 REL-SFT, 2 도구 보조 GRPO 로 "언제 확대할지"와 "확대 결과를 어떻게 쓸지"를 강화학습. 별도의 continual-pretraining 단계는 없습니다. 성능: RULER v1(72 DPI)에서 2.9× 입력 압축으로 87.4점 — 같은 압축률대(3.0×)의 Glyph 57.5점을 크게 앞섭니다. LongBench에서는 텍스트 입력 백본보다 더 높은 56.40 (백본 55.86)을 기록했습니다. 압축 모델의 숙제인 일반 멀티모달 능력 퇴화도 일어나지 않았습니다 (MMMU 65.12 → 66.73, MME 2424.02 → 2457.62). MRCR 지연 평가에서는 텍스트 입력 대비 2.79× 온라인 종단간 속도 향상 . 한 줄 요약 : "문서를 통째로 고해상도로 읽을 필요 없다 — 작게 훑고, 필요한 데만 확대해서 읽으면 압축률과 정확도를 동시에 가져갈 수 있다"는 것을 9B VLM 하나로 실증한 연구. 📄 초록(Abstract) 한국어 번역 대형 언어 모델의 롱컨텍스트 추론은 상당한 연산량과 메모리를 요구한다. 시각 텍스트 압축(Visual Text Compression, VTC)은 텍스트를 이미지로 렌더링해 입력 길이를 줄이지만, 고정된 해상도로 렌더링하는 방식은 압축률과 성능 사이의 상충을 강제한다. 낮은 DPI는 토큰을 절약하는 대신 가독성을 희생하고, 높은 DPI는 가독성을 확보하는 대신 정작 무관한 내용에까지 토큰을 소모한다. 본 논문은 FocusVTC 를 제안한다. FocusVTC는 일반적인 멀티모달 능력을 보존하면서 적응형 해상도를 통해 이 상충을 깨뜨린다. 압축된 저DPI 전역 뷰를 선택적 영역 확대(selective region enhancement) 와 결합하고, 확대된 뷰를 진행 중인 추론 과정에 통합한다. 이를 위해 29.4K 규모의 고품질 Reasoning-Evidence Localization(REL) chain-of-thought 예제(REL-CoT) 를 구축하여 추론 흐름을 페이지 인덱스와 바운딩 박스에 연결한다. 다해상도 REL 지도 미세조정( REL-SFT )이 관련 영역을 지역화하도록 모델을 학습시키고, Group Relative Policy Optimization(GRPO) 이 언제 해상도를 높일 것인지와 그 결과로 얻은 관측을 어떻게 활용할 것인지를 학습하되, 별도의 continual-pretraining 단계를 두지 않는다 . RULER v1에서 72 DPI 기준, FocusVTC는 (도구 관측을 포함한) 2.9× 입력 압축에서 87.4점을 기록하며, 3.0× 입력 압축에서 57.5점인 Glyph를 능가한다. LongBench에서는 자신의 텍스트 입력 백본을 상회하고(56.40 대 55.86), MRCR 매크로 평균을 13.91점 향상시키며, VTCBench에서 51.19의 매크로 평균을 달성한다. MRCR 지연(latency) 평가에서는 텍스트 입력 대비 2.79× 온라인 종단간 속도 향상을 보인다. 또한 일반 멀티모달 능력이 보존되어 MMMU는 65.12에서 66.73으로, MME는 2424.02에서 2457.62로 상승한다. 요약 정리. VTC는 "긴 텍스트를 이미지로 바꿔서 토큰 수를 줄이자"는 접근인데, 지금까지는 렌더링 해상도를 한 번 정하면 그걸로 끝이었습니다. 이 논문은 그 고정 해상도 가정 자체를 버립니다. 저DPI로 전체를 보고, 근거가 있는 영역만 고DPI로 다시 들여다보는 능동적 읽기(active reading) 정책을 모델이 직접 학습합니다. 핵심 기여는 (1) 추론-근거-좌표를 하나의 CoT로 묶은 REL-CoT 데이터, (2) 다해상도 SFT + 도구 보조 GRPO의 2단계 레시피, (3) 압축률을 유지하면서 텍스트 백본과 일반 멀티모달 성능을 모두 지켜냈다는 실증입니다. 🧩 왜 이 문제가 중요한가 (배경) 롱컨텍스트 문제를 "픽셀로 푸는" 흐름은 지난 1년간 Document AI에서 가장 뜨거운 줄기였습니다. DeepSeek-OCR 계열이 "텍스트 토큰 대신 비전 토큰"이라는 아이디어를 대중화한 뒤로, 표 하나를 64토큰으로 압축하거나 레이아웃을 시각 토큰으로 접어 넣는 시도가 쏟아졌습니다. 발상은 단순하고 강력합니다. 텍스트 1,000토큰 분량의 한 페이지를 이미지 수백 토큰으로 넣을 수 있다면, 컨텍스트 윈도우와 KV 캐시를 그만큼 아낄 수 있다 는 것입니다. 그런데 실전에 쓰려고 하면 바로 벽을 만납니다. 압축률을 결정하는 변수가 사실상 렌더링 DPI 하나 인데, 이 값은 문서 전체에 일괄 적용됩니다. DPI를 낮추면 : 토큰은 확실히 줄어듭니다. 하지만 작은 글자, 각주, 표 셀 안의 숫자, 도면 레이블이 뭉개집니다. 바로 QA 정확도가 꺾이는 구간입니다. DPI를 높이면 : 글자는 읽힙니다. 그런데 100페이지 문서에서 정답과 관련된 부분이 두 문단뿐이라면, 나머지 98페이지를 또렷하게 렌더링하는 데 쓴 토큰은 전부 낭비입니다. 즉 압축률과 판독성이 전역적으로 묶여 있다 는 게 문제의 본질입니다. 사람이 두꺼운 보고서를 읽을 때를 생각해 보면 이상한 제약입니다. 우리는 목차와 페이지 레이아웃을 흐릿하게 훑다가, 숫자를 확인해야 할 때만 눈을 가까이 가져갑니다. 해상도를 전역 상수가 아니라 추론 중에 내리는 국소적 결정 으로 바꾸는 것 — FocusVTC가 겨냥하는 지점이 정확히 여기입니다. 이 변화는 단순한 효율 개선이 아닙니다. 모델이 "어디를 확대할지" 고르려면 근거의 위치를 알아야 하고, 근거 위치를 안다는 것은 곧 답의 출처를 좌표로 지목할 수 있다는 뜻입니다. 압축 연구가 자연스럽게 근거 귀속(evidence attribution) 연구와 만나는 셈이고, 이는 문서 QA를 실무에 올릴 때 가장 많이 요구받는 기능 중 하나입니다. 🔬 방법론 (출처: arXiv:2609.36651, 방법론 개요 그림 — 저자 공개 리포지토리 docs/assets/focusvtc_overview.png) 1) 기본 설정: 두 겹의 페이지 FocusVTC는 같은 문서를 두 가지 해상도로 동시에 준비 합니다. 공개된 평가 파이프라인 기준으로 저해상도 72 DPI / 고해상도 144 DPI 쌍입니다. 디스크 상에서도 dpi_72/ 와 dpi_144/ 두 트리로 나란히 유지되고, 두 트리의 파일명과 페이지 레이아웃이 정확히 일치 해야 합니다. 모델의 초기 프롬프트에 들어가는 것은 저해상도 페이지뿐 입니다. 고해상도 페이지는 디스크에 있지만 컨텍스트에는 없습니다 — 모델이 요청할 때만 지연 로딩(lazy load)됩니다. 이 설계가 압축률을 지켜 주는 핵심입니다. 렌더링 폰트는 DejaVu Sans 로 고정되어 있고, 학습 데이터 쪽은 더 넓게 48 / 60 / 72 / 84 / 96 / 120 / 144 — 7가지 DPI 뷰 를 모두 렌더링합니다. 한 문서의 모든 DPI 변형이 train/val 중 한쪽에만 들어가도록 metadata.base_id 기준으로 분할하는데, 이는 같은 내용이 해상도만 바꿔 양쪽에 걸치는 누출을 막기 위한 장치입니다. 2) zoom_region: 추론 도중에 해상도를 올리는 도구 모델이 쓰는 도구는 딱 하나입니다. { "name": "zoom_region", "description": "Crop a potentially unreadable region from a document page and return it as a new image.", "parameters": { "page": { "type": "integer", "description": "1-based page number." }, "bbox_2d": { "type": "array", "items": {"type": "number"}, "minItems": 4, "maxItems": 4, "description": "[x1, y1, x2, y2] normalized to [0, 1000]." } } } 인터페이스가 의도적으로 단순합니다. 1-based 페이지 번호 와 [0, 1000]으로 정규화된 바운딩 박스 네 값만 내놓으면, 런타임이 해당 저DPI 페이지에 대응하는 고DPI 페이지( dpi_72/page_k → dpi_144/page_k )를 찾아 그 영역을 잘라 다음 턴의 시각 관측으로 되돌려 줍니다 . 여기서 중요한 포인트 두 가지입니다. 좌표 정규화가 해상도 독립성을 만듭니다. [0, 1000] 스케일로 말하기 때문에, 모델은 저DPI 뷰에서 본 위치를 그대로 고DPI 좌표계로 옮길 수 있습니다. 모델이 두 해상도의 픽셀 치수를 알 필요가 없습니다. 크롭은 모델이 아니라 호스트 런타임이 수행합니다. 모델 카드가 명시적으로 경고하듯, 순수 transformers.generate() 만으로는 도구가 실행되지 않습니다. 도구 호출을 파싱하고 박스를 검증하고 크롭을 수행해 다시 붙여 주는 외부 게이트웨이가 반드시 필요합니다. 짝이 되는 고해상도 페이지가 없으면 명시적으로 실패한 도구 관측 이 되돌아옵니다(조용히 저해상도 이미지로 대체하지 않습니다). 평가 시 기본 정책은 최대 8회 줌 호출, 총 9턴, 턴당 생성 2,048토큰, 전체 트래젝토리 10,240토큰 입니다. 이 트래젝토리 예산에는 생성 텍스트뿐 아니라 되돌아온 크롭 이미지의 토큰까지 포함 해서 셉니다 — 즉 "확대해서 읽은 비용"이 압축률 계산에 정직하게 반영됩니다. 초록이 압축률을 보고할 때 "도구 관측을 포함한 2.9×"라고 단서를 붙인 이유입니다. 3) REL-CoT: 추론을 좌표에 묶는 데이터 적응형 읽기를 학습시키려면 "이 질문의 근거는 몇 페이지 어디에 있다"는 지도가 필요합니다. 저자들이 만든 것이 REL-CoT(Reasoning-Evidence Localization chain-of-thought) , 규모는 29.4K 예제 입니다. 구조적으로 각 예제는 추론 트레이스를 근거 페이지 인덱스 + 바운딩 박스 에 연결합니다. 좌표 규약은 도구와 동일하게 1-based 페이지, [0, 1000] 정규화 박스 로 통일되어 있습니다. 학습 타깃은 <think>...</think> 블록과 답변 텍스트를 포함하는 형태입니다. 데이터 구축 방식에서 눈여겨볼 점은, 공개된 렌더러가 새로운 지도 신호를 모델로 생성하지 않는다 는 것입니다. 원문 텍스트를 7가지 DPI로 다시 렌더링하면서 기존의 추론과 정답은 보존하고, 근거 박스와 페이지 참조만 새 레이아웃에 맞게 재매핑 합니다. 공개 범위 문서에서도 "자동 REL 주석 생성과 합성 학습 태스크 생성"은 릴리스에 포함되지 않는다고 분명히 적어 두었습니다. 즉 좌표 재매핑이 곧 해상도 증강 인 구조입니다. 같은 근거가 48 DPI에서는 어떻게, 144 DPI에서는 어떻게 보이는지를 동일한 의미 라벨 아래에서 학습하게 됩니다. 4) 1단계 — REL-SFT (다해상도 지도 미세조정) 백본은 Qwen3.5-9B 입니다. 이 단계의 목표는 "관련 영역을 지역화하는 능력"을 심는 것입니다. 공개 레시피에서 확인되는 설정들: 비전 인코더( model.visual )는 동결(freeze) 합니다. 시각 표현은 건드리지 않고 언어 측의 지역화·추론 능력을 올립니다. 랜덤 그라운딩 프롬프트 를 활성화해 좌표 지시 형식에 과적합되지 않게 합니다. 32K 토큰 패킹 으로 샘플을 묶고, FSDP2 로 분산 학습합니다. 패킹은 mRoPE 위치와 어텐션/DeltaNet/컨볼루션의 시퀀스 경계를 함께 공급합니다. Qwen3.5의 하이브리드 어텐션 구현 때문에 use_rmpad: false , sp_ulysses_degree: 1 이 요구됩니다. 기준 구성은 8 GPU 단일 노드 입니다. 다해상도 데이터를 한 매니페스트에 섞어 학습하므로, 모델은 "저DPI에서 뭉개진 글자를 보고도 그게 어디쯤 무슨 내용인지 추정하는" 감각을 갖게 됩니다. 이 감각이 곧 "어디를 확대해야 하는지" 판단의 기반이 됩니다. 5) 2단계 — 도구 보조 GRPO (언제·어떻게 확대할지 학습) SFT만으로는 부족합니다. 지역화를 배운다고 해서 호출 타이밍과 호출 효율 이 좋아지지는 않습니다. 쓸데없이 8번 다 쓰거나, 정작 필요할 때 안 쓰거나, 페이지 전체를 박스로 잡아 압축 이득을 날려버릴 수 있습니다. 이를 GRPO(Group Relative Policy Optimization) 로 교정합니다. 보상 설계 가 이 논문에서 가장 음미할 부분입니다. 보상은 세 축으로 구성됩니다. 보상 구성 요소 역할 답변 정확도(answer accuracy) 최종 정답의 정합성 구조적 포맷(structural format) <think> /도구 호출 스키마 등 출력 형식 준수 도구 품질(tool quality) 확대 행위 자체의 효율성 핵심 장치는 도구 보너스가 "완전히 정답일 때만" 열린다(gated on a fully correct answer) 는 점입니다. 틀린 답을 내면서 도구를 예쁘게 쓰는 것으로는 보상을 얻을 수 없습니다. 도구 사용이 목적이 되는 리워드 해킹을 원천 차단 하는 설계입니다. 그리고 열린 도구 보너스는 다음 네 가지에 의존합니다. 근거 IoU — 잡은 박스가 실제 근거와 얼마나 겹치는가 크롭 면적 — 필요한 만큼만 작게 잡았는가 (페이지 통째로 잡으면 압축 이득이 사라집니다) DPI — 해상도 상승폭. 네이티브 144→144 학습 케이스에서는 DPI 보너스가 0 으로 정의됩니다(올릴 해상도가 없으므로 공짜 점수를 주지 않습니다) 중복 호출 — 같은 영역을 반복 호출하는 낭비에 대한 페널티 근거 주석이 비어 있거나 없는 샘플은 근거 중첩 보너스를 받을 수 없습니다 . 즉 좌표 라벨이 있는 데이터에만 지역화 보상이 걸립니다. 학습 측 하이퍼파라미터(공개 기본값): 항목 값 프롬프트 배치 / PPO 미니배치 8 / 8 프롬프트당 롤아웃 수 4 PPO 마이크로배치 (GPU당) 1 초기 프롬프트 예산 8,192 토큰 응답 + 도구 관측 예산 10,240 토큰 총 에폭 1 저장·검증 주기 50 스텝 상호작용 예산 도구 호출 8회 + 최종 답변 1턴 학습률 문서상 예시 오버라이드로 5e-7 제시 (기본값으로 명시되진 않음) 인프라는 Ray 워커 + FSDP + 수정된 vLLM SPMD 백엔드이고, 기준 레시피는 역시 대용량 메모리 8 GPU 단일 노드를 가정합니다. 참조 환경은 PyTorch 2.10.0 / Transformers 5.16.1 / vLLM 0.19.1 / FlashAttention 2.8.3입니다. 마지막으로 학습과 평가의 정합성 을 챙긴 디테일이 하나 있습니다. 평가 게이트웨이가 GRPO 환경과 도구 파서, 박스 검증, 크롭 기하를 공유 하고, 커스텀 Qwen3.5 챗 템플릿이 과거 도구 턴의 reasoning을 보존 합니다. GRPO의 누적 토큰 트래젝토리와 추론 시 동작이 어긋나지 않게 맞춘 것입니다. 도구 사용 에이전트에서 train/serve skew는 흔한 함정인데, 이 부분을 명시적으로 처리했습니다. 📊 실험 결과 초록에서 보고된 수치를 정리하면 다음과 같습니다. (상세 표는 arXiv 본문에 있으며, 본 리뷰는 초록·모델 카드·공개 리포지토리에서 확인 가능한 수치만 사용했습니다.) 롱컨텍스트 벤치마크 벤치마크 조건 FocusVTC 비교 대상 RULER v1 72 DPI, 2.9× 입력 압축(도구 관측 포함) 87.4 Glyph 57.5 (3.0× 입력 압축) LongBench — 56.40 텍스트 입력 백본 55.86 MRCR 매크로 평균 +13.91점 향상 (기준 대비) VTCBench 매크로 평균 51.19 — RULER v1이 가장 강한 결과입니다. 압축률은 사실상 동급(2.9× vs 3.0×)인데 점수가 87.4 대 57.5로 29.9점 벌어집니다. 고정 해상도 VTC가 72 DPI 구간에서 겪는 판독성 붕괴를, 선택적 확대가 거의 전부 복구한다고 읽을 수 있습니다. 여기서 압축률에 도구로 되돌아온 크롭 이미지 토큰까지 포함 시켰다는 점이 중요합니다. "확대하느라 쓴 토큰을 빼고 계산한 압축률"이 아니라는 뜻이니, 비교가 공정한 쪽으로 기울어 있습니다. LongBench 결과는 성격이 다릅니다. 56.40 대 55.86, 차이는 0.54점으로 작습니다. 하지만 비교 대상이 같은 모델의 텍스트 입력 버전 이라는 게 핵심입니다. 압축 연구에서 보통 기대하는 최선은 "원본 텍스트 성능에 근접"인데, 여기서는 그 선을 넘었습니다 . 전역 저해상도 뷰가 레이아웃 정보를 함께 주는 효과, 그리고 확대 과정이 일종의 명시적 근거 집중으로 작동하는 효과가 압축 손실을 상쇄했다고 해석할 수 있습니다. 다만 0.54점은 작은 마진이므로, 이를 "압축이 텍스트보다 낫다"로 일반화하기보다 "최소한 손해는 아니다"로 읽는 쪽이 안전합니다. MRCR 매크로 평균 +13.91점 은 다중 참조 해결(multi-round co-reference) 과제에서 적응형 확대의 이득이 크다는 신호입니다. 긴 대화·문서 속에서 특정 지점을 다시 찾아 확인해야 하는 과제 성격과 zoom_region 의 작동 방식이 잘 맞습니다. 효율 지표 결과 MRCR 온라인 종단간 속도 텍스트 입력 대비 2.79× RULER v1 입력 압축률 2.9× (도구 관측 포함) 속도 이득이 압축률(2.9×)과 거의 같은 배율(2.79×)로 나타난다는 게 깔끔합니다. 토큰을 줄인 만큼 실제 지연으로 돌려받았다는 뜻이고, 도구 호출로 인한 멀티턴 오버헤드가 압축 이득을 잡아먹지 않았다는 증거이기도 합니다. 일반 멀티모달 능력 보존 벤치마크 학습 전 FocusVTC MMMU 65.12 66.73 (+1.61) MME 2424.02 2457.62 (+33.60) 이 표가 생각보다 중요합니다. 문서 특화 미세조정을 세게 하면 일반 VQA·추론 능력이 깎이는 게 흔한 부작용인데(이른바 특화 세금), FocusVTC는 두 지표 모두 오히려 소폭 상승 했습니다. 비전 인코더를 동결하고, 랜덤 그라운딩 프롬프트로 형식 과적합을 막고, 초록이 강조하듯 별도 continual-pretraining 단계를 두지 않은 설계 선택이 여기서 값을 하는 것으로 보입니다. 상승폭 자체는 크지 않으니 "향상됐다"보다 "퇴
Case Escalation Rule 케이스 재할당 이메일 알림 보내는거 case Escalation rule re assgin the case send email notification 어디에 뭐 추가하는지 related list - page layout component - lightning record page field - page layout,compact layout button,quick action - page layout related list - page layout component - lightining record apge field - page layout, compact layout button , quick action - page layout
프로그래밍을 처음 배울 때 암호화와 해싱은 보통 “데이터를 알아볼 수 없는 값으로 바꾸는 기술”이라고 배운다. const encryptedValue = encrypt(plainText); const hashedValue = hash(plainText); 두 결과 모두 원본을 읽기 어려운 문자열처럼 보인다. 기본 개념을 이해하기에는 충분한 설명이다. 하지만 실제 서비스를 개발하면 결과의 모양보다 그 값을 나중에 어떻게 사용해야 하는지가 중요해진다. 사용자의 비밀번호를 다시 복원할 필요가 있는가? 상담 예약자의 전화번호는 언제 원문으로 읽어야 하는가? 같은 비밀번호를 사용하는 두 계정의 해시가 같아도 되는가? 암호화 키는 데이터와 어디까지 분리해야 하는가? 키가 노출되거나 교체되면 기존 데이터는 어떻게 처리하는가? 암호화된 개인정보를 검색해야 한다면 어떤 정보가 노출되는가? 로그와 백업에도 같은 보호 정책이 적용되는가? 암호화와 해싱은 데이터를 알아볼 수 없게 만드는 기술이 아니다. 암호화와 해싱은 데이터가 다시 복원되어야 하는지, 비교만 가능해야 하는지에 따라 민감한 정보의 사용 방식과 노출 범위를 제한하는 설계 도구다. 원본이 다시 필요할지를 먼저 결정해야 한다 크리스가 온라인 상담 서비스를 개발한다고 생각해 보자. 서비스는 회원 비밀번호, 전화번호, 상담 기록, 비밀번호 재설정 토큰을 다룬다. 이 값들은 모두 민감하지만 사용 방식은 서로 다르다. 데이터 원본 복원 필요 주된 사용 비밀번호 아니요 로그인 시 일치 여부 확인 전화번호 예 상담 일정 안내 상담 기록 예 담당 상담사가 내용 확인 재설정 토큰 아니요 사용자가 제출한 값과 비교 이메일 예 로그인과 알림 전송 모든 값을 같은 함수로 처리하면 각 데이터의 책임을 표현할 수 없다. const protectedPassword = protect(password); const protectedPhone = protect(phoneNumber); protect 라는 이름만으로는 원본을 복원할 수 있는지, 어떤 키를 사용하는지, 비교는 어떻게 하는지 알 수 없다. 사용 목적을 코드에 드러내는 편이 낫다. const passwordHash = await passwordHasher.hash(password); const encryptedPhoneNumber = await personalDataCipher.encrypt(phoneNumber); 비밀번호는 나중에 원문으로 읽지 않고 검증에만 사용한다. 전화번호는 권한이 있는 상담 업무에서 다시 읽어야 하므로 암호화한다. 어떤 알고리즘을 고를지 묻기 전에 먼저 물어야 할 질문은 “이 서비스가 원본을 다시 알아야 하는가?”이다. 인코딩은 표현을 바꾸지만 비밀을 만들지는 않는다 Base64처럼 데이터를 다른 문자열 형식으로 바꾸는 작업도 암호화처럼 보일 수 있다. const encodedPhoneNumber = Buffer.from(phoneNumber).toString("base64"); 하지만 Base64는 키 없이 누구나 되돌릴 수 있다. const phoneNumber = Buffer .from(encodedPhoneNumber, "base64") .toString("utf8"); 이 코드는 인코딩된 값을 원래 전화번호로 복원한다. 인코딩은 데이터가 특정 통신이나 저장 형식에 맞도록 표현을 변경한다. 비밀을 보호하는 보안 경계가 아니다. 방식 목적 원본 복원 비밀 필요 인코딩 표현 형식 변환 가능 없음 암호화 권한 있는 주체만 원본 사용 가능 암호화 키 해싱 입력에서 고정된 검증값 계산 일반적으로 불가능 보통 없음 키 기반 해시 비밀키를 사용한 무결성·비교 불가능 HMAC 키 URL 인코딩, Base64, 압축은 데이터 보호를 대신하지 않는다. 결과가 읽기 어렵게 보인다는 사실과 안전하게 보호된다는 사실을 구분해야 한다. 비밀번호는 복호화하지 않고 일치 여부만 확인해야 한다 서비스가 비밀번호를 암호화해서 저장한다고 생각해 보자. const encryptedPassword = await cipher.encrypt(password); 로그인 시 암호문을 복호화한 뒤 사용자의 입력과 비교할 수 있다. const savedPassword = await cipher.decrypt(user.encryptedPassword); const matches = savedPassword === inputPassword; 이 구조에서는 암호화 키를 얻은 공격자나 과도한 권한을 가진 내부 코드가 모든 비밀번호를 원문으로 복원할 수 있다. 서비스가 로그인 과정에서 필요한 것은 기존 비밀번호의 원문이 아니다. 사용자가 이번에 입력한 값이 저장 당시의 값과 같은지에 대한 결과다. const passwordHash = await passwordHasher.hash(password); await userRepository.create({ email, passwordHash, }); 회원가입 시 비밀번호 전용 해시를 저장한다. const matches = await passwordHasher.verify( inputPassword, user.passwordHash ); if (!matches) { throw new InvalidCredentialsError(); } 로그인 시 저장된 비밀번호를 복원하지 않는다. 새 입력이 기존 해시와 일치하는지만 검증한다. 비밀번호 해싱에는 SHA-256처럼 빠른 범용 해시를 직접 사용하지 않아야 한다. const passwordHash = sha256(password); 빠른 해시는 파일 무결성 확인에는 유용하지만, 공격자가 많은 비밀번호 후보를 빠르게 시도할 수도 있게 한다. 비밀번호에는 계산 비용과 메모리 비용을 조절할 수 있는 전용 알고리즘을 사용해야 한다. const passwordHash = await passwordHasher.hash(password, { algorithm: "argon2id", }); 실제 구현에서는 검증된 라이브러리가 솔트 생성과 알고리즘 매개변수 저장을 관리하도록 해야 한다. 암호 알고리즘을 직접 구현하지 않는 편이 안전하다. 솔트는 같은 비밀번호가 같은 해시가 되는 것을 막는다 크리스와 다른 사용자가 우연히 같은 비밀번호를 선택할 수 있다. 솔트가 없다면 같은 입력은 같은 해시를 만들 수 있다. password123 → same-hash password123 → same-hash 데이터베이스를 본 사람은 원문을 몰라도 두 사용자가 같은 비밀번호를 쓴다는 사실을 알 수 있다. 미리 계산한 해시 목록도 여러 계정에 재사용할 수 있다. 각 비밀번호에 고유한 무작위 솔트를 사용하면 결과가 달라진다. password123 + salt-a → hash-a password123 + salt-b → hash-b 현대적인 비밀번호 해싱 라이브러리는 보통 솔트를 자동으로 생성하고 결과 문자열에 필요한 정보를 함께 저장한다. const passwordHash = await passwordHasher.hash(password); 이 한 줄의 결과에는 알고리즘, 매개변수, 솔트, 해시 결과를 검증에 필요한 형식으로 포함할 수 있다. 솔트는 비밀일 필요가 없다. 각 비밀번호마다 고유해야 하고 예측하기 어려운 무작위 값이어야 한다. 페퍼는 솔트와 역할이 다르다. const passwordHash = await passwordHasher.hash( combine(password, passwordPepper) ); 페퍼는 여러 비밀번호에 공통으로 적용할 수 있는 별도의 비밀이다. 데이터베이스와 같은 위치에 저장하면 추가 보호 효과가 사라진다. 값 계정별 고유 비밀 여부 일반적인 저장 위치 솔트 예 아니요 비밀번호 해시와 함께 페퍼 보통 아니요 예 비밀 관리 시스템 비밀번호 사용자별 예 저장하지 않음 비밀번호 해시 사용자별 원문은 아니지만 보호 필요 데이터베이스 페퍼는 선택적인 추가 방어선이지 안전하지 않은 해싱 방식을 보완하는 대체재가 아니다. 해시는 복호화되지 않지만 추측을 막아 주지는 않는다 해싱을 단방향이라고 설명하면 해시값에서 비밀번호를 절대 알아낼 수 없다고 오해할 수 있다. 공격자는 해시를 수학적으로 되돌리는 대신 후보를 직접 계산해 비교할 수 있다. 후보 password1 계산 → 불일치 후보 password2 계산 → 불일치 후보 password123 계산 → 일치 따라서 비밀번호 해시는 다음 조건을 함께 만족해야 한다. 각 계정에 고유한 솔트를 사용한다. 비밀번호 저장용으로 설계된 느린 알고리즘을 사용한다. 서버 성능에 맞는 작업 비용을 설정한다. 로그인 시도 횟수를 제한한다. 유출된 비밀번호와 지나치게 약한 비밀번호를 방지한다. 시간이 지나면 알고리즘과 비용 설정을 갱신한다. 작업 비용은 무조건 높게 설정하는 값도 아니다. const startedAt = performance.now(); await passwordHasher.hash(samplePassword); const elapsedMs = performance.now() - startedAt; 운영 환경과 비슷한 장비에서 측정해 정상적인 로그인은 처리할 수 있으면서 대량 추측에는 비용이 들도록 조정해야 한다. 작업 비용이 지나치게 높으면 공격자가 로그인 요청을 반복해 서버 자원을 고갈시키는 문제도 생길 수 있다. 해싱 설정은 보안성과 서비스 처리 능력 사이의 판단이다. 복원이 필요한 개인정보는 키로 암호화한다 상담 담당자는 예약자의 전화번호를 확인해야 한다. 전화번호를 해싱하면 원문을 다시 표시할 수 없다. const phoneNumberHash = hash(phoneNumber); 이 값은 같은 입력을 비교하는 데는 사용할 수 있지만 고객에게 전화를 걸기 위한 번호를 복원할 수 없다. 복원이 필요한 값에는 암호화를 적용한다. const encryptedPhoneNumber = await personalDataCipher.encrypt({ plaintext: phoneNumber, context: { field: "patient_phone_number", patientId, }, }); 암호화 결과는 데이터베이스에 저장한다. await patientRepository.update(patientId, { encryptedPhoneNumber, }); 권한이 있는 업무에서만 복호화를 수행한다. await authorizationService.assertCan({ actor: currentUser, action: "patient:read_contact", resource: patient, }); const phoneNumber = await personalDataCipher.decrypt( patient.encryptedPhoneNumber ); 암호화는 접근 권한을 대신하지 않는다. 키를 사용할 수 있는 애플리케이션이라도 현재 사용자가 해당 환자의 연락처를 볼 수 있는지 확인해야 한다. 암호화는 저장소가 단독으로 노출되었을 때 원문을 바로 읽지 못하게 한다. 실행 중인 애플리케이션이 정상적으로 복호화한 뒤의 데이터까지 자동으로 보호하지는 않는다. 암호화는 기밀성뿐 아니라 변경 여부도 확인해야 한다 암호문을 저장했다고 해서 누군가 값을 변경하지 못하는 것은 아니다. 암호화된 전화번호가 조작되었는데 애플리케이션이 이를 감지하지 못하면 잘못된 데이터로 처리될 수 있다. 인증된 암호화 방식은 기밀성과 무결성을 함께 다룬다. const encryptedValue = await cipher.encryptAuthenticated({ plaintext: phoneNumber, associatedData: { patientId, field: "phone_number", }, }); associatedData 는 암호화하지 않더라도 암호문이 특정 환자와 필드에 연결되어 있음을 검증하는 데 사용할 수 있다. const phoneNumber = await cipher.decryptAuthenticated({ encryptedValue, associatedData: { patientId, field: "phone_number", }, }); 암호문을 다른 환자의 레코드로 복사하거나 내용을 변경하면 검증에 실패해야 한다. AES-GCM 같은 검증된 인증 암호화 방식을 적절한 라이브러리를 통해 사용할 수 있다. 알고리즘 이름만 선택한다고 안전해지는 것은 아니다. 키 크기, nonce 생성, 재사용 방지, 오류 처리까지 라이브러리와 키 관리 정책이 함께 책임져야 한다. 암호화 키를 데이터 옆에 두면 보호 경계가 사라진다 다음 코드는 암호화 키를 소스 코드에 포함한다. const encryptionKey = "consultation-service-secret-key"; 저장소나 빌드 결과가 노출되면 데이터와 키를 함께 얻을 수 있다. 데이터베이스에 암호화 키를 함께 저장하는 방식도 비슷한 문제가 있다. await settingsRepository.save({ encryptionKey, }); 데이터베이스 백업 하나만 탈취해도 암호문과 복호화 키를 함께 얻을 수 있다. 키는 보호 대상 데이터와 다른 경계에서 관리해야 한다. const dataKey = await keyManagementService.getKey({ keyId: encryptedRecord.keyId, }); 실제 운영 환경에서는 클라우드 KMS, 비밀 관리 시스템, HSM처럼 접근 제어와 감사 기능을 제공하는 키 관리 수단을 사용할 수 있다. 키 관리에는 값의 저장 위치뿐 아니라 전체 생명주기가 포함된다. type EncryptedField = { keyVersion: number; ciphertext: string; nonce: string; authenticationTag: string; }; keyVersion 을 함께 저장하면 어떤 키로 암호화했는지 식별할 수 있다. 키는 누가 생성하는가? 어떤 서비스가 사용할 수 있는가? 개발·테스트·운영 환경의 키가 분리되어 있는가? 키 사용 기록을 확인할 수 있는가? 키는 언제 회전하는가? 키가 손상되면 어떤 데이터를 다시 암호화하는가? 이전 키는 언제 폐기하는가? 암호화 알고리즘보다 키 관리 실패가 전체 보호를 무효화할 수 있다. 키 교체는 문자열 하나를 바꾸는 작업이 아니다 운영 중인 데이터가 기존 키로 암호화되어 있다고 생각해 보자. type EncryptedPatientData = { keyVersion: 1; ciphertext: string; }; 설정에서 키를 즉시 새 값으로 덮어쓰면 기존 데이터를 복호화할 수 없게 된다. 키 버전을 구분해야 한다. const key = await keyManagementService.getKey({ version: encryptedData.keyVersion, }); 새 데이터는 최신 키로 암호화한다. const latestKey = await keyManagementService.getLatestKey(); const encryptedData = await cipher.encrypt({ plaintext: phoneNumber, key: latestKey, keyVersion: latestKey.version, }); 기존 데이터는 읽을 때 다시 암호화하거나 별도의 마이그레이션 작업으로 갱신할 수 있다. if ( encryptedData.keyVersion < latestKey.version ) { const plaintext = await decryptWithPreviousKey(encryptedData); await saveWithLatestKey(plaintext); } 이 코드는 이전 키로 읽은 값을 최신 키로 다시 암호화한다. 키 회전에는 다음 상태가 존재할 수 있다. 키 상태 용도 활성 새 데이터 암호화와 복호화 이전 기존 데이터 복호화만 허용 폐기 예정 마이그레이션 완료 확인 중 폐기 더 이상 사용할 수 없음 손상 즉시 사용 중지와 영향 조사 필요 키 회전 정책은 데이터 마이그레이션, 백업 복원, 장애 대응과 함께 설계해야 한다. 검색 가능한 개인정보는 추가적인 노출을 만든다 전화번호를 무작위성이 있는 방식으로 암호화하면 같은 번호도 매번 다른 암호문이 나올 수 있다. 0412 345 678 → ciphertext-a 0412 345 678 → ciphertext-b 이 성질은 암호문 사이의 관계 노출을 줄인다. 그러나 다음과 같은 검색은 어려워진다. SELECT * FROM patients WHERE encrypted_phone_number = $1; 새로 암호화한 값이 저장된 암호문과 같다는 보장이 없기 때문이다. 검색을 위해 항상 같은 암호문을 만드는 방식을 사용하면 동일한 값의 반복 패턴이 노출될 수 있다. 전화번호나 이메일처럼 가능한 입력을 추측하기 쉬운 값에 일반 해시만 저장하는 것도 충분하지 않다. const phoneLookup = sha256( normalizePhoneNumber(phoneNumber) ); 공격자는 가능한 전화번호를 계산해 저장된 값과 비교할 수 있다. 별도의 비밀키를 사용하는 검색용 HMAC 값을 고려할 수 있다. const normalizedPhoneNumber = normalizePhoneNumber(phoneNumber); const phoneLookup = await lookupKeyService.hmac( normalizedPhoneNumber ); 서비스는 원문 표시용 암호문과 정확 일치 검색용 값을 따로 저장한다. await patientRepository.update(patientId, { encryptedPhoneNumber, phoneLookup, }); 각 값의 책임이 다르다. encryptedPhoneNumber 는 권한이 있는 사용자가 원문을 복원할 때 사용한다. phoneLookup 은 정규화된 번호의 정확 일치 검색에 사용한다. HMAC 비밀키는 데이터베이스와 분리해 관리한다. 검색용 값도 정보 노출을 완전히 제거하지는 않는다. 같은 입력이 같은 검색값을 만들기 때문에 중복과 조회 패턴이 드러날 수 있다. 민감한 값을 검색 가능하게 만드는 일은 단순한 편의 기능이 아니다. 필요한 검색 종류와 허용할 정보 노출을 결정하는 설계 판단이다. 재설정 토큰과 API 키도 원문을 저장하지 않을 수 있다 비밀번호 재설정 토큰은 사용자가 나중에 다시 제출해야 하는 비밀이다. 서버가 토큰 원문을 다시 보여줄 필요는 없다. const resetToken = secureRandomToken(); await passwordResetRepository.create({ userId: user.id, token: resetToken, expiresAt, }); 원문을 저장하면 데이터베이스 유출 시 아직 유효한 토큰을 바로 사용할 수 있다. 검색 가능한 식별자와 검증용 해시를 나눌 수 있다. const resetToken = secureRandomToken(); const tokenHash = await tokenHasher.hash(resetToken); await passwordResetRepository.create({ userId: user.id, tokenHash, expiresAt, usedAt: null, }); 사용자에게는 원문 토큰을 한 번 전달하고 서버에는 검증값만 저장한다. const resetRequest = await passwordResetRepository.findActive( requestId ); const matches = await tokenHasher.verify( presentedToken, resetRequest.tokenHash ); 제출된 토큰이 저장된 검증값과 일치하는지 확인한다. 토큰 자체에 충분한 무작위성이 있다면 비밀번호와 동일한 느린 해싱 정책이 항상 필요한 것은 아니다. 토큰의 생성 방식, 수명, 조회 구조, 공격 가능성을 기준으로 적절한 HMAC이나 해시 방식을 선택해야 한다. 외부 입력은 검증 전까지 신뢰할 수 없다. const ResetPasswordSchema = z.object({ requestId: z.string().uuid(), token: z.string().min(32).max(512), newPassword: z.string().min(12).max(128), }); const input = ResetPasswordSchema.parse(request.body); 형식 검증 후 토큰 일치 여부, 만료 시각, 사용 여부를 각각 확인해야
OTP 자동 입력 확장의 제작 경험은 AI 코딩에 관해 무엇을 보여 줄까요? GeekNews에 소개된 제작자는 반복 로그인의 불편을 줄이려고 Claude Code와 기능 구현부터 심사 준비까지 진행했다고 설명합니다. 다만 구현 시간 비교나 결함 발생률 측정값이 없어, 모든 확장 개발의 생산성 향상으로 일반화하기는 어렵다는 점에 주의해야 합니다. 인증 앱에서 숫자를 가져오는 일은 잠깐이면 끝납니다. 하지만 로그인할 때마다 같은 일을 반복하면 하던 작업의 흐름도 자꾸 끊깁니다. 이 불편을 이해하려면 클릭 횟수뿐 아니라, 인증을 위해 작업을 멈추는 순간에도 주목해야 합니다. GeekNews 제작기에서는 인증 앱을 열고 숫자를 복사해 붙여넣는 반복 동작이 출발점입니다. 사용자가 매번 직접 처리하던 과정을 살펴보면, 작은 자동화 도구가 필요한 이유를 구체적으로 이해할 수 있습니다. 제작기 주소는 https://news.hada.io/topic?id=32350 입니다. 사이트 등록부터 인증값 입력과 버튼 클릭까지 사용자가 무엇을 등록하면 확장이 어떤 순서로 로그인을 자동화할까요? 사용자는 사이트별 URL 패턴과 Secret Key, OTP 입력창과 로그인 버튼의 CSS 선택자를 등록합니다. 해당 페이지에 들어가면 확장이 TOTP를 계산하고 입력한 뒤 로그인 버튼을 클릭합니다. 자동 입력을 설정하려면 어느 사이트에서 동작할지와 화면의 어느 요소를 사용할지를 함께 정해야 합니다. URL 패턴은 대상 페이지를 정하는 데 쓰입니다. CSS 선택자는 OTP를 넣을 입력창과 누를 로그인 버튼을 지정하는 데 쓰입니다. 사이트와 대상 등록 — URL 패턴, Secret Key, 입력창과 버튼의 CSS 선택자를 등록합니다. 대상 페이지 진입 — 등록한 사이트의 페이지에 들어갑니다. 인증값 계산과 입력 — 확장이 TOTP를 계산해 지정한 입력창에 넣습니다. 로그인 버튼 클릭 — 입력을 마치면 지정한 로그인 버튼을 누릅니다. 화면에서 고르게 하려면 작성 상태도 지켜야 합니다 화면에서 입력창을 직접 고르게 하면 사용자는 CSS 선택자를 몰라도 대상을 지정합니다. 페이지를 누르는 순간 팝업이 닫히므로, 요소 선택과 작성 중인 폼을 이어 주는 처리가 함께 필요합니다. 제작자는 storage.session에 상태를 남겼다가 복원하도록 처리했습니다. 화면을 클릭하는 기능을 완성하려면, 클릭한 대상을 고르는 일뿐 아니라 팝업이 닫힌 뒤에도 작성 내용을 이어 가는 일까지 다뤄야 했습니다. QR 코드를 등록하는 경로도 마련했습니다. 현재 탭을 스캔하거나 이미지를 업로드하는 방식입니다. 이 처리를 위해 jsQR을 확장에 함께 넣었으며, 두 경로 모두 오프라인에서 처리하도록 구성했습니다. 계산 위치와 실제 동작 조건을 따로 정합니다 자동화할 사이트가 정해져 있다면 접근 권한을 검토할 기준도 구체적입니다. 실제 기능에 필요한 접근 범위를 그 사이트의 사용 맥락에 맞춰 정하는 것입니다. 여기서 권한을 받는 범위와 자동 입력을 시작하는 주소 조건을 각각 살펴볼 이유가 생깁니다. 제작자가 설명한 크롬 match pattern에는 포트를 표현하는 제약이 있었습니다. 그래서 호스트 단위로 권한을 받는 처리와 정확한 URL을 대조하는 처리를 나눴습니다. 페이지 쪽에서 실행되는 content script가 주소 대조를 맡습니다. 제작기에 따르면 사내망 HTTP 페이지의 content script에서는 crypto.subtle을 사용할 수 없었습니다. RFC 6238에 따른 TOTP 계산을 background 서비스 워커로 옮긴 이유입니다. 인증값 계산을 구현하는 일에는 그 계산에 필요한 기능을 어디서 사용할 수 있는지 살피는 일도 포함됐습니다. 이 구조에서 도출한 검증 제안은 구체적입니다. 등록한 URL과 포트가 다른 페이지에서 자동 입력이 시작되는지 살펴봅니다. 화면이 개편된 뒤에는 CSS 선택자가 의도한 요소를 계속 가리키는지도 점검합니다. 오프라인 계산과 인증 수단의 분리는 다른 질문입니다 외부 서버 없이 OTP를 계산한다면 인증 구조에 관한 검토도 끝날까요? 제작자는 서버 호출·트래킹·계정 없이 OTP를 계산하지만, 로그인하는 브라우저에 시크릿을 두면 별도 기기를 쓰는 2차 인증의 의미가 약해진다고 설명합니다. 상정한 사용처도 보안 요구가 낮고 반복 로그인이 많은 사내 시스템이므로, 도입 시 조직의 인증 수단 분리 요구를 함께 검토해야 합니다. 도입을 검토할 때는 조직이 인증 수단을 별도 기기에 두려는 이유부터 살펴봅니다. 별도 기기를 사용하는 방식은 로그인하는 브라우저와 인증 수단을 나누는 구성입니다. 제작자는 서버 호출·트래킹·계정 없이 계산하더라도, 브라우저에 TOTP 시크릿을 두면 이런 분리의 의미가 약해진다고 밝혔습니다. 따라서 제작자가 상정한 보안 요구가 낮고 반복 로그인이 많은 사내 시스템이라는 사용 조건도 도입 판단에 함께 넣어야 합니다. 추가 작업이 평소의 기대치가 될 수 있습니다 여러 작업을 동시에 진행하는 모습에도 주목할 필요가 있습니다. ITWorld가 소개한 연구에서는 직원들이 맡는 업무의 범위를 넓혔습니다. 쉬던 시간에도 프롬프트를 작성했습니다. 관찰 대상은 직원 200명 규모의 기술 기업이었습니다. 연구는 8개월간의 관찰과 40건 이상의 인터뷰를 포함합니다. 이 연구를 소개한 ITWorld 기사 주소는 https://www.itworld.co.kr/article/4228563/ai-%ec%bd%94%eb%94%a9%ec%9d%98-%ec%86%8d%eb%8f%84-%ed%96%a5%ec%83%81%ec%9d%b4-%eb%b6%80%eb%a5%b8-%ec%97%94%ec%a7%80%eb%8b%88%ec%96%b4%eb%a7%81-%eb%b6%80%eb%8b%b4.html 입니다. 한 기업을 관찰한 질적 연구라는 한계 안에서 살펴볼 대목은 추가 작업에 대한 기대의 변화입니다. 자발적으로 더 하던 일이 평소에도 해야 하는 일로 받아들여질 수 있습니다. 개발자에게는 새 작업을 시작할 여력이 생겼다는 사실과, 그 작업이 일상적인 업무로 자리 잡는 과정을 함께 살펴보라는 의미입니다. 절약한 시간의 일부를 검증과 유지보수에 배분합니다 클릭으로 요소를 고르는 기능에는 팝업이 닫힌 뒤 작성 내용을 복원하는 처리가 따라왔습니다. 사내망 HTTP 페이지에서 인증값을 계산하려면 계산 위치도 조정해야 했습니다. 사용자의 짧은 조작 뒤에 화면 상태와 실행 환경에 관한 결정이 놓여 있었습니다. 외부 서버 없이 계산한다는 특성과 인증 수단을 분리한다는 요구도 따로 살펴봤습니다. 또한 한 기업의 연구에서는 직원들이 업무를 넓히고 쉬던 시간에도 작업했습니다. 추가 작업이 평소의 기대치로 이어질 가능성은 확보한 시간을 어떻게 쓸지 생각하게 합니다. 다음에 해 볼 것 — 팀에 적용할 다음 행동으로, 완성한 기능 목록 옆에 남은 일을 적어 보는 방식을 제안합니다. 권한 검토의 완료 여부, 페이지 변경에 대응할 담당자, 자동 입력 실패를 사용자가 이해할 수 있는지를 기록합니다. 절약한 시간 중 일부는 이 기록을 바탕으로 동작 범위를 점검하고 유지보수 조건을 정하는 데 배분합니다. 원문: webi 기술 블로그 참고한 자료: Show GN: OTP 복사-붙여넣기가 귀찮아서 자동 입력 크롬 확장을 만들었습니다 아이폰 듀오의 진짜 경쟁자, 갤럭시 폴드 아닌 아이폰+아이패드 조합 AI 코딩의 속도 향상이 부른 엔지니어링 부담