트랜잭션을 걸었는데 왜 데이터가 꼬이지? — 싱글톤 Repository에서 UoW 패턴까지
결제시스템에서 발생한 트랜잭션 이슈를 해결하기까지의 여정을 소개합니다
안녕하세요. Order Payment Service를 담당하고 있는 Software Engineer 정현서입니다. 영광스럽게 포스트매스 기술 블로그에 첫 번째 글을 기고하게 되어 기쁩니다🙃
배경
PostMath의 Order Payment Service는 Go + Gin + GORM 기반의 결제/포인트/주문 처리 서비스입니다. Clean Architecture 3계층 구조로 되어 있고, 트랜잭션도 적용하고 있었습니다.
[Client] → [Handler] → [Usecase] → [Repository] → [PostgreSQL]
↕
[gRPC Client] → [외부 서비스]
- Handler: HTTP/gRPC 요청을 받아서 Usecase에 전달하는 진입점
- Usecase: 비즈니스 로직을 수행하고 트랜잭션을 관리하는 핵심 계층
- Repository: GORM을 통해 PostgreSQL에 접근하는 데이터 계층
결제가 발생하면 주문 정보 업데이트, 결제 정보 저장, 포인트 차감, 회원 등급 갱신 등 여러 도메인에 걸친 작업이 하나의 요청 안에서 이루어집니다. 이 작업들은 전부 성공하거나 전부 실패해야 하는 원자적 단위입니다.
그런데 운영 중에 포인트 잔액이 맞지 않는 케이스가 발견됐습니다. 두 결제 요청이 동시에 들어왔을 때, 둘 다 같은 잔액을 읽고 각자 차감하면서 한쪽의 결과가 사라지는 현상이었습니다. 트랜잭션을 걸고 있었는데도 발생한 이 문제의 원인과 해결 과정을 정리합니다.
초기 구조: 싱글톤 Repository와 WithTransaction
DI 구조
앱 시작 시 Repository를 한 번만 생성해서 모든 Usecase에 주입하고 있었습니다.
func setupHandlers(router *gin.Engine, db *database.Database) {
// Repository 계층 초기화 — 앱 전체에서 하나의 인스턴스
orderRepo := repository.NewOrderRepository(db)
pointRepo := repository.NewPointRepository(db)
policyRepo := repository.NewPolicyRepository(db)
// gRPC Client 세팅
mimgRPCClient := mimgRPCStart()
// Usecase 계층 초기화 — 싱글톤 Repository를 주입받음
orderUc := usecase.NewOrderUsecase(orderRepo, mimgRPCClient, pointRepo, policyRepo)
pointUc := usecase.NewPointUsecase(pointRepo, mimgRPCClient, orderRepo)
policyUc := usecase.NewPolicyUsecase(policyRepo)
}
Go의 Gin 프레임워크는 HTTP 요청이 들어올 때마다 고루틴을 생성합니다. 이 고루틴들이 전부 같은 Repository 인스턴스를 공유합니다. 각 Repository는 내부에 *gorm.DB를 하나 들고 있었고, 이 DB 커넥션도 역시 공유되는 상태였습니다.
트랜잭션 처리 방식
트랜잭션은 Repository 내부의 WithTransaction 콜백 방식으로 처리하고 있었습니다.
func (r *PointRepository) WithTransaction(
ctx context.Context,
fn func(tx IPointRepository) (*domain.PointHistoryModel, error),
) (*domain.PointHistoryModel, error) {
db := r.getDB()
var result *domain.PointHistoryModel
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// tx로 새 Repository를 만들어서 콜백에 전달
txRepo := NewPointRepositoryFromTx(tx)
res, err := fn(txRepo)
if err != nil {
return err
}
result = res
return nil
})
return result, err
}
GORM의 Transaction() 안에서 tx를 이용해 txRepo를 생성하고, 콜백 함수에 넘겨줍니다. 콜백 안에서 txRepo를 통해 쿼리를 실행하면 진짜 트랜잭션 안에서 동작합니다.
Usecase에서는 이렇게 사용했습니다.
func (u *pointUsecaseImpl) WithdrawPoint(ctx context.Context, req *opsv1.WithdrawPointRequest) (*emptypb.Empty, error) {
_, err := u.pointRepo.WithTransaction(ctx, func(tx repository.IPointRepository) (*domain.PointHistoryModel, error) {
// tx를 통해 쿼리 실행 — 진짜 트랜잭션 안에서 동작
holdingHistories, err := tx.GetHoldingHistoriesByMemberId(ctx, int(req.MemberId))
if err != nil {
return nil, fmt.Errorf("유저 보유 포인트 이력 가져오기 실패: %v", err)
}
for _, history := range holdingHistories {
err = tx.DeductedHoldingCurrentPoint(ctx, history.Id, 0)
if err != nil {
return nil, fmt.Errorf("유저 포인트 보유이력 남은 잔액 수정 실패: %v", err)
}
}
currentPoint, err := tx.GetMemberPointBalance(ctx, int(req.MemberId))
if err != nil {
return nil, fmt.Errorf("현재 잔액 가져오기 실패: %v", err)
}
err = tx.MemberCurrentPointChange(ctx, int(req.MemberId), true, false, currentPoint.CashedPoint)
if err != nil {
return nil, fmt.Errorf("현금성 포인트 잔액 변경 실패: %v", err)
}
return nil, nil
})
if err != nil {
return nil, err
}
return &emptypb.Empty{}, nil
}
WithTransaction 안에서 tx를 통해 모든 쿼리를 실행하므로, 단일 Repository 범위에서는 트랜잭션이 정상적으로 동작했습니다. 포인트 조회 → 차감 → 잔액 변경이 하나의 트랜잭션으로 묶였습니다.
여기까지는 문제가 없어 보입니다.
문제 1: Repository별 독립 트랜잭션 — 도메인 간 불일치
문제는 결제 처리에서 드러났습니다. 결제는 단일 도메인이 아니라 여러 도메인에 걸친 작업입니다.
결제 요청 → 주문 정보 조회 (OrderRepo)
→ 결제 승인 API 호출 (Toss Payments)
→ 결제 정보 저장 (OrderRepo)
→ 포인트 차감 (PointRepo)
→ 포인트 이력 저장 (PointRepo)
→ 누적 사용량 업데이트 (PointRepo)
→ 회원 등급 확인/변경 (gRPC → MIM)
→ 주문 상태 업데이트 (OrderRepo)
이 모든 작업이 하나의 원자적 단위여야 합니다. 결제 정보는 저장됐는데 포인트가 안 빠지면 안 됩니다.
그런데 WithTransaction은 PointRepository 하나의 범위에서만 트랜잭션을 보장했습니다.
func (u *orderUsecase) Payment(ctx context.Context, memberId int, ...) (...) {
// 주문 정보 조회 — tx 없음
order, err := u.repo.GetOrderByOrderId(ctx, req.OrderId)
// 결제 정보 저장 — tx 없음
if err = u.repo.SavePayment(ctx, memberId, *paymentResponse); err != nil {
return http.StatusInternalServerError, nil, fmt.Errorf("결제 정보 저장 실패: %w", err)
}
// 카드 정보 저장 — tx 없음
if paymentResponse.Card != nil {
if err = u.repo.SavePaymentCard(ctx, *paymentResponse, memberId, *paymentResponse.Card); err != nil {
return http.StatusInternalServerError, nil, fmt.Errorf("카드 결제 정보 저장 실패: %w", err)
}
}
// 포인트 사용 — PointUsecase 내부의 별도 tx
_, err = u.pointUsease.UseCard(ctx, memberId, float64(paymentResponse.TotalAmount), false)
if err != nil {
return http.StatusInternalServerError, nil, err
}
// 주문 정보 수정 — tx 없음
if err = u.repo.UpdateOrder(ctx, memberId, *paymentResponse); err != nil {
return http.StatusInternalServerError, nil, fmt.Errorf("주문 정보 저장 실패: %w", err)
}
}
OrderRepository의 작업(결제 저장, 주문 수정)은 트랜잭션 없이 실행되고, PointUsecase의 작업(포인트 차감)은 별도 트랜잭션에서 실행됩니다. 두 트랜잭션은 완전히 분리되어 있습니다.
이 구조에는 OrderUsecase가 PointUsecase를 직접 생성하는 문제도 있었습니다.
func NewOrderUsecase(repo OrderRepository, gRPCClient Client,
pointRepo IPointRepository, policyRepo PolicyRepository) OrderUsecase {
return &orderUsecase{
repo: repo,
gRPCClient: gRPCClient,
pointUsease: NewPointUsecase(pointRepo, gRPCClient, repo), // 내부에서 직접 생성
policyRepo: policyRepo,
}
}
OrderUsecase와 PointUsecase가 각자 다른 Repository 인스턴스(또는 같은 싱글톤이지만 다른 트랜잭션)를 사용하기 때문에, 둘을 같은 트랜잭션으로 묶을 방법이 없었습니다.
주문은 저장됐는데 포인트 차감이 실패하면? 별도로 주문을 취소하는 보상 트랜잭션(Compensating Transaction)을 구현해야 합니다. 실제로 코드에 이런 보상 로직이 있었고, 복잡도를 높이는 주요 원인이었습니다.
또한 새로운 도메인(배송, 환불 등)이 추가될 때마다 생성자 파라미터가 늘어나는 확장성 문제도 있었습니다.
문제 2: 동시 SELECT — Lost Update
트랜잭션 범위를 넓혀서 문제 1을 해결한다 하더라도, 근본적인 동시성 문제가 하나 더 있습니다.
PostgreSQL의 기본 격리 수준은 Read Committed입니다. 이 격리 수준에서는 다른 트랜잭션이 커밋한 결과를 읽을 수 있고, 같은 행을 동시에 SELECT하는 것을 막지 않습니다.
포인트 차감 시나리오로 보겠습니다.
시간 →
TX-A: BEGIN
SELECT balance WHERE member_id = 1 → 잔액: 10,000
↑ 같은 값을 읽음
TX-B: BEGIN
SELECT balance WHERE member_id = 1 → 잔액: 10,000
UPDATE balance = 5,000 (10,000 - 5,000)
COMMIT
↓ A의 업데이트를 모르고 덮어씀
UPDATE balance = 7,000 (10,000 - 3,000)
COMMIT
기대 결과: 잔액 2,000 (10,000 - 5,000 - 3,000)
실제 결과: 잔액 7,000 (마지막 쓰기가 이김)
이것이 Lost Update 문제입니다. 8,000원이 차감되어야 하는데 3,000원만 빠집니다. 두 트랜잭션이 동시에 같은 잔액을 읽고, 각자 계산한 결과를 쓰면, 나중에 쓴 값이 먼저 쓴 값을 덮어씁니다.
트랜잭션은 “내 안의 쿼리들이 원자적으로 커밋/롤백된다”를 보장하지, “다른 트랜잭션이 같은 데이터를 동시에 읽지 못한다”를 보장하지 않습니다. 이 차이를 이해하는 것이 중요합니다.
운영 환경에서 동시 결제 요청이 들어올 때 실제로 이 현상이 발생했고, 포인트 잔액 불일치의 직접적인 원인이었습니다.
해결 1: UoW(Unit of Work) 패턴 — 여러 Repository를 하나의 트랜잭션으로
개념
마트에서 물건을 하나 집을 때마다 계산하는 사람은 없습니다. 카트에 다 담고 마지막에 계산대에서 한 번에 결제합니다. 카드가 거부되면 전체 취소합니다.
UoW(Unit of Work)도 같은 개념입니다. Martin Fowler가 정의한 엔터프라이즈 애플리케이션 아키텍처 패턴 중 하나로, 여러 Repository의 작업을 하나의 트랜잭션으로 묶어서 전체 성공 또는 전체 실패를 보장합니다.
기존 WithTransaction과의 차이는 범위입니다.
| WithTransaction | UoW | |
|---|---|---|
| 트랜잭션 범위 | 단일 Repository | 여러 Repository |
| 사용 방식 | 콜백 안에서 txRepo 사용 | Begin/Commit/Rollback 직접 호출 |
| 도메인 간 일관성 | 보장 안 됨 | 보장됨 |
구현
5월 26일, UoW 인터페이스와 구현체를 작성했습니다.
// repository/UoW.go
type UnitOfWork interface {
Begin(ctx context.Context) error
Commit(ctx context.Context) error
Rollback(ctx context.Context) error
// Repository 팩토리 — 같은 tx를 자동으로 공유
OrderRepository() OrderRepository
PointRepository() PointRepository
PolicyRepository() PolicyRepository
}
type gormUnitOfWork struct {
db *gorm.DB // 주 DB 연결
tx *gorm.DB // 활성 트랜잭션 (Begin 후 생성)
}
func NewGormUnitOfWork(db *gorm.DB) UnitOfWork {
return &gormUnitOfWork{db: db}
}
트랜잭션 관리 메서드:
func (uow *gormUnitOfWork) Begin(ctx context.Context) error {
uow.tx = uow.db.WithContext(ctx).Begin()
return uow.tx.Error
}
func (uow *gormUnitOfWork) Commit(ctx context.Context) error {
if uow.tx == nil {
return nil // 트랜잭션이 시작되지 않았거나 이미 종료됨
}
err := uow.tx.WithContext(ctx).Commit().Error
uow.tx = nil // 트랜잭션 종료 후 초기화
return err
}
func (uow *gormUnitOfWork) Rollback(ctx context.Context) error {
if uow.tx == nil {
return nil
}
err := uow.tx.WithContext(ctx).Rollback().Error
uow.tx = nil
return err
}
그리고 핵심인 Repository 팩토리 메서드:
func (uow *gormUnitOfWork) OrderRepository() OrderRepository {
if uow.tx != nil {
return NewOrderRepository(uow.tx) // 트랜잭션 안에서
}
return NewOrderRepository(uow.db) // 트랜잭션 없이
}
func (uow *gormUnitOfWork) PointRepository() PointRepository {
if uow.tx != nil {
return NewPointRepository(uow.tx) // 같은 tx!
}
return NewPointRepository(uow.db)
}
func (uow *gormUnitOfWork) PolicyRepository() PolicyRepository {
if uow.tx != nil {
return NewPolicyRepository(uow.tx)
}
return NewPolicyRepository(uow.db)
}
Begin()을 호출하면 uow.tx가 생기고, 이후 OrderRepository()나 PointRepository()를 호출하면 이 tx가 주입된 Repository를 반환합니다. 모든 Repository가 같은 트랜잭션을 공유하는 것이 핵심입니다.
트랜잭션이 필요 없는 조회 API에서는 Begin()을 호출하지 않으면 uow.tx가 nil이므로, 일반 db 커넥션으로 Repository가 생성됩니다. 같은 UoW 인터페이스로 트랜잭션이 필요한 경우와 불필요한 경우를 모두 처리할 수 있습니다.
DI 구조 변경
// Before: 개별 Repository 4개를 주입
orderUc := usecase.NewOrderUsecase(orderRepo, mimgRPCClient, pointRepo, policyRepo)
pointUc := usecase.NewPointUsecase(pointRepo, mimgRPCClient, orderRepo)
policyUc := usecase.NewPolicyUsecase(policyRepo)
// After: UoW 하나만 주입
uow := repository.NewGormUnitOfWork(db.PostgreSQL)
orderUc := usecase.NewOrderUsecase(uow, mimgRPCClient)
pointUc := usecase.NewPointUsecase(uow, mimgRPCClient)
policyUc := usecase.NewPolicyUsecase(uow)
생성자 파라미터가 크게 줄어들었습니다. 새 도메인(배송, 환불 등)이 추가되면 UoW 인터페이스에 팩토리 메서드만 추가하면 됩니다.
Usecase 구조체도 단순해졌습니다.
// Before
type orderUsecase struct {
repo repository.OrderRepository
gRPCClient mimv1.MimServiceClient
pointUsease PointUsecase
policyRepo repository.PolicyRepository
}
// After
type orderUsecase struct {
uow repository.UnitOfWork
gRPCClient mimv1.MimServiceClient
}
Usecase 사용 패턴
UoW를 사용하는 결제 처리 코드입니다.
func (u *orderUsecase) Payment(ctx context.Context, memberId int, req PaymentConfirmRequest) (int, *PaymentResponse, error) {
// 1. 트랜잭션 시작
if err := u.uow.Begin(ctx); err != nil {
return http.StatusInternalServerError, nil, err
}
// 2. 같은 tx를 공유하는 Repository 획득
orderRepo := u.uow.OrderRepository()
pointRepo := u.uow.PointRepository()
// 3. 실패 시 자동 롤백 보장
committed := false
defer func() {
if !committed {
_ = u.uow.Rollback(ctx)
}
}()
// 4. 비즈니스 로직 — 전부 같은 트랜잭션
order, err := orderRepo.GetOrderByOrderId(ctx, req.OrderId)
if err != nil {
return http.StatusInternalServerError, nil, fmt.Errorf("주문 정보 불러오기 실패: %w", err)
}
// ... 결제 승인 API 호출 ...
if err = orderRepo.SavePayment(ctx, memberId, *paymentResponse); err != nil {
return http.StatusInternalServerError, nil, fmt.Errorf("결제 정보 저장 실패: %w", err)
}
// 포인트 차감 — OrderRepo와 같은 tx에서 실행!
cumulative, err := pointRepo.GetCumulativeMemberPoint(ctx, memberId)
if err != nil {
return http.StatusBadRequest, nil, fmt.Errorf("누적 포인트 조회 실패: %w", err)
}
cumulative.UsedCard += req.PurchaseAmount
if err = pointRepo.UpsertCumulativeMemberPoint(ctx, memberId, cumulative); err != nil {
return http.StatusBadRequest, nil, fmt.Errorf("누적 포인트 업데이트 실패: %w", err)
}
// 주문 정보 수정 — 여전히 같은 tx
if err = orderRepo.UpdateOrder(ctx, memberId, *paymentResponse); err != nil {
return http.StatusInternalServerError, nil, fmt.Errorf("주문 정보 저장 실패: %w", err)
}
// 5. 모든 작업 성공 시 한 번에 커밋
if err = u.uow.Commit(ctx); err != nil {
return http.StatusInternalServerError, nil, fmt.Errorf("트랜잭션 커밋 실패: %w", err)
}
committed = true
return http.StatusOK, resp, nil
}
orderRepo와 pointRepo가 같은 트랜잭션 안에서 동작합니다. 어디서든 에러가 발생하면 defer에 의해 전체가 롤백되므로, “결제는 저장됐는데 포인트는 안 빠졌다”는 불일치가 구조적으로 불가능해집니다.
PointUsecase를 직접 생성할 필요도 없어졌습니다. uow.PointRepository()로 같은 트랜잭션의 PointRepo를 바로 꺼내 쓰면 됩니다.
해결 2: SELECT FOR UPDATE — 동시 읽기 차단
UoW로 트랜잭션 범위 문제(문제 1)는 해결했지만, 동시 SELECT로 인한 Lost Update(문제 2)는 여전히 남아있었습니다.
6월 13일, 포인트 관련 SELECT 쿼리에 FOR UPDATE를 추가했습니다.
변경 내용
// Before — 일반 SELECT
func (r *pointRepository) GetMemberPointBalance(ctx context.Context, memberId int) (*domain.CurrentMemberPoints, error) {
var entity *domain.CurrentMemberPoints
err := r.db.WithContext(ctx).
Where("member_id = ?", memberId).
First(&entity).Error
return entity, err
}
// After — SELECT FOR UPDATE
func (r *pointRepository) GetMemberPointBalance(ctx context.Context, memberId int) (*domain.CurrentMemberPoints, error) {
var entity *domain.CurrentMemberPoints
err := r.db.WithContext(ctx).
Where("member_id = ?", memberId).
Clauses(clause.Locking{Strength: "UPDATE"}). // 이 한 줄 추가
First(&entity).Error
return entity, err
}
GORM에서는 clause.Locking{Strength: "UPDATE"} 한 줄로 SELECT ... FOR UPDATE를 적용할 수 있습니다.
포인트 잔액 조회뿐 아니라, 포인트 보유 이력, 누적 포인트, 포인트 히스토리 등 금전 데이터를 다루는 모든 SELECT 쿼리에 FOR UPDATE를 추가했습니다.
// GetHoldingHistoryByMemberId — 포인트 보유 이력 (FIFO 차감 시 사용)
query := r.db.WithContext(ctx).
Table(domain.PointHoldingHistories{}.TableName()).
Where("member_id = ?", memberId).
Where("current_point > 0").
Where(gorm.Expr("expiry_date > NOW() OR expiry_date IS NULL")).
Order("created_at ASC").
Order("id ASC").
Clauses(clause.Locking{Strength: "UPDATE"}) // FOR UPDATE 추가
// GetCumulativeMemberPoint — 누적 사용량
err := r.db.WithContext(ctx).
Where("member_id = ?", memberId).
Clauses(clause.Locking{Strength: "UPDATE"}). // FOR UPDATE 추가
First(&entity).Error
// GetPointHistoriesByOrderId — 주문별 포인트 이력
err := r.db.WithContext(ctx).
Table(domain.PointHistories{}.TableName()).
Where("purchase_order_id = ?", orderId).
Clauses(clause.Locking{Strength: "UPDATE"}). // FOR UPDATE 추가
Find(&entities).Error
FOR UPDATE가 하는 일
SELECT ... FOR UPDATE는 SELECT 시점에 해당 행에 잠금(Lock) 을 겁니다. 다른 트랜잭션이 같은 행을 SELECT FOR UPDATE하려 하면, 첫 번째 트랜잭션이 끝날 때까지 대기합니다.
TX-A: BEGIN
SELECT balance WHERE member_id = 1 FOR UPDATE → 10,000 (잠금 획득)
TX-B: BEGIN
SELECT balance WHERE member_id = 1 FOR UPDATE → 대기... (A가 잠금 보유 중)
TX-A: UPDATE balance = 5,000 (10,000 - 5,000)
COMMIT → 잠금 해제
TX-B: → 이제 읽힘! 잔액: 5,000 (A가 업데이트한 최신 값)
UPDATE balance = 2,000 (5,000 - 3,000)
COMMIT
결과: 정확히 8,000 차감! (Lost Update 방지)
TX-B는 TX-A가 커밋할 때까지 기다린 후 최신 값을 읽습니다. TX-A가 5,000으로 업데이트한 결과를 바탕으로 TX-B가 차감하므로, 최종 잔액은 정확히 2,000원이 됩니다.
비관적 잠금 vs 낙관적 잠금
이 방식을 비관적 잠금(Pessimistic Locking) 이라 합니다. “충돌이 발생할 것이다”라고 비관적으로 가정하고, 미리 잠금을 걸어서 충돌을 원천 차단합니다.
반대로 낙관적 잠금(Optimistic Locking) 은 version 컬럼을 두고, 업데이트 시 version이 변경되었는지 확인합니다. 변경되었으면 충돌이 발생한 것이므로 재시도합니다. 읽기 위주이고 충돌이 드문 경우에 적합합니다.
결제 시스템에서 비관적 잠금을 선택한 이유는 두 가지입니다:
- 충돌 확률이 높음: 같은 회원이 동시에 결제할 가능성이 있음
- 재시도 비용이 큼: 결제 승인 API 호출, 포인트 차감 등 사이드 이펙트가 있는 작업을 재시도하기 어려움
FOR UPDATE를 추가한 후에는 트랜잭션 타임아웃이 발생하는 경우가 있었습니다. 대기 시간이 길어질 수 있기 때문에 타임아웃을 조정하는 작업도 함께 진행했습니다.
해결 3: UoW 미들웨어 — 요청 격리와 안전망
6월 19일, UoW 생명주기를 Gin 미들웨어로 옮겼습니다. 두 가지 목적이 있었습니다.
목적 1: 요청마다 새 UoW 인스턴스 생성
초기 UoW는 setupHandlers에서 하나만 만들어 Usecase에 주입했습니다. 이렇게 하면 gormUnitOfWork 안의 tx 필드가 요청 간에 공유될 수 있습니다. 미들웨어에서 요청마다 새 UoW를 생성하면 이 문제가 해결됩니다.
목적 2: 커밋/롤백 누락 방지
개발자가 실수로 Commit()이나 Rollback()을 빠뜨리면 트랜잭션이 열린 채로 남아서 커넥션 풀을 소모합니다. 미들웨어가 핸들러 실행 후 트랜잭션 상태를 확인해서 자동 롤백하면 이런 실수를 방지할 수 있습니다.
구현
// middleware/handler.go
func UowMiddleware(db *gorm.DB, uowFactory func(*gorm.DB) types.UnitOfWork) gin.HandlerFunc {
return func(c *gin.Context) {
// OPTIONS, health check 요청은 UoW 불필요
if c.Request.Method == "OPTIONS" {
c.Next()
return
}
if c.Request.URL.Path == "/healthz" || c.Request.URL.Path == "/readyz" || c.Request.URL.Path == "/livez" {
c.Next()
return
}
// 1. 요청마다 새 UoW 생성 — 요청 간 격리
uow := uowFactory(db)
// 2. 트랜잭션 상태를 추적할 포인터 변수
closedTransaction := true
// 3. Context에 UoW와 트랜잭션 상태 저장
ctx := context.WithValue(c.Request.Context(), types.UoW, uow)
ctx = context.WithValue(ctx, types.ClosedTransaction, &closedTransaction)
c.Request = c.Request.WithContext(ctx)
// 4. 패닉 발생 시 롤백 보장
defer func() {
if r := recover(); r != nil {
_ = uow.Rollback(ctx)
panic(r) // 원래 패닉은 계속 전파
}
}()
// 5. 핸들러 실행
c.Next()
// 6. 안전망: 트랜잭션이 닫히지 않았으면 롤백
if !closedTransaction {
_ = uow.Rollback(ctx)
}
}
}
closedTransaction 포인터 트릭
Go의 context.Context는 불변(immutable) 입니다. context.WithValue로 값을 저장하면 새 context가 만들어지고, 원래 context는 변하지 않습니다. 그래서 context에 bool 값을 직접 저장하면, Usecase에서 값을 변경해도 미들웨어의 context에는 반영되지 않습니다.
이 문제를 *bool 포인터로 해결했습니다.
// types/context.go
type ContextKey string
const (
UoW ContextKey = "uow"
ClosedTransaction ContextKey = "closedTransaction"
)
// 포인터를 통해 값을 변경하는 헬퍼
func SetTransactionClosed(ctx context.Context, closed bool) {
if ptr, ok := ctx.Value(ClosedTransaction).(*bool); ok && ptr != nil {
*ptr = closed
}
}
미들웨어에서 closedTransaction 변수의 포인터(&closedTransaction)를 context에 저장합니다. UoW의 Begin()은 *ptr = false로, Commit()/Rollback()은 *ptr = true로 변경합니다. 미들웨어와 UoW가 같은 메모리 주소를 참조하므로 상태 동기화가 가능합니다.
closedTransaction의 초기값이 true인 이유는, GET 요청처럼 트랜잭션이 필요 없는 경우 Begin()을 호출하지 않기 때문입니다. 초기값이 false면 미들웨어가 불필요한 롤백을 시도합니다. Begin() 호출 시 false로 변경되고, Commit() 또는 Rollback() 호출 시 true로 돌아갑니다.
해결 4: VO(Value Object) — 트랜잭션 전의 방어선
왜 필요한가
잘못된 입력이 Usecase까지 내려오면 어떻게 될까요? 트랜잭션이 시작되고, FOR UPDATE로 포인트 행이 잠깁니다. 그 후 검증 로직에서 “음수 포인트는 허용되지 않습니다”와 같은 에러가 발생하고, 롤백됩니다.
이 과정에서 다른 정상적인 결제 요청은 FOR UPDATE 잠금 때문에 대기합니다. 잘못된 입력 하나가 정상 트래픽의 지연을 유발하는 것입니다.
VO(Value Object)는 트랜잭션 시작 전에 입력을 검증합니다. 유효하지 않은 요청은 Handler 레벨에서 즉시 반환되므로, 불필요한 트랜잭션 시작과 잠금 획득을 방지합니다.
구현
VO는 팩토리 함수에서 생성 시점에 모든 비즈니스 규칙을 검증하고, 하나라도 실패하면 nil을 반환하는 패턴입니다.
// domain/pointGiftVO.go
type GiftPointRequestVO struct {
FromMemberId int
ToMemberId int
Point float64
RequestMessage *string
IdentityId string
}
func NewGiftPointRequestVo(fromMemberId, toMemberId int, point float64,
requestMessage *string, identityId string) *GiftPointRequestVO {
// Member ID는 양수여야 함
if fromMemberId <= 0 || toMemberId <= 0 {
return nil
}
// 자기 자신에게 선물 불가
if fromMemberId == toMemberId {
return nil
}
// 포인트는 양수여야 함
if point <= 0 {
return nil
}
// 실명인증 ID는 필수
if identityId == "" {
return nil
}
return &GiftPointRequestVO{
FromMemberId: fromMemberId,
ToMemberId: toMemberId,
Point: point,
RequestMessage: requestMessage,
IdentityId: identityId,
}
}
nil이 아닌 VO가 반환됐다면, 그 데이터는 모든 비즈니스 규칙을 통과한 유효한 상태입니다.
Handler에서는 이렇게 사용합니다.
func (h *pointHandler) GiftPoint(c *gin.Context) {
var req GiftPointRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "잘못된 요청 형식"})
return
}
// VO 생성 — 비즈니스 규칙 검증
vo := domain.NewGiftPointRequestVo(
req.FromMemberId, req.ToMemberId, req.Point, req.RequestMessage, req.IdentityId,
)
if vo == nil {
// 트랜잭션 시작 전에 차단!
c.JSON(http.StatusBadRequest, gin.H{"error": "유효하지 않은 요청"})
return
}
// Usecase는 VO를 받으면 데이터가 유효하다고 신뢰
statusCode, resp, err := h.usecase.GiftPoint(c.Request.Context(), vo)
if err != nil {
c.JSON(statusCode, gin.H{"error": err.Error()})
return
}
c.JSON(statusCode, resp)
}
Usecase는 VO를 받으면 fromMemberId > 0, point > 0, fromMemberId != toMemberId 등을 다시 검증할 필요가 없습니다. 방어 코드 없이 비즈니스 로직에 집중할 수 있습니다.
이 패턴은 포인트 선물 외에도 정책(Policy) 관련 요청, 알림(NMS) 메시지 등 다양한 도메인에 적용했습니다.
// domain/policy_vo.go — 정책 VO
func NewPolicyRequestVo(plan, action string, ..., discountRate int, ...) *PolicyRequestVO {
if plan == "" || action == "" {
return nil
}
if periodEnd.Before(periodStart) {
return nil
}
if discountRate < 0 || discountRate > 100 {
return nil
}
// ...
}
// domain/nmsVO.go — 알림 메시지 VO
func NewMessageContentVo(title, content string, retentionType RetentionType, ...) *MessageContentVO {
if title == "" || content == "" || retentionType == "" {
return nil
}
if len([]rune(title)) > 256 {
return nil
}
// ...
}
정리
| 문제 | 원인 | 해결 |
|---|---|---|
| 도메인 간 트랜잭션 분리 | Repository별 독립 트랜잭션, Usecase 직접 생성 | UoW — 여러 Repository를 하나의 tx로 통합 |
| Lost Update | Read Committed에서 동시 SELECT 허용 | SELECT FOR UPDATE — 비관적 잠금 |
| 요청 간 UoW 상태 공유 | 싱글톤 인스턴스 | 미들웨어 — 요청당 UoW 생성 + 자동 롤백 |
| 잘못된 입력이 tx까지 도달 | 검증 없이 Usecase 진입 | VO — 팩토리 함수에서 생성 시점 검증 |
진화 타임라인:
4/30 WithTransaction — 단일 Repository 범위의 트랜잭션 (콜백 방식)
5/26 UoW 구현 — 여러 Repository를 하나의 tx로 통합 + DI 단순화
6/13 SELECT FOR UPDATE — 동시 읽기 직렬화, Lost Update 방지
6/19 UoW 미들웨어 — 요청당 인스턴스 생성 + closedTransaction 포인터로 자동 롤백
가장 큰 교훈은 “트랜잭션을 걸었다” ≠ “보호된다” 입니다.
트랜잭션이 존재한다는 것만으로는 부족합니다. 그 트랜잭션의 범위가 비즈니스 단위와 일치하는지, 동시 읽기에 대한 잠금이 있는지, 요청 간 격리가 되는지까지 확인해야 진짜 보호됩니다.
읽어주셔서 감사합니다.
*정현서 | Software Engineer at Order Payment Service @PostMath Co., Ltd.