๐ ๏ธ ๊ฐ๋ฐ ํ๊ฒฝ
- LM Studio
- n8n
- GitLab
โ๏ธ ์ค์ ๋ฐฉ๋ฒ
์ด ๊ตฌ์ฑ์ GitLab์์ MR(Merge Request)์ด ์์ฑ๋ ๋, n8n๊ณผ LLM ๊ธฐ๋ฐ ์ฝ๋ ๋ฆฌ๋ทฐ ์์คํ ์ด ์๋์ผ๋ก ์ฝ๋๋ฅผ ๋ถ์ํ๊ณ GitLab์ ๋ฆฌ๋ทฐ๋ฅผ ๋ฑ๋กํ๋ ํ๋ก์ธ์ค๋ค. ์ด ๊ณผ์ ์ ํตํด ๋ฆฌ๋ทฐ ์๋์ ํ์ง์ ๋์ผ ์ ์๋ค.
LM Studio ์ค์

LM Studio๋ฅผ ์ค์นํ ํ, gpt-oss-20b ๋ชจ๋ธ์ ์ค์นํ๊ณ , ํ๊ฒฝ ์ค์ ์์ ๋ค์ ํญ๋ชฉ์ ๋ฐ๋์ ์ฒดํฌํ๋ค.
- When applicable, separate
reasoning_contentandcontentin API responses

์ด ํญ๋ชฉ์ ์ฒดํฌํ์ง ์์ผ๋ฉด LLM์ด ์๋ตํ ๋ reasoning ๊ฒฐ๊ณผ๊ฐ content์ ํจ๊ป ํฌํจ๋์ด ์ ๋ฌ๋๊ธฐ ๋๋ฌธ์, API ์๋ต ํ์ฑ ์ ๋ถํ์ํ ๋ฐ์ดํฐ๋ฅผ ๋ค๋ฃจ๊ฒ ๋๋ค.
ํฌํธํฌ์๋ฉ ์ค์ ์ ํตํด ์ธ๋ถ์์ LM Studio API๋ฅผ ํธ์ถํ ์ ์๋๋ก ํ๊ฒฝ์ ๊ตฌ์ฑํด์ผ ํ๋ค. ์ด ๊ณผ์ ์ ํ ๋ธ๋ก๊ทธ์์ ๋ค๋ฃจ๋ ํฌํธํฌ์๋ฉ ๋ฐฉ๋ฒ์ ์ฐธ๊ณ ํ๋ฉด ๋๋ค.
n8n ์ค์
1. Webhook ๋ ธ๋ ์์ฑ

n8n์์ Webhook ๋ ธ๋๋ฅผ ์์ฑํ๊ณ ์ค์ ํ๋ฉด์ผ๋ก ๋ค์ด๊ฐ ๊ธฐ๋ณธ์ ์ธ ๊ฐ์ ์ธํ ํ๋ค.

GitLab์ Webhook์์ Secret token์ ์๊ตฌํ ๋๋ ์์์ UUID ๊ฐ์ ์ ๋ ฅํด๋ ๋ฌด๋ฐฉํ๋ค. ์ด ๊ฐ์ ๋์ค์ GitLab Webhook ์ค์ ์ ๊ทธ๋๋ก ์ฌ์ฉ๋๋ค.
๋ํ, ์ค์ ์ ์ ์ฅํ ๋๋ Production URL์ ์ ํํ๋ ๊ฒ์ ๊ถ์ฅํ๋ค. Test ํ๊ฒฝ๊ณผ Production ํ๊ฒฝ์ด ๋์ผํ๊ธฐ ๋๋ฌธ์, Production ํ๊ฒฝ์ผ๋ก ํ ์คํธํด๋ ํฐ ์ฐจ์ด๊ฐ ์๋ค.
2. GitLab Webhook ์์ฑ

์ฝ๋ ๋ฆฌ๋ทฐ๋ฅผ ๋ฐ์ Repository์ Webhook ์ค์ ์ ์ ์ํด ๋ค์์ ์ค์ ํ๋ค.

- URL: n8n Webhook ๋ ธ๋์ ํ์๋ URL
- Secret token: n8n์ ์ ๋ ฅํ๋ UUID ๊ฐ
- ์ด๋ฒคํธ: Merge request events ์ฒดํฌ
3. ๋์ ํ ์คํธ
์ค์ ์ด ์๋ฃ๋๋ฉด n8n์์ Execute workflow๋ฅผ ํด๋ฆญํ๊ณ , GitLab์ Webhook ์ค์ ํ๋ฉด์์ Test โ Merge request event๋ฅผ ์คํํ๋ค. ์ ์์ ์ผ๋ก ์ฐ๊ฒฐ๋์ด ์๋ค๋ฉด n8n์์ ์์ฒญ์ ์์ ํ๊ณ , ์ํฌํ๋ก์ฐ๊ฐ ๋์ํ๋ ๊ฒ์ ํ์ธํ ์ ์๋ค.
4. ์ ์ฒด์ ์ธ ์ํฌ ํ๋ก์ฐ
๋ชจ๋ ๊ณผ์ ์ ์ค๋ช ํ๊ธฐ์ ๋ด์ฉ์ด ๋ฐฉ๋ํ๊ธฐ์, ๋ด๊ฐ ๋ง๋ ํ์ดํ๋ผ์ธ์ ๋ํด์ ๊ณต์ ๋ง ํ๋๋ก ํ๊ฒ ๋ค.

- MR ์ด๋ฒคํธ๊ฐ ๋ฐ์ํ๋ฉด Webhook ๋ ธ๋๊ฐ ์คํ๋๋ค.
- ํด๋น MR์ด open ์ํ์ธ์ง ํ๋ณํ๋ค.
const state = $input.first().json.body.object_attributes.state;
const action = $input.first().json.body.object_attributes.action;
if (state !== "opened" || action !== "open") {
return [];
}
return $input.all();- ํด๋น MR์ ํฌํจ๋ ๋ณ๊ฒฝ๋ ์ฝ๋ ์ ๋ณด๋ค์ ๊ฐ์ ธ์จ๋ค.
GET /api/v4/projects/{{ $id }}/merge_requests/{{ $iid }}/changes
- ๊ฐ ๋ณ๊ฒฝ ์ฌํญ์ LLM ๋ฆฌ๋ทฐ ๋ฐ Comment๋ฅผ ๋จ๊ธธ ๋ ํ์ํ ๊ฐ์ฒด๋ก ๋งคํํ๋ค.
n8n์์ ์ฝ๋ ๋ฆฌ๋ทฐ๋ฅผ ๋ฑ๋กํ ๋ ๊ฐ์ฅ ์ ๋ฅผ ๋จน์ ๋ถ๋ถ์ ๊ฐ๊ฐ์ hunk๋ฅผ ๊ตฌ๋ถํ๋ ๊ฒ์ด๋ค. GPT์๊ฒ ๋ถํํ์ฌ ์ป์ ๊ฒฐ๊ณผ๋ฌผ์ด์ง๋ง, 100% ๋ง์์ ๋ค์ง๋ ์๋ ๊ฒ ๊ฐ๋ค.
onst mr = $json;
const diffRefs = mr.diff_refs;
/** @@ ํค๋ ๊ธฐ์ค์ผ๋ก diff๋ฅผ hunk ๋ฐฐ์ด๋ก ๋ถ๋ฆฌ */
function splitDiffByHunks(diff) {
// CRLF ๋ฐฉ์ด
const lines = diff.split('\n').map(l => l.replace(/\r$/, ''));
const hunks = [];
let current = [];
for (const line of lines) {
if (line.startsWith('@@')) {
if (current.length) hunks.push(current.join('\n'));
current = [];
}
current.push(line);
}
if (current.length) hunks.push(current.join('\n'));
return hunks;
}
/** @@ -a,b +c,d @@ (b,d ์๋ต ๊ฐ๋ฅ) */
function parseHunkHeader(headerLine) {
const m = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/.exec(headerLine);
if (!m) return null;
const oldStart = parseInt(m[1], 10);
const oldLen = m[2] ? parseInt(m[2], 10) : 1;
const newStart = parseInt(m[3], 10);
const newLen = m[4] ? parseInt(m[4], 10) : 1;
return { oldStart, oldLen, newStart, newLen };
}
/** ๋จ์ผ hunk์์ position๊ณผ changes(hunk ํ
์คํธ) ์ถ์ถ */
function extractFromHunk(hunk, new_path, old_path) {
const lines = hunk.split('\n');
if (lines.length === 0 || !lines[0].startsWith('@@')) return null;
const header = parseHunkHeader(lines[0]);
if (!header) return null;
const { newStart, newLen } = header;
// ์ ๊ท ํ์ผ ์ธก ์ค์ด ํ๋๋ ์๋ hunk(์ญ์ ๋ง ์๋ ๊ฒฝ์ฐ) โ ์คํต
if (newLen === 0) return null;
let oldLine = header.oldStart;
let newLine = newStart;
const changes = [lines[0]];
const addedNewLines = []; // ๋น-import ์ถ๊ฐ ๋ผ์ธ์ new ๋ผ์ธ ๋ฒํธ๋ค
for (let i = 1; i < lines.length; i++) {
const line = lines[i];
changes.push(line);
// Git diff ํน์ ๋ผ์ธ: ๋ผ์ธ ์นด์ดํธ ์ฆ๊ฐ X
if (line.startsWith('\\')) {
continue;
}
const isAdd = line.startsWith('+') && !line.startsWith('+++');
const isDel = line.startsWith('-') && !line.startsWith('---');
if (isAdd) {
const content = line.slice(1);
// import ๋ผ์ธ์ ์ ์ธ(์ ์ฑ
์ ์ง). ํฌํจํ๋ ค๋ฉด ์๋ if ์ ๊ฑฐ
if (!/^\s*import\b/.test(content)) {
addedNewLines.push(newLine);
}
newLine++; // ์ถ๊ฐ ๋ผ์ธ์ new ํ์ผ๋ง ์ฆ๊ฐ
} else if (isDel) {
oldLine++; // ์ญ์ ๋ผ์ธ์ old ํ์ผ๋ง ์ฆ๊ฐ
} else {
// context ๋ผ์ธ
oldLine++;
newLine++;
}
}
// ๋น-import ์ถ๊ฐ ๋ผ์ธ์ด ์๋ค๋ฉด ์ฝ๋ฉํธ ์์น๋ฅผ ๋ง๋ค์ง ์์
if (addedNewLines.length === 0) return null;
// GitLab์ด ์ ํจํ line_code๋ฅผ ๊ณ์ฐํ ์ ์๊ฒ, ์ค์ ์ถ๊ฐ๋ ๋ผ์ธ ์ค ๋ง์ง๋ง ์ค๋ก ์ง์
const anchorNewLine = Math.max(...addedNewLines);
const position = {
position_type: "text",
old_path: old_path, // GitLab diff์ ์ ํํ ์ผ์นํด์ผ ํจ
new_path: new_path, // (rename ๋ฑ ๋๋น, ๋ ๊ฐ ๋ชจ๋ ์ ๋ฌ)
base_sha: diffRefs.base_sha,
start_sha: diffRefs.start_sha,
head_sha: diffRefs.head_sha,
new_line: anchorNewLine
};
return {
json: {
position,
changes: hunk
}
};
}
const results = mr.changes
.flatMap(change =>
splitDiffByHunks(change.diff)
.map(h => extractFromHunk(h, change.new_path, change.old_path))
.filter(Boolean)
);
return results;- LLM์๊ฒ ์์ฒญ์ ๋ณด๋ด๊ธฐ ์ํด changes์ system prompt๋ฅผ ๋งคํํด์ค๋ค.
const systemPrompt = "..."
return $input.all().map(data => {
return {
json: {
model: "openai/gpt-oss-20b",
messages: [
{
role: "system",
content: systemPrompt
},
{
role: "user",
content: data.json.changes
}
]
}
};
});- ๋ชจ๋ ์๋ต์ด ์๋ฃ๋๋ฉด, ์ด์ ๋ฐ์ดํฐ ํํ์ Merge ํ๋ค.
- Gitlab์ Comment ๋ฑ๋ก ์์ฒญ์ ๋ณด๋ธ๋ค.
POST /api/v4/projects/{{ $project_id }}/merge_requests/{{ $iid }}/discussions
์ด ๊ณผ์ ์ ๊ฑฐ์น๋ฉด, LM Studio์ ์์ฒญํ ๋ฆฌ๋ทฐ๋ฅผ ๋ฐ์์ Gitlab์ ๋ฑ๋กํ ์ ์๊ฒ ๋๋ค.

๐ค ํ๊ณ
์ฝ๋ ๋ฆฌ๋ทฐ ์๋ฒ๋ฅผ ๋์ฐ๋ ๋ฐฉ๋ฒ๋ ์์ง๋ง, ๊ฐํธํ๊ฒ n8n์ ์ฌ์ฉํด๋ ์ข์ ๊ฒ ๊ฐ๋ค. n8n์ ์ฌ์ฉํ๋ฉด์ ๋ถํธํ๋ ์ ์, ์ด์ ๊ฒฐ๊ณผ๋ฅผ ๊ณ์ ์ ์งํ๊ธฐ ์ด๋ ต๋ค๋ ๊ฒ์ด๋ค. ์๋ฅผ ๋ค์ด, Comment๋ฅผ ๋จ๊ธธ ๋ ํ์ํ ๋ฐ์ดํฐ๋ฅผ ๊ณ์ ์ ์งํ๋ค๊ฐ, HTTP ์์ฒญ์ ๋ค๋ ์ค๋ฉด ๋ฐ์ดํฐ๋ฅผ ์ด์ ๋ฐ์ดํฐ๋ฅผ ๊ฐ์ ธ์ค๊ธฐ ์ด๋ ต๋ค๋ ๊ฒ์ด๋ค. ๋๋ฌธ์ Merge ๋ ธ๋๋ฅผ ์ฌ์ฉํ๊ธด ํ์ง๋ง, ๋ ํจ์จ์ ์ธ ๋ฐฉ๋ฒ์ด ์๋์ง ์ฐพ์๋ณด๋ฉด ์ข์ ๊ฒ ๊ฐ๋ค.
ํจ๊ณผ์ ์ผ๋ก System prompt๋ฅผ ๋ง๋๋ ค๋ฉด ์ฌ๋ฌ ๋ฒ์ ๊ณผ์ ์ ๊ฑฐ์ณ์ผํ๋ค. ํ์๋ ๋ค์๊ณผ ๊ฐ์ ๊ณผ์ ์ ํตํด ํ๋กฌํํธ๋ฅผ ์์ฑํ๋ค.
- ๊ฐ๋จํ ์ด๊ธฐ ํ๋กฌํํธ๋ฅผ GPT์๊ฒ ๋ง๋ค์ด๋ฌ๋ผ๊ณ ํ๋ค.
- ๋ฆฐํธ ์๋ฌ, ์คํ, ๋นํจ์จ์ ์ธ ์ฝ๋๋ฅผ GPT์๊ฒ ๋ง๋ค์ด๋ฌ๋ผ๊ณ ํ๋ค.
- LM Studio์์ gpt-oss-20b ๋ชจ๋ธ์๊ฒ 1๋ฒ์์ ๋ง๋ System prompt๋ฅผ ๋ฃ๊ณ , 2๋ฒ ์ฝ๋๋ฅผ ์ง์ํ๋ค.
- 1, 2, 3๋ฒ์ ๋ด์ฉ์ Google AI Studio์ Gemini์๊ฒ ์ ๊ณตํ๊ณ , ์์คํ ํ๋กฌํํธ๋ฅผ ์์ ํด๋ฌ๋ผ๊ณ ํ๋ค.
- ๋ง์กฑํ๋ ๊ฒฐ๊ณผ๊ฐ ๋์ฌ ๋๊น์ง 3, 4๋ฒ ๊ณผ์ ์ ๋ฐ๋ณตํ๋ค.
๋๋ System prompt๋ฅผ ๋ง๋ค ๋, ํ 100๋ฒ ์ ๋ ๊ณผ์ ์ ๊ฑฐ์ณค๋๋ฐ, ์์ง๋ ์๋ฒฝํ์ง๋ ์์ ๊ฒ ๊ฐ๋ค. Google AI Studio๋ฅผ ์ฌ์ฉํ ์ด์ ๋, ๋ฌด๋ฃ์ด๊ธฐ๋ ํ์ง๋ง, ๋จ์ํ ํ๋กฌํํธ๋ฅผ ๋ง๋๋ ๊ฒ๋ง ํ๋ฉด ๋๊ธฐ ๋๋ฌธ์, ๊ตณ์ด GPT๋ฅผ ์ฌ์ฉํ ํ์๊ฐ ์์๋ค.
