crossorigin="anonymous"> $(function(){ $('.article_view').find('table').each(function (idx, el) { $(el).wrap('
') }); $('img[alt="N"]').each(function(){ $(this).replaceWith('

N

') }); });

새소식

고수만/필드에서 써먹은 것들

실서버 장애 회고: DB 서버 변경 하나로 서비스가 죽은 이유

  • -

MySQL timeout 한 번에 .NET 서버가 죽은 이유 (Task vs Timer + 커넥션 풀의 진짜 관계)

 

1. 문제 상황

 

회사 게임 포털 페이지에서 장애가 발생했고, 비즈니스 서버가 죽어 있었다.
로그를 확인해보니 MySQL timeout이 지속적으로 발생하고 있었고, 장애 시점과 정확히 일치했다.

 

상황은 이러하였다. 기존에 연결되있던 특정 게임 db가 별도의 공유 없이 스케일업 작업에 들어갔고
이를 모르던 우리 서비스는 해당 연결이 끊어졌던 것

 

그렇다 하더라도 db가 연결이 안된다고 비즈니스 서버가 전체가 죽는게 말이 되는가?

그래서 원인을 찾기 시작했다.

 

application insight log 와 각 vm에 남은 이벤트 로그 등을 분석하였고

 

 

개발 환경에서는:

System.TimeoutException
CancellationTokenSource.ExecuteCallbackHandlers
TimerQueueTimer.Fire

 

 

운영 환경에서는:

 

Timeout expired prior to obtaining a connection from the pool
Dapper.QueryAsync

 

 

그리고 추가로:

 

An item with the same key has already been added

 

 

의 오류들이 나고 있다는 것을 트러블 슈팅을 하며 알아 냈다.

 

 

2. 처음 했던 오해

 

  • MySQL 서버가 스케일업 중 30분간 다운
  • 요청이 쌓였다가 복구 시 커넥션 경쟁 발생
  • 스레드 부족으로 서버 다운

 

하지만 개발 환경에서 확인해보니:

 

단 1건 요청으로도 서버가 즉시 종료됨

 

 

 

트래픽 문제가 아니라 구조 문제였다.

 

 

3. 핵심 원인

 

 

예외가 발생한 것이 아니라, 예외가 Task로 전달되지 않은 것

 

 

Task vs Timer

 

비동기로 호출 되었지만 문제는 그것이 아니다. 

 

“예외가 Task 안에 있느냐 / 밖에 있느냐”


정상 구조 (MSSQL)

 

비즈니스 서버는 기본적으로 mssql과 dapper로 ORM을 구성하여 사용했는데

이에 사용한 드라이버가 System.Data.SqlClient이다.

주로 .NET에서 사용하는 전용 드라이버다.

 

System.Data.SqlClient (예시)

var tcs = new TaskCompletionSource<T>();

BeginIo(
    result => tcs.SetResult(result),
    error  => tcs.SetException(error)
);

return tcs.Task;

 

흐름

백그라운드 스레드
 → 예외 발생
 → Task.SetException
 → await에서 throw
 → try-catch 처리

 

예외가 발생하여도 항상 Task를 통해 예외 전달됨(중요)

 

 

 

문제 구조 (MySQL)

 

문제는 비즈니스 서버는 mysql을 딱 한곳만 사용하는데 MySql.Data.MySqlClient 로 ORM을 연결하여 구현되어 있었다.

문제는 같은 ORM 래퍼라도 내부 동작이 달랐다.

 

MySql.Data(예시)

var cts = new CancellationTokenSource(timeout);

var connectTask = tcp.ConnectAsync(...);

var winner = await Task.WhenAny(
    connectTask,
    Task.Delay(timeout, cts.Token)
);

if (winner != connectTask)
    throw new MySqlException("Timeout");

 

 

 

4. 진짜 문제: Timer 기반 Cancellation

 

로그의 이 부분이 핵심이다:

CancellationTokenSource.ExecuteCallbackHandlers
TimerQueueTimer.Fire

 

의미:

  • timeout 발생
  • CancellationToken이 Timer 기반으로 실행됨
  • Timer 스레드에서 콜백 실행

 

 

예외가 Task로 예외가 전달되는 것이 아니라 Timer 기반으로 Task 체인 외부에서 콜백으로 실행되다보니

예외가 Task로 핸들링 안되고 직접 throw되어 Unhandled Exception으로 이어진 것이다!

 

 

5. 개발 vs 운영 차이

 

 

개발 환경

요청 1개
→ timeout
→ 바로 Unhandled
→ 💀 즉시 종료

 

“즉사”

 

 

 

운영 환경

요청 다수
→ 모두 DB connect 시도 (hang)
→ 동시에 살아있음
→ 커넥션 반환 안됨
→ 풀 점점 채워짐
→ pool 고갈
→ 추가 오류 발생
→ Unhandled → 💀 종료

 

“누적 후 사망”

 

죽는 건 똑같았으나 운영환경에선 호출이 많았다보니 풀이 먼저 고갈나 

커넥션 풀에 대한 오류도 같이 생겼다

 

 

6. 왜 요청이 많으면 풀 고갈이 생기나

 

여기서 중요한 포인트:

❗ 요청이 많다고 빨리 죽는 게 아니다
👉 동시에 안 끝나고 쌓인다

 

 

 

시간 흐름으로 보면

t=0   요청 50개 시작
t=1   모두 connect 대기
t=2   커넥션 50개 점유
t=3   반환 없음
t=4   pool 꽉 참
t=5   추가 요청 실패
t=6   내부 오류 발생
t=7   💀 종료

 

 

 

 

7. 정리

 

 

구분개발운영

요청 수 1 많음
상태 즉시 종료 동시에 대기
풀 상태 의미 없음 누적 후 고갈
결과 즉사 누적 후 사망

 

 

8. 해결 방법

 

1️⃣ 호출부 try-catch

try
{
    await repo.QueryAsync(...);
}
catch (Exception ex)
{
    Log(ex);
}

 

 

2️⃣ Repository 구조 개선

protected async Task<T> QueryFirstOrDefaultAsync<T>(
    string sql,
    object param = null,
    CommandType commandType = CommandType.Text)
{
    try
    {
        using (var connection = CreateConnection())
        {
            await connection.OpenAsync();
            return await connection.QueryFirstOrDefaultAsync<T>(
                sql, param, commandType: commandType);
        }
    }
    catch (Exception ex)
    {
        Log(ex);
        throw;
    }
}

 

 

3️⃣ 드라이버 교체(Best)

👉 MySqlConnector

  • 모든 예외를 Task로 전달
  • Timer 기반 예외 누락 방지

 

 

후기

DB가 죽는 건 정상이고,
서버가 같이 죽는 건 예외가 Task 밖에서 터졌기 때문이다


 

오랫동안 운영되던 서버의 레거시 코드인데 업데이트도 없었는데 갑자기 라이브 이슈가 터지니 당황스러웠다.

크리티컬한 장애였고 단일 장애점으로 비즈니스 서비스와 포털 페이지까지 오류가 생겼었다.

 

빨리 해결해야 한다고 생각하니식은땀이 흐렸다는..(사람들이 내 자리로 점점 몰려들면서 웅성웅성)

비동기로 잘 만들어져 있었고 다른 코드들도 정상작동 하던 코드라 처음엔 인프라나 DB만의 이슈라고 생각했다.

 

문제 원인을 상상만으로 짐작하지 않고 구체적인 증거들로 찾는다는게 중요했다.

 

이런식으로도 서버가 죽는구나 라는걸 오랜만에 느끼고

단일장애점의 개념을 왜 그토록 AWS에서 중요하게 물어보는지 이해가 됐다.

Contents