Post

파일 업로드 동시성 제어 - 비관적 락과 낙관적 락의 조합

MongoDB WriteConflict 에러를 해결하기 위해 Redis 분산 락과 MongoDB 트랜잭션 재시도를 결합한 동시성 제어 메커니즘 구현 경험

파일 업로드 동시성 제어 - 비관적 락과 낙관적 락의 조합

문제의 시작: WriteConflict 에러

AI 플랫폼에서 사용자가 10개 이상의 파일을 동시에 업로드하면 일부가 실패하는 문제가 발생했다.

1
2
ERROR - WriteConflict: write conflict during plan execution
ERROR - WriteConflict: write conflict during plan execution

MongoDB의 WriteConflict 에러였다. 원인은 명확했다: 동일한 사용자 문서의 file_ids 배열을 여러 트랜잭션이 동시에 업데이트하려고 했기 때문이다.

문제 상황

sequenceDiagram
    participant Client as 클라이언트
    participant API as API Gateway
    participant MongoDB as MongoDB

    Note over Client,MongoDB: 동시 파일 업로드 10개
    Client->>API: 파일 1 업로드
    Client->>API: 파일 2 업로드
    Client->>API: 파일 3 업로드

    API->>MongoDB: 트랜잭션 1 (file_ids 업데이트)
    API->>MongoDB: 트랜잭션 2 (file_ids 업데이트)
    API->>MongoDB: 트랜잭션 3 (file_ids 업데이트)

    MongoDB-->>API: 트랜잭션 1 성공
    MongoDB-->>API: WriteConflict!
    MongoDB-->>API: WriteConflict!

해결책: 비관적 락 + 낙관적 락

하나의 락 전략만으로는 부족했다. Redis 분산 락(비관적 락)MongoDB 트랜잭션 재시도(낙관적 락)를 결합했다.

전체 아키텍처

flowchart TD
    subgraph 동시성제어["동시성 제어 메커니즘"]
        A[분산 락<br/>Pessimistic Locking] --> B[Redis SET NX<br/>원자적 락 획득]
        C[낙관적 락<br/>Optimistic Locking] --> D[MongoDB 트랜잭션<br/>재시도 메커니즘]
    end

    subgraph 락레벨["락 레벨"]
        B --> E[사용자 레벨]
        B --> F[세션 레벨]
        B --> G[에이전트 레벨]
    end

    subgraph 재시도["재시도 전략"]
        D --> I["Exponential Backoff<br/>50ms → 200ms → 450ms"]
        I --> J[최대 3회 재시도]
    end

    subgraph 안전장치["안전 장치"]
        K["TTL 5분<br/>데드락 방지"] --> B
        L["Graceful Degradation<br/>Redis 장애 시 락 없이 진행"] --> B
    end

1단계: Redis 분산 락 획득

동시 접근을 원천적으로 차단한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func (s *fileService) acquireUserFileUploadLock(ctx context.Context, lockKey string) (bool, error) {
    if s.services.Cache == nil {
        // Cache가 없으면 락 없이 진행 (graceful degradation)
        return true, nil
    }

    lockValue := time.Now().UTC().Format(time.RFC3339Nano)
    ttl := 5 * time.Minute

    // Redis SET NX: 키가 없을 때만 설정 (원자적)
    acquired, err := s.services.Cache.SetNX(lockKey, lockValue, ttl)
    if err != nil {
        return false, err
    }

    return acquired, nil
}

2단계: MongoDB 트랜잭션 재시도

분산 락 내에서 발생하는 일시적 충돌을 처리한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
func (s *fileService) uploadUserFileInternal(ctx context.Context, ...) (*models.File, error) {
    // 1. 락 획득
    lockKey := s.generateUserFileUploadLockKey(user.ID, fileSize)
    lockAcquired, err := s.acquireUserFileUploadLock(ctx, lockKey)
    if !lockAcquired {
        return nil, customErrors.New(customErrors.ErrConflict,
            "another file upload is in progress", nil)
    }
    defer s.releaseUserFileUploadLock(lockKey)

    // 2. MongoDB 트랜잭션 재시도 로직
    maxRetries := 3
    for attempt := 0; attempt < maxRetries; attempt++ {
        if attempt > 0 {
            // Exponential backoff: attempt² × 50ms
            waitTime := time.Duration(attempt*attempt) * 50 * time.Millisecond
            time.Sleep(waitTime)
        }

        session, _ := s.services.MongoDB.StartSession()
        defer session.EndSession(ctx)

        _, err = session.WithTransaction(ctx, func(mongoCtx mongo.SessionContext) (any, error) {
            // 파일 삽입, 권한 생성, 사용자 file_ids 업데이트
            return nil, nil
        })

        // WriteConflict가 아니면 즉시 반환
        if err == nil || !isWriteConflict(err) {
            break
        }
    }

    return file, err
}

func isWriteConflict(err error) bool {
    return strings.Contains(err.Error(), "WriteConflict") ||
           strings.Contains(err.Error(), "Write conflict")
}

파일 크기별 락 키 세분화

문제가 하나 더 있었다. 10GB 파일을 업로드하는 동안 1MB 파일도 대기해야 했다. 파일 크기별로 락 키를 분리했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func getFileSizeCategory(fileSize int64) string {
    const (
        smallLimit  = 10 * 1024 * 1024   // 10MB
        mediumLimit = 100 * 1024 * 1024  // 100MB
    )

    if fileSize <= smallLimit {
        return "small"
    } else if fileSize <= mediumLimit {
        return "medium"
    }
    return "large"
}

func (s *fileService) generateUserFileUploadLockKey(userID string, fileSize int64) string {
    category := getFileSizeCategory(fileSize)
    return fmt.Sprintf("user_file_upload_lock:%s:%s", userID, category)
}

결과:

  • 5MB 파일: user_file_upload_lock:user123:small
  • 50MB 파일: user_file_upload_lock:user123:medium
  • 200MB 파일: user_file_upload_lock:user123:large

이제 대용량 파일 업로드 중에도 소용량 파일을 바로 업로드할 수 있다.

서버 측 자동 재시도

처음에는 락 획득 실패 시 즉시 409 Conflict를 반환했다. 하지만 클라이언트 재시도 로직이 복잡해지고 네트워크 트래픽이 증가했다. 서버 측에서 자동 재시도하도록 변경했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
func (s *fileService) acquireUserFileUploadLock(ctx context.Context, lockKey string) (bool, error) {
    maxWaitTime := 30 * time.Second
    initialBackoff := 100 * time.Millisecond
    maxBackoff := 2 * time.Second
    backoff := initialBackoff
    startTime := time.Now()

    for {
        // Context 취소 확인
        if ctx.Err() != nil {
            return false, ctx.Err()
        }

        // 최대 대기 시간 확인
        if time.Since(startTime) > maxWaitTime {
            return false, customErrors.New(customErrors.ErrTimeout,
                "lock acquisition timeout: failed to acquire lock within 30 seconds", nil)
        }

        // 락 획득 시도
        acquired, err := s.services.Cache.SetNX(lockKey, lockValue, ttl)
        if err != nil {
            return false, err
        }
        if acquired {
            return true, nil
        }

        // Exponential backoff: 100ms → 150ms → 225ms → ...
        time.Sleep(backoff)
        backoff = time.Duration(float64(backoff) * 1.5)
        if backoff > maxBackoff {
            backoff = maxBackoff
        }
    }
}

효과:

  • 클라이언트 재시도 로직 불필요
  • 네트워크 트래픽 감소
  • 사용자 경험 개선 (대부분 30초 이내 성공)

락 레벨 설계

어떤 단위로 락을 걸어야 할까? 파일 레벨은 너무 세밀하고, 전역 락은 너무 거칠다.

mindmap
  root((동시성 제어<br/>락 레벨))
    사용자 레벨 락
      user_file_upload_lock
        파일 업로드
        파일 삭제
    세션 레벨 락
      session_file_add_lock
        세션에 파일 추가
        세션 파일 삭제
    에이전트 레벨 락
      agent_file_add_lock
        에이전트에 파일 추가
      agent_config_lock
        MCP 연결/해제
        ToolSet 연결/해제
락 레벨락 키적용 대상
사용자user_file_upload_lock:{userID}:{size}파일 업로드, 삭제
세션session_file_add_lock:{sessionID}세션 파일 추가/삭제
에이전트agent_file_add_lock:{agentID}에이전트 파일 추가
에이전트 설정agent_config_lock:{agentID}MCP, ToolSet 연결

MinIO 고아 객체 관리

파일 업로드는 MinIO에 저장 후 MongoDB에 메타데이터를 기록한다. 만약 MongoDB 저장이 실패하면? MinIO에 파일은 있지만 참조가 없는 고아 객체가 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// MinIO 삭제 실패 시 고아 객체 기록
func (s *fileService) recordOrphanMinIOObject(ctx context.Context, bucket, key string, err error) {
    orphan := models.OrphanMinIOObject{
        ID:        primitive.NewObjectID(),
        Bucket:    bucket,
        Key:       key,
        Status:    "pending",
        Retries:   0,
        Error:     err.Error(),
        CreatedAt: time.Now().UTC(),
    }

    s.services.MongoDB.Collection("orphan_minio_objects").InsertOne(ctx, orphan)
}

// 배치 작업으로 주기적 정리 (최대 3회 재시도, 1시간 간격)
func (s *fileService) CleanupOrphanMinIOObjects(ctx context.Context) error {
    filter := bson.M{
        "retries": bson.M{"$lt": 3},
        "last_retry_at": bson.M{
            "$lt": time.Now().UTC().Add(-1 * time.Hour),
        },
    }

    cursor, _ := s.services.MongoDB.Collection("orphan_minio_objects").Find(ctx, filter)

    for cursor.Next(ctx) {
        var orphan models.OrphanMinIOObject
        cursor.Decode(&orphan)

        // MinIO에서 삭제 시도
        err := s.services.MinIO.DeleteObject(ctx, orphan.Bucket, orphan.Key)
        if err == nil {
            // 성공: 상태를 deleted로 변경
            s.updateOrphanStatus(ctx, orphan.ID, "deleted")
        } else {
            // 실패: 재시도 횟수 증가
            s.incrementOrphanRetries(ctx, orphan.ID, err)
        }
    }

    return nil
}

성능 비교

시나리오BeforeAfter
정상 케이스즉시 성공즉시 성공 + 2-4ms (Redis 호출)
동시 업로드 10개1개 성공, 9개 WriteConflict10개 모두 성공 (순차 처리)
동시 업로드 + 대용량대용량 완료까지 모두 대기크기별 독립 처리

Graceful Degradation

Redis가 죽으면? 락 없이 진행하도록 설계했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func (s *fileService) acquireUserFileUploadLock(ctx context.Context, lockKey string) (bool, error) {
    if s.services.Cache == nil {
        // 개발 환경: 락 없이 진행
        return true, nil
    }

    acquired, err := s.services.Cache.SetNX(lockKey, lockValue, ttl)
    if err != nil {
        // 운영 환경: Redis 장애 시 에러 반환
        if s.isProduction() {
            return false, customErrors.New(customErrors.ErrDependency,
                "redis unavailable", err)
        }
        // 개발 환경: 경고만 남기고 락 없이 진행
        s.logger.Warnf("redis unavailable, proceeding without lock: %v", err)
        return true, nil
    }

    return acquired, nil
}

배운 점

1. 단일 락 전략의 한계

비관적 락만 사용하면 동시성이 떨어지고, 낙관적 락만 사용하면 충돌이 많아진다. 두 전략을 결합하면 각각의 단점을 보완할 수 있다.

2. 락 키 설계의 중요성

너무 세밀한 락(파일 단위)은 오버헤드가 크고, 너무 거친 락(전역)은 동시성을 죽인다. 실제 충돌 패턴을 분석해서 적절한 단위를 선택해야 한다.

3. 서버 측 재시도의 효과

클라이언트에 부담을 주지 않고 서버에서 재시도하면:

  • 네트워크 트래픽 감소
  • 클라이언트 코드 단순화
  • 사용자 경험 개선

4. 장애 대응 설계

Redis 장애 시에도 서비스가 동작하도록 Graceful Degradation을 설계했다. 다만 운영 환경에서는 데이터 일관성을 위해 에러를 반환한다.

결론

파일 업로드 동시성 문제를 해결하면서 배운 것:

  1. 비관적 락 + 낙관적 락 조합: 동시 접근은 분산 락으로 차단, 일시적 충돌은 재시도로 해결
  2. 락 키 세분화: 파일 크기별로 분리하여 대용량이 소용량을 차단하지 않음
  3. 서버 측 자동 재시도: 30초 내 exponential backoff로 클라이언트 부담 제거
  4. 고아 객체 관리: 분산 시스템에서 불일치는 필연적, 정리 메커니즘 필수

가장 중요한 것은 문제 패턴을 정확히 분석하고, 상황에 맞는 전략을 조합하는 것이다.

This post is licensed under CC BY 4.0 by the author.