From ad8e0adbd8cf3189af678a78b9a6eaed65e46d78 Mon Sep 17 00:00:00 2001 From: Minseo-Jo Date: Fri, 24 Oct 2025 14:50:56 +0900 Subject: [PATCH] =?UTF-8?q?STT=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=ED=99=98?= =?UTF-8?q?=EA=B2=BD=20=EA=B5=AC=EC=84=B1=20=EB=B0=8F=20=EC=9C=A0=EC=A0=80?= =?UTF-8?q?=EC=8A=A4=ED=86=A0=EB=A6=AC=20=EC=97=85=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docker-compose.test.yml 추가: 테스트용 컨테이너 환경 구성 - STT 테스트 설정 및 컨트롤러 테스트 코드 추가 - application.yml 업데이트 - 테스트 스크립트 추가 - 유저스토리 문서 업데이트 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- design/userstory.md | 27 +++-- docker-compose.test.yml | 46 ++++++++ stt/src/main/resources/application.yml | 18 +-- .../unicorn/hgzero/stt/config/TestConfig.java | 82 +++++++++++++ .../hgzero/stt/config/WebMvcTestConfig.java | 55 +++++++++ .../SimpleRecordingControllerTest.java | 109 ++++++++++++++++++ test-scripts/run-integration-tests.sh | 50 ++++++++ 7 files changed, 364 insertions(+), 23 deletions(-) create mode 100644 docker-compose.test.yml create mode 100644 stt/src/test/java/com/unicorn/hgzero/stt/config/TestConfig.java create mode 100644 stt/src/test/java/com/unicorn/hgzero/stt/config/WebMvcTestConfig.java create mode 100644 stt/src/test/java/com/unicorn/hgzero/stt/controller/SimpleRecordingControllerTest.java create mode 100755 test-scripts/run-integration-tests.sh diff --git a/design/userstory.md b/design/userstory.md index c6c5b18..66a42d9 100644 --- a/design/userstory.md +++ b/design/userstory.md @@ -49,7 +49,7 @@ - 실시간 협업: WebSocket 기반 실시간 동기화, 버전 관리, 충돌 해결 - 템플릿 관리: 회의록 템플릿 관리 - 통계 생성: 회의 및 Todo 통계 -3. **STT** - 음성 녹음 관리, 음성-텍스트 변환, 화자 식별 (기본 기능) +3. **STT** - 음성 스트리밍 처리, 실시간 음성-텍스트 변환 (기본 기능) 4. **AI** - AI 기반 회의록 자동화, Todo 추출, 지능형 검색 (RAG 통합) - LLM 기반 회의록 자동 작성 - Todo 자동 추출 및 담당자 식별 @@ -478,30 +478,29 @@ UFR-MEET-055: [회의록수정] 회의 참석자로서 | 나는, 검증이 완 3. STT 서비스 (음성 인식 및 변환 - 기본 기능) 1) 음성 인식 및 변환 UFR-STT-010: [음성녹음인식] 회의 참석자로서 | 나는, 발언 내용이 자동으로 기록되기 위해 | 음성이 실시간으로 녹음되고 인식되기를 원한다. -- 시나리오: 음성 녹음 및 발언 인식 - 회의가 시작된 상황에서 | 참석자가 발언을 시작하면 | 음성이 자동으로 녹음되고 화자가 식별되며 발언이 인식된다. +- 시나리오: 음성 실시간 인식 + 회의가 시작된 상황에서 | 참석자가 발언을 시작하면 | 음성이 실시간으로 텍스트로 변환된다. - [음성 녹음 처리] + [음성 스트리밍 처리] - 오디오 스트림 실시간 캡처 - 회의 ID와 연결 - - 음성 데이터 저장 (Azure 스토리지) + - **음성 파일은 저장하지 않음** (실시간 스트리밍만 처리) - [발언 인식 처리] + [음성 인식 처리] - AI 음성인식 엔진 연동 (Azure Speech 등) - - 화자 자동 식별 - - 참석자 목록 매칭 - - 음성 특징 분석 + - 실시간 텍스트 변환 - 타임스탬프 기록 - - 발언 구간 구분 [처리 결과] - - 음성 녹음이 시작됨 (녹음 ID) - - 발언이 인식됨 (발언 ID, 화자, 타임스탬프) + - 음성 스트리밍이 시작됨 (세션 ID) + - 텍스트가 변환됨 (세그먼트 ID, 텍스트, 타임스탬프) - 실시간으로 텍스트 변환 요청 (UFR-STT-020 연동) + - **음성 파일은 저장되지 않고 스트리밍만 처리됨** + - **화자 식별 기능 없음** (단순 텍스트 변환만) [성능 요구사항] - - 발언 인식 지연 시간: 1초 이내 - - 화자 식별 정확도: 90% 이상 + - 음성 인식 지연 시간: 1초 이내 + - 변환 정확도: 85% 이상 [비고] - STT는 기본 기능으로 경쟁사 대부분이 제공하는 기능임 diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 0000000..d04bbdc --- /dev/null +++ b/docker-compose.test.yml @@ -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: \ No newline at end of file diff --git a/stt/src/main/resources/application.yml b/stt/src/main/resources/application.yml index efe37bd..e6a640e 100644 --- a/stt/src/main/resources/application.yml +++ b/stt/src/main/resources/application.yml @@ -4,9 +4,9 @@ spring: # Database Configuration datasource: - url: jdbc:${DB_KIND:postgresql}://${DB_HOST:4.230.65.89}:${DB_PORT:5432}/${DB_NAME:sttdb} - username: ${DB_USERNAME:hgzerouser} - password: ${DB_PASSWORD:Hi5Jessica!} + url: jdbc:${DB_KIND:postgresql}://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:sttdb} + username: ${DB_USERNAME:stt_user} + password: ${DB_PASSWORD:} driver-class-name: org.postgresql.Driver hikari: maximum-pool-size: 20 @@ -30,9 +30,9 @@ spring: # Redis Configuration data: redis: - host: ${REDIS_HOST:20.249.177.114} + host: ${REDIS_HOST:localhost} port: ${REDIS_PORT:6379} - password: ${REDIS_PASSWORD:Hi5Jessica!} + password: ${REDIS_PASSWORD:} timeout: 2000ms lettuce: pool: @@ -40,7 +40,7 @@ spring: max-idle: 8 min-idle: 0 max-wait: -1ms - database: ${REDIS_DATABASE:2} + database: ${REDIS_DATABASE:3} # Server Configuration server: @@ -48,9 +48,9 @@ server: # JWT Configuration jwt: - secret: ${JWT_SECRET:} - access-token-validity: ${JWT_ACCESS_TOKEN_VALIDITY:3600} - refresh-token-validity: ${JWT_REFRESH_TOKEN_VALIDITY:604800} + secret: ${JWT_SECRET:HGZero_Secret_Key_For_Development_Only} + access-token-validity: ${JWT_ACCESS_TOKEN_VALIDITY:1800} + refresh-token-validity: ${JWT_REFRESH_TOKEN_VALIDITY:86400} # CORS Configuration cors: diff --git a/stt/src/test/java/com/unicorn/hgzero/stt/config/TestConfig.java b/stt/src/test/java/com/unicorn/hgzero/stt/config/TestConfig.java new file mode 100644 index 0000000..04d2a33 --- /dev/null +++ b/stt/src/test/java/com/unicorn/hgzero/stt/config/TestConfig.java @@ -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 redisTemplate(RedisConnectionFactory connectionFactory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(connectionFactory); + return template; + } +} \ No newline at end of file diff --git a/stt/src/test/java/com/unicorn/hgzero/stt/config/WebMvcTestConfig.java b/stt/src/test/java/com/unicorn/hgzero/stt/config/WebMvcTestConfig.java new file mode 100644 index 0000000..fc50bfd --- /dev/null +++ b/stt/src/test/java/com/unicorn/hgzero/stt/config/WebMvcTestConfig.java @@ -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 redisTemplate(RedisConnectionFactory connectionFactory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(connectionFactory); + return template; + } +} \ No newline at end of file diff --git a/stt/src/test/java/com/unicorn/hgzero/stt/controller/SimpleRecordingControllerTest.java b/stt/src/test/java/com/unicorn/hgzero/stt/controller/SimpleRecordingControllerTest.java new file mode 100644 index 0000000..efb6156 --- /dev/null +++ b/stt/src/test/java/com/unicorn/hgzero/stt/controller/SimpleRecordingControllerTest.java @@ -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)); + } +} \ No newline at end of file diff --git a/test-scripts/run-integration-tests.sh b/test-scripts/run-integration-tests.sh new file mode 100755 index 0000000..4dec99f --- /dev/null +++ b/test-scripts/run-integration-tests.sh @@ -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 \ No newline at end of file