지난 글에서는 SSE 클라이언트의 구현과 React Query를 활용한 상태 관리에 대해 살펴보았습니다. 이번 글에서는 오류 처리와 실제 서비스 운영 과정에서 마주친 문제들과 그 해결 방법, 단위 테스트 전략, 최적화에 대해 공유하고자 합니다.
SSE 클라이언트에서 발생할 수 있는 오류를 다음과 같이 세 가지 계층으로 분류하여 처리했습니다
HTTP 요청 및 응답 과정에서 발생하는 오류로, 네트워크 연결 실패, 인증 오류(401), 요청 제한 초과(429) 등이 여기에 해당합니다.
// SSEFetch 함수 내부의 오류 처리
if (response.status === 401) {
// 토큰 만료 시 자동 갱신 및 재요청 로직
const store = useTokenRetryQueueStore.getState();
// ...토큰 갱신 로직...
} else if (response.status === 429) {
throw new SSEError('quota over', 'ERR_QUOTA_OVER');
} else {
const { code, message } = await response.json();
throw new SSEError(message ?? 'sse error message', code);
}
이 계층에서는 토큰 만료와 같은 인증 오류를 자동으로 처리하여 사용자 경험을 향상시켰습니다. 요청 한도 초과나 서버 오류와 같은 경우에는 명확한 오류 코드와 메시지를 포함한 SSEError
를 발생시켜 호출자에게 오류를 전달합니다.
SSE 연결이 성공적으로 이루어진 후, 수신된 이벤트 스트림을 처리하는 과정에서 발생하는 오류입니다.
const parseEventData = (eventType: EventType, rawData?: string): any | null => {
// close 이벤트는 데이터가 없어도 정상 처리
if (!rawData && eventType === 'close') {
return '';
}
if (!rawData) return;
try {
return JSON.parse(rawData);
} catch {
// close 이벤트의 경우 빈 문자열 반환
// 다른 이벤트의 경우 파싱 오류를 상위로 전파
return eventType === 'close'
? ''
: (() => {
throw new SSEError('json parsing error');
})();
}
};
이벤트 데이터 파싱 과정에서는 특히 JSON 구문 분석 오류를 처리합니다. 'close' 이벤트의 경우 데이터가 없거나 JSON 형식이 아니어도 정상 처리하여 연결 종료를 원활하게 처리합니다. 하지만 다른 이벤트 타입의 경우 파싱 오류가 발생하면 SSEError
를 발생시켜 개발자가 디버깅할 수 있도록 했습니다.
서버가 정상적인 HTTP 응답(200 OK)을 반환했지만, 스트림 내에서 'error' 타입의 이벤트를 통해 오류 정보를 전달하는 경우입니다.
private handleEvent(
type: keyof EventTypes,
data: EventTypes[keyof EventTypes]
): void {
const handlers = this.eventHandlers.get(type);
if (handlers) {
handlers.forEach((handler) => handler(data));
}
if (type === 'error') {
const errorData = data as SSEErrorData;
throw new SSEError(
errorData?.error
);
}
}
// 호출부
client.addEventListener('error', (data) => {
// 서버에서 전달된 에러 처리
switch (data.code) {
case 'AI_PROVIDER_ERROR':
// AI 서비스 일시적 장애
break;
case 'CONTENT_FILTER':
// 부적절한 컨텐츠 필터링
break;
case 'CONTEXT_LENGTH_EXCEEDED':
// 컨텍스트 길이 초과
break;
default:
// 기타 에러는 상위로 전파
throw new SSEError(data.message);
}
});
서버로부터 'error' 타입의 이벤트가 수신되면, 등록된 에러 핸들러를 호출한 후 SSEError
를 발생시켜 연결을 종료합니다. 이를 통해 컨텐츠 필터링, AI 제공자 오류, 컨텍스트 길이 초과 등 비즈니스 로직과 관련된 오류를 애플리케이션 수준에서 처리할 수 있게 됩니다.
SSE 클라이언트는 이 세 가지 계층의 오류를 일관된 방식으로 처리합니다.
SSEError
클래스로 래핑되어 오류 코드와 메시지를 일관되게 제공합니다.addEventListener
를 통해 등록된 핸들러를 사용하여 특정 타입의 오류를 선택적으로 처리할 수 있습니다.이러한 다층적인 오류 처리 전략을 통해 개발자는 애플리케이션의 요구사항에 맞게 오류 처리 로직을 구성할 수 있으며, 사용자에게는 적절한 피드백을 제공하여 더 나은 사용자 경험을 제공할 수 있습니다.
문제 상황
사용자가 질문을 하고 서버가 응답을 완료했음에도 불구하고 간헐적으로 무한히 로딩 상태가 지속되는 오류가 있었습니다.
원인 파악
Chrome 개발자 도구의 Network 탭에서는 'close' 이벤트가 정상적으로 전송되었지만 클라이언트 애플리케이션에서 이를 인식하지 못하는 것을 확인했습니다.
구현 당시에는 청크가 이벤트 단위로 깔끔하게 구분되어 전송될 것이라고 가정했으나, 실제로는 네트워크 조건에 따라 이벤트가 불규칙하게 분할되어 전송되고 있었습니다.
// 기대했던 청크 단위
Chunk 1: "event: message\ndata: {\"text\": \"첫 번째 메시지\"}\n\n"
Chunk 2: "event: message\ndata: {\"text\": \"두 번째 메시지\"}\n\n"
// 실제 전송되는 청크
Chunk 1: "event: message\ndata: {\"text\": \"첫 번째 메시"
Chunk 2: "지\"}\n\nevent: message\ndata: {\"text\": \"두 번"
Chunk 3: "째 메시지\"}\n\n"
일반 메시지의 경우 부분적으로 누락되더라도 다음 메시지가 처리되면서 크게 문제가 되지 않았습니다. 그러나 'close' 이벤트가 분할되는 경우가 치명적이었습니다:
// close 이벤트가 분할된 경우
Chunk N: "event: clo"
Chunk N+1: "se\ndata: {\"reason\": \"completed\"}\n\n"
close 이벤트가 우연히 분할된 경우 'close' 이벤트를 인식하지 못해 Promise를 resolve하는 로직이 실행되지 않았고, 결과적으로 애플리케이션은 무한 로딩 상태로 남게 되었습니다.
해결 방법
해결책을 찾기 위해 Chrome의 EventSource 구현체 소스 코드를 참고했습니다. 표준 EventSource 구현체는 이런 불완전한 청크 문제를 해결하기 위해 버퍼 시스템을 사용하고 있었습니다.
private eventChunksBuffer = '';
private processEventChunks = (chunk: string): void => {
let start = 0;
const length = chunk.length;
for (let i = 0; i < length; i++) {
if (chunk[i] === '\r' || chunk[i] === '\n') {
start = this.processLine(chunk, start, i);
}
}
// 청크가 라인의 중간에서 끝난 경우
if (start < length) {
this.eventChunksBuffer += chunk.slice(start);
}
};
private processLine = (chunk: string, start: number, end: number) => {
const lineContent = chunk.slice(start, end);
if (lineContent.length === 0) {
// 빈 라인은 하나의 이벤트가 완성되었음을 의미
this.processEventBuffer();
return end + 1;
}
this.eventChunksBuffer += lineContent + '\n';
return end + 1;
};
\n\n
)을 만나면 하나의 완전한 이벤트가 구성되었다고 판단합니다.이러한 개선을 통해 이벤트가 여러 청크에 걸쳐 분할되어 전송되더라도 누락되는 이벤트 없이 처리가 가능해졌고 무한로딩 현상도 해결되었습니다.
문제 상황
성능 모니터링 과정에서 메모리 힙 스냅샷을 분석한 결과 심각한 메모리 누수 문제를 발견했습니다. 사용자가 질문-응답 세션을 반복할수록 SSEClient 인스턴스들이 제대로 해제되지 않고 메모리에 계속 누적되어 불필요하게 메모리를 점유하고 있었습니다.
원인 파악
eventHandlers
Map에 등록된 이벤트 리스너들이 메모리에서 해제되지 않고 있었습니다.
// 🚨 문제가 되는 코드
client.addEventListener('close', () => {
resolve(lastData);
// 여기서 리스너 정리가 누락됨
});
해결 방법
문제 해결을 위해 SSEClient 클래스에 명시적인 정리(cleanup) 메서드를 추가했습니다.
cleanup(): void {
// 모든 이벤트 핸들러 제거
this.eventHandlers.clear();
// 버퍼 초기화
this.eventChunksBuffer = '';
// 기타 참조 상태 초기화
this.isRecognizingCRLF = false;
this.abortController = null;
}
연결이 종료되거나 오류가 발생할 때 사용자가 명시적으로 연결을 중단할 때 이 메서드를 호출하도록 구현했습니다.
client.addEventListener('close', () => {
resolve(lastData);
client.cleanup(); // 연결 종료 시 정리
});
client.connect().catch((error) => {
client.cleanup(); // 에러 발생 시 정리
reject(error);
});
// 사용자 중단 처리
stop(): void {
if (this.abortController) {
this.abortController.abort();
this.cleanup(); // 사용자가 중단했을 때도 정리
}
}
이러한 개선을 통해 SSEClient 인스턴스와 관련 리소스들이 적절한 시점에 완전히 해제되도록 보장했습니다. 메모리 힙 스냅샷을 다시 분석한 결과, 더 이상 SSEClient 인스턴스가 메모리에 불필요하게 누적되지 않음을 확인했습니다.
문제 상황
개발 과정에서 AI 응답이 스트리밍될 때마다 React 컴포넌트의 상태가 지나치게 자주 업데이트되면서 성능문제가 발생했습니다. 특히 빠른 속도로 스트리밍되면 짧은 시간에 상태 업데이트가 연속해서 발생하면서 브라우저 콘솔에 "Maximum update depth exceeded" 오류가 표시되었습니다.
원인 파악
스트리밍 방식으로 전달되는 AI 응답 데이터가 도착할 때마다 즉시 React 상태를 업데이트하는 구현 방식이 근본적인 문제였습니다.
// 🚨 성능 문제가 발생할 수 있는 코드
const onMessage = (message: ChatResponse) => {
setMessage(message.content); // 매 메시지마다 즉시 상태 업데이트
};
이 코드로 인해 초당 수십 번의 상태 업데이트가 발생했고, 그 결과 React의 리렌더링 사이클이 과도하게 실행되어 심각한 성능 저하로 이어졌습니다.
해결 방법
이 문제를 효과적으로 해결하기 위해 lodash의 throttle
함수를 활용하여 상태 업데이트 빈도를 제한했습니다.
const throttledCallback = useMemo(
() =>
throttle((message: ChatResponse) => {
setMessage(message.content);
}, 100), // 100ms 간격으로 제한
[]
);
const onMessage = useCallback(
(message: ChatResponse) => {
throttledCallback(message);
},
[throttledCallback]
);
이 최적화를 통해 리렌더링 빈도를 초당 최대 10회로 제한하여 브라우저 부하가 감소하고, 오류가 해소되어 애플리케이션 안정성이 개선되었습니다. 특히 모바일이나 저사양 기기에서도 성능을 보장할 수 있게 되었습니다.
SSEClient는 서버에서 전송되는 이벤트 스트림을 처리하는 핵심 모듈로서 안정적인 동작이 매우 중요합니다. 다양한 네트워크 환경과 브라우저별 차이로 인해 예상치 못한 상황이 발생할 수 있어 철저한 단위 테스트를 통한 검증이 필수적이었습니다.
먼저 Jest 환경에서는 브라우저 API가 제공되지 않기 때문에 Fetch API와 ReadableStream 인터페이스를 모방하여 스트림 동작을 모킹했습니다.
function createMockSSEResponse(chunks: string[]) {
let chunkIndex = 0;
const mockReader = {
read: jest.fn().mockImplementation(() => {
if (chunkIndex < chunks.length) {
return Promise.resolve({
done: false,
value: chunks[chunkIndex++],
});
}
return Promise.resolve({ done: true });
}),
releaseLock: jest.fn(),
};
const mockStream = {
getReader: () => mockReader,
pipeThrough: () => mockStream,
};
return { mockReader, mockResponse: { ok: true, body: mockStream } };
}
read
메서드가 호출될 때마다 미리 정의된 청크 배열에서 순차적으로 데이터를 반환하여 실제 네트워크에서 데이터가 순차적으로 도착하는 상황을 모킹했습니다.{ done: true }
를 반환하여 네트워크 연결이 종료되는 시점을 시뮬레이션합니다.response.body?.pipeThrough(...).getReader()
와 같은 메서드 체이닝을 지원하기 위해 pipeThrough
메서드가 자기 자신을 반환하도록 구현했습니다.createMockSSEResponse
함수를 활용하여 다음과 같은 주요 기능들에 대한 테스트를 구현했습니다
test('불완전한 청크 데이터를 순차적으로 처리한다', async () => {
// JSON이 중간에 잘린 형태로 청크 설정
const chunks = [
'event: message\ndata: {"content": "H1"}\n\n',
'event: message\ndata: {"conte',
'nt": "lastContent"}\n\n',
];
createMockSSEResponse(chunks);
const onMessage = jest.fn();
const sseClient = new SSEClient({ url: 'test-url' });
sseClient.addEventListener('message', onMessage);
await sseClient.connect();
// 두 개의 완전한 메시지로 처리되었는지 확인
expect(onMessage).toHaveBeenCalledTimes(2);
expect(onMessage).toHaveBeenNthCalledWith(1, { content: 'H1' });
expect(onMessage).toHaveBeenNthCalledWith(2, { content: 'lastContent' });
});
test('이벤트 타입에 맞는 핸들러를 호출한다', async () => {
const chunks = [
'event: message\ndata: {"content":"messageData"}\n\n',
'event: close\ndata: {"content":"closeData"}\n\n',
];
createMockSSEResponse(chunks);
const onMessage = jest.fn();
const onClose = jest.fn();
const sseClient = new SSEClient({
url: 'test-url',
});
sseClient.addEventListener('message', onMessage);
sseClient.addEventListener('close', onClose);
await sseClient.connect();
// 각 이벤트 타입별로 올바른 핸들러가 호출되었는지 확인
expect(onMessage).toHaveBeenCalledWith({ content: 'messageData' });
expect(onClose).toHaveBeenCalledWith({ content: 'closeData' });
});
test('다양한 줄바꿈 문자 형식을 처리한다', async () => {
const chunks = [
'event: message\ndata: {"content": "CR"}\r\r', // CR
'event: message\ndata: {"content": "LF"}\n\n', // LF
'event: message\ndata: {"content": "CRLF"}\r\n\r\n', // CRLF
];
createMockSSEResponse(chunks);
const onMessage = jest.fn();
const sseClient = new SSEClient({ url: 'test-url' });
sseClient.addEventListener('message', onMessage);
await sseClient.connect();
// 모든 줄바꿈 형식이 올바르게 처리되었는지 확인
expect(onMessage).toHaveBeenCalledTimes(3);
});
test('HTTP 에러 코드를 적절히 처리한다', async () => {
global.fetch = jest.fn().mockResolvedValue({
ok: false,
status: 429
});
const sseClient = new SSEClient({ url: 'test-url' });
await expect(sseClient.connect()).rejects.toThrow('quota over');
});
테스트 구현을 통해 실제 서비스 환경에서 발견할 수 있는 다양한 상황들을 사전에 검증할 수 있었습니다. 실제로 운영 환경에서 'close' 이벤트 누락 문제가 발생했을 때 이를 재현하는 테스트 케이스를 추가하여 문제를 해결하고 유사한 이슈가 재발하지 않도록 예방할 수 있었습니다.
테스트 코드는 새로운 기능 개발 과정에서도 중요한 역할을 했습니다. 사용자가 AI 응답을 중간에 중단할 수 있는 stop() 메서드를 추가할 때도 기존 테스트 케이스들을 기반으로 안정적으로 기능을 추가할 수 있었습니다.
이번 시리즈를 통해 SSE 기반 AI 채팅 시스템의 설계, 구현, 그리고 최적화까지의 과정을 공유했습니다. SSEClient
는 재사용성을 고려한 설계 덕분에 RAG 시스템이나 다양한 AI 모델 기반의 채팅 인터페이스 등 여러 기능에 활용되고 있습니다. 앞으로는 네트워크 불안정 상황에서도 사용자 경험을 개선할 수 있도록 연결 재시도 기능을 추가할 계획입니다.
다양한 테스트를 실시했음에도 불구하고 실제 운영 환경에서는 예상치 못했던 청크 단위의 데이터 분리 현상이 발생했습니다. 돌이켜보면 Chrome의 EventSource 구현체를 더 꼼꼼히 살펴보고 특히 eventBuffer
의 역할을 깊이 고민했더라면 이러한 문제를 줄일 수 있었을 것이라 아쉬움이 남습니다. 이번 경험을 통해 기존에 존재하는 인터페이스를 재구현할 때는 그 구현체의 동작 원리를 철저히 이해하는 것이 무엇보다 중요하다는 교훈을 얻었습니다.
또한, Readable Stream Web API의 작동 원리를 조사하면서 가장 인상적이었던 점은 백프레셔(Backpressure) 메커니즘의 정교함입니다. 이 메커니즘은 스트림 처리 속도가 느려지거나 네트워크 상태가 악화되더라도 데이터가 손실되지 않도록 흐름을 능동적으로 조율합니다. Web API들이 얼마나 정교하고 단단하게 설계되어 있는지 깊이 이해하게 되었습니다. 야심차게 POST 요청을 지원하는 EventSource
함수를 구현해봤지만, 결국 브라우저 내부 구현체의 이런 정교함을 완벽히 따라가긴 어렵겠다는 한계를 느꼈습니다. 추후에 POST 요청을 지원하는 EventSource의 공식적 지원이 등장하길 기대해 봅니다!
이 시리즈가 SSE 기반 AI 채팅 개발에 관심이 있는 분들께 유용한 참고 자료가 되기를 바랍니다. 긴 글을 읽어주셔서 감사합니다.