Backend · Reliability

계좌 둘, 트랜잭션 하나,
답은 다섯 가지

Aggregate 2개를 트랜잭션 하나 안에서 원자적으로 쓰는 일은 어느 프레임워크에서나 이미 풀린 문제처럼 보인다. 그런데 장치도 프레임워크마다 다르고, 소리 없이 깨지는 자리도 다르다. 두 Account 사이 송금에 필요한 게 바로 이것이었고, 5개 언어로 만들어 보니 답이 다섯 가지였다. 어떤 언어는 이 장치가 이미 완전히 동작하고 있었다. 어떤 언어는 그럴듯한 수정 한 번이면 소리 없는 회귀로 이어질 뻔했다. 어떤 언어는 문서가 자기 코드와 어긋나 있었다. 그리고 어떤 언어는 이 기능이 처음 호출하기 전까지 이런 능력이 필요했던 적이 아예 없었다.

대상은 같은 백엔드 설계(DDD, CQRS, Outbox)를 5개 언어로 나란히 구현해 둔 내 예제 프로젝트다. 한동안 알고도 못 푼 빈틈이 하나 있었다. Go에 여러 Repository에 걸친 트랜잭션 전파가 없다는 것이었고, 기록만 해 두고 풀지 못한 상태였다. 예전 실험에서 정기 송금(recurring-transfer) 기능으로 이 설계를 검증한 적은 있지만, 그 코드는 버렸다. 실험용으로 떼어 낸 코드 사본은 원래 쓰고 버리는 것이었고, main 브랜치에는 이 기능이 필요한 사용처가 아직 없었다. 이번에 계좌 간 송금을 Go만이 아닌 5개 언어 모두에 진짜로 구현하면서, 모든 언어의 트랜잭션 장치가 프로덕션 호출자를 갖게 됐다. 몇몇 언어에서는 첫 호출자였다.

모양은 어디서나 같았다. POST /accounts/{sourceId}/transfer가 있고, TransferEligibilityService가 어느 계좌도 건드리기 전에 양쪽을 모두 확인한다. 같은 계좌인지, 두 계좌가 활성 상태인지, 통화가 같은지, 잔액이 충분한지를 본다. 그래서 거부되더라도 한쪽은 출금됐는데 다른 쪽은 입금이 안 된 상태로 남는 일이 없다. 거부할 때는 새 에러를 만들지 않고, withdraw/deposit이 같은 조건에서 이미 던지는 에러를 그대로 쓴다. Transfer에는 거부 상태를 기록할 자기 영속 Aggregate가 없기 때문이다.

새 테이블도 만들지 않았다. 출금 하나와 입금 하나, 서로 짝이 되는 transaction 행 2개가 새로 만든 id 하나를 reference_id로 같이 쓴다. 접미사는 일부러 붙이지 않았다. 예전 실험에서 접미사 붙인 id가 VARCHAR(36) 컬럼을 넘친 적이 있어서, 같은 실수를 반복할 생각이 없었다.

NestJS, 이미 동작하던 언어

NestJS에는 AsyncLocalStorage 기반 TransactionManager가 이미 있었고, 다른 곳에서도 쓰고 있었다. 인프라는 하나도 바꿀 필요가 없었다. saveAccount 호출 두 개를 .run() 하나로 감싸면 끝이었다.

Go, 뻔한 수정 바로 안쪽에 숨은 회귀

Go에는 internal/infrastructure/database/를 새로 만들어 WithTx, TxFromContext, QuerierFrom, Manager를 넣었고, 그 빈틈도 메웠다. 다음 단계는 간단해 보였다. SaveAccount가 언제나 QuerierFrom으로 querier를 가져오게 하면 될 것 같았다. 그렇게 했다면 기존 단일 계좌 호출자들의 원자성이 소리 없이 깨졌을 것이다. QuerierFrom은 context에 트랜잭션이 없으면 원시 *sql.DB를 그대로 돌려준다. 계좌 행, transaction 행, outbox 행을 한 번에 쓰던 원자적 쓰기가, 각자 auto-commit되는 명령문 3개로 쪼개졌을 것이다.

그래서 SaveAccount가 직접 TxFromContext를 확인해 커밋을 자기가 맡을지 정하게 했다. SQL 본문은 공용 private 함수로 뽑아서, 새 송금 호출과 기존 단일 계좌 호출이 모두 같은 코드를 타게 했다.

func (r *AccountRepository) SaveAccount(ctx context.Context, a *account.Account) error {
	if tx, ok := database.TxFromContext(ctx); ok {
		// An ambient transaction already owns the commit — just run inside it.
		return r.saveAccount(ctx, tx, a)
	}
	// No ambient transaction: this call owns its own commit, exactly as it always did.
	return database.WithTx(ctx, r.db, func(tx *sql.Tx) error {
		return r.saveAccount(ctx, tx, a)
	})
}

같은 변경에서 같은 종류의 실수가 하나 더 나왔다. 초안은 트랜잭션이 커밋됐는지 확인하기도 전에 메모리에 쌓아 둔 미처리 transaction과 이벤트 버퍼를 먼저 비웠다. 그 뒤 커밋이 실패하면, 기존 호출자들의 재시도 경로는 아직 갖고 있다고 믿던 데이터를 잃었을 것이다. 머지하기 전에 잡았다. 쓰기 호출이 반환됐을 때가 아니라 커밋 성공을 확인했을 때 버퍼를 비우도록 바꿨다.

위험한 버그는 새 경로에 없다

Go 버그 2개는 둘 다 송금 기능의 코드에 있지 않았다. 그럴듯한 리라이트가, 이미 있고 잘 돌던 호출자들을 망가뜨릴 뻔한 곳에 있었다. 자리 잡은 함수 밑에 공용 인프라를 깔면, 그 순간 기존 호출자 전부를 다시 시험대에 올리는 셈이다. 그걸 의식하든 못 하든 마찬가지다.

Java와 Kotlin, 같은 모양과 자기모순이던 문서

둘 다 AccountRepository.saveAccounts(source, target)를 추가하고, 각 코드베이스가 이미 하던 대로 Repository 경계에 @Transactional을 붙였다. 공용 private saveAccountInternal도 뽑아서, 새 두 계좌 경로와 기존 단일 계좌 경로가 구현 하나를 같이 쓰게 했다.

@Transactional을 어디에 붙일지 정하려고 문서를 꼼꼼히 읽다가, 문서끼리 서로 어긋나 있다는 걸 알게 됐다. Java의 design-principles.md는 이 애노테이션이 Command/Query Service에 있어야 한다고 적고 있었다. 그런데 persistence.md는 "Command Service에 @Transactional을 다시 붙이는 건 회귀다"라고 분명히 경고하고 있었고, 실제 코드도 처음부터 Repository에 붙어 있었다. 틀린 건 design-principles의 그 문장이었으니, 실제에 맞게 고쳤다.

Kotlin의 persistence.md에도 비슷한 문제가 있었다. 한 번도 구현된 적 없는 예시 코드가, 가상의 Service 레벨 TransferService에 @Transactional을 붙이고 있었다. Kotlin의 Repository 레벨 컨벤션을 따르지 않고 Java의 틀린 문서를 따른 모양이었다. 이제 기능이 실제로 생겼으니, 그 예시를 구현된 코드로 바꿀 계획이었다.

FastAPI, 한 번도 시험받지 않은 빈틈

새 Repository 메서드도 필요 없었다. Depends로 요청마다 캐싱되는 공용 AsyncSession 덕분에 save_account 두 번은 구조상 이미 원자적이었다. 대신 get_session()에 숨어 있던 빈틈이 드러났다. 예외가 나도 except도 rollback도 없었다. 지금까지는 그게 필요한 적이 없었다. 이 기능 전에는 한 요청에서 서로 다른 Aggregate 인스턴스 2개를 저장하는 일이 없었기 때문이다. 빠진 rollback이 이론상의 문제에서 실제로 일해야 하는 코드가 된 건 Transfer 핸들러가 처음이다.

고쳤다고 적은 것과 고친 것은 다르다

같은 날 후속 문서 감사를 돌렸다. 이 기능 때문에 틀린 말이 된 문서가 없는지 찾으려는 감사였고, 5개 언어에서 낡은 문서 9건이 나왔다. 대부분 송금 기능 전의 상태를 현재처럼 설명하던 문서로, 기능이 배포되면서 틀린 말이 됐다. 9건 중 하나는 조금 다른 의미로 불편했다. 두 절 위에서 말한, 예시 코드를 구현 코드로 바꾸는 Kotlin persistence.md 수정이 작업 요약에는 끝났다고 적혀 있었다. 그런데 그 편집은 실제로 한 적이 없었다. 별도 감사에서 앞의 설명을 믿지 않고 파일을 다시 읽었기에 드러났다.

check_docs_drift.py는 그동안 계속 0건을 보고했다. 이 스크립트는 경로가 있는지만 보기 때문에, 파일은 있는데 문서 설명이 그 내용과 다른 경우는 구조상 볼 수 없다. 9건을 찾은 방법은 따로 있었다. 모든 언어 문서에서 그 문서들이 습관처럼 쓰던 "아직 없다" 표현을 grep하고, 걸린 것마다 지금 상태와 손으로 대조했다. 방법은 같았고 한 단계 더 의심했을 뿐인데, 기능이 바꾼 것뿐 아니라 요약이 바꿨다고 주장만 한 것까지 잡혔다.

요구사항은 하나였고, 언어마다 이미 다른 트랜잭션 컨벤션이 5가지 있었다. 그리고 한 언어를 빼면 가장 위험한 부분은 새 코드를 쓰는 일이 아니었다. 새 코드를 옛 코드 옆에 두자, 옛 코드가 그런 조건에서 한 번도 테스트된 적이 없다는 사실이 드러났다.

더 볼 자료

transaction.go(같은 백엔드 설계를 5개 언어로 나란히 구현해 둔 내 예제 프로젝트 backend-service-playbook의 Go 트랜잭션 매니저 구현) · docs/architecture/persistence.md(모든 언어의 구현이 따라야 하는 루트 트랜잭션 경계 원칙)