대부분의 개발자들은 서버 로컬 스토리지에 파일을 저장하다가, 트래픽이 늘어나거나 용량 문제가 생기면 그때서야 AWS S3 같은 클라우드 스토리지를 고민하기 시작한다. 다만 AWS SDK 설치부터 시작해서 버킷 설정, IAM 인증, API 호출까지 단계가 많아서 어디서부터 시작해야 할지 모르는 경우가 대부분이다. 이번에는 AWS S3의 정확한 개념부터 PHP SDK 설치, 파일 업로드/다운로드 구현까지 실무에서 바로 써먹을 수 있는 완벽한 가이드를 정리해서 소개하겠다.
AWS S3(Simple Storage Service)는 클라우드 파일 저장소다. 로컬 서버 디스크처럼 파일을 저장하지만, 확장성과 안정성이 뛰어나고 여러 서버에서 동시에 접근할 수 있다는 장점이 있다. S3에 파일을 저장하려면 먼저 '버킷'을 만들어야 하는데, 이걸 생각하기 쉽게 말하면 클라우드 드라이브라고 보면 된다.
IAM(Identity and Access Management)은 AWS의 권한 관리 서비스다. 데이터베이스에 접근할 때 사용자명과 비밀번호가 필요하듯이, S3에 접근하려면 AWS Access Key ID와 Secret Access Key 같은 인증 정보가 필요하다. 이 정보는 절대 외부에 노출되면 안 되므로 환경 변수나 별도 설정 파일로 관리해야 한다.
먼저 AWS 콘솔(console.aws.amazon.com)에 로그인한 후 S3 서비스를 검색해서 들어간다. '버킷 만들기' 버튼을 클릭하고 고유한 버킷 이름을 입력한다. 버킷 이름은 전 세계 AWS 계정 중에서 중복되지 않아야 하므로, 보통 프로젝트명-날짜 형식으로 만들면 좋다.
버킷을 생성한 후에는 IAM 콘솔로 가서 사용자를 추가해야 한다. '사용자 추가' → 이름 입력 → '프로그래밍 방식 액세스' 체크 → 다음 진행. 정책 추가 단계에서 'AmazonS3FullAccess' 또는 커스텀 정책을 선택해서 생성한 S3 버킷에만 접근할 수 있도록 제한하는 것이 보안상 좋다. 사용자 생성이 완료되면 Access Key ID와 Secret Access Key를 받게 되는데, 이 정보는 절대 잃어버리면 안 된다.
PHP에서 AWS S3를 사용하려면 공식 SDK를 설치해야 한다. Composer를 사용하고 있다면 터미널에서 아래 명령을 실행한다.
composer require aws/aws-sdk-php
설치가 완료되면 프로젝트 폴더에 vendor 디렉토리가 생성되고, autoload.php가 자동으로 생성된다. 이제 PHP 코드에서 AWS SDK를 사용할 수 있다.
파일 업로드나 다운로드를 하기 전에, 먼저 AWS S3와 연결해야 한다. 보안을 위해 Access Key와 Secret Key는 환경 변수에 저장해서 사용한다.
✗ 잘못된 코드 (하드코딩된 인증 정보)
<?php
require 'vendor/autoload.php';
use AwsS3S3Client;
$s3Client = new S3Client([
'version' => 'latest',
'region' => 'ap-northeast-2',
'credentials' => [
'key' => 'AKIAIOSFODNN7EXAMPLE', // 노출되면 안 됨!
'secret' => 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY'
]
]);
?>
이렇게 하면 코드를 깃허브나 다른 곳에 올렸을 때 인증 정보가 노출된다. 악의적인 사용자가 이 정보를 이용해서 S3 버킷에 접근하거나 파일을 삭제할 수 있다.
✓ 올바른 코드 (환경 변수 사용)
<?php
require 'vendor/autoload.php';
use AwsS3S3Client;
$s3Client = new S3Client([
'version' => 'latest',
'region' => 'ap-northeast-2',
'credentials' => [
'key' => getenv('AWS_ACCESS_KEY_ID'),
'secret' => getenv('AWS_SECRET_ACCESS_KEY')
]
]);
?>
환경 변수는 서버의 .env 파일이나 웹호스팅 제어판에서 설정할 수 있다. 로컬 개발 환경에서는 .env 파일을 만들고 아래처럼 작성한다.
AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE
AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
PHP에서 .env 파일을 읽으려면 vlucas/phpdotenv 라이브러리를 사용하는 것이 좋다.
composer require vlucas/phpdotenv
<?php
require 'vendor/autoload.php';
$dotenv = DotenvDotenv::createImmutable(__DIR__);
$dotenv->load();
use AwsS3S3Client;
$s3Client = new S3Client([
'version' => 'latest',
'region' => 'ap-northeast-2',
'credentials' => [
'key' => getenv('AWS_ACCESS_KEY_ID'),
'secret' => getenv('AWS_SECRET_ACCESS_KEY')
]
]);
?>
이제 실제로 파일을 S3에 업로드하는 코드를 작성한다. putObject 메서드를 사용하면 로컬 파일이나 스트림을 S3로 전송할 수 있다.
✗ 잘못된 코드 (예외 처리 없음)
<?php
$filePath = $_FILES['file']['tmp_name'];
$fileName = $_FILES['file']['name'];
$result = $s3Client->putObject([
'Bucket' => 'my-bucket-name',
'Key' => $fileName,
'Body' => fopen($filePath, 'r')
]);
echo "파일이 업로드되었습니다.";
?>
이 코드의 문제점은 세 가지다: 첫째, 파일 유효성 검증이 없다. 두 번째, 네트워크 오류 같은 예외 상황을 처리하지 않는다. 셋째, 같은 이름의 파일이 있으면 덮어씌운다.
✓ 올바른 코드 (검증 및 예외 처리 포함)
<?php
require 'vendor/autoload.php';
require '.env.php';
use AwsS3S3Client;
use AwsExceptionAwsException;
// S3 클라이언트 초기화
$s3Client = new S3Client([
'version' => 'latest',
'region' => 'ap-northeast-2',
'credentials' => [
'key' => AWS_ACCESS_KEY_ID,
'secret' => AWS_SECRET_ACCESS_KEY
]
]);
function uploadFileToS3($s3Client, $file, $bucketName) {
// 파일 유효성 검증
if (!isset($file['tmp_name']) || !is_uploaded_file($file['tmp_name'])) {
return [
'success' => false,
'message' => '올바른 파일이 아닙니다.'
];
}
// 파일 크기 제한 (10MB)
$maxSize = 10 * 1024 * 1024;
if ($file['size'] > $maxSize) {
return [
'success' => false,
'message' => '파일 크기가 10MB를 초과합니다.'
];
}
// 허용된 파일 타입만 업로드 가능
$allowedTypes = ['image/jpeg', 'image/png', 'application/pdf'];
if (!in_array($file['type'], $allowedTypes)) {
return [
'success' => false,
'message' => '허용되지 않는 파일 형식입니다.'
];
}
// 고유한 파일명 생성 (같은 파일명 충돌 방지)
$fileExt = pathinfo($file['name'], PATHINFO_EXTENSION);
$uniqueFileName = uniqid() . '_' . time() . '.' . $fileExt;
try {
$result = $s3Client->putObject([
'Bucket' => $bucketName,
'Key' => 'uploads/' . $uniqueFileName,
'Body' => fopen($file['tmp_name'], 'r'),
'ACL' => 'private' // 공개 접근 차단
]);
return [
'success' => true,
'message' => '파일이 성공적으로 업로드되었습니다.',
'fileName' => $uniqueFileName,
'url' => $result['ObjectURL']
];
} catch (AwsException $e) {
return [
'success' => false,
'message' => 'S3 업로드 실패: ' . $e->getMessage()
];
}
}
// 사용 예제
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['file'])) {
$result = uploadFileToS3($s3Client, $_FILES['file'], 'my-bucket-name');
echo json_encode($result);
}
?>
이 코드에서 주목할 점들:
- is_uploaded_file(): 실제로 form을 통해 업로드된 파일인지 검증한다.
- 파일 크기 제한: 악의적으로 큰 파일을 올리는 공격(DoS)을 방지한다.
- MIME 타입 검증: 실제 파일 내용을 확인해서 확장자 위변조 공격을 방지한다.
- uniqid() + time(): 같은 이름의 파일이 있어도 덮어씌워지지 않도록 고유한 이름을 생성한다.
- ACL 'private': 기본적으로 파일을 비공개로 설정해서 URL 직접 접근을 막는다.
S3에 저장된 파일을 다운로드하거나 브라우저에서 볼 수 있도록 하려면 getObject 또는 getSignedUrl을 사용한다. 공개 파일이면 직접 URL로 접근하면 되지만, 비공개 파일은 시간 제한이 있는 서명된 URL을 생성해야 한다.
✗ 잘못된 코드 (항상 공개 접근)
<?php
// 버킷 권한이 공개로 설정되어 있으면 누구나 접근 가능
$fileUrl = 'https://my-bucket-name.s3.amazonaws.com/uploads/file.jpg';
echo "<img src="" . $fileUrl . "" />";
?>
이 방식은 누구나 S3의 모든 파일 URL을 알면 접근할 수 있다. 사용자별 접근 권한을 제어할 수 없다.
✓ 올바른 코드 (서명된 임시 URL 생성)
<?php
function getSignedUrlForFile($s3Client, $bucketName, $fileName, $expiresIn = '20 minutes') {
try {
$cmd = $s3Client->getCommand('GetObject', [
'Bucket' => $bucketName,
'Key' => 'uploads/' . $fileName
]);
$request = $s3Client->createPresignedRequest($cmd, $expiresIn);
$signedUrl = (string)$request->getUri();
return [
'success' => true,
'url' => $signedUrl
];
} catch (AwsException $e) {
return [
'success' => false,
'message' => '서명된 URL 생성 실패: ' . $e->getMessage()
];
}
}
// 사용 예제: 파일명을 받아서 임시 다운로드 URL 제공
if (isset($_GET['file'])) {
$fileName = basename($_GET['file']); // 디렉토리 트래버설 공격 방지
$result = getSignedUrlForFile($s3Client, 'my-bucket-name', $fileName, '+20 minutes');
if ($result['success']) {
// 클라이언트에게 URL 제공 (또는 리다이렉트)
header('Location: ' . $result['url']);
} else {
http_response_code(404);
echo '파일을 찾을 수 없습니다.';
}
}
?>
서명된 URL의 장점:
- 시간 제한: 설정한 시간(예: 20분) 후 자동으로 URL이 만료된다.
- 접근 제어: 서명된 URL을 받은 사람만 파일을 다운로드할 수 있다.
- 로그 기록: 누가 언제 어떤 파일에 접근했는지 추적 가능하다.
| 문제 | 원인 | 해결 방법 |
|---|---|---|
| Access Key 노출 | 코드에 하드코딩된 인증 정보 | 환경 변수나 .env 파일 사용, .gitignore에 포함 |
| 403 Forbidden 에러 | IAM 정책이 너무 제한적이거나 버킷 이름 오류 | IAM 정책 확인, 버킷 이름 정확히 입력 |
| 큰 파일 업로드 속도 저하 | 메모리에 전체 파일을 로드 | multipart upload 사용 (>100MB 파일) |
| 파일 덮어씌워짐 | 같은 Key 값으로 업로드 | uniqid() 또는 타임스탬프로 고유한 파일명 생성 |
| 모든 파일이 공개됨 | ACL을 'public-read'로 설정 | ACL을 'private'으로 설정, 필요한 경우만 서명된 URL 제공 |
사용자 프로필 이미지를 S3에 저장하고, 필요할 때만 임시 URL을 제공하는 완전한 예제다.
<?php
require 'vendor/autoload.php';
require '.env.php';
use AwsS3S3Client;
use AwsExceptionAwsException;
$s3Client = new S3Client([
'version' => 'latest',
'region' => 'ap-northeast-2',
'credentials' => [
'key' => AWS_ACCESS_KEY_ID,
'secret' => AWS_SECRET_ACCESS_KEY
]
]);
class ProfileImageManager {
private $s3Client;
private $bucketName = 'my-bucket-name';
private $maxFileSize = 5 * 1024 * 1024; // 5MB
private $allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];
public function __construct($s3Client) {
$this->s3Client = $s3Client;
}
public function uploadProfileImage($userId, $file) {
// 유효성 검증
if (!isset($file['tmp_name']) || !is_uploaded_file($file['tmp_name'])) {
return ['success' => false, 'message' => '올바른 파일이 아닙니다.'];
}
if ($file['size'] > $this->maxFileSize) {
return ['success' => false, 'message' => '파일이 5MB를 초과합니다.'];
}
if (!in_array($file['type'], $this->allowedTypes)) {
return ['success' => false, 'message' => '이미지 형식만 허용됩니다.'];
}
// 기존 프로필 이미지 삭제
$this->deleteProfileImage($userId);
// 새 이미지 업로드
$fileKey = "profiles/{$userId}/avatar.jpg";
try {
$this->s3Client->putObject([
'Bucket' => $this->bucketName,
'Key' => $fileKey,
'Body' => fopen($file['tmp_name'], 'r'),
'ContentType' => 'image/jpeg',
'ACL' => 'private',
'Metadata' => ['userId' => $userId, 'uploadTime' => time()]
]);
return ['success' => true, 'message' => '프로필 이미지가 업로드되었습니다.'];
} catch (AwsException $e) {
return ['success' => false, 'message' => 'S3 업로드 실패: ' . $e->getMessage()];
}
}
public function getProfileImageUrl($userId, $expiresIn = '+30 minutes') {
$fileKey = "profiles/{$userId}/avatar.jpg";
try {
// 파일이 존재하는지 확인
if (!$this->s3Client->doesObjectExist($this->bucketName, $fileKey)) {
return ['success' => false, 'message' => '프로필 이미지가 없습니다.'];
}
// 서명된 URL 생성
$cmd = $this->s3Client->getCommand('GetObject', [
'Bucket' => $this->bucketName,
'Key' => $fileKey
]);
$request = $this->s3Client->createPresignedRequest($cmd, $expiresIn);
$signedUrl = (string)$request->getUri();
return ['success' => true, 'url' => $signedUrl];
} catch (AwsException $e) {
return ['success' => false, 'message' => 'URL 생성 실패: ' . $e->getMessage()];
}
}
public function deleteProfileImage($userId) {
$fileKey = "profiles/{$userId}/avatar.jpg";
try {
$this->s3Client->deleteObject([
'Bucket' => $this->bucketName,
'Key' => $fileKey
]);
return ['success' => true];
} catch (AwsException $e) {
return ['success' => false, 'message' => '삭제 실패: ' . $e->getMessage()];
}
}
}
// 사용 예제
$manager = new ProfileImageManager($s3Client);
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['profileImage'])) {
$userId = $_POST['userId'] ?? 1;
$result = $manager->uploadProfileImage($userId, $_FILES['profileImage']);
echo json_encode($result);
}
if (isset($_GET['action']) && $_GET['action'] === 'getImage') {
$userId = $_GET['userId'] ?? 1;
$result = $manager->getProfileImageUrl($userId);
if ($result['success']) {
header('Content-Type: application/json');
echo json_encode(['url' => $result['url']]);
} else {
http_response_code(404);
echo json_encode($result);
}
}
?>
AWS S3를 사용하면 로컬 서버의 저장소 부담을 크게 줄일 수 있고, 여러 서버에서 동시에 접근해야 하는 상황에서도 안정적으로 파일을 관리할 수 있다. 초반에는 설정이 복잡해 보이지만, 인증 정보를 환경 변수로 관리하고 파일 검증을 철저히 하면 안전한 시스템을 만들 수 있다는 점을 잊지 말자. 특히 대규모 서비스로 성장할수록 S3의 가치가 더욱 돋보인다. 이 글의 ProfileImageManager 클래스를 참고해서 실제 프로젝트에 적용하면, 확장 가능하고 보안이 강화된 파일 업로드 시스템을 바로 구축할 수 있을 것이다.