HTTP 요청을 데이터베이스 트랜잭션과 1:1로 매핑해 운영 환경의 예외 상황에서도 데이터 정합성을 지키고, 직렬화 가능한 트랜잭션과 재시도, 트랜잭션-스테이징된 백그라운드 작업으로 멱등 API를 구현하는 방법을 살펴본다.
소프트웨어 업계 전체를 보면 정말 많은 사람들이 정말 다양한 일을 하고 있다. 하지만 새로운 임베디드 펌웨어를 만드는 개발자 한 명이 있다면, 그에 비해 열 명 정도는 현대 소프트웨어의 핵심축인 — HTTP로 요청을 처리하는 CRUD 앱 — 을 만들고 있다. 이런 앱들 상당수는 Ruby on Rails나 ASP.NET 같은 MVC 프레임워크 위에 구축되어 있고, Postgres나 SQL Server 같은 ACID를 준수하는 관계형 데이터베이스를 사용한다.
운영 환경의 날카로운 모서리들은 HTTP 요청을 실행하는 동안 온갖 예상치 못한 경우를 만들어낸다. 클라이언트의 연결 끊김, 요청을 중간에서 실패시키는 애플리케이션 버그, 타임아웃 같은 것들은 충분한 요청량이 주어지면 정기적으로 발생하는 ‘비정상’ 상황들이다. 데이터베이스는 트랜잭션으로 애플리케이션을 무결성 문제로부터 보호할 수 있고, 이를 최대한 잘 활용하기 위해 잠깐 시간을 들여 생각해볼 만하다.
HTTP 요청과 데이터베이스 트랜잭션 사이에는 놀라울 정도의 대칭성이 있다. 트랜잭션과 마찬가지로, HTTP 요청은 하나의 트랜잭셔널한 작업 단위다 — 분명한 시작과 끝, 그리고 결과가 있다. 클라이언트는 보통 요청이 원자적으로 실행될 것이라고 기대하며(물론 구현에 따라 달라지긴 하지만), 실제로도 그렇게 동작한다고 가정하고 행동한다. 여기서는 예시 서비스를 통해 HTTP 요청과 트랜잭션이 서로 어떻게 잘 맞물리는지 살펴본다.
일반적인 멱등(idempotent) HTTP 요청의 경우, 요청은 백엔드 트랜잭션과 1:1로 매핑되어야 한다는 주장을 하려 한다. 각 요청마다 그 안에서 단일 트랜잭션을 열고, 모든 연산을 그 하나의 트랜잭션에서 커밋하거나 중단(abort)한다.
트랜잭션(tx1, tx2, tx3)이 HTTP 요청에 1:1 비율로 매핑된 모습.
처음 보기엔 멱등성을 요구하는 것이 꽤 큰 단서처럼 들릴 수도 있지만, 많은 API에서는 엔드포인트의 HTTP 메서드(verb)와 동작을 조금 다듬고, 네트워크 호출처럼 비멱등적인 작업을 백그라운드 잡으로 옮김으로써 연산을 멱등적으로 만들 수 있다.
어떤 API는 멱등적으로 만들 수 없고, 그런 경우에는 추가적인 고려가 필요하다. 이 글의 후속편에서 그 경우를 어떻게 처리할지 더 자세히 다룰 것이다.
단일 “사용자 생성(create user)” 엔드포인트만 있는 간단한 테스트 서비스를 만들어 보자. 클라이언트가 email 파라미터로 요청하면 엔드포인트는 사용자가 생성되었음을 알리기 위해 201 Created 상태를 반환한다. 또한 이 엔드포인트는 멱등적이어서, 클라이언트가 동일한 파라미터로 다시 호출하더라도 200 OK를 반환해 “여전히 모든 것이 정상”임을 알린다.
PUT /users?email=jane@example.com
백엔드에서는 세 가지를 수행한다:
구현은 Postgres, Ruby, 그리고 ActiveRecord나 Sequel 스타일의 ORM을 사용해 작성하겠지만, 이 개념들은 특정 기술에 국한되지 않는다.
이 서비스는 사용자와 사용자 액션에 대한 테이블을 포함하는 간단한 Postgres 스키마를 정의한다1:
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
email TEXT NOT NULL CHECK (char_length(email) <= 255)
);
-- 우리의 "사용자 액션" 감사 로그
CREATE TABLE user_actions (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users (id),
action TEXT NOT NULL CHECK (char_length(action) < 100),
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
서버 라우트는 사용자가 존재하는지 확인한다. 존재한다면 즉시 반환한다. 존재하지 않는다면 사용자와 사용자 액션을 만들고 반환한다. 두 경우 모두 트랜잭션은 성공적으로 커밋된다.
put "/users/:email" do |email|
DB.transaction(isolation: :serializable) do
user = User.find(email)
halt(200, 'User exists') unless user.nil?
# 사용자 생성
user = User.create(email: email)
# 사용자 액션 생성
UserAction.create(user_id: user.id, action: 'created')
# 성공 응답 반환
[201, 'User created']
end
end
성공적으로 삽입되는 경우 생성되는 SQL은 대략 다음과 같다:
START TRANSACTION
ISOLATION LEVEL SERIALIZABLE;
SELECT * FROM users
WHERE email = 'jane@example.com';
INSERT INTO users (email)
VALUES ('jane@example.com');
INSERT INTO user_actions (user_id, action)
VALUES (1, 'created');
COMMIT;
눈썰미가 좋은 독자라면 잠재적인 문제를 발견했을지도 모른다. users 테이블의 email 컬럼에는 UNIQUE 제약이 없다. 이 제약이 없으면, 두 개의 트랜잭션이 교차(interleaved) 실행되면서 SELECT 단계를 동시에 수행해 둘 다 빈 결과를 얻을 수 있다. 그 다음 둘 다 INSERT를 수행해 중복 행이 생긴다.
두 개의 동시 HTTP 요청이 같은 행을 삽입하도록 만드는 데이터 레이스.
다행히 이 예제에서는 UNIQUE보다 더 강력한 메커니즘으로 데이터의 정합성을 보호하고 있다. 트랜잭션을
DB.transaction(isolation:
:serializable)
로 시작하면 SERIALIZABLE로 실행된다. 이 격리 수준은 보장이 너무 강력해서 거의 마법처럼 느껴질 정도다. 동시에 실행되는 것처럼 보이는 여러 트랜잭션이 사실은 하나씩 순차적으로 실행된 것처럼 동작하도록(직렬 실행을 에뮬레이션하도록) 만든다. 위와 같은 레이스 컨디션에서 한 트랜잭션이 다른 트랜잭션의 결과를 오염시킬 수 있는 상황이라면, 둘 중 하나는 커밋 시점에 다음과 같은 메시지와 함께 커밋에 실패한다:
ERROR: could not serialize access due to read/write dependencies among transactions
DETAIL: Reason code: Canceled on identification as a pivot, during commit attempt.
HINT: The transaction might succeed if retried.
여기서는 SERIALIZABLE이 어떻게 동작하는지까지 파고들지는 않겠지만, 요컨대 이는 다양한 데이터 레이스를 감지해줄 수 있고, 감지되면 커밋 시점에 트랜잭션을 중단(abort)시킨다.
우리 예제에서 레이스는 드물겠지만, 애플리케이션 코드에서 이를 올바르게 처리해 클라이언트에게 500으로 노출되지 않게 하고 싶다. 요청의 핵심 연산을 루프로 감싸면 가능하다:
MAX_ATTEMPTS = 2
put "/users/:email" do |email|
MAX_ATTEMPTS.times do
begin
DB.transaction(isolation: :serializable) do
...
end
# 성공! 루프를 종료한다.
break
rescue Sequel::SerializationFailure
log.error "Failed to commit serially: #{$!}"
# 실패: 다음 루프로 진행한다.
end
end
end
이 경우, 같은 HTTP 요청에 다음처럼 동일한 트랜잭션이 하나 이상 매핑될 수도 있다:
동일한 요청 안에서 중단된 트랜잭션을 재시도하는 모습.
이런 루프는 평소보다 비용이 더 들겠지만, 다시 말해 우리는 드문 레이스로부터 스스로를 보호하고 있는 것이다. 실제로 호출자들이 특별히 경쟁적(contentious)이지 않다면, 이런 상황은 거의 발생하지 않는다.
Sequel 같은 젬은 이를 자동으로 처리해줄 수 있다(이 코드는 위의 루프와 유사하게 동작한다):
DB.transaction(isolation: :serializable,
retry_on: [Sequel::SerializationFailure]) do
...
end
여기서는 직렬화 가능한(serializable) 트랜잭션의 강력함을 보여줄 기회로 삼았지만, 실제 환경에서는 직렬화 격리 수준을 사용할 계획이더라도 email에 UNIQUE 제약을 두는 편이 좋다. SERIALIZABLE이 중복 삽입을 막아주긴 하지만, UNIQUE는 트랜잭션이 잘못 호출되거나 코드에 버그가 있을 때 애플리케이션을 보호하는 추가적인 안전장치가 된다. 넣어둘 가치가 충분하다.
HTTP 요청 중에 백그라운드 큐에 잡을 추가해, 별도(out-of-band)로 처리하게 하는 것은 흔한 패턴이다. 이렇게 하면 기다리는 클라이언트가 비용이 큰 연산 때문에 블로킹될 필요가 없다.
위의 사용자 서비스에 한 단계만 더 추가해보자. 사용자와 사용자 액션 레코드를 만드는 것뿐 아니라, 외부 지원(support) 서비스에도 새 계정이 생성되었다고 알리기 위해 API 요청을 하자. 이 작업은 요청과 같은 경로(in-band)에서 일어날 이유가 없으니, 백그라운드 잡을 큐잉해서 처리하겠다.
put "/users/:email" do |email|
DB.transaction(isolation: :serializable) do
...
# 외부 지원 서비스에 새 사용자가 생성되었음을
# 알리는 잡을 큐에 넣는다
enqueue(:create_user_in_support_service, email: email)
...
end
end
Sidekiq 같은 흔한 잡 큐를 써서 이 작업을 수행한다면, 트랜잭션이 롤백되는 경우(위에서 이야기한 것처럼 트랜잭션 충돌이 나는 경우) 큐 안에 잘못된 잡이 남을 수 있다. 더 이상 존재하지 않는 데이터를 참조하므로, 잡 워커가 몇 번을 재시도하더라도 성공할 수 없다.
이를 피하는 방법은 데이터베이스에 잡 스테이징 테이블을 만드는 것이다. 잡을 큐로 바로 보내는 대신 먼저 스테이징 테이블로 보낸 다음, enqueuer가 그것들을 배치로 꺼내 실제 잡 큐에 넣는다.
CREATE TABLE staged_jobs (
id BIGSERIAL PRIMARY KEY,
job_name TEXT NOT NULL,
job_args JSONB NOT NULL
);
enqueuer는 잡을 조회해 큐에 넣고, 그 다음 스테이징 테이블에서 삭제한다2. 대략적인 구현은 다음과 같다:
loop do
DB.transaction do
# 큰 배치 단위로 잡을 가져온다
job_batch = StagedJobs.order('id').limit(1000)
if job_batch.count > 0
# 각각을 실제 잡 큐에 넣는다
job_batch.each do |job|
Sidekiq.enqueue(job.job_name, *job.job_args)
end
# 그리고 같은 트랜잭션에서 이 레코드들을 삭제한다
StagedJobs.where('id <= ?', job_batch.last).delete
end
end
end
잡이 트랜잭션 안에서 스테이징 테이블에 삽입되기 때문에, 트랜잭션의 격리(isolation) 성질(ACID의 “I”)은 삽입한 트랜잭션이 커밋되기 전까지 다른 어떤 트랜잭션에서도 그 잡이 보이지 않음을 보장한다. 롤백된 스테이징 잡은 enqueuer에게 결코 보이지 않으며, 실제 잡 큐로도 들어가지 않는다.
나는 이 패턴을 트랜잭션적으로 스테이징된 잡 드레인(transactionally-staged job drain)이라고 부른다.
Que 같은 라이브러리로 잡 큐 자체를 데이터베이스 안에 직접 두는 것도 가능하지만, Postgres 같은 시스템에서 bloat가 잠재적으로 위험할 수 있기 때문에 아마 그리 좋은 아이디어는 아닐 것이다.
지금까지 다룬 내용은 멱등적인 HTTP 요청에 잘 들어맞는다. 잘 설계된 API라면 대부분이 여기에 해당하겠지만, 언제나 비멱등적인 엔드포인트도 존재한다. 예를 들어 신용카드로 외부 결제 게이트웨이를 호출한다든지, 서버 프로비저닝을 요청한다든지, 동기식 네트워크 요청을 반드시 수행해야 하는 무엇이든 그렇다.
이런 종류의 요청에는 조금 더 정교한 것을 만들어야 한다. 하지만 이 더 단순한 경우와 마찬가지로, 데이터베이스가 해답을 제공해준다. 이 시리즈의 2부에서는 다단계 트랜잭션 위에 멱등성 키(idempotency keys)를 구현하는 방법을 살펴볼 것이다.