개발 단계에서는 Cognito client ID나 API ID와 같이 백엔드에 필수적인 정보들을 모두 클라이언트 측에 하드코딩하였다(12편에서 일시적으로 SSM을 사용하긴 했으나 해당 아키텍처를 폐기하면서 우선 하드코딩하는 방식으로 회귀했었다).
프로덕션 단계로 개발이 넘어가면서 이 환경 변수들을 SSM 파라미터로 이동하고자 한다. 클라이언트에는 APIGW 도메인 하나만 노출하고, 해당 도메인과 연결된 Lambda 함수가 백엔드에서 나머지 파라미터들을 받아서 전송하는 형태를 목표로 하였다.
먼저 하드코딩된 변수들을 SSM으로 이동해야 한다.
CLI를 이용해 다음과 같이 환경변수들을 업로드할 수 있다. 이때 Cognito와 관련된 변수들은 Securestring 타입으로 업로드하였다. 해당 타입의 파라미터는 KMS를 통해 암호화되어 저장된다.
aws ssm put-parameter --name "/taroyaki/product/clientId" --value "XXXXXXXXXX" --type "SecureString"
aws ssm put-parameter --name "/taroyaki/product/clientSecret" --value "XXXXXXXXXX" --type "SecureString"
aws ssm put-parameter --name "/taroyaki/product/domain" --value "https://us-east-1XXXXXXX.auth.us-east-1.amazoncognito.com" --type "String"
aws ssm put-parameter --name "/taroyaki/product/authEndpoint" --value "https://xxxxxxxx.execute-api.us-east-1.amazonaws.com/product/userinfo" --type "String"
aws ssm put-parameter --name "/taroyaki/product/restEndpoint" --value "https://xxxxxxxxxx.execute-api.us-east-1.amazonaws.com/product" --type "String"
aws ssm put-parameter --name "/taroyaki/product/wsEndpoint" --value "wss://xxxxxxxxx.execute-api.us-east-1.amazonaws.com/production/" --type "String"
aws ssm put-parameter --name "/taroyaki/product/redirectUri" --value "https://xxxxxxxx.cloudfront.net/index.html" --type "String"
aws ssm put-parameter --name "/taroyaki/product/logoutRedirectUri" --value "https://xxxxxxxxxx.cloudfront.net/index.html" --type "String"

이어서 Lambda 함수가 동작하기 위한 IAM 정책을 만든다. 이를 lambda가 사용할 IAM role에 연결해 두었다.
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"ssm:GetParametersByPath",
"ssm:GetParameters",
"ssm:GetParameter"
],
"Resource": "arn:aws:ssm:us-east-1:00000000:parameter/taroyaki/*"
},
{
"Effect": "Allow",
"Action": [
"kms:Decrypt"
],
"Resource": "*",
"Condition": {
"StringEquals": {
"kms:ViaService": "ssm.us-east-1.amazonaws.com"
}
}
},
{
"Effect": "Allow",
"Action": [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents"
],
"Resource": "arn:aws:logs:*:*:*"
}
]
}
이제 SSM에서 환경 변수들을 제공하는 Lambda 함수가 필요하다. Node.js로 동작하며 코드는 아래와 같다:
// SDK 모듈 임포트
import { SSMClient, GetParametersByPathCommand } from '@aws-sdk/client-ssm';
// SSM 클라이언트 초기화
const ssmClient = new SSMClient({ region: 'us-east-1' });
// Lambda Handler
export const handler = async (event) => {
try {
// taroyaki/params 아래의 모든 파라미터 가져오기
const params = {
Path: '/taroyaki/product/',
Recursive: true, // 하위 경로 포함
WithDecryption: true // Securestring 복호화
};
// SSM으로부터 파라미터 요청하고 응답 받음
const command = new GetParametersByPathCommand(params);
const response = await ssmClient.send(command);
// 응답에서 마지막 부분만 추출하여 config 개체에 저장
// ex. taroyaki/product/clientId 파라미터에서 clientId만 키로 사용
const config = {};
response.Parameters.forEach(param => {
// 전체 경로에서 마지막 부분만 추출
const paramName = param.Name.split('/').pop();
config[paramName] = param.Value;
});
// 그 외 토큰 및 세션 관련 지속시간 설정
config.tokenRefreshThreshold = ####;
config.sessionDuration = ####;
config.tokenExpirations = {
id: ####,
access: ####,
refresh: ####
};
// 응답 반환
return {
statusCode: 200,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
},
body: JSON.stringify(config)
};
// 오류 처리
} catch (error) {
console.error('Error fetching configuration:', error);
return {
statusCode: 500,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
},
body: JSON.stringify({ error: 'Failed to load configuration', details: error.message })
};
}
};
해당 Lambda를 사용하기 위해 또 하나의 REST API를 만들었다. 아래와 같이 /config 경로에 GET 메서드를 만들고 CORS설정을 해 두었다. 앞에서 만든 Lambda를 여기에 연결한다.

프론트 코드에는 기존 환경변수 값들을 제거하고 loadconfig 함수를 추가하는 수정을 하였다.
다만 앞에서 말한 토큰 유효값, 세션 지속시간 값은 여전히 하드코딩된 값을 유지하였는데, 이는 fallback에 대응하고 초기화 시간을 조금이나마 줄이기 위함이다.
let config = {
tokenRefreshThreshold: 5 * 60,
sessionDuration: 3 * 60, // 3분
tokenExpirations: {
id: 60 * 60, // 60분
access: 60 * 60, // 60분
refresh: 5 * 24 * 60 * 60 // 5일
}
};
loadconfig 함수를 살펴보자.
먼저 앞에서 만든 rest api를 통해 응답을 받아온다.
async function loadConfig() {
try {
const configApiUrl = 'https://xxxxxxxx.execute-api.us-east-1.amazonaws.com/product/config';
const response = await fetch(configApiUrl);
if (!response.ok) {
throw new Error(`Failed to load config: ${response.status}`);
}
const responseData = await response.json();
파싱 과정에서 에러가 많이 발생해서 응답 형태를 확인하는 과정을 추가하였다.
응답이 { body: "..." } 형태인지 확인하고 json 형태인 경우 body를 파싱한다.
let loadedConfig;
if (responseData.body && typeof responseData.body === 'string') {
loadedConfig = JSON.parse(responseData.body);
}
} else {
// body 속성이 없는 경우 응답 전체 사용
loadedConfig = responseData;
}
이렇게 로드된 변수들은 위에서 하드코딩한 기존 설정값들과 병합된다.
config = { ...config, ...loadedConfig };
환경변수 로드에 성공했다면 configLoadevent를 발생시킨다.
const configLoadedEvent = new Event('configLoaded');
document.dispatchEvent(configLoadedEvent);
return true;
// 에러 대응
} catch (error) {
console.error('Error loading configuration:', error);
if (typeof window.handleConfigError === 'function') {
window.handleConfigError();
}
return false;
}
}
해당 함수는 페이지 초기화 시 제일 먼저 트리거되고, 이후 나머지 동작들이 수행된다.
async function initializePage() {
try {
// 1. 설정 로드
const configLoaded = await loadConfig();
if (!configLoaded) {
console.error('Failed to load configuration');
return;
}
// 이하 생략: 나머지 동작들 (이벤트 리스너 초기화, 토큰 검증 등)
7편 말미에서 RAG 비용 절감을 위해 Aurora DB를 사용하지 않을 시 일시정지가 되도록 변경하였다.
그러나 해당 DB가 resume되는 데에는 10초 정도의 딜레이가 발생하며, 이 상태에서 RAG가 필요한 질의을 던지면 아래와 같은 에러가 발생한다.

때문에 몇 초가 지난 후 응답 재시도가 필요하며, 그에 대응하는 코드를 구축할 필요가 생겼다.
다음과 같은 로직으로 프론트 코드를 개선하였다:
먼저 다음 변수들을 추가하였다:
let retryCount = 0; // 현재 재시도 횟수
let maxRetries = 3; // 최대 재시도 횟수
let retryDelay = 3000; // 1회차 재시도 간격
let retryBackoffFactor = 1.5; // 재시도 백오프 지수
let retryMessageId = null; // 재시도 중 표시되는 메시지 ID
let lastSentMessage = ''; // 마지막 전송 메시지 저장용
다음으로 WS 메시지 핸들러를 개선한다.
재시도 성공 시 재시도 인디케이터를 제거하는 로직을 맨 앞에 추가하였다.
// WebSocket 메시지 핸들러 수정
function handleIncomingMessage(data) {
// 재시도 성공 시 재시도 메시지 및 인디케이터 제거
if ((data.type === 'stream' || data.type === 'end') && retryMessageId) {
removeRetryMessage();
}
if (data.type === 'stream') {
// 생략: 일반 메시지 처리 로직 (인디케이터 표시, 스트림 처리 등)
} else if (data.type === 'end') {
hideTypingIndicator();
// 생략: 스트림 완료 처리
메시지 스트림을 마치고 나면 재시도 카운터를 초기화하여 재사용에 대비한다.
resetRetryCounter();
} else if (data.type === 'error') {
hideTypingIndicator();
console.error('Error:', data.message);
// 생략: 에러 처리
} else if (data.type === 'session_name_update') {
// 생략: 세션명 업데이트
}
}
아래 함수는 Aurora DB 재시작을 포함, 에러를 뱉을 경우 전송을 재시도하는 로직을 담고 있다.
먼저 첫 번째 에러가 발생할 경우 재시도 메시지를 별도 말풍선으로 표시한다.
// DB 재개 에러 처리 함수
function handleDBResumingError() {
if (retryCount === 0) {
retryMessageId = 'retry-' + Date.now();
showRetryMessage(); // 메시지 표시 함수
} else {
updateRetryMessage(); // 메시지 업데이트 함수
}
앞에서 정의한 최대 재시도 횟수 내에서는 재시도를 수행한다. 최대 재시도 횟수를 다 채울 경우에는 실패 메시지를 띄운다.
if (retryCount < maxRetries) {
const currentDelay = retryDelay * Math.pow(retryBackoffFactor, retryCount);
console.log(`재시도 ${retryCount + 1}/${maxRetries}, ${currentDelay}ms 후 시도`);
setTimeout(() => {
retryCount++;
updateRetryMessage(); // 메시지 업데이트
retrySendMessage(); // 백엔드에 메시지 재전송
}, currentDelay);
} else {
// 최대 재시도 횟수 초과 시 실패 처리
if (retryMessageId) {
removeRetryMessage();
appendMessage('ai', '서비스 준비에 실패했습니다. 잠시 후 다시 시도해 주세요.');
}
enableInput();
resetRetryCounter();
}
}
앞에서 살펴본 재시도 메시지 표시 함수는 아래와 같이 구성되어 있다:
RAG의 개념이 익숙지 않을 대다수의 사람들에게 직관적으로 상황을 설명할 단어를 고민하다가 '타로 책'이라는 단어를 사용하기로 했다.
function showRetryMessage() {
const chatBox = document.getElementById('chatBox');
// 재시도 메시지 element 생성
const retryElement = document.createElement('div');
retryElement.className = 'message ai-message retry-message';
retryElement.id = retryMessageId;
// 메시지 텍스트 생성
const contentElement = document.createElement('div');
contentElement.className = 'message-content';
contentElement.innerHTML = `타로 책을 펼치는 중이에요. 잠시만 기다려 주세요... (${retryCount+1}/${maxRetries})`;
retryElement.appendChild(contentElement);
// 대화 영역에 메시지 추가 후 맨 아래로 자동 스크롤
chatBox.insertBefore(retryElement, chatBox.firstChild);
scrollToBottom();
}
재시도 메시지 업데이트는 아래와 같이 말풍선 텍스트를 바꾸는 역할을 한다.
// 재시도 메시지 업데이트
function updateRetryMessage() {
const retryMessage = document.getElementById(retryMessageId);
if (retryMessage) {
const contentDiv = retryMessage.querySelector('.message-content');
if (contentDiv) {
contentDiv.innerHTML = `타로 책을 펼치는 중이에요. 잠시만 기다려 주세요(${retryCount+1}/${maxRetries})`;
}
}
}
해당 메시지가 더이상 필요 없다면 메시지를 제거한다.
function removeRetryMessage() {
const retryMessage = document.getElementById(retryMessageId);
if (retryMessage) {
// 제거 시 투명도 애니메이션 사용
retryMessage.style.opacity = '0';
retryMessage.style.transform = 'translateY(-10px)';
// 메시지 요소 제거
setTimeout(() => {
retryMessage.remove();
}, 300);
}
retryMessageId = null;
}
Websocket 쪽으로 메시지를 다시 보내는 함수이다.
// 메시지 재전송
function retrySendMessage() {
if (!socket || socket.readyState !== WebSocket.OPEN) {
try {
// 메시지 전송 시도
connectWebSocket().then(() => {
const payload = {
action: 'sendMessage',
message: lastSentMessage,
userId: userId,
sessionId: currentSessionId
};
socket.send(JSON.stringify(payload));
}).catch(error => {
console.error('WebSocket 재연결 실패:', error);
handleDBResumingError(); // 소켓 연결된 상태에서 실패할 경우
});
} catch (error) {
console.error('WebSocket 재연결 시도 중 오류:', error);
handleDBResumingError(); // 소켓이 닫혀 있을 경우
}
} else {
const payload = {
action: 'sendMessage',
message: lastSentMessage,
userId: userId,
sessionId: currentSessionId
};
socket.send(JSON.stringify(payload));
}
}
재시도 카운터 초기화 시 클라이언트에 남은 재시도 메시지가 있는지 한 번 더 확인하는 로직이 함께 동작한다.
// 재시도 카운터 초기화
function resetRetryCounter() {
retryCount = 0;
// 재시도 메시지가 있으면 제거
if (retryMessageId) {
removeRetryMessage();
}
}
기존에 만든 sendMessageToCurrentSession 쪽도 수정이 필요한데, 사용자가 마지막으로 보낸 메시지를 잘 보관하고 있다가 재시도 시 사용해야하기 때문이다.
// 메시지 전송 함수 수정 - 마지막 전송 메시지 저장
function sendMessageToCurrentSession(message) {
if (!socket || socket.readyState !== WebSocket.OPEN) {
console.error('WebSocket is not connected');
return;
}
appendMessage('user', message);
isEmptySession = false;
updateNewChatButtonState();
// 마지막 전송 메시지 저장
lastSentMessage = message;
const payload = {
action: 'sendMessage',
message: message,
userId: userId,
sessionId: currentSessionId
};
socket.send(JSON.stringify(payload));
}
여담이지만 비용의 문제로 인해 어쩔 수 없이 사용성을 크게 희생한 부분인지라, 무척 아쉬운 부분이다.
재시도 로직을 구축하면서 예기치 못한 문제가 하나 더 생겼는데, 현재 로직 상 백엔드에 요청이 여러 번 넘어간다는 것.
단순히 '메시지를 다시 보내면 되지 않나?' 하고 만들었지만, 답변 생성에 실패한 사용자 메시지들이 쌓여서 답변이 두 번 이상씩 오는 상황이 종종 발생했다.
솔직히 이 부분은 복잡도에 비해 효과가 크게 떨어져서 코드를 썩 공개하고 싶지가 않다...ㅎㅎㅎ
간단하게만 언급하자면 각 요청마다 고유한 ID를 부여하고 이를 추적, 이미 처리된 ID가 또 처리될 경우 응답 생성을 막는 형태로 코드를 수정했다.
아마 이 문제를 다시 마주했다면 메시지 전송 시 SQS Queue와 같은 큐 구조를 구현하여 메시지가 여러 번 넘어가는 현상을 막을 것이다.