STT 테스트 환경 구성 및 유저스토리 업데이트

- docker-compose.test.yml 추가: 테스트용 컨테이너 환경 구성
- STT 테스트 설정 및 컨트롤러 테스트 코드 추가
- application.yml 업데이트
- 테스트 스크립트 추가
- 유저스토리 문서 업데이트

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Minseo-Jo 2025-10-24 14:50:56 +09:00
parent 694a84e4f5
commit ad8e0adbd8
7 changed files with 364 additions and 23 deletions

View File

@ -49,7 +49,7 @@
- 실시간 협업: WebSocket 기반 실시간 동기화, 버전 관리, 충돌 해결 - 실시간 협업: WebSocket 기반 실시간 동기화, 버전 관리, 충돌 해결
- 템플릿 관리: 회의록 템플릿 관리 - 템플릿 관리: 회의록 템플릿 관리
- 통계 생성: 회의 및 Todo 통계 - 통계 생성: 회의 및 Todo 통계
3. **STT** - 음성 녹음 관리, 음성-텍스트 변환, 화자 식별 (기본 기능) 3. **STT** - 음성 스트리밍 처리, 실시간 음성-텍스트 변환 (기본 기능)
4. **AI** - AI 기반 회의록 자동화, Todo 추출, 지능형 검색 (RAG 통합) 4. **AI** - AI 기반 회의록 자동화, Todo 추출, 지능형 검색 (RAG 통합)
- LLM 기반 회의록 자동 작성 - LLM 기반 회의록 자동 작성
- Todo 자동 추출 및 담당자 식별 - Todo 자동 추출 및 담당자 식별
@ -478,30 +478,29 @@ UFR-MEET-055: [회의록수정] 회의 참석자로서 | 나는, 검증이 완
3. STT 서비스 (음성 인식 및 변환 - 기본 기능) 3. STT 서비스 (음성 인식 및 변환 - 기본 기능)
1) 음성 인식 및 변환 1) 음성 인식 및 변환
UFR-STT-010: [음성녹음인식] 회의 참석자로서 | 나는, 발언 내용이 자동으로 기록되기 위해 | 음성이 실시간으로 녹음되고 인식되기를 원한다. UFR-STT-010: [음성녹음인식] 회의 참석자로서 | 나는, 발언 내용이 자동으로 기록되기 위해 | 음성이 실시간으로 녹음되고 인식되기를 원한다.
- 시나리오: 음성 녹음 및 발언 인식 - 시나리오: 음성 실시간 인식
회의가 시작된 상황에서 | 참석자가 발언을 시작하면 | 음성이 자동으로 녹음되고 화자가 식별되며 발언이 인식된다. 회의가 시작된 상황에서 | 참석자가 발언을 시작하면 | 음성이 실시간으로 텍스트로 변환된다.
[음성 녹음 처리] [음성 스트리밍 처리]
- 오디오 스트림 실시간 캡처 - 오디오 스트림 실시간 캡처
- 회의 ID와 연결 - 회의 ID와 연결
- 음성 데이터 저장 (Azure 스토리지) - **음성 파일은 저장하지 않음** (실시간 스트리밍만 처리)
[발언 인식 처리] [음성 인식 처리]
- AI 음성인식 엔진 연동 (Azure Speech 등) - AI 음성인식 엔진 연동 (Azure Speech 등)
- 화자 자동 식별 - 실시간 텍스트 변환
- 참석자 목록 매칭
- 음성 특징 분석
- 타임스탬프 기록 - 타임스탬프 기록
- 발언 구간 구분
[처리 결과] [처리 결과]
- 음성 녹음이 시작됨 (녹음 ID) - 음성 스트리밍이 시작됨 (세션 ID)
- 발언이 인식됨 (발언 ID, 화자, 타임스탬프) - 텍스트가 변환됨 (세그먼트 ID, 텍스트, 타임스탬프)
- 실시간으로 텍스트 변환 요청 (UFR-STT-020 연동) - 실시간으로 텍스트 변환 요청 (UFR-STT-020 연동)
- **음성 파일은 저장되지 않고 스트리밍만 처리됨**
- **화자 식별 기능 없음** (단순 텍스트 변환만)
[성능 요구사항] [성능 요구사항]
- 발언 인식 지연 시간: 1초 이내 - 음성 인식 지연 시간: 1초 이내
- 화자 식별 정확도: 90% 이상 - 변환 정확도: 85% 이상
[비고] [비고]
- STT는 기본 기능으로 경쟁사 대부분이 제공하는 기능임 - STT는 기본 기능으로 경쟁사 대부분이 제공하는 기능임

46
docker-compose.test.yml Normal file
View File

@ -0,0 +1,46 @@
version: '3.8'
services:
# Test Database
postgres-test:
image: postgres:15-alpine
environment:
POSTGRES_DB: sttdb_test
POSTGRES_USER: testuser
POSTGRES_PASSWORD: testpass
ports:
- "5433:5432"
volumes:
- postgres_test_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U testuser -d sttdb_test"]
interval: 5s
timeout: 5s
retries: 5
# Test Redis
redis-test:
image: redis:7-alpine
ports:
- "6380:6379"
command: redis-server --requirepass testpass
healthcheck:
test: ["CMD", "redis-cli", "-a", "testpass", "ping"]
interval: 5s
timeout: 3s
retries: 5
# Test Azure Storage Emulator (Azurite)
azurite-test:
image: mcr.microsoft.com/azure-storage/azurite
ports:
- "10000:10000" # Blob service
- "10001:10001" # Queue service
- "10002:10002" # Table service
command: azurite --blobHost 0.0.0.0 --queueHost 0.0.0.0 --tableHost 0.0.0.0
volumes:
- azurite_data:/data
volumes:
postgres_test_data:
azurite_data:

View File

@ -4,9 +4,9 @@ spring:
# Database Configuration # Database Configuration
datasource: datasource:
url: jdbc:${DB_KIND:postgresql}://${DB_HOST:4.230.65.89}:${DB_PORT:5432}/${DB_NAME:sttdb} url: jdbc:${DB_KIND:postgresql}://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:sttdb}
username: ${DB_USERNAME:hgzerouser} username: ${DB_USERNAME:stt_user}
password: ${DB_PASSWORD:Hi5Jessica!} password: ${DB_PASSWORD:}
driver-class-name: org.postgresql.Driver driver-class-name: org.postgresql.Driver
hikari: hikari:
maximum-pool-size: 20 maximum-pool-size: 20
@ -30,9 +30,9 @@ spring:
# Redis Configuration # Redis Configuration
data: data:
redis: redis:
host: ${REDIS_HOST:20.249.177.114} host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379} port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:Hi5Jessica!} password: ${REDIS_PASSWORD:}
timeout: 2000ms timeout: 2000ms
lettuce: lettuce:
pool: pool:
@ -40,7 +40,7 @@ spring:
max-idle: 8 max-idle: 8
min-idle: 0 min-idle: 0
max-wait: -1ms max-wait: -1ms
database: ${REDIS_DATABASE:2} database: ${REDIS_DATABASE:3}
# Server Configuration # Server Configuration
server: server:
@ -48,9 +48,9 @@ server:
# JWT Configuration # JWT Configuration
jwt: jwt:
secret: ${JWT_SECRET:} secret: ${JWT_SECRET:HGZero_Secret_Key_For_Development_Only}
access-token-validity: ${JWT_ACCESS_TOKEN_VALIDITY:3600} access-token-validity: ${JWT_ACCESS_TOKEN_VALIDITY:1800}
refresh-token-validity: ${JWT_REFRESH_TOKEN_VALIDITY:604800} refresh-token-validity: ${JWT_REFRESH_TOKEN_VALIDITY:86400}
# CORS Configuration # CORS Configuration
cors: cors:

View File

@ -0,0 +1,82 @@
package com.unicorn.hgzero.stt.config;
import com.unicorn.hgzero.stt.event.publisher.EventPublisher;
import com.unicorn.hgzero.stt.repository.jpa.RecordingRepository;
import com.unicorn.hgzero.stt.repository.jpa.SpeakerRepository;
import com.unicorn.hgzero.stt.repository.jpa.TranscriptionRepository;
import com.unicorn.hgzero.stt.repository.jpa.TranscriptSegmentRepository;
import com.unicorn.hgzero.stt.service.RecordingService;
import com.unicorn.hgzero.stt.service.SpeakerService;
import com.unicorn.hgzero.stt.service.TranscriptionService;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.domain.EntityScan;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Primary;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import redis.embedded.RedisServer;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import static org.mockito.Mockito.mock;
/**
* 테스트용 설정 클래스
* Embedded Redis 서버와 Mock Bean들을 제공
*/
@TestConfiguration
@EnableJpaRepositories(basePackages = "com.unicorn.hgzero.stt.repository.jpa")
@EntityScan(basePackages = "com.unicorn.hgzero.stt.repository.entity")
@ComponentScan(basePackages = {"com.unicorn.hgzero.stt.service", "com.unicorn.hgzero.stt.config"})
public class TestConfig {
private RedisServer redisServer;
@PostConstruct
public void startRedis() {
try {
redisServer = new RedisServer(6370);
redisServer.start();
} catch (Exception e) {
// Redis 서버 시작 실패 로그만 남기고 계속 진행
System.err.println("Failed to start embedded Redis server: " + e.getMessage());
}
}
@PreDestroy
public void stopRedis() {
if (redisServer != null && redisServer.isActive()) {
redisServer.stop();
}
}
@Bean
@Primary
public EventPublisher eventPublisher() {
return mock(EventPublisher.class);
}
@Bean
@Primary
@ConditionalOnMissingBean
public RedisConnectionFactory redisConnectionFactory() {
// Embedded Redis에 연결하는 실제 ConnectionFactory
return new LettuceConnectionFactory("localhost", 6370);
}
@Bean
@Primary
@ConditionalOnMissingBean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
return template;
}
}

View File

@ -0,0 +1,55 @@
package com.unicorn.hgzero.stt.config;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Primary;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import redis.embedded.RedisServer;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
/**
* WebMvcTest용 최소한의 설정 클래스
* Repository는 MockBean으로 별도 처리
*/
@TestConfiguration
@ComponentScan(basePackages = "com.unicorn.hgzero.stt.config")
public class WebMvcTestConfig {
private RedisServer redisServer;
@PostConstruct
public void startRedis() {
try {
redisServer = new RedisServer(6371); // 다른 포트 사용
redisServer.start();
} catch (Exception e) {
System.err.println("Failed to start embedded Redis server: " + e.getMessage());
}
}
@PreDestroy
public void stopRedis() {
if (redisServer != null && redisServer.isActive()) {
redisServer.stop();
}
}
@Bean
@Primary
public RedisConnectionFactory redisConnectionFactory() {
return new LettuceConnectionFactory("localhost", 6371);
}
@Bean
@Primary
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
return template;
}
}

View File

@ -0,0 +1,109 @@
package com.unicorn.hgzero.stt.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.unicorn.hgzero.stt.dto.RecordingDto;
import com.unicorn.hgzero.stt.service.RecordingService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import java.time.LocalDateTime;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
/**
* 간단한 녹음 컨트롤러 테스트
*/
@WebMvcTest(RecordingController.class)
@DisplayName("간단한 녹음 컨트롤러 테스트")
class SimpleRecordingControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@MockBean
private RecordingService recordingService;
private RecordingDto.PrepareRequest prepareRequest;
private RecordingDto.PrepareResponse prepareResponse;
@BeforeEach
void setUp() {
prepareRequest = RecordingDto.PrepareRequest.builder()
.meetingId("MEETING-001")
.sessionId("SESSION-001")
.language("ko-KR")
.build();
prepareResponse = RecordingDto.PrepareResponse.builder()
.recordingId("REC-20250123-001")
.sessionId("SESSION-001")
.status("READY")
.streamUrl("wss://api.example.com/stt/v1/ws/stt/SESSION-001")
.storagePath("recordings/MEETING-001/SESSION-001.wav")
.estimatedInitTime(1100)
.build();
}
@Test
@DisplayName("녹음 준비 API 성공")
void prepareRecording_Success() throws Exception {
// Given
when(recordingService.prepareRecording(any(RecordingDto.PrepareRequest.class)))
.thenReturn(prepareResponse);
// When & Then
mockMvc.perform(post("/api/v1/stt/recordings/prepare")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(prepareRequest)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.recordingId").value("REC-20250123-001"))
.andExpect(jsonPath("$.data.sessionId").value("SESSION-001"))
.andExpect(jsonPath("$.data.status").value("READY"));
verify(recordingService).prepareRecording(any(RecordingDto.PrepareRequest.class));
}
@Test
@DisplayName("녹음 시작 API 성공")
void startRecording_Success() throws Exception {
// Given
String recordingId = "REC-20250123-001";
RecordingDto.StartRequest startRequest = RecordingDto.StartRequest.builder()
.startedBy("user001")
.build();
RecordingDto.StatusResponse statusResponse = RecordingDto.StatusResponse.builder()
.recordingId(recordingId)
.status("RECORDING")
.startTime(LocalDateTime.now())
.duration(0)
.build();
when(recordingService.startRecording(eq(recordingId), any(RecordingDto.StartRequest.class)))
.thenReturn(statusResponse);
// When & Then
mockMvc.perform(post("/api/v1/stt/recordings/{recordingId}/start", recordingId)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(startRequest)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.recordingId").value(recordingId))
.andExpect(jsonPath("$.data.status").value("RECORDING"));
verify(recordingService).startRecording(eq(recordingId), any(RecordingDto.StartRequest.class));
}
}

View File

@ -0,0 +1,50 @@
#!/bin/bash
# STT 서비스 통합 테스트 실행 스크립트
set -e
echo "🚀 STT 서비스 통합 테스트 시작"
# 1. Docker 테스트 환경 시작
echo "📦 Docker 테스트 환경 시작..."
docker-compose -f docker-compose.test.yml up -d
# 2. 서비스 준비 대기
echo "⏳ 서비스 준비 대기 중..."
sleep 15
# 3. 데이터베이스 준비 확인
echo "🔍 PostgreSQL 연결 확인..."
until docker-compose -f docker-compose.test.yml exec -T postgres-test pg_isready -U testuser -d sttdb_test; do
echo "PostgreSQL 준비 중..."
sleep 2
done
# 4. Redis 연결 확인
echo "🔍 Redis 연결 확인..."
until docker-compose -f docker-compose.test.yml exec -T redis-test redis-cli -a testpass ping | grep PONG; do
echo "Redis 준비 중..."
sleep 2
done
# 5. 통합 테스트 실행
echo "🧪 통합 테스트 실행..."
export JAVA_HOME=/Library/Java/JavaVirtualMachines/jdk-23.0.2.jdk/Contents/Home
./gradlew :stt:test -Dspring.profiles.active=integration-test
# 6. 결과 출력
if [ $? -eq 0 ]; then
echo "✅ 통합 테스트 성공!"
else
echo "❌ 통합 테스트 실패!"
fi
# 7. Docker 환경 정리 (선택)
read -p "Docker 테스트 환경을 정리하시겠습니까? (y/N): " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
echo "🧹 Docker 테스트 환경 정리 중..."
docker-compose -f docker-compose.test.yml down -v
echo "✅ 정리 완료"
fi