Pool Dead Lock 이 발생하면 서버가 죽어요!

김법우·2024년 8월 8일
0

Database

목록 보기
12/12
post-thumbnail

Connection Pool Dead Lock

💡 Pool Dead Lock 이란?

Pool Dead Lock 은 여러 스레드나 프로세스가 공유 자원을 획득하려다 상호간의 대기가 발생해 시스템이 멈추는 현상을 의미한다.

Dead Lock, RDB 를 사용한다면 한번쯤은 들어본 용어다. Dead Lock 은 기본적으로 여러 프로세스가 공유 자원을 획득하려는 과정에서 상호간의 대기가 발생해 무한히 대기하는 상황을 의미한다.

동시에 실행되는 트랜잭션에서 행 잠금을 걸고 서로가 가진 다른 잠금을 기다리는 상황에서는 “특정 행에 대한 잠금” 이라는 공유 자원을 기다리는 것이고, 스레드끼리 가진 메모리를 요구하는 상황에서는 “메모리” 라는 공유 자원을 기다리다 Dead Lock 이 발생하는 것이다.

오늘 다룰 내용은 서버 프로세스가 DB 에 엑세스하기 위해 관리하는 공유 자원인 Connection 에 대한 Dead Lock 이다.


💡 Connection Pool Dead Lock

Connection 은 공유 자원이야

서버에서 DB 에 SQL 쿼리를 날리기 위해서는 Connection 이 필요하다. 많은 서버 사이드 ORM 도구나 드라이버 추상화 모듈에서는 클라이언트와 DB 간의 연결을 관리하기 위해 인스턴스를 할당하고, 이것은 곧 Conneciton 이라고 볼 수 있다.

// (1)데이터베이스 연결 정보와 커넥션 풀에 대한 설정 정보를 초기화
const client = new pg.Pool({
  host: "xxxx",
  user: "xxx",
  password: "xxxx",
  database: "xxxxxx",
  port: 5432,
  max: 3,
});

// (2)커넥션 풀에 커넥션을 할당
client.connect();

// (3)커넥션 풀에서 커넥션을 가져와 쿼리를 실행
await client.query("SELECT pg_sleep(3);");

즉 하나의 Connection 은 클라이언트와 DB 간의 TCP 연결에 필요한 정보, 연결 전후에 필요한 작업, 연결 후 쿼리를 실행하기 위한 작업들이 구현된 객체가 올라간 메모리라고 봐도 되겠다. (Connection 과 Connection Pool 에 대해서는 다음에 더 자세히 다루어보자.)

중요한 점은 Connection 은 제한된 리소스로 서버와 DB 간에 무한히 필요할 때 마다 만들 수 있는게 아니라는 점이다. 새로 만들려고해도 매번 생성하기는 매우 비효율적일 것이다.

따라서 많은 ORM, 드라이버에서는 미리 Connection 을 만들어 Connection Pool 이라는 일종의 관리 저장소에 넣어둔 뒤 필요할때마다 꺼내서 사용하는 방식을 사용한다. Connection Pool 에는 지정된 수만큼의 Connection 이 포함되며, 프로세스들은 이러한 Connection 을 Connection Pool 에서 가져다 쓰고 반납해 재사용하는 메커니즘을 사용한다.


Dead Lock 의 발생 ... 그 영향은?

Connection 은 위에서 설명했듯이 제한된 공유 자원이다. 따라서 높은 동시 요청이 발생할 경우 다른 프로세스가 Connection 을 반납하기를 대기하는 상태가 되는데, 이때 서로가 Connection 을 점유하고 대기하는 경우가 중첩될 경우 Dead Lock 에 빠지게 된다.

애플리케이션 성능 저하 - 여러 클라이언트 요청이 대기 상태에 머물게되고 급격한 성능 저하를 초래한다.
무한 대기 상태 - 타임아웃 때문에 무한은 아니지만, 사용자 입장에서는 무한인 대기 상태에 빠지게 된다.
빠른 리소스 고갈 - 대기 상태로 인해 CPU, 메모리 등의 서버 및 DB 의 자원을 빠르게 고갈시켜 시스템 장애를 유발한다.

Pool Dead Lock 을 처음 듣는 개발자라면 지금 당장 코드창을 켜고 찾아볼만큼 심각한 영향밖에 없다. 이전에 잘 되던 서비스도 사용자가 늘자 갑자기 다운된다거나, 특정 API 하나 때문에 시스템이 다운되는 등 내가 겪은 영향은 정말 치명적이었다.


🛑 근본적인 원인

풀 데드락은 별도의 커넥션 풀 파라미터 조정이 없는 한 아래의 2가지 조건이 맞물리는 순간 발생하게 된다.

  • 하나의 작업을 처리하기 위해 커넥션을 풀에서 가져온 뒤 추가적인 커넥션을 풀에서 가져오는 경우
  • 해당 작업에 대한 높은 동시성 요청이 발생하는 경우

즉, 프로세스 시작시 필요한 커넥션 수와 종료시 필요한 커넥션 수가 다른 경우 발생한다. 대부분의 상황에서는 Pool Size 가 허용하는한 위의 조건이 맞아떨어지더라도 문제가 없을 수 있지만 높은 동시 요청이 발생한다면 이야기가 달라진다. TypeORM 의 기본 Pool Size 는 10인데, 별도의 조정이 없는 한 위의 조건을 만족하는 10개의 동시 요청이 오는 순간 Dead Lock 이 걸린다고 할 수 있다.

  • 예시

    // 최초의 커넥션 획득
    const originConnection = e2eTestManager.dataSource.createQueryRunner();
    await originConnection.connect();
    
    try {
      await originConnection.manager.query("SELECT pg_sleep(5)");
    
      // 최초 커넥션을 반환하기 전 추가 커넥션을 획득
      const extraConnection = e2eTestManager.dataSource.createQueryRunner();
      try {
        await extraConnection.manager.query("SELECT pg_sleep(5)");
      } finally {
        await extraConnection.release();
      }
    } finally {
      await originConnection.release();
    }

    위의 코드가 정상적으로 종료되기 위해서는 2개의 커넥션이 유휴 상태여야한다.


해결하려면?

결국 Pool Dead Lock 을 풀기 위해서는 timeOut 까지 대기해 강제로 풀에 반납되는 것을 기다리거나, 누군가 먼저 반납해 돌려쓰도록 해야한다. 위의 도식에서도 idle 에 사용 가능한 커넥션이 1개라도 있으면 정상적으로 대기 후 커넥션을 돌려사용하며 작업을 종료할 수 있음을 알 수 있다.

앞으로 다룰 내용의 키 포인트로 구현 방법은 제각각이지만 idle 이 고갈된 상태에서 use 를 반환하지 않는 상태를 방지하는 것을 주 목적으로 한다.


Pool Dead Lock 의 해결

🔥 Connection Pool Parameter 조정

Connection Options | TypeORM Docs

1️⃣ 풀 사이즈 조정

가장 직관적인 방법이다. Pool Dead Lock 이 현재 서버 스펙에서 처리 할 수 없을만큼의 동시 요청이 발생했다고 판단하고 스케일 업하는 과정에서 적용 할 수 있겠다. 시스템 한도내에서 해당 서버와 데이터베이스의 커넥션 수를 늘릴 수 있다면 늘리는 것으로 해결 할 수 있다.

⛔ 해당 방법은 근본적인 문제를 해결하지 못한다.
풀 사이즈를 늘려 커넥션 갯수를 늘리더라도 다음 시점에 늘린 커넥션 갯수 이상으로 처리량이 도달할 경우 마찬가지로 풀 데드락이 발생한다. 풀 데드락은 “절대” 피해야한다. 즉 이 해결방법은 무엇도 해결하지 못한다는 뜻이다.

  • 풀 데드락이 발생했음을 인지
  • 원인 코드를 찾지 못해 급히 수정부터해야하는 상황

위의 조건에 해당하는 경우에만 “일시적”으로 풀 사이즈를 빠르게 조정해 서버를 재배포하고 원인을 찾아 해결 한 뒤 다시 재조정하는게 가장 합리적인 선택이라고 생각한다.

About Pool Sizing


2️⃣ 타임아웃 조정

Postgres 에서 두 프로세스간 Dead Lock 을 발생시키는 순간 즉시 두 트랜잭션이 종료된 경험을 한 적이 있을 것이다. 타임아웃 파라미터 조정은 이와 유사하게 Dead Lock 이 발생하더라도 작업을 취소하고 빠르게 커넥션을 강제로 반납시켜 전체 시스템 장애를 막는 것을 목표로 한다.

Commons DBCP 이해하기

⛔ 트래픽이 몰려 connectTimeOutMS 만큼 기다려 실패하는 상황이 잦다면 connectTimeOutMS 를 낮춰 빠른 실패를 유도한다.
제한된 리소스를 바탕으로 “전체 시스템 장애” 가 아닌 “간헐적 장애” 수준으로 유지할 수 있도록 하는 방법이다.

⛔ 너무 긴 connectionTimeOutMS 는 의미가 없다.
웹/앱 서비스의 백엔드 API 애플리케이션을 만드는 상황에서 API 가 커넥션을 얻기 위해 대기할 시간이 10초라면 API 반환값이 10초 이상이 될 것이고, 대부분의 유저들은 해당 시점에 이미 떠나고 없을 것이다. 따라서 이러한 불필요한 대기가 발생하지 않도록 적절한 시간 설정이 필요하다.

connectTimeOutMS 를 설정하는 것은 서버뿐만 아니라 요청 트래픽 분석, 데이터베이스 및 서버 애플리케이션 가용 사양, 서비스 성격 등 다양한 요소를 고려해 설계해야한다.

즉 이게 정답이야! 하는 요소가 없단 뜻이다. 지속적으로 서비스를 운영하며 조정해나가야하고, 이를 위한 모니터링 및 자체 규칙을 구축하는게 가장 중요하다.


🔥 가능한 Dead Lock 이 일어날 상황을 피하기

1️⃣ 풀 데드락을 유발하는 다양한 예시

몇가지 사용하는 패턴내에서 가능한 시나리오를 세우고 어떤 점이 Pool Dead Lock 의 유발 원인이 되는지를 알아본다.

  • 커넥션을 반환하기 전 다른 커넥션을 가져오는 케이스

    // 1번째 커넥션 획득 후 실행
    const originConnection = e2eTestManager.dataSource.createQueryRunner();
    await originConnection.connect();
    
    try {
      await originConnection.manager.query("SELECT pg_sleep(5)");
    
      // 2번째 커넥션 획득 후 실행
      const extraConnection = e2eTestManager.dataSource.createQueryRunner();
      try {
        await extraConnection.manager.query("SELECT pg_sleep(5)");
      } finally {
        await extraConnection.release();
      }
    } finally {
      // FAIL : 1 획득 -> 2 획득 -> 2 반환 -> 1 반환
      await originConnection.release();
    }
  • 트랜잭션 시작 후 트랜잭션내에서 다른 커넥션을 명시적으로 가져오는 케이스

    // 1번쨰 커넥션 획득 후 트랜잭션 시작
    const originConnection = e2eTestManager.dataSource.createQueryRunner();
    await originConnection.startTransaction();
    
    try {
      await originConnection.manager.query("SELECT pg_sleep(5)");
    
      // 트랜잭션 내부에서 2번째 커넥션 획득
      const extraConnection = e2eTestManager.dataSource.createQueryRunner();
      try {
        await extraConnection.manager.query("SELECT pg_sleep(5)");
      } finally {
        await extraConnection.release();
      }
    
      await originConnection.commitTransaction();
    } finally {
      // FAIL : 1 획득 -> 2 획득 -> 2 반환 -> 1 반환
      await originConnection.release();
    }
  • 트랜잭션 시작 후 다른 커넥션을 Manager API 를 사용해 가져오는 케이스

    // 1번쨰 커넥션 획득 후 트랜잭션 시작
    const originConnection = e2eTestManager.dataSource.createQueryRunner();
    await originConnection.startTransaction();
    
    try {
      await originConnection.manager.query("SELECT pg_sleep(5)");
    
      // 트랜잭션 내부에서 manager.query 를 실행하기 위해 내부적으로 2번째 커넥션을 가져와 사용
      await e2eTestManager.dataSource.manager.query("SELECT pg_sleep(5)");
    
      await originConnection.commitTransaction();
    } finally {
      // FAIL : 1 획득 -> 2 획득 -> 2 반환 -> 1 반환
      await originConnection.release();
    }
  • Manager API 를 사용해 커넥션을 가져온 뒤 반환전에 트랜잭션을 새로 여는 케이스

    // 1번째 커넥션을 획득 후 변수에 저장
    const extraConnection = e2eTestManager.dataSource.manager;
    
    // 2번째 커넥션을 획득 후 트랜잭션 시작
    const originConnection = e2eTestManager.dataSource.createQueryRunner();
    await originConnection.startTransaction();
    
    try {
      await originConnection.manager.query("SELECT pg_sleep(5)");
    
      // query, repository API 등 암시적 반환 API 를 호출할때까지 반환하지 않음
      await extraConnection.query("SELECT pg_sleep(5)");
    
      await originConnection.commitTransaction();
    } finally {
      // FAIL : 1 획득 -> 2 획득 -> 1 반환 -> 2 반환
      await originConnection.release();
    }

앞서 말한대로 얻어온 커넥션을 반환하기전 추가 커넥션을 가져오는 경우 높은 동시 요청이 발생하면 풀 데드락이 발생한다.

위는 명시적으로 코드를 압축해 놓아 가시적으로 보이지만 실제 코드에서는 트랜잭션내에서 다른 서비스의 API 를 호출하는 식으로 동작하므로 비교적 문제 발생시 원인을 찾기 어려울 수 있다.

또한 TypeORM 의 EntityManager, Repository, DataSource 에서 제공하는 추상화된 Connection API 를 사용하는 케이스에서는 명시적인 커넥션 반환이 아닌 내부 로직에 의해 호출이 종료되면 자동으로 반환하므로 디버깅이 더 힘들 수 있다.

따라서 처음 설계하는 시점에 위의 예시와 같은 패턴을 불필요한곳에 사용하는 것을 지양하는 것이 최선이라고 생각한다.

동시에 실행 하지 않으면 된다고 생각 할 수 있지만, 모든 작업은 호출자 입장에서 동시에 실행 될 수 있음을 보장하는게 전반적인 시스템의 복잡도와 안정성을 향상시킨다.

서버 개발자로서 지속적으로 염두에 두고 개선해야할 부분이라고 할 수 있다. 실제 비즈니스 로직의 구현 및 설계와 관련이 있기 때문이다. 앞서 다룬 다양한 예시를 토대로 어떤식으로 설계를 해야할지 일반화된 몇가지 규칙을 생각해보고 도입하면 좋을 것 같다.


참고한 자료

About Pool Sizing
Commons DBCP 이해하기
NodeJS 와 PostgreSQL Connection Pool
Connection Options | TypeORM Docs

profile
개발을 사랑하는 개발자. 끝없이 꼬리를 물며 답하고 찾는 과정에서 공부하는 개발자 입니다. 잘못된 내용 혹은 더해주시고 싶은 이야기가 있다면 부디 가르침을 주세요!

0개의 댓글