-
Notifications
You must be signed in to change notification settings - Fork 0
perf: ArrayPool 기반 LOH 최적화로 음성 데이터 메모리 효율성 개선 #19
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
- TTS 클라이언트: 32KB 청크 단위 스트림 읽기로 대용량 음성 데이터 LOH 할당 방지 - WebSocket 전송: UTF8 인코딩 시 ArrayPool 활용으로 임시 배열 생성 최소화 - ChatSegment: IMemoryOwner<byte> 지원 및 ReadOnlySpan<byte> 패턴 도입 - Base64 인코딩: System.Buffers.Text.Base64 활용으로 메모리 할당 최적화 - 성능 테스트: ArrayPool vs 직접 할당 21.7% 성능 개선 확인 85KB 이상 객체의 LOH 할당을 청크 방식으로 분산하여 GC 압박 완화
Walkthrough메모리 풀(ArrayPool/MemoryPool) 기반의 오디오 처리와 Base64 인코딩 최적화를 도입하고, WebSocket 전송과 TTS 응답 수신에 풀을 적용했습니다. ChatSegment는 메모리 소유자(IMemoryOwner) 기반 오디오를 지원하며, 관련 유틸과 수명 관리가 추가되었습니다. 성능/GC 비교용 테스트가 추가되었습니다. Changes
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
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
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related issues
Poem
✨ Finishing touches
🧪 Generate unit tests
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. Comment Pre-merge checks❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
|
There was a problem hiding this 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 필요연결/소켓 누수를 피하려면
HttpResponseMessage와StringContent를using 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)를 권장합니다.SendAsync에CancellationToken을 허용하면 종료/타임아웃 처리에 유리합니다(공개 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
📒 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기존 배열 입력도 최적화된 인코딩 경로로 통일되어 일관성이 좋아졌습니다.
| /// <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; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
| /// <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.
| 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 | ||
| }; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
소유권 이전 시 크기/수명 검증 및 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.
| 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.
| /// <summary> | ||
| /// 리소스 해제 (IMemoryOwner 해제) | ||
| /// </summary> | ||
| public void Dispose() | ||
| { | ||
| AudioMemoryOwner?.Dispose(); | ||
| } |
There was a problem hiding this comment.
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.
| // 스트림 기반으로 음성 데이터 읽기 (LOH 방지) | ||
| voiceResponse.AudioData = await ReadAudioDataWithPoolAsync(response.Content); | ||
| voiceResponse.ContentType = response.Content.Headers.ContentType?.ToString(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
오디오 읽기 실패/빈 결과 처리 누락 및 LOH 목표 불충족
ReadAudioDataWithPoolAsync가null(실패) 또는 길이 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.
| // 스트림 기반으로 음성 데이터 읽기 (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).
| /// <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(); | ||
| } | ||
| } | ||
|
|
There was a problem hiding this comment.
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/StringContent를 using 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.
| 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); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
| 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).
Summary by CodeRabbit
관련 이슈
#15