FE

센트리(Sentry) 정상화를 통해 깨진 유리창 고치기

jvn4dev 2025. 3. 15. 16:59
서비스를 사용 중에 갑자기 튕기거나 새로고침되는 이슈가 있어요. 이 부분들을 빠르게 수정해야 할 것 같아요.

 

서비스의 튕김이라는 사업적으로도 제품적으로도 안좋은 경험을 해결하고자 해당 이슈 해결에 대한 프론트파트 차원의 온도가 급격하게 높아졌습니다.

 

현재 저희 서비스는 브라우저에 phaser 게임엔진을 통해 움직이는 캐릭터를 중심으로 web-rtc나 react 등 많은 일을 하고 있어 코드만 봤을 때는 어디서 어떤 에러가 발생할지 예측하기가 어렵습니다. 그렇기 때문에 우선 현재 발생하는 에러들 중에 어디서 어떤 에러가 발생하는지를 파악하는 게 중요했어요. 이를 위해서도 예전부터 에러 모니터링 툴인 센트리(Sentry)를 사용하여 현재 저희의 주요 애플리케이션에서 발생하는 에러를 수집하고 있었어요. 

 

하지만 이 센트리는 너무 많은 에러를 수집하고 있었습니다. 게다가 수집되는 에러가 너무 많았기 때문에 과도한 band-width와 noise를 줄이기 위해 에러 수집률을 1%로 낮춰둔 상태였습니다. 수집률을 1%로 낮췄음에도 한달 기준으로 거의 100만 건의 에러가 리포트되고 있었고 이런 상황 때문에 사실상 보고되는 에러를 무시하게 되고 센트리를 유의미하게 사용할 수 없는 상황이었어요.

 

튕김과 같은 어떤 문제가 발생했을 때, 파트 차원에서 센트리를 유의미하게 사용하려면 어떤 문제들을 해결해야하는지 고민했어요. 해결하고자 했던 내용은 다음과 같았습니다.

  • 에러 수집률을 기존 1%에서 100%로 복구하여 에러 인식률을 높인다.
  • 단순히 수집률을 높이면 불필요한 에러까지 과도하게 쌓이는 문제가 발생할 수 있기에 불필요하게 발생하는 에러를 mute 한다. (절대적인 에러 이벤트 수 줄이기)
  • 발생하는 에러들을 Slack integration을 통해 개발자가 빠르게 인지할 수 있도록 한다.

수집률을 높이기 이전 절대적인 에러의 수를 줄이기 위해 발생하는 에러의 이벤트수로 나열하여 에러를 보기 시작했습니다. 그런데 여기서 또 다른 문제가 발생했어요. 에러가 발생하는 코드를 확인하려면 센트리에 현재 우리 프로젝트의 소스맵을 업로드해주어야 하는데 소스맵이 없어 이 에러가 어디서 발생하는지 알 수 없는 문제였어요. 여기서 해결하고자 하는 목표를 다시 수정했습니다. 

  • 에러가 어디서 발생하는지 알 수 있도록 소스맵 업로드하기
  • 발생하는 에러 수 줄이기
  • 수집률 100%로 올리기

소스맵을 업로드하는 과정에서 꽤 삽질을 했지만 사실 방법은 생각보다 간단합니다. Sentry가 제공하는 withSentryConfig를 통해 next.config.js가 export 하는 모듈을 감싸주면 빌드 id에 따라 자동으로 결과물이 센트리 소스맵이 프로젝트에 따라 업로드 됩니다.

 

저는 소스맵을 관리하는 다른 방법 중 좀 더 다양한 옵션을 사용하기 위해 sentryWebpackPlugin을 통해 소스맵을 업로드하도록 하였습니다.

sourcemaps: {
  disable: process.env.NEXT_PUBLIC_STAGE === 'LOC',
},

해당 옵션을 통해 로컬 환경에서 개발 중일 때, 변경사항이 감지될 때마다 소스맵을 업로드하지 않도록 하였어요. 

그리고 소스맵은 개발 단계에서 디버깅을 용이하게 하기 위해 사용되지만, 보안이나 성능상의 이유로 프로덕션 환경에 노출되면 안되기 때문에 옵션 중 filestodeleteafterupload를 사용하여 빌드 결과물에 존재하는 소스맵을 제거하도록 처리하고자 했어요.

 

그런데 해당 옵션을 사용하여도 빌드 결과물에 소스맵이 제거되지 않는 이슈가 있었어요.(사실 이 옵션때문에 사용한거였는데...)

관련해서 삽질을 좀 하다가 sentry-webpack-plugin 레포의 이슈를 참고하여 빌드 결과물에 소스맵을 직접 제거하는 방식으로 해결하였습니다.

 

sentry의 소스맵 업로드 과정은 빌드 과정에서 이루어지기 때문에 해당 과정이 끝난 빌드 완료 시점에 개발 환경이 아닌 프로덕션 환경일 때만 동작하는 스크립트를 build.sh에 추가하여 소스맵이 존재하는 path의 .js.map파일 전부를 제거하고 해당 과정마다 로깅을 하도록 하였어요. 

꿀팁: 소스맵이 존재하는지 확인하는 여러가지 방법으로 버셀의 빌드 결과물(output)의 .js.map이 존재하는지 직접 확인하거나 브라우저의 네트워크 요청으로 받은 랜덤한 js chunk를 주소창에 .js.map으로 변경하여 요청했을 때 요청할 수 없는 페이지에 도달한다면 소스맵이 성공적으로 지워진 것 입니다.
제가 찾아본 내용으로는 next.config.js의 hidden-source-map 옵션은 켜더라도 브라우저의 실제 소스맵을 제거하는 것이 아닌 indexing 정도를 방지하는 것으로 확인하여 직접 제거하는 방식으로 처리하였습니다.

 

해당 과정을 비개발자인 제품팀 전체가 이해할 수 있도록 정리하여 해당 작업에 제품팀 전부가 높은 이해도를 가질 수 있도록 공유하였어요.

 

소스맵 업로드를 성공적으로 마친 이후에 비로소 에러들이 어디서 발생하는지 파악할 수 있게 됐습니다. 드디어 원래 제가 하려던 일을 할 수 있게 되었군요..

 

이제 발생하는 에러의 수를 줄였어야 했는데 생각보다 양이 많고, 에러에 undefined나 null을 참조하려는 에러가 꽤 많았는데 해당 에러들을 대충 try, catch 등으로 감싸게 되면 진짜 발생해야 하는 에러들이 mute 되어버리거나, 실제 해당 객체가 nullish 하면 안 되는 상황 등에서 문제가 될 수 있어 에러 하나하나를 꽤 신중하게 처리해야 하는 상황이었습니다.

 

시간이 꽤 걸리는 작업이 될 것 같아 더 이상 개인이 아닌 파트차원에서 문제를 해결하기로 했습니다. 이때부터 제품팀에 튕김과의 전쟁을 선포하고 노션에 발생하는 에러들을 전부 수집하여 해당 에러들의 기준에 따른 진단결과, 작업 상태, 복구 수준, 우선순위 등을 기록하기로 했습니다.

진짜 전쟁선포한 기분을 내고 싶었는데 성공적이였다.

 

해당 과정에서 해당 에러가 튕김을 유발하는지, 빈도수와 에러를 경험하는 유저수를 기반으로 우선순위를 결정했어요.

 

팀원들 모두 제품 피쳐개발과 센트리 에러 해결을 병행하면서 고생하는 시기였어요. 디버깅이 쉽지 않고 "왜 나한테는 재현이 안되죠?"와 같은 상황이 많았어요. 우선 이번 과정에서의 목표는 모든 에러를 수정하는 것이 아닌 튕김을 유발하는 에러들을 해결하는 것이 목표였기 때문에 우선순위를 High로 설정한 이슈들만을 해결하기로 했어요.

 

그리고 다른 팀원분의 아이디어로 센트리에도 튕김을 유발한 에러인지를 명확하게 나타내기 위해 앱의 최상위를 감싸고 있는 Sentry GlobalErrorBoundary의 옵션 중 'beforeCapture'를 통해 에러들에 다음과 같은 태그를 추가하여 이 에러가 튕김을 일으키는 에러인지 바로 알 수 있도록 했어요.

beforeCapture={scope => {
  scope.setTag('globalErrorBoundaryRendered', true);
}}

 

추가로 에러들에 명확한 태깅을 통해 Discover 탭에서 'has globalErrorBoundaryRendered' 쿼리를 통해 검색도 편하게 할 수 있다는 장점이 있어요.

 

해당 과정에서 알게 된 서비스의 튕김을 유발하는 에러들은 다음과 같은 것들이 있었습니다. 해결 방법은 아이디어를 기반으로 간단하게 정리했어요.

  • phaser의 game scene을 load 하는 과정에서 preload 된 플러그인을 참조하려할 때 해당 플러그인이 null인 경우
더보기

현재 서비스의 scene은 총 2개로 BootStrapScene(제일 초기에 생성되는 scene)과 LaunchMap이 동작하면서 각종 맵데이터를 불러온 뒤에 비동기로 phaser scenes 목록에 추가되는 GameScene(플레이화면에 사용되는 메인 scene)이 있어요.

 

GameScene에서 이전 BootStrapScene에서 preload된 플러그인들을 사용하도록 되어있는데 해당 두 scene이 로드되는 타이밍이 상위 컴포넌트 위 위계를 보니 같아 비동기적으로 컴포넌트가 마운트 된다고 할 때 race condition이 발생하면서 특정 타이밍에 GameScene에서 참조하는 플러그인이 없을 수 있겠다는 생각이 들었어요.

 

이를 방지하기 위해 GameScene에서 사용되는 플러그인은 명확하게 GameScene 클래스에서 preload 하도록 수정했어요. 다만 이렇게되면 같은 스페이스의 다른 맵으로 이동하게 됐을 때 이미 Scene에 존재하는 플러그인을 다시 preload하려는 상황(Console에 warning이 발생)이 발생하기에 preload과정에서 this.scene.plugins를 참조하여 해당 플러그인이 없을 때만 preload하도록 수정했습니다.

  • 특정 타이밍에 illegal buffer에러가 발생하며 알 수 없는 에러가 발생했다는 모달과 함께 홈화면으로 나가지는 경우
더보기

해당 에러의 경우 정확히 어떤 이유에서 발생하는지 아직 원인을 찾아내지 못해 우선 내부적으로 로깅을 통해 정보를 더 취합할 수 있도록 하였어요.

 

우선 지금까지 찾아낸 정보로는 공지 관련 패킷을 invoke 하고 응답을 decode 하는 과정에서 Illegal Buffer 에러가 발생했을 것으로 예상하고 있어요. 패킷이 유효하지 않은 경우 이러한 에러가 발생할 수 있는데, 개발 환경에서는 DB나 패킷이 자주 수정되기 때문에 발생 가능성이 높지만, 운영 환경에서도 동일한 에러가 발생하는 이유는 명확하지 않은 상황이에요.

 

공지 내용에 특수 문자나 이모지가 포함될 가능성을 의심하고 이를 확인하기 위해 서버 개발자와 함께 특정 스페이스의 DB를 조회했는데 해당 스페이스는 공지가 없는 상태였으며, DB에서 공지 값이 null, updatedAt 값이 비정상적인 형태(0001-01-01…)로 저장되어 있었고, 서버 코드에서 패킷이 전달되는 과정을 확인한 결과, 이러한 경우 소켓 서버의 응답이 500 에러이거나 빈 문자열('') 일 가능성이 있었어요. 이를 검증하기 위해 decode() 함수의 입력 값을 ''로 설정해 테스트한 결과, 동일하게 Illegal Buffer 에러가 발생함을 확인했습니다.

 

하지만 해당 에러에는 silent 하게 발생하는 에러와 튕김을 유발하는 에러가 존재하여 좀 더 강화한 로깅을 확인하고 추후에 수정하는 것이 좋다고 판단했어요. 그리고 발생하는 빈도 자체가 적었어서 중요도는 높지 않다고 판단했습니다.

  • lazy loading으로 dynamic import 되는 컴포넌트를 Suspense로 감싸지 않은 경우
더보기

에러 중 꽤 많은 발생률과 유저수를 갖고 있어 우선순위가 높았던 이슈로 에러 메시지는 다음과 같았어요.

"A component suspended while responding to synchronous input. This will cause the UI to be replaced with a loading indicator. To fix, updates that suspend should be wrapped with startTransition."

 

센트리 상의 소스맵으로는 알아낼 수 있는 정보가 사실상 없었고 에러 메시지를 기반으로 gpt와 함께 에러를 찾아내보고자 했어요. 제가 처음 생각한 가설로는 앱이 마운트직후에 맵데이터를 불러오는 과정을 캐싱되는 promise를 throw 하여 상위 suspense를 일으켜 fallback으로 loading화면을 보여주고 있는데 해당 과정에서 synchronous input이 발생하면서 발생하는 문제일 것 같다는 생각이 들었어요. 그런데 이를 해결하려면 해당 과정에서 발생하는 모든 상태 업데이트를 startTransition으로 감싸주어야 하는데 해당 부분의 코드 복잡도가 높아 해결하기에 어려운 문제가 있었어요.

 

관련해서 다른 팀원과 많은 삽질을 하다가 플레이화면에 마운트 되는 거대한 PlayClient 컴포넌트가 번들 최적화 및 lazy loading으로 최적화하기 위해 dynamic import를 통해 불러와지는데 Suspense로 감싸져있지 않은 부분을 의심했고 상위에 Suspense를 감싸주어 해당 에러를 해결할 수 있었습니다.

 

해당 과정을 통해 ai를 적절하게 사용하되 너무 의존하지 않는 것도 중요하다는 점을 배웠어요. 어려운 문제를 ai로만 해결하려고 할 때 쉽게 함정에 빠질 수 있겠다는 생각이 들었습니다.

이 외에도 phaser의 webglcontext 관련 에러 복구 과정에서 더 나은 UX를 제공한다거나 센트리의 session replay 기능을 통해 canvas와 react ui의 에러 발생 과정을 시각적으로 더 쉽게 파악할 수 있게 하는 등의 다른 많은 개선들이 있었습니다.

추가로 Slack integration을 통해 개발자들은 에러가 발생했을 때 주말에도 알람을 받고 있어요...ㅎㅎ

 

해당 과정을 통해 수집률 100% 기준 기존 발생하던 약 100만 건의 에러를 현재 30일 기준 약 56만 건 정도로 44% 정도 개선을 하였네요.

적지 않은 band-width이지만 제품 관점에서 출시할 기능을 미루면서까지 너무 많은 시간을 쓸 수 없고 과금 비용이 크지 않아 회사에서도 요금제를 조금 높여서 유지하는 방향으로 합의했어요. 프론트엔드 파트 차원에서도 globalErrorBoundaryRendered 태그를 가진 에러들을 우선적으로 해결할 수 있도록 목표를 설정했어요.

 

아직 초기에 목표하였던 1/10 수준으로 개선하진 못했지만 대부분의 튕김을 유발하던 문제들을 해결하고 더 이상 센트리를 통해 발생하는 에러를 확인하는 일이 깨진 유리창이 되지 않고 점진적으로 문제를 해결해 나갈 수 있다는 점에서 유의미한 작업이었다는 생각을 했습니다.