MultipeerConnectivity 동시성 테스트 ‐ 허혜민, 윤지성 - Team-HGD/SniffMEET GitHub Wiki
백로그
동시에 2명 이상이 만족할 경우 대처(동시성 문제)
테스트로 확인할 동작
동시에 2명 이상 peer가 존재하는 경우 1 명의 peer에 대해서만 연결 및 연결 해제를 수행하는지 확인한다.
테스트 배경
근거리 통신 기술인 MPC와 NI를 사용하여 프로필 데이터를 공유하는 기능이 있습니다. 두 기기 간이 거리가 특정 거리 안으로 좁혀졌을 때 프로필 데이터를 송신하게 됩니다. 그 전에 내부적으로 Browsing과 advertising을 수행하여 연결 관계를 수립합니다. 기존에는 변수를 통해서 연결된 단일 peer 관리하였습니다.
하지만 MPC session 연결 수립 작업을 담당하는 메서드가 동시적으로 호출되어 2명 이상의 peer와 연결이 수립되고 연결된 피어를 관리하는 변수에 대해서 업데이트가 동시에 일어나거나 혹은 프로필 데이터 수신이 동시에 이루어진다면 data race 문제가 발생하고 ProfileDrop 기능이 제대로 동작하지 않을 수 있음을 발견했습니다. 따라서 이를 해결하기 위해 actor를 도입하였습니다.
이처럼 actor를 사용하여 연결 수립 시점과 연결 해제 시점에만 변경이 가능하고 동시에 값에 접근하지 못하도록 보장하였습니다.
// ConnectedPeerManager.swift
actor ConnectedPeerManager: @preconcurrency ConnectedPeerManagable {
var connectedPeer: MCPeerID?
func connect(peer: MCPeerID) {
if connectedPeer != nil { return }
connectedPeer = peer
}
func disconnect() {
connectedPeer = nil
}
}
예상한대로 동작하는지 확인하기 위해서 유사한 환경을 만들어 테스트해보려고 합니다.
현재 프로필 드랍 기능에서 peer 간의 연결 수립 및 해제 순간에 호출되는 메서드는 MCSessionDelegate가 제공합니다. MCSessionDelegate
의 메서드는 MultipeerConnectivity
프레임워크의 MCSession
객체가 특정 이벤트를 감지했을 때 호출된다는 의미입니다.
실제 코드에서 MPCManager와 MCSession
, 연결된 Peer를 관리하는 connectedPeerManager의 관계는 다음과 같습니다. MPCManager의 MCSession
의 Usecase가 MCSessionDelegate
역할을 수행하고 있습니다.
그렇다면 어떻게 동시적으로 Peer가 접근하는 것과 같이 유사한 환경을 만들 수 있을까요?
<현재 코드에서 MPCManager와의 주변 객체의 관계도>
그렇다면 MPCSession delegate를 채택하는 mock 객체를 만드는 것입니다. 따라서 session이 연결되지 않더라도 peer 연결과 관련된 메서드를 동시에 여러번 호출한다면 연결된 peer가 동시적으로 접근하는 것과 유사한 환경이 될 것입니다.
<테스트를 위한 mock 객체와의 sut 관계도>
실제 코드에서 MockSessionDelegate 객체를 구현하여 TryProfileDropUseCase를 대체하여 MCSession의 delegate 역할을 수행하는 것입니다.
<Mock 객체와 MCSession 관계도>
테스트 과정
MockMPCSession
현재 프로필 드랍의 기능 대부분은 MPC, NI Session의 Delegate가 호출될 때 이루어집니다.
Advertising과 Browsing은 Session이 맺어지기 위한 과정이므로 우선적으로 테스트가 필요한 부분은 Session이라고 보았습니다.
extension TryProfileDropUseCaseImpl: MCSessionDelegate {
// 중략
}
MCSession의 Mock 객체를 통해 MCSessionDelegate에서 일어나는 동작을 정의하고 이에 따라 MPCManager가 의도한대로 동작하는지 확인했습니다.
final class MockMPCSession: NSObject, MCSessionDelegate {
var connectedState: ConnectedState
var mpcManager: MPCManager
func session(_ session: MCSession, peer peerID: MCPeerID, didChange state: MCSessionState) {
switch state {
case .connected:
connectedState = .connected
Task {
await mpcManager.connectedPeerManager.connect(peer: peerID)
}
case .connecting:
connectedState = .connecting
case .notConnected:
connectedState = .notConnected
Task {
await mpcManager.connectedPeerManager.disconnect()
}
@unknown default:
break
}
// ,,, 중략
}
실제 Local Network 환경에서는 자동으로 MCSessionDelegate의 메서드가 호출됩니다.
테스트 환경에서도 이와 같은 환경을 구현하기 위해서는 직접 적절한 타이밍에 MCSessionDelegate 메서드를 호출하면 됩니다.
MCSessionDelegate에서 특정 peer의 상태가 변경되었을 때 호출되는 메서드입니다.
func session(_ session: MCSession, peer peerID: MCPeerID, didChange state: MCSessionState) {}
위 메서드를 호출해 동시에 여러 peer가 동시에 connected 상태가 된 상황을 재현할 수 있었습니다.
func test_peer가_동시에_세션에_접근할_때_connectedPeer는_하나만_연결되어야_한다() async throws {
// Arrange
let peerName = "테스트 피어"
// Act
await withTaskGroup(of: Void.self) { [weak self] group in
guard let self else {
XCTFail("self 바인딩 실패")
return
}
for _ in 0..<1000 {
group.addTask {
self.sessionMock.session(self.session, peer: MCPeerID(displayName: peerName), didChange: .connected)
}
}
}
// Assert
try await Task.sleep(nanoseconds: 1000000000)
let connectedPeerCount = await connectedPeerManagerSpy.connectedPeerCount
let connectionTrialCount = await connectedPeerManagerSpy.connectionTrialCount
XCTAssertEqual(1, connectedPeerCount)
XCTAssertEqual(1000, connectionTrialCount)
}
ConnectedPeerSpy
기존의 ConnectedManager는 구체 타입 객체로만 구현되어 있었습니다.
테스트 용이성을 위해 ConnectedManagable로 추상화를 진행해 채택하는 과정을 거쳤습니다.
protocol ConnectedPeerManagable {
var connectedPeer: MCPeerID? { get }
func connect(peer: MCPeerID) async
func disconnect() async
}
ConnectedPeerManagable을 채택하는 ConnectedPeerManagerSpy를 구현할 수 있었습니다.
연결된 peer의 수(connectedPeerCount), 가장 최근에 연결되었다 해제된 peer(previousPeer), 실제로 연결을 시도한 횟수(connectionTrialCount) 등 테스트에서 필요한 정보도 함께 선언해 활용할 수 있었습니다.
actor ConnectedPeerManagerSpy: @preconcurrency ConnectedPeerManagable {
var previousConnectedPeer: MCPeerID?
var connectedPeer: MCPeerID?
var connectedPeerCount: Int = 0
var connectionTrialCount: Int = 0
func connect(peer: MCPeerID) async {
connectionTrialCount += 1
guard connectedPeer == nil else { return }
connectedPeerCount += 1
connectedPeer = peer
}
func disconnect() async {
if connectedPeer != nil {
previousConnectedPeer = connectedPeer
}
connectedPeer = nil
}
}
인사이트
MultipeerConnectivity(MPC)를 활용한 Peer 연결에서 동시성 문제 해결이 중요하다는 것을 파악했습니다. 실제 환경에서는 여러 Peer가 동시에 연결을 시도할 수 있으며, 이 과정에서 데이터 경합이나 예측하지 못한 상태 변화가 발생할 수 있습니다. 특히, MCSessionDelegate
의 연결 이벤트는 네트워크 상태에 따라 예측할 수 없는 시점에 호출되므로, 이러한 환경을 테스트하고 검증하는 것이 중요하다고 생각했습니다. 이번 테스트를 통해 Mock 객체를 활용하여 동시성 문제를 효과적으로 재현하고 해결할 수 있다는 점을 확인할 수 있었습니다.
해당 아티클의 관련 PR은 해당 링크에서 확인할 수 있습니다.