• Home
  • About
    • lahuman photo

      lahuman

      열심히 사는 아저씨

    • Learn More
    • Facebook
    • LinkedIn
    • Github
  • Posts
    • All Posts
    • All Tags
  • Projects

cokacremote 톺아보기 3부 — AI는 왜 apply_patch를 사용할까?

23 Aug 2026

Reading time ~11 minutes

1부에서는 ChatGPT의 요청이 MCP Tool까지 들어오는 길을 봤고,

2부에서는 exec_command가 실제 OS 프로세스로 내려가는 과정을 봤습니다.

이제 마지막으로 파일 수정입니다.

제가 ChatGPT에게 코드를 수정시켜보면 자주 보이는 방식이 있습니다.

apply_patch입니다.

처음에는 조금 궁금했습니다.

그냥 파일 전체를 다시 쓰면 되는 것 아닌가?

왜 굳이 patch를 만들까?

그래서 이번에는 apply_patch를 까봤습니다.

분석 기준은 앞의 두 글과 같습니다.

repository: https://github.com/lahuman/cokacremote
commit: 1bb0f767c4249e8536bcee98341808bebcbbebaa
date: 2026-08-21

먼저 하나 구분해야 합니다.

제목은 “AI는 왜 apply_patch를 사용할까?”지만,

실제 cokacremote 코드 안에는

이 상황에서는 apply_patch를 사용한다

같은 판단 로직이 없습니다.

cokacremote는 파일 수정용 Tool을 여러 개 제공합니다.

write_file
replace_in_file
apply_patch

mcp-server.ts의 instruction도 파일 작업은 file tool을 사용하라고만 되어 있습니다.

"...and the file tools for direct file operations."

적어도 이 저장소의 서버 코드에는 Tool 선택 규칙이 없습니다.

어떤 Tool을 호출할지는 서버 바깥의 ChatGPT/Agent 쪽에서 결정됩니다.

이건 먼저 구분하고 봐야 합니다.

cokacremote가 하는 일은

apply_patch를 호출받았을 때 그 patch를 어떻게 검증하고 실제 파일에 반영할 것인가?

입니다.

파일 전체를 쓰는 방법도 있습니다.

먼저 write_file을 보면 정말 파일 전체를 쓸 수 있습니다.

Tool 설명도 이렇게 되어 있습니다.

description:
  "Create, overwrite, or append to any host file using UTF-8 or base64 content."

mode: "overwrite"라면 결국 writeFile()을 호출합니다.

await writeFile(resolvedPath, data, ...);

예를 들어 500줄짜리 파일에서 한 줄만 바꿔도,

Agent가 write_file을 사용한다면 수정된 500줄 전체를 다시 전달할 수 있습니다.

그렇다고 이 방식이 나쁜 건 아닙니다.

새 파일을 만들거나 파일 전체를 갈아엎어야 한다면 오히려 단순합니다.

그런데 기존 코드 몇 줄만 바꿀 때는 이야기가 조금 달라집니다.

replace_in_file도 있습니다.

문자열 하나만 바꾼다면 이것도 있습니다.

server.registerTool(
  "replace_in_file",

기본 동작은 꽤 보수적입니다.

const expected = expectedOccurrences ?? (replaceAll ? occurrences : 1);
if (occurrences !== expected) {
  throw new Error(...);
}

기본적으로 바꾸려는 문자열이 정확히 한 번 있는지 확인합니다.

예를 들어

oldText: const port = 3000;
newText: const port = 3001;

처럼 정확한 문자열 하나를 바꾸는 작업이라면 이해하기 쉽습니다.

하지만 여러 파일의 여러 위치를 한 번에 바꾸거나,

주변 문맥을 포함해서 변경하고 싶다면 unified diff가 더 자연스럽습니다.

여기서 apply_patch가 나옵니다.

apply_patch Tool을 봤습니다.

등록 위치는 src/file-tools.ts입니다.

server.registerTool(
  "apply_patch",
  {
    title: "Apply unified diff",
    description:
      "Validate and apply a standard unified diff with git apply. Paths are unrestricted and --unsafe-paths is enabled.",

설명에 답이 거의 적혀 있습니다.

standard unified diff를 git apply로 적용합니다.

입력값은 네 가지 옵션과 patch 본문입니다.

patch
cwd
checkOnly
reverse
threeWay

Schema는 이렇게 되어 있습니다.

inputSchema: {
  patch: z.string().min(1).describe("Standard unified diff text."),
  cwd: cwdSchema,
  checkOnly: z.boolean().default(false),
  reverse: z.boolean().default(false),
  threeWay: z.boolean().default(false),
},

Handler 자체는 아주 짧습니다.

async ({ patch, cwd, checkOnly, reverse, threeWay }) =>
  runTool(() => files.applyPatch(patch, cwd, { checkOnly, reverse, threeWay })),

실제 작업은 다시 FileService로 내려갑니다.

unified diff가 뭘까?

Git을 사용하다 보면 익숙한 형식입니다.

실제 cokacremote integration test에 있는 patch를 보면 이렇습니다.

diff --git a/patch-target.txt b/patch-target.txt
--- a/patch-target.txt
+++ b/patch-target.txt
@@ -1 +1 @@
-old
+new

뜻은 단순합니다.

patch-target.txt 파일에서
old를 지우고
new를 넣는다

입니다.

파일 전체 내용이 아니라 어디가 어떻게 바뀌는지를 표현합니다.

제가 보기에는 patch가 Agent와 잘 맞는 이유도 여기 있습니다.

500줄 파일에서 3줄만 바꾼다면 500줄 전체를 다시 보내는 것보다

-기존 3줄
+바뀐 3줄

을 보내는 쪽이 훨씬 작습니다.

그리고 diff 안에는 변경할 위치 주변의 문맥도 같이 들어갈 수 있습니다.

그래서 원본 파일이 Agent가 읽었을 때와 달라졌다면 적용에 실패할 수도 있습니다.

오히려 이런 실패가 안전장치가 됩니다.

다만 이건 cokacremote가 Agent에게 patch를 선택하도록 강제해서 생기는 장점은 아닙니다.

Agent가 apply_patch를 선택했을 때 얻을 수 있는 특성입니다.

실제 FileService.applyPatch()를 열어봤습니다.

파일은 src/file-service.ts입니다.

일단 cwd부터 처리합니다.

const resolvedCwd = this.resolve(".", cwd);

2부의 exec_command와 비슷하게 기준 디렉터리를 먼저 결정합니다.

FileService.resolve()는 절대경로, ~/, 상대경로를 처리합니다.

resolve(inputPath: string, cwd?: string): string {
  const base = cwd
    ? expandPath(cwd, this.#options.defaultCwd)
    : this.#options.defaultCwd;
  return expandPath(inputPath, base);
}

여기에는 /workspace 안쪽만 허용하는 containment check 같은 것은 없습니다.

원래 프로젝트 자체가 unrestricted remote development environment를 목표로 하고 있기 때문입니다.

받은 patch는 임시 파일로 만듭니다.

다음 코드가 나옵니다.

const temporaryDirectory = await mkdtemp(
  path.join(os.tmpdir(), "remote-dev-mcp-patch-"),
);
const patchPath = path.join(temporaryDirectory, `${randomUUID()}.patch`);
await writeFile(patchPath, patchText, "utf8");

patch 문자열을 바로 git apply의 stdin으로 넣는 건 아니었습니다.

임시 디렉터리를 하나 만들고

/tmp/remote-dev-mcp-patch-XXXXXX/

그 안에 UUID 이름의 .patch 파일을 만듭니다.

대략 이런 모양입니다.

/tmp/remote-dev-mcp-patch-abc123/
└── 550e8400-e29b-41d4-a716-446655440000.patch

그리고 이 파일 경로를 git apply에 넘깁니다.

실제 명령을 만드는 부분입니다.

제가 제일 보고 싶었던 부분이 여기입니다.

const baseArguments = ["apply", "--unsafe-paths", "--whitespace=nowarn"];

즉 기본 명령은 사실상 이렇게 됩니다.

git apply --unsafe-paths --whitespace=nowarn PATCH_FILE

여기서 --whitespace=nowarn은 whitespace 차이를 무시한다는 뜻은 아닙니다.

Git 문서 기준으로 trailing whitespace 경고를 끄는 옵션입니다. patch의 문맥에서 whitespace 차이를 무시하는 --ignore-whitespace와는 다른 옵션입니다.

그런데 바로 적용하지는 않습니다.

먼저 한 번 검사합니다.

무조건 --check부터 합니다.

코드를 보면 check용 arguments를 따로 만듭니다.

const checkArguments = [...baseArguments, "--check", patchPath];

그리고 먼저 실행합니다.

const checked = await execFileAsync("git", checkArguments, {
  cwd: resolvedCwd,
  encoding: "utf8",
  maxBuffer: this.#options.maxOutputBytes,
});

실제 흐름은 이렇습니다.

git apply --unsafe-paths --whitespace=nowarn --check PATCH_FILE

git apply --check는 파일을 수정하지 않고 patch를 적용할 수 있는지 확인합니다.

그래서 apply_patch는 무작정 파일부터 바꾸지 않습니다.

flowchart TD; A[patch 전달] --> B[임시 patch 파일 생성]; B --> C[git apply --check]; C -->|실패| D[에러 반환]; C -->|성공| E{checkOnly?}; E -->|YES| F[파일 수정 없이 성공 반환]; E -->|NO| G[git apply]; G --> H[파일 수정];

이 방식은 꽤 마음에 들었습니다.

실제 파일을 건드리기 전에 같은 옵션으로 먼저 검증합니다.

checkOnly면 여기서 끝납니다.

Tool 호출에서

checkOnly: true

를 주면 --check까지만 실행합니다.

if (options.checkOnly) {
  return {
    cwd: resolvedCwd,
    applied: false,
    checkOnly: true,
    stdout: checked.stdout,
    stderr: checked.stderr,
  };
}

여기서 applied: false는 실패했다는 뜻이 아닙니다.

검증만 했기 때문에 실제 적용하지 않았다는 뜻입니다.

integration test도 이걸 확인합니다.

먼저 파일 내용을

old

로 만든 다음 checkOnly: true로 호출합니다.

expect(await callOk("apply_patch", {
  patch,
  cwd: testRoot,
  checkOnly: true,
})).toMatchObject({ applied: false, checkOnly: true });

그리고 다시 파일을 읽어서 여전히

old

인지 확인합니다.

검증을 통과하면 실제로 한 번 더 실행합니다.

checkOnly가 아니라면 다음 코드로 갑니다.

const applied = await execFileAsync("git", [...baseArguments, patchPath], {
  cwd: resolvedCwd,
  encoding: "utf8",
  maxBuffer: this.#options.maxOutputBytes,
});

이번에는 --check가 없습니다.

git apply --unsafe-paths --whitespace=nowarn PATCH_FILE

실제 적용입니다.

성공하면

return {
  cwd: resolvedCwd,
  applied: true,
  checkOnly: false,
  stdout: applied.stdout,
  stderr: applied.stderr,
};

를 반환합니다.

결국 흐름만 줄이면 이 정도입니다.

patch 받기
↓
임시 파일 만들기
↓
git apply --check
↓
성공?
├─ NO → error
└─ YES
   ├─ checkOnly → 종료
   └─ git apply → 적용

실패하면 어떻게 될까?

git apply --check나 실제 git apply가 실패하면 catch로 들어갑니다.

catch (error) {
  const execError = error as Error & { stdout?: string; stderr?: string };
  throw new Error(
    [errorMessage(execError), execError.stdout, execError.stderr]
      .filter(Boolean)
      .join("\n"),
  );
}

git 실행 중 발생한 에러와 stdout, stderr를 합쳐서 다시 throw합니다.

그런데 Tool Handler는 runTool()로 감싸져 있습니다.

export async function runTool(...) {
  try {
    return successResult(await operation());
  } catch (error) {
    return errorResult(error);
  }
}

그래서 MCP 쪽에는 isError: true인 Tool 결과로 돌아가고, Agent는 git apply에서 나온 에러 메시지를 확인할 수 있습니다.

apply_patch
   ↓
git apply --check 실패
   ↓
FileService throw
   ↓
runTool errorResult
   ↓
ChatGPT가 에러 확인

여기서 하나 구분해야 합니다.

cokacremote 서버가 실패한 patch를 스스로 고쳐서 재시도하지는 않습니다.

그 다음은 Agent가 결정합니다.

예를 들어 Agent가

1. read_file로 현재 파일을 다시 읽고
2. 바뀐 내용을 확인하고
3. 새로운 patch를 만들고
4. apply_patch를 다시 호출

할 수 있습니다.

실제로 AI 개발 도구를 쓰다 보면 이런 흐름을 자주 보게 됩니다.

flowchart TD; A[파일 읽기] --> B[patch 생성]; B --> C[apply_patch]; C --> D{성공?}; D -->|YES| E[테스트/다음 작업]; D -->|NO| F[파일 다시 읽기]; F --> G[현재 상태 기준 patch 재생성]; G --> C;

다만 이 반복 흐름은 cokacremote 내부 구현이 아닙니다.

MCP Tool의 실패 결과를 받은 Agent가 할 수 있는 작업 흐름입니다.

왜 patch가 Agent에게 잘 맞을까?

여기부터는 cokacremote 소스 자체라기보다 Agent 작업 방식에 대한 이야기입니다.

코드를 보고 실제로 써보니 patch가 잘 맞는 이유가 몇 가지 보였습니다.

가장 먼저 수정량이 작습니다.

파일 전체를 다시 보내지 않고 바뀌는 부분만 전달할 수 있습니다.

그리고 기존 문맥으로 검증할 수 있습니다.

patch의 주변 코드가 현재 파일과 맞지 않으면 git apply --check에서 실패할 수 있습니다.

또 여러 파일 변경을 하나의 diff로 표현할 수도 있습니다.

unified diff 자체가 여러 파일의 변경을 담을 수 있기 때문에 관련 수정들을 하나의 patch로 묶는 것도 가능합니다.

반대로 무조건 patch가 좋은 것도 아닙니다.

새 파일 전체를 만드는 작업이라면 write_file이 더 단순할 수 있고,

정확한 문자열 하나만 바꾸는 작업이라면 replace_in_file이 더 이해하기 쉬울 수 있습니다.

그래서 상황에 따라 Tool을 나눠 쓸 수 있습니다.

새 파일 / 전체 교체
→ write_file

정확한 문자열 교체
→ replace_in_file

문맥을 가진 여러 줄 변경
→ apply_patch

이건 cokacremote에 박혀 있는 선택 규칙은 아닙니다.

제가 Tool의 동작을 기준으로 정리한 사용 관점입니다.

reverse도 있습니다.

apply_patch 입력값에는

reverse: true

도 있습니다.

이 값이 들어오면

if (options.reverse) {
  baseArguments.push("--reverse");
}

가 실행됩니다.

실제 명령은

git apply --unsafe-paths --whitespace=nowarn --reverse PATCH_FILE

형태가 됩니다.

말 그대로 patch를 반대로 적용합니다.

integration test에서도

old → new

patch를 먼저 적용한 다음

reverse: true

로 다시 적용해서 파일이 old로 돌아오는지 확인합니다.

threeWay도 있습니다.

다음은

threeWay: true

입니다.

코드는

if (options.threeWay) {
  baseArguments.push("--3way");
}

입니다.

즉

git apply --3way ...

가 됩니다.

여기는 조건이 하나 있습니다.

Git 공식 문서를 보면 --3way는 patch에 원본 blob을 식별할 정보가 있고, 그 blob을 로컬에서 찾을 수 있을 때 three-way merge를 시도합니다. 아무 unified diff에나 항상 three-way merge가 되는 것은 아닙니다.

그리고 --3way는 --cached를 쓰는 경우를 제외하면 --index를 암묵적으로 사용합니다. Git 문서상 --unsafe-paths는 --index나 --cached와 함께 사용할 때는 효과가 없습니다.

코드에서는 --unsafe-paths와 --3way를 모두 arguments에 넣지만, 실제 의미는 Git 쪽 규칙을 따르는 셈입니다.

프로젝트 integration test에서는 실제 Git 저장소를 하나 만든 뒤 이 옵션까지 테스트합니다.

await callOk("exec_command", {
  cmd: "git init -q && git config user.email e2e@example.invalid && git config user.name E2E && git add value.txt && git commit -qm base",
  workdir: path.join(testRoot, "three-way"),
});

그 다음 파일을 수정하고

git diff --binary -- value.txt

로 실제 patch를 만듭니다.

파일을 원상복구한 뒤 그 patch를

threeWay: true

로 apply_patch에 넘깁니다.

마지막에 파일 내용이 의도한 값으로 바뀌었는지 확인합니다.

테스트 흐름도 실제 사용할 때와 꽤 비슷합니다.

참고로 기본 git apply는 Git 저장소가 아니어도 현재 작업 디렉터리의 파일에 patch를 적용할 수 있습니다. 반면 threeWay 동작을 제대로 검증하려면 원본 blob 정보가 들어 있는 patch와 Git 저장소 문맥이 필요해서, 테스트에서는 일부러 저장소를 만들어 확인합니다.

그런데 --unsafe-paths는 왜 있을까?

이건 그냥 넘어가기 어려웠습니다.

기본 arguments에 항상 들어가 있습니다.

const baseArguments = ["apply", "--unsafe-paths", "--whitespace=nowarn"];

git apply -h에서 --unsafe-paths 설명은 이렇습니다.

accept a patch that touches outside the working area

작업 영역 밖을 건드리는 patch도 허용한다는 뜻입니다.

cokacremote의 Tool 설명도 아예

Paths are unrestricted and --unsafe-paths is enabled.

라고 적어두었습니다.

README에도 apply_patch가 host의 git apply --unsafe-paths를 사용한다고 명시되어 있습니다.

이름 그대로 주의할 옵션입니다.

정말 바깥 파일도 수정될까?

그래서 격리된 /tmp에서 직접 해봤습니다. 이번 검증 환경의 Git은 2.39.5였습니다.

구조를 이렇게 만들었습니다.

/tmp/cokacremote-patch-check-XXXXXX/
├── outside.txt
└── work/

work를 현재 작업 디렉터리로 두고 patch에는

diff --git a/../outside.txt b/../outside.txt
--- a/../outside.txt
+++ b/../outside.txt
@@ -1 +1 @@
-old
+new

를 넣었습니다.

먼저 일반 git apply --check를 실행했습니다.

결과는

exit=128
error: invalid path '../outside.txt'

였습니다.

바로 거부됩니다.

이번에는 --unsafe-paths를 붙였습니다.

git apply --unsafe-paths --check

검증이 성공했습니다.

실제로 적용한 뒤 outside.txt를 확인하니

new

으로 바뀌었습니다.

--unsafe-paths는 이름만 그런 게 아니었습니다.

실제로 cwd 바깥 경로를 건드릴 수 있습니다.

이게 무조건 취약점이라는 뜻은 아닙니다.

다만 여기서는 프로젝트 목적도 같이 봐야 합니다.

cokacremote 자체가 서버 instruction에서부터

unrestricted remote development environment

라고 밝히고 있습니다.

파일 Tool도 absolute path를 허용하고,

exec_command 역시 MCP 서버 프로세스의 OS 권한으로 명령을 실행합니다.

apply_patch만 특별히 sandbox 안에 가두는 구조가 아닙니다.

원래 설계 자체가 신뢰된 사용자에게 원격 개발환경 전체를 열어주는 형태입니다.

그래서 더 중요한 건 --unsafe-paths 자체보다

이 MCP 서버에 누가 접근할 수 있느냐입니다.

1부에서 인증 부분을 본 이유도 여기와 연결됩니다.

인증 없이 인터넷에 그대로 노출하면 안 되는 종류의 도구입니다.

제 Docker 환경에서는 조금 다르게 보입니다.

제가 사용하는 구조는 이렇습니다.

flowchart TD; A[ChatGPT] --> B[nginx proxy]; B --> C[Mac]; C --> D[Docker]; D --> E[cokacremote MCP]; E --> F[workspace];

2부에서 봤듯이 cokacremote의 Node.js 프로세스는 Docker 컨테이너 안에서 실행됩니다.

따라서 apply_patch가 실행하는

git apply ...

도 컨테이너 안에서 실행됩니다.

여기서 /workspace를 Mac의 프로젝트 디렉터리에 volume으로 연결해두면,

/workspace 아래 파일 수정은 결국 Mac의 실제 프로젝트 파일에도 반영됩니다.

반대로 컨테이너에서 보이지 않는 Mac 경로까지 마음대로 접근하는 건 아닙니다.

Docker가 어떤 경로를 mount했고 컨테이너 프로세스에 어떤 권한을 줬는지가 경계가 됩니다.

즉 제가 사용하는 환경에서는

MCP 인증
+
Docker filesystem / volume 경계
+
컨테이너 사용자 권한

도 같이 봐야 합니다.

임시 patch 파일은 마지막에 지웁니다.

applyPatch() 마지막에는 finally가 있습니다.

finally {
  await rm(temporaryDirectory, { recursive: true, force: true });
}

성공하든 실패하든 임시 patch 디렉터리를 정리합니다.

그래서 정상 흐름에서는 /tmp/remote-dev-mcp-patch-* 파일이 계속 쌓이지 않습니다.

이런 건 확인해두면 마음이 편합니다.

실제 테스트도 돌려봤습니다.

이번에도 테스트를 직접 돌렸습니다.

test/file-service.test.ts
test/all-tools.integration.test.ts

결과는

Test Files  2 passed (2)
Tests       13 passed (13)

였습니다.

여기에는 apply_patch 관련해서

checkOnly 검증
실제 patch 적용
reverse 적용
잘못된 patch의 error 처리
--3way 적용
MCP를 통한 Tool 호출

이 포함되어 있습니다.

그리고 --unsafe-paths의 작업 디렉터리 밖 수정은 프로젝트 테스트와 별도로 격리된 /tmp에서 직접 확인했습니다.

결국 왜 apply_patch일까?

다시 처음 질문으로 돌아가봤습니다.

파일 전체를 다시 쓰면 되는데 왜 AI는 patch를 사용할까?

cokacremote만 보고 “AI가 반드시 patch를 선택하는 이유”까지 알 수는 없습니다.

그 선택은 Agent 쪽에 있기 때문입니다.

대신 apply_patch를 까보니 코드 수정에 왜 잘 맞는지는 보였습니다.

flowchart TD; A[현재 코드 읽기] --> B[필요한 부분만 diff 생성]; B --> C[apply_patch]; C --> D[git apply --check]; D -->|성공| E[git apply]; D -->|실패| F[에러 반환]; F --> G[파일 다시 읽기]; G --> B;

전체 파일을 덮어쓰지 않고 변경분만 전달할 수 있고,

적용 전에 git apply --check로 현재 파일과 맞는지 확인할 수 있고,

실패하면 에러를 받아 현재 상태를 다시 읽고 새 patch를 만들 수 있습니다.

AI Agent가

읽기
→ 수정
→ 실행
→ 확인
→ 실패하면 다시 읽기

를 반복하는 구조와 꽤 잘 맞습니다.

그리고 cokacremote는 그 반복에서 필요한 도구를 제공합니다.

read_file
apply_patch
exec_command
read_process

1부부터 여기까지 보고 나니 처음의 “ChatGPT에 내 개발환경을 연결했다”는 말도 조금 다르게 보입니다.

실제로는

HTTP 요청을 MCP Tool로 바꾸고
↓
Tool이 OS process와 filesystem으로 내려가고
↓
결과를 다시 Agent에게 돌려주고
↓
Agent가 다음 행동을 결정하는 구조

였습니다.

까보니 특별한 마법은 없었습니다.

익숙한 HTTP, process, filesystem, Git이 이어져 있을 뿐입니다.

그런데 이걸 Agent가 반복해서 사용할 수 있게 연결해두니 개발 방식이 꽤 달라졌습니다.

처음 cokacremote를 Docker에 띄울 때는 그냥 한번 써보자는 생각이었는데,

코드까지 열어보니 왜 이걸로 실제 개발이 되는지 이제 좀 알겠습니다.

딸칵질 뒤에서 무슨 일이 일어나는지 드디어 다 봤습니다.


시리즈

  • Codex 토큰을 다 썼다. 그래서 웹을 써봤습니다.
  • cokacremote 톺아보기 1부 — MCP Server는 어떻게 시작될까?
  • cokacremote 톺아보기 2부 — AI가 명령을 실행한다는 것
  • cokacremote 톺아보기 3부 — AI는 왜 apply_patch를 사용할까?

참고

  • cokacremote
  • git apply documentation
  • 분석 기준 commit: 1bb0f767c4249e8536bcee98341808bebcbbebaa


aichatgptmcpcokacremotegit Share Tweet +1