Scrum AI

Scrum AI

[Devlime 개발기] Scrum AI 도입

1. 들어가며

스크럼으로 개발하다 보면 백로그 아이템과 유저스토리를 작성하는 데 생각보다 많은 시간이 들어갑니다. 회의에서 "다음 스프린트에는 예약 기능을 추가하자"는 결론이 나도 개발자가 할 일은 남아 있습니다. 제목을 정리하고, As-A / I-Want / So-That 형식으로 내용을 작성하고, 인수 조건을 적고, 스토리 포인트와 가치 점수까지 입력해야 합니다. 하나만 보면 금방 끝날 것 같지만 스프린트마다 스무 개 남짓 반복되면 이야기가 달라집니다.

그래서 Devlime의 Scrum 모듈을 개발하면서 이 과정을 줄여보기로 했습니다. 처음 생각한 기능은 단순했습니다. 요구사항을 입력하면 UserStory 초안을 만들어주고, 비슷한 과거 작업을 찾아 점수를 참고할 수 있게 하는 정도였습니다. 3주 정도면 끝날 것으로 예상했습니다.

실제로 개발해 보니 예상과 달랐습니다. AI API를 붙이는 일 자체는 어렵지 않았고, 시행착오는 대부분 어떤 문제를 AI에게 맡길 것인지 결정하는 과정에서 나왔습니다. 이 글에서는 기능을 어떤 방식으로 시작했고, 어디에서 문제가 생겼으며, 어떻게 구조를 바꿨는지 정리했습니다.

2. 기술 선택 배경

2.1 구조화된 출력

가장 먼저 만든 것은 UserStory 초안 생성 기능이었습니다. 사용자가 입력한 요구사항을 Claude에 전달하고, JSON 형태로 받은 결과를 화면에 채우는 방식이었습니다. 처음에는 잘 동작했지만 항상 그렇지는 않았습니다. 응답 앞뒤에 설명 문장이 붙거나, JSON 앞에 마크다운 코드 블록이 붙거나, 특정 필드가 빠지는 경우가 있었습니다.

초기에는 응답 문자열을 직접 가공하는 방식으로 대응했습니다. 백틱을 제거하고, JSON이 시작하는 위치를 찾고, 필드가 없으면 기본값을 채우고, 그래도 실패하면 예외로 처리하는 코드를 하나씩 추가했습니다. 그러다 보니 AI를 호출하는 코드보다 응답을 복구하는 코드가 더 길어졌습니다. 프롬프트를 더 다듬는 것만으로는 한계가 있었고, 모델의 출력 형식 자체를 애플리케이션에서 통제할 방법이 필요하다고 판단했습니다.

그래서 Claude Java SDK가 제공하는 구조화된 출력을 적용했습니다. 문자열을 받아 직접 변환하는 대신, 서버에서 사용하는 DTO를 그대로 응답 스키마로 정의하는 방식입니다.

public class UserStoryDraftAiSchema {

    public boolean supported;

    public String rejectionReason;

    public List<TitleOption> titleOptions;

    public List<NarrativeOption> narrativeOptions;
}

기존에 작성했던 파싱 및 보정 코드는 대부분 제거할 수 있었습니다. 코드가 줄어든 것보다 서버에서 사용하는 데이터 구조와 모델에게 요구하는 출력 구조를 하나의 타입으로 관리하게 된 점이 더 컸습니다. 이 기능에서 구조화된 출력을 선택한 이유도 여기에 있습니다. 모델의 성능이 좋아서가 아니라, 애플리케이션에서 불필요하게 처리해야 하는 코드가 줄어들기 때문입니다.

2.2 모델 선택

처음에는 품질을 우선해서 claude-opus-5를 사용했습니다. 결과 자체는 좋았지만 실제 화면에서 사용해보니 응답 시간이 문제였습니다. 한 번 요청할 때 6~7초 정도가 걸렸는데, 스프린트 계획 중에 여러 번 호출하는 기능이라는 점을 생각하면 부담스러운 시간이었습니다.

그래서 claude-haiku로 변경했습니다. 품질이 많이 떨어질 것으로 예상했지만, 팀원들에게 두 모델의 결과를 함께 보여주고 비교했을 때 차이를 명확하게 구분하지 못했습니다. 이번 기능이 요구하는 것은 복잡한 추론이나 창작이 아니라, 입력한 요구사항을 정해진 형식으로 정리하는 작업에 가까웠기 때문입니다. 결과적으로 응답 시간과 호출 비용을 함께 줄일 수 있었고, 더 좋은 모델보다 기능에 맞는 모델을 선택하는 편이 낫다는 것을 확인했습니다.

2.3 점수 추천 방식

방향을 가장 크게 바꾼 부분입니다. 처음에는 초안 생성과 같은 방식으로 Claude에게 스토리 포인트를 물어보려고 했습니다. 결과는 잘 나왔지만 왜 3점인지 설명할 수 없었습니다. 같은 요구사항을 여러 번 요청하면 3점이 나오기도 하고 5점이 나오기도 했고, temperature를 낮춰 결과를 고정하더라도 근거가 생기는 것은 아니었습니다.

생각해 보면 당연한 결과였습니다. Claude는 Devlime 팀이 지난 몇 달 동안 어떤 작업을 했고 그 작업을 몇 점으로 평가했는지 알지 못합니다. 스토리 포인트는 일반적인 정답이 있는 값이 아니라 팀의 과거 작업을 기준으로 판단하는 상대적인 값이기 때문입니다. 근거 없이 제시된 숫자는 참고가 되지 않을 뿐 아니라, 화면에 먼저 표시된다는 이유만으로 논의의 기준이 될 위험도 있었습니다.

그래서 문제를 다시 정의했습니다. 점수를 생성하는 것이 아니라, 비슷한 과거 작업을 찾아 그때의 점수를 함께 보여주는 방향으로 바꿨습니다.

3. 적용 과정

3.1 생성 대신 검색

완료된 과거 BacklogItem의 제목을 Voyage AI의 임베딩 모델로 벡터화해 저장했습니다. 새로운 요구사항이 들어오면 같은 방식으로 임베딩한 뒤 기존 데이터와 코사인 유사도를 비교하고, 유사도가 높은 상위 세 건을 추천 대상으로 사용합니다.

[저장] 완료된 BacklogItem 제목 → Voyage Embedding → Vector 저장
[검색] 새로운 요구사항 → Voyage Embedding → Cosine Similarity → 유사 항목 Top 3

화면에는 추천 점수만 표시하지 않고, 그 근거가 된 과거 항목의 제목과 당시 점수를 함께 보여줍니다.

추천 참고 항목

  1. 예약 화면에 사용자 검색 기능 추가 (Story Point 3)
  2. 예약 상세 조회 기능 추가 (Story Point 3)
  3. 예약 상태 변경 기능 추가 (Story Point 5)

이 구조에서 AI는 정답을 제시하지 않습니다. 과거에 이런 작업이 있었고 당시에는 이 정도로 평가했다는 정보만 제공하고, 최종 판단은 사용자가 합니다. 근거를 함께 보여줄 수 있게 된 점이 방식을 바꾼 가장 큰 이유였습니다.

3.2 호출 경로

React에서 Anthropic API를 직접 호출하는 방법도 있었습니다. 다만 그렇게 하면 API Key가 클라이언트에 노출되고 호출에 대한 통제도 어려워집니다.

React →  Scrum Backend  →  AI API

프론트엔드는 요청에 필요한 값만 전달합니다.

const draftUserStory = (params: {
    rawInput: string;
    productId: string;
    epicId?: string;
}) => {
    const query = { ...params };
    return axios.post(url('/draft-user-story/fetch'), query);
};

프롬프트와 모델 선택, 출력 스키마, 재시도와 같은 AI 관련 정책은 모두 서버에서 관리합니다. 이렇게 해두면 이후 모델을 교체하더라도 프론트엔드에는 변경 사항이 거의 발생하지 않습니다.

3.3 API Key가 없는 환경

AI 기능을 사용하지 않는 개발자의 로컬 환경에는 Anthropic API Key가 없습니다. 그런데 서버가 시작될 때 Anthropic Client를 무조건 생성하도록 하면, Key가 없는 환경에서는 애플리케이션 자체가 시작되지 않을 수 있습니다. 그래서 Bean을 조건부로 등록했습니다.

@Bean
@ConditionalOnProperty(name = "ANTHROPIC_API_KEY")
public AnthropicClient anthropicClient() {
    return AnthropicOkHttpClient.fromEnv();
}

API Key가 존재하는 환경에서만 Client를 생성하고, 없는 환경에서는 AI 기능만 비활성화된 상태로 나머지 기능이 정상 동작합니다. AI 기능 하나 때문에 팀 전체의 개발 환경에 영향을 주지 않도록 경계를 만든 것입니다.

3.4 스키마 필드 순서

구조화된 출력으로 변경한 뒤 예상하지 못했던 부분은 스키마의 필드 순서에 따라 결과가 달라진다는 점이었습니다. 처음에는 제목 후보를 먼저 만들도록 선언했는데, "다음 주 회식 장소 정하기"처럼 요구사항으로 보기 어려운 입력에도 제목이 먼저 생성됐습니다. 지원 가능 여부를 판단하는 supported가 뒤에서 결정되다 보니, 이미 만들어진 결과에 맞춰 지원 가능한 입력처럼 처리되는 경우가 있었습니다.

그래서 판단에 필요한 필드를 앞으로 옮겼습니다.

public boolean supported;
public String rejectionReason;

public List<TitleOption> titleOptions;
public List<NarrativeOption> narrativeOptions;

입력이 UserStory로 만들 수 있는 요구사항인지 먼저 판단하고, 그 결과가 true일 때만 나머지 내용을 생성하도록 순서를 바꿨습니다. 이 과정에서 스키마가 단순한 DTO가 아니라 모델의 출력 과정을 제어하는 프롬프트의 일부라는 점을 알게 됐습니다.

4. 문제 해결

기능을 붙이고 나서도 문제는 계속 나왔습니다. 아래 다섯 가지는 모두 구현이 끝난 뒤 실제로 사용하면서 확인한 내용입니다.

4.1 정상 응답인데 내용이 비어 있던 문제

UserStory 초안에서 narrativeOptions가 빈 배열로 내려오는 문제가 있었습니다. HTTP Status는 200이었고 서버에서도 예외가 발생하지 않아 처음에는 프론트엔드 문제라고 생각했습니다. 실제 응답을 확인해보니 생성 도중 응답이 잘리고 있었습니다.

원인은 maxTokens였습니다. BacklogItem을 기준으로 2048을 설정해두었는데, UserStory는 제목 여러 개와 각 제목에 대한 서술, 인수 조건까지 생성해야 해서 필요한 출력량이 훨씬 많았습니다. 값을 4096으로 늘려 해결했지만, 더 중요한 것은 HTTP 200이라고 해서 AI 응답이 정상적으로 끝났다고 볼 수 없다는 점이었습니다. 현재는 응답의 stop_reason을 확인하고 최대 출력 토큰에 도달한 경우 로그를 남기도록 처리했습니다.

4.2 추천 대상이 0건이 되는 문제

점수 추천 기능을 배포한 뒤 특정 프로젝트에서는 추천 결과가 나오지 않는다는 피드백이 들어왔습니다. 확인해보니 유사도 검색 대상이 0건이었습니다. 처음에는 현재 선택한 이슈 타입과 동일한 완료 항목만 검색하도록 제한했는데, 실제 데이터에서는 특정 타입의 완료 항목이 많지 않아 필터링하고 나면 비교할 데이터 자체가 없어졌습니다.

그래서 타입 필터를 제거했습니다. 타입이 다른 작업의 점수를 참고하는 것이 부정확하다고 생각했지만, 이 기능에서 보여주는 점수는 확정값이 아니라 참고값입니다. 타입이 다르더라도 제목과 작업 내용이 유사하다면 과거 사례로서 충분히 참고할 수 있습니다. 결국 정확도를 조금 낮추더라도 검색 결과가 존재하도록 하는 쪽을 선택했습니다.

4.3 응답을 기다리는 시간

모델을 Haiku로 변경한 뒤에도 요청부터 응답까지 약 2~4초가 걸렸습니다. 처음에는 Spinner만 보여줬는데, 실제로 사용해 보니 이 시간이 생각보다 길게 느껴졌습니다. 그래서 생성 중에는 상태 문구를 단계별로 변경하도록 했습니다.

요구사항을 분석하고 있습니다.
UserStory 초안을 작성하고 있습니다.
인수 조건을 정리하고 있습니다.

실제 모델의 진행 상황과 연결된 것은 아닙니다. 다만 사용자가 아무 변화 없이 Spinner만 보고 있는 상황을 줄이는 데는 도움이 됐습니다. AI 기능의 사용 경험은 모델의 응답 품질만으로 결정되지 않고, 모델이 응답하는 동안 사용자가 무엇을 보게 할 것인지도 기능의 일부였습니다.

5. 최종 구조

이번 기능을 만들면서 최종적으로 다음과 같은 구조가 만들어졌습니다.

사용자 → BFF → Scrum API
                  ├─ Claude : UserStory 초안 생성
                  └─ Voyage : 과거 유사 항목 검색

두 기능을 같은 AI 문제로 묶지 않았습니다. 문장을 만드는 문제와 우리 데이터에서 근거를 찾는 문제를 분리했고, UserStory 초안은 Claude가, 과거 작업 검색은 Voyage 임베딩이 담당합니다. 최종 결정은 사용자가 합니다. 역할을 나누고 나니 처음보다 구조가 오히려 단순해졌습니다.

6. 배운 점

3주 동안 개발하면서 가장 크게 느낀 것은 AI 기능에서 어려운 부분이 API 호출 자체가 아니라는 점입니다. Claude API를 호출하는 코드는 생각보다 빠르게 만들 수 있습니다. 시간이 많이 들어간 부분은 그 이후였습니다. 어떤 작업을 생성 모델에게 맡기고 어떤 작업을 우리 데이터에서 찾아야 하는지 결정해야 했고, 정상적인 HTTP 응답을 받았더라도 실제 내용이 정상인지 확인해야 했으며, 모델의 응답 시간을 사용자가 어떻게 받아들일지도 함께 고민해야 했습니다.

특히 점수 추천에서 이 차이를 크게 느꼈습니다. 모델에게 근거가 없는 판단을 요구하면 그럴듯한 답은 만들 수 있지만, 그 답이 우리 팀의 기준과 맞는지는 다른 문제입니다. 반대로 팀의 과거 데이터가 있다면 모델에게 답을 만들어 달라고 하기보다 그 데이터를 검색해서 사용자에게 보여주는 편이 더 적합할 수 있습니다. 결국 AI에게 무엇을 시킬 것인지보다, 이 문제를 정말 AI가 생성해야 하는지를 먼저 확인하는 것이 중요했습니다.

7. 마무리

처음에는 간단한 기능이라고 생각했습니다. 요구사항을 입력하면 UserStory를 만들어주고, 비슷한 작업을 찾아 점수를 보여주면 된다고 봤습니다. 하지만 실제로 만들어 보니 두 번 크게 방향을 바꿨습니다. 점수 추천을 생성 문제로 접근했던 것이 첫 번째였고, 검색 정확도를 높이려고 후보를 지나치게 제한했던 것이 두 번째였습니다. 둘 다 코드를 잘못 작성해서 생긴 문제가 아니라 처음 문제를 정의한 방식이 맞지 않아서 생긴 문제였습니다.

이번 작업을 통해 앞으로 AI 기능을 만들 때 세 가지는 계속 확인하려고 합니다. 첫 번째는 기능에 맞는 모델을 선택하는 것, 두 번째는 비용이 발생하는 호출을 반드시 서버에서 통제하는 것, 세 번째는 기능을 만든 뒤 팀원이 직접 사용해 보게 하는 것입니다.

마지막이 특히 중요하다고 생각합니다. 이번에 발견한 문제 대부분은 개발 단계에서 확인하지 못했습니다. 빈 배열 문제도, 한글 입력 문제도, 검색 대상이 0건이 되는 문제도 모두 실제로 기능을 사용한 뒤에야 드러났습니다.

결국 이번 Scrum AI 개발에서 가장 많이 바뀐 것은 코드가 아니라 문제를 바라보는 방식이었습니다. 앞으로 Devlime에 AI 기능을 추가할 때도 먼저 모델부터 선택하기보다, 어떤 문제인지 정의하고 그 문제에 생성과 검색, 규칙 중 어떤 방식이 가장 적합한지부터 확인해 보려고 합니다.

긴 글 읽어주셔서 감사합니다.

jun

Site footer