들어가며
이번 글은 Superb AI의 Front-End Engineer가 버그를 발견하고 어떤 과정으로 해결해 가는지 공유해 보려고 합니다. 버그가 발생하면 어떤 과정을 거쳐 해결하시나요? 각 회사에서 개발팀의 버그 대응 과정은 비슷할 수 있지만, Superb AI에서는 제일 먼저 이슈 재현 및 파악을 선행합니다. 특히, Superb Label은 많은 고객과 라벨러 분들이 사용하고 있는 서비스이기 때문에 버그 발생 경로 또한 매우 다양한데요. 고객이 비용을 지불하고 사용하는 SaaS 플랫폼을 만드는 Front-End Engineer는 어떻게 버그를 잡는지🐞🔫 살펴보겠습니다.
버그 발견🐞
Superb AI는 Superb Label이라는 제품을 통해 비전 데이터의 라벨링을 더욱 쉽게 할 수 있는 서비스를 제공하고 있습니다. 라벨링 어노테이션을 생성할 때, 가장 많이 사용하는 어노테이션은 박스와 폴리곤일 것 같은데요. 그중 폴리곤을 그리는 과정에서 버그를 발견하게 되었습니다.
이번에 발견하게 된 버그는 폴리곤 도형을 그리는 역할을 담당하는 PolygonDrawing이라는 컴포넌트를 리팩터링한 이후 발생하였는데요. 이 PolygonDrawing 컴포넌트에는 faces라는, 폴리곤을 그리는 도중의 점들의 좌표의 배열 상태가 존재하는데요. 이 상태가 undefined가 되는 버그가 센트리의 에러 모니터링에서 나타나기 시작했습니다. 도형을 그리는 도중에 faces가 undefined 되는 상황은 상정하지 않았기 때문에 서둘러 살펴보기 시작했습니다.
센트리(Sentry) : 센트리는 오류 모니터링, 애플리케이션 릴리스별로 상태를 추적하는 기능 등을 제공하는 서비스입니다.
조사 과정
Superb Label에서 폴리곤 도형을 그릴 때, 두 가지 컴포넌트가 렌더링 됩니다. 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란?
- 도형 그리기 모드(Drawing) by Drawing.tsx (이 글에서는 PolygonDrawing에 해당하겠죠💁)
- 도형 선택하기 모드(Selection) by Selection.tsx
- 이슈 생성하기 모드(IssueCreating) by IssueCreating.tsx
- Drawing
- Selection
- IssueCreating
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>);
- Superb Label의 어노테이션 앱은 사용자가 어떤 행동을 하고 있는지에 대한 상태 mode를 가지게 됩니다. mode는 canvas에서 어떤 상호작용을 할지에 따라 다음과 같이 크게 3가지 범주의 상태로 나타낼 수 있습니다.
일단 센트리를 확인하여 버그 내용을 더 조사해 보기로 했는데요. 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를 담당하는 컴포넌트들의 렌더링이 정상적으로 작동하지 않아 발생한 버그였던 것입니다.
2. 사용자는 어떤 행동을 했을까?
그렇다면 사용자들이 어떤 행동을 취했길래 mode의 전환이 우리의 예상을 벗어났던 것일까요?
버그가 발생할 당시 이벤트 로그 또한 센트리에서 확인할 수 있는데요. 버그 8건의 이벤트를 확인해 본 결과, canvas, span, textarea 태그를 사용하는 컴포넌트를 클릭한 후, 버그가 발생했음을 확인했습니다. Superb Label의 어노테이션 앱에서 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)을 보장하는 것인데요. 이처럼 버그 수정 이후, 기능에 대한 리팩터링을 진행하는 과정에 대한 글도 써보면 좋을 것 같다고 생각하게 되었습니다. 이후 이어질 테크 블로그 시리즈도 많은 관심 부탁드립니다!