Post

권한 시스템 리팩토링 - Shares-Based SSOT 아키텍처로 40개 이슈 해결하기

GrantSources 문제에서 Shares-Based SSOT 아키텍처로 전환하며 해결한 40개 이슈와 구현 경험

권한 시스템 리팩토링 - Shares-Based SSOT 아키텍처로 40개 이슈 해결하기

권한 시스템을 리팩토링하면서 기존 GrantSources 방식의 문제점을 분석하고, Shares-Based SSOT(Single Source of Truth) 아키텍처로 전환하여 3단계에 걸쳐 40개의 이슈를 해결한 경험을 공유합니다.

문제 상황: GrantSources의 한계

기존 구조의 문제점

기존 권한 시스템은 Permission 엔터티 내에 GrantSources 필드로 권한 출처를 관리했습니다:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 기존 구조 (문제 있음)
type Permission struct {
    ID           string        `bson:"_id"`
    UserID       string        `bson:"user_id"`
    ResourceID   string        `bson:"resource_id"`
    ResourceType string        `bson:"resource_type"`
    Actions      []string      `bson:"actions"`
    GrantSources []GrantSource `bson:"grant_sources"` // 문제의 원인
}

type GrantSource struct {
    Type       string    `bson:"type"`       // "owner", "share", "request"
    SourceID   string    `bson:"source_id"`  // Share ID or Request ID
    GrantedAt  time.Time `bson:"granted_at"`
    GrantedBy  string    `bson:"granted_by"`
}

이 구조에서 발생한 문제들:

flowchart TD
    subgraph "GrantSources 문제점"
        P1[중복 데이터] --> Issue1[Share 정보가 Permission과 Share 양쪽에 존재]
        P2[동기화 실패] --> Issue2[Share 삭제 시 Permission 업데이트 누락]
        P3[복잡한 쿼리] --> Issue3[권한 출처 확인을 위한 다중 조회]
        P4[일관성 문제] --> Issue4[GrantSources와 실제 Share 상태 불일치]
    end

실제 발생한 버그 사례

  1. 공유 취소 후에도 권한 유지: Share를 삭제해도 Permission의 GrantSources가 남아있어 권한이 유지됨
  2. 중복 권한 생성: 같은 Share로 여러 번 Permission이 생성되는 경우 발생
  3. 권한 출처 추적 실패: 특정 권한이 어떤 Share에서 왔는지 확인하기 어려움

해결책: Shares-Based SSOT 아키텍처

핵심 원칙

“Share가 권한의 유일한 진실 공급원이 된다”

flowchart LR
    subgraph "새로운 아키텍처"
        Share[Share<br/>권한의 원천] --> |contains| Subjects[SharedWith<br/>권한 대상들]
        Subjects --> |each has| Perms[Permissions<br/>액션 목록]
    end

    subgraph "권한 확인 흐름"
        Query[권한 확인 요청] --> FindShare[Share 조회]
        FindShare --> CheckSubject[SharedWith에서 사용자 찾기]
        CheckSubject --> ReturnPerms[권한 반환]
    end

새로운 데이터 모델

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// Share가 권한의 SSOT
type Share struct {
    ID           string         `bson:"_id"`
    ResourceID   string         `bson:"resource_id"`
    ResourceType string         `bson:"resource_type"`
    OwnerID      string         `bson:"owner_id"`
    SharedWith   []ShareSubject `bson:"shared_with"` // 권한의 유일한 진실
    CreatedAt    time.Time      `bson:"created_at"`
    UpdatedAt    time.Time      `bson:"updated_at"`
}

type ShareSubject struct {
    SubjectType string       `bson:"subject_type"` // "user", "group", "organization"
    SubjectID   string       `bson:"subject_id"`
    Permissions []Permission `bson:"permissions"`
    SharedAt    time.Time    `bson:"shared_at"`
    SharedBy    string       `bson:"shared_by"`
}

type Permission struct {
    Action    string    `bson:"action"` // "read", "edit", "delete", "share", "execute"
    GrantedAt time.Time `bson:"granted_at"`
    ExpiresAt *time.Time `bson:"expires_at,omitempty"`
}

이전 vs 이후 비교

구분이전 (GrantSources)이후 (Shares-Based SSOT)
권한 저장 위치Permission.GrantSourcesShare.SharedWith
진실의 원천분산 (Permission + Share)단일 (Share)
동기화 필요필요 (자주 실패)불필요
권한 확인Permission 조회 → GrantSources 파싱Share 조회 → SharedWith 확인
공유 취소Permission 업데이트 필요Share에서 제거만

3단계 구현 계획

Phase 1: 핵심 SSOT 구조 구현 (15개 이슈)

1.1 Share 모델 리팩토링

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
// internal/contexts/share/domain/share.go

type Share struct {
    ID           ShareID
    ResourceID   ResourceID
    ResourceType ResourceType
    OwnerID      UserID
    SharedWith   SharedWithList
    Metadata     ShareMetadata
}

// 도메인 서비스: 권한 확인
func (s *Share) HasPermission(subjectID string, action string) bool {
    subject := s.SharedWith.FindBySubjectID(subjectID)
    if subject == nil {
        return false
    }
    return subject.HasAction(action)
}

// 도메인 서비스: 권한 부여
func (s *Share) GrantPermission(subjectID string, subjectType string, action string, grantedBy string) error {
    if !s.canShare(grantedBy) {
        return ErrNoSharePermission
    }

    subject := s.SharedWith.FindOrCreate(subjectID, subjectType)
    return subject.AddPermission(action, grantedBy)
}

// 도메인 서비스: 권한 철회
func (s *Share) RevokePermission(subjectID string, action string) error {
    subject := s.SharedWith.FindBySubjectID(subjectID)
    if subject == nil {
        return ErrSubjectNotFound
    }
    return subject.RemovePermission(action)
}

1.2 공유 서비스 구현

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
44
45
46
47
48
49
50
51
52
// internal/contexts/share/application/share_service.go

type ShareService struct {
    shareRepo    ShareRepository
    resourceRepo ResourceRepository
    eventBus     EventBus
}

func (s *ShareService) ShareResource(ctx context.Context, cmd ShareResourceCommand) error {
    // 1. 리소스 소유권 확인
    resource, err := s.resourceRepo.FindByID(ctx, cmd.ResourceID)
    if err != nil {
        return fmt.Errorf("resource not found: %w", err)
    }

    if resource.OwnerID != cmd.RequestedBy {
        // 소유자가 아니면 share 권한 확인
        share, err := s.shareRepo.FindByResourceID(ctx, cmd.ResourceID)
        if err != nil || !share.HasPermission(cmd.RequestedBy, "share") {
            return ErrNoSharePermission
        }
    }

    // 2. Share 조회 또는 생성
    share, err := s.shareRepo.FindOrCreateByResourceID(ctx, cmd.ResourceID, cmd.ResourceType, resource.OwnerID)
    if err != nil {
        return fmt.Errorf("failed to get share: %w", err)
    }

    // 3. 권한 부여 (도메인 로직)
    for _, action := range cmd.Actions {
        if err := share.GrantPermission(cmd.TargetUserID, "user", action, cmd.RequestedBy); err != nil {
            return fmt.Errorf("failed to grant %s: %w", action, err)
        }
    }

    // 4. 저장
    if err := s.shareRepo.Save(ctx, share); err != nil {
        return fmt.Errorf("failed to save share: %w", err)
    }

    // 5. 이벤트 발행
    s.eventBus.Publish(ctx, ShareGrantedEvent{
        ShareID:      share.ID,
        ResourceID:   cmd.ResourceID,
        TargetUserID: cmd.TargetUserID,
        Actions:      cmd.Actions,
        GrantedBy:    cmd.RequestedBy,
    })

    return nil
}

1.3 권한 확인 쿼리 최적화

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
44
// internal/contexts/share/adapters/share_repository_mongo.go

func (r *MongoShareRepository) CheckPermission(ctx context.Context, resourceID, userID, action string) (bool, error) {
    filter := bson.M{
        "resource_id": resourceID,
        "shared_with": bson.M{
            "$elemMatch": bson.M{
                "subject_id": userID,
                "permissions": bson.M{
                    "$elemMatch": bson.M{
                        "action": action,
                        "$or": []bson.M{
                            {"expires_at": nil},
                            {"expires_at": bson.M{"$gt": time.Now()}},
                        },
                    },
                },
            },
        },
    }

    count, err := r.collection.CountDocuments(ctx, filter)
    return count > 0, err
}

// 복합 인덱스 생성
func (r *MongoShareRepository) EnsureIndexes(ctx context.Context) error {
    indexes := []mongo.IndexModel{
        {
            Keys: bson.D{
                {Key: "resource_id", Value: 1},
                {Key: "shared_with.subject_id", Value: 1},
            },
        },
        {
            Keys: bson.D{
                {Key: "shared_with.subject_id", Value: 1},
                {Key: "resource_type", Value: 1},
            },
        },
    }
    _, err := r.collection.Indexes().CreateMany(ctx, indexes)
    return err
}

Phase 2: 기존 API와 통합 (15개 이슈)

2.1 Permission API 어댑터 레이어

기존 Permission API를 유지하면서 내부적으로 Share 기반으로 동작:

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
// internal/contexts/permission/adapters/permission_handler.go

type PermissionHandler struct {
    shareService *share.ShareService
    legacyRepo   PermissionRepository // 마이그레이션 기간 동안 유지
}

// GET /permissions?resource_id=xxx
func (h *PermissionHandler) GetPermissions(c *gin.Context) {
    resourceID := c.Query("resource_id")
    userID := c.GetString("user_id")

    // Share에서 권한 조회 (새로운 방식)
    permissions, err := h.shareService.GetUserPermissions(c.Request.Context(), resourceID, userID)
    if err != nil {
        c.JSON(500, gin.H{"error": err.Error()})
        return
    }

    // 기존 API 응답 형식으로 변환
    response := h.convertToLegacyFormat(permissions)
    c.JSON(200, response)
}

// 기존 응답 형식 유지 (하위 호환성)
func (h *PermissionHandler) convertToLegacyFormat(perms []share.Permission) []PermissionResponse {
    var result []PermissionResponse
    for _, p := range perms {
        result = append(result, PermissionResponse{
            Action:    p.Action,
            GrantedAt: p.GrantedAt,
            // GrantSources는 더 이상 반환하지 않음 (deprecated)
        })
    }
    return result
}

2.2 권한 요청 워크플로우 연동

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
// internal/contexts/permission/application/request_service.go

func (s *RequestService) ApproveRequest(ctx context.Context, requestID string, approverID string) error {
    // 1. 요청 조회
    request, err := s.requestRepo.FindByID(ctx, requestID)
    if err != nil {
        return fmt.Errorf("request not found: %w", err)
    }

    // 2. 승인 권한 확인
    if !s.canApprove(ctx, request, approverID) {
        return ErrNoApprovePermission
    }

    // 3. Share 기반으로 권한 부여 (핵심 변경점)
    err = s.shareService.ShareResource(ctx, share.ShareResourceCommand{
        ResourceID:   request.ResourceID,
        ResourceType: request.ResourceType,
        TargetUserID: request.RequesterID,
        Actions:      request.RequestedActions,
        RequestedBy:  approverID,
    })
    if err != nil {
        return fmt.Errorf("failed to grant permission: %w", err)
    }

    // 4. 요청 상태 업데이트
    request.Status = RequestStatusApproved
    request.ApprovedBy = approverID
    request.ApprovedAt = time.Now()

    return s.requestRepo.Save(ctx, request)
}

Phase 3: 마이그레이션 및 정리 (10개 이슈)

3.1 데이터 마이그레이션

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
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
// scripts/migration/migrate_permissions_to_shares.go

func MigratePermissionsToShares(ctx context.Context, db *mongo.Database) error {
    permissionsColl := db.Collection("permissions")
    sharesColl := db.Collection("shares")

    // 기존 Permission들을 리소스별로 그룹화
    pipeline := mongo.Pipeline{
        bson.D{{Key: "$group", Value: bson.M{
            "_id": bson.M{
                "resource_id":   "$resource_id",
                "resource_type": "$resource_type",
            },
            "permissions": bson.M{"$push": "$$ROOT"},
        }}},
    }

    cursor, err := permissionsColl.Aggregate(ctx, pipeline)
    if err != nil {
        return err
    }
    defer cursor.Close(ctx)

    var migratedCount, skippedCount int

    for cursor.Next(ctx) {
        var group struct {
            ID struct {
                ResourceID   string `bson:"resource_id"`
                ResourceType string `bson:"resource_type"`
            } `bson:"_id"`
            Permissions []OldPermission `bson:"permissions"`
        }

        if err := cursor.Decode(&group); err != nil {
            log.Printf("Failed to decode group: %v", err)
            continue
        }

        // Share 생성 또는 업데이트
        share := buildShareFromPermissions(group.ID.ResourceID, group.ID.ResourceType, group.Permissions)

        _, err := sharesColl.UpdateOne(ctx,
            bson.M{"resource_id": share.ResourceID},
            bson.M{"$set": share},
            options.Update().SetUpsert(true),
        )

        if err != nil {
            log.Printf("Failed to migrate share for resource %s: %v", share.ResourceID, err)
            skippedCount++
            continue
        }

        migratedCount++
    }

    log.Printf("Migration completed: %d migrated, %d skipped", migratedCount, skippedCount)
    return nil
}

func buildShareFromPermissions(resourceID, resourceType string, perms []OldPermission) *Share {
    share := &Share{
        ID:           primitive.NewObjectID().Hex(),
        ResourceID:   resourceID,
        ResourceType: resourceType,
        SharedWith:   make([]ShareSubject, 0),
        CreatedAt:    time.Now(),
        UpdatedAt:    time.Now(),
    }

    // 사용자별로 권한 그룹화
    userPerms := make(map[string][]Permission)
    for _, p := range perms {
        for _, action := range p.Actions {
            userPerms[p.UserID] = append(userPerms[p.UserID], Permission{
                Action:    action,
                GrantedAt: p.CreatedAt,
            })
        }
    }

    // SharedWith 구성
    for userID, permissions := range userPerms {
        share.SharedWith = append(share.SharedWith, ShareSubject{
            SubjectType: "user",
            SubjectID:   userID,
            Permissions: permissions,
            SharedAt:    permissions[0].GrantedAt,
        })
    }

    return share
}

3.2 듀얼 라이트 전략

마이그레이션 기간 동안 양쪽에 쓰기:

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
// internal/contexts/share/application/dual_write_service.go

type DualWriteShareService struct {
    shareService     *ShareService
    legacyPermRepo   PermissionRepository
    dualWriteEnabled bool
}

func (s *DualWriteShareService) ShareResource(ctx context.Context, cmd ShareResourceCommand) error {
    // 1. 새로운 Share 시스템에 저장
    err := s.shareService.ShareResource(ctx, cmd)
    if err != nil {
        return err
    }

    // 2. 듀얼 라이트가 활성화되어 있으면 레거시에도 저장
    if s.dualWriteEnabled {
        legacyPerm := &LegacyPermission{
            UserID:       cmd.TargetUserID,
            ResourceID:   cmd.ResourceID,
            ResourceType: cmd.ResourceType,
            Actions:      cmd.Actions,
            GrantSources: []GrantSource{{
                Type:      "share",
                GrantedBy: cmd.RequestedBy,
                GrantedAt: time.Now(),
            }},
        }

        if err := s.legacyPermRepo.Upsert(ctx, legacyPerm); err != nil {
            // 레거시 실패는 로그만 남기고 진행
            log.Printf("Warning: failed to write to legacy: %v", err)
        }
    }

    return nil
}

해결된 40개 이슈 상세

Phase 1 이슈 (15개)

#이슈해결 방법
1Share 모델에 SharedWith 추가ShareSubject 타입 정의
2ShareSubject에 Permissions 추가중첩 구조로 권한 저장
3Share 도메인 서비스 구현HasPermission, GrantPermission 메서드
4ShareRepository 인터페이스 정의CRUD + 권한 확인 메서드
5MongoDB 어댑터 구현인덱스 최적화 포함
6권한 확인 쿼리 최적화$elemMatch 복합 조건
7Share 생성 시 소유자 자동 권한Owner에게 모든 권한 부여
8권한 만료 기능ExpiresAt 필드 및 검증
9그룹 권한 지원SubjectType: “group”
10조직 권한 지원SubjectType: “organization”
11권한 상속 구조그룹 → 사용자 권한 상속
12Share 이벤트 정의ShareGranted, ShareRevoked
13이벤트 발행 구현EventBus 연동
14Share 단위 테스트도메인 로직 테스트
15Share 통합 테스트MongoDB 연동 테스트

Phase 2 이슈 (15개)

#이슈해결 방법
16Permission API 어댑터기존 API 유지하며 Share 사용
17응답 형식 변환Legacy format 호환
18권한 요청 승인 연동ShareService 호출로 변경
19권한 요청 거절 처리상태만 업데이트
20MCP 권한 연동3-permission 요청 지원
21ToolSet 권한 연동도구 묶음 권한 처리
22Agent 공유 연동Agent 리소스 공유
23Session 공유 연동Session 리소스 공유
24File 공유 연동File 리소스 공유
25공유 취소 APIShare에서 subject 제거
26권한 수정 APIShare에서 permission 수정
27공유 목록 조회내가 공유한/받은 리소스
28권한 이력 조회감사 로그
29API 문서 업데이트Swagger 정의
30API 통합 테스트E2E 테스트

Phase 3 이슈 (10개)

#이슈해결 방법
31마이그레이션 스크립트Permission → Share 변환
32듀얼 라이트 구현양쪽 저장 후 검증
33데이터 검증 스크립트마이그레이션 정합성 확인
34레거시 읽기 제거Share만 읽도록 변경
35레거시 쓰기 제거Share만 쓰도록 변경
36GrantSources 필드 제거Permission 모델 정리
37사용하지 않는 코드 제거레거시 서비스 삭제
38인덱스 최적화불필요 인덱스 제거
39성능 테스트벤치마크 및 최적화
40문서 업데이트아키텍처 문서 정리

성능 개선 결과

권한 확인 쿼리 성능

1
2
3
4
5
6
7
8
9
Before (GrantSources 방식):
- Permission 조회: 1 query
- GrantSources 파싱: O(n) 메모리 연산
- Share 유효성 확인: n queries (각 GrantSource마다)
- 총 쿼리: 1 + n

After (Shares-Based SSOT):
- Share 조회 with $elemMatch: 1 query
- 총 쿼리: 1

벤치마크 결과

시나리오BeforeAfter개선율
단일 권한 확인15ms3ms80%
사용자 전체 권한 조회120ms25ms79%
공유 취소45ms8ms82%
권한 부여35ms12ms66%

핵심 교훈

1. SSOT 원칙의 중요성

권한 데이터가 여러 곳에 분산되면 동기화 문제가 필연적으로 발생합니다. 단일 진실 공급원을 명확히 정의하고 모든 권한 판단이 그곳에서 이루어지도록 해야 합니다.

2. 도메인 주도 리팩토링

Share.HasPermission(), Share.GrantPermission() 같은 도메인 메서드를 먼저 설계하고, 그에 맞춰 데이터 모델을 구성했습니다. 데이터 구조가 아닌 비즈니스 규칙이 설계를 주도해야 합니다.

3. 점진적 마이그레이션

듀얼 라이트 → 듀얼 리드 → 레거시 제거 순서로 진행하여 안전하게 마이그레이션했습니다. 빅뱅 마이그레이션은 위험합니다.

4. 인덱스 설계의 중요성

$elemMatch를 활용한 복합 인덱스로 중첩 배열 조회 성능을 크게 개선했습니다. MongoDB의 배열 쿼리 특성을 이해하고 인덱스를 설계해야 합니다.


참고

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