루프 엔지니어링, 직접 만들어보며 이해하기

GitHub Actions와 Gemini API로 만드는 작은 루프. 저장소를 스스로 점검하고, 고치고, 커밋까지.

지난 글에서 루프 엔지니어링이 뭔지 살펴봤습니다. 이번 글에서는 직접 루프를 만들어 보려고 합니다. 작게라도 한 번 만들어서 돌려보면 개념이 훨씬 또렷해지잖아요? 매일 정해진 시각에 저장소를 점검하고, 문제가 있으면 고치고, 고친 내용을 커밋으로 남기는 작은 루프를 만들어보겠습니다. 글 내용에 따라 한번 직접 해보시면 좋을 것 같아요.

준비물은 깃허브 계정과 구글 계정, 이렇게 두 개입니다. 실습에 쓸 소스 파일은 미리 만들어 올려뒀고, 설치할 프로그램도 없습니다. 전부 브라우저 안에서 끝나고, 비용도 들지 않는 구성으로 진행해보겠습니다.

만들 내용 훑어보기

이번에 만들 루프는 이렇게 돌아갑니다. 먼저 테스트를 실행해서 저장소 상태를 점검해요. 실패한 테스트가 있으면, 지금 코드와 실패 로그를 AI 모델에게 넘깁니다. 모델이 고쳐준 코드를 저장하고 다시 테스트하고요. 통과할 때까지 이 과정을 반복하다가, 통과하면 고친 내용을 커밋으로 남깁니다.

테스트 실행에서 시작해, 실패하면 모델에게 코드와 로그를 넘기고 고친 코드를 저장한 뒤 다시 테스트하는 순환. 통과할 때까지 반복하고, 통과하면 커밋으로 기록한다

지난 글에서 루프 엔지니어링을 "할 일을 찾고 넘기고 검증하고 기록하는 시스템"을 설계하는 일이라고 정리했었죠. 그 네 가지가 위 구조 안에 들어가 있는데요. 할 일을 찾는 게 테스트 실행이고, 넘기는 게 모델 호출, 검증이 재실행, 기록이 커밋입니다. 사람이 하는 일은 이 루프 틀을 한 번 짜두는 것입니다.

지금 만들어볼 구성은 파일 다섯 개가 필요한데, 그중 세 개는 미리 준비해뒀습니다. 우리는 루프를 직접 만드는 데 더 집중해보자고요. (루프에 해당하는 것은 두 개 파일입니다.)

구분파일역할
준비됨server.test.js서버가 갖춰야 할 동작을 적어둔 테스트. 루프가 목표로 삼는 기준
준비됨server.js고칠 대상. 일부러 허술하게 만들어둔 서버 코드
준비됨package.json프로젝트 설정
직접 만들 것loop.js루프 본체. 점검하고, 모델에게 넘기고, 다시 점검
직접 만들 것.github/workflows/loop.yml루프를 언제 실행할지 정해둔 예약표

이번 실습은 GitHub 공개 저장소로 GitHub Actions을 사용하고, AI 호출은 구글 Gemini API의 무료 키로 처리합니다. 비용 소모없이 실습할 수 있도록 이렇게 구성하였습니다.

Step 1. 저장소 만들기

위 표에서 준비됨으로 구분한 파일들은 제가 템플릿 저장소로 올려뒀습니다. 아래 주소에서 버튼 클릭하시면 같은 내용의 저장소가 본인 계정에 만들어집니다. 이렇게 만든 저장소로 실습해보시면 됩니다.

github.com/padawanjoy/loop-engineering-template 접속 → Use this template → Create a new repository

loop-engineering-template 저장소 화면. 오른쪽 위 Use this template 버튼을 누르면 Create a new repository 항목이 나온다

이름은 원하는 대로 정하시면 되고요. Public으로 둬 주세요. 공개 저장소여야 액션 실행이 무료거든요.

Step 2. 실습 파일 살펴보기

만들기 전에, 준비된 내용을 먼저 살펴볼까요? 실습 파일 내용은 북마크를 저장하는 간단한 API입니다. 주소와 제목을 받아서 목록에 넣고, 목록을 리턴해주는 심플한 구성입니다. 그리고 몇 가지 조건을 정해봤는데요. 주소는 https://를 붙이고 끝의 슬래시를 떼는 식으로 정리해서 저장할 것, 이미 등록된 주소가 또 들어오면 거절할 것, 이상한 입력에는 400으로 답할 것. 이렇게 정해봤습니다.

server.test.js에는 이런 조건들이 잘 처리되는지를 테스트하는 스크립트가 적혀있습니다. 지난 글에서 루프의 조건은 기계가 확인할 수 있어야 한다고 했었죠. 루프는 정상 여부를 판단할 수 있는 목표가 필요하고, server.test.js가 바로 그 목표가 되는 것이죠. "이 서버는 이렇게 동작해야 한다"를 코드로 적어두고, 통과 여부가 곧 루프의 판정 기준이 되거든요. 해당 파일 안에는 이런 테스트 일곱 개가 들어 있습니다.

test("등록에 성공하면 201과 정리된 주소를 돌려준다", async () => {
  await withServer(async (base) => {
    const res = await post(base, { url: "Example.com/blog/", title: "예제 블로그" });
    assert.equal(res.status, 201);
    const body = await res.json();
    assert.equal(body.url, "https://example.com/blog");
    assert.equal(body.title, "예제 블로그");
  });
});

각각 빈 목록 조회, 정상 등록, 목록 반영, 중복 차단, 그리고 잘못된 입력 세 가지(제목 없음, 주소 형태 아님, JSON 아닌 본문)를 검사하도록 구성했습니다. Node.js에 내장된 테스트 러너를 쓰기 때문에 따로 설치할 라이브러리 같은 건 없고, package.json에는 이 테스트를 실행하는 명령 한 줄이 들어 있습니다.

루프가 돌아가며 고칠 대상인 server.js는 일부러 허술하게 만들어놨습니다. 열어보면 GET과 POST를 처리하는 뼈대는 있는데 빠진 게 많습니다. 주소를 정리하지 않고 그대로 저장하고, 중복도 확인하지 않고, title이 없어도 받아주고, 등록 성공에 201이 아니라 200을 돌려주고, JSON이 아닌 본문에는 400 대신 500으로 답하도록 해놨습니다. 이 상태로 테스트를 돌리면 실패가 우수수 나오겠죠? 실패가 나와야 루프가 돌고, 우리는 루프를 살펴보기 위해 이 실습을 해보는 거니까 이렇게 만들었습니다.

Step 3. Gemini API 키 준비하기

깃허브에는 별도로 키 발급 같은걸 하지 않아도 액션 안에서 AI를 부를 수 있는 GitHub Models라는 기능이 있었는데요. 아쉽게도 2026년 7월 30일에 서비스가 종료됩니다. 그래서 이 실습이 장기적으로도 유효할 수 있도록, 구글 Gemini API를 사용해서 진행해보겠습니다. 구글 계정만 있으면 결제 정보 없이 무료 키를 받을 수 있습니다.

aistudio.google.com/apikey → API 키 만들기 (Create API key)

Gemini API 키는 구글 클라우드 프로젝트에 속하는 형태로 발급됩니다. 이전에 구글 클라우드를 써보신 적이 있다면 기존 프로젝트를 선택하셔도 되고, 처음이라 No Cloud Projects Available라는 문구가 뜨신다면 프로젝트 생성부터 해주셔야 합니다.

새 키 만들기 화면에서 프로젝트 선택 드롭다운을 열면 프로젝트 가져오기와 프로젝트 만들기 항목이 보인다

프로젝트 이름 작성하고 만들기를 클릭하면 됩니다. 그 다음, 방금 만든 프로젝트를 선택하고 키 만들기를 누르면 Gemini API 키가 발급됩니다.

발급된 키는 깃허브 저장소에 아래 경로 참고해서 등록합니다.

저장소 Settings → Secrets and variables → Actions → New repository secret

깃허브 저장소 Settings의 Secrets and variables → Actions 화면. New repository secret 버튼을 눌러 시크릿을 추가한다

Name에는 GEMINI_API_KEY, Secret에는 방금 받은 키를 붙여 넣어 주세요.

사용하는 Gemini API가 무료이긴 하지만 한도가 있습니다. 한도에 대한 정확한 수치는 AI Studio의 rate limit 화면에서 확인할 수 있습니다. 이번에 만드는 루프는 실행 한 번에 많아야 다섯 번 호출을 하니까 무료 한도로 충분합니다.

Step 4. 루프 만들기

이제 루프 본체인 loop.js를 만들 차례입니다. 루프가 하는 일은 앞에서 그린 도식 그대로입니다. 먼저 테스트를 돌려보고, 다 통과하면 그대로 끝나고, 실패한 게 있으면 그때부터 루프가 돕니다. 실패한 테스트가 있으면 server.js 코드와 테스트 파일, 그리고 실패 로그를 Gemini에게 보냅니다. Gemini가 고쳐서 돌려준 코드로 server.js를 바꾸고, 다시 테스트를 돌립니다. 수정이 정말 잘 되었는지 보는 거죠.

한 번에 다 못 고칠 수도 있어요. 그러면 바뀐 코드로 또 테스트하고, 또 실패하면 다시 Gemini에게 보내고. 이렇게 통과할 때까지 반복합니다. 다만 끝없이 돌면 안 되니까 실습에서는 상한을 다섯번까지로 뒀습니다. 다섯 번을 시도해도 통과하지 못하면 루프를 멈추고 로그를 남기도록 하겠습니다.

저장소에서 loop.js 파일을 만들어 보겠습니다.

저장소 첫 화면 → Add file → Create new file

파일 이름은 loop.js로 하고, 내용은 아래 그대로 적용해주세요.

import { execSync } from "node:child_process";
import { readFileSync, writeFileSync } from "node:fs";
 
const TARGET = "server.js";
const MAX_FIXES = 5;
const MODEL = "gemini-2.5-flash";
 
if (!process.env.GEMINI_API_KEY) {
  console.log("GEMINI_API_KEY가 없습니다. 저장소 Secrets에 등록했는지 확인해 주세요.");
  process.exit(1);
}
 
function runTests() {
  try {
    execSync("node --test server.test.js", { encoding: "utf8", stdio: "pipe" });
    return { pass: true, log: "" };
  } catch (error) {
    return { pass: false, log: `${error.stdout ?? ""}\n${error.stderr ?? ""}` };
  }
}
 
async function requestFix(log) {
  const response = await fetch(
    "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions",
    {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.GEMINI_API_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        model: MODEL,
        temperature: 0.2,
        messages: [
          {
            role: "system",
            content:
              "당신은 Node.js 개발자입니다. server.test.js를 통과하도록 server.js를 고치는 일을 맡았습니다. " +
              "답변에는 수정한 server.js 전체를 자바스크립트 코드 블록 하나로만 작성하세요. " +
              "설명은 쓰지 말고, server.test.js는 절대 수정하지 마세요.",
          },
          {
            role: "user",
            content:
              `테스트 파일(server.test.js):\n\`\`\`js\n${readFileSync("server.test.js", "utf8")}\`\`\`\n\n` +
              `현재 코드(server.js):\n\`\`\`js\n${readFileSync(TARGET, "utf8")}\`\`\`\n\n` +
              `테스트 실패 로그:\n\`\`\`\n${log.slice(-4000)}\n\`\`\``,
          },
        ],
      }),
    },
  );
 
  if (!response.ok) {
    throw new Error(`모델 호출에 실패했습니다: ${response.status} ${await response.text()}`);
  }
  const data = await response.json();
  return data.choices[0].message.content;
}
 
function extractCode(answer) {
  const match = answer.match(/```(?:js|javascript)?\s*\n([\s\S]*?)```/);
  return match ? match[1] : answer;
}
 
let result = runTests();
let fixes = 0;
 
while (!result.pass && fixes < MAX_FIXES) {
  fixes += 1;
  console.log(`테스트 실패. ${fixes}번째 수정을 요청합니다.`);
  console.log(result.log);
 
  const answer = await requestFix(result.log);
  writeFileSync(TARGET, extractCode(answer));
  result = runTests();
}
 
if (result.pass) {
  console.log(`테스트 통과. 수정 ${fixes}번으로 루프를 마칩니다.`);
} else {
  console.log(`${MAX_FIXES}번 고쳐도 통과하지 못했습니다. 로그를 확인해 주세요.`);
  process.exit(1);
}

코드 내용을 좀 살펴볼게요.

모델에게 고친 파일 전체를 코드 블록 하나로 달라고 요청했습니다. 바뀐 부분만 받는 방식도 있지만, 파일이 작을 때는 통째로 받아서 덮어쓰는 쪽이 훨씬 단순해서 이렇게 했고요. extractCode 함수가 답변에서 코드 블록만 꺼내는 역할을 합니다.

MAX_FIXES는 안전장치라고 할 수 있습니다. 모델이 계속 못 고칠 수도 있는데, 그럴 때 루프가 멈추지 않고 계속 돌면 곤란하니까요. 그래서 몇 번까지만 시도할지 미리 MAX_FIXES로 정해두었습니다. 지난 글에서 /goal에 횟수 제한을 걸어둔 것과 같은 이유죠. 이번 실습에서는 MAX_FIXES를 다섯 번으로 해두었습니다. 다섯 번 안에 통과하지 못하면 실패로 마치고 로그를 남깁니다.

temperature는 답변의 변화 폭 (높을 수록 창의적인 답)을 정하는 값인데, 낮게 잡았습니다. 창의적인 답보다 일관된 수정이 필요하니까요.

모델은 gemini-2.5-flash를 넣었습니다. 무료 티어에서 쓸 수 있고, 이 정도 코드 수정은 무리 없이 해낼 수 있는 좋은 모델이기도 하고요. 더 최신 모델을 쓰고 싶으면 이 이름만 바꾸면 되고요. 참고로 이 코드는 OpenAI가 정한 요청 형식을 따르도록 했습니다. 요즘 AI 서비스들이 대부분 이 형식을 같이 지원하는데요. 그래서 나중에 다른 API로 옮기고 싶을 때, 주소와 키만 바꾸면 나머지 코드는 그대로 쓸 수 있어서 편합니다.

Step 5. 예약표 만들기

마지막 파일입니다. 이 루프를 언제 실행할지 정하는 워크플로우 역할을 하는데요. .github/workflows/loop.yml이라는 이름으로 만들어 주세요. 파일 이름 칸에 경로를 그대로 입력하면 폴더가 같이 만들어집니다.

name: loop
 
on:
  workflow_dispatch:
  schedule:
    - cron: "0 21 * * *"
 
permissions:
  contents: write
 
jobs:
  check-and-fix:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
 
      - uses: actions/setup-node@v4
        with:
          node-version: 22
 
      - name: 점검하고 고치기
        run: node loop.js
        env:
          GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
 
      - name: 수정 사항 기록하기
        run: |
          git config user.name "loop-bot"
          git config user.email "loop-bot@users.noreply.github.com"
          git add server.js
          git diff --cached --quiet || git commit -m "loop: 실패한 테스트를 고쳤습니다"
          git push

루프가 시작하는 조건은 두 가지입니다. workflow_dispatch는 화면에서 누르는 수동 실행 버튼이고, schedule은 예약 실행입니다. cron이라는 표기법으로 시각을 적는데, "0 21 * * *"는 매일 UTC 21시, 한국 시간으로 아침 6시입니다. 매일 아침 6시마다 루프가 저장소를 점검한다는 뜻입니다.

permissionscontents: write는 워크플로가 저장소에 커밋을 남길 수 있게 허용하는 설정입니다. 루프가 고친 server.js에 변경이 있으면 loop-bot 이름으로 커밋하고 푸시하는데, 이때 이 권한이 필요합니다.

'커밋이 또 워크플로우를 실행시키면 무한 반복이 되는 거 아닌가?' 이렇게 생각하실 수도 있을 텐데요. 기본 토큰으로 푸시한 커밋은 다른 워크플로우를 새로 실행하지 않도록 깃허브가 막아둬서, 그런 일은 생기지 않습니다. 그리고 저장소에 60일 동안 활동이 없으면 예약 실행이 자동으로 꺼진다는 점도 참고로 알아두면 좋습니다.

Step 6. 돌려보기

이제 직접 실행해 봅니다.

저장소 Actions 탭 → 왼쪽에서 loop 선택 → Run workflow 버튼

깃허브 Actions 탭에서 왼쪽 목록의 loop를 선택하고, 오른쪽 Run workflow 버튼을 눌러 워크플로를 수동 실행한다

실행이 끝나면 로그에서 루프가 돈 과정을 그대로 볼 수 있습니다. 처음에 테스트 실패 로그가 나오고, "1번째 수정을 요청합니다"가 찍히고, 다시 테스트가 돌고요. 모델이 한 번에 다 고치면 수정 한 번으로 끝나고, 놓친 게 있으면 두 번, 세 번으로 늘어납니다. 마지막에 "테스트 통과"가 나오면 성공이에요.

check-and-fix 로그. 테스트 실패로 1번째 수정을 요청한 뒤, 일곱 개 중 여섯 개가 실패했다가 수정 한 번으로 테스트를 전부 통과하고 루프를 마친다

저장소 첫 화면으로 돌아가면 커밋 목록에 loop-bot이 남긴 커밋이 하나 생겨 있을 거예요. server.js를 열어보면 재료로 가져왔던 허술한 코드가 아니라, 테스트를 전부 통과하는 코드로 바뀌어 있습니다. 실습이라 간단하게 구성했지만, 실제 프로젝트라고 한다면, 이 저장소에 올라오는 코드들을 루프가 알아서 체크하며 오류들을 수정하고 개선해 나가는 모습이겠죠. AI를 정말 동료처럼 사용할 수 있게 해주는 것이 바로 루프 엔지니어링입니다.

Step 7. 직접 망가뜨려 보기

한 가지를 더 해볼까요? 실제 프로젝트를 수행하다보면 실수를 할 수도 있고, 잘못된 코드를 푸시하게 되는 경우도 있잖아요? 그런 경우처럼 일부러 코드를 망가뜨려 보세요. server.js를 열어서 중복을 확인하는 부분이든 주소를 정리하는 함수든, 한 덩어리를 지우고 커밋해 보세요.

우리 루프는 매일 아침 6시에 돌아가도록 해놨으니, 자고 일어나면 밤사이 고쳐진 커밋이 하나 남아 있는 겁니다. 루프가 실패한 테스트를 발견하고 지워진 부분을 다시 채워 넣은 것입니다.

마치며

깃허브 계정과 구글 계정만으로, 테스트를 목표 삼아 스스로 점검하고 고치고 커밋까지 남기는 루프를 만들었습니다. 규모는 작아도 할 일 찾기, 넘기기, 검증, 기록이라는 루프의 뼈대는 전부 들어 있었습니다. 실제 에이전트 도구들이 도는 방식도 결국 이 구조에서 크게 벗어나지 않습니다. 본 글에서 다룬 대로 해보시면서 루프 엔지니어링이 뭔지 더 잘 이해하고, 루프의 힘을 체감하는 데 도움이 되었으면 좋겠습니다.