카카오뱅크 알림탭 시스템에서 발생한 동시성 문제를 해결한 경험을 다룬 글입니다. ShardingSphere 라이브러리로 인해 발생한 문제를 분석하고, 이를 재현 및 해결한 과정을 상세히 담았습니다. 또한, 핵심 라이브러리 관리의 중요성과 새롭게 도입한 관리 방법에 대해 설명합니다. 알림탭 시스템의 비정상적인 동작과 그 원인을 파악하는 여정을 다룬 만큼, 백엔드 개발과 시스템 안정성에 관심 있는 분들은 꼭 읽어보세요!

안녕하세요, 카카오뱅크 서버 개발자 Finn입니다. 이 글은 2020년 팀 내에서 공유한 자료를 재구성한 것으로, 그 당시 저는 카카오뱅크 알림탭 시스템을 개발 및 운영하고 있었습니다. 그런데 어느 날, 동료로부터 제가 담당하던 알림탭 서비스가 이상 동작을 보이는 기이한 현상을 제보 받았습니다. 👻

이 글에서는 그 당시 알림탭 시스템에서 일어난 마치 귀신 같은 현상과 그 원인을 분석하는 과정을 공유하고자 합니다. 이 글을 통해 저는 오류가 어디서부터 발생했는지 감이 잡히지 않을 때, 어떤 방식으로 문제를 정의하고 오류의 원인을 해결해 나가는지를 보여드리고자 합니다. 특히, 중간에 등장하는 ‘바닐라 아이스크림 알러지가 있는 자동차 이야기’와 제 경험을 통해 여러분도 비슷한 문제를 해결하는 데 도움이 되었으면 합니다.

그럼 그 당시 알림탭 시스템이 무엇인지부터 시작하겠습니다.

알림탭 시스템이란?

카카오뱅크 앱을 들어가보시면, 우측 상단에 위치한 ‘종 모양의 아이콘(🔔)’ 보이시죠? 해당 아이콘을 클릭하면, 나의 카카오뱅크 계좌와 이용하는 금융 서비스들에 대한 최신 정보를 확인 가능합니다. 특히 알림탭은 고객들에게 계좌 입출금 정보, 각종 혜택 등 유용한 정보를 제공하여 매우 유용한 서비스라고 할 수 있습니다. 이런 알림탭 시스템 구축에 대한 자세한 설명은 같은 팀 동료였던 Mason이 지난 if(kakao)2022 기술 컨퍼런스에서 발표한 알림 서비스로 시작하는 서버 개발에서도 확인하실 수 있습니다.

1-abount-alarmtab-image
[그림 1] 카카오뱅크 앱 내 알림탭 화면 (알림탭 아이콘이 우측 하단에 위치해 있었던 2020년 UI 예시)

읽기와 쓰기 비율이 8:2 정도인 일반적인 대고객 서비스 시스템과 달리, 알림탭 시스템은 읽기와 쓰기 비율이 2:8 정도로, 쓰기 비율이 압도적으로 높은 특징을 가지고 있었습니다. 이러한 데이터베이스 워크로드(Database Workload) 요구 사항을 충족하기 위한 방법 중 하나로, 저희는 데이터베이스 샤딩(Database Sharding)을 사용하고 있습니다.

카카오뱅크 알림탭 서비스는 샤딩된 데이터베이스를 지원하는 여러 오픈소스 라이브러리 중 Apache 재단에 속한 샤딩스피어(ShardingSphere) 오픈소스 라이브러리를 사용하고 있었습니다. 그런데 어느 날, 샤딩스피어 라이브러리를 잘 사용하던 알림탭 시스템에서 이상 현상이 발생합니다. 정확히 어떤 현상이었는지 이어서 설명드리겠습니다.

어느 날 동료에게 받은 제보

주말을 보내고 평소와 같이 출근했던 월요일, 한 동료의 메시지로 인해 팀 전체가 분주해졌습니다.

2-coworker-warning-image
[그림 2] 알림탭 화면의 알림이 잘못 노출된다는 동료의 제보

APM(Application Performance Monitoring) 도구로 그 이상 현상을 면밀히 살펴본 결과, 동료가 공유한 현상 외에도 추가로 몇 가지 현상을 발견했습니다. 이를 정리해보면, 다음의 3가지 정도로 문제 현상을 요약할 수 있습니다.

1. 가끔씩 알림 메시지가 하나만 조회되는 현상
2. 특정 서버에서 모든 샤드에 질의하는 현상
3. 특정 서버에서 엉뚱한 샤드에 질의하는 현상

저희 입장에서는 알림 메시지가 계속 사라지는 것이 좀 의아했습니다. 그 많던 메시지는 누가 다 먹어버린 걸까요?

3-illegal-state-alarmtab-image
[그림 3] 새로고침 마다 다르게 보이는 알림탭 속 알림 내역 예시

종합해 보자면, 새로고침을 할 때마다 간헐적으로 사용자의 알림탭 속에서 알림이 누락되거나 시스템 성능에 영향을 주는 현상이 발생했습니다. 이러한 감쪽같이 사라지는 메시지를 찾기 위해 저희는 여러 가지 시도를 해보기 시작했습니다.

문제의 원인을 찾아서

문제 해결의 반은 재현이라는 말이 있습니다. 다시 말해, 문제가 발생한 상황을 정확히 재현할 수 있어야만 효과적인 디버깅이 가능하다는 의미입니다.

동료에게 이상 현상을 공유받았을 때, 즉각적으로 개발 환경에서도 동일한 현상을 재현하기 위해 여러 시도를 했습니다. 그러나 쉽게 재현되지 않았습니다. 이에 따라 사용자 요청의 진입점부터 애플리케이션의 로직과 DB Connection을 포함한 각종 인프라와의 연동 지점까지 다양한 로그를 추가하고 이를 샅샅이 살펴보았습니다. 그렇게 각 구성 요소를 세밀하게 점검하며, 문제가 발생할 가능성을 하나씩 배제해 나갔습니다.

그러나 로그와 각 구성 요소에서 특이점을 발견할 수 없었고, 시스템은 정상적으로 작동하는 것처럼 보였습니다. 개발자들이 흔히 겪는 상황처럼 개발 환경에서는 재현이 불가능했지만, 운영 환경에서 드물게 재현될 수 있는 일시적인 문제로 보였습니다. 이처럼 원인을 명확히 알 수 없는 상황에서 문제 해결은 어려웠고, 마치 귀신 같은 일이라고 생각하던 중 ‘바닐라 아이스크림 알러지가 있는 자동차 이야기’가 떠올랐습니다.

🍦 바닐라 아이스크림과 자동차

혹시 바닐라 아이스크림 알러지가 있는 자동차 이야기를 들어보신 적 있으신가요?

4-vanila-icecream-allergic-car-image
[그림 4] 바닐라 아이스크림을 사는 날이면 시동이 걸리지 않았던 자동차 이미지

이 이야기에 따르면, 어느 날 미국의 대표적인 자동차 제조사인 GM(General Motors)사에 아래와 같은 불만이 접수됐습니다.

  • • 불만 제보자는 아이스크림을 사러 종종 차를 타고 방문했습니다.
  • • 딸기 혹은 초콜릿 아이스크림을 사는 날이면 차에 시동을 거는데 문제가 없었고 잘 돌아올 수 있었습니다.
  • • 반면, 바닐라 아이스크림을 사는 날이면 차에 시동이 걸리지 않았습니다.

이 현상만 놓고 보자면, 마치 자동차가 바닐라 아이스크림 알레르기라도 있는 듯합니다. 하지만 진짜 원인을 들여다보면, 아이스크림 가게의 상품 배치로 인해 딸기나 초콜릿보다 바닐라 아이스크림을 더 빨리 구매할 수 있었고, 그렇기 때문에 바닐라 아이스크림을 구매한 날에는 자동차 엔진이 충분히 식지 않아 시동이 바로 걸리지 않았던 것입니다. 이 이야기를 통해 저는 ‘겉으로 보이는 현상 너머로 원인이 있음을 짐작할 수 있어야 진정한 원인을 밝혀낼 수 있다’는 점을 다시 한번 되새길 수 있었습니다.

그러다 문득 시스템 오픈 후 시간이 지남에 따라 점진적으로 요청량이 증가해온 지표가 눈에 들어왔습니다. 이는 문제의 중요한 단서였고, 저희는 ‘동시성 문제’를 의심하지 않을 수 없었습니다. 이 의심을 해소하기 위해 개발 환경에서 성능 테스트 도구를 사용하여 운영 환경과 유사한 트래픽을 인위적으로 발생시켜 보기로 했습니다. 부하 테스트 도구를 활용하여 다양한 시나리오를 설정했고, 이를 통해 운영 환경에서 발생할 수 있는 다양한 트래픽 조건을 재현해 보기 위해 노력했습니다. 그렇게 마침내 개발 환경에서 앞서 동료가 발견했던 그 문제의 현상을 재현하는 데 성공했습니다.

📁 범인은 바로 라이브러리!

문제의 원인은 생각보다 가까이 있었습니다. 믿었던 핵심 라이브러리에서 문제가 발생한 것으로, 바로 샤딩스피어 라이브러리 코드의 일부 문제였습니다. 문제 상황을 더 잘 이해하기 위해 샤딩스피어에 대해 간단히 소개해 드리겠습니다.

샤딩스피어는 Apache 재단의 오픈소스 프로젝트로, 데이터베이스의 분산 처리를 위한 샤딩, 복제, 고가용성을 제공하며 분산 트랜잭션 등을 다루기 위해 설계되었습니다. 샤딩스피어 내에도 다양한 컴포넌트가 있으며, 저희는 그중 ShardingSphere-JDBC라는 컴포넌트를 사용했습니다.

ShardingSphere-JDBC가 적용된 알림탭 시스템에서는 데이터베이스 샤딩에 관한 구체적인 코드를 직접 구현하지 않으면서도 설정을 통해 손쉽게 각 샤드에 데이터를 영속화하고 조회할 수 있었습니다. 결과적으로 개발자는 비즈니스 코드와 샤딩 관련 코드가 구분되어 있어, 비즈니스 코드에 더 집중할 수 있었습니다.

5-shardingsphere-jdbc-image
[그림 5] 샤딩스피어 JDBC 소개

참고로 이 글을 작성하는 시점의 샤딩스피어 최신 버전은 5.5.1 이지만, 2020년 당시 알림탭 시스템은 3.x 버전이 적용되어 있었습니다.

문제의 원인

앞서 공유드린 3가지 문제 현상을 여러 번 재현해보며 직접 찾은 문제별 원인을 하나씩 설명드리겠습니다.

문제 1. 가끔씩 알림 메시지가 하나만 조회되는 현상

샤딩스피어는 SQL문에서 샤딩 컨텍스트를 추출하고 이를 재작성하기 위해 구문을 분석하여 추상 구문 트리(Abstract Syntax Tree, 이하 구문 트리)로 변환하여 처리합니다. 이 과정에서 ANTLRAST 같은 개념이 등장하지만, 이 글에서는 중요한 부분은 아니므로 자세한 설명은 생략하겠습니다.

아래 SQL문을 바탕으로 [그림 6]의 구문 트리를 만들어보면 다음과 같습니다.

SELECT id, name FROM t_user WHERE status = 'ACTIVE' AND age > 18

6-shardingsphere-ast-image
[그림 6] SQL문을 구문 트리로 표현한 도식

파싱 비용을 줄이고 효율성을 높이기 위해, 한 번 분석한 SQL문의 구문 트리는 로컬 캐싱된 후 싱글톤 객체로 재사용됩니다. 로컬 캐시를 통해 구문 트리를 조회하고 처리하는 의사 코드는 다음과 같습니다.

String sql = "SELECT * FROM message WHERE status = ? LIMIT ?";
SQLStatement cachedSQLStatement = parsingResultCache.get(sql);

// LIMIT, OFFSET 값 연산 및 바인딩
processLimit(cachedSQLStatement);

// Where 절의 변수 바인딩
setParameterForStatements(cachedSQLStatement);

다시 문제로 돌아가면,

  • Thread 1: 알림 메뉴에서 목록을 노출하기 위한 메시지 목록을 조회하는 쿼리 (LIMIT 30)
  • Thread 2: 최근 메시지 존재 여부를 확인하기 위해 메시지를 조회하는 쿼리 (LIMIT 1)

이 두 SQL문은 LIMIT 조건만 제외하면 동일했기 때문에, 같은 구문 트리를 캐시에서 가져왔습니다. 또한, 이 SQL문들은 시스템에서 빈번히 사용되었기 때문에, 경쟁 상태(Race Condition)가 발생할 가능성이 높았습니다.

7-promblem1-data-race-image
[그림 7] Thread 1와 Thread 2의 경쟁 상태

[그림 7]을 설명드리면 다음과 같습니다.

  1. 여러 스레드가 동일한 구문 트리를 사용하여 LIMIT와 WHERE 조건을 처리합니다.
  2. Thread 1, Thread 2의 진입 순서에 따라 경쟁 상태가 발생할 수 있습니다.
    a. LIMIT 1 조건이 30으로 처리되는 상황
    b. LIMIT 30 조건이 1으로 처리되는 상황

2-a의 경우 사용자 경험에 큰 문제가 발생하지 않지만, 2-b의 경우 알림 메시지가 가끔 1개만 노출되는 현상이 발생하게 됩니다.

문제 2. 특정 서버에서 모든 샤드에 질의하는 현상

아래 [그림 8]을 통해서 문제 2번 현상을 설명드리겠습니다.

기대 동작은 쿼리 조건에 샤드 키가 포함되어 그 키를 통해 대상 샤드를 올바르게 판단하고 질의하는 것이었습니다. 그러나 특정 서버에서 모든 샤드를 대상으로 쿼리를 실행하고 있었습니다. 즉, getConnection()preparedStatement()가 원래는 특정 샤드에서만 실행되어야 하는데, 모든 샤드의 커넥션을 얻고 쿼리를 실행했던 것입니다. 즉, 정상적인 상황에서는 단 건만 발생해야 하지만, 실제로는 복수 건이 발생한 것입니다.

8-illegal-query-shard-image
[그림 8] APM을 통해 확인한 모든 샤드에 질의 현상

이 문제는 문제 1 현상과도 연관이 있습니다. 앞서 SQL문을 분석하여 구문 트리를 만든다고 했는데, 이 트리의 각 노드는 SQLSegment라는 타입으로 구성되어 있습니다. 그 중에서도 FromWhereSegment는 조금 특별한데, 바로 이 타입이 쿼리 대상 샤드 후보 테이블 목록 정보를 가지고 있기 때문입니다.

public final class FromWhereSegment implements SQLSegment {
	private final Map<String, String> tableAliases = new HashMap<>();
	private final OrConditionSegment conditions = new OrConditionSegment();
	private final Collection<SubquerySegment> subQueries = new LinkedList<>();
	private int parameterCount;
}

그런데 구문 트리에서 FromWhereSegment를 추출하는 코드에서 의심스러운 점이 있습니다.

public final class FromWhereExtractor implements OptionalSQLSegmentExtractor {
	private PredicateExtractor predicateSegmentExtractor; // 공유 멤버 변수

	public Optional<FromWhereSegment> extract(final ParserRuleContext ancestorNode, final ParserRuleContext rootNode) {
		// 중략
		// 메서드 내에서 공유 멤버 변수 predicateSegmentExtractor를 신규 객체로 생성
		FromWhereSegment result = new FromWhereSegment();
		predicateSegmentExtractor = new PredicateExtractor(result.getTableAliases());
	}

	private Optional<OrConditionSegment> buildCondition(final ParserRuleContext node, final Map<ParserRuleContext, Integer> questionNodeIndexMap) {
		// 공유 멤버 변수 predicateSegmentExtractor의 메서드 호출
		Optional<ParserRuleContext> exprNode = ExtractorUtils.findFirstChildNode(node, RuleName.EXPR);
		return exprNode.isPresent() ? predicateSegmentExtractor.extractCondition(questionNodeIndexMap, exprNode.get()) : Optional.<OrConditionSegment>absent();
    }
}

바로 FromWhereExtractor는 싱글톤 객체인데, predicateSegmentExtractor 멤버 변수를 공유한다는 것입니다. 이 경우, 아래와 같은 의도되지 않은 상황이 전개될 수 있습니다.

  • • 두 개 이상의 스레드에서 동시에 FromWhereExtractor::extract()에 진입
  • • 나중에 진입한 스레드가 extract() 내부에서 멤버 변수인 predicateSegmentExtractor를 재초기화
  • • 먼저 진입한 스레드가 초기화된 predicateSegmentExtractorbuildCondition()으로 잘못된 Segment 생성

결국 FromWhereSegment를 통해 샤드 후보를 제대로 파악할 수 없으므로 모든 샤드에 쿼리를 실행하게 됐습니다. 더불어 구문 트리를 재사용하다 보니 한번 잘못 캐싱된다면 문제가 반복해서 발생했던 것입니다.

문제 3. 특정 서버에서 엉뚱한 샤드에 질의하는 현상

문제 3 역시 위의 문제 1,2처럼 ‘경쟁 상태’ 때문에 발생한 문제 현상입니다. 하나의 트랜잭션에서 다음과 같은 SQL이 순차적으로 실행되고 있었습니다.

1. INSERT INTO message (id, {...}) values ('message-id', {...})
2. SELECT * FROM message WHERE id = 'message-id'

신규 message를 생성한 후, 다시 조회해 일부 속성을 UPDATE하려 했지만

3. UPDATE message SET({...}) WHERE id = 'message-id'

실제로 일어난 일은 INSERT였습니다. 😱

3. INSERT INTO message (id, {...}) values ('message-id', {...})

알림탭 시스템에서는 JPA(Java Persistence API)를 사용하고 있습니다. 2번 질의 결과가 없었기에 기대한 UPDATE 대신 INSERT를 실행했지만, 이번에는 DuplicateKeyException이 발생했습니다. 엉뚱한 샤드에서 질의했기 때문입니다.

1. INSERT INTO message (id, {...}) values ('message-id', {...}) # 1 샤드에서 실행
2. SELECT * FROM message WHERE id = 'message-id' # 2 샤드에서 실행
3. INSERT INTO message (id, {...}) values ('message-id', {...}) # 1 샤드에서 실행. 1번에서 이미 Insert 했기에 예외발생.

엉뚱한 샤드에 질의하게 된 것도 경쟁 상태가 원인이었습니다.

public final class ParsingRuleRegistry {
	private static volatile ParsingRuleRegistry instance;

	public static ParsingRuleRegistry getInstance() {
		if (null == instance) {
			synchronized (ParsingRuleRegistry.class) {
				if (null == instance) {
					instance = new ParsingRuleRegistry();
					instance.init();
				}
			}
		}
		return instance;
	}
}

ParsingRuleRegistry 클래스는 SQL문 분석 규칙을 관리하는 싱글톤 클래스입니다. 이 클래스는 데이터베이스 유형에 따른 SQL문 규칙과 SQL 세그먼트에 대한 규칙을 로드하고 저장하는 역할을 합니다. getInstance() 구현을 살펴보면 멀티 스레드 환경에서 지연 초기화를 위해 Double-Checked Locking 패턴을 잘 구현한 것처럼 보입니다.

하지만 함정이 하나 있습니다. 바로 new 연산자를 통해 초기화되는 부분과 init() 호출을 통해 초기화되는 부분이 분리되어 있는 점입니다. 이로 인해 나중에 진입한 스레드는 아직 초기화되지 않은 상태의 ParsingRuleRegistry를 반환받게 됩니다. ParsingRuleRegistry는 SQL문의 파싱과 재작성에 관여하는 중요한 객체로, 잘못된 상태에서 사용되면서 문제 3과 같이 특정 서버에서 엉뚱한 샤드로 쿼리를 실행하는 현상이 발생합니다.

문제 해결하기

이러한 문제 상황은 당장 알림 서비스를 이용하는 고객들의 사용자 경험에 영향을 줄 수 있기 때문에, 빠르게 문제를 해결할 방안을 마련해야 했습니다. 위에서 설명드린 3개의 문제 현상들 모두 ‘경쟁 상태’가 원인이었지만, 라이브러리 코드를 직접 수정해서 사용하는 것은 또 다른 부작용이 우려되었습니다. 따라서 가장 빠른 해결 방법은 경쟁 상태를 제거하는 것이었습니다.

9-promblem1-data-race-resolved-image
[그림 9] 경쟁 상태 제거를 통한 문제 해결

해결책1. SQL문 대체를 통한 경쟁 상태 해소

문제 1과 2는 동일한 SQL문을 LIMIT 조건만 다르게 설정하여 사용함으로써 발생한 문제였습니다. 구체적으로, 두 SQL문은 LIMIT 30과 LIMIT 1로 서로 다른 조건을 가지면서도 동일한 구문 트리를 공유하여, 경쟁 상태에 의해 LIMIT 조건이 잘못 처리되는 문제가 발생했습니다.

다행스럽게도, LIMIT 1 조건을 가진 SQL문을 다른 방식으로 쉽게 대체할 수 있었습니다. 이를 통해 동일한 구문 트리를 사용하지 않도록 함으로써 경쟁 상태를 근본적으로 제거할 수 있었고, 이로 인해 두 문제를 모두 효과적으로 해결할 수 있었습니다.

해결책 2. 임계영역 진입 순서 보장을 통한 경쟁 상태 해소

이 역시 경쟁 상태를 제거해야 했지만 이전 해결책과 다른 접근이 필요했습니다. ParsingRuleRegistry를 비롯한 SQL 파싱에 관여하는 주요 객체들은 SQL 실행 시점에 지연 초기화 방식으로 동작합니다. 따라서 동일한 SQL문이 동시에 파싱되지 않도록 보장한다면, 제대로 초기화된 ParsingRuleRegistry를 통해 구문 분석이 가능할 것이라고 예상했습니다.

이를 위해 애플리케이션 부트스트랩 직후, 경쟁 상태 가능성이 높은 SQL문을 Warm-Up 코드를 통해 경쟁 없이 순차적으로 실행시키는 방식을 취했습니다. 이렇게 함으로써 ParsingRuleRegistry의 초기화를 보장할 수 있었으며, 올바르게 SQL문을 구문 분석하고 캐싱할 수 있었습니다. 이러한 접근을 통해 문제 3의 현상도 해결할 수 있었습니다.

@Service
public class ShardingSphereWorkaroundService {
		@PostConstruct
		public void warmUp() {
			repository.doSomething1();
			repository.doSomething2();
		}
}

핵심 라이브러리 관리를 위한 프로세스 도입

문제의 Root Cause는 단순해 보입니다. 결국 라이브러리가 원인이었고, 멀티스레드 환경에서 공유 자원을 사용할 때 발생하는 잘 알려진 Anti-Pattern 문제였습니다. 그런데 원인을 찾는 과정이 왜 이렇게 어렵게 느껴졌는지 돌아보니, 아래 두 가지 이유 때문이었습니다.

  • • 간헐적으로, 일부 서버에서만 발생하는 동시성 문제를 일시적인 현상으로 간주
  • • 사내 여러 시스템에서 사용 중인 핵심 라이브러리를 향한 지나친 신뢰

원인 파악 후 이슈 레포트를 차근차근 살펴보니, 이 문제들은 이미 제보되어 메이저 버전 업데이트에서 해결되었음을 알게 되었습니다. 그러나 시스템 핵심 라이브러리의 메이저 버전 업그레이드는 언제나 부담스러운 일입니다. 시스템이 기존과 동일하게 동작하는지 검토하고 검증해야 할 부분이 많기 때문입니다.

따라서 라이브러리 버전을 업그레이드하는 대신, 앞서 설명한 임시 해결책을 채택했습니다. 다행히 사내 다른 시스템은 이미 문제가 해결된 버전을 사용하고 있었습니다. 이 일을 계기로, 시스템의 핵심 라이브러리 역시 시스템에 직접 작성된 코드와 유기적으로 관리될 필요가 있다는 판단을 하게 되었습니다. 그렇게 다음과 같은 규칙을 만들었고 이를 팀 내에 적용해 보았습니다.

  1. Github에서 관리되는 라이브러리는 새로운 릴리즈 소식을 팀 채널로 Atom RSS 구독 (참고: REST API endpoints for feeds)
  2. 팀 내에서 라이브러리별 담당자(Owner)를 지정하고, 이 담당자가 주요 변경사항을 검토
  3. 메이저, 마이너, 패치 버전별 반영 규칙을 합의한 ADR(Architecture Decision Records)을 작성

10-rss-library-release
[그림 10] Github Release 소식을 팀 채널로 RSS 구독

[그림 10]에서 처럼 RSS를 통해 시스템의 핵심 라이브러리를 지속적으로 모니터링하고 관리할 수 있도록 하였으며, 중요한 업데이트가 있을 경우 신속하게 반영할 수 있도록 체계를 마련했습니다. 이러한 규칙을 통해 앞으로도 시스템의 안정성과 신뢰성을 유지할 수 있을 것으로 기대됩니다.

마치며

이 블로그에서도 여러 번 인용되었던 로버트 C. 마틴(Robert C. Martin)의 저서 <클린 코드>에도 스레드 코드 오류에 대한 구절이 나옵니다.

흔히 스레드 코드는 오류를 찾기가 쉽지 않다. 간단한 테스트로는 버그가 드러나지 않는다. 아니, 대개 일상적인 상황에서는 아무 문제도 없다. 몇 시간, 며칠, 혹은 몇 주가 지나서야 한 번씩 모습을 드러낸다. (중략) 초반에 드러나지 않는 문제는 일회성으로 치부해 무시하기 십상이다. 소위 일회성 문제는 대개 시스템에 부하가 걸릴 때나 아니면 뜬금없이 발생한다.
- Robert C. Martin, Clean Code

이처럼 알림탭 시스템에서 발생한 문제는 평소와 같이 아무런 이상이 없는 상황에서 간헐적으로 발생하는, 마치 눈에 보이지 않는 귀신 같은 문제였습니다. 이러한 문제를 해결하는 과정에서 다시 한번 ‘엔지니어링 문제에서 원인 없는 결과는 없다‘는 것을 깨달을 수 있었습니다. 앞서 설명드린 ‘바닐라 아이스크림 알러지가 있는 자동차 이야기’에서 얻을 수 있는 교훈처럼, 겉으로 보기에는 이성적으로 설명되지 않는 신기한 현상도, 알고 보면 근본 원인이 있음을 상기시켜 주었습니다.

이 글이 제가 겪은 현상과 비슷한 문제를 겪고 있는 분들에게 도움이 되기를 바라며, 엔지니어링 문제 해결의 핵심은 문제의 근본 원인을 찾는 집요한 노력에 있다는 것을 강조하고 싶습니다. 오늘도 다양한 문제를 해결해 나가고 있는 개발자 여러분, 화이팅입니다!