Skip to content

Conversation

@ImGdevel
Copy link
Member

@ImGdevel ImGdevel commented Sep 13, 2025

Summary by CodeRabbit

  • New Features
    • 대용량 바이너리 데이터를 청크 단위로 전송 지원하여 실시간 전송 안정성 및 확장성 향상.
  • Refactor
    • 오디오 처리와 텍스트 전송 경로 전반에서 메모리 사용 최적화(풀 기반 버퍼, 스팬 활용, Base64 인코딩 개선)로 메모리 점유·GC 압력 감소 및 응답성 향상.
    • 텍스트 음성 변환 응답의 스트리밍식 읽기 도입으로 대규모 오디오 처리 안정성 향상.
    • 실시간 메시지 전송 시 인코딩 효율 개선으로 지연 시간 감소.
  • Tests
    • 메모리 풀링·인코딩 성능 비교 및 메모리 압력 검증 테스트 추가.

관련 이슈
#15

- TTS 클라이언트: 32KB 청크 단위 스트림 읽기로 대용량 음성 데이터 LOH 할당 방지
- WebSocket 전송: UTF8 인코딩 시 ArrayPool 활용으로 임시 배열 생성 최소화
- ChatSegment: IMemoryOwner<byte> 지원 및 ReadOnlySpan<byte> 패턴 도입
- Base64 인코딩: System.Buffers.Text.Base64 활용으로 메모리 할당 최적화
- 성능 테스트: ArrayPool vs 직접 할당 21.7% 성능 개선 확인

85KB 이상 객체의 LOH 할당을 청크 방식으로 분산하여 GC 압박 완화
@coderabbitai
Copy link

coderabbitai bot commented Sep 13, 2025

Walkthrough

메모리 풀(ArrayPool/MemoryPool) 기반의 오디오 처리와 Base64 인코딩 최적화를 도입하고, WebSocket 전송과 TTS 응답 수신에 풀을 적용했습니다. ChatSegment는 메모리 소유자(IMemoryOwner) 기반 오디오를 지원하며, 관련 유틸과 수명 관리가 추가되었습니다. 성능/GC 비교용 테스트가 추가되었습니다.

Changes

Cohort / File(s) Summary
Chat 오디오 메모리 & Base64 최적화
ProjectVG.Application/Models/Chat/ChatSegment.cs, ProjectVG.Application/Models/Chat/ChatProcessResultMessage.cs
ChatSegment에 IMemoryOwner 및 AudioDataSize 추가, HasAudio/수명관리/스팬 액세스(GetAudioSpan) 지원, WithAudioMemory/배열 변환 메서드 도입. ChatProcessResultMessage는 ArrayPool과 Base64.EncodeToUtf8 기반의 스팬 입력 최적화 인코딩을 사용하도록 변경.
TTS 응답 읽기 풀 적용
ProjectVG.Infrastructure/Integrations/TextToSpeechClient/TextToSpeechClient.cs
ReadAsByteArrayAsync를 대체하는 ReadAudioDataWithPoolAsync 도입(32KB 청크, ArrayPool 버퍼, MemoryStream 누적), 로깅 및 예외 처리 유지. 공용 시그니처 불변.
WebSocket 텍스트/바이너리 전송 최적화
ProjectVG.Infrastructure/Realtime/WebSocketConnection/WebSocketClientConnection.cs
SendTextAsync가 ArrayPool로 UTF-8 버퍼 임차/반환하도록 변경(시그니처 async로 변경). 대용량 바이너리 청크 전송 메서드 SendLargeBinaryAsync 추가(32KB 분할). 기존 바이너리 전송은 경로만 단순화.
성능/GC 테스트 추가
ProjectVG.Tests/Infrastructure/Integrations/MemoryPoolingPerformanceTests.cs
ArrayPool vs 직접 할당, Base64 인코딩 경로 비교, ChatSegment의 byte[] vs MemoryPool 오디오 경로 비교 테스트 추가. Stopwatch/GC 카운터 측정 및 로그 출력.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  actor Caller as Caller
  participant TTS as TextToSpeechClient
  participant HTTP as HttpContent
  participant Pool as ArrayPool<byte>
  participant Seg as ChatSegment
  participant Msg as ChatProcessResultMessage

  Caller->>TTS: Request TTS audio
  TTS->>HTTP: Get Stream
  TTS->>Pool: Rent 32KB buffer
  loop Read chunks
    HTTP-->>TTS: Read chunk
    TTS->>TTS: Write to MemoryStream
  end
  TTS->>Pool: Return buffer
  TTS-->>Caller: byte[] audio + content-type + length

  Caller->>Seg: WithAudioMemory(IMemoryOwner, size, type, length) or WithAudioData(byte[])
  Seg->>Seg: GetAudioSpan() when needed
  Caller->>Msg: FromSegment(segment)
  Msg->>Pool: Rent Base64 target buffer
  Msg->>Msg: Base64.EncodeToUtf8(span → utf8 bytes)
  Msg->>Pool: Return buffer
  Msg-->>Caller: message with base64 audio
Loading
sequenceDiagram
  autonumber
  actor Svc as Service
  participant WS as WebSocketClientConnection
  participant Pool as ArrayPool<byte>

  rect rgba(200,235,255,0.3)
    note right of WS: 텍스트 전송(LOH 회피)
    Svc->>WS: SendTextAsync(message)
    WS->>Pool: Rent UTF-8 buffer
    WS->>WS: Encode message → buffer
    WS->>Svc: SendAsync(Text, exact byte count)
    WS->>Pool: Return buffer
  end

  rect rgba(220,255,220,0.3)
    note right of WS: 바이너리 대용량 청크 전송
    Svc->>WS: SendLargeBinaryAsync(data)
    loop 32KB chunks
      WS->>Svc: SendAsync(Binary, isLastChunk)
    end
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related issues

  • GC (LOH) 문제 #15 — ArrayPool/MemoryPool 기반 최적화와 LOH/GC 부담 완화 요구사항을 본 변경들이 직접 구현합니다.

Poem

버퍼를 빌려 톡톡, 메모리는 가볍게
바이트를 접어 휙휙, Base64로 날아가네
웹소켓은 청크로 철썩, 파도처럼 보내고
토끼는 귀를 쫑긋, GC 바람 가라앉네 🐇
풀에서 빌려 쓰고, 제때에 돌려주세!

✨ Finishing touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch perf/LOH-optimize

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Pre-merge checks

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 44.44% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Title Check ✅ Passed 제목은 PR의 핵심인 ArrayPool 기반 LOH 최적화를 통해 음성 데이터 메모리 효율성을 개선한 점을 명확히 요약합니다. 변경사항(ChatSegment의 IMemoryOwner 도입, TTS의 스트리밍/풀 사용, WebSocket의 ArrayPool 적용, Base64 최적화 등)이 메모리 할당 최적화에 집중되어 있어 제목과 일치합니다. 문장이 간결하고 불필요한 정보나 이모지를 포함하지 않아 히스토리 스캔 시 주요 변경점을 빠르게 파악할 수 있습니다.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
ProjectVG.Infrastructure/Integrations/TextToSpeechClient/TextToSpeechClient.cs (1)

34-38: HttpResponseMessage/StringContent 명시적 Dispose 필요

연결/소켓 누수를 피하려면 HttpResponseMessageStringContentusing var로 감싸는 것이 안전합니다. 공개 API 변경 없이도 적용 가능합니다.

// 적용 예 (메서드 내부 구조 참고용 스니펫)
using var content = new StringContent(json, Encoding.UTF8, "application/json");
var startTime = DateTime.UtcNow;
using var response = await _httpClient.PostAsync($"/v1/text-to-speech/{voiceId}", content).ConfigureAwait(false);
🧹 Nitpick comments (9)
ProjectVG.Infrastructure/Integrations/TextToSpeechClient/TextToSpeechClient.cs (2)

13-15: 사용되지 않는 상수 제거 또는 실제 제약 로직에 반영 필요

MaxPoolSize(1MB)가 선언만 되고 사용되지 않습니다. 유지하지 않을 거라면 제거하거나, 청크/버퍼 대여 시 상한으로 활용하세요.

-        private const int MaxPoolSize = 1024 * 1024; // 1MB max pooled size

66-67: 널 가능 값에 서식 지정 사용 자제

{AudioLength:F2}는 값이 null일 때 로거에서 예외가 날 수 있습니다. 포맷 제거 또는 널 병합 값을 권장합니다.

-                _logger.LogDebug("[TTS][Response] 오디오 길이: {AudioLength:F2}초, ContentType: {ContentType}, 바이트: {Length}, 소요시간: {Elapsed}ms",
-                    voiceResponse.AudioLength, voiceResponse.ContentType, voiceResponse.AudioData?.Length ?? 0, elapsed);
+                _logger.LogDebug("[TTS][Response] 오디오 길이: {AudioLength}초, ContentType: {ContentType}, 바이트: {Length}, 소요시간: {Elapsed}ms",
+                    voiceResponse.AudioLength?.ToString("F2"), voiceResponse.ContentType, voiceResponse.AudioData?.Length ?? 0, elapsed);
ProjectVG.Infrastructure/Realtime/WebSocketConnection/WebSocketClientConnection.cs (2)

32-52: 풀 반납 시 민감 데이터 정리 옵션 고려 + 취소 토큰 전달

  • 텍스트에는 개인/민감 데이터가 포함될 수 있으므로 ArrayPool.Return(buffer, clearArray: true)를 권장합니다.
  • SendAsyncCancellationToken을 허용하면 종료/타임아웃 처리에 유리합니다(공개 API에 토큰 추가는 호환성 영향 검토).
-                await WebSocket.SendAsync(segment, WebSocketMessageType.Text, true, CancellationToken.None);
+                await WebSocket.SendAsync(segment, WebSocketMessageType.Text, true, CancellationToken.None);
@@
-                    _arrayPool.Return(rentedBuffer);
+                    _arrayPool.Return(rentedBuffer, clearArray: true);

필요 시 public Task SendTextAsync(string message, CancellationToken ct) 오버로드 추가도 제안합니다.


63-83: 대용량 바이너리 청크 전송 설계 적합하나 API 개선 여지

  • 청크 전송 로직은 적절합니다.
  • 입력을 ReadOnlyMemory<byte> 또는 Stream으로 받는 오버로드를 추가하면 전체 byte[] 생성 없이 송신이 가능해져 LOH를 더 줄일 수 있습니다.
-        public async Task SendLargeBinaryAsync(byte[] data)
+        public async Task SendLargeBinaryAsync(ReadOnlyMemory<byte> data, CancellationToken ct = default)
         {
             const int chunkSize = 32768; // 32KB 청크
-            var totalLength = data.Length;
+            var totalLength = data.Length;
             var offset = 0;

             while (offset < totalLength)
             {
                 var remainingBytes = totalLength - offset;
                 var currentChunkSize = Math.Min(chunkSize, remainingBytes);
                 var isLastChunk = offset + currentChunkSize >= totalLength;

-                var segment = new ArraySegment<byte>(data, offset, currentChunkSize);
-                await WebSocket.SendAsync(segment, WebSocketMessageType.Binary, isLastChunk, CancellationToken.None);
+                var segment = data.Slice(offset, currentChunkSize);
+                await WebSocket.SendAsync(segment, WebSocketMessageType.Binary, isLastChunk, ct);
                 offset += currentChunkSize;
             }
         }
ProjectVG.Application/Models/Chat/ChatSegment.cs (2)

28-29: HasAudio 조건은 적절하나 불변/소유권 계약 명시 필요

메모리 기반/배열 기반을 모두 포괄하는 조건은 좋습니다. 다만, WithAudioMemory 사용 시 소유권(누가 Dispose 하는가)을 명확히 문서화하세요.

원하시면 XML 주석/README 섹션 초안을 제공하겠습니다.


99-116: 배열 변환: OK, 하지만 대용량 시 LOH 안내 주석 추가 권장

ToArray()는 대용량에서 LOH를 유발할 수 있으므로, 호출자에 이를 알리는 주석을 추가하면 사용 오해를 줄일 수 있습니다.

ProjectVG.Application/Models/Chat/ChatProcessResultMessage.cs (1)

77-105: Base64 최적화: 버퍼 반납 시 초기화 및 실패 시 폴백 유지

  • 풀 반납 시 민감 데이터(오디오)를 고려해 clearArray: true 권장.
  • 나머지 로직은 적절합니다.
-            var buffer = arrayPool.Rent(base64Length);
+            var buffer = arrayPool.Rent(base64Length);
@@
-                arrayPool.Return(buffer);
+                arrayPool.Return(buffer, clearArray: true);

또한 Base64 문자열은 크기가 커질 수 있어(입력 대비 약 4/3배) 결과 문자열 자체가 LOH에 오를 수 있음을(불가피) 주석으로 명시하면 기대치 관리에 도움이 됩니다.

ProjectVG.Tests/Infrastructure/Integrations/MemoryPoolingPerformanceTests.cs (2)

21-41: 성능 비교 테스트의 공정성 주석 보강 제안

직접 할당/풀 대여 모두 testData.Length * 2 버퍼를 사용해(첫 절반만 사용) 시나리오가 다소 인공적입니다. 주석으로 의도(Base64 크기 증가를 가정한 스트레스)를 명시하거나 입력/출력 구분이 명확한 벤치마크로 조정하는 것을 권장합니다.


58-62: GC 압박 비교는 환경 의존적 — 불안정성에 대한 가이드 안내 권장

CI 환경/런타임 버전에 따라 흔들릴 수 있으니, 실패 시 정보성 로그만 남기게 하거나 [Trait]로 성능 카테고리를 표시해 선택적 실행을 고려하세요.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 96eeaa9 and e92f564.

📒 Files selected for processing (5)
  • ProjectVG.Application/Models/Chat/ChatProcessResultMessage.cs (3 hunks)
  • ProjectVG.Application/Models/Chat/ChatSegment.cs (2 hunks)
  • ProjectVG.Infrastructure/Integrations/TextToSpeechClient/TextToSpeechClient.cs (4 hunks)
  • ProjectVG.Infrastructure/Realtime/WebSocketConnection/WebSocketClientConnection.cs (3 hunks)
  • ProjectVG.Tests/Infrastructure/Integrations/MemoryPoolingPerformanceTests.cs (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (5)
ProjectVG.Infrastructure/Realtime/WebSocketConnection/WebSocketClientConnection.cs (2)
ProjectVG.Infrastructure/Integrations/TextToSpeechClient/TextToSpeechClient.cs (3)
  • Task (22-80)
  • Task (85-124)
  • Task (131-183)
ProjectVG.Api/Middleware/WebSocketMiddleware.cs (3)
  • Task (31-53)
  • Task (94-104)
  • Task (109-198)
ProjectVG.Application/Models/Chat/ChatProcessResultMessage.cs (1)
ProjectVG.Application/Models/Chat/ChatSegment.cs (1)
  • ReadOnlySpan (36-47)
ProjectVG.Tests/Infrastructure/Integrations/MemoryPoolingPerformanceTests.cs (1)
ProjectVG.Application/Models/Chat/ChatSegment.cs (5)
  • ChatSegment (51-60)
  • ChatSegment (62-65)
  • ChatSegment (67-70)
  • ChatSegment (73-81)
  • ChatSegment (86-97)
ProjectVG.Infrastructure/Integrations/TextToSpeechClient/TextToSpeechClient.cs (1)
ProjectVG.Infrastructure/Realtime/WebSocketConnection/WebSocketClientConnection.cs (3)
  • Task (30-53)
  • Task (58-61)
  • Task (66-83)
ProjectVG.Application/Models/Chat/ChatSegment.cs (1)
ProjectVG.Application/Services/Chat/Factories/ChatLLMFormat.cs (4)
  • ChatSegment (167-244)
  • List (123-129)
  • List (131-165)
  • List (246-253)
🔇 Additional comments (5)
ProjectVG.Infrastructure/Realtime/WebSocketConnection/WebSocketClientConnection.cs (1)

60-61: 단순 위임 OK

SendBinaryAsync는 비동기 지연 없이 위임만 수행합니다. 현재 형태로 충분합니다.

ProjectVG.Application/Models/Chat/ChatSegment.cs (2)

75-81: 배열 기반 설정 메서드: LGTM

간결하고 의도에 부합합니다.


1-3: using System.Linq 추가 또는 전역 using 확인 필요

ProjectVG.Application/Models/Chat/ChatSegment.cs 상단(using 목록 바로 아래)에 using System.Linq;을 추가하세요 — HasActions가 Actions.Any()를 호출합니다 (31행).

ProjectVG.Application/Models/Chat/ChatProcessResultMessage.cs (2)

47-49: Span 기반 경로 도입: LGTM

segment.GetAudioSpan()을 활용한 경로로 전환이 잘 반영됐습니다.


69-70: WithAudioData의 통일성 확보 OK

기존 배열 입력도 최적화된 인코딩 경로로 통일되어 일관성이 좋아졌습니다.

Comment on lines +33 to +47
/// <summary>
/// 메모리 효율적인 방식으로 음성 데이터에 접근합니다
/// </summary>
public ReadOnlySpan<byte> GetAudioSpan()
{
if (AudioMemoryOwner != null && AudioDataSize > 0)
{
return AudioMemoryOwner.Memory.Span.Slice(0, AudioDataSize);
}
if (AudioData != null)
{
return new ReadOnlySpan<byte>(AudioData);
}
return ReadOnlySpan<byte>.Empty;
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Span 접근 자체는 적절하나 크기 검증 필요

AudioMemoryOwner.Memory.Span.Slice(0, AudioDataSize)AudioDataSize가 실제 메모리 길이보다 클 경우 예외를 유발합니다. 방어적 체크를 추가하세요.

         public ReadOnlySpan<byte> GetAudioSpan()
         {
-            if (AudioMemoryOwner != null && AudioDataSize > 0)
+            if (AudioMemoryOwner != null && AudioDataSize > 0)
             {
-                return AudioMemoryOwner.Memory.Span.Slice(0, AudioDataSize);
+                var mem = AudioMemoryOwner.Memory;
+                var size = Math.Min(AudioDataSize, mem.Length);
+                return mem.Span.Slice(0, size);
             }
             if (AudioData != null)
             {
                 return new ReadOnlySpan<byte>(AudioData);
             }
             return ReadOnlySpan<byte>.Empty;
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/// <summary>
/// 메모리 효율적인 방식으로 음성 데이터에 접근합니다
/// </summary>
public ReadOnlySpan<byte> GetAudioSpan()
{
if (AudioMemoryOwner != null && AudioDataSize > 0)
{
return AudioMemoryOwner.Memory.Span.Slice(0, AudioDataSize);
}
if (AudioData != null)
{
return new ReadOnlySpan<byte>(AudioData);
}
return ReadOnlySpan<byte>.Empty;
}
/// <summary>
/// 메모리 효율적인 방식으로 음성 데이터에 접근합니다
/// </summary>
public ReadOnlySpan<byte> GetAudioSpan()
{
if (AudioMemoryOwner != null && AudioDataSize > 0)
{
var mem = AudioMemoryOwner.Memory;
var size = Math.Min(AudioDataSize, mem.Length);
return mem.Span.Slice(0, size);
}
if (AudioData != null)
{
return new ReadOnlySpan<byte>(AudioData);
}
return ReadOnlySpan<byte>.Empty;
}
🤖 Prompt for AI Agents
ProjectVG.Application/Models/Chat/ChatSegment.cs around lines 33-47: the current
GetAudioSpan() slices AudioMemoryOwner.Memory.Span with AudioDataSize without
validating that AudioDataSize is within the backing memory/span length (and
non-negative), which can throw; update the method to defensively clamp
AudioDataSize to a valid range before slicing (e.g., compute int validSize =
Math.Max(0, Math.Min(AudioDataSize, AudioMemoryOwner.Memory.Span.Length)) and
use that for Slice), and similarly when returning a ReadOnlySpan from AudioData
validate/clamp against AudioData.Length to avoid out-of-range issues; keep
behavior of returning ReadOnlySpan<byte>.Empty when size is zero or data absent.

Comment on lines +86 to 97
public ChatSegment WithAudioMemory(IMemoryOwner<byte> audioMemoryOwner, int audioDataSize, string audioContentType, float audioLength)
{
return this with
{
AudioMemoryOwner = audioMemoryOwner,
AudioDataSize = audioDataSize,
AudioContentType = audioContentType,
AudioLength = audioLength,
// 기존 AudioData는 null로 설정하여 중복 저장 방지
AudioData = null
};
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

⚠️ Potential issue

소유권 이전 시 크기/수명 검증 및 IDisposable 구현 권장

  • audioDataSize <= audioMemoryOwner.Memory.Length 검증이 없습니다.
  • 이 타입에 Dispose()가 있으나 IDisposable을 구현하지 않아 호출 누락 위험이 큽니다. 소유권을 이전받는 API라면 세멘틱을 강제해야 합니다.

아래 보강을 권장합니다.

-        public ChatSegment WithAudioMemory(IMemoryOwner<byte> audioMemoryOwner, int audioDataSize, string audioContentType, float audioLength)
+        public ChatSegment WithAudioMemory(IMemoryOwner<byte> audioMemoryOwner, int audioDataSize, string audioContentType, float audioLength)
         {
+            if (audioMemoryOwner is null) throw new ArgumentNullException(nameof(audioMemoryOwner));
+            if (audioDataSize < 0 || audioDataSize > audioMemoryOwner.Memory.Length)
+                throw new ArgumentOutOfRangeException(nameof(audioDataSize));
             return this with
             {
                 AudioMemoryOwner = audioMemoryOwner,
                 AudioDataSize = audioDataSize,
                 AudioContentType = audioContentType,
                 AudioLength = audioLength,
                 // 기존 AudioData는 null로 설정하여 중복 저장 방지
                 AudioData = null
             };
         }

또한, 레코드 선언부에 : IDisposable을 추가하고 Dispose()를 통해 메모리 해제를 보장하세요(선언부 변경은 별도 전체 코드 스니펫 제공 가능).

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public ChatSegment WithAudioMemory(IMemoryOwner<byte> audioMemoryOwner, int audioDataSize, string audioContentType, float audioLength)
{
return this with
{
AudioMemoryOwner = audioMemoryOwner,
AudioDataSize = audioDataSize,
AudioContentType = audioContentType,
AudioLength = audioLength,
// 기존 AudioData는 null로 설정하여 중복 저장 방지
AudioData = null
};
}
public ChatSegment WithAudioMemory(IMemoryOwner<byte> audioMemoryOwner, int audioDataSize, string audioContentType, float audioLength)
{
if (audioMemoryOwner is null) throw new ArgumentNullException(nameof(audioMemoryOwner));
if (audioDataSize < 0 || audioDataSize > audioMemoryOwner.Memory.Length)
throw new ArgumentOutOfRangeException(nameof(audioDataSize));
return this with
{
AudioMemoryOwner = audioMemoryOwner,
AudioDataSize = audioDataSize,
AudioContentType = audioContentType,
AudioLength = audioLength,
// 기존 AudioData는 null로 설정하여 중복 저장 방지
AudioData = null
};
}
🤖 Prompt for AI Agents
In ProjectVG.Application/Models/Chat/ChatSegment.cs around lines 86-97, add
validation in WithAudioMemory to ensure audioMemoryOwner is not null and
audioDataSize is non-negative and does not exceed audioMemoryOwner.Memory.Length
(throw ArgumentNullException/ArgumentOutOfRangeException/ArgumentException as
appropriate) and validate audioLength is non-negative; also make the record
implement IDisposable and implement Dispose to release AudioMemoryOwner (call
Dispose on the owned IMemoryOwner<byte> and clear related fields) so ownership
transfer is explicit and memory is deterministically freed. Ensure
WithAudioMemory performs the checks before returning the new instance and does
not swallow exceptions, and update the type declaration to implement IDisposable
so callers can rely on the dispose semantic.

Comment on lines +118 to +124
/// <summary>
/// 리소스 해제 (IMemoryOwner 해제)
/// </summary>
public void Dispose()
{
AudioMemoryOwner?.Dispose();
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

IDisposable 비구현 상태의 Dispose 메서드

메서드만 있고 인터페이스를 구현하지 않아 호출 누락 위험이 큽니다. IDisposable 구현 및 using 패턴 사용을 유도하세요.

// 선언부 예시 (파일 상단 선언부 수정 필요)
public record ChatSegment : IDisposable
{
    // ...
    public void Dispose() => AudioMemoryOwner?.Dispose();
}
🤖 Prompt for AI Agents
In ProjectVG.Application/Models/Chat/ChatSegment.cs around lines 118-124, the
Dispose method exists but the record doesn't implement IDisposable which risks
callers not disposing the AudioMemoryOwner; update the record declaration to
implement IDisposable (e.g., add ": IDisposable" to the record type) and keep
the existing public void Dispose() method so the compiler enforces the
interface; optionally call GC.SuppressFinalize(this) inside Dispose if you add a
finalizer, and update any callers to use using or using-declaration to ensure
deterministic disposal.

Comment on lines +53 to 55
// 스트림 기반으로 음성 데이터 읽기 (LOH 방지)
voiceResponse.AudioData = await ReadAudioDataWithPoolAsync(response.Content);
voiceResponse.ContentType = response.Content.Headers.ContentType?.ToString();
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

⚠️ Potential issue

오디오 읽기 실패/빈 결과 처리 누락 및 LOH 목표 불충족

  • ReadAudioDataWithPoolAsyncnull(실패) 또는 길이 0을 반환해도 성공 경로로 진행합니다.
  • 현재 구현은 최종적으로 byte[]를 생성(ToArray)하므로 85KB+ 응답에서는 LOH 할당이 여전히 발생합니다. “LOH 방지” 목표와 불일치합니다.

아래처럼 실패 시 에러로 처리하고, 후속 PR에서 IMemoryOwner 기반 반환(또는 스트리밍 전달)로 전환을 검토하세요.

-                // 스트림 기반으로 음성 데이터 읽기 (LOH 방지)
-                voiceResponse.AudioData = await ReadAudioDataWithPoolAsync(response.Content);
+                // 스트림 기반으로 음성 데이터 읽기
+                voiceResponse.AudioData = await ReadAudioDataWithPoolAsync(response.Content);
+                if (voiceResponse.AudioData == null || voiceResponse.AudioData.Length == 0)
+                {
+                    voiceResponse.Success = false;
+                    voiceResponse.ErrorMessage = "오디오 데이터를 읽지 못했습니다.";
+                    return voiceResponse;
+                }

추가 제안(별도 변경): TextToSpeechResponse가 메모리 소유(IMemoryOwner)를 수용하도록 확장하면 최종 byte[] 생성을 피할 수 있습니다. 원하시면 스펙/코드 초안을 드리겠습니다.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// 스트림 기반으로 음성 데이터 읽기 (LOH 방지)
voiceResponse.AudioData = await ReadAudioDataWithPoolAsync(response.Content);
voiceResponse.ContentType = response.Content.Headers.ContentType?.ToString();
// 스트림 기반으로 음성 데이터 읽기
voiceResponse.AudioData = await ReadAudioDataWithPoolAsync(response.Content);
if (voiceResponse.AudioData == null || voiceResponse.AudioData.Length == 0)
{
voiceResponse.Success = false;
voiceResponse.ErrorMessage = "오디오 데이터를 읽지 못했습니다.";
return voiceResponse;
}
voiceResponse.ContentType = response.Content.Headers.ContentType?.ToString();
🤖 Prompt for AI Agents
In
ProjectVG.Infrastructure/Integrations/TextToSpeechClient/TextToSpeechClient.cs
around lines 53-55, the code currently assigns the result of
ReadAudioDataWithPoolAsync directly to voiceResponse.AudioData and then calls
ToArray which allows null/empty to be treated as success and forces LOH
allocations for large responses; update the flow to treat a null result or a
byte length of 0 as an error (throw or set a failure on voiceResponse and avoid
continuing the success path), and remove the immediate ToArray allocation by
returning/propagating pooled memory instead (plan a follow-up to change
ReadAudioDataWithPoolAsync to return IMemoryOwner<byte> or stream the data so
you never call ToArray() and thus avoid LOH allocations).

Comment on lines +82 to +125
/// <summary>
/// ArrayPool을 사용하여 스트림 기반으로 음성 데이터를 읽습니다 (LOH 할당 방지)
/// </summary>
private async Task<byte[]?> ReadAudioDataWithPoolAsync(HttpContent content)
{
const int chunkSize = 32768; // 32KB 청크 크기
byte[]? buffer = null;
MemoryStream? memoryStream = null;

try
{
buffer = _arrayPool.Rent(chunkSize);
memoryStream = new MemoryStream();

using var stream = await content.ReadAsStreamAsync();
int bytesRead;

// 청크 단위로 데이터 읽어서 MemoryStream에 복사
while ((bytesRead = await stream.ReadAsync(buffer, 0, chunkSize)) > 0)
{
await memoryStream.WriteAsync(buffer, 0, bytesRead);
}

var result = memoryStream.ToArray();
_logger.LogDebug("[TTS][ArrayPool] 음성 데이터 읽기 완료: {Size} bytes, 청크 크기: {ChunkSize}",
result.Length, chunkSize);

return result;
}
catch (Exception ex)
{
_logger.LogError(ex, "[TTS][ArrayPool] 음성 데이터 읽기 실패");
return null;
}
finally
{
if (buffer != null)
{
_arrayPool.Return(buffer);
}
memoryStream?.Dispose();
}
}

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

HTTP 리소스/스트림 수명 및 버퍼 관리 개선

  • MemoryStream 사전 용량(capacity)을 Content-Length로 지정하면 재할당/복사를 줄일 수 있습니다.
  • 스트림/요청에 CancellationToken 전달이 없습니다.
  • ArrayPool 반환 시 민감 데이터 취급 시 clearArray: true 고려.

아래와 같이 보완을 제안합니다.

-        private async Task<byte[]?> ReadAudioDataWithPoolAsync(HttpContent content)
+        private async Task<byte[]?> ReadAudioDataWithPoolAsync(HttpContent content, CancellationToken ct = default)
         {
             const int chunkSize = 32768; // 32KB 청크 크기
             byte[]? buffer = null;
             MemoryStream? memoryStream = null;

             try
             {
                 buffer = _arrayPool.Rent(chunkSize);
-                memoryStream = new MemoryStream();
+                var capacity = content.Headers.ContentLength is long len && len <= int.MaxValue
+                    ? (int)len
+                    : 0;
+                memoryStream = capacity > 0 ? new MemoryStream(capacity) : new MemoryStream();

-                using var stream = await content.ReadAsStreamAsync();
+                using var stream = await content.ReadAsStreamAsync(ct);
                 int bytesRead;

                 // 청크 단위로 데이터 읽어서 MemoryStream에 복사
-                while ((bytesRead = await stream.ReadAsync(buffer, 0, chunkSize)) > 0)
+                while ((bytesRead = await stream.ReadAsync(buffer, 0, chunkSize, ct)) > 0)
                 {
-                    await memoryStream.WriteAsync(buffer, 0, bytesRead);
+                    await memoryStream.WriteAsync(buffer, 0, bytesRead, ct);
                 }
@@
             finally
             {
                 if (buffer != null)
                 {
-                    _arrayPool.Return(buffer);
+                    _arrayPool.Return(buffer, clearArray: false);
                 }
                 memoryStream?.Dispose();
             }
         }

또한, 상위 TextToSpeechAsync에도 HttpResponseMessage/StringContentusing var로 감싸 리소스 누수를 방지하세요(변경 범위가 넓어 별도 스니펫으로 제시 가능합니다).

🤖 Prompt for AI Agents
In
ProjectVG.Infrastructure/Integrations/TextToSpeechClient/TextToSpeechClient.cs
around lines 82-125, improve stream and buffer handling by: pre-sizing
MemoryStream with content.Headers.ContentLength when available to avoid
reallocations, pass a CancellationToken into ReadAsStreamAsync and into
ReadAsync/WriteAsync calls to allow cancellation, return the rented array with
_arrayPool.Return(buffer, clearArray: true) to avoid leaking sensitive audio
bytes, and ensure streams and HttpContent are disposed via using (e.g., using
var stream = await content.ReadAsStreamAsync(cancellationToken); using var
memoryStream = new MemoryStream((int?)content.Headers.ContentLength ?? 0)). Also
update the caller TextToSpeechAsync to wrap HttpResponseMessage and any
StringContent in using/using var to prevent resource leaks.

Comment on lines +74 to +77
using var memoryOwner = MemoryPool<byte>.Shared.Rent(testData.Length);
testData.CopyTo(memoryOwner.Memory.Span);
var segment2 = ChatSegment.CreateText("Test content")
.WithAudioMemory(memoryOwner, testData.Length, "audio/wav", 5.0f);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Span 복사 호출 컴파일 이슈

byte[]에는 CopyTo(Span<byte>) 인스턴스 메서드가 없습니다. AsSpan()을 사용하세요.

-            using var memoryOwner = MemoryPool<byte>.Shared.Rent(testData.Length);
-            testData.CopyTo(memoryOwner.Memory.Span);
+            using var memoryOwner = MemoryPool<byte>.Shared.Rent(testData.Length);
+            testData.AsSpan().CopyTo(memoryOwner.Memory.Span);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
using var memoryOwner = MemoryPool<byte>.Shared.Rent(testData.Length);
testData.CopyTo(memoryOwner.Memory.Span);
var segment2 = ChatSegment.CreateText("Test content")
.WithAudioMemory(memoryOwner, testData.Length, "audio/wav", 5.0f);
using var memoryOwner = MemoryPool<byte>.Shared.Rent(testData.Length);
testData.AsSpan().CopyTo(memoryOwner.Memory.Span);
var segment2 = ChatSegment.CreateText("Test content")
.WithAudioMemory(memoryOwner, testData.Length, "audio/wav", 5.0f);
🤖 Prompt for AI Agents
In ProjectVG.Tests/Infrastructure/Integrations/MemoryPoolingPerformanceTests.cs
around lines 74-77, the call testData.CopyTo(memoryOwner.Memory.Span) fails
because byte[] has no CopyTo(Span<byte>) instance method; change the call to use
a Span by invoking testData.AsSpan().CopyTo(memoryOwner.Memory.Span) so the
array is converted to a Span before copying (ensure System namespace is
available if needed).

@ImGdevel ImGdevel linked an issue Sep 14, 2025 that may be closed by this pull request
@ImGdevel ImGdevel merged commit da6c585 into develop Sep 14, 2025
2 of 3 checks passed
@ImGdevel ImGdevel deleted the perf/LOH-optimize branch September 14, 2025 05:32
@coderabbitai coderabbitai bot mentioned this pull request Sep 14, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

GC (LOH) 문제

2 participants