안녕하세요!
이번 주제는 오픈소스에 기여해 보는 경험을 공유하기 위해서 왔습니다.
프로젝트를 진행하면서 어느 정도 기능 개발에 익숙해져서 자신감에 차 있던 저는
오픈소스를 이해하고, 이를 위해서 테스트 코드를 작성하는 과정에서 우매함의 봉우리에 있다는 것을 깨달았습니다.
덕분에 개발에 대해 더욱 진지하고 열심히 해야겠다는 좋은 다짐을 하게 되었는데요.
그 과정을 설명드리겠습니다.
도전하게 된 배경
제가 주로 사용하는 Spring boot, JPA 등등 오픈소스들은 모든 것이 다 소스코드가 공개되어 있기에
저도 언젠가 공부하고 기여해보고 싶다는 생각을 했습니다.
하지만 뭔가 대단한 사람들만 오픈소스를 개발하거나 기여하는 것 같고,
당장 할 일들이 너무 많아서 도전하지 못하고 있던 중에
인제 님의 오픈소스 멘토링 6기 모집 글과 오픈소스 멘토링 컨트리뷰션 아카데미 체험형 2기 모집글을 보게 되었습니다.
그래서 둘 다 지원했고, 감사하게도 합격하여서 오픈소스에 도전할 좋은 기회를 얻었습니다.
그중 오늘은 인제님의 오픈소스 멘토링 6기를 통해 Spring AI의 이슈를 선정하고, 디버깅하고 PR을 올린 얘기입니다.
(추후에 merged 되면 내용을 추가하겠습니다)
오픈소스 결정
우선 도전해보고 싶은 오픈소스를 결정해야 합니다.
보통 자기가 많이 써본 오픈소스를 도전하는 것이 가장 좋다고 하셨습니다만
그냥 관심 많은 오픈소스에 도전하는 것도 좋은 방법인 것 같습니다.
저의 경우 자주 사용하는 Spring-data-JPA와
지인이신 현준 님이 기여하신 글을 통해서 관심 있었던 Spring AI 두 개를 선정하였습니다.
이슈 선정
인제 님의 이슈 선정 가이드에 적혀있듯이 이슈 선정이 오픈소스 기여하는데 가장 중요한 부분입니다.
선정하는 조건을 정리하자면
1. 명확한 동기부여가 있는 오픈소스 이슈
2. 해결해야 할 목표, 제안하는 해결 방법, 예시 코드, 테스트 코드 등 재현 방법이 적혀 있을 것
3. 이슈 라벨을 활용 - waiting-for-triage처럼 메인테이너가 확인하지 않는 건 제외, bug, core,
enhancement와 같이 대표적인 라벨 중에서 메인테이너가 확인한 경우를 선정
4. 댓글이 5~10개 사이
물론 추후에는 단순히 재밌어 보이는 이슈를 선정해서 해도 되겠지만, 저처럼 처음 도전해 보는 경우에는
위와 같은 조건을 참고하여 선정하는 게 merged 될 가능성이 높습니다.
그래서 저는 아래와 같이 2가지 이슈를 선정하여 멘토님과 상의를 했고
단기간 내 해결할 수 있을 것 같은 Spring AI의 1292번 이슈를 선정하였습니다.
1. https://github.com/spring-projects/spring-data-jpa/issues/3144
enhancement, 댓글 3개, 메인테이너가 확인한 사항
2. https://github.com/spring-projects/spring-ai/issues/1292
메인테이너가 확인하고, 상세한 설명
디버깅
이제 선정하였으니 오픈소스 기여의 거의 마무리가 머지않았습니다..!라고 하지만
사실 이때부터 저에게는 고난의 시작이었습니다.
협업을 여러 번 했지만 오픈소스처럼 아예 모르는 누군가의 코드를 이렇게 분석해 본 적이 처음이었고
테스트 코드의 중요성은 익히 알지만 빠르게 개발하다 보니 실제 코드에 api 요청해서 테스트 케이스대로
작동하는지 확인하는 방식으로 테스트했지, 실제 테스트 코드 작성에는 굉장히 미숙한 상황이었습니다.
그래서 이슈를 남겨주신 분이 자세하게 남겨주신 내용과 코드 분석, 그리고 인제님의 조언으로
Spring AI를 이용할 때 MiniMax 모델을 사용하는 경우 표준 형식으로 프롬프팅하고 반환받으면
message 형태로 받는데 web_search 형식으로 프롬프팅 하면
messages 형태로 반환받아서 생기는 문제라는 걸 파악했습니다.
그래도 이를 제대로 검증하기 위해서 test 코드를 작성해서 해야 하는데, 이 부분에서 애를 많이 먹었습니다.
테스트 코드 작성을 제대로 해보는 것은 거의 처음이라 매우 오래 걸렸고
원하는 대로 구현이 안 되는 경우가 많았습니다.
하지만 결국 web_search 방식으로 검색하는 경우 에러가 발생하는 구간을
mocking을 통한 유닛테스트를 통하여 발견하였습니다.
에러 메시지에서 볼 수 있듯이
choice의 message()가 null 값인데 그 이후에 message의 role을 조회해서 생기는 문제입니다.
또한 이슈에 적혀있는 문제는 null 값뿐만 아니라, message와 messages가 아니라 항상 일관된 형식으로
반환해야 한다는 의견이었습니다.
결론적으로
- 표준 응답은 choice.message()에 정보가 있었지만, web search 응답은 choice.messages()에 정보가 있었습니다.
- role 정보 추출 시 null 체크가 불충분했습니다.
이를 바탕으로 디버깅을 시작했습니다.
수정
그래서 이를 위해서 null 체크와 choice.message()를 적절하게 처리하는 방식으로 수정했습니다.
좀 더 정리하자면,
- call 메서드를 수정하여 choice.message()와 choice.messages() 모두를 처리할 수 있게 했습니다.
- null 체크를 강화하고, content와 role에 대한 기본값을 제공했습니다.
- buildGeneration 메서드를 추출하여 책임을 분리하였습니다.
기존 코드
@Override
public ChatResponse call(Prompt prompt) {
ChatCompletionRequest request = createRequest(prompt, false);
ResponseEntity<ChatCompletion> completionEntity = this.retryTemplate
.execute(ctx -> this.miniMaxApi.chatCompletionEntity(request));
var chatCompletion = completionEntity.getBody();
if (chatCompletion == null) {
logger.warn("No chat completion returned for prompt: {}", prompt);
return new ChatResponse(List.of());
}
List<Choice> choices = chatCompletion.choices();
if (choices == null) {
logger.warn("No choices returned for prompt: {}, because: {}}", prompt,
chatCompletion.baseResponse().message());
return new ChatResponse(List.of());
}
List<Generation> generations = choices.stream().map(choice -> {
// @formatter:off
Map<String, Object> metadata = Map.of(
"id", chatCompletion.id(),
"role", choice.message().role() != null ? choice.message().role().name() : "",
"finishReason", choice.finishReason() != null ? choice.finishReason().name() : "");
// @formatter:on
return buildGeneration(choice, metadata);
}).toList();
ChatResponse chatResponse = new ChatResponse(generations, from(completionEntity.getBody()));
if (isToolCall(chatResponse,
Set.of(ChatCompletionFinishReason.TOOL_CALLS.name(), ChatCompletionFinishReason.STOP.name()))) {
var toolCallConversation = handleToolCalls(prompt, chatResponse);
// Recursively call the call method with the tool call message
// conversation that contains the call responses.
return this.call(new Prompt(toolCallConversation, prompt.getOptions()));
}
return chatResponse;
}
수정된 코드
public class MiniMaxChatModel extends AbstractToolCallSupport implements ChatModel, StreamingChatModel {
// 기존 코드 유지
@Override
public ChatResponse call(Prompt prompt) {
// 기존 코드 유지
List<Generation> generations = choices.stream().map(choice -> {
ChatCompletionMessage message;
if (choice.message() != null) {
message = choice.message();
} else if (choice.messages() != null && !choice.messages().isEmpty()) {
message = choice.messages().get(0);
} else {
logger.warn("No message or messages found in choice: {}", choice);
return null;
}
String content = message.content();
String role = message.role() != null ? message.role().name() : "";
Map<String, Object> metadata = Map.of(
"id", chatCompletion.id(),
"role", role,
"finishReason", choice.finishReason() != null ? choice.finishReason().name() : "");
return buildGeneration(message, metadata);
}).filter(Objects::nonNull).toList();
// 나머지 코드 유지
}
private static Generation buildGeneration(ChatCompletionMessage message, Map<String, Object> metadata) {
List<AssistantMessage.ToolCall> toolCalls = message.toolCalls() == null ? List.of()
: message.toolCalls().stream()
.map(toolCall -> new AssistantMessage.ToolCall(toolCall.id(), "function",
toolCall.function().name(), toolCall.function().arguments()))
.toList();
var assistantMessage = new AssistantMessage(message.content(), metadata, toolCalls);
String finishReason = (String) metadata.get("finishReason");
var generationMetadata = ChatGenerationMetadata.from(finishReason, null);
return new Generation(assistantMessage, generationMetadata);
}
// 나머지 코드 유지
}
테스트
이후 이렇게 제대로 작동하는 것을 볼 수 있습니다.
PR 작성
그리고 이후에
1. 변경 사항의 요약
2. 주요 변경 사항에 대한 상세 설명
3. 테스트 방법 및 검증 단계
4. 관련된 이슈에 대한 언급
순으로 PR을 작성하였습니다.
아래는 PR 링크입니다.
아래는 좀 더 PR 내용을 이해하기 쉽게, 한글로 번역한 글입니다.
# MiniMaxChatModel의 웹 검색 응답 처리 개선
## 요약
이 PR은 MiniMax API의 표준 및 웹 검색 응답 형식을 모두 적절히 처리하도록 `MiniMaxChatModel` 클래스를 업데이트합니다. 또한 응답 처리 로직 전반에 걸쳐 null 안전성을 개선하고 `MiniMaxApi` 클래스에 설명 주석을 추가합니다.
## 변경 사항
1. `MiniMaxChatModel`의 `call` 메서드를 업데이트하여 `choice.message()`와 `choice.messages()` 모두 처리합니다.
2. 메시지 내용과 역할 정보에 대한 null 체크를 개선했습니다.
3. 다양한 응답 형식을 처리하기 위해 `buildGeneration` 메서드를 리팩터링 했습니다.
4. 유효한 메시지가 없는 경우에 대한 오류 로깅을 강화했습니다.
5. 웹 검색 응답의 `messages` 필드에 관한 설명 주석을 `MiniMaxApi` 클래스에 추가했습니다.
## 상세 설명
### MiniMaxChatModel 변경 사항
`MiniMaxChatModel`의 `call` 메서드가 표준 및 웹 검색 응답 형식을 모두 처리하도록 업데이트되었습니다:
```java
List <Generation> generations = choices.stream(). map(choice -> {
ChatCompletionMessage message;
if (choice.message()!= null) {
message = choice.message();
} else if (choice.messages()!= null &&! choice.messages(). isEmpty()) {
message = choice.messages(). get(0);
} else {
logger.warn("No message or messages found in choice: {}", choice);
return null;
}
//... 메서드의 나머지 부분
}). filter(Objects::nonNull). toList();
```
이 변경으로 모델은 `message`와 `messages` 필드를 모두 처리할 수 있게 되어, 표준 및 웹 검색 메서드 간의 응답 형식 불일치를 수용합니다.
### MiniMaxApi 변경 사항
`MiniMaxApi` 클래스에 `messages` 필드의 목적을 명확히 하는 설명 주석을 추가했습니다:
```java
/**
* @param messages 모델이 생성한 채팅 완성 메시지 목록. 웹 검색 응답에서 사용됩니다.
* 이 필드는 표준 메서드와 웹 검색 메소드 간의 응답 형식 불일치를
* 처리하기 위해 사용됩니다.
*/
```
이러한 주석들은 `MiniMaxApi` 클래스의 관련 부분에 추가되어 코드 이해도와 유지보수성을 향상합니다.
## 테스팅
- `MiniMaxChatModelWebSearchTest`를 업데이트하여 표준 및 웹 검색 응답 형식을 모두 다룹니다.
- null 메시지 시나리오에 대한 테스트 케이스를 추가했습니다.
- 모든 기존 테스트가 새로운 구현으로 통과합니다.
## 테스트 방법
1. `MiniMaxChatModelWebSearchTest` 클래스를 실행합니다.
2. `testStandardResponseHandling()`과 `testWebSearchResponseHandling()` 모두 통과하는지 확인합니다.
3. `MiniMaxChatModelNullSafetyTest`의 null 안전성 테스트가 통과하는지 확인합니다.
## 관련 이슈
#1292 해결
## 추가 참고 사항
1. 이 PR은 MiniMax API의 다양한 응답 형식 처리의 불일치를 해결합니다. 잠재적인 NullPointerException을 해결하고 다양한 응답 유형에 대해 더 일관된 동작을 제공합니다.
2. 웹 검색 응답의 `messages` 필드에 대한 정보를 공식 MiniMax API 문서에 포함시킬 것을 제안합니다. 이는 다른 개발자들이 다양한 응답 형식을 이해하고 올바르게 처리하는 데 도움이 될 것입니다.
3. 공식 문서에 다음 내용을 추가하는 것을 고려해 주세요:
"웹 검색 기능을 사용할 때, API는 `message` 필드 대신 `messages` 필드로 응답을 반환할 수 있습니다. 완전한 호환성을 위해 클라이언트는 두 형식을 모두 처리할 준비가 되어 있어야 합니다."
## 다음 단계
- 이러한 변경 사항을 반영하고 다양한 응답 형식 처리에 대한 명확한 지침을 제공하도록 공식 MiniMax API 문서를 업데이트합니다.
- 클라이언트 측 처리를 간소화하기 위해 향후 API 버전에서 응답 형식을 표준화하는 것을 고려합니다.
마무리하며
제가 이번 오픈소스 기여 경험을 통해서 느낀 것이 몇 가지 있습니다.
1. 우매함의 봉우리
2. 오픈소스 기여 도전 가능성 & 오픈소스 코드 분석을 통한 공부
3. 테스트 코드의 중요성
4. 서로 공유하고 돕는 개발문화에 대한 감사
1. 우매함의 봉우리
우선, 위에서 말한 것과 같이 오픈소스 분석과 테스트 코드에 매우 미숙한 제 모습을 보면서
최근 프로젝트 진행하면서 기능 개발을 어느 정도 한다는 것에 흐뭇해하던 제가 웃겼습니다.
아직 한참 부족하고 배울 것이 많다는 것을 깨달았고, 적절한 시기에 이런 깨달음을 얻게 되어
정말 좋은 경험을 했다고 생각합니다.
2. 오픈소스 기여 도전 가능성
하지만 오픈소스 기여 자체는 생각한 것만큼..? 개발 분야의 거장들만 하는 것이 아니라 충분히 일반인들도
도전할 수 있다는 것을 알게 되었습니다.
좋은 개발자들의 코드를 읽으면서 좋은 글을 읽어야 좋은 글을 쓸 수 있는 것처럼,
오픈소스를 통해 좋은 코드를 많이 읽고 작성할 수 있는 개발자가 되어야겠다는 생각을 했습니다.
3. 테스트 코드의 중요성
제가 규모가 커지면 커질수록, 실제로 로컬에서 build가 되지 않는 경우도 빈번하고
특정 기능에만 테스트가 필요한 경우 있습니다.
또한 협업을 하다 보면 다른 사람의 코드를 인용해 테스트하는 경우에는 그런 경우가 더 많은데
이번에 현업에서 겪을 법한 일을 간접적으로 경험하면서 테스트 코드의 중요성을 한 번 더 깨닫게 된 것 같습니다.
또한 이번에 도전한 이슈는 Spring AI의 다른 모든 model은 message로 통일되어 있는데,
MiniMax만 messages가 하나 더 있어서 생긴 이슈였습니다.
아마 메인테이너 분들도 이를 정확히 인지하셨다면
이전에 올라왔던 PR을 통해서 반영된 이 내용을 반영하지 않으셨을 텐데
이전 분이 이 내용을 추가하면서 제대로 된 주석이나 공식 문서의 설명 없이 진행하셔서 그대로 반영되었습니다.
에러가 나올 법한 부분들을 테스트 코드를 작성해 검증했다면 이런 상황을 피할 수 있지 않았을까 생각합니다.
오픈소스의 특성상 한 번 merged 되면 다시 되돌릴 수 없기에, 이렇게 철저해야 하는 경우
더욱 테스트 코드를 잘 작성하여 제대로 검증하고 진행하는 것이 중요하겠다는 생각을 했습니다.
4. 서로 공유하고 돕는 개발문화에 대한 감사
마지막으로, 이번 경험을 하게 해 주신 인제님께 감사했습니다.
다른 분야는 본인만의 스킬이나 경험을 숨기고 암암리에 조용히 공유하는 경우가 많습니다.
하지만 개발은 서로가 공유하지 않으면 안 된다는 것을 잘 알기 때문에
굉장히 공유하는 부분에 있어 열려있고 특히 깃허브는 그런 교류의 장이 되고 있습니다.
인제님께서는 오픈소스 경험이 굉장히 많으신 컨트리뷰터이시고, 충분히 주말에는 본인을 위해서
휴식을 가지실만한데도 남는 시간을 할애해 멘토팅을 진행하시면서
한국의 오픈소스 문화 개선을 위해서 기여하시고 계십니다.
덕분에 저를 비롯해 같이 멘토링을 받으신 분들도 오픈소스 이슈 선정 - 디버깅 - PR 작성하는
하나의 흐름을 경험할 수 있게 되었고 오픈소스에 한 발 더 가까워졌습니다.
내수 시장의 한계를 아시고, 한국인들의 오픈소스를 통한 글로벌 시장 진출까지도 생각하시며
멘토링부터 시작해서 많은 것을 준비 중이신 인제님이 정말 대단하시다는 생각이 들었고,
다시 한번 감사하다는 말씀드리고 싶습니다.
저도 바로 테스트 코드 학습과 오픈소스 기여에 바로 도전해 볼 생각입니다!
메인테이너가 확인하고, 논의가 진행되고 merged 되는 때까지 시간이 좀 걸리겠지만
컨트리뷰터가 된다면 내용을 추가하러 오겠습니다!
😊 긴 글 읽어주셔서 감사합니다.,