나만의 작은 도서관

[TIL][C++] 250630 MMO 서버 개발 47일차: DB 처리 함수는 동기 함수이다. 그렇다면 DB 처리는 어떻게 해야할까? 본문

Today I Learn

[TIL][C++] 250630 MMO 서버 개발 47일차: DB 처리 함수는 동기 함수이다. 그렇다면 DB 처리는 어떻게 해야할까?

pledge24 2025. 6. 30. 23:19
주의사항: 해당 글은 일기와 같은 기록용으로, 다듬지 않은 날것 그대로인 글입니다. 

DB 처리 함수는 동기 함수이다.

  • 어느 DB를 사용하던, DB에 접근하여 쿼리나 트랜젝션을 처리할 때는 항상 커넥션 객체를 통해야 한다. 그래서 서버는 커넥션 객체의 멤버 함수를 사용하여 쿼리문을 보내고, 그 결과를 확인한다.
/* 서버 */                    /* DB */
[커넥션 객체] -----쿼리-----> [테이블]
  • 문제는 쿼리를 보내거나 트랜젝션을 보내는 함수는 대부분 동기 함수라는 것이다. 예시로 윈도에서 SQL문으로 작성된 쿼리를 보내는 SQLExecDirect 함수가 있는데, 이 함수는 동기함수라서 함수를 호출한 서버는 DB에서 해당 SQL문에 대한 처리가 완료될 때까지 다음 코드를 진행할 수 없다는 것이다.
  • RDBMS를 사용할 경우 요청받은 작업의 처리 비용이 무겁고, 원격 DB라면 네트워크 지연도 있기 때문에 결과를 받기까지 100ms 이상의 시간이 걸릴 수도 있다. 사람의 입장에서 100ms는 굉장히 짧은 시간이지만 컴퓨터의 코어의 입장에선 굉장히 긴 시간이다. 이 시간 동안 동기함수란 이유로 스레드가 코드를 실행하지 않고 대기만 타고 있다면, 성능적인 측면에서 좋지 못하다.

정말 치명적인 문제: 서비스 서버의 모든 스레드가 멈출 수 있다.

  • 동기 함수로 진행되는 DB 처리 함수를 서비스 서버의 모든 스레드가 호출할 가능성이 있다면 어떻게 될까?
  • 스레드는 DB 작업만 하지 않는다. 내부 로직을 처리하기도 하고, 클라이언트로부터 받은 패킷을 까거나, 다른 서버와 통신을 해야 하기도 한다. 그런데 DB 처리가 늦어지면서 모든 스레드가 DB 처리 함수에 의해 블록(blocking) 되었다면 다른 작업들도 전부 진행이 되지 않아 난리가 날 것이다.

 

그렇다면 DB 처리는 어떻게 해야 할까?

  • DB 처리 함수를 호출하는 스레드를 따로 분리하면 된다. 모든 스레드가 블록 할 수 있는 DB 처리 함수를 직접 호출하지 않고, DB 스레드가 처리할 수 있도록 Queue와 같은 자료구조에 해당 작업을 밀어 넣어주거나, 스레드 풀에 있는 스레드 하나를 깨워 해당 DB 작업을 처리하도록 한다.
  • DB 스레드는 Queue에 쌓인 DB 처리 일감을 하나씩 Pop 해 동기 함수를 실행한다.

또 다른 문제점: 처리 효율이 굉장히 낮다

  • 하나의 커넥션은 하나의 스레드에서 점유하여 사용한다. 그런데, “하나의 커넥션 객체는 동시에 오직 하나의 쿼리나 트랜젝션만 처리할 수 있기 때문에” 커넥션 객체를 하나만 운영하게 되면 수많은 처리 요청을 빠르게 처리할 수 없다.
// -----------------시간선----------------->
DB 스레드: [작업1 요청->대기->완료] -> [작업2 요청->대기->완료] -> [작업3 요청->대기->완료]
  • 각 작업의 대기 시간이 상당하기 때문에, 정말 상황이 좋지 않으면 1초에 10개 정도의 DB 작업만 처리할 수 있을 것이다. 동접자가 1,000명이 넘어가는 상황에서 1초에 10개는 택도 없다.

 

해결법: 커넥션 객체를 여러 개 둔다.

  • 한 커넥션 객체론 처리량이 부족하다면, 여러 커넥션 객체를 만들어 처리량을 늘리면 된다. 주의할 점은 위에서 말했듯, 하나의 커넥션 객체는 하나의 스레드가 점유하기 때문에 커넥션 객체의 개수 이상의 스레드가 존재해야 한다. (스레드 풀을 사용할 경우)
  • 각 스레드는 독립적으로, 병렬적으로 수행될 테니, 아래와 같이 성능이 개선된다.
// -----------------시간선----------------->
DB 스레드 1: [작업1 요청->대기->완료]-->
DB 스레드 2: ---[작업2 요청->대기->완료]-->
DB 스레드 3: --[작업3 요청->대기->완료]-->

 

 

커넥션 객체 관리법: 커넥션 풀(Connection Pool)

  • DB 커넥션을 위한 커넥션 객체의 생성 비용은 굉장히 높다. 그렇기 때문에 새로운 요청 때마다 커넥션 객체를 생성하는 것은 효율적인 방법이 아니다.
  • 따라서, 커넥션 객체를 미리 생성해 두고 필요할 때마다 꺼내 쓰는 방식인 풀링(pooling) 방식을 적용해서 사용하는 것이 일반적이다. 업계에선 이를 “커넥션 풀”이라고 부른다.

 

게임 서버에서의 DB 스레드 운영 과정

  1. 클라이언트가 게임 서버에게 DB 정보를 요구하는 패킷을 전송
  2. 게임 서버는 packetHandler를 통해 pkt을 깐다.
  3. 깐 pkt의 id가 DB 요청 id임을 확인하고, 해당 Job을 DB Queue에 밀어 넣는다. 그런 다음, 해당 네트워크 스레드는 그대로 다음 코드를 진행한다.
  4. DB Queue를 감시하는 스레드가 일감을 감지하고, 준비해 둔 DB 스레드 하나에게 해당 일감을 실행시키도록 한다.
  5. 해당 DB 스레드가 작업을 완료했다면, 후속 작업(callback)을 진행한다.
  6. 후속 작업에 의해 jobQueue에 job이 들어갔다면, 로직 처리 스레드가 이를 처리한다.

 

커넥션 객체의 수는 어느 정도가 적당할까?

  • 이런저런 이야기가 많은데, 기본적으로 DB 처리 시, 해당 스레드가 코어를 점유하고 있는 시간은 굉장히 적기 때문에 10개 이상 만들어두는 것 같다. MaxConnection이 100개까지 되니, MaxConnetion이 넘어가지 않는 선에서 상황에 맞춰 개수를 정하면 될 듯하다.
  • 아래는 알려져 있는 적절한 커넥션 풀 크기 설정에 대한 공식 중 하나이다.
최적 커넥션 풀 크기 = CPU 코어 수 × (1 + 대기시간/처리시간)

 

 

코어보다 많은 DB 스레드 배치는 괜찮을까?

  • 그런데, 스레드를 잔뜩 만들어두면 Context Switching 자주 발생해서 성능이 떨어지는 거 아닌가? 하는 걱정이 든다.
  • Context Switching은 실행해야 할 스레드가 많아질수록 더 자주 발생한다. 즉, 스레드가 아무리 많아도 만들어놓고 실행하지 않으면, Context Switcing 일어나지 않는다.
  • 하지만, DB의 경우 대부분의 시간이 대기 시간이므로 활성화되어 있는 시간이 굉장히 적다. 결론적으로, 코어보다 많은 DB 스레드 배치는 Context Switching이 더 자주 발생시키긴 하겠지만, DB 작업의 특성에 의해 주는 영향이 미미하고, 여러 DB 스레드를 배치해서 얻는 성능적 이점이 훨씬 크므로 신경 쓰지 않아도 된다.

 

알아보며 정리한 기타 내용들

  • Context Switching은 코어를 점유하는 스레드가 교체되는 것을 의미. 다음과 같은 상황에서 발생한다.
    • OS의 CPU 스케쥴러로부터 할당받은 time slice를 초과한 경우
    • 코어를 점유하고 있던 스레드가 작업이 일찍 끝났거나 대기 상태로 빠져야 한다는 이유 등으로 양보(yield) 한 경우
      • 예시. I/O 작업을 하는 경우
    • 인터럽트가 걸렸을 때
  • Context Switching은 추가 비용, 즉, 오버헤드가 발생하는 작업이다. 왜냐하면, 문맥이 교환되면서 해당 코어에 저장해 둔 캐시 데이터나 레지스터 데이터를 교체해야 하기 때문이다.
    • 이러한 이유로 이전 스레드가 같은 프로세스의 스레드인지 아닌지에 따라 교체 비용이 달라진다. 같은 프로세스인 경우, 공유 메모리가 캐시에 올라가 있을 수 있기 때문에 굳이 교체하지 않아도 되는 경우가 생기기 때문.
  • CPU 스케쥴러는 되도록이면 Context Switching이 적게 발생하도록 스레드를 코어에 배치한다. 그래서, time slice를 넘어서 쫓겨난 경우에도, 상황이 괜찮다면 Context Switching 없이 다시 같은 코어에 배치받게 된다. 반대로 상황이 좋지 않다면(해당 코어가 너무 뜨겁다거나, 모든 코어가 busy상태이거나) 다른 스레드가 해당 코어에 배치되거나, 현재 스레드가 다른 코어에 배치되어 Context Switcing이 발생한다.

 

코어와 논리적 프로세서는 다르다: 하이퍼스레딩

  • 이번에 처음 알게 된 내용인데, 하나의 코어에서 2개의 논리적 스레드가 돌아간다고 한다. 이를 하이퍼스레딩 기술이라고 부르는데(인텔에서), 원리는 다음과 같다.
    • 하나의 물리 코어는 가지는 여러 하드웨어 자원(ALU, FPU, 레지스터 등)을 가지고 있지만, 단일 스레드만 실행할 경우 일부 자원은 사용하지 않는, idle 상태가 되므로, 하나의 스레드가 특정 자원을 사용하지 않는 동안 다른 스레드가 해당 자원을 사용하는 방식이다.
  • 어찌 되었든 하이퍼스레딩은 같은 코어를 두 스레드가 공유해서 사용하는 것이기 때문에 성능 향상은 엄청 크진 않고, 20~30% 정도의 성능 향상이 있다고 한다.

 

논리적 프로세서?

  • 논리적 프로세서는 논리적 스레드를 실행시킬 수 있는 대상을 의미한다. 따라서, 하이퍼스레딩을 통해 4개의 코어가 8개의 논리적 스레드를 가진다면, 논리적 프로세서가 8개 있다는 것을 의미한다.
  • 하나의 앱에서 병렬 처리로 최대한의 처리량을 이끌어내고 싶다면, 코어 수가 아닌 논리적 프로세서 기준으로 고려해야 한다.