포스트

[Swift] 실시간 협업 편집에서 동시 편집 충돌 해결하기

[Swift] 실시간 협업 편집에서 동시 편집 충돌 해결하기

PhotoGether는 최대 4명이 동시에 접속하여 실시간으로 함께 사진을 편집할 수 있는 iOS 앱입니다. WebRTC를 기반으로 한 P2P 연결을 통해 참가자들이 같은 화면을 보면서 스티커 추가, 이동, 삭제 등의 편집 작업을 동시에 수행할 수 있습니다.

마치 Google Docs에서 여러 명이 동시에 문서를 편집하듯이, PhotoGether에서는 여러 명이 동시에 하나의 사진 위에 스티커를 배치하고 꾸밀 수 있습니다. 하지만 텍스트와 달리 시각적 객체들은 위치와 크기라는 물리적 속성을 가지고 있어, 동시 편집 시 더 복잡한 충돌 상황들이 발생합니다.

이런 복잡한 동시성 문제를 해결하기 위해서는 체계적인 아키텍처 설계가 필요했습니다.

🏗️ 이벤트 허브 아키텍처

이벤트 허브

동시 편집의 복잡성을 해결하기 위해 Event Hub를 중심으로 한 중앙 집중식 구조를 설계했습니다.

Host Client는 단순히 방을 만든 사용자가 아니라 모든 편집 이벤트를 검증하고 처리하는 중앙 서버 역할을 담당합니다. Guest Client들이 스티커를 편집하면 해당 이벤트가 WebRTC 데이터 채널을 통해 Host에게 전송되고, Host의 Event Hub에서 이를 처리한 후 Source of Truth(신뢰할 수 있는 단일 데이터 소스)를 모든 참가자에게 브로드캐스팅하는 방식입니다.

Event Hub 내부에는 Event Queue가 있어 들어오는 이벤트들을 타임스탬프 순으로 정렬하고, Event Manager가 각 이벤트 타입에 맞는 CRUD 로직을 수행합니다. 이렇게 처리된 최종 결과는 SoT(Source of Truth) 브로드캐스팅을 통해 모든 클라이언트에게 전달됩니다.

하지만 이런 아키텍처를 설계하게 된 배경에는 실제로 마주했던 구체적인 문제 상황들이 있었습니다.

🚨 문제 상황

동일 객체 동시 편집

PhotoGether에서는 A 사용자가 스티커를 드래그 중일 때 B 사용자가 같은 스티커를 터치하는 동시 편집 충돌 상황이 발생할 수도 있었고, 두 사용자가 동시에 스티커를 선택하여 편집하는 경우가 발생할 때, 이 두 이벤트간 충돌이 발생하지 않도록 처리해줘야 했습니다.

예를 들어 A가 강아지 스티커를 왼쪽으로 드래그하고 있는데, 동시에 B가 같은 강아지 스티커를 오른쪽으로 드래그하려고 한다면 이 스티커는 어디로 이동해야 할까요? 또한 A가 스티커 크기를 키우고 있는데 B가 동시에 같은 스티커를 삭제하려고 한다면 어떻게 처리해야 할까요?

삭제 vs 업데이트 경쟁 상황

A가 스티커를 삭제 중일 때 B가 해당 스티커의 크기를 조절하는 경우, 또는 네트워크 지연으로 삭제 이벤트보다 업데이트 이벤트가 먼저 도착하는 경우도 발생할 수 있기에 이를 처리해줘야 했습니다.

실제로 테스트 중에 A가 고양이 스티커를 삭제했는데, 네트워크 지연으로 인해 B의 “고양이 스티커 위치 변경” 이벤트가 삭제 이벤트보다 늦게 도착하여 이미 존재하지 않는 스티커를 업데이트하려는 상황이 발생했습니다.

타이밍 이슈

만약 A가 편집 이벤트를 먼저 시도했지만, 네트워크 상태로 인해서 이벤트의 전달이 이벤트 처리객체에 늦게 도착하는 상황, 또한 같은 타임 스탬프를 지니는 여러 이벤트가 발생하는 경우를 처리해줘야 됐습니다.

WebRTC의 P2P 특성상 각 사용자의 네트워크 상태가 다르기 때문에, 시간상으로는 A → B 순서로 발생한 이벤트가 처리 시점에는 B → A 순서로 도착할 수 있습니다. 이런 상황에서도 논리적 일관성을 유지해야 했습니다.

이러한 다양한 문제 상황들을 체계적으로 해결하기 위해 몇 가지 핵심 접근 방식을 도입했습니다.

💡 해결 접근 방식

Owner 기반 편집 권한 시스템

가장 핵심적인 해결책은 편집 권한을 명시적으로 관리하는 것이었습니다. 각 스티커에 현재 편집하고 있는 사용자 정보를 담는 owner 속성을 추가해서 누가 해당 스티커를 편집할 수 있는지 명시적으로 관리하는 시스템을 도입했습니다.

이는 마치 현실에서 물건을 집어들면 다른 사람이 동시에 건드릴 수 없는 것과 같은 개념입니다. 누군가 스티커를 “집어들고” 있으면 다른 사람은 그 스티커를 편집할 수 없게 하는 것이죠.

1
2
3
4
5
6
7
8
9
10
11
12
public struct StickerEntity: Equatable, Codable {
    public let id: UUID
    public let image: String
    public private(set) var frame: CGRect
    public private(set) var owner: UserInfo?  // 현재 편집 권한 소유자
    public private(set) var latestUpdated: Date
    
    public mutating func updateOwner(to owner: UserInfo?) {
        self.owner = owner
        self.latestUpdated = Date()
    }
}

사용자가 스티커를 터치하면 해당 스티커의 소유권을 획득하고, 편집이 끝나면 소유권을 해제하는 방식으로 동작합니다. 이를 통해 동시에 같은 객체를 편집하려는 시도를 원천적으로 차단할 수 있었습니다.

특히 시각적으로도 현재 누군가 편집 중인 스티커는 테두리를 표시해서 다른 사용자들이 “아, 저 스티커는 지금 다른 사람이 만지고 있구나”라고 인지할 수 있도록 했습니다.

이론적인 접근 방식을 실제 상황에 어떻게 적용했는지 구체적인 시나리오를 통해 살펴보겠습니다.

🔧 충돌 해결 시나리오

정상적인 이벤트 처리

적용 상황

위 다이어그램은 충돌이 없는 정상적인 상황에서의 이벤트 처리 과정을 보여줍니다. Event Queue에서 Event2(A가 스티커1의 Frame을 변경하는 이벤트)가 처리되는 상황입니다.

현재 Source of Truth를 보면 스티커1의 Owner가 A로 설정되어 있고, 들어온 이벤트도 동일하게 A가 Owner인 상태입니다. 이는 편집 권한이 일치하는 상황이므로 이벤트가 정상적으로 적용되어 스티커1의 Frame이 (0,0,60,60)으로 업데이트됩니다.

이런 경우에는 별다른 충돌 검증 없이 바로 Source of Truth가 업데이트되고, 변경된 전체 상태가 모든 클라이언트에게 브로드캐스팅됩니다.

충돌 상황에서의 거부 처리

경쟁 상황

반면 이 다이어그램은 편집 권한 충돌이 발생한 상황을 보여줍니다. Event1에서 B가 스티커1의 Frame을 변경하려고 시도하고 있지만, 현재 Source of Truth에서 스티커1의 Owner는 A로 설정되어 있습니다.

이는 편집 권한이 없는 사용자가 편집을 시도하는 상황이므로 해당 이벤트는 거부되고, Source of Truth에는 아무런 변경이 일어나지 않습니다.

이벤트별 세분화된 충돌 해결 규칙

단순히 “먼저 온 요청 우선 처리”가 아니라 편집 이벤트의 타입별로 서로 다른 충돌 해결 규칙을 적용했습니다.

예를 들어, 삭제 이벤트의 경우 현재 해당 스티커를 편집 중인 사용자가 있다면 그 사용자만 삭제할 수 있도록 하고, 누구도 편집하지 않는 상태라면 삭제 요청을 보낸 사용자에게 권한을 부여하는 방식입니다.

이는 논리적 일관성을 중시한 접근 방법입니다. 누군가 스티커를 편집 중이라면 다른 사람이 갑자기 삭제할 수 없게 하고, 편집이 끝난 후에야 삭제가 가능하도록 하는 것이죠.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private func deleteEvent(by sticker: StickerEntity) {
    guard isObejctDeleted[sticker.id] == false, 
          let oldSticker = stickerDictionary[sticker.id] else {
        debugPrint("A/B 삭제 경쟁 상황에서 이미 처리된 삭제를 요청함")
        return
    }
    
    guard let newOwner = sticker.owner else { return }
    
    // Owner 기반 권한 검증 - 편집 권한이 있는 경우에만 삭제 허용
    if oldSticker.owner == nil || oldSticker.owner == newOwner {
        stickerDictionary[sticker.id] = nil
        isObejctDeleted[sticker.id] = true
        broadcastSubject.send(currenntStickerList)
    }
}

타이밍 보장을 위한 이벤트 큐

네트워크의 특성상 이벤트들이 전송된 순서와 다르게 도착할 수 있는 문제를 해결하기 위해 타임스탬프 기반 이벤트 큐 시스템을 구축했습니다.

모든 편집 이벤트에 정확한 타임스탬프를 부여하고 이를 기준으로 정렬하여 순차적으로 처리함으로써 시간적 일관성을 유지하면서도 동시 접근으로 인한 Race Condition을 방지할 수 있었습니다.

이는 시간을 기준으로 한 공정한 처리를 보장하는 방식입니다. 실제로는 B의 이벤트가 먼저 도착했지만, 시간상으로는 A가 먼저 시도했다면 A의 이벤트를 먼저 처리하는 것이죠.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
final class EventQueue {
    private var queue = [EventEntity]()

    func push(event: EventEntity) {
        queue.append(event)
        // 타임스탬프 기준으로 정렬하여 시간적 순서 보장
        queue.sort { $0.timeStamp > $1.timeStamp }
        isExistSubject.send(true)
    }
    
    func popLast() -> EventEntity? {
        let popLast = queue.popLast()
        if queue.isEmpty { isExistSubject.send(false) }
        return popLast
    }
}

이렇게 동시 편집 상황에서는 다양한 충돌상황들이 발생합니다. 하지만 이러한 문제를 해결하는 것만큼 중요한 것이 바로 해결된 결과를 어떻게 효율적으로 모든 사용자에게 전달하느냐는 것이었습니다.

🔄 상태 동기화 최적화

전체 상태 브로드캐스팅 선택

개별 편집 이벤트를 각각 전송하는 대신 편집이 완료된 최종 상태 전체를 모든 참가자에게 전송하는 방식을 선택했습니다. 이는 “결과 중심” 접근법으로, 중간 과정보다는 최종적으로 모든 사용자가 같은 화면을 보는 것을 우선시한 것입니다.

예를 들어 “스티커 A가 (100,100)에서 (200,200)으로 이동했다”는 개별 이벤트를 보내는 대신, “현재 화면에는 스티커 A가 (200,200)에, 스티커 B가 (50,50)에 있다”는 전체 상태를 보내는 방식입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
private func createEvent(by sticker: StickerEntity) {
    guard isObejctDeleted[sticker.id] == nil, 
          stickerDictionary[sticker.id] == nil else {
        debugPrint("Create event 실패")
        return
    }

    stickerDictionary[sticker.id] = sticker
    isObejctDeleted[sticker.id] = false
    
    // 개별 이벤트가 아닌 전체 상태를 브로드캐스팅
    broadcastSubject.send(currenntStickerList)
}

물론 데이터 전송량이 증가하는 단점이 있지만, 스티커 개수가 많지 않은 현재 상황에서는 무시할 수 있는 수준이었고, 또한 데이터의 일관성을 고려한다면 전체를 브로드캐스팅 하는 방식이 무결성을 지켜주기에 더 적합하다 판단했습니다.

호스트 중심 아키텍처

P2P 환경이지만 데이터 일관성을 위해 호스트를 중심으로 한 구조를 채택했습니다. 게스트들은 편집 이벤트를 호스트에게 전송하고, 호스트는 이를 검증 및 처리한 후 모든 참가자에게 결과를 브로드캐스팅하는 방식입니다.

이는 “선생님이 학생들의 의견을 종합해서 칠판에 정리해주는 것”과 같은 개념입니다. 모든 학생(게스트)이 동시에 칠판에 뭔가를 쓰려고 하면 난장판이 되지만, 선생님(호스트)이 중재하면 질서 있게 진행될 수 있죠.

✅ 결과

이러한 체계적인 접근 방식들을 통해 저희 앱에서는 동시 편집 충돌 상황을 99% 해결할 수 있었고, 데이터 불일치로 인한 버그를 완전히 제거했습니다.

실제 테스트에서 4명이 동시에 접속하여 약 10분간 스티커를 추가하고 편집하는 과정에서 단 한 번도 데이터 불일치가 발생하지 않았습니다.

또한 사용자 경험 측면에서도 편집 중인 스티커를 시각적으로 표시함으로써 충돌을 사전에 방지할 수 있게 되었고, 모든 사용자가 일관된 화면 상태를 유지함으로써 혼란 요소를 제거하여 부드러운 실시간 협업 경험을 제공할 수 있었습니다.

물론 이런 해결책에도 한계가 있었고, 앞으로 개선해야 할 부분들도 있었습니다.

🤔 아쉬운 점과 개선 방향

현재는 스티커 개수가 적어 전체 상태 전송이 효율적이지만, 만약 수백 개의 객체를 다뤄야 한다면 CRDT와 같은 데이터 동기화 방식(변경된 부분만 전송)을 고려해야 할 것 같습니다.

또한 현재는 실시간 연결 상태에서만 동작하는데, 네트워크 단절 후 재연결 시의 상태 복구 로직도 필요할 것 같습니다.

🏁 마무리

실시간 협업 기능을 구현하면서 단순해 보이는 기능도 동시성을 고려하면 얼마나 복잡해질 수 있는지 깨달았습니다. 특히 사용자 경험을 해치지 않으면서도 데이터 일관성을 보장하는 것이 가장 어려운 부분이었어요.

혹시 비슷한 문제를 겪고 계신다면 도움이 되었으면 좋겠습니다! 🙌

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.