10/02 Photon PUN2 - 멀티플레이 동기화 기법

심지태
∙∙조회 3
10/02 Photon PUN2 - 멀티플레이 동기화 기법

1. Photon PUN2 동기화의 기본 흐름

PUN2에서 PhotonView는 네트워크상의 같은 오브젝트를 식별하고, 누가 그 오브젝트를 제어하는지 관리한다. 동기화 방식은 목적에 따라 선택한다.

방식전달하는 것적합한 상황장점단점
OnPhotonSerializeViewPhotonView가 관찰하는 오브젝트의 값을 주기적으로 읽고 씀위치·회전 등 자주 바뀌는 상태보낼 값과 수신 후 적용 방식을 직접 정할 수 있음빈도를 높이면 통신량과 실행 횟수, 경우에 따라 GC 할당이 증가함. 이전 상태를 보관하는 방식은 아님
Custom Properties방 또는 플레이어의 현재 상태를 키: 값으로 저장맵, 팀, 준비 상태, 라운드 등 가끔 바뀌지만 남아 있어야 하는 값나중에 들어온 사람도 현재 값을 확인할 수 있음. 과거 변경을 계속 쌓지 않음매 프레임 바뀌는 위치에는 부적합. 설정 후 서버를 거쳐 반영되므로 즉시 적용을 가정하면 안 됨
RPC특정 PhotonView의 [PunRPC] 메서드 호출해당 오브젝트의 피격 효과, 문 열기 같은 순간적 동작메서드 호출 형태라 간단하고 대상 오브젝트가 분명함대상 PhotonView가 필요함. 기본 RPC는 늦게 입장한 사람에게 재생되지 않음. Buffered RPC를 많이 쌓으면 입장이 느려질 수 있음
RaiseEvent개발자가 정한 이벤트 코드와 데이터특정 오브젝트의 함수 호출보다 방의 사건을 메시지로 다루는 편이 자연스러운 경우PhotonView 없이 보낼 수 있고 수신 대상·캐시·신뢰성 등을 직접 설정할 수 있음이벤트 코드를 정하고 수신·분기·데이터 해석을 직접 작성해야 함

한 기능에서 여러 방식을 같이 쓸 수 있다. 예를 들어 움직이는 문이라면 문이 현재 열려 있는지는 현재 상태로 저장하고, 문 열리는 효과를 지금 재생하라는 동작은 RPC로 알릴 수 있다. 선택 기준은 절대적이지 않다.

2. IPunObservable, OnPhotonSerializeView, PhotonStream

IPunObservable은 스크립트가 OnPhotonSerializeView(PhotonStream stream, PhotonMessageInfo info)를 구현하도록 하는 인터페이스다. 이 메서드에서 어떤 값을 보내고 받은 값을 어떻게 사용할지 정한다. 스크립트를 해당 PhotonView의 Observed Components에 등록해야 PUN이 호출한다. PhotonNetwork.SerializationRate는 호출 빈도를 정한다.

PhotonStream은 이번 동기화에서 값을 담거나 꺼내는 객체다. 소켓의 내부 구현을 알 필요는 없다. 보내는 쪽은 값을 담고, 받는 쪽은 보낸 순서대로 꺼낸다고 이해하면 된다.

구성 요소뜻
stream.IsWriting이 클라이언트가 현재 값을 쓰는 쪽인지 나타낸다. 보통 해당 PhotonView를 제어하는 쪽이 쓴다.
stream.SendNext(value)보낼 값을 스트림에 하나 추가한다.
stream.ReceiveNext()다음 값을 하나 꺼낸다. 반환형은 object이므로 실제 타입으로 캐스팅한다.
PhotonMessageInfo info업데이트를 보낸 사람과 보낸 시각 같은 메시지 정보를 제공한다.

예를 들어 Vector3 위치를 먼저 보내고 Quaternion 회전을 다음에 보냈다면, 받는 쪽도 위치를 Vector3로 먼저, 회전을 Quaternion으로 다음에 읽어야 한다. 보내고 읽는 순서·개수·타입이 맞아야 한다. 보내는 쪽이 아무 값도 쓰지 않으면 해당 업데이트가 전송되지 않을 수 있으므로 수신 쪽 호출을 매 프레임 Update()처럼 기대하면 안 된다.

csharp
if (stream.IsWriting)
{
    stream.SendNext(transform.position);
    stream.SendNext(transform.rotation);
}
else
{
    networkPosition = (Vector3)stream.ReceiveNext();
    networkRotation = (Quaternion)stream.ReceiveNext();
}

박싱과 GC

SendNext는 인자를 object로 받는다. Vector3, Quaternion, int, bool 같은 값 형식을 object에 넘길 때는 박싱으로 임시 관리 메모리 할당이 생긴다. 받은 쪽은 ReceiveNext()의 object를 원래 값 형식으로 캐스팅해 언박싱한다. 문자열처럼 참조 형식인 값에는 값 형식 박싱이 없다.

따라서 위치와 회전을 SendNext()로 보내는 코드에서 SerializationRate가 5에서 30으로 바뀌면, 보내는 쪽의 OnPhotonSerializeView 실행이 목표상 초당 약 5회에서 30회로 늘고 두 값의 박싱 기회도 대략 초당 10회에서 60회로 늘어난다. 이는 해당 객체의 보내는 쪽 예시이며, 실제 실행 횟수는 프레임 속도와 PUN 동작에 영향을 받는다. 객체가 많아지면 임시 할당이 쌓이고 GC 정리 시 프레임이 잠시 끊길 수 있다. 실제 부담은 Unity Profiler의 GC Alloc으로 확인한다. Serialize(ref Vector3) 같은 오버로드가 있더라도 API 이름만 보고 내부 할당이 전혀 없다고 단정하지 않는다.

3. 보간, 지연 보상, 외삽

Vector3.Lerp(현재 위치, 목표 위치, 비율)은 두 위치 사이의 값을 구한다. 수신한 위치로 곧바로 순간이동하면 움직임이 튈 수 있으므로, 매 프레임 현재 화면 위치에서 목표 위치 쪽으로 조금씩 움직일 때 쓴다. 매 프레임 새 현재 위치를 시작점으로 넣으면 목표까지 남은 거리가 점점 줄어드는 형태다. lerpSpeed * Time.deltaTime은 이 프레임에서 좁힐 비율에 관여하며, lerpSpeed 자체가 정확한 초당 이동 거리라는 뜻은 아니다.

방식목표 위치를 정하는 법특징
보간 Interpolate마지막으로 받은 위치부드럽지만 실제 위치보다 뒤에 보일 수 있음
지연 보정 LagCompensate받은 위치 + 속도 × 메시지가 오래된 시간이동 중이던 물체가 수신 시점에 어디쯤 왔을지 추정
외삽 Extrapolate지연 보정 위치 + 속도 × 마지막 수신 이후 시간새 데이터가 오기 전에도 같은 속도로 계속 움직였다고 추정

예를 들어 받은 위치가 10, 속도가 초당 2, 메시지 지연이 0.1초라면 지연 보정 위치는 10.2다. 이 예측은 계속 같은 속도와 방향으로 움직인다는 가정에 의존한다. 갑자기 멈추거나 방향을 바꾸면 실제 위치와 어긋날 수 있다. 이 수업의 지연 보상은 화면에 원격 오브젝트를 표시할 위치를 추정하는 것이며, 과거의 피격 판정을 다시 계산하는 기능은 아니다.

Snapshot: 특정 시점의 상태 기록

스냅샷 Snapshot은 특정 시점의 오브젝트 상태를 기록한 데이터다. 예를 들어 시각 1.0초, 위치 (10, 0, 0), 회전 ...을 하나의 스냅샷으로 생각할 수 있다. 필요하면 속도나 애니메이션 상태도 함께 담는다. 화면을 찍은 이미지라는 뜻은 아니며, 5절에서 설명하는 스냅처럼 위치를 즉시 맞추는 동작과도 다른 개념이다.

PUN2에서는 OnPhotonSerializeView로 시점별 상태를 전달하고, 수신 측에서 그 상태를 보관해 활용하는 방식으로 구성할 수 있다. 스냅샷은 전달할 데이터의 개념이고, OnPhotonSerializeView는 그 데이터를 전달하는 수단이다. 수신한 값을 변수 하나에 덮어쓰는 것만으로 여러 시점의 기록이 남지는 않는다. 여러 스냅샷을 이용하려면 수신 측에 별도의 버퍼가 필요하다. PUN2 상태 동기화 공식 문서

Snapshot Interpolation: 두 상태 사이의 시점을 재생하기

앞의 Lerp(현재 화면 위치, 마지막으로 받은 위치, 비율)은 화면 위치가 목표를 부드럽게 따라가게 한다. 스냅샷 보간 Snapshot Interpolation은 시각이 붙은 상태들을 버퍼에 보관하고, 표시할 시각을 앞뒤로 감싸는 두 스냅샷 사이를 보간한다. 매 프레임 바뀌는 현재 화면 위치 대신, 기록된 두 상태가 보간의 기준이 된다.

네트워크에서는 일정한 간격으로 보내도 도착 간격이 불규칙할 수 있다. 이를 완화하기 위해 원격 오브젝트를 현재보다 조금 과거의 시각으로 표시한다. 이미 받은 상태 사이를 재생할 여유를 두는 것이다. 버퍼는 수신 측에서 표시를 위해 잠깐 보관하는 기록이며, 늦게 입장한 사람에게 RPC를 재생하는 서버의 이벤트 캐시와는 목적이 다르다. 스냅샷 보간 참고 문서 — Mirror

두 스냅샷을 모두 받은 상황에서 다음처럼 계산할 수 있다. 시각은 같은 기준을 사용한다고 가정한다.

text
스냅샷 A: 시각 1.0초, 위치 10
스냅샷 B: 시각 1.2초, 위치 14
이번 프레임에 표시할 시각: 1.1초

보간 비율 = (1.1 - 1.0) / (1.2 - 1.0) = 0.5
표시 위치 = Lerp(10, 14, 0.5) = 12

표시 시각이 앞으로 진행하면 그 시각에 맞는 두 상태를 선택해 위치를 계산한다. 회전은 같은 비율로 Quaternion.Slerp를 적용할 수 있다. 수신 간격이 흔들려도 버퍼에 필요한 상태가 남아 있다면 움직임을 이어갈 수 있다.

방식계산의 기준움직임을 표현하는 방법
마지막 수신 위치를 따라가는 Lerp현재 화면 위치와 마지막 수신 위치목표까지 남은 차이를 매 프레임 줄임
스냅샷 보간표시할 시각을 감싸는 두 상태와 각각의 시각이미 받은 과거 상태 사이를 재생함
외삽마지막 상태와 속도, 경과 시간아직 받지 못한 시점의 위치를 예측함

버퍼에 여유를 많이 두면 불규칙한 수신을 견디기 쉬워지지만, 원격 움직임을 더 늦게 보게 된다. 표시할 시각 뒤의 스냅샷이 아직 없다면 두 상태 사이의 보간을 계속할 수 없으므로, 마지막 상태에서 잠시 멈추거나 제한된 시간 동안 외삽하는 정책이 필요하다. 순간이동처럼 연속 이동이 아닌 경우에는 이전 기록을 비우고 새 위치로 맞추는 처리가 필요할 수 있다.

PUN2에서 직접 구현할 때는 상태의 시각을 같은 Photon 네트워크 시간 기준으로 맞추고, 오래된 기록을 정리하며 버퍼 크기도 제한한다. SerializationRate는 새 상태를 만드는 빈도이고 보간 버퍼의 지연 시간은 수신 측에서 언제의 상태를 보여줄지 정하는 값이다. 둘은 별도로 조절한다. 아래의 위치·속도 기반 지연 보정은 현재 위치를 추정하는 접근이며, 과거 상태 사이를 재생하는 스냅샷 보간과 구분해서 이해한다.

4. SerializationRate와 SendRate: 데이터 생성과 전송 작업

설정무엇을 정하나높이면 얻는 점높이면 드는 비용낮추면
PhotonNetwork.SerializationRate제어 중인 PhotonView의 OnPhotonSerializeView를 목표상 초당 몇 번 호출할지위치·회전의 새 값을 더 자주 만들어 원격 화면이 움직임을 더 촘촘히 따라갈 수 있음콜백·값 읽기·직렬화 작업이 늘고, 새 전송 데이터도 늘 수 있음. 이 수업의 SendNext 값 형식에서는 박싱·GC 할당도 더 자주 생김CPU·통신 부담이 줄지만 새 위치 사이 간격이 길어져 늦게 따라오거나 보간에 더 의존함
PhotonNetwork.SendRate내 클라이언트가 대기 중인 메시지를 서버로 보내는 작업을 목표상 초당 몇 번 할지RPC·RaiseEvent 등이 다음 전송 시점까지 기다리는 시간을 줄일 수 있음데이터가 있을 때 작은 전송이 잦아지면 전송 횟수와 부가적인 네트워크 비용이 늘 수 있음전송을 묶기 쉬우나 대기 중인 메시지가 다음 시점까지 더 기다릴 수 있음

여기서 컴퓨터 작업량은 콜백 실행, 값 수집, 직렬화·역직렬화 등에 쓰는 CPU 시간이다. 통신량은 네트워크로 오가는 데이터 양과 각 전송에 필요한 부가 정보를 포함한다. 부하가 작으면 체감되지 않을 수 있지만, 객체와 수신자가 많아지고 네트워크가 감당하기 어려워지면 지연이나 불규칙한 움직임이 생길 수 있다. 값을 높이는 것 자체가 항상 성능 문제를 일으키는 것은 아니다.

SendRate를 올려도 RPC를 만드는 횟수는 늘어나지 않는다. 점프 RPC를 1번 호출하면 전송할 점프 RPC 메시지는 1개다. 다른 클래스나 다른 PhotonView에서 호출한 RPC, RaiseEvent 등도 같은 클라이언트가 보낼 메시지가 된다. 클래스별로 따로 모으는 개념이 아니다.

text
내 클라이언트: RPC A 호출 → 메시지 A 대기
내 클라이언트: RPC B 호출 → 메시지 B 대기
다음 전송 작업: 대기 중인 A와 B를 서버로 보냄

서로 가까운 시각에 만들어진 메시지는 같은 네트워크 전송에 담길 수 있지만, 크기나 상황에 따라 여러 전송으로 나뉠 수도 있다. SendRate는 한 번에 보낼 메시지 개수나 묶음 크기가 아니다. 예를 들어 SendRate = 5는 대략 0.2초 간격의 전송 작업을 뜻한다. RPC를 호출한 순간부터 별도의 0.2초 타이머가 시작되는 것이 아니라 다음 전송 시점을 기다린다. 보낼 것이 없다면 빈 RPC를 반복해서 보내지 않는다. 실제 연결 유지용 통신은 이 설명과 별개다.

여러 플레이어가 동시에 점프해도 한 클라이언트가 모든 플레이어의 RPC를 모아 보내는 것은 아니다. 각 플레이어의 클라이언트가 자기 RPC를 서버로 보낸다. 두 플레이어의 점프가 모든 화면에서 같은 순서로 처리되어야 한다면 단순한 동시 호출만으로는 충분하지 않으며 RpcTarget.AllViaServer처럼 서버를 거치는 순서 지정 방식을 고려한다.

RPC를 지속적으로 너무 많이 만들어 메시지 생성 속도가 네트워크의 전송·처리 속도보다 빨라지면 대기열이 밀려 지연이 커질 수 있다. 소수의 RPC가 다음 전송 시점까지 잠깐 기다리는 정상적인 상황과는 다르다. SendRate만 높여도 RPC 생성량 자체가 줄지는 않는다.

5. 위치와 속도를 이용한 지연 보정

원격 플레이어의 위치 정보는 작성된 순간의 상태다. 네트워크를 거쳐 도착할 때에는 이미 과거의 위치가 되어 있다. 받은 위치를 그대로 화면에 표시하면 원격 플레이어가 실제 움직임보다 뒤따라오는 것처럼 보일 수 있다. 이를 줄이는 한 방법은 위치와 이동 속도를 함께 보내고, 정보가 오래된 만큼 움직였을 거리를 수신 측에서 추정하는 것이다.

속도는 일정 시간 동안 바뀐 위치를 그 시간으로 나누어 추정할 수 있다. 예를 들어 0.5초 동안 위치가 10에서 11로 바뀌었다면 평균 속도는 초당 2다. 메시지에 붙은 전송 시각과 현재 Photon 네트워크 시각을 비교하면 수신한 정보가 얼마나 오래되었는지도 추정할 수 있다.

text
받은 위치: 10
받은 속도: 초당 2
정보가 오래된 시간: 0.1초
추정 위치: 10 + (2 × 0.1) = 10.2

이것이 수신 지연 보정이다. 새 업데이트가 아직 오지 않은 동안에도 마지막 속도로 계속 이동한다고 가정해 위치를 더 앞쪽으로 추정하는 방식이 외삽이다. 두 시간은 구분해야 한다. 전송부터 수신까지의 시간은 이미 도착한 정보가 오래된 정도이고, 마지막 수신 이후의 시간은 새 정보 없이 추가로 기다린 정도다. 외삽에 사용할 마지막 수신 이후 시간은 새 업데이트를 받을 때 다시 0부터 세어야 한다.

예측이 항상 맞는 것은 아니다. 플레이어가 멈추거나 방향을 바꾸면 마지막 속도로 계산한 목표가 실제 위치와 달라진다. 그래서 지연 시간과 외삽 시간을 일정 범위로 제한하고, 평소에는 Lerp로 표시 위치를 목표에 부드럽게 접근시킨다. 차이가 지나치게 커지면 보간만으로는 오래 틀린 위치에 남을 수 있으므로 정해 둔 거리 이상에서 즉시 위치를 맞추는 스냅을 사용할 수 있다. 회전은 Quaternion.Slerp로 부드럽게 따라가게 할 수 있지만, 회전의 미래값을 예측하려면 이동 속도와 별도로 회전 정보가 필요하다.

이 방법은 원격 오브젝트를 화면에 어디에 그릴지 개선한다. 속도와 시각을 이용해 위치를 추정하므로, 피격 판정을 과거 시점으로 되돌리는 서버 측 지연 보상과는 범위가 다르다.

6. RaiseEvent와 RPC의 관계

RPC는 PhotonView에 붙은 [PunRPC] 메서드를 원격에서 실행하도록 하는 PUN의 편의 기능이다. RaiseEvent는 개발자가 이벤트 코드와 데이터를 정해 보내고, 수신 측에서 IOnEventCallback.OnEvent 등으로 코드를 확인해 처리하는 범용 이벤트 기능이다. “한 단계 아래”는 개발자가 메시지 형식을 더 직접 관리한다는 뜻이지, 무조건 먼저 실행되거나 더 빠르다는 뜻이 아니다.

RPC도 모두·다른 사람·특정 플레이어에게 보낼 수 있다. 따라서 게임 시작 알림이나 특정 플레이어에게 보내기는 RaiseEvent만의 장점이 아니다. RPC로 충분하면 RPC가 더 간단할 수 있다. 자연스러운 대상 PhotonView가 없거나, 이벤트 코드 기반으로 메시지를 설계하고 전송 대상·관심 그룹·캐싱·신뢰성 같은 옵션을 직접 다루고 싶을 때 RaiseEvent를 고려한다. RPC를 위한 별도 PhotonView를 두어 같은 기능을 구현하는 것도 가능하다.

기본 RPC와 일반 이벤트는 나중에 들어온 플레이어에게 지난 호출을 다시 보여주지 않는다. 필요하면 Buffered RPC 또는 이벤트 캐시를 사용할 수 있지만, 오래된 호출을 계속 쌓으면 나중에 입장할 때 재생할 기록이 커진다. 현재 상태 한 값만 필요하다면 Custom Properties가 더 알맞을 수 있다.

7. 소유권 요청과 제어권 이전

네트워크 오브젝트를 여러 클라이언트가 동시에 마음대로 움직이면 서로 다른 위치를 주장할 수 있다. PUN2는 PhotonView마다 현재 누가 상태 업데이트를 담당하는지 정하고, 로컬 클라이언트가 그 제어자인지 photonView.IsMine으로 확인한다. IsMine은 “내가 처음 만들었는가?”가 아니라 “지금 내가 제어하는가?”라는 뜻이다.

다른 플레이어가 제어하는 오브젝트를 내가 조작하려면 제어권의 근거가 되는 소유권 이전이 필요할 수 있다. photonView.RequestOwnership()은 해당 PhotonView의 소유권을 달라고 요청한다. 요청은 즉시 소유권을 확정하는 함수가 아니다. 이전이 완료되어 내가 새 제어자가 된 뒤에 IsMine이 true가 된다.

옵션요청 결과
Fixed기본값. 소유권이 고정되어 요청만으로 가져올 수 없음
Request현재 소유자 또는 제어자가 OnOwnershipRequest를 받고 TransferOwnership(requestingPlayer)로 승인해야 이전됨
TakeoverRequestOwnership() 요청이 별도 승인 없이 받아들여질 수 있음. 공식 문서는 직접 TransferOwnership 사용을 권장함

Request 옵션은 요청과 승인이라는 두 단계가 필요하다. 승인 콜백을 구현한 코드가 없다면 요청만 보내도 소유권은 바뀌지 않는다. 반면 Takeover는 현재 소유자의 승인을 기다리지 않고 가져올 수 있다. 어느 방식을 허용할지는 공처럼 플레이어 사이에서 주고받는 물체인지, 특정 플레이어에게 고정된 캐릭터인지 등 게임 규칙에 따라 정한다.

소유권 이전은 누가 오브젝트 상태를 만들고 보낼 권한을 갖는가를 바꾸는 것이다. 오브젝트의 실제 위치나 회전을 다른 화면에 보여주는 일은 별도의 상태 동기화가 담당한다. 따라서 제어권을 넘기는 것과 위치를 전송하는 것을 같은 기능으로 생각하면 안 된다.

8. 씬 객체, 생성자, 소유자, 제어자

씬에 미리 배치된 오브젝트는 게임 실행 전에 Unity 에디터의 Hierarchy에 넣어 둔 오브젝트다. 예를 들어 씬 파일 안에 PhotonView가 붙은 공이 처음부터 있으면 플레이 도중 누군가 PhotonNetwork.Instantiate()한 공과 구분된다. 씬 객체는 특정 플레이어의 PhotonNetwork.Instantiate() 결과가 아니므로 처음에는 특정 플레이어 소유자가 없고, 보통 Master Client가 제어한다.

용어뜻바뀔 수 있나?
생성자 CreatorPhotonNetwork.Instantiate()를 호출해 객체를 처음 만든 플레이어객체의 생성 이력으로 남음
소유자 Owner객체의 기본 제어권을 가진 플레이어소유권 이전 가능
제어자 Controller현재 상태 업데이트를 담당하는 플레이어. IsMine의 기준소유자가 비활성화되거나 없어지면 Master Client가 대신할 수 있음

소유자가 일시적으로 연결이 끊기고 재접속 가능한 상태라면 남아 있는 객체의 소유권은 그대로이고 Master Client가 임시로 제어할 수 있다. 소유자가 돌아오면 제어를 되찾을 수 있다. 플레이어가 완전히 룸에서 제거되고 객체가 남아 있다면 그 객체의 소유자는 null이 되고 Master Client가 제어한다. 즉, 마스터에게 소유권이 자동으로 이전되는 것은 아니다.

객체가 아예 남는지는 누가 생성했는지와 자동 정리 설정에 달려 있다. 기본 정리가 켜진 방에서 플레이어가 완전히 떠나면 그가 PhotonNetwork.Instantiate()로 만든 객체는 삭제된다. 이미 다른 사람이 소유권을 넘겨받았더라도 생성자 이력은 바뀌지 않으므로 생성자가 떠날 때 삭제될 수 있다. 반면 씬에 원래 있던 객체나 다른 사람이 만든 객체를 떠난 사람이 소유만 했다면 객체는 남고 위의 제어권 규칙을 따른다.

9. AutoCleanUp 설정

PUN2의 AutoCleanUp은 전역 PhotonNetwork 설정이 아니라 방 생성 시 정하는 옵션이다. RoomOptions.CleanupCacheOnLeave의 기본값은 true다. 현재 방에서는 읽기 전용인 PhotonNetwork.CurrentRoom.AutoCleanUp으로 적용된 값을 확인한다.

csharp
using Photon.Pun;
using Photon.Realtime;

RoomOptions options = new RoomOptions
{
    CleanupCacheOnLeave = false
};

PhotonNetwork.CreateRoom("MyRoom", options);

true이면 나간 플레이어의 캐시와 그가 생성한 네트워크 객체를 자동으로 정리한다. false이면 자동 정리를 끄므로 남는 객체와 캐시를 게임 코드에서 직접 관리해야 한다. 이미 존재하는 방에 참가하는 사람은 그 방을 만들 때 정해진 설정을 따른다. PlayerTtl이 설정되어 잠시 비활성화된 상태인지, 완전히 제거된 상태인지에 따라 소유·제어권의 처리 시점은 달라질 수 있다.