본문으로 건너뛰기
글 목록으로 돌아가기
이 글 목차
~/posts/backend

TypeORM으로 PostgreSQL Advisory Lock 사용하기

PostgreSQL이 관리하는 애플리케이션 수준의 lock으로 분산 환경에서 작업을 조율하는 방법.

주요 특성

속성
Lock IDbigint (integrationId 같은 엔티티 ID 사용)
저장 위치PostgreSQL 공유 메모리
가시성모든 커넥션에서 확인 가능(분산)
범위세션 범위(데이터베이스 커넥션에 종속)
자동 해제커넥션 종료 시 자동 해제

SQL 명령어

-- 획득 (논블로킹, 즉시 true/false 반환)
SELECT pg_try_advisory_lock(123);

-- 해제
SELECT pg_advisory_unlock(123);

-- 활성 lock 조회
SELECT locktype, objid AS lock_id, pid, granted
FROM pg_locks WHERE locktype = 'advisory';

-- 어떤 커넥션이 lock을 보유하고 있는지 확인
SELECT l.objid, p.pid, p.application_name, p.client_addr
FROM pg_locks l
JOIN pg_stat_activity p ON l.pid = p.pid
WHERE l.locktype = 'advisory';

핵심 규칙: 세션 범위

lock을 획득한 커넥션만 해제할 수 있어요.

Lock Table:
┌─────────┬─────────────┬─────────┐
│ Lock ID │ Session/PID │ Granted │
├─────────┼─────────────┼─────────┤
│    5    │    1234     │  true   │ ← PID 1234만 해제 가능
└─────────┴─────────────┴─────────┘

TypeORM: Connection Pool vs QueryRunner

Connection Pool(기본값) - lock에 사용하면 안 돼요

// ❌ Advisory lock에 사용하면 안 됨
await this.dataSource.query("SELECT pg_try_advisory_lock($1)", [id]);
// 매번 풀에서 랜덤한 커넥션을 가져옴!

QueryRunner(전용 커넥션) - 올바른 방법

// ✅ Advisory lock에 올바른 방법
const qr = dataSource.createQueryRunner();
await qr.connect();     // 전용 커넥션 획득
await qr.query(...);    // 항상 같은 커넥션 사용
await qr.query(...);    // 여전히 같은 커넥션
await qr.release();     // 풀에 반환

QueryRunner를 사용해야 할 때:

  • 트랜잭션(같은 커넥션이어야 함)
  • Advisory lock(같은 세션이어야 함)
  • 커넥션 친화성이 필요한 모든 작업

구현 패턴

lock별로 전용 QueryRunner를 Map에 저장해요.

@Injectable()
export class LockService {
  private readonly lockConnections = new Map<number, QueryRunner>();

  async acquireLock(id: number): Promise<boolean> {
    const qr = this.dataSource.createQueryRunner();
    await qr.connect();

    const result = await qr.query(
      "SELECT pg_try_advisory_lock($1) as acquired",
      [id],
    );

    if (result[0]?.acquired) {
      this.lockConnections.set(id, qr); // 해제용으로 저장
      return true;
    }

    await qr.release();
    return false;
  }

  async releaseLock(id: number): Promise<boolean> {
    const qr = this.lockConnections.get(id);
    if (!qr) return false;

    try {
      const result = await qr.query(
        "SELECT pg_advisory_unlock($1) as released",
        [id],
      );
      return result[0]?.released ?? false;
    } finally {
      this.lockConnections.delete(id);
      await qr.release();
    }
  }
}

멀티 Pod(ECS) 환경에서의 고려사항

┌─────────┐      ┌─────────┐      ┌─────────┐
│  Pod A  │      │  Pod B  │      │  Pod C  │
│ Map:{5} │      │ Map:{}  │      │ Map:{}  │
└────┬────┘      └────┬────┘      └────┬────┘
     └────────────────┼────────────────┘

            ┌─────────────────┐
            │   PostgreSQL    │
            │ Lock 5: Pod A   │ ← 진실의 원천
            └─────────────────┘
시나리오동작
Pod A가 동기화 중일 때 Pod B가 요청Pod B의 pg_try_advisory_lockfalse 반환
Pod A가 작업 중 크래시PostgreSQL이 lock 자동 해제(커넥션 종료)
같은 Pod에서 동시 요청Map이 ID당 하나의 QueryRunner만 보장

FAQ: 인메모리 Map은 안전한가요?

Q: 컨테이너가 스케일링될 때 인메모리 Map에 문제가 생기지 않나요?

A: 아니요. 획득과 해제는 같은 HTTP 요청에서 같은 컨테이너에서 일어나요. Map은 단일 요청 내에서 추적하는 용도일 뿐이에요. 컨테이너 간 조율은 PostgreSQL의 세션 범위 동작에 의존해요.

흔한 실수

  1. dataSource.query()를 lock에 사용 - 매번 랜덤 커넥션을 가져와요
  2. 다른 세션의 lock을 강제 해제하려는 시도 - 설계상 불가능해요
  3. 복구를 위한 타임아웃 의존 - 불필요해요. PostgreSQL이 연결 끊김 시 자동 해제해요
  4. 커넥션 풀이 버그를 숨기는 경우 - 풀이 작으면 우연히 커넥션이 재사용될 수 있어요

댓글

글 목록으로 돌아가기
enko