DEV Community

Chan
Chan

Posted on • Edited on

connection pool과 세마포어, 그 응용들에 대하여

connection pool을 사용하는 이유

DB connection을 맺을 때는 tcp handshake, 메모리, 세션, 실행 컨텍스트 등의 서버 자원 할당이 필요하다. 데이터베이스와 통신이 connection setup을 하면 시간이 오래 걸린다. 따라서 connection 리스트를 만들어두고, DB 리소스를 재사용할 수 있는 저장소가 connection pool이다.

connection pool의 역할

  • 사용 중인 connection과 사용 중이지 않은 connection을 구분한다.
  • idle(사용중 X) connection이 있을 때 connection을 활용하여 DB query를 실행한다. connection이 모두 in-use(사용중)인 경우, idle로 전환된 connection을 이용하여 DB query를 실행한다.

호출해야 하는 DB query는 애플리케이션(또는 DB driver)에서 대기열로 관리된다.

일반적인 경우 브라우저와 서버는 웹 상에서 통신하기 때문에 http 통신을 하고, 서버와 DB는 tcp 통신을 한다.

connection pool의 구현

semaphore를 통해 connection pool을 구현할 수 있다. connection를 일종의 임계구역(critical section)으로 보고, 동시에 실행될 수 있는 DB query의 개수를 lock의 개수로 볼 수 있다. lock이 모두 사용 중인 경우에는 DB query가 queue에서 대기했다가, 빠져나갈 때는 lock을 acquire한다는 관점으로도 해석이 가능하다.

class Semaphore {
  constructor({ connections }) {
    this.totalConnections = [ ...connections ];
    this.idleConnections = [ ...connections ];
    this.inUseConnectionsMap = new Map();
    this.queue = [];
  }

  async run(task) {
    await acquire(task);
    await task();
    await release();
  }

  async acquire(task) {
    if (InUseConnections.length === totalConnections.length) {
      await new Promise((resolve) => {
        setTimeout(() => 
          this.queue.push(resolve);
        );
      });
    }

    const toBeInUseConnection = this.idleConnections.shift();
    this.inUseConnectionsMap.set(task, toBeInUseConnection);
  }

  async release(task) {
    const toBeIdleConnection = this.inUseConnectionsMap.get(task);
    this.inUseConnectionsMap.delete(task);
    this.idleConnections.push(toBeIdleConnection);

    if (this.idleConnections.length > 0 && queue.length > 0) {
      const resolve = this.queue.shift();
      resolve();
    }
  }
}

class DB {
  constructor() {
    this.semaphore = new Semaphore();
  }

  findUser(userId) {

  }
}

const db = new DB();
const semaphore = new Semaphore();
semaphore.run(async () => db.findUser(userId));
Enter fullscreen mode Exit fullscreen mode

HTTP/1.1 상 브라우저의 동시 요청 개수 제한과 connection pool 사이의 연관성

HTTP/1.1 상에서 대부분의 브라우저는 동일한 origin에 대한 요청을 한번에 6개까지만 처리가 가능하도록 설정되어 있다. 이를 구현하기 위해서는 다음 스펙을 만족시키는 코드를 작성하면 된다.

  • 현재 진행 중인 요청의 개수를 기록한다.
  • 호출해야하는 HTTP 요청을 순서대로 기억한다.
  • 현재 진행 중인 요청의 개수 < 6인 경우, 요청을 실행한다. 현재 진행 중인 요청의 개수 >= 6인 경우, 가장 먼저 끝나는 HTTP 요청을 완료한다.

위의 스펙과 connection pool 사이의 공통점이 많다. DB query를 HTTP 요청으로 대체하고, connection의 상태를 in use(사용중) / idle(사용중 X)으로 구분하는 대신에 현재 진행 중인 요청의 개수를 기록하면 된다.

구현

첫번째 시도(오답)

const tasks = new Set();

const lock = async () => {
  if (tasks.size >= 6) {
    await Promise.any(tasks);
  }
}

const run = async (task) => {
  await lock();

  const promise = task();
  tasks.add(promise);
  console.log("it has waited");
  const result = await promise;
  tasks.delete(promise);

  return result;
}

const exampleTask = async (input) => {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(input);
    }, 3000);
  });
};

run(async () => exampleTask(1));
run(async () => exampleTask(2));
run(async () => exampleTask(3));
run(async () => exampleTask(4));
run(async () => exampleTask(5));
run(async () => exampleTask(6));
run(async () => exampleTask(7));
run(async () => exampleTask(8));
Enter fullscreen mode Exit fullscreen mode
  • 결과: 8번의 "it has waited"가 즉시 실행되었다.
  • 원인:
    1. 첫번째 promise가 lock을 실행하고 resolve(), await을 만나 그 다음 태스크 microtask에 적재
    2. 두번째 promise가 lock을 실행하고 resolve(), await을 만나 그 다음 테스크 microtask에 적재
    3. ... 8번째 promise가 lock을 실행하고 resolve(), await을 만나 그 다음 테스크 microtask에 적재
    4. 첫번째 task 실행, await을 만나 그 다음 테스크 microtask에 적재
    5. 두번째 task 실행, await을 만나 그 다음 테스크 microtask에 적재
    6. ... 8번째 task 실행, await을 만나 그 다음 테스크 microtask에 적재

내가 원했던 것은 1,4,2,5,3,6 실행인데 promise의 동작구조로 인해 모든 data fetching이 동시에 lock을 얻어버린다.

  • 정리:
    • then chaining 또는 await을 만나는 순간 메인 스레드는 해당 비동기 코드 실행을 중단하고 동기 코드 실행으로 전환한다.
    • resolve되면 then chaining callback 또는 await 후속 코드 태스크를 microtask queue에 적재한다.
    • 따라서 Promise.any()를 호출하는 순간에, 어떤 태스크도 tasks에 추가되지 않아 모든 요청이 한번에 실행된다.

두번째 시도(해답)

connection pool과 유사하게 semaphore를 통해 을 구현할 수 있다. http connection을 일종의 임계구역(critical section)으로 보고, 동시에 실행될 수 있는 요청의 개수를 lock의 개수로 볼 수 있다. lock이 모두 사용 중인 경우에는 요청이 queue에서 대기했다가, 빠져나갈 때는 lock을 acquire하여 실행된다는 관점으로 해석이 가능하다.

const exampleTask = async (input) => {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(input);
    }, 3000);
  }).then(() => {
    console.log(input);
    return input;
  });
};


class Semaphore {
  constructor({ max }){
    this.queue = [];
    this.counter = 0;
    this.max = max;
  }

  async acquire() {
    if (this.counter >= this.max){
        await new Promise((resolve) => {
          this.queue.push(resolve);
        });
    }

    this.counter += 1;
  }

  async release() {
    this.counter -= 1;
    if (this.counter < this.max && this.queue.length > 0) {
      const resolve = this.queue.shift();
      resolve();
    }
  }

  async run(task) {
    await this.acquire();
    await task();
    await this.release();
  }
}

const semaphore = new Semaphore({ max: 6 });
semaphore.run(async () => exampleTask(1));
semaphore.run(async () => exampleTask(2));
semaphore.run(async () => exampleTask(3));
semaphore.run(async () => exampleTask(4));
semaphore.run(async () => exampleTask(5));
semaphore.run(async () => exampleTask(6));
semaphore.run(async () => exampleTask(7));
semaphore.run(async () => exampleTask(8));
Enter fullscreen mode Exit fullscreen mode

html element <link rel="preconnect"> 태그와의 연관성: ahead-of-time connection setup

html link tag에 rel=preconnect 속성을 지정하면, 리소스 다운로드 요청이 실제로 이루어지기 전에 미리 connection을 열어서 추후 리소스 다운로드 시간을 단축시킨다.

공통점

connection을 맺기 위해 필요한 setup(initiation)을 미리 완료하여, 실제로 통신이 수행되는데 걸리는 시간을 단축시킨다는 점이 동일하다.

차이점

<link rel="preconnect">은 http connection을 미리 setup하고, connection pool은 DB connection을 setup한다. 따라서 <link rel="preconnect">은 DNS lookup, tcp 3 way handshake, tls handshake을 미리 수행하기 위해 사용하지만, connection pool의 경우 connection에 저장된 DB 상태를 재사용하기 위해 사용한다는 차이가 있다.

TL; DR

  1. DB Query를 처리할 때마다 서버 자원 할당을 하지 않기 위해 위해 connection pool을 유지하고 여기 저장된 connection을 재사용한다.
  2. connection pool은 한번에 진행되는 태스크의 개수를 제한할 수 있다는 특징이 있어 semaphore를 통해 구현할 수 있다.
  3. connection pool은 미리 connection을 setup한다는 점에서 html의 <link rel="preconnect">와 유사성이 있고, 한번에 진행되는 태스크의 개수를 제한한다는 점에서 http/1.1의 브라우저 요청 개수 제한과 유사성이 있다.

Top comments (0)