콘텐츠로 이동

팀 모델 깊이 보기

이 페이지는 타입 시그니처를 보기 전에, team() 뒤에 있는 아이디어를 쉬운 말로 설명해요. 레퍼런스 페이지를 보기 전에 멘털 모델부터 잡고 싶은 분들을 위해 프로젝트 내부 설계 노트를 각색했어요. 지금 바로 만들어보고 싶다면 가이드: 멀티 에이전트 팀을 확인하세요.

개념은 이 순서로 쌓여요: 채널 → ReviewResultpassTo vs .router() → 나머지 코드 → 전체 실행.

team은 여러 직원(에이전트)이 순서대로 같은 케이스를 처리하는 거예요.

이 페이지 전체에서 쓰는 예제는 PR/이슈 트리아지 팀이에요. 사무실에 비유하면:

이슈 도착 → [접수] → [버그 담당자] 또는 [문서 담당자] → [리뷰어] ─┬─ 승인 → 완료
└─ 반려 → 담당자에게 반송
  • 접수 (triage) — 이슈를 읽고 “이건 버그다” 또는 “이건 문서 요청이다”를 판단해서 알맞은 담당자에게 넘겨요.
  • 버그 담당자 (bugFixer) / 문서 담당자 (docsWriter) — 실제 작업을 처리하고 리뷰어에게 핸드오프해요.
  • 리뷰어 (reviewer) — 결과를 검토해요: 승인하면 → 완료, 부족하면 → 담당자에게 반송해요.

이게 바로 사람 팀이 실제로 일하는 방식이에요. 이 페이지의 나머지 부분은 이게 코드로 어떻게 매핑되는지, 낯선 용어를 하나씩 풀어가며 설명해요.

1. 채널이 뭔가요? (가장 먼저 이해해야 할 것)

섹션 제목: “1. 채널이 뭔가요? (가장 먼저 이해해야 할 것)”

채널은 팀 전체가 공유하는 화이트보드의 한 칸이에요.

팀이 협업하려면 정보가 공유된 어딘가에 있어야 해요. 그 공유 공간이 화이트보드이고, 화이트보드는 여러 칸으로 나뉘어 있어요. 각 칸이 하나의 채널이에요. 모든 칸에는 다음이 있어요:

  • 이름 (예: issue, review), 그리고
  • 이미 있는 값에 새 값을 병합하는 규칙.

이 병합 규칙이 채널 종류를 구분해요:

종류 의미 비유 예시
lastChannel 가장 최신 값만 유지해요 (새 값이 이전 값을 덮어써요) 칸을 지우고 다시 쓰기 review (최신 판정)
listChannel 계속 쌓여요 (새 값이 뒤에 추가돼요) 한 줄씩 쓰는 회의록 transcript (지금까지의 대화)
inputChannel 시작 시점에 외부에서 제공되는 입력 접수할 때 건네받는 케이스 파일 issue (트리아지 대상)

“그냥 평범한 변수를 쓰면 안 되나요?” loopy는 모든 걸 이벤트 로그에 기록해요. 그래서 나중에 정확히 리플레이하고 타임 트래블 디버깅을 할 수 있어요. 채널은 그렇게 “기록되고 되감을 수 있는 변수”예요. (이건 loopy 전체를 관통하는 state = fold(reducer, log)와 같은 아이디어예요. 자세한 내용은 이벤트 소싱과 리플레이를 참고하세요. 여기서는 이 개념을 깊이 이해하지 않아도 계속 읽을 수 있어요.)

모든 팀이 자동으로 받는 두 개의 칸이 있어요 (직접 선언할 필요가 없어요):

  • transcript — 지금까지 누가 무슨 말을 했는지 전부 담고 있어요. 늦게 합류한 에이전트도 이 칸을 읽으면 이미 무슨 일이 있었는지 알 수 있어요.
  • nextAgent — “다음은 누구 차례인지”를 적어두는 포스트잇 한 장이에요. (이게 아래에서 핵심 아이디어가 돼요.)

직접 선언하는 칸은 여러분의 작업에 필요한 것만 있으면 돼요. 이 예제에서는 issue(입력)와 review(판정)예요.

ReviewResult는 loopy의 개념이 아니에요. 이 트리아지 예제가 만들어낸 “판정” 데이터일 뿐이에요.

리뷰어가 작업을 마치면 결론에 도달해요. 그 결론의 형태가 ReviewResult이고, 경우는 정확히 두 가지예요:

type ReviewResult =
| { approved: true; notes: string } // approved: 통과 + 노트
| { approved: false; assignee: "bugFixer" | "docsWriter"; notes: string } // rejected: 반송 대상 + 노트
  • 승인approved: true와 노트예요.
  • 반려approved: false, 누구에게 돌아가는지(assignee), 그리고 노트예요.

이렇게 나눈 이유(판별 유니온): 컴파일러가 “반려됐다면 assignee가 반드시 있어야 한다”를 강제하게 만들기 위해서예요. 승인에는 assignee가 필요 없지만, 반려에는 필요해요. 이렇게 작성하면 “반려됐는데 아무도 배정 안 됨”은 새벽 3시에 발견하는 버그가 아니라 타입 에러가 돼요.

핵심은 이거예요: ReviewResult는 이 예제만의 도메인 데이터일 뿐이에요. 여러분의 앱이 주문을 처리한다면 이 자리엔 OrderResult가 들어가고, 분석 파이프라인이라면 AnalysisReport가 들어가요. loopy는 이걸 강제하지 않아요. 여러분의 팀이 실제로 만들어내는 결과 타입을 직접 정의하면 돼요.

3. passTo vs .router(), 다들 헷갈려 하는 부분

섹션 제목: “3. passTo vs .router(), 다들 헷갈려 하는 부분”

둘 다 **“다음은 누구 차례인가?”**에 답해요. 유일한 차이는 누가 결정하느냐예요.

passTo = 직원(모델)이 스스로 핸드오프해요

섹션 제목: “passTo = 직원(모델)이 스스로 핸드오프해요”

접수 에이전트에게 미리 알려주세요: “버그 담당자나 문서 담당자에게 핸드오프할 수 있어요”(passTo: ["bugFixer", "docsWriter"]). 접수 모델이 이슈를 읽고 스스로 판단해서 어느 쪽 버튼을 누를지 결정해요.

  • “이건 버그인가, 문서 이슈인가?“는 실제로 이슈를 읽어야만 내릴 수 있는 판단이에요. 모델만 할 수 있어요.
  • 모델이 “pass_to_bugFixer 버튼을 누르면”, 그 기록은 nextAgent 포스트잇"bugFixer"라고 남아요.

.router() = 여러분의 코드가 규칙으로 결정해요

섹션 제목: “.router() = 여러분의 코드가 규칙으로 결정해요”

“리뷰가 승인됐으면 실행을 끝낸다.” “반려됐으면 담당자에게 돌려보낸다.” 이건 판단이 아니라 고정된 규칙이라, 코드가 처리해요. 그게 .router(...)예요.

.router((s) => { // s = 화이트보드 전체의 현재 상태
if (s.nextAgent) return s.nextAgent; // ① 핸드오프 포스트잇이 있으면 그걸 먼저 따라가요
if (s.review?.approved) return END; // ② 리뷰가 "승인"이면 멈춰요
if (s.review) return s.review.assignee; // ③ 반려됐으면 지정된 assignee로 가요
return END; // ④ 핸드오프도 판정도 없으면 → 멈춰요
})
  • s.nextAgent는 “다음은 누구 차례인지” 적힌 포스트잇이에요. 모델이 이미 pass_to_* 호출로 이걸 써놨다면, 그게 다른 무엇보다 우선해요. (참고: 턴 0에는 이 포스트잇에 entry(접수 에이전트)가 미리 채워져 있어서, 접수가 자동으로 먼저 시작돼요.)
  • 리뷰어의 판정이 “승인”(review.approved)이면 → 멈춰요 (END).
  • 리뷰가 “반려”면 → 리뷰어가 지정한 사람(assignee)에게 돌려보내요.
  • 포스트잇도 판정도 없으면 → 멈춰요.

리뷰 판정보다 포스트잇(nextAgent)을 먼저 확인하는 이유는?

섹션 제목: “리뷰 판정보다 포스트잇(nextAgent)을 먼저 확인하는 이유는?”

반려된 이슈가 담당자에게 돌아가서 다시 수정되고, 담당자가 리뷰어에게 다시 핸드오프하면, 그 핸드오프는 포스트잇(nextAgent = "reviewer")을 남겨요. 이 순간에는 review에 여전히 남아 있는 낡은 반려 판정보다 포스트잇을 먼저 봐야 해요. 그렇지 않으면 라우터가 오래된 “반려” 결과만 보고 케이스를 담당자에게 무한 반복으로 돌려보내게 돼요. (이건 설계 자체의 검증 단계에서 실제로 발견된 버그예요.)

passTo .router()
누가 결정하나 직원(모델)이 스스로 코드가 규칙으로
언제 쓰나 입력을 읽어야 하는 판단 (“버그냐 문서냐?”) 고정된 규칙 (“승인 → 완료”)
동작 방식 모델이 pass_to_X()를 호출 → 포스트잇에 기록 .router()가 포스트잇 + 결과를 읽고 결정
필수인가? 선택 사항 선택 사항 (생략하면 기본값은 “포스트잇을 따르고, 없으면 멈춘다”)

둘은 경쟁하는 메커니즘이 아니에요. 최종 결정권은 .router()에 있어요. passTo는 모델이 포스트잇을 통해 “이 사람에게 넘겨주세요”라고 요청하는 거예요. 단순한 팀이라면 passTo만으로 돌아갈 수 있어요(.router()는 생략해도 돼요). 규칙 기반 종료나 반려 처리가 필요할 때만 .router()를 사용하세요.

4. 나머지 코드: .writes(), defineLoopy, rt.run

섹션 제목: “4. 나머지 코드: .writes(), defineLoopy, rt.run”
.writes({ reviewer: "review" })

이건 리뷰어의 결과(ReviewResult)가 어느 칸에 쓰이는지를 정해요: “reviewer의 출력을 review 칸에 써라.” 이게 없으면 라우터의 s.review는 절대 채워지지 않아요.

const rt = defineLoopy({ teams: { prTriage } }).provide(/* deps */);
  • defineLoopy(...)앱 레지스트리예요. 여기에 만든 팀과 에이전트를 등록해요.
  • .provide(...)실제 의존성 주입이에요 (에이전트가 사용하는 실제 툴/API가 여기서 연결돼요).
const out: ReviewResult | null = await rt.run("prTriage", { issue }, { threadId: "t-7" });
  • rt.run("prTriage", { issue })“이 이슈로 트리아지 팀을 실행해라.” { issue }inputChannel 칸을 채우는 값이에요.
  • threadId: "t-7" — 이 실행의 식별자예요. 나중에 일시 중단/재개하거나 리플레이할 때 쓰여요.
  • 반환값 out: ReviewResult | nullreview 칸의 최종 값이에요. 리뷰어가 판정에 도달했다면 ReviewResult를, 아무도 리뷰하지 않은 채 실행이 끝났다면 null을 받아요. (maxTurns 안전 상한에 걸리면 값을 반환하지 않고 아예 예외를 던져요.)

5. 전체 실행 한 번, 턴별로 따라가기

섹션 제목: “5. 전체 실행 한 번, 턴별로 따라가기”

버그 이슈가 들어와서, 한 번 반려됐다가 통과해요:

활동 중인 직원 무슨 일이 일어나나 포스트잇 / 판정 라우터의 결정
0 접수 이슈를 읽고 → “버그다” → 버그 담당자에게 핸드오프 포스트잇 = bugFixer → 버그 담당자
1 버그 담당자 코드를 수정 → 리뷰어에게 핸드오프 포스트잇 = reviewer → 리뷰어
2 리뷰어 검토 → “부족해요, 버그 담당자에게 돌려보내요” 판정 = 반려 (assignee: bugFixer) → 버그 담당자 (반송)
3 버그 담당자 다시 수정 → 리뷰어에게 핸드오프 포스트잇 = reviewer → 리뷰어
4 리뷰어 검토 → “승인!” 판정 = 승인 종료 (END)

최종 반환값 = 승인된 ReviewResult예요. 이 전체 흐름은 이벤트 로그에 기록되기 때문에, 런타임이 존재하게 되면 동일하게 리플레이하거나 사람이 개입하도록 일시 정지할 수 있어요 (자세히는 휴먼 인 더 루프 참고).

  • 채널은 팀이 공유하는 화이트보드의 한 칸이에요 (기록되고 리플레이 가능한 변수). last(덮어쓰기) / list(추가) / input(시작 시점에 채워짐).
  • **ReviewResult**는 이 예제만의 “판정” 데이터예요 — loopy의 개념이 아니라 여러분의 도메인 타입이에요.
  • **passTo**는 모델이 스스로 핸드오프하는 거예요 (“다음은 저 사람”) — 입력을 읽어야 하는 판단에 써요.
  • **.router()**는 코드가 고정된 규칙으로 “다음은 X / 멈춤”을 결정하는 거예요.
  • 둘은 협력해요: .router()가 결정하고, passTo는 포스트잇을 통해 요청해요.