추가 될 줄 몰랐던 회고2편,, 이전 글들에서 시장조사부터 요구사항 정의, AI 개발 하네스, 정량 예측 모델까지 프로젝트를 만드는 과정을 정리했다.
시리즈를 마무리한 뒤에도 프로젝트는 계속 움직였다. (왜냐면 주식을 매일 보기 때문에, 어느정도 퍼포먼스가 나올 수 있는지 보고 싶었다.)
실제 서비스를 사용하면서 무한 로딩 버그를 발견했고, 오래된 스캐폴드의 흔적을 걷어냈다. Next.js 15, React 19, Auth.js v5로 업그레이드한 뒤에는 프리뷰에서 발견하지 못한 인증 오류를 프로덕션에서 만났다. 한동안 꺼두었던 OneSignal 웹 푸시를 다시 활성화하는 과정에서도 여러 문제가 연달아 나타났다.
이번 글은 깔끔한 설계나 새로운 기능보다는 다음과 같은 상황에 가깝다.
분명히 구현했는데, 실제로 켜보니 제대로 동작하지 않았다.
배포 이후에 발견한 문제들과 이를 수정한 과정을 정리한다.
1. “생성이 오래 걸리고 있어요”에서 멈춰버린 추천 카드
어느 날 홈 화면에 들어갔더니 추천 카드 생성이 다음 메시지에서 더 이상 진행되지 않았다.
생성이 오래 걸리고 있어요.
잠시 후 다시 확인해 주세요.
새로고침해도 추천 카드는 나타나지 않았다.
처음에는 특정 LLM 요청이 일시적으로 느려진 문제라고 생각했다. 하지만 실행 시간을 따라가 보니 애초에 성공할 수 없는 구조였다.
처음부터 맞지 않았던 타임아웃 예산
관심 종목은 최대 3개까지 등록할 수 있었고, 기존 코드는 각 종목을 순차적으로 처리했다.
AAPL 생성 완료
→ MSFT 생성
→ NVDA 생성
종목 하나의 LLM 호출은 재시도를 포함하면 최대 약 50초까지 걸릴 수 있었다.
따라서 최악의 경우 필요한 시간은 다음과 같았다.
종목당 최대 50초 × 3종목
= 최대 150초
하지만 서버 함수와 클라이언트 타임아웃은 모두 약 55~60초로 설정돼 있었다.
종목이 2개 이상이면 정상적인 요청이라도 제한 시간 안에 끝날 가능성이 낮았다. 각 단계만 따로 보면 그럴듯했지만, 전체 실행 시간을 합산하지 않은 것이 문제였다.
“서버에서 계속 진행 중”이라는 잘못된 안내
더 큰 문제는 타임아웃 이후의 처리였다.
클라이언트에는 먼저 응답을 반환하고 다음과 같이 안내했다.
서버에서는 추천 카드 생성을 계속 진행하고 있습니다.
하지만 실제 Serverless 환경에서는 응답을 반환한 뒤 실행 중이던 작업이 계속된다고 보장할 수 없었다.
응답이 끝나면서 함수도 종료됐고, 생성 중이던 추천 카드는 데이터베이스에 저장되지 못했다. 사용자에게 보여준 안내와 실제 동작이 달랐던 것이다.
수정 방법
먼저 종목별 LLM 호출을 순차 실행에서 병렬 실행으로 변경했다.
기존
AAPL
→ MSFT
→ NVDA
변경 후
AAPL ─┐
MSFT ─┼─ 동시에 생성
NVDA ─┘
이제 최악의 실행 시간은 3개 종목의 합이 아니라 가장 오래 걸린 종목 하나를 기준으로 계산할 수 있다.
추가로 다음 항목을 함께 수정했다.
- 서버 함수의 실행 제한을 300초로 확대
- 응답 이후에도 필요한 작업은 waitUntil()로 명시
- Yahoo Finance와 Finnhub 호출에 각각 10초 타임아웃 적용
- 단계별 실행 시간 로그 추가
외부 API 호출에는 별도의 타임아웃이 없었다. 응답이 지연되면 전체 생성 과정이 함께 멈출 수 있는 상태였다.
지금은 다음과 같이 각 단계의 소요 시간을 로그로 남긴다.
price_history_fetch: 820ms
news_fetch: 1,430ms
gemini_generation: 18,240ms
database_write: 170ms
total: 20,660ms
다음에 다시 느려지더라도 어느 단계가 병목인지 빠르게 찾을 수 있게 됐다.
2. 신규 상장주에는 왜 예측값이 표시되지 않았을까
관심 종목에 신규 상장 종목을 추가한 뒤 가격 상세 페이지를 확인했는데, 통계 예측값이 나타나지 않았다.
처음에는 데이터 조회나 차트 렌더링 문제라고 생각했다.
하지만 원인은 정상적인 비즈니스 규칙이었다.
통계 예측 모듈은 최소 20거래일의 가격 데이터가 있어야 동작한다. 해당 종목은 상장 이후 데이터가 19거래일만 쌓여 있었다.
필요 데이터: 20거래일
현재 데이터: 19거래일
→ 통계 예측 생략
코드는 설계된 대로 동작하고 있었다.
문제는 화면에서 아무 설명도 하지 않았다는 점이었다.
의도된 생략도 사용자에게는 오류처럼 보인다
기존 화면에서는 예측 데이터가 없으면 해당 영역을 표시하지 않았다.
개발자는 내부 조건을 알고 있으므로 정상 동작으로 이해할 수 있다. 하지만 사용자는 데이터가 부족한 것인지, 서버가 실패한 것인지, 기능이 지원되지 않는 것인지 알 수 없다.
그래서 데이터가 부족할 때 다음 안내를 표시하도록 수정했다.
현재 19거래일의 가격 데이터가 쌓였습니다.
통계 예측에는 최소 20거래일의 데이터가 필요합니다.
데이터가 충분히 쌓이면 예상 가격과 범위가 자동으로 표시됩니다.
진행률도 함께 표시했다.
19 / 20 거래일
███████████████████░ 95%
기능을 새로 추가한 것은 아니지만, 사용자가 현재 상태를 이해할 수 있게 됐다.
조용히 비워둔 화면과 이유를 설명하는 화면의 차이는 생각보다 컸다. 실제로 나 역시 빈 화면만 보고 버그라고 판단해 원인을 조사했기 때문이다.
3. 스캐폴드의 흔적을 걷어내다
이 프로젝트는 Figma Make와 Vite 기반 스캐폴드 위에 Next.js 구조를 추가하면서 시작했다.
개발이 진행되면서 실제 애플리케이션은 Next.js 중심으로 바뀌었지만, 초기 스캐폴드의 의존성과 컴포넌트는 그대로 남아 있었다.
예를 들어 package.json의 프로젝트 이름은 여전히 다음과 같았다.
{
"name": "@figma/my-make-file"
}
실제로 한 번도 사용하지 않은 패키지도 다수 포함돼 있었다.
- MUI
- React Router
- React DnD
- Vite 관련 패키지
- 초기 스캐폴드용 유틸리티
shadcn/ui 컴포넌트도 약 50개가 설치돼 있었지만 실제로 사용하는 것은 7개 정도였다.
실제 import를 기준으로 전수조사
정리는 단순히 사용하지 않을 것 같은 패키지를 눈으로 골라내는 방식으로 진행하지 않았다.
소스 전체에서 실제 import되는 패키지를 추출한 뒤, 이를 package.json의 의존성 목록과 대조했다.
package.json 의존성
− 실제 import된 패키지
= 제거 후보
UI 컴포넌트도 동일하게 실제 참조 여부를 확인했다.
그 결과 다음 항목을 제거했다.
- 사용하지 않는 의존성 43개
- 사용하지 않는 UI 컴포넌트 43개
- 약 8,000줄의 불필요한 코드
React가 일반 dependencies가 아니라 optional peer dependency로 선언돼 있던 비정상적인 구조도 이때 바로잡았다.
계속 실패하던 테스트 13개
같은 작업에서 오랫동안 실패 상태로 방치되던 테스트 13개도 정리했다.
대부분 기능이 잘못된 것이 아니라, 테스트가 이전 계약을 계속 검증하고 있었다.
예를 들면 다음과 같았다.
기존 페이지 경로: /
현재 페이지 경로: /today
기존 Gemini 기본 모델명
→ 현재 모델명으로 변경됨
코드는 이미 변경됐지만 테스트는 과거 구조에 머물러 있었다.
이런 테스트를 stale test, 즉 현재 구현과 맞지 않는 오래된 테스트라고 볼 수 있다.
테스트 스위트가 항상 빨간 상태라면 새로운 회귀가 발생해도 눈에 띄지 않는다.
항상 실패하는 테스트 13개
+ 새로 발생한 실패 1개
= 어떤 문제가 새로 생겼는지 구분하기 어려움
오래된 테스트를 현재 계약에 맞게 수정한 뒤 전체 493개 테스트가 통과하도록 만들었다.
이후부터는 변경 작업을 수행할 때 무엇을 새로 깨뜨렸는지 바로 확인할 수 있었다.
4. Next.js 15, React 19, Auth.js v5 업그레이드
프레임워크 업그레이드는 별도 브랜치에서 진행했다. 주요 변경 대상은 다음과 같았다.
- Next.js 15
- React 19
- NextAuth v4에서 Auth.js v5로 전환
Next.js 공식 Codemod가 params와 searchParams의 Promise 전환을 대부분 처리해줘서 프레임워크 자체의 수정은 예상보다 순탄했다. Auth.js v5 전환도 내부의 getCurrentUserId() 인터페이스를 유지한 덕분에 기존 호출부와 테스트 Mock을 크게 바꾸지 않고 진행할 수 있었다.
프리뷰 배포에서는 Google 로그인까지 정상적으로 확인했다.
프리뷰에서는 됐지만 프로덕션에서는 로그인 실패
프로덕션 배포 후 Google 로그인을 시도하자 다시 로그인 페이지로 돌아왔다. 이부분이 백엔드나 인증관련 배경지식이 없는 나로써는 이해하기 어려웠고 오래걸렸다...
서버 로그를 확인해 보니 다음 단계까지는 정상적으로 진행되고 있었다.
Google OAuth 인증 성공
→ Callback 처리 성공
→ 세션 쿠키 발급 성공
→ 미들웨어에서 다시 로그인 페이지로 이동
로그인 자체가 실패한 것이 아니라, 로그인 이후 미들웨어가 세션을 읽지 못하고 있었다.
원인은 Secure Cookie 이름이었다
HTTPS 프로덕션 환경에서 실제로 발급된 쿠키 이름은 다음과 같았다.
__Secure-authjs.session-token
하지만 미들웨어의 getToken()은 다음 쿠키를 찾고 있었다.
authjs.session-token
Auth.js v5의 getToken() 사용 시 secureCookie 설정이 현재 코드와 맞지 않아, 프로덕션에서 사용하는 Secure Cookie를 읽지 못한 것이 원인이었다.
결과적으로 다음 세 요소는 모두 정상적이었다.
- Google OAuth
- 콜백 처리
- 쿠키 발급
오직 미들웨어의 쿠키 조회만 실패하고 있었다. 마이그레이션 문서와 설정을 다시 확인했지만 원인이 명확하지 않아, 결국 설치된 라이브러리의 구현 코드를 직접 열어 동작을 확인했다.
이 경험에서 남은 교훈은 단순했다.
마이그레이션 가이드를 따라도 실제 동작이 맞지 않는다면, 결국 사용 중인 버전의 소스가 가장 정확한 문서다.
또한 인증은 프리뷰 환경에서 성공했다고 끝난 것이 아니었다. HTTPS 도메인과 쿠키 정책이 달라지는 프로덕션 환경에서 반드시 다시 확인해야 했다.
5. OneSignal 푸시를 다시 켜면서 발견한 문제들
마지막 작업은 한동안 비활성화해 두었던 OneSignal 웹 푸시를 다시 활성화하는 것이었다.
푸시 기능을 끌 때 코드에 PUSH_DISABLED라는 마커를 남겨뒀다.
PUSH_DISABLED
덕분에 복구해야 할 위치를 찾는 것은 어렵지 않을 것이라고 생각했다.
하지만 실제로는 구독, 서비스워커, CSP, API 키, 운영체제 설정까지 이어지는 긴 문제 해결 과정이 됐다.
발송 로직과 구독 로직이 연결돼 있지 않았다
푸시를 켜기 전에 전체 흐름을 확인하다가 중요한 문제를 발견했다.
서버의 Cron 작업은 사용자 ID를 OneSignal의 external_id로 사용해 알림을 발송하고 있었다.
DB User ID
→ OneSignal external_id
→ 해당 사용자에게 발송
하지만 클라이언트에서는 다음 코드가 한 번도 호출되지 않았다.
OneSignal.login(userId);
즉, 브라우저의 푸시 구독 정보와 우리 데이터베이스의 사용자 ID가 연결되지 않은 상태였다.
이대로 기능을 활성화했다면 서버 요청은 성공하더라도 OneSignal이 어느 구독자가 해당 사용자와 연결돼 있는지 알 수 없었다.
발송 API 성공
수신 대상 0명
발송 코드와 구독 코드는 각각 존재했지만, 두 시스템 사이의 연결이 빠져 있었던 것이다.
외부 서비스를 연동할 때는 양쪽 기능이 각각 동작하는지만 확인해서는 부족했다.
두 시스템을 이어주는 식별자 연결도 별도의 검증 대상이다.
여섯 개를 복원했는데 일곱 번째 비활성화 코드가 남아 있었다
PUSH_DISABLED를 검색해 발견한 여섯 곳을 모두 복원했다.
하지만 브라우저에서는 계속 다음 오류가 발생했다.
no active Service Worker
원인은 public/OneSignalSDKWorker.js에 있었다.
이 파일은 서비스워커가 설치되자마자 스스로 등록을 해제하도록 만든 비활성화용 Stub 상태였다.
서비스워커 설치
→ 즉시 unregister()
→ 활성 서비스워커 없음
처음 검색할 때 범위를 src/로 제한했기 때문에 public/ 폴더에 남아 있던 코드를 발견하지 못했다.
기능 비활성화 마커를 찾을 때는 애플리케이션 소스 폴더만 검색해서는 안 됐다.
src/
public/
scripts/
config/
root files
저장소 전체를 확인해야 했다.
CSP 오류가 하나씩 나타났다
서비스워커를 복원한 뒤에는 CSP(Content Security Policy) 문제가 이어졌다.
CSP는 브라우저가 어떤 출처의 스크립트, 스타일, 네트워크 요청을 허용할지 제한하는 보안 정책이다.
처음에는 OneSignal API 통신만 허용하면 될 것으로 생각해 connect-src를 추가했다.
하지만 OneSignal SDK는 네트워크 요청만 사용하는 것이 아니었다.
- CDN에서 JavaScript 로드
- JSONP 방식으로 동기화 엔드포인트 호출
- 외부 스타일시트 로드
- 서비스워커 실행
따라서 connect-src만으로는 부족했다.
콘솔에서 첫 번째 오류를 해결하면 다음 CSP 오류가 나타났다.
connect-src 위반 해결
→ script-src 위반
script-src 위반 해결
→ style-src 위반
결국 필요한 OneSignal 도메인을 다음 지시어에 맞춰 각각 허용해야 했다.
- connect-src
- script-src
- style-src
- 서비스워커 관련 정책
서드파티 SDK를 CSP가 적용된 환경에 추가할 때는 브라우저 오류를 하나씩 해결하기보다, 해당 SDK가 사용하는 리소스와 필요한 지시어를 먼저 확인하는 편이 효율적이다.
모든 코드를 고쳤는데 API 키가 틀려 있었다
서비스워커와 CSP까지 수정한 뒤에도 발송 결과는 실패였다.
{
"scheduled": 1,
"sent": 0,
"failed": 1
}
로컬 환경에서 동일한 자격증명으로 OneSignal API를 직접 호출해 보니 403 Forbidden이 반환됐다. 환경변수에 저장돼 있던 REST API 키가 올바른 형식이 아니었다.
정상적인 키
→ os_v2_ 로 시작
→ 약 113자
저장돼 있던 값
→ 약 25자
새로운 키를 발급해 환경변수를 교체한 뒤 결과가 바뀌었다.
{
"scheduled": 1,
"sent": 1,
"failed": 0
}
코드 문제처럼 보였지만 최종 원인은 자격증명이었다. 외부 API 연동에서는 구현을 디버깅하기 전에 최소한의 직접 호출로 자격증명이 유효한지 먼저 확인하는 것이 시간을 줄여준다.
마지막 단계는 macOS 알림 설정이었다
서버 발송까지 성공했는데도 알림이 화면에 나타나지 않았다. 마지막으로 확인한 곳은 코드가 아니라 macOS 시스템 설정이었다.
Chrome의 알림 권한이 비활성화돼 있었다. 푸시 알림이 실제 사용자에게 도달하려면 다음 단계를 모두 통과해야 한다.
Cron 실행
→ OneSignal API 호출
→ 사용자 external_id 연결
→ 브라우저 구독
→ 서비스워커
→ CSP
→ 브라우저 권한
→ 운영체제 알림 권한
→ 실제 화면 표시
서버에서 sent: 1이 반환됐다고 해서 사용자가 알림을 확인했다는 의미는 아니었다.
현재 서버 발송 단계까지는 정상화했지만, 실제 단말 수신에 대한 최종 검증은 계속 진행 중이다. 따라서 이번 기록 역시 완료 보고라기보다는 중간 점검에 가깝다.
6. 이번 운영 사이클에서 남은 교훈
이번 작업에서 얻은 교훈을 정리하면 다음과 같다.
1. 타임아웃은 단계별이 아니라 전체 합계로 계산한다
각 작업이 제한 시간 안에 들어오더라도 순차 실행하면 전체 시간은 쉽게 제한을 넘는다.
단계 A 20초
+ 단계 B 20초
+ 단계 C 20초
= 총 60초
필요한 경우 병렬화하고, 외부 API에도 개별 타임아웃을 설정해야 한다.
2. Serverless의 응답 이후 실행은 자동으로 보장되지 않는다
“먼저 응답하고 나중에 처리한다”는 구조를 사용하려면 waitUntil()이나 별도의 Queue처럼 실행을 보장할 방법이 필요하다.
안내 문구와 실제 실행 방식도 일치해야 한다.
3. 의도된 빈 화면에도 이유를 표시한다
데이터가 부족하거나 기능이 적용되지 않는 경우, 아무것도 표시하지 않으면 사용자에게는 오류로 보인다.
생략 이유와 필요한 조건을 보여주는 편이 낫다.
4. 항상 실패하는 테스트는 회귀 탐지를 방해한다
테스트 스위트가 계속 빨간 상태라면 새로운 실패가 발생해도 구분하기 어렵다.
오래된 테스트는 삭제하거나 현재 계약에 맞게 수정해야 한다.
5. 프리뷰 성공이 프로덕션 성공을 의미하지 않는다
특히 인증, 쿠키, 도메인, HTTPS 정책은 환경에 따라 다르게 동작할 수 있다.
프로덕션 환경에서 별도의 확인이 필요하다.
6. 기능 마커는 저장소 전체에서 검색한다
코드는 src/에만 존재하지 않는다.
서비스워커, 설정 파일, 스크립트, Public Asset에도 비활성화 코드가 남을 수 있다.
7. 외부 연동은 연결 구간을 따로 검증한다
발송 로직과 구독 로직이 각각 정상이어도 사용자 식별자가 연결되지 않으면 실제 수신자는 0명이다.
각 컴포넌트뿐 아니라 컴포넌트 사이의 연결도 테스트해야 한다.
마무리
이전 글들이 “어떻게 만들었는가”에 대한 기록이었다면, 이번 편은 “만든 다음에 어떤 일이 생겼는가”에 가깝다.
개발 단계에서는 코드가 빌드되고 테스트가 통과하면 기능이 완성된 것처럼 느껴진다.
하지만 실제 운영에서는 다음과 같은 요소가 함께 동작해야 한다.
애플리케이션 코드
+ Serverless 실행 환경
+ 외부 API
+ 인증과 쿠키
+ 브라우저 보안 정책
+ 서비스워커
+ 운영체제 설정
이번에 발견한 문제 대부분은 특정 함수 하나의 버그라기보다 여러 계층 사이의 가정이 맞지 않아 발생했다.
LLM 호출 시간
↔ 서버 함수 제한
로그인 쿠키
↔ 미들웨어 설정
OneSignal 발송
↔ 사용자 구독 연결
브라우저 SDK
↔ CSP와 서비스워커
푸시 수신
↔ 운영체제 권한
배포는 개발의 끝이 아니라, 이전까지 세운 가정이 실제 환경에서도 맞는지 확인하기 시작하는 시점이었다.
이 글을 작성하는 현재, 다음 자동 브리핑 푸시는 내일 오전 7시에 실행될 예정이다. 서버 발송뿐 아니라 실제 단말에서 알림이 도착하는지까지 확인한 뒤, 필요하다면 운영 기록을 다시 이어서 정리해 보려 한다.
프로젝트 저장소
AI Stock Alarm App
https://github.com/hy2207/ai-stock-alarm-app
GitHub - hy2207/ai-stock-alarm-app
Contribute to hy2207/ai-stock-alarm-app development by creating an account on GitHub.
github.com
'Side Projects > AI Stock Alarm' 카테고리의 다른 글
| [AI 주식 알람] 개발회고: 예측 모델을 두 번 갈아엎은 이유 (0) | 2026.07.28 |
|---|---|
| [AI 주식 알람] Claude Code 서브에이전트를 6개로 나눈 기준 (0) | 2026.07.21 |
| [AI 주식 알람] 하나의 규칙, 세 개의 AI 도구: Cursor・Claude Code・Antigravity 동시 운영기 (0) | 2026.07.16 |
| [AI 주식 알람] SRS 한 줄이 커밋 20개가 되기까지: 요구사항을 태스크로 쪼개는 스킬 (0) | 2026.07.14 |
| [AI 주식 알람] PRD를 AI 에이전트가 읽게 만들기: SRS를 따로 쓰는 이유 (0) | 2026.07.13 |