Flutter는 뛰어난 크로스플랫폼 개발 프레임워크이지만, 모든 클라우드 서비스에 대한 공식 SDK가 완벽하게 갖춰져 있지는 않습니다. 특히 AWS(Amazon Web Services)의 경우, Dart/Flutter를 위한 공식 SDK가 아직 부재하여 많은 개발자들이 AWS 서비스 연동에 어려움을 겪고 있습니다. 그중에서도 S3(Simple Storage Service)에 파일을 업로드하는 기능은 많은 애플리케이션의 핵심 요구사항입니다.
이러한 상황에서 가장 보편적이고 안전한 해결책은 'Presigned URL'을 사용하는 것입니다. Presigned URL은 백엔드 서버가 AWS 자격 증명을 사용하여 특정 S3 객체에 대해 제한된 시간 동안 유효한 서명된 URL을 생성하는 방식입니다. Flutter 앱은 이 URL을 받아 AWS 자격 증명 없이도 직접 S3에 파일을 업로드(PUT)하거나 다운로드(GET)할 수 있습니다. 이 방식은 클라이언트 앱에 민감한 AWS 키를 노출하지 않아 보안상 매우 안전하며, 파일 업로드 트래픽을 백엔드 서버를 거치지 않고 S3로 직접 보내므로 서버 부하를 줄일 수 있는 효율적인 아키텍처입니다.
이 글에서는 백엔드에서 Presigned URL을 생성하는 과정은 이미 완료되었다고 가정합니다. 우리의 초점은 Flutter 앱에서 이 Presigned URL과 강력한 HTTP 클라이언트 라이브러리인 Dio를 사용하여 파일을 S3에 안정적으로 업로드하는 방법에 있습니다. 많은 개발자들이 Dio의 기본 사용법만으로 파일 업로드를 시도하다가 원인 모를 파일 손상 문제에 직면하곤 합니다. 이 글을 통해 그 문제의 근본적인 원인을 파헤치고, 재사용 가능하며 안정적인 최종 코드를 완성해 보겠습니다.
1. 문제의 시작: 순조로워 보였던 첫 시도
Flutter에서 HTTP 통신을 할 때 Dio는 거의 표준처럼 사용되는 라이브러리입니다. 풍부한 기능과 직관적인 API를 제공하기 때문이죠. Dio 공식 문서나 여러 예제를 살펴보면, 바이너리 데이터(파일)를 업로드하는 것은 매우 간단해 보입니다. 일반적으로 파일의 byte 스트림을 `data` 속성에 전달하면 됩니다.
예를 들어, `image_picker`와 같은 패키지를 사용하여 사용자로부터 이미지 파일을 선택받았다고 가정해 봅시다. 선택된 파일은 `File` 객체로 얻을 수 있으며, `file.readAsBytes()`나 `file.openRead()`를 통해 파일 데이터를 얻을 수 있습니다. 이를 바탕으로 S3 Presigned URL에 PUT 요청을 보내는 코드는 다음과 같이 작성할 수 있습니다.
import 'package:dio/dio.dart';
import 'dart:io';
Future<void> uploadFileIncorrectly(String presignedUrl, File file) async {
final dio = Dio();
final fileBytes = await file.readAsBytes();
try {
final response = await dio.put(
presignedUrl,
data: fileBytes, // 파일의 바이트 데이터를 직접 전달
options: Options(
headers: {
'Content-Length': fileBytes.length.toString(), // 콘텐츠 길이를 헤더에 명시
// 다른 헤더들을 이곳에 추가하려는 시도를 할 수 있다.
},
),
);
if (response.statusCode == 200) {
print('파일 업로드 성공!');
} else {
print('파일 업로드 실패: ${response.statusCode}');
}
} catch (e) {
print('업로드 중 에러 발생: $e');
}
}
위 코드는 논리적으로 완벽해 보입니다. Dio 인스턴스를 생성하고, 업로드할 파일의 `presignedUrl`과 `File` 객체를 받아 `dio.put` 메소드를 호출합니다. 데이터로는 파일의 바이트 배열(`Uint8List`)을 전달하고, `Options`를 통해 S3가 요구할 수 있는 `Content-Length` 헤더도 명시했습니다. 이 코드를 실행하면 에러 없이 `200 OK` 응답을 받으며 '파일 업로드 성공!'이라는 메시지가 출력될 가능성이 높습니다.
하지만 진짜 문제는 여기서부터 시작됩니다. 업로드된 파일에 접근하기 위해 S3 URL을 열어보면, 우리가 기대했던 이미지나 파일이 아니라 다음과 같이 깨진 데이터, 즉 알아볼 수 없는 문자들의 나열이 나타납니다.

사진: 성공적으로 업로드된 것처럼 보였지만 실제로는 손상된 파일
이 현상은 개발자를 매우 혼란스럽게 만듭니다. 서버는 성공(200)을 반환했는데, 왜 파일은 깨져있을까요? 많은 개발자들이 이 단계에서 인코딩 문제라고 추측하고 `headers`에 `charset=utf-8`과 같은 값을 추가해보거나 다른 헤더들을 조정해보지만 문제는 해결되지 않습니다. Flutter나 Dio 관련 자료, 특히 S3 연동에 대한 구체적인 자료가 부족하여 구글링으로도 명확한 해답을 찾기 어려운 '미궁'에 빠지게 됩니다.
2. 실패의 근본 원인: Content-Type의 배신
결론부터 말하자면, 이 문제의 핵심 원인은 `Content-Type` 헤더의 부재 또는 잘못된 설정에 있습니다.
HTTP 프로토콜에서 `Content-Type` 헤더는 요청(Request)이나 응답(Response)의 본문(Body)에 포함된 데이터가 어떤 종류의 미디어 타입인지를 명시하는 매우 중요한 역할을 합니다. 예를 들어, `Content-Type: application/json`은 본문이 JSON 데이터임을, `Content-Type: image/jpeg`는 JPEG 이미지 데이터임을 알려줍니다.
S3는 파일을 저장할 때 이 `Content-Type`을 객체의 메타데이터로 함께 저장합니다. 만약 이 헤더가 제공되지 않으면, S3는 기본값으로 `binary/octet-stream`이나 `application/octet-stream`과 같은 일반적인 이진 데이터 타입으로 간주합니다. 이 타입 자체는 파일 저장을 막지는 않지만, 브라우저나 다른 클라이언트가 이 파일을 내려받을 때 어떻게 처리해야 할지 알 수 없게 만듭니다. 브라우저는 `image/jpeg` 파일을 받으면 이미지 뷰어로 렌더링하지만, `binary/octet-stream` 파일을 받으면 단순 텍스트나 바이너리 덤프로 표시하려고 시도할 수 있으며, 이것이 바로 위 스크린샷에서 본 '깨진' 데이터의 정체입니다.
"그렇다면 `headers`에 `Content-Type`을 추가하면 되지 않을까?" 라고 생각하는 것이 당연한 수순입니다. 그래서 많은 개발자들이 다음과 같이 코드를 수정합니다.
// 잘못된 시도: headers 맵에 직접 Content-Type을 추가하는 방법
final response = await dio.put(
presignedUrl,
data: fileBytes,
options: Options(
headers: {
'Content-Length': fileBytes.length.toString(),
'Content-Type': 'image/jpeg', // <-- 이렇게 추가해 보지만...
},
),
);
놀랍게도, 이 방법 역시 동일하게 실패합니다. Dio의 내부 동작 방식과 관련이 있는데, `dio.put`과 같은 요청 메소드의 `Options` 객체에 있는 `headers` 맵은 일반적인 헤더를 설정하는 데 사용됩니다. 하지만 파일 스트림이나 바이트 데이터를 `data`로 전달할 때, Dio는 `Content-Type`을 설정하는 데 있어 더 특별한 메커니즘을 사용합니다. 단순히 `headers` 맵에 문자열로 추가하는 방식은 Dio가 내부적으로 데이터 타입을 처리하는 로직에 의해 무시되거나 올바르게 적용되지 않을 수 있습니다.
이것이 바로 많은 개발자들이 함정에 빠지는 지점입니다. 가장 직관적인 방법이 통하지 않기 때문에, 문제의 원인을 다른 곳(인코딩, S3 설정, Presigned URL 생성 오류 등)에서 찾기 시작하며 시간을 허비하게 됩니다.
3. 완벽한 해결책: Dio의 `Options.contentType` 활용하기
문제의 해결책은 의외로 간단하며, Dio가 이러한 시나리오를 위해 마련해 둔 '올바른' 방법을 사용하는 것입니다. 바로 `Options` 객체의 `headers` 맵이 아닌, `contentType` 속성을 직접 사용하는 것입니다.
`Options` 클래스에는 `String? contentType`이라는 별도의 속성이 존재합니다. 이 속성은 요청의 `Content-Type` 헤더를 설정하기 위해 특별히 설계되었습니다. Dio는 이 속성값을 사용하여 `Content-Type` 헤더를 올바르게 구성합니다.
따라서, 실패했던 코드를 다음과 같이 단 한 줄만 수정하면 모든 문제가 해결됩니다.

위 이미지를 코드로 표현하면 다음과 같습니다.
import 'package:dio/dio.dart';
import 'dart:io';
Future<void> uploadFileCorrectly(String presignedUrl, File file) async {
final dio = Dio();
// 대용량 파일을 위해 바이트 배열 대신 스트림 사용을 권장합니다.
final stream = file.openRead();
final length = await file.length();
try {
final response = await dio.put(
presignedUrl,
data: stream, // 데이터 스트림 전달
options: Options(
// headers 맵이 아닌, contentType 속성을 직접 사용합니다.
contentType: 'image/jpeg', // <-- 이것이 핵심!
headers: {
// Content-Length는 여전히 중요합니다.
'Content-Length': length.toString(),
},
),
);
if (response.statusCode == 200) {
print('파일 업로드 성공! 파일이 정상적으로 저장되었습니다.');
} else {
print('파일 업로드 실패: ${response.statusCode}');
}
} catch (e) {
print('업로드 중 에러 발생: $e');
}
}
주목할 점:
- `Options(contentType: ...)`: `headers` 맵에서 `Content-Type`을 제거하고, `Options`의 최상위 속성인 `contentType`에 직접 미디어 타입을 문자열로 지정했습니다. 이것이 Dio가 파일 업로드 시 `Content-Type` 헤더를 안정적으로 설정하도록 하는 공식적인 방법입니다.
- 스트림(`Stream`) 사용: `file.readAsBytes()`는 파일 전체를 메모리에 한 번에 로드합니다. 작은 파일에는 문제가 없지만, 수십, 수백 메가바이트의 대용량 파일을 업로드할 경우 메모리 부족(Out of Memory) 오류를 유발할 수 있습니다. `file.openRead()`는 파일을 스트림 형태로 열어주므로, Dio가 데이터를 조금씩 읽어 네트워크로 전송하게 됩니다. 이는 메모리 사용량 측면에서 훨씬 효율적이고 안정적인 방법입니다.
- `Content-Length`: 스트림을 사용할 때도 파일의 전체 길이를 `Content-Length` 헤더로 알려주는 것이 좋습니다. 대부분의 경우 S3 Presigned URL은 이 헤더를 필수로 요구합니다. `await file.length()`로 파일 길이를 미리 계산하여 헤더에 포함시킵니다.
이렇게 수정된 코드로 파일을 다시 업로드하고 S3 URL을 열어보면, 이번에는 파일이 손상되지 않고 브라우저에서 이미지나 해당 파일 타입에 맞게 정상적으로 표시되는 것을 확인할 수 있습니다.
4. 실전 적용: 동적 Content-Type 및 업로드 진행률 표시
실제 애플리케이션에서는 업로드할 파일의 타입이 `image/jpeg`로 고정되어 있지 않습니다. PNG, GIF, PDF, 동영상 등 다양한 파일을 다루어야 합니다. 파일 확장자에 따라 `Content-Type`을 동적으로 결정하는 기능이 필요합니다.
또한, 사용자 경험을 위해 파일 업로드가 얼마나 진행되었는지 시각적으로 보여주는 것이 매우 중요합니다. Dio는 `onSendProgress` 콜백을 통해 이 기능을 손쉽게 구현할 수 있도록 지원합니다.
이 두 가지 실전 기능을 추가한 최종 코드를 만들어 보겠습니다. `Content-Type`을 동적으로 알아내기 위해 `mime` 패키지를 사용하겠습니다.
먼저 `pubspec.yaml` 파일에 `dio`와 `mime`을 추가합니다.
dependencies:
flutter:
sdk: flutter
dio: ^5.4.3+1 # 최신 버전 확인
mime: ^1.0.5 # 최신 버전 확인
image_picker: ^1.1.2 # 예제를 위한 패키지
다음은 모든 기능을 통합한 업로드 서비스 클래스 예제입니다.
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:mime/mime.dart'; // mime 패키지 임포트
class FileUploadService {
final Dio _dio = Dio();
Future<void> uploadToS3({
required String presignedUrl,
required File file,
required void Function(int sent, int total) onProgress,
}) async {
try {
final stream = file.openRead();
final length = await file.length();
// 파일 경로를 기반으로 MIME 타입 추론
// lookupMimeType은 'path/to/image.jpg' -> 'image/jpeg' 와 같이 변환해줍니다.
// 만약 알 수 없는 타입이면 null을 반환할 수 있으므로 기본값을 설정합니다.
final mimeType = lookupMimeType(file.path) ?? 'application/octet-stream';
print('Uploading file: ${file.path}');
print('Content-Type: $mimeType');
print('Content-Length: $length');
final response = await _dio.put(
presignedUrl,
data: stream,
options: Options(
contentType: mimeType, // 동적으로 결정된 Content-Type 사용
headers: {
'Content-Length': length.toString(),
// Presigned URL에 따라 다른 헤더가 필요할 수 있습니다.
// 'x-amz-acl': 'public-read' 등
},
),
onSendProgress: onProgress, // 진행률 콜백 연결
);
if (response.statusCode == 200) {
print('✅ S3 Upload Success');
} else {
print('❌ S3 Upload Failed: ${response.statusCode}');
print('Response Body: ${response.data}');
}
} on DioException catch (e) {
print('❌ Dio Error during S3 Upload');
print('Error: ${e.message}');
print('Response: ${e.response}');
// DioException은 더 자세한 네트워크 오류 정보를 제공합니다.
rethrow;
} catch (e) {
print('❌ Unexpected Error during S3 Upload: $e');
rethrow;
}
}
}
이 `FileUploadService`를 Flutter 위젯에서 사용하는 방법은 다음과 같습니다.
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'dart:io';
// ... (FileUploadService 클래스는 다른 파일에 있다고 가정)
class UploadScreen extends StatefulWidget {
const UploadScreen({super.key});
@override
State<UploadScreen> createState() => _UploadScreenState();
}
class _UploadScreenState extends State<UploadScreen> {
final FileUploadService _uploadService = FileUploadService();
final ImagePicker _picker = ImagePicker();
File? _selectedFile;
double _uploadProgress = 0.0;
bool _isUploading = false;
Future<void> _pickImage() async {
final XFile? image = await _picker.pickImage(source: ImageSource.gallery);
if (image != null) {
setState(() {
_selectedFile = File(image.path);
_uploadProgress = 0.0; // 새 파일 선택 시 진행률 초기화
});
}
}
Future<void> _uploadFile() async {
if (_selectedFile == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('먼저 파일을 선택해주세요.')),
);
return;
}
setState(() {
_isUploading = true;
});
// !!! 중요: 실제 앱에서는 이 URL을 백엔드 API를 통해 받아와야 합니다.
const String FAKE_PRESIGNED_URL = 'https://your-s3-bucket.s3.amazonaws.com/your-object-key?AWSAccessKeyId=...&Expires=...&Signature=...';
try {
await _uploadService.uploadToS3(
presignedUrl: FAKE_PRESIGNED_URL,
file: _selectedFile!,
onProgress: (sent, total) {
setState(() {
_uploadProgress = sent / total;
});
print('Progress: ${(_uploadProgress * 100).toStringAsFixed(2)}%');
},
);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('업로드 성공!')),
);
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('업로드 실패: $e')),
);
} finally {
setState(() {
_isUploading = false;
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('S3 파일 업로드 테스트')),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
if (_selectedFile != null)
Image.file(_selectedFile!, height: 200),
const SizedBox(height: 20),
ElevatedButton(
onPressed: _pickImage,
child: const Text('이미지 선택'),
),
const SizedBox(height: 20),
if (_isUploading)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 40.0),
child: LinearProgressIndicator(
value: _uploadProgress,
minHeight: 20,
),
),
const SizedBox(height: 20),
ElevatedButton(
onPressed: _isUploading ? null : _uploadFile,
child: const Text('S3로 업로드'),
),
],
),
),
);
}
}
5. 추가 고려사항 및 문제 해결
성공적으로 업로드 코드를 작성했더라도 실제 운영 환경에서는 다른 문제들을 마주칠 수 있습니다. 다음은 흔히 발생하는 문제와 그 해결책입니다.
A. CORS (Cross-Origin Resource Sharing) 오류
Flutter 웹이나 모바일 앱에서 S3로 직접 요청을 보내면 브라우저나 OS의 보안 정책에 의해 CORS 오류가 발생할 수 있습니다. S3 버킷 설정에서 CORS 정책을 올바르게 구성해야 합니다.
S3 버킷의 '권한' > 'CORS(Cross-origin 리소스 공유)' 편집으로 이동하여 다음과 유사한 JSON 정책을 추가해야 합니다.
[
{
"AllowedHeaders": [
"*"
],
"AllowedMethods": [
"PUT",
"POST",
"GET",
"HEAD"
],
"AllowedOrigins": [
"*" // 실제 프로덕션에서는 "https://your-app-domain.com" 과 같이 특정 도메인을 지정해야 합니다.
],
"ExposeHeaders": []
}
]
AllowedMethods
에 `PUT`이 반드시 포함되어야 하며, `AllowedOrigins`에는 여러분의 앱이 서비스되는 도메인이나, 모바일 앱의 경우 와일드카드(`*`)를 (개발 중에는) 사용할 수 있습니다. `AllowedHeaders`는 앱에서 보내는 모든 헤더(예: `Content-Type`, `Content-Length`)를 허용하도록 `*`로 설정하는 것이 편리합니다.
B. 403 Forbidden 오류
이 오류는 권한 문제일 가능성이 가장 높습니다.
- Presigned URL 만료: 생성된 URL은 유효 시간이 매우 짧을 수 있습니다(보통 5분~15분). 업로드를 시도하기 직전에 URL을 생성하여 사용하고 있는지 확인하세요.
- 헤더 불일치: 백엔드에서 Presigned URL을 생성할 때 특정 `Content-Type`이나 `Content-Length`를 조건으로 걸 수 있습니다. 예를 들어, `Content-Type: image/jpeg`만 허용하도록 URL을 생성했다면, Flutter 앱에서도 정확히 동일한 헤더를 보내야 합니다. 하나라도 다르면 S3는 403 오류를 반환합니다.
- 잘못된 HTTP 메소드: 업로드용 URL에는 `PUT`을, 다운로드용 URL에는 `GET`을 사용해야 합니다. 메소드가 일치하지 않으면 403 오류가 발생합니다.
C. 백엔드에서의 Presigned URL 생성
이 글에서 다루지는 않았지만, 백엔드에서 Presigned URL을 올바르게 생성하는 것도 매우 중요합니다. 다음은 Node.js (AWS SDK v3)와 Python (Boto3)의 간단한 예시입니다.
Node.js (aws-sdk/s3-request-presigner)
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
const s3Client = new S3Client({ region: "ap-northeast-2" });
async function createPresignedUrl(bucket, key, contentType) {
const command = new PutObjectCommand({
Bucket: bucket,
Key: key,
ContentType: contentType, // 클라이언트가 보내야 할 Content-Type을 명시
});
// URL은 15분 동안 유효
return getSignedUrl(s3Client, command, { expiresIn: 900 });
}
Python (boto3)
import boto3
from botocore.exceptions import ClientError
def create_presigned_url(bucket_name, object_name, content_type, expiration=900):
s3_client = boto3.client('s3', region_name='ap-northeast-2')
try:
response = s3_client.generate_presigned_url(
'put_object',
Params={
'Bucket': bucket_name,
'Key': object_name,
'ContentType': content_type # 클라이언트가 보내야 할 Content-Type을 명시
},
ExpiresIn=expiration
)
except ClientError as e:
print(e)
return None
return response
백엔드와 클라이언트(Flutter) 양측에서 `Content-Type`과 같은 핵심 파라미터를 일관되게 처리하는 것이 프로젝트의 안정성을 보장하는 길입니다.
결론
Flutter에서 Dio를 사용하여 S3 Presigned URL로 파일을 업로드할 때 발생하는 파일 손상 문제는 대부분 `Content-Type` 헤더를 잘못된 방식으로 설정했기 때문에 발생합니다. 이 문제를 해결하는 열쇠는 Dio의 `Options` 객체 내 `headers` 맵이 아닌, 별도로 마련된 `contentType` 속성을 사용하는 것입니다.
우리는 이 글을 통해 다음과 같은 내용을 학습했습니다.
- 잘못된 접근 방식과 그로 인해 발생하는 파일 손상 현상.
- 문제의 근본 원인이 `Content-Type` 헤더 처리 방식에 있음을 이해.
- `Options(contentType: ...)`를 사용하여 문제를 해결하는 명확하고 올바른 방법.
- `mime` 패키지를 활용한 동적 `Content-Type` 설정, `onSendProgress`를 이용한 업로드 진행률 표시, 대용량 파일을 위한 스트림 사용 등 실전적인 코드 작성법.
- S3의 CORS 설정, 403 오류 디버깅 등 운영 시 마주칠 수 있는 추가적인 문제들과 해결책.
이제 여러분은 Flutter 앱에서 S3로 파일을 안정적이고 효율적으로 업로드하는 데 필요한 모든 지식과 코드를 갖추게 되었습니다. 더 이상 원인 모를 파일 손상 문제로 시간을 낭비하지 마시고, 이 가이드를 통해 견고한 파일 업로드 기능을 구현하시길 바랍니다.
0 개의 댓글:
Post a Comment