슈퍼브에이아이의 Front-End Engineer가 버그🐞를 대응하는 자세
들어가며
이번 글은 슈퍼브에이아이의 Front-End Engineer가 버그를 발견하고 어떤 과정으로 해결해 가는지 공유해 보려고 합니다. 버그가 발생하면 어떤 과정을 거쳐 해결하시나요? 각 회사에서 개발팀의 버그 대응 과정은 비슷할 수 있지만, 슈퍼브에이아이에서는 제일 먼저 이슈 재현 및 파악을 선행합니다. 특히, 슈퍼브 라벨은 많은 고객과 라벨러 분들이 사용하고 있는 서비스이기 때문에 버그 발생 경로 또한 매우 다양한데요. 고객이 비용을 지불하고 사용하는 SaaS 플랫폼을 만드는 Front-End Engineer는 어떻게 버그를 잡는지🐞🔫 살펴보겠습니다.
버그 발견🐞
슈퍼브에이아이는 슈퍼브 라벨이라는 제품을 통해 비전 데이터의 라벨링을 더욱 쉽게 할 수 있는 서비스를 제공하고 있습니다. 라벨링 어노테이션을 생성할 때, 가장 많이 사용하는 어노테이션은 박스와 폴리곤일 것 같은데요. 그중 폴리곤을 그리는 과정에서 버그를 발견하게 되었습니다.
이번에 발견하게 된 버그는 폴리곤 도형을 그리는 역할을 담당하는 PolygonDrawing
이라는 컴포넌트를 리팩터링한 이후 발생하였는데요. 이 PolygonDrawing
컴포넌트에는 faces
라는, 폴리곤을 그리는 도중의 점들의 좌표의 배열 상태가 존재하는데요. 이 상태가 undefined가 되는 버그가 센트리의 에러 모니터링에서 나타나기 시작했습니다. 도형을 그리는 도중에 faces
가 undefined 되는 상황은 상정하지 않았기 때문에 서둘러 살펴보기 시작했습니다.
센트리(Sentry) : 센트리는 오류 모니터링, 애플리케이션 릴리스별로 상태를 추적하는 기능 등을 제공하는 서비스입니다.
조사 과정
슈퍼브 라벨에서 폴리곤 도형을 그릴 때, 두 가지 컴포넌트가 렌더링 됩니다. PolygonDrawing
과 AppBarPolygonDrawingController
인데요. PolygonDrawing
은 canvas
를 덮는 투명한 div
로 만들어진 이벤트 패널을 렌더링하여 마우스 포인터에서 발생하는 이벤트를 수집합니다. 또한 마우스 포인터 입력으로 생성되는 좌표 정보에 따라 점, 선, 면을 렌더링합니다.
AppbarPoloygonDrawingController
는 현재 그려지고 있는 폴리곤의 상태를 사용자가 제어할 수 있도록 하는 컴포넌트인데요. 그려지고 있는 폴리곤을 완성된 형태로 바꾸기 위해 PolygonDrawing
의 faces
라는 점 좌표들의 배열을 의존 하게 됩니다.
const faces: Face[] = [[[{point}, ... , {point}]], ... ,[[{point}, ... {point}]]]
바로 이 faces
의 값이 undefined
되는 버그가 발생하게 된 것이죠.
하지만 버그가 발생한 컴포넌트 AppBarPolygonDrawingController
의 호출부를 조사한 결과, 모든 곳에서faces
값을 명시적으로 전달하고 있었습니다. 최소한 점이 없는 상황에도 빈 배열을 할당하고 있었는데요.
또한 faces
가 undefined
라면, 같은 상태를 공유하는 PolygonDrawing
컴포넌트 역시 undefined
값이 되어 버그가 발생했어야 한다고 생각했습니다. 오히려 faces
의 흐름은 PolygonDrawing
에서AppBarPolygonDrawingController
로 향하기 때문에 버그는 PolygonDrawing
에서 먼저 발생하여야 한다고 본 것이죠. 요약하자면 undefined 값이 들어오는 경우를 상정하지 않은 상태가 undefined 값을 갖게 되어 발생한 버그입니다!
추가 조사
- 에러 발생 당시 어떤
mode
일까?
배경지식: mode
란?
슈퍼브 라벨의 어노테이션 앱은 사용자가 어떤 행동을 하고 있는지에 대한 상태 mode
를 가지게 됩니다. mode
는 canvas
에서 어떤 상호작용을 할지에 따라 다음과 같이 크게 3가지 범주의 상태로 나타낼 수 있습니다.
- 도형 그리기 모드(
Drawing
) by Drawing.tsx (이 글에서는 PolygonDrawing에 해당하겠죠💁) - 도형 선택하기 모드(
Selection
) by Selection.tsx - 이슈 생성하기 모드(
IssueCreating
) by IssueCreating.tsx
mode
의 대략적인 구현은 다음과 같습니다.
type Mode<State = {}> = {
// State: PolygonDrawing | Selection | IssueCreating
(state: State): ReactElement | null;
modeName: string;
};
type ModeElement<State = {}> = {
mode: Mode<State>;
state: State; // PolygonDrawing의 경우, 각 점들의 좌표 혹은 점 간의 연결 관계를 저장
};
const [mode, setMode] = useState<ModeElement<unknown> | null>({
mode: Selection, // 기본적으로는 도형을 선택할 수 있는 Selection모드로 설정되어있습니다
state: {},
} as ModeElement<unknown>);
사용자가 선택한 행동에 따라 세 가지 중 하나의 mode
가 렌더링되는 것이죠.
Drawing
Selection
IssueCreating
일단 센트리를 확인하여 버그 내용을 더 조사해 보기로 했는데요. faces
의 값이 갑자기 undefined
되는 현상에 대해 생각해 볼 수 있는 유일한 경우는, 폴리곤을 그리는 도중에 비정상적인 방법으로 PolygonDrawing
이 화면에서 제거(unmount)되고, Selection
모드로 전환되는 것이었습니다. 가설을 확인하기 위해 버그가 발생하는 곳에 에러 로그를 추가하고 배포하여, 에러가 발생할 당시 어떤 mode
인지 확인하기로 했습니다.
// AppBarPolygonDrawingController.tsx
const points = useMemo(() => {
if (!faces) {
throw new Error(`faces is undefined\\nfaces:${faces}\\nmode:${mode.mode}\\nstate:${mode.state}`);
}
return faces.flat().flat();
}, [faces]);
에러 로그의 소스맵을 확인해 보니 Selection.tsx
의 코드가 찍히고 있었습니다. 이로써 Selection
모드가 의도치 않게 활성화되고 있다는 것이 확실하게 되었습니다. 위에서 세운 가설이 맞았던 것이죠.
요약하면 의도된 순서대로 각 mode
의 전환이 일어나지 않았고, 그에 따라 mode
를 담당하는 컴포넌트들의 렌더링이 정상적으로 작동하지 않아 발생한 버그였던 것입니다.
- 사용자는 어떤 행동을 했을까?
그렇다면 사용자들이 어떤 행동을 취했길래 mode
의 전환이 우리의 예상을 벗어났던 것일까요?
버그가 발생할 당시 이벤트 로그 또한 센트리에서 확인할 수 있는데요. 버그 8건의 이벤트를 확인해 본 결과, canvas
, span
, textarea
태그를 사용하는 컴포넌트를 클릭한 후, 버그가 발생했음을 확인했습니다. 슈퍼브 라벨의 어노테이션 앱에서 textarea
를 사용하는 곳은, 이슈를 생성하는 mode
를 담당하는 IssueCreating
컴포넌트가 유일하기 때문에 버그 발생 지점을 좁혀볼 수 있었습니다.
버그 판단 및 재현
추가 조사를 통해 확인한 사실은 아래와 같습니다.
- 도형을 그리는
Drawing
모드에서Selection
모드가 비정상적으로 활성화 - 이슈를 생성한 직후 발생
위의 두 가지 사실에서 3가지 mode
가 모두 등장하고 있죠. 저희가 상정한 3가지 mode
의 순차적인 흐름은 다음과 같습니다.
- 이슈를 생성한다(
IssueCreating
) - 선택 모드로 들어선다(
Selection
) - 도형을 그린다(
Drawing
)
그리고 이 순서가 꼬였기 때문에 버그가 발생한다는 것을 앞선 조사로 유추할 수 있었습니다.
이슈 생성 함수를 확인했을 때, 비동기로 작동하고 마지막에는 Selection
모드로 전환하는 코드가 있었는데요. 비동기 코드 때문에 저희가 의도한 순서대로 모드가 활성화되지 않았던 것입니다.
이로써 Selection
모드가 비정상적으로 활성화된 원인을 비동기 코드로 특정할 수 있었습니다.
// IssueCreating에서 이슈를 생성하는 함수
const handleClickIssuePost = async () => {
const res = await createIssueThreadMutation.mutateAsync({ ... });
await refetchIssueThreads({ projectId, labelId });
...
// Selection 모드로 전환하는 함수
setMoldModeToSelection();
};
버그 재현을 위해 이슈를 하나 생성(IssueCreating
)하여 비동기 작업이 끝나기 직전에 Drawing
모드로 전환해 보았는데요. 아래 화면과 같이 버그가 재현되었습니다.
그렇다면 버그 재현 시, mode
의 변화 과정을 다음과 같이 정리해 볼 수 있습니다.
IssueCreating=(비동기 작업 중)=> Drawing=(비동기 작업 완료)=> Selection
원래 의도한 흐름은 다음과 같습니다.
IssueCreating => Selection => Drawing
결론
두 개의 단서는 연결되었지만, 어딘가 이상하다고 생각했습니다. 어찌 됐든 Drawing
모드에서 Selection
모드로 전환되면 PolygonDrawing
, AppBarPolygonDrawingController
둘 다 제거(unmount) 될 텐데, undefined
에러가 발생하는 이유가 무엇이었을지 다시 생각해 봤습니다.
다시 한번 AppBarPolygonDrawingController
의 호출부를 확인하고 유레카를 외쳤는데요.
if (task.type === 'CATEGORIZATION') {
} else {
if (task.content.annotationType === 'polygon') {
return <AppBarPolygonDrawingController />;
} else if (task.content.annotationType === 'polyline') {
return <AppBarPolylineDrawingController />;
} else if (task.content.annotationType === 'keypoint') {
return <AppBarKeypointDrawingController />;
...
else {
return null;
}
}
AppBarPolygonDrawingController
의 렌더링은 mode
(PolygonDrawing
)에 의존하지 않고, 다른 상태인 task
라는 상태에 의존하고 있었습니다.
faces
의 흐름은 PolygonDrawing
⇒ AppBarPolygonDrawingController
로 향하기 때문에 PolygonDrawing
이 화면에서 제거될(unmount) 경우 AppBarPolygonDrawingController
또한 함께 화면에서 제거(unmount)되어야 합니다.
여태 버그가 발생하지 않고 다른 mode
로 정상적으로 전환할 수 있었던 이유는, 비슷한 역할을 하는 task
라는 상태 덕분이었는데요. task
는 간단히 설명하자면, 좌측 사이드바의 어느 기능을 선택했는지에 대한 상태입니다.
task의 상태에 따라 Drawing
모드가 활성화됩니다. 그렇다면 각 상태 간의 의존성은 다음과 같이 되어야 합니다.
task 선택 -> mode 활성화 -> AppBarController 렌더링
지금까지는 다음과 같은 병렬적인 의존성을 갖고 있었는데요.
task 선택 ㅜ-> mode 활성화
ㄴ-> AppBarController 렌더링
이슈 생성 작업은 mode
전환까지는 책임을 지고 있었지만, task
전환에 대한 책임은 지고 있지 않았습니다. mode
는 Drawing
에서 Selection
으로 변화하여도 task
의 값은 변화하지 않았기 때문에 AppBarController
는 언마운트 되지 않고 살아남아, 사라져 버린 faces
를 계속 찾아 헤매고 있었던 것이죠.
버그를 해결하기 위해 AppBarController
의 렌더링 조건에 mode
에 의존하는 조건을 추가하게 되었습니다.
if (task.type === 'CATEGORIZATION') {
} else {
if (task.content.annotationType === 'polygon' && mode?.mode.modeName === POLYGON_DRAWING_MODE_NAME) {
return <AppBarPolygonDrawingController />;
} else if (task.content.annotationType === 'polyline' && mode?.mode.modeName === POLYLINE_DRAWING_MODE_NAME) {
return <AppBarPolylineDrawingController />;
} else if (task.content.annotationType === 'keypoint' && mode?.mode.modeName === KEYPOINT_DRAWING_MODE_NAME) {
return <AppBarKeypointDrawingController />;
...
} else {
return null;
}
}
마무리
지금 당장은 버그를 해결하기 위해 단순한 방법을 썼지만, 다음에도 이런 일이 충분히 발생할 수 있다고 생각합니다. 이를 해결하기 위한 핵심은 각 FSM(Finite State Machine)상태 간의 직교성(orthogonality)을 보장하는 것인데요. 이처럼 버그 수정 이후, 기능에 대한 리팩터링을 진행하는 과정에 대한 글도 써보면 좋을 것 같다고 생각하게 되었습니다. 이후 이어질 테크 블로그 시리즈도 많은 관심 부탁드립니다!