
Whisper에서 Deepgram으로 전환하며 회의 참석자별 발화를 구분하고, 이를 기반으로 의 할 일 자동 생성 흐름을 설계한 과정

우리 서비스는 회의 녹음 파일을 기반으로 회의 내용을 기록하고, 회의 중 언급된 할 일을 자동으로 추출해주는 회의 기록 서비스다.
회의가 끝난 뒤 사용자는 “누가 어떤 일을 하기로 했는지” 다시 정리해야 하는 번거로움을 겪는다.
우리는 이 과정을 줄이기 위해 음성 파일을 텍스트로 변환하고, 변환된 회의록을 기반으로 Action Item을 자동 생성하는 기능을 제공한다.
개발 초기에는 OpenAI Whisper를 사용해 회의 녹음 파일을 텍스트로 변환했다.
Whisper는 음성을 텍스트로 변환하는 데는 적합했지만, 회의 서비스에서 중요한 “누가 말했는지”를 구분하는 것, 즉 화자 분리를 적용하는 데 한계가 있었다.
우리 서비스에서 필요한 것은 단순한 전체 회의록이 아니라, 다음과 같은 형태의 데이터였다.
# 예시
화자 0: 오늘까지 포스터 수정해볼게요.
화자 1: 저는 발표 자료 정리하겠습니다.
화자 0: 그럼 내일까지 최종본 공유할게요.
이처럼 발화자가 구분되어야 이후 LLM이 “누가 어떤 일을 맡았는지” 더 정확하게 추출할 수 있다.
따라서 STT 도구를 Whisper에서 Deepgram으로 변경했고, Deepgram의 diarization 기능을 활용해 화자 분리가 포함된 회의록을 생성하도록 개선했다.
전체 서비스 처리 흐름
[회의 녹음 파일 업로드]
↓
[Deepgram STT 요청]
↓
[화자 분리 diarization]
↓
[화자별 회의록 문자열 변환]
↓
[LLM에게 Action Item 추출 요청]
↓
[할 일 목록 생성]
↓
[KNOTE 화면에 표시]
Deepgram Service 구현
// DeepgramService.java
@Slf4j
@Service
public class DeepgramService {
@Value("${deepgram.api.key}")
private String apiKey;
@Value("${deepgram.api.url}")
private String apiUrl;
private final RestTemplate restTemplate;
public DeepgramService() {
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(60000);
factory.setReadTimeout(120000);
this.restTemplate = new RestTemplate(factory);
}
public String transcribeWithDiarization(byte[] fileBytes) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.parseMediaType("audio/wav"));
headers.set("Authorization", "Token " + apiKey);
String url = apiUrl
+ "?model=nova-2&smart_format=true&diarize=true&language=ko";
HttpEntity<byte[]> requestEntity = new HttpEntity<>(fileBytes, headers);
try {
ResponseEntity<Map> response =
restTemplate.postForEntity(url, requestEntity, Map.class);
return formatDeepgramResult(response.getBody());
} catch (Exception e) {
log.error("Deepgram API 호출 실패", e);
throw new RuntimeException("화자 분리 처리 중 오류 발생");
}
}
}
이어서 아래의 부분에서 코드 설명을 진행하겠다.
코드 설명
DeepgramService는 회의 녹음 파일을 받아 Deepgram API에 전달하고, 응답 결과를 KNOTE에서 사용하기 쉬운 문자열 형태로 변환하는 역할을 한다.
핵심 옵션은 다음과 같다.
| 옵션 | 역할 |
| model=nova-2 | 음성 인식 모델 지정 |
| smart_format=true | 문장 부호, 숫자 등 텍스트 형식 개선 |
| diarize=true | 화자 분리 활성화 |
| language=ko | 한국어 회의 녹음 처리 |
특히 diarize=true 옵션이 가장 중요하다.
이 옵션을 통해 Deepgram은 발화 구간마다 speaker 값을 부여하고, KNOTE는 이를 이용해 “화자 0”, “화자 1” 형태의 회의록을 만들 수 있다.
서비스에 회의 녹음 파일을 업로드하면 먼저 회의 정보를 DB에 저장한다.
이후 업로드된 파일을 byte 배열로 복사한 뒤, 별도의 AI 처리 로직으로 전달한다.
파일 업로드 직후 바로 분석 결과를 기다리게 하면 사용자는 긴 시간 동안 응답을 기다려야 한다.
따라서 회의는 먼저 `PENDING` 상태로 생성하고, STT와 요약, Action Item 생성은 별도 처리 흐름에서 진행되도록 구성했다.
@PostMapping(value = "/record-file", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<CreateMeetingRecordResponse> createMeetingByFile(
@RequestPart("file") MultipartFile file,
@ModelAttribute @Valid CreateMeetingRecordRequest request
) {
return ResponseEntity.status(HttpStatus.CREATED)
.body(meetingService.createMeetingByFile(file, request));
}
실제 AI 분석은 processAiTasks() 메서드에서 진행된다.
이 메서드는 하나의 회의 ID와 녹음 파일 byte 배열을 받아 다음 순서로 동작한다.
회의 조회
↓
Deepgram STT 실행
↓
화자별 회의록 저장
↓
OpenAI 요약/분석 요청
↓
AI 응답 JSON 파싱
↓
회의 요약 저장
↓
Action Item 저장
String transcript = deepgramService.transcribeWithDiarization(fileBytes);
meeting.setTranscript(transcript);
meeting.setStatus("PROCESSING");
meetingRepository.saveAndFlush(meeting);
String aiJsonResponse = openAiService.summarize(transcript);
Deepgram을 통해 생성된 transcript는 Meeting 엔티티의 transcript 필드에 저장된다.
이후 해당 transcript를 OpenAI 요약 로직에 전달하여 회의 요약과 Action Item을 생성한다.
즉, Deepgram은 음성을 텍스트로 변환하는 역할을 하고, OpenAI는 텍스트에서 의미 있는 회의 결과를 추출하는 역할을 한다.
AI 응답에서 Action Item 추출하기
OpenAI 응답은 문자열 형태의 JSON으로 반환되기 때문에, 백엔드에서는 ObjectMapper를 사용해 JSON을 파싱한다.
그중 actionItems 배열을 순회하면서 각각의 할 일을 `ActionItem` 엔티티로 변환해 DB에 저장한다.
ObjectMapper mapper = new ObjectMapper();
JsonNode root = mapper.readTree(aiJsonResponse);
JsonNode actionItemsNode = root.path("actionItems");
if (actionItemsNode.isArray()) {
for (JsonNode node : actionItemsNode) {
ActionItem item = new ActionItem();
item.setTitle(node.path("title").asText("새로운 할 일"));
item.setMemo(node.path("memo").asText("내용 없음"));
item.setMeeting(meeting);
item.setCompleted(false);
item.setActivated(true);
item.setIsConfirmed(true);
item.setPhase(node.path("phase").asLong(1L));
item.setScope("TEAM");
item.setImage("default.png");
actionItemRepository.save(item);
}
}
AI가 생성한 결과를 그대로 화면에 보여주는 것이 아니라, 서비스에서 관리 가능한 ActionItem 엔티티로 저장한다는 점이 중요하다.
이를 통해 사용자는 이후 화면에서 할 일 제목, 마감일, 담당자를 수정하거나 삭제할 수 있다.
즉, AI는 초안을 생성하고, 사용자는 최종적으로 이를 확인하고 수정하는 구조다.
생성된 Action Item 조회 및 수정
생성된 Action Item은 회의 상세 화면이나 할 일 관리 화면에서 조회할 수 있다.
KNOTE에서는 전체 Action Item 조회, 특정 담당자별 조회, 특정 회의별 조회 API를 제공한다.
@GetMapping("/meeting/{meetingId}")
public ResponseEntity<List<ActionItemDTO>> getByMeeting(@PathVariable Long meetingId) {
return ResponseEntity.ok(actionItemService.getByMeetingId(meetingId));
}
@Transactional(readOnly = true)
public List<ActionItemDTO> getByMeetingId(Long meetingId) {
List<ActionItem> actionItems = actionItemRepository.findByMeetingId(meetingId);
return actionItems.stream()
.map(item -> mapToDTO(item, new ActionItemDTO()))
.collect(Collectors.toList());
}
또한 사용자가 AI가 만든 할 일을 그대로 사용하는 것이 아니라, 제목이나 마감 기한, 담당자를 직접 수정할 수 있도록 PATCH API도 구현했다.
@PatchMapping("/{id}")
public ResponseEntity<Void> patchActionItem(
@PathVariable Long id,
@RequestBody ActionItemPatchRequest request
) {
actionItemService.patchActionItem(id, request);
return ResponseEntity.ok().build();
}
참고로 액션아이템의 마감일은 AI 응답에 포함될 수 있지만, 현재 구현에서는 기본값으로 회의 생성일 기준 3일 뒤를 설정하도록 처리했다.
이는 AI가 마감일을 정확히 추출하지 못하는 경우에도 Action Item이 유효한 기본값을 갖도록 하기 위함이다.
OpenAI로 회의 요약과 Action Item 생성하기
Deepgram을 통해 화자별 회의록이 생성되면, 다음 단계에서는 OpenAI API를 사용해 회의 내용을 분석한다.
이때 단순히 “회의를 요약해줘”라고 요청하지 않고, KNOTE에서 바로 사용할 수 있는 JSON 구조로 응답하도록 프롬프트를 설계했다.
public String summarize(String transcript) {
String url = baseUrl + "/chat/completions";
String systemPrompt =
"너는 전문 회의 기록가이자 프로젝트 매니저야. 제공된 회의 스크립트를 분석해서 'Action Items(할 일)'를 추출해.\n\n" +
"1. 오직 회의 스크립트에서 언급된 할 일만 추출해.\n" +
"2. 마감 기한이 있다면 dueDate에 작성해.\n" +
"3. 해당 할 일을 언급한 화자를 suggestedSpeaker에 작성해.\n" +
"4. 반드시 JSON 형식으로만 응답해.";
Map<String, Object> body = new HashMap<>();
body.put("model", "gpt-4o-mini");
body.put("messages", List.of(
Map.of("role", "system", "content", systemPrompt),
Map.of("role", "user", "content", transcript)
));
body.put("response_format", Map.of("type", "json_object"));
}
AI 응답 예시
{
"title": "포스터 제작 회의",
"summarySegments": [
"포스터의 색상과 레이아웃 수정 방향을 논의했다.",
"발표 자료와 최종본 공유 일정을 정했다."
],
"actionItems": [
{
"title": "포스터 색상과 레이아웃 수정",
"memo": "KNOTE 로고 색감에 맞춰 포스터 디자인을 정리한다.",
"suggestedSpeaker": "화자 1",
"dueDate": "2026-05-26T18:00:00",
"phase": 2
},
{
"title": "수정본 공유",
"memo": "수정된 포스터 최종본을 팀원들에게 공유한다.",
"suggestedSpeaker": "화자 0",
"dueDate": "2026-05-27T18:00:00",
"phase": 5
}
]
}
실행 방법
실행을 위해서는 Deepgram API Key와 OpenAI API Key가 필요하다.
`application.yml`에는 다음과 같이 외부 API 설정을 추가한다.
deepgram:
api:
key: ${DEEPGRAM_API_KEY}
url: https://api.deepgram.com/v1/listen
openai:
api:
key: ${OPENAI_API_KEY}
url: https://api.openai.com/v1
이후 회의 녹음 파일을 업로드하는 API를 호출한다.
마무리
관련 기술에 대해 고민한 점을 얘기해보면서 마무리를 지어보고싶다.
첫 번째로 고려한 점은 화자 분리였다.
회의 서비스에서는 단순히 내용을 텍스트로 바꾸는 것보다, 누가 어떤 말을 했는지 보존하는 것이 중요하다.
이 때문에 Whisper 대신 Deepgram을 사용했고, `diarize=true` 옵션을 적용했다.
두 번째로 고려한 점은 응답 형식이다.
LLM 응답이 자유로운 문장 형태로 오면 백엔드에서 안정적으로 파싱하기 어렵다.
따라서 JSON 형식을 강제하고, `title`, `summarySegments`, `actionItems`처럼 필요한 필드를 명확히 지정했다.
세 번째로 고려한 점은 사용자 수정 가능성이다.
AI가 생성한 Action Item이 항상 완벽하지는 않기 때문에, 사용자가 제목, 마감일, 담당자를 수정할 수 있도록 PATCH API를 함께 제공했다.
이번 구현을 통해 KNOTE는 회의 녹음 파일을 단순히 텍스트로 변환하는 데서 끝나지 않고, 실제 사용자가 관리할 수 있는 Action Item으로 연결할 수 있게 되었다.
Deepgram은 화자 분리를 통해 회의 맥락을 보존하고, OpenAI는 해당 회의록에서 요약과 할 일을 추출한다.
그리고 백엔드는 AI 결과를 ActionItem 엔티티로 저장해 회의 상세 화면과 할 일 관리 화면에서 사용할 수 있도록 연결한다.
즉, KNOTE의 핵심 기능은 다음 흐름으로 정리할 수 있다.
“회의 녹음 → 화자별 회의록 → AI 요약 → Action Item 생성 → 사용자 확인 및 수정”
이 구조를 통해 회의가 끝난 뒤 사용자가 직접 회의록을 다시 읽고 할 일을 정리해야 하는 부담을 줄일 수 있었다.
참고용으로 우리 AllIsWell 팀의 깃허브 주소 또한 첨부한다.
(아직 개발이 진행 중이라는 점은 비밀)
https://github.com/ALLISWELL-Lab