안녕하세요! 피피아노입니다 🎵
iOS 16부터 제공되는 Object Capture API를 사용하면 iPad의 LiDAR 센서로 실제 물체를 3D 모델로 변환할 수 있습니다. 하지만 생성된 모델을 어떻게 활용할 것인가에 대한 고민이 생겼습니다.
처음에는 아이폰과 아이패드 내부에만 저장하고 있었는데, 이렇게 되면 여러 기기에서 모델을 공유하기 어렵고, 서버에서 추가 처리를 할 수 없으며, 데이터 분석이나 백업이 불편했습니다.
물론 학습 목적으로 만든 앱이기 때문에 해당 부분이 없다고 해서 치명적인 문제가 발생하는 것은 아니지만, 실제로 서비스를 한다면 이러한 부분도 빼놓을 수 없을 정도로 중요한 부분이기 때문에 스캔 후 사용자가 원하면 바로 서버로 업로드하는 기능을 추가하기로 하였습니다.
우선 사용한 기술을 아래와 같습니다.
- iOS: SwiftUI, RealityKit, The Composable Architecture (TCA)
- 서버: Python Fast API, Uvicorn
- 통신: URLSession, Multipart Form Data
왜 로컬 서버로?
처음에는 AWS나 Firebase Storage 혹은 iCloud의 사용을 고려했지만 해당 기술들은 유료로 사용해야 하고, 아직은 학습이 주된 목적이기 때문에 개발 초기 단계에서는 로컬 서버가 더 적합하다고 생각하였습니다.
통신 구조는 아래처럼 구현하였습니다.
iPhone, iPad 앱에서 3D 스캔 -> USDZ 파일 생성(Multipart Form Data) -> Mac 로컬 서버에 파일 전송(Fast API 사용)
Multipart Form Data를 사용한 이유는 JSON으로는 바이너리 파일을 전송하기 어렵기도 하고 HTTP 표준 방식으로 대부분의 서버가 지원하고 메타데이터와 파일을 함께 전송 가능했기 때문에 해당 기술을 사용하게 되었습니다.
서버 구축하기
우선 앱의 기본적인 기능은 완성이 되어 있었기 때문에 Mac에 서버 구축부터 해줬습니다.
해당 서버를 구축하기 위해서 Fast API로 서버를 구축했습니다.
from fastapi import FastAPI, File, UploadFile
from fastapi.responses import JSONResponse
import shutil
from pathlib import Path
app = FastAPI()
# 업로드된 파일을 저장할 디렉토리
UPLOAD_DIR = Path("uploads")
UPLOAD_DIR.mkdir(exist_ok=True)
@app.get("/")
def root():
return {"status": "server running"}
@app.post("/upload-model")
async def upload_model(file: UploadFile = File(...)):
try:
# 파일 저장
file_path = UPLOAD_DIR / file.filename
with file_path.open("wb") as buffer:
shutil.copyfileobj(file.file, buffer)
return {
"success": True,
"message": "File uploaded successfully",
"fileId": file.filename,
"size": file_path.stat().st_size
}
except Exception as e:
return JSONResponse(
status_code=500,
content={
"success": False,
"message": f"Upload failed: {str(e)}",
"fileId": None
}
)
서버 실행
그리고 나서 Mac의 터미널에서 서버를 실행해주었습니다.
# 모든 네트워크 인터페이스에서 접근 가능하도록 설정
uvicorn main:app --host 0.0.0.0 --port 8000 --reload
우선은 서버와 제 앱의 연결을 목표로 공부하는 것이기 때문에 모든 네트워크에서 접근 가능하도록 해줬습니다.
iOS 클라이언트 구현
TCA의 Dependency 패턴을 사용해서 네트워크 클라이언트를 구현해주었습니다.
그리고 Alamofire 같은 라이브러리도 있었지만 저는 URLSession을 사용했습니다.
단순한 파일 업로드에는 URLSession만으로도 충분해서 굳이 외부 라이브러리를 쓸 이유도 없었고 외부 의존성을 최소화해서 앱의 크기나 빌드 시간을 줄이는 것이 좋다고 생각했기 때문입니다.
// Boundary: 멀티파트 데이터의 각 부분을 구분하는 구분자
let boundary = UUID().uuidString
// HTTP Body 구성
var body = Data()
body.append("--\(boundary)\r\n".data(using: .utf8)!)
body.append("Content-Disposition: form-data; name=\"file\"; filename=\"MyModel.usdz\"\r\n".data(using: .utf8)!)
body.append("Content-Type: model/vnd.usdz+zip\r\n\r\n".data(using: .utf8)!)
body.append(fileData) // 실제 파일 내용
body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!)
이 구조는 HTTP 표준 RFC 7578을 따르며, 모든 웹 서버가 이해할 수 있습니다.
UX 설계 고민
언제, 어떻게 업로드를 하게 할 것인가에 대해서도 3가지 옵션을 고민했습니다.
- 자동 업로드: 모델 생성 즉시 자동으로 서버에 업로드
- 장점: 사용자가 신경 쓸 필요 없음
- 단점: 원하지 않는 업로드 발생 가능, 실제 서비스라면 네트워크 비용 증가
- 별도 화면: 스캔한 모델을 별로의 갤러리 화면에서 선택하여 업로드
- 장점: 사용자에게 선택권을 제공
- 단점: 추가 화면 필요, 플로우 복잡도 증가(살짝..?)
- 미리보기 화면에서 업로드 여부 선택: AR QuickLook 뷰어에 업로드 버튼 추가
- 장점: 모델 확인 후 즉시 업로드 가능
- 단점: 스캔하고 업로드 여부를 당장 결정해야 함
이렇게 3가지 옵션 중에서 저는 미리보기 화면에서 업로드를 하기로 결정했습니다.
현재 제가 구현한 기능에 이미 AR QuickLook으로 스캔하고 미리보기 기능을 제공해주고 있었기 때문에 이 방법을 선택하였습니다.
업로드 상태 피드백
사용자는 항상 "지금 뭐가 일어나고 있는지"를 알아야 합니다.
그렇기 때문에 업로드 상태 피드백을 넣어주었습니다.
// 상태별 메시지
switch uploadState {
case .idle: ""
case .uploading: "Uploading to server..."
case .success: "✓ Upload complete!" // 3초 후 자동 제거
case .failure: "✗ Upload failed" // 3초 후 자동 제거
}
겪었던 주요 문제와 해결 과정
Cannot make a view for a deinitialized ObjectCaptureSession 에러 발생
문제 발견 과정은 앱을 테스트 해보기 위해서 몇 번 스캔을 진행했는데 스캔 후 다음 단계로 넘어가면 일부 상황에서 검은 화면에 해당 메세지가 뜨는 것을 발견하였습니다.
원인을 분석해보니, ObjectCaptureSession은 생명주기가 있는데 문제는 세션이 completed 상태가 된 후에도 ObjectCaptureView가 계속 렌더링을 시도했다는 것입니다.
initializing → ready → detecting → capturing → finishing → completed
해당 문제를 해결하기 위해 세션 상태에 따라 다른 뷰를 표시해주도록 하였고 상태 기반으로 UI를 분기 처리해주었습니다.
// 세션 상태에 따라 다른 뷰 표시
if case .completed = viewModel.session.state {
// 촬영 완료 화면 (3D 모델 생성 버튼)
CompletedView()
} else {
// 촬영 중 화면 (카메라 뷰)
ObjectCaptureView(session: viewModel.session)
}
배운 점과 개선 방향
아키텍처 설계의 중요성을 알게 되었습니다.
처음부터 NetworkClient를 분리하고 TCA를 사용한 덕분에 나중에 로컬 서버에서 클라우드 서버로 마이그레이션할 때 URL 하나만 바꾸면 되는 구조가 되었고, 복잡한 비동기 작업도 명확한 액션과 상태로 관리할 수 있었습니다.
그리고 생명주기를 알고 있음에도 막상 앱을 개발하니 생명주기를 제대로 관리해주지 않아서 에러가 발생한 경우가 많았기에 생명주기를 더 꼼꼼하게 체계적으로 관리하는 것이 중요하다는 것을 느끼게 되었습니다.
감사합니다.
잘못된 내용이 있거나 더 좋은 내용 피드백은 언제나 환영합니다!
궁금하신 부분은 댓글로 질문 부탁드립니다!
'Apple > Swift' 카테고리의 다른 글
| [Swift] SwiftData 모델 구조 변경 시 런타임 에러 해결하기 (0) | 2025.10.13 |
|---|---|
| [Swift] 앱 인텐트 알아보기 (5) | 2025.09.03 |
| [Swift] Multipeer Connectivity 톺아보기 (5) | 2025.08.23 |
| [Swift] Swift Testing 톺아보기 (6) | 2025.07.02 |
| [Swift] suffix()로 인한 시간 초과 문제 해결하기 (8) | 2025.06.27 |
