현재 토이프로젝트로 채팅앱 프론트를 담당 중인데, 여기서 프로필 사진 업로드를 S3를 사용하기로 했습니다.
최근 Next.js의 server action을 이용해 동영상 업로드 CRUD를 구현했었고, 이와 비슷한 결이지만 lambda를 한번 사용해보고 싶어서 백엔드 친구에게 프로필 관련 CRUD를 제가 하겠다고 부탁했습니다.
이번 구현을 통해 AWS를 이용한 Serverless 구현을 맛볼 수 있었고 앞으로 다른 프로젝트에서도 다양하게 활용할 수 있을 것 같아 의미있는 시간이었습니다.

S3가 그림에서 빠졌지만 최종적으로 구현해보고 싶은 그림입니다!
각각에 대한 설명은 밑에 구현했던 흔적을 작성하며 풀어보겠습니다.
이 포스트에서는 S3 Presigned URL을 이용한 이미지 업로드를 진행하려합니다.

이번 포스트에서는 lambda와 API Gate를 이용해 S3에 이미지를 업로드하는 것까지 구현하기 때문에 따로 DB를 그림에 넣지 않았습니다. DB관련 구현은 다음 포스팅에서 이어서 할게요!
한글로 다시 설명하자면 다음과 같습니다.
들어가기 앞서 계속해서 언급되는 Presigned URL 요놈은 무엇일까요?
Presigned URL은 AWS S3 버킷에 있는 객체에 대한 임시 접근 권한을 부여하는 URL입니다. 이를 통해 클라이언트가 S3 버킷에 파일을 업로드하거나 다운로드할 수 있도록 허용할 수 있습니다. Presigned URL은 지정된 유효 기간이 지나면 만료됩니다.
Presigned URL을 사용하면 좋은 점은 다음과 같습니다.
IAM 사용자를 , lambda에서는 IAM 역할에서 특정 권한만 부여하여 제한합니다)아무튼 쓸만한 장점이 많은 녀석입니다.
AWS Lambda는 서버를 관리하지 않고 코드를 실행할 수 있는 서버리스 컴퓨팅 서비스입니다. 특정 이벤트에 반응하여 코드를 실행하며, 필요한 만큼만 비용을 지불하면 되기 때문에 비용 효율적입니다.
- 서버리스 아키텍쳐: 서버를 관리할 필요없이 코드 실행 가능. 인프라 부담 x
- 자동확장 기능: 트래픽 증가에 따라 필요한 만큼만 실행됨
- 다양한 트리거 : S3, DynamoDB, API Gateway
- API 관리: API의 생성, 배포 모니터링, 유지 관리를 손쉽게 할 수 있다.
- 보안: 인증 및 권한 부여 (AWS IAM, Amazon Cognito 등 사용)
API Gateway와 통합해 서버리스 애플리케이션을 구축하면 사용자가 HTTP 요청을 보내면 API Gateway가 이를 받아 Lambda 함수 전달하고, Lambda 함수가 필요한 작업을 수행한 후 응답을 반환하는 구조.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "Statement1",
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::user-profile-image-kimjang2023/*"
}
]
}
- S3에 파일 업로드를 위한 IAM이나 권한설정을 하기 귀찮다면 여기에 PutObject 권한까지 넣어두면 됩니다.
AllowedOrigins를 설정해서 이 도메인들만 리소스에 접근가능하도록 합니다![
{
"AllowedHeaders": [
"*"
],
"AllowedMethods": [
"PUT",
"DELETE",
"POST",
],
"AllowedOrigins": [
"http://localhost:3000",
"http://localhost:5173",
"http://localhost:5174",
"http://localhost:8080",
"https://-배포해놓은 CDN주소"
],
"ExposeHeaders": []
}
]
AWS 콘솔에 lambda함수 > 함수 생성
다음과 같이 설정 후, 함수 생성
- 아키텍쳐는 arm64가 더 저렴하다고 하여 선택하였고, 역할은 lambda 기본 권한 생성 후 s3접근을 위한 역할 정책설정을 따로 해줄 예정입니다.


해당 함수에서 코드> index.mjs를 다음과 같이 작성
코드는 이 공식문서를 많이 참고하였습니다.
https://docs.aws.amazon.com/ko_kr/AmazonS3/latest/userguide/example_s3_Scenario_PresignedUrl_section.html
userId를 pathParameter로 받아서 유저의 프로필사진파일을 구분해줄거라 다음과 같이 작성해주었습니다. import {S3Client,PutObjectCommand} from '@aws-sdk/client-s3';
import {getSignedUrl} from "@aws-sdk/s3-request-presigner";
const s3= new S3Client({
region:'ap-northeast-2' //본인 S3의 Region 설정
})
export async function handler(event){
console.log("event",event);
// const userId= event.pathParameters.userId; 이런식으로 pathParam을 받을 수도 있다.
const command=new PutObjectCommand({
Bucket:"bucketName",
Key:'example/', //파일이 들어갈 S3내 경로
ContentType:"image/*",
});
try{
const signedUrl= await getSignedUrl(s3,command,{
expiresIn:60, //URL 유효기간 설정
})
return{
statusCode:200,
body:JSON.stringify({url:signedUrl}),
};
}catch(error){
console.error("error putting Profile image",error)
return{
statusCode:500,
body: JSON.stringify({message:"failed to generate presigned URL"})
}
}
}
labmbda 함수 내에서 test를 돌려주면 200을 받아볼 수 있다.


get delete post 요청을 해당 url로 요청하면 연결된 lambda에서 method에 따라 알맞는 로직을 처리하도록 만들어줄 거에요.


routines/images를 뒤에 붙여서 주소창에 쳐봅니다.

구현할 로직
1. 리액트에 presignedURL을 요청하는 코드 추가
2. presignedURL로 S3에 파일 직접 업로드
3. post로 해당 S3 파일 url 넘겨주기
4. dynamoDB에 해당 URL저장해주기
아까 API gateway에 api의 method를 any로 해줬기 때문에 lambda http method에 따라 로직을 구분해주어야 합니다.
이를 위해 테스트를 해볼게요
console.log("event",event")를 찍어본걸 참고해서 http method를 어떻게 확인하는지 까봅시다.
event {
version: '2.0',
routeKey: '$default',
rawPath: '/path/to/resource',
rawQueryString: 'parameter1=value1¶meter1=value2¶meter2=value',
cookies: [ 'cookie1', 'cookie2' ],
headers: { Header1: 'value1', Header2: 'value1,value2' },
queryStringParameters: { parameter1: 'value1,value2', parameter2: 'value' },
requestContext: {
accountId: '123456789012',
apiId: 'api-id',
authentication: { clientCert: [Object] },
authorizer: { jwt: [Object] },
domainName: 'id.execute-api.us-east-1.amazonaws.com',
domainPrefix: 'id',
http: {
method: 'POST',
path: '/path/to/resource',
protocol: 'HTTP/1.1',
sourceIp: '192.168.0.1/32',
userAgent: 'agent'
}, # 이 부분을 참고!
requestId: 'id',
routeKey: '$default',
stage: '$default',
time: '12/Mar/2020:19:03:58 +0000',
timeEpoch: 1583348638390
},
body: 'eyJ0ZXN0IjoiYm9keSJ9',
pathParameters: { parameter1: 'value1' },
isBase64Encoded: true,
stageVariables: { stageVariable1: 'value1', stageVariable2: 'value2' }
}
위를 참고해서 http method에 따라 분기문으로 콘솔을 찍는 테스트 코드를 추가해봤어요.
export async function handler(event){
const operator= event.requestContext.http.method
console.log("event!!!!!!",operator);
if(operator=="POST")
return {"message": "this is the post request"}
if(operator=="GET")
return {"message": "this is the get request"}
...
포스트맨 같은 API 플랫폼을 이용해 POST와 GET 메서드로 각각 테스트해보니 응답이 원하는대로 오네요!


이를 토대로 get요청이 들어오면 presignedURL을 발급해주는 함수를,
delete요청이 들어오면 body에 담긴 파일 url로 s3내에 파일을 삭제하는 함수를 연결해줄게요.
import { S3Client, PutObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
const s3 = new S3Client({
region: 'ap-northeast-2', // S3의 Region 설정
});
const bucketName= '버킷이름'
export async function handler(event) {
const operator = event.requestContext.http.method;
if (operator === "DELETE") {
const imageUrl = JSON.parse(event.body).url;
const key = extractKeyFromUrl(imageUrl);
return await deleteImageFile(key);
}
if (operator === "GET") {
return await createPresignedURL();
}
return {
statusCode: 405,
body: JSON.stringify({ message: "Method Not Allowed" }),
};
}
// Presigned URL 생성 함수
async function createPresignedURL() {
try {
const today = new Date(new Date().getTime() + 9 * 60 * 60 * 1000)
const strDate = today.toISOString()
//날짜를 파일 이름으로하여 파일이 덮어씌워지지 않도록 함.
const command = new PutObjectCommand({
Bucket: bucketName,
Key: `routines-photos/${strDate}`, // 업로드할 파일 키 지정
ContentType: "image/*",
});
const signedUrl = await getSignedUrl(s3, command, {
expiresIn: 60, // URL 유효기간 설정
});
return {
statusCode: 200,
body: JSON.stringify({ url: signedUrl }),
};
} catch (error) {
console.error("Error generating presigned URL", error);
return {
statusCode: 500,
body: JSON.stringify({ message: "Failed to generate presigned URL" })
};
}
}
// S3 파일 삭제 함수
async function deleteImageFile(key) {
try {
const command = new DeleteObjectCommand({
Bucket: bucketName,
Key: key,
});
await s3.send(command);
return {
statusCode: 200,
body: JSON.stringify({ message: "File deleted successfully" }),
};
} catch (error) {
console.error("Error deleting file", error);
return {
statusCode: 500,
body: JSON.stringify({ message: "Failed to delete file" })
};
}
}
// URL에서 S3 Key 추출 함수
function extractKeyFromUrl(url) {
try {
const urlObject = new URL(url);
return urlObject.pathname.substring(1); // 버킷 이름 이후의 경로 반환
} catch (error) {
console.error("Error extracting key from URL", error);
return null;
}
}
자 그럼 리액트 코드를 만져보겠습니다.
1. 파일업로드에 필요한 기능을 훅으로 만들어주고
2. 업로드에 쓰일 컴포넌트에 훅을 연결해줍니다.

요렇게 생긴 컴포넌트에 업로드 기능을 불어넣어줄게요.
//useFileUpload 훅
import { useState } from 'react'
import axios from 'axios'
export default function useFileUpload() {
const [file, setFile] = useState<File | null>(null)
const [preview, setPreview] = useState<string | null>(null)
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
setFile(file)
setPreview(URL.createObjectURL(file))
}
}
const handleRemove = () => {
setFile(null)
setPreview(null)
}
const handleUpload = async () => {
try {
const response = await axios.get(
'https://example.ap-northeast-2.amazonaws.com/dev/routines/images',
)
const preSignedUrl = response.data.url
await axios.put(preSignedUrl, file, {
headers: {
'Content-Type': file?.type,
},
}) //s3로 파일 업로드
} catch (error) {
console.error('Error uploading file:', error)
}
}
return { file, preview, handleChange, handleRemove, handleUpload }
}
//파일업로드 drawer
import useFileUpload from '@/hooks/useFileUpload'
import CameraIcon from '@assets/icons/camera.svg?react'
import Drawer from '@components/Drawer'
import { Button } from '@nextui-org/react'
import useModalStore from '@stores/modal.store'
import { useRef } from 'react'
interface UploadDrawerProps {
onSubmit?: () => void
}
function UploadDrawer({ onSubmit }: UploadDrawerProps) {
const closeModal = useModalStore((state) => state.closeModal)
const { preview, handleChange, handleUpload } = useFileUpload()
const fileRef = useRef<HTMLInputElement>(null)
const handleSubmit = () => {
onSubmit?.()
handleUpload() //업로드 훅
closeModal()
}
return (
<Drawer title="사진 첨부">
<div
onClick={() => {
fileRef.current?.click()
}}
className="flex flex-col h-full gap-5 cursor-pointer"
>
<input ref={fileRef} type="file" accept="image/*" onChange={handleChange} hidden />
{preview ? (
<div className="flex items-center justify-center bg-white h-60 rounded-xl">
<img src={preview} alt="첨부 사진" className="object-contain w-full h-full" />
</div>
) : (
<div className="flex flex-col items-center justify-center gap-1 py-20 bg-white h-60 rounded-xl text-zinc-400 text-body2 ">
<CameraIcon />
<div className="flex flex-col items-center">
<p>사진 추가하기</p>
<p>(인증을 위해 사진을 첨부해주세요.)</p>
</div>
</div>
)}
<Button color="primary" radius="sm" className="h-fit py-3.5" onClick={handleSubmit}>
<p className="text-white text-subhead1">제출하기</p>
</Button>
</div>
</Drawer>
)
}
export default UploadDrawer
presignedURl 요청을 날렸을 때 CORS 에러가 발생하네요!

API GateWay에 다시 돌아가서 CORS구성을 손봐주어요.



정상적으로 요청이 되고, 파일도 s3버킷에 잘 저장되었어요.
저의 경우는 이미 백엔드 친구가 dynamoDB를 만들어준 상황이라 dynamoDB가 있다는 가정하에 진행할게요!
dynamoDB를 aws-sdk로 불러오고 update까지 하는 로직을 추가한 최종 lambda함수 코드
import { S3Client, PutObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, UpdateCommand } from "@aws-sdk/lib-dynamodb";
const s3 = new S3Client({
region: 'ap-northeast-2', // S3의 Region 설정
});
const dynamodb = new DynamoDBClient({});
const dynamo = DynamoDBDocumentClient.from(dynamodb);
const tableName = "dynamodb-table-name";
const bucketName = 'bucketname';
export async function handler(event) {
const operator = event.requestContext.http.method;
if (operator === "DELETE") {
const imageUrl = JSON.parse(event.body).url;
const key = extractKeyFromUrl(imageUrl);
return await deleteImageFile(key);
}
if (operator === "GET") {
return await createPresignedURL();
}
if (operator === "POST") {
const response = JSON.parse(event.body);
return await updateDynamoDB(response);
}
return {
statusCode: 403,
body: JSON.stringify({ message: "Method Not Allowed" }),
};
}
// Presigned URL 생성 함수
async function createPresignedURL() {
try {
const today = new Date(new Date().getTime() + 9 * 60 * 60 * 1000);
const strDate = today.toISOString();
const command = new PutObjectCommand({
Bucket: bucketName,
Key: `routines-photos/${strDate}`, // 업로드할 파일 키 지정
ContentType: "image/*",
});
const signedUrl = await getSignedUrl(s3, command, {
expiresIn: 60, // URL 유효기간 설정
});
return {
statusCode: 200,
body: JSON.stringify({ url: signedUrl }),
};
} catch (error) {
console.error("Error generating presigned URL", error);
return {
statusCode: 500,
body: JSON.stringify({ message: "Failed to generate presigned URL" })
};
}
}
// S3 파일 삭제 함수
async function deleteImageFile(key) {
try {
const command = new DeleteObjectCommand({
Bucket: bucketName,
Key: key,
});
await s3.send(command);
return {
statusCode: 200,
body: JSON.stringify({ message: "File deleted successfully" }),
};
} catch (error) {
console.error("Error deleting file", error);
return {
statusCode: 500,
body: JSON.stringify({ message: "Failed to delete file" })
};
}
}
// DynamoDB 업데이트 함수 (POST 요청 처리)
async function updateDynamoDB(response) {
const fileUrl = response.url; // 전달받은 URL
try {
await dynamo.send(
new UpdateCommand({
TableName: tableName,
Key: {
pk: `STORE#${response.storeId}`,
sk: `USER#${response.userId}#POSITION#${response.positionId}#USERROUTINE#${response.routineId}`,
},
UpdateExpression: "SET url = :fileUrl",
ExpressionAttributeValues: {
":url": fileUrl
},
ReturnValues: "ALL_NEW" // 업데이트된 항목을 반환
})
);
return {
statusCode: 200,
body: JSON.stringify({ message: "URL updated successfully " })
};
} catch (error) {
console.error("Error updating or creating item in DynamoDB", error);
return {
statusCode: 500,
body: JSON.stringify({ message: "Failed to update or create item in DynamoDB" })
};
}
}
// URL에서 S3 Key 추출 함수
function extractKeyFromUrl(url) {
try {
const urlObject = new URL(url);
const decodedKey = decodeURIComponent(urlObject.pathname.substring(1)); //디코딩
return decodedKey;
} catch (error) {
console.error("Error extracting key from URL", error);
return null;
}
}
이후 dynamo접근권한을 lambda 역할에 권한정책을 추가해주었고,
API Gateway에서 CORS정책구성 중 DELETE, POST, OPTIONS를 추가해주었어요.
ANY로 설정해두었던걸 POST, GET으로 나누어서 설정해주었어요.