AI가 팀 규칙을 지키도록 하는 방법
2026년 9월 8일
원문에서 보기 ↗안녕하세요, 토스뱅크 ML Engineer 김경윤입니다.
지난 글 DS와 MLE가 함께 일하는 법에서, 마지막에 이런 문장을 한 줄 남겼어요.
AI로 코드를 작성하게 되면서, 역할 나누기를 넘어 코드 스타일까지 맞추는 게 새로운 협업 과제가 됐습니다.
오늘은 위 내용에 이어서 얘기해보려고 합니다. Coding Agent로 코드를 작성하면서 겪은 문제와, 그걸 어떻게 풀었는지에 대한 이야기예요. 결론부터 말하면, 저희가 찾은 답은 Agent Loop 안에 Feedback Loop를 하나 더 심는 것이었습니다.
Coding Agent로 작성하면서 시작된 문제
예전엔 이게 문제가 아니었습니다. 사람이 직접 코드를 작성했으니까요.
사람이 코드를 작성할 때는 다른 사람이 작성한 코드를 보면서 맥락을 파악해요. 리뷰를 주고받으면서 "아 이 팀은 이렇게 쓰는구나"를 자연스럽게 인지하게 되고요. 굳이 문서로 안 적어도 시간이 알아서 맞춰줬습니다.
그런데 에이전트는 전반적인 코드 맥락을 보지 않습니다. 수정이 필요한 일부 맥락만 파악하고, 그 안에서만 코드를 작성하다 보니, 큰 틀의 규칙을 잘 지키지 못하는 문제가 발생합니다.

처음엔 그냥 말로 알려줬습니다. "여기는 Enum을 쓰는 게 좋을 것 같아요", " if-else 보다 match을 쓰는 게 좋을 것 같아요" 같은 피드백을 리뷰마다 남기는 식이었죠.
그런데 이 방식은 결국 팀 약속을 각자의 기억에 맡기는 셈이 됩니다. 아는 사람이 있으면 고쳐지고, 아무도 못 보면 그냥 넘어가요.
그래서 다음으로 간 게 AGENTS.md 같은 Global Instruction이었어요. 프로젝트 루트에 두는 안내 문서인데, 세션이 시작될 때 에이전트가 이 문서를 먼저 읽고, 그대로 따르려고 합니다.
그런데 여기에 적어두는 것만으로는 해결되지 않았습니다.
Lost in the Middle
Global Instruction을 적용하고, 며칠 써보니 이상한 패턴이 보였습니다. 세션 초반엔 잘 지키지만, 길어지면 잘 안 지키는 패턴이었어요.
이 문제는 흔히 Lost in the Middle(긴 입력을 줬을 때 가운데 있는 내용을 모델이 잘 못 쓰는 현상)이라고 부르는 상황입니다. Context 오염이라고도 하고요.
세션 시작에 규칙을 읽긴 읽습니다. 그런데 그 뒤로 파일 읽기, 코드 작성, 테스트 결과가 계속 쌓입니다.
30번째 바퀴쯤 되면 에이전트는 세션 시작에 읽은 규칙보다 방금 읽은 코드와 맥락을 더 믿습니다. 맨 앞에 넣어둔 Context는 조용히 무시하게 되고요.
모델이 강하게 반응하는 자리는 맨 앞과 맨 뒤입니다. 문제는 대화가 길어질수록 맨 앞이 더 이상 '맨 앞'처럼 느껴지지 않는다는 거예요. 규칙은 어느새 한가운데, 가장 안 보는 자리에 가 있어요.

여기에 문제가 하나 더 있는데요. 세세한 규칙은 Global Instruction에 적기가 부담스럽다는 점입니다.
'외부 라이브러리 타입은 반드시 내부 DTO로 한 번 매핑해야 한다' 같은 것은 한 줄로 적을 수 있어요. 그런데 저희가 리뷰에서 실제로 주고받는 지적은 훨씬 자세합니다.
asyncio.gather대신asyncio.TaskGroup쓰기 하나가 실패해도 나머지 코루틴이 계속 돌면서 메모리를 붙잡고 있습니다.- trace id를uuid.uuid4().hex로 직접 만들지 않기 팀 공통 라이브러리의 generate_trace_id()를 써야 값이 잘 Relay 됩니다.
- 테스트의 기대값을 코드를 참조하지 않기 그 설정이 틀어지면 테스트도 같이 변경되어 못 잡습니다.
- Kafka 토픽 이름은{domain}.{phase}.{explain}.{version} 규칙을 지키기
이런 걸 다 적으면 어떤 프로젝트에서 작업하든 Context를 크게 차지합니다. 규칙이 늘어날수록 그중 하나하나를 지키기는 오히려 더 어려워지고요. 앞에서 본 문제가 그대로 심해지는 거죠.
즉 문제는 '무엇을 알려주느냐'가 아니었습니다. '**언제, 몇 개를 알려주느냐'**였어요. 그래서 저희는 이 세세한 규칙들을 언제 어떻게 꺼낼지 고민하기 시작했어요.
Coding Agent는 어떻게 동작할까
먼저 Coding Agent가 어떻게 동작하는지부터 다시 봤습니다. 크게, 다섯 단계로 볼 수 있어요.

- Pre-Define된 Context가 먼저 실려요.
- 사용자가 필요한 일을 말해요.
- LLM이 읽고 판단해요.
- Tool Calling으로 실제 일을 해요.
- 결과를 다시 LLM에게 돌려줘요.
그리고 마지막 세 단계는 목표를 이룰 때까지 반복됩니다.
여기서 중요한 지점이 있는데요. 사용자가 요청을 한 번 하면, 에이전트는 그 안에서 Tool Calling을 수십 번 반복합니다. 파일 하나 고칠 때마다 한 번, 테스트 돌릴 때마다 한 번씩이요.
그런데 지금까지 팀 약속이 들어가던 자리는 맨 앞 한 곳뿐이었습니다. 반복 구간에는 아무것도 안 들어가고 있었던 거죠.
Linter Feedback Loop에서 가져온 아이디어
여기서 오래된 기억이 하나 떠올랐습니다.
LLM 성능이 지금 같지 않던 시절엔, 코드를 한 번에 제대로 못 짰어요. 그래서 흔히 쓰던 방법이 Linter(코드에 문법 오류나 나쁜 습관이 있는지 자동으로 검사해주는 도구)를 함께 쓰는 것이었습니다.
LLM이 코드를 쓰면 → Linter를 돌려서 → 에러 메시지를 다시 LLM에게 넣어주고 → 고치게 하고 → 통과할 때까지 반복하는 방법이었죠. 사람이 중간에서 맥락을 계속 날라줬습니다.
이 방식엔 장점이 하나 있었습니다. 방금 작성한 코드에 바로 코멘트를 남길 수 있다는 것이에요. "아까 세션 시작에 말했잖아"가 아니라, "방금 네가 쓴 이 줄이 문제야"라고 말할 수 있는 거죠.

그래서 Linter 자리에 팀 컨벤션을 넣으면 되겠다는 아이디어를 떠올렸어요. Linter는 "이건 문법이 틀렸어"를 말하는데, 저희가 넣고 싶은 건 "이건 우리 팀 방식이 아니야"거든요. 검사하는 내용만 다르고 흐름은 똑같습니다.
코드를 작성한 직후에 피드백하기
여기서 쓰는 장치가 Hook입니다. 에이전트가 특정 순간에 다다르면 자동으로 실행되는 작은 스크립트인데요. 세션이 시작될 때, 파일을 작성한 직후처럼 정해진 시점에 끼어들 수 있습니다.
저희가 고른 자리는 파일을 작성한 직후예요. 에이전트가 파일을 하나 작성하면, 그 직후에 방금 쓴 코드 본문을 훑어서 관련 규칙을 골라 넣습니다.
이렇게 팀 규칙을 모아두고 각 Hook에 꽂아주는 플러그인을 만들었어요. 이름은 pfmls-stylepack이에요. 아래에서는 줄여서 'Stylepack'이라고 부를게요.
규칙 파일 하나가 곧 규칙 하나입니다. 언제 띄울지를 정하는 발동 조건과, 실제로 에이전트에게 들어갈 문장이 한 파일에 같이 있어요.
id: enum-exhaustive-branch
level: SHOULD
triggers: # 발동 조건
file_pattern: ["*.py"]
code_regex:
- 'case \w+\.\w+\s*:' # enum 멤버로 나누는 match 문
exclude_pattern: ["test_*.py", "conftest.py"]
context_injection: | # 실제로 에이전트에게 들어가는 문장
Exhaustive enum branches
When dispatching over enum members, make the type checker enforce
coverage: a declared return type turns a missed member into an
invalid-return-type error; side-effect (no-return) dispatches must
end with `case _: assert_never(x)`. A new enum member should fail
`ty check`, never silently skip a branch
여기서 중요한 건 속도예요. Hook은 파일을 작성할 때마다 도니까 즉시 끝나야 해요. 그래서 AI를 부르지 않고 정규식과 파일명 패턴 같은 간단한 방법으로 규칙을 골라요.
사실 처음엔 "정규식은 부정확하니 AI가 관련성을 판정하게 하자"고 생각해서 붙여봤어요. 그런데 재보니 요청 하나당 10초가 더 걸렸어요. Hook이 매번 AI를 부르니까요.
관련 있는지 없는지의 최종 판단은 어차피 규칙을 받아 든 에이전트가 하면 되거든요. 저희는 후보를 좁혀주기만 하면 돼요.
실제 동작 예시
팀원이 Verdict에 PASS를 하나 더 넣고, "새 판정값 추가했는데 알림 쪽도 좀 봐줘"라고만 입력했어요. 팀 약속 얘기는 한 마디도 안 했고요.
작업 중인 코드는 아래와 같았습니다.
class Verdict(StrEnum):
REJECT = "REJECT"
REVIEW = "REVIEW"
PASS = "PASS" # 새로 추가한 Case
class ReviewNotifier:
def notify(self, verdict: Verdict) -> None:
match verdict:
case Verdict.REJECT:
self._mailer.send_rejection()
case Verdict.REVIEW:
self._queue.enqueue_review()
# Verdict.PASS 가 조용히 빠져도 아무도 모르는 상태예요
문제가 눈에 잘 안 보입니다. 방금 추가한 PASS를 처리하는 분기가 없는데도, **아무데서도 에러가 안 나요.**알림만 조용히 안 나갈 뿐입니다. 반환값이 없는 함수라 타입 검사기가 걸릴 자리가 없거든요.
에이전트가 이 파일을 고쳐 쓰는 순간, Hook이 코드 본문에서 case Verdict.REJECT: 같은 모양을 보고 enum 분기 규칙을 걸어요.
그러면 이런 내용이 에이전트에게 바로 들어갑니다.
[PF MLS Team Convention] --- apply these to your change:
Exhaustive enum branches
When dispatching over enum members, make the type checker enforce coverage:
a declared return type turns a missed member into an invalid-return-type
error; side-effect (no-return) dispatches must end with
`case _: assert_never(x)`. A new enum member should fail `ty check`,
never silently skip a branch.
(rule: enum-exhaustive-branch)
그러면 에이전트가 이 피드백을 읽고, 고칠 게 있는지 스스로 판단해서 이렇게 바꿔요.
class ReviewNotifier:
def notify(self, verdict: Verdict) -> None:
match verdict:
case Verdict.REJECT:
self._mailer.send_rejection()
case Verdict.REVIEW:
self._queue.enqueue_review()
case Verdict.PASS:
self._audit.record_pass()
case _:
assert_never(verdict)
빠져 있던 PASS 분기가 채워졌습니다. 앞으로 값이 하나 더 늘어도 assert_never에 걸리기 때문에, 실행해 보기 전 타입 검사 단계에서 바로 알 수 있고요.
다만 같은 얘기를 계속 반복하면 그것대로 Context를 잡아먹어요. 그래서 한 번에 최대 2개, 같은 규칙은 세션당 한 번만 넣도록 제한했습니다.
남은 문제, 전체적인 그림
이 Feedback Loop로 대부분의 문제가 풀렸습니다.
그런데 이걸로도 안 풀리는 종류가 하나 남았어요. 파일 하나만 봐서는 알 수 없는 문제인데요. 이번엔 저장소 계층을 새로 만드는 작업이었어요. 에이전트가 파일 두 개를 작성했어요.
# repository.py --- 인터페이스와 구현을 나눠서 잘 만들었어요
class VerdictRepository(Protocol):
def find_pending(self) -> list[Verdict]: ...
class VerdictRepositoryImpl:
def __init__(self, session: AsyncSession) -> None:
self._session = session
def find_pending(self) -> list[Verdict]: ...
# service.py --- 그런데 서비스가 인터페이스(VerdictRepository)가 아니라 구현체(VerdictRepositoryImpl)를 붙잡고 있어요
class VerdictService:
def __init__(self, repository: VerdictRepositoryImpl) -> None:
self._repository = repository
파일 하나씩 보면 둘 다 멀쩡합니다. repository.py는 인터페이스와 구현을 제대로 나눴고, service.py는 생성자로 주입을 받고 있어요. 두 파일을 나란히 놓고 봐야 '서비스가 인터페이스가 아니라 구현체에 묶여 있다'가 보여요.
코드를 작성한 직후에 도는 Hook은 방금 쓴 그 파일 하나만 보기 때문에 이걸 잡지 못합니다.
- repository.py를 쓸 때: 2계층 저장소 규칙이 떴어요. 그런데 이 파일은 규칙을 잘 지켰으니 에이전트는 그냥 넘어가요.
- service.py를 쓸 때: 이 파일엔 class ...Repository 선언이 없어서 규칙이 아예 안 떠요.

그래서 Agent Loop가 마무리되는 자리에 한 번 더 넣기로 했어요.
에이전트가 "다 했어요" 하고 끝내려는 순간, 이번 작업의 변경 전체(git diff)를 다시 봅니다. 이제 두 파일이 한 덩어리로 들어오니까, 같은 규칙이 실제 위반을 짚을 수 있어요.
[PF MLS Stylepack] Before wrapping up, your changes touch the team conventions below.
Please double-check them once. If your code already follows them, leave it as-is and
finish --- this is a reminder to review, not a hard failure.
Two-tier repository
Define a domain repository interface (Protocol/ABC) with business methods; put DB/client
access in an implementation. Services depend on the interface only --- never on the ORM
session or an external client directly. Tests use an in-memory fake
그러면 에이전트가 service.py의 타입을 인터페이스로 되돌립니다.
class VerdictService:
def __init__(self, repository: VerdictRepository) -> None:
self._repository = repository
타입 이름 하나 바뀐 것뿐이지만, 이제 테스트에서 DB를 띄우지 않고 가짜 구현을 넣을 수 있습니다. 두 자리는 같은 규칙 저장소를 쓰지만, 성격이 달라요.
- 코드 작성 직후: 방금 쓴 파일 본문 하나, 빠르게, 최대 2개. 지금 쓰는 코드를 그 자리에서 고치게 하는 게 목적이에요
- 끝내기 직전: 이번 변경 전체, 조금 느리게, 최대 4개. 마지막 안전망이에요
여기까지를 한 장으로 정리하면 이런 그림입니다.

팀 공통 Context 주입과 실시간 전파
Stylepack이 Feedback Loop만 하는 건 아닙니다. 세션이 시작할 때 하는 일이 하나 더 있어요.
바로 레포의 언어 환경에 맞는 팀 공통 Context를 넣어주는 일이에요. 레포에 pyproject.toml이 있으면 Python 안내를, build.gradle이 있으면 Spring 안내를 붙입니다. Python 레포에 Kotlin 규칙이 뜨면 방해만 되니까요.
그런데 이걸 만들면서 풀어야 했던 문제가 하나 더 있었어요. 전파예요.
보통 새 Python 서비스를 만들 때는 Template 레포를 복사해서 시작합니다. 그 안에는 AGENT.md도 들어 있고, 팀 약속을 적어두기에 딱 좋은 자리로 보였어요.
문제는 복사하고 나면 끝이라는 것이었습니다. 원본을 아무리 좋게 고쳐도, 이미 복사돼 나간 서비스 레포로는 그 변화가 흘러가지 않아요. 템플릿은 복사되는 순간부터 늙기 시작합니다.
그래서 방향을 뒤집었어요. 각 레포에 규칙을 복사해 두는 게 아니라, 중앙 저장소 하나에 두고 플러그인이 주기적으로 당겨오게 했습니다. 세션이 시작될 때마다 백그라운드로 최신 규칙을 받아와요. 팀원은 아무것도 안 해도 늘 최신 규칙을 쓰게 되죠.

Stylepack 자체의 Feedback Loop
여기까지 만들고 나니 겉으로는 잘 돌아가는 것 같았습니다. 그런데 "정말 모든 규칙이 제대로 먹히고 있나?"라고 스스로 물어보니, 답을 하기가 어려웠어요. 에이전트에게 Feedback Loop를 만들어줬으면, 저희 규칙에도 Feedback Loop가 있어야 하는데요. 그래서 규칙이 뜨는 모든 순간을 파일에 한 줄씩 남기기 시작했습니다.
- 어떤 규칙이 어느 시점에 적용되었는지
- 어떤 조건 때문에 적용되었는지
조건이 너무 간단해서, 불필요한 Feedback을 하는 경우
enum 멤버를 키로 쓴 dict를 잡는 규칙이에요. 데이터를 enum 밖에 흩어놓지 말라는 규칙이죠.
id: enum-keyed-dict-to-member
triggers:
file_pattern: ["*.py", "*.kt"]
code_regex:
- '\{\s*[\s\S]{0,80}?\b\w+\.\w+\s*:' # 이름.이름 다음에 콜론이 오는 모양
- '\bmapOf\(\s*[\s\S]{0,80}?\b\w+\.\w+\s+to\b'
이 조건은 이름 · . · 이름 · : 이 순서로 이어지는 자리를 찾아요. 그런데 이 순서를 만족하는 코드가 하나 더 있었습니다.
# ⭕ 잡고 싶던 코드 --- dict 항목. 콜론 뒤에 공백이 있어요
{Verdict.REJECT: "reject"}
# ❌ 실제로 걸린 코드 --- f-string 포맷 스펙. 콜론 뒤에 바로 점이 와요
f"{item.score:.2f}"
item.score:도 이름 · . · 이름 · : 이거든요. 그래서 dict가 아닌데도 그대로 걸렸습니다. 21개 세션에서 떴는데 코드가 바뀐 건 0이었어요. 둘을 갈라내는 건 콜론 바로 뒤 한 칸이었습니다. 발동 조건 끝에 공백 하나를 더 요구하는 것으로 해결됐어요.
- '\{\s*[\s\S]{0,80}?\b\w+\.\w+\s*:\s' # 맨 뒤에 \s 하나를 붙였어요
이런 Feedback이 쌓이면 에이전트는 규칙 자체를 흘려듣게 됩니다. 틀린 지적은 안 하느니만 못하죠.
조건이 너무 까다로워서, 필요한 케이스에 Feedback을 하지 않는 경우
반복문 안에서 저장소를 조회하지 말라는 규칙이에요. 흔히 N+1이라고 부르는 문제죠.
id: n-plus-one-in-loop
triggers:
file_pattern: ["*.py", "*.kt"]
code_regex:
- 'for\b[\s\S]{0,160}?\b\w*(repository|repo|dao)\.\w*(find|get|load|query)\w*\('
- '\.forEach\s*[\({][\s\S]{0,160}?\b\w*(repository|repo|dao)\.\w*(find|get|load|query)\w*\('
for와 .forEach, 딱 두 형태만 보고 있어요. 그래서 이런 코드는 잡아요.
for (id in sampleIds) {
repository.findAllActiveByGroupIds(id)
}
그런데 팀 PR 리뷰에서 사람이 실제로 잡은 N+1은 이 형태였어요.
sampleIds.map { repository.findAllActiveByGroupIds(it) }
[repository.find_by_id(x) for x in ids]
for도 .forEach도 아니라서 규칙이 아예 안 떴어요.
.map, .flatMap을 조건에 추가할 수도 있습니다. 그런데 이렇게 넓히는 건 금방 한계가 와요. 반복을 표현하는 방법은 계속 늘어나기 때문입니다.
다시 보니 이건 코드 모양의 문제가 아니었어요.
이렇게 코드 모양이 아니라 실행돼 봐야 드러나는 문제는, 발동 조건으로 좁히기보다 상시 Context로 깔아두는 게 낫습니다. 그래서 팀 Context에 이렇게 한 줄 추가했어요.
Don't fetch from a repository inside a loop --- that's one DB round-trip per item (N+1).
Pull once before the loop (into a Set/Map) and do the lookup in memory
기준은 이렇게 갈라요.
- 코드 모양으로 판정되는 건 규칙으로 --- 보면 바로 알 수 있는 형태
- 실행돼 봐야 아는 건 상시 Context로 --- N+1처럼 런타임에만 드러나는 문제
새 규칙은 PR 리뷰에서 가져와요
규칙은 사람이 직접 추가하기도 합니다. 리팩토링을 하다가 "이건 팀 규칙으로 두자" 싶으면 그 자리에서 만들어요.
그런데 그것만으로는 부족했습니다. 여러 PR에서 같은 피드백이 반복되는데, 아무도 그걸 규칙으로 옮기지 않는 경우가 많았거든요. 그래서 리뷰 코멘트를 모아 반복되는 지적을 찾아내는 입구를 따로 만들었어요.
실제로 이렇게 만들어진 규칙이 있어요. 한 서비스 레포의 PR 두 건에서 리뷰 코멘트를 묶었더니, 같은 지적이 반복되고 있었습니다.
@dataclass
class VerdictChecker:
repository: VerdictRepository
_cache: dict[str, Verdict] = field(default_factory=dict)
def check(self, request_id: str) -> Verdict:
verdict = self.repository.find_one(request_id)
self._cache[request_id] = verdict
return verdict
@dataclass는 '값을 담는 상자'라는 뜻입니다. 그런데 이 클래스는 캐시를 들고 있고 조회도 해요. 읽는 사람에게 값처럼 보이는데 실제로는 서비스인 거죠. 값인 줄 알고 복사해 쓰다가 상태가 어긋나기 좋습니다.
그래서 이렇게 바꾸자는 코멘트가 여러 PR에서 반복됐어요.
class VerdictChecker:
def __init__(self, repository: VerdictRepository) -> None:
self._repository = repository
self._cache: dict[str, Verdict] = {}
이걸 규칙 하나로 옮겼어요.
id: dataclass-behavior-is-class
triggers:
file_pattern: ["*.py"]
code_regex:
- '@dataclass\b[\s\S]{0,200}?\bclass \w*(Checker|Service|Manager|Handler|Runner)\b'
context_injection: |
@dataclass is for data, not behavior
A stateful service with real methods shouldn't be a @dataclass.
Use a plain class; keep @dataclass for immutable value holders
여기서는 발동 조건에 이름 접미사라는 문턱을 하나 둔 게 중요합니다. @dataclass만 보면 멀쩡한 값 홀더에 전부 뜨거든요. Checker, Service, Manager처럼 '동작하는 물건'임을 드러내는 이름이 같이 있을 때만 뜨게 했습니다.
Stylepack Flywheel
마지막으로 Stylepack이 스스로 좋아지는 흐름을 한 장으로 보면 아래와 같아요.

마무리
팀 컨벤션은 정하는 것도 중요하지만, 잘 지켜지게 만드는 게 더 중요합니다. Coding Agent에게는 코드를 고친 바로 그 순간에 피드백을 줘야 잘 작동한다는 것을 알게 됐죠. 이런 고민을 같이 하고 싶으시다면, 토스뱅크에 합류해 주세요.
감사합니다.