이 글은 운영 중인 서비스에 파일 업로드 시 AWS S3 Pre-signed URL을 도입한 과정을 정리한 글입니다.
이전에 운영 중인 서비스에서, 파일 업로드 기능이 있었다. AWS Presigned URL을 도입하기 전까지는 다음과 같은 순서로 파일을 업로드 해왔다.
- 클라이언트에서 백엔드 서버에 파일 업로드를 위한 요청을 보낸다. (JSON 파일을 주고받는 형태)
- 백엔드 서버에서 AWS S3로 파일 업로드를 위한 요청을 보낸다.
- 요청이 성공했을 경우, 파일이 S3에 업로드 된다.
그런데, 문제는 유저가 10MB가 넘는 용량의 파일을 올릴 때가 있다. (주로 PPT가 이런 경우가 많았다.) 이럴 경우, 아직 정확한 원인을 파악하지는 못했지만 업로드가 종종 실패하는 경우가 있었다.
보다 상세한 문제 상황은 다음과 같다. Presigned URL 도입 이전의 파일 업로드 순서에서, 3번 부분에서 문제가 발생했었다. 요청 자체는 정상적으로 보내지는데, 당시 사용하는 API(Lambda 함수)에서 timeout 에러가 간헐적으로 발생했다.
그런데, 테스트 서버에서 직접 테스트를 했을 때는 timeout 에러가 발생하지 않았었다. 그래서 원인이 무엇인지 특정하기가 어려웠다.
파일 업로드 시 발생한 에러에서의 한가지 공통점은, 유저가 업로드한 파일이 10MB가 넘는다는 점이 있었다.
먼저 알아야 할 점은, 운영 중인 서비스는 백엔드 서버를 AWS에서 Lambda+API Gateway를 사용하여 구축했다.
Lambda에서 timeout 에러가 발생했으니, Lambda 관련 문서를 찾아봤지만 Lambda가 문제의 원인이라는 점을 확신하지 못했다. 그래서 API Gateway 관련 문서를 더 찾아보니, HTTP API의 페이로드 크기의 기본 할당량이 10MB인걸 확인할 수 있었다.
결국, 문제의 원인은 백엔드 서버에서 파일 업로드 API 요청 시 API Gateway의 기본 페이로드 크기 제한인 10MB를 초과했기 때문에 발생한 에러였다.
가장 먼저 생각한 해결방법은 API Gateway의 기본 페이로드 크기 제한을 올리는 것이었다. (ex: 10MB -> 50MB) 그런데, 이 방법을 사용했을 때의 내가 예상했던 문제점은 다음과 같다.
Q. 큰 용량의 파일을 업로드하기 위해서 API 요청에 시간이 더 걸릴텐데, 현재의 Lambda 시간 제한인 6초를 넘겨버린다면?
Q. 그럼 Lambda의 timeout 제한을 늘린다면?
Q. 그럼 동시 업로드 요청수를 제한한다면?
이런 여러가지 이유로 이 방법을 선택하지는 않았다.
다른 해결방법을 찾던 중, 대용량 파일 전송 시 S3 PreSigned URL이 적합하다는 글을 보게 되었다.
기존의 파일 업로드 과정을 한번 더 확인해보면 다음과 같다.
- 클라이언트에서 백엔드 서버에 파일 업로드를 위한 요청을 보낸다. (JSON 파일을 주고받는 형태)
- 백엔드 서버에서 AWS S3로 파일 업로드를 위한 요청을 보낸다.
- 요청이 성공했을 경우, 파일이 S3에 업로드 된다.
그런데, S3 Presigned URL을 사용하면 다음과 같은 과정으로 바뀌게 된다.
- 클라이언트에서 백엔드 서버로 Presigned URL 요청
- 백엔드 서버에서 S3에 Presigned URL 요청
- S3에서 백엔드 서버에 Presigned URL 응답
- 백엔드 서버에서 클라이언트로 Presigned URL 응답
- 백엔드 서버로부터 받은 Presigned URL로 클라이언트에서 S3로 직접 파일 업로드
기존 업로드 과정보다 뭔가 더 많고 복잡하다. 여기서 대용량 파일 업로드에 S3 Presigned URL이 적합한 이유는 5번, 클라이언트에서 S3로 직접 파일 업로드가 가능하다는 점 때문이다.
큰 용량의 파일을 업로드 하는건 서버에 걸리는 부하가 매우 큰 작업이다. 그런데, S3 PreSigned URL을 사용하면 그 부하를 운영 중인 서비스의 백엔드 서버가 아닌, AWS 서버가 부담할 수 있게 된다.
그런데, 여기까지 간단하게 Presigned URL을 알아보고 나서 든 의문이 있었다.
Q. 백엔드 서버에 부담을 줄이기 위해서라면, 굳이 S3 PreSigned URL을 사용하지 말고 그냥 바로 클라이언트에서 S3 버킷에 업로드를 하면 되는게 아닌가?
A. 그런데 이렇게 바로 클라이언트에서 직접 AWS S3에 요청을 하지 않는 이유는 바로 보안문제 때문이다. 즉, 아무나 업로드하고 삭제하면 안되기 때문이다.
파일을 업로드할 때, 백엔드 서버에서 S3 Bucket에 접근하기 위해 Access Key를 사용할 것이다. 이 절차 덕분에 S3에서 이 요청은 신뢰할 수 있는 요청으로 판단, 정상적인 응답을 할 수 있게 되는 것이다.
Presigned URL을 이용하면, 백엔드 서버는 Presigned URL 생성으로 보안 절차 작업만 하게 되고, 파일 업로드는 클라이언트가 하게 된다.
PreSigned URL은 말 그대로, 미리 서명한 URL이라는 뜻이다.
버킷 자체를 public으로 열기엔 보안 문제가 걸리고, 그렇다고 이용자에게 일일히 AWS IAM을 이용해 S3 버킷에 대한 접근 권한을 일일히 부여하기도 번거로울 때 사용할 수 있는게 바로 Presigned URL이다.
추가로, 이 Presigned URL에 만료 시간을 설정할 수도 있다. 만료 시간이 지난다면 해당 버킷에 대한 접근을 막을수도 있다.
예를 들어, 인프런 같은 온라인 교육 사이트에서 강사가 학생들에게 강의 자료를 제공하려 할 때를 생각해보자. 강사는 파일을 S3 버킷에 업로드하고 그 파일에 대한 Presigned URL을 생성한다. 이 URL을 학생들에게 공유하면, 학생들은 설정된 시간(ex: 24시간) 동안 해당 파일을 다운로드할 수 있다. 시간이 지나면 URL은 자동으로 만료되어 버킷에 더 이상 접근할 수 없게 되므로, 외부인의 무단 다운로드를 방지할 수 있다.
이처럼 Presigned URL은 보안 편의점, 유효기간 설정, 대용량 파일 업로드 용이 등 여러 장점이 있다.
먼저, 기존의 파일 업로드 코드는 다음과 같았다. 백엔드는 Javascript로 작성되었다.
// 백엔드 코드
// createFileUpload.js
const AWS = require('aws-sdk');
const parser = require('lambda-multipart-parser');
const { nanoid } = require('nanoid');
const { NO_EXIST_FILE, NOT_EXPECT_ERROR } = require('../../lib/constants');
const sequelize = require('../../lib/database');
const { createResponse, manageSequelizeConnection } = require('../../lib/utils');
const { uploadFileToS3 } = require('../../lib/upload');
const s3 = new AWS.S3();
let transaction;
exports.createFileUpload = async (event, context, cb) => {
try {
await manageSequelizeConnection(sequelize);
transaction = await sequelize.transaction();
const fileId = nanoid();
const result = await parser.parse(event);
const { files, uploadType } = result;
const file = files[0];
if (files.length === 0) {
return cb(null, createResponse(500, { message: NO_EXIST_FILE }));
}
const uploadedData = await uploadFileToS3(s3, file, {
bucketName: process.env.AWS_S3_BUCKET_NAME,
path: `${uploadType}/${fileId}-${file.filename}`,
});
await transaction.commit();
return cb(
null,
createResponse(200, {
message: uploadedData.Location,
filename: file.filename,
}),
);
} catch (error) {
if (transaction) await transaction.rollback();
cb(
null,
createResponse(500, {
message: {
content: NOT_EXPECT_ERROR,
error,
},
}),
);
throw error;
} finally {
await sequelize.connectionManager.close();
}
};
기존 파일 업로드 방식인 createFileUpload API이다. 전체 코드 대신, 핵심은 아래 코드이기 때문에 아래 코드를 확인해보자.
const fileId = nanoid();
const result = await parser.parse(event);
const { files, uploadType } = result;
const file = files[0];
if (files.length === 0) {
return cb(null, createResponse(500, { message: NO_EXIST_FILE }));
}
const uploadedData = await uploadFileToS3(s3, file, {
bucketName: process.env.AWS_S3_BUCKET_NAME,
path: `${uploadType}/${fileId}-${file.filename}`,
});
lambda-mulitpart-parser
라는 라이브러리를 통해 클라이언트로부터 받아온 파일 데이터를 파싱해서, uploadFileToS3
함수로 전달한다.
uploadType
, fileId
등의 변수를 사용한 이유는 Presigned URL을 적용하는 부분에서 함께 설명하겠다.
// upload.js
exports.uploadFileToS3 = async (s3, file, awsParams) => {
const { bucketName, path } = awsParams;
const uploadParams = {
Bucket: bucketName,
Key: path,
Body: file.content,
ContentType: file.contentType,
};
let data;
try {
data = await s3.upload(uploadParams).promise();
} catch (e) {
console.log('ERRORRRR!!!!!:: ', e);
}
return data;
};
uploadFileToS3
함수이다. 이 함수는 s3
객체와 file
객체, 그리고 업로드할 S3 버킷명과 해당 버킷에 저장될 경로가 들어있는 awsParams
객체를 인자로 받는다.
그리고, s3 객체의 upload
메서드에 전달할 인자인 uploadParams
객체에는 다음과 같은 속성들이 들어가게 된다.
Bucket
: 업로드할 파일이 저장될 버킷명Key
: 업로드할 파일이 저장될 경로Body
: 파일 내용ContentType
: 업로드할 파일의 MIME 유형. 예를 들어, 이미지 파일이라면 image/png
, image/jpeg
등이 들어갈 수 있다.새로 Presigned URL을 적용한 방식의 순서는 다음과 같다.
// 클라이언트 코드
export const createPresignedUrl = async (
file: File,
type: string,
isSizeLimit?: boolean,
) => {
try {
if (!isSizeLimit) return;
const response = await DefaultAPI.post(presignedUrlAPI, {
filename: file.name,
contentType: file.type,
uploadType: type,
});
const { preSignedUrl, key, bucketName } = response.data.message;
const s3FileUrl = `https://${bucketName}.s3.ap-northeast-2.amazonaws.com/${key}`;
// 아래 코드는 5번 단계에 해당하는 코드이기 때문에 나중에 확인하자.
const res = await uploadImageToS3(preSignedUrl, file);
return { res, s3FileUrl };
} catch (err) {
console.error('SECOND_ERROR:: ', err);
throw err;
}
};
먼저, 클라이언트에서 백엔드 서버로부터 Presigned URL을 받아와야 한다. 그래서 위 코드는 해당 API를 요청하는 코드다. 이 때, 백엔드에 보낼 정보로는 파일 정보를 보내야 한다. 파일명, 컨텐츠 타입, 업로드 타입 등을 함께 보낸다.
uploadType
: 업로드 타입은 업로드할 파일이 저장될 버킷 내부의 폴더명이라고 생각하면 된다. 예를 들어, 회원가입 시 제출할 파일에 대한 uploadType
은 signup
이라는 이름이 될 수 있고, 계획을 작성할 때 추가로 업로드할 파일을 모아놓는 폴더의 경우는 uploadType
이 plan
이라고 될 수 있다. (변수명이 잘못된 것 같긴 하다)이후, 서버로부터 정상적으로 응답을 받았다면 클라이언트에서는 preSignedUrl과 파일을 저장할 경로, 버킷 이름을 응답값으로 받게 된다.
// 백엔드 코드
// createPreSignedUrl.js
const AWS = require('aws-sdk');
const { nanoid } = require('nanoid');
const { createResponse, sendErrorToDiscord } = require('../../lib/utils');
exports.createPresignedUrl = (event, context, cb) => {
const s3 = new AWS.S3();
const request = JSON.parse(event.body);
const { filename, contentType, uploadType } = request;
const fileId = nanoid();
const encodedFileName = encodeURIComponent(filename);
const key = `${uploadType}/${fileId}-${filename}`;
const encodedKey = `${uploadType}/${fileId}-${encodedFileName}`;
const params = {
Bucket: process.env.AWS_S3_BUCKET_NAME,
Key: key,
Expires: 60,
ContentType: contentType,
};
s3.getSignedUrl('putObject', params, async (err, url) => {
if (err) {
if (process.env.NODE_ENV === 'production' && err) {
await sendErrorToDiscord('createPresignedUrl', err, process.env.ALLOW_ORIGIN);
}
return cb(null, createResponse(500, { message: 'pre-signed url을 받아오지 못했어요.' }));
}
return cb(
null,
createResponse(200, {
message: {
preSignedUrl: url,
key: encodedKey,
bucketName: params.Bucket,
},
}),
);
});
};
위 코드는 서버에서 S3로 Presigned URL을 요청하는 API이다. 각 코드를 분리해서 확인해보자.
// 고유한 파일명을 만들기 위한 변수
const fileId = nanoid();
// 1. 한글 파일명이 깨지지 않도록 인코딩해주는 코드
const encodedFileName = encodeURIComponent(filename);
// 2. S3 버킷 내에서 파일이 저장될 경로
const key = `${uploadType}/${fileId}-${filename}`;
// 3. 클라이언트로 전달할 파일 경로
const encodedKey = `${uploadType}/${fileId}-${encodedFileName}`;
fileId
: 고유한 파일명을 만들기 위한 변수. 만약 서로 다른 유저가 ‘자기소개서’라는 똑같은 파일명을 가진 서로 다른 파일을 업로드한다고 가정해보자. 그럼, 파일이 겹치게 되어 업로드한 자기 파일이 손실되는 버그가 발생할 것이다. 이런 위험을 방지하기 위한 변수다.encodedFileName:
파일 이름에 포함된 특수 문자나 한글 등의 문자가 깨지지 않도록 인코딩하는 코드. encodeURIComponent
함수는 공백, 한글, &, %, # 등과 같은 문자를 URL-safe 형식으로 변환한다. 예를 들어, 한글이나 공백이 포함된 파일 이름이 있을 때(ex: 자기소개서), 이를 인코딩하여 URL 파라미터로 전달할 수 있도록 한다.const params = {
Bucket: process.env.AWS_S3_BUCKET_NAME,
Key: key,
Expires: 60,
ContentType: contentType,
};
Bucket
: 업로드할 파일이 저장될 S3 버킷명이다. dev, production 환경에 따라 버킷명이 달라지기 때문에 환경변수로 받아온다.Key
: Presigned URL에 포함될 파일의 경로에 대한 속성Expires
: 60초의 만료시간을 가진다. 60초를 넘어가면 Presigned URL이 만료된다.ContentType
: 위에서 설명했으니 패스 s3.getSignedUrl('putObject', params, async (err, url) => {
// 에러 처리 부분은 무시하자
if (err) {
if (process.env.NODE_ENV === 'production' && err) {
await sendErrorToDiscord('createPresignedUrl', err, process.env.ALLOW_ORIGIN);
}
return cb(null, createResponse(500, { message: 'pre-signed url을 받아오지 못했어요.' }));
}
return cb(
null,
createResponse(200, {
message: {
preSignedUrl: url,
key: encodedKey,
bucketName: params.Bucket,
},
}),
);
});
getSignedUrl
메서드를 사용해 S3로부터 Presigned URL을 요청하는 부분이다. 첫번째 인자로는 putObject
문자열이 인자로 들어갔다. 다음은 첫번째 인자로 들어갈 수 있는 문자열에 대한 설명이다.
putObject
: S3 버킷에 ****파일을 업로드할 수 있는 URL을 생성getObject
: S3 버킷에서 파일을 다운로드할 수 있는 URL을 생성. 이 URL을 사용하면 유저가 파일을 다운로드할 수 있다.deleteObject
: S3 버킷에서 파일을 삭제할 수 있는 URL을 생성. 이 URL을 사용하면 유저가 지정된 파일을 삭제할 수 있다.listObjects
: 버킷이나 특정 폴더의 파일 목록을 조회할 수 있는 URL을 생성.위 코드에서 putObject
를 사용한 이유는 파일을 업로드하는 코드이기 때문이다.
두번째 인자로는 직전에 설명한 params 객체가 들어간다.
S3로부터 정상적으로 응답을 받았다면, Presigned URL을 반환받게 된다. 그럼, 마지막으로 이 URL과 인코딩된 파일 경로, 버킷의 이름을 클라이언트로 반환한다.
그럼, 다시 클라이언트 코드로 돌아가자. response
객체 내부에 변수에 백엔드로부터 받아온 Presigned URL이 담겨있게 된다.
// 클라이언트 코드
export const createPresignedUrl = async (
file: File,
type: string,
isSizeLimit?: boolean,
) => {
try {
if (!isSizeLimit) return;
// 1. Presigned URL을 서버로 요청
const response = await DefaultAPI.post(presignedUrlAPI, {
filename: file.name,
contentType: file.type,
uploadType: type,
});
// 2. 받아온 Presigned URL
const { preSignedUrl, key, bucketName } = response.data.message;
// 3. S3 버킷에 업로드할 파일 경로
const s3FileUrl = `https://${bucketName}.s3.ap-northeast-2.amazonaws.com/${key}`;
// 4. S3 버킷에 파일을 업로드하는 API 요청
const res = await uploadImageToS3(preSignedUrl, file);
return { res, s3FileUrl };
} catch (err) {
console.error('SECOND_ERROR:: ', err);
throw err;
}
};
그럼, 이제 받아온 Presigned URL을 이용해 S3에 직접 API 요청을 해야 한다. uploadImageToS3
함수는 다음과 같다.
export const uploadImageToS3 = async (url: string, file: File) => {
try {
const response = await axios.put(url, file);
return response;
} catch (error) {
throw error;
}
};
첫번째 인자로 Presigned URL, 두번째 인자로 파일 객체를 받는 함수이다. API 요청 부분은 따로 설명할게 없으니 넘어가겠다.
여기까지 진행하면, S3 버킷에 업로드한 파일이 담기게 된다.
운영 중인 서비스의 프론트 코드는 React + Typescript로 작성되었다. 그런데 파일 업로드할 때마다 createPresignedUrl
함수를 부르는게 번거로워서, useFileUpload
라는 커스텀 훅을 만들게 되었다.
// 프론트 코드
// useFileUpload.tsx
import { useEffect, useState } from 'react';
import { message } from 'antd';
import { UploadProps, RcFile } from 'antd/lib/upload';
import { createPresignedUrl } from '../task/callbackFunctions';
import useCustomNotification from '@/hooks/common/useCustomNotification';
const FILE_LIMIT = 30;
const useFileUpload = (
uploadType: string,
initialUrl?: string,
initialName?: string,
onSuccessUploadFile?: (params: any) => void,
data?: any,
onClearUploadFile?: (params: any) => void,
) => {
const [fileUrl, setFileUrl] = useState(initialUrl || '');
const [fileName, setFileName] = useState(initialName || '');
const [isLoading, setIsLoading] = useState(false);
const { success, error } = useCustomNotification();
useEffect(() => {
if (data) {
setFileUrl(initialUrl || '');
setFileName(initialName || '');
}
}, [data]);
const beforeUpload = (file: RcFile) => {
const isSizeLimit = file.size / 1024 / 1024 < FILE_LIMIT;
const allowFileTypes = true;
if (!allowFileTypes) {
error('파일 형식이 잘못된 것 같아요.');
return;
}
if (!isSizeLimit) {
error(`${file.name} 파일이 ${FILE_LIMIT}MB를 넘어요. 다른 파일을 선택해주세요.`);
return;
}
return isSizeLimit;
};
// 이 부분만 확인해도 된다.
const customRequest = async ({ file, onSuccess, onError }: any) => {
try {
const key = 'updatable';
setIsLoading(true);
message.loading({
content: '파일을 업로드하고 있어요..',
key,
});
const isSizeLimit = file.size / 1024 / 1024 < FILE_LIMIT;
const result = await createPresignedUrl(file, uploadType, isSizeLimit);
setIsLoading(false);
if (result?.res.status === 200) {
const s3FileUrl = result?.s3FileUrl;
const body = {
fileUrl: s3FileUrl,
fileName: file.name,
};
setFileUrl(result?.s3FileUrl);
setFileName(file.name);
success('파일 업로드에 성공했어요');
if (onSuccessUploadFile) {
onSuccessUploadFile(body);
} else {
setIsLoading(false);
onError(new Error('Some error occurred'));
}
} else {
error(`파일 업로드에 실패했어요.`);
}
} catch (error) {
setIsLoading(false);
error(`파일 업로드에 실패했어요.`);
onError(error);
}
};
const props: UploadProps = {
multiple: false,
maxCount: 1,
showUploadList: false,
onChange({ file, fileList }) {},
onDrop(e) {
console.log('Dropped files', e.dataTransfer.files);
},
onRemove(file) {
setFileUrl('');
setFileName('');
},
beforeUpload,
customRequest,
};
const clearFile = () => {
setFileUrl('');
setFileName('');
const body = {
fileUrl: '',
fileName: '',
};
if (onClearUploadFile) {
onClearUploadFile(body);
}
};
return { props, fileUrl, fileName, clearFile, isLoading };
};
export default useFileUpload;
파일을 업로드할 때, Ant-Design의 Upload
컴포넌트를 사용했기 때문에, 코드가 거기에 맞춰 작성되어 있다.
중요한 부분은 customRequest
함수다(나머지 코드는 무시해도 된다). 이 함수에 들어가는 인자는 Ant-Design의 Upload 컴포넌트에서 요구하는 인자이니 무시하자.
const customRequest = async ({ file, onSuccess, onError }: any) => {
try {
const key = 'updatable';
setIsLoading(true);
message.loading({
content: '파일을 업로드하고 있어요..',
key,
});
const isSizeLimit = file.size / 1024 / 1024 < FILE_LIMIT;
const result = await createPresignedUrl(file, uploadType, isSizeLimit);
setIsLoading(false);
if (result?.res.status === 200) {
const s3FileUrl = result?.s3FileUrl;
const body = {
fileUrl: s3FileUrl,
fileName: file.name,
};
setFileUrl(result?.s3FileUrl);
setFileName(file.name);
success('파일 업로드에 성공했어요');
if (onSuccessUploadFile) {
onSuccessUploadFile(body);
} else {
setIsLoading(false);
onError(new Error('Some error occurred'));
}
} else {
error(`파일 업로드에 실패했어요.`);
}
} catch (error) {
setIsLoading(false);
error(`파일 업로드에 실패했어요.`);
onError(error);
}
};
파일 업로드 컴포넌트에는 업로드 시 파일이 업로드 되는 짧은 시간 동안 보여줄 로딩 상태, 업로드가 실패했을 때, 또는 성공했을 때 보여줄 메시지, 유저가 파일을 잘못 업로드 했을 때 삭제하기 위한 함수 등이 포함되어 있으면 편하다.
이 함수는 로딩 상태, 업로드 성공/실패 여부 서로 다른 메시지를 보여준다. 또한 유저가 잘못 업로드한 파일을 제거하기 위해 X 버튼을 누르면 업로드한 파일이 초기화되게 된다.
import React from 'react'
import useFileUpload from '@/hooks/common/useFileUpload';
const Test = () => {
const { props, fileUrl, fileName, clearFile } = useFileUpload('test');
return (
<div>
<FileUpload
props={propsFile}
fileUrl={fileUrl}
fileName={fileName}
clearFile={clearFile}
/>
</div>
)
}
export default Test;
이 코드로 파일을 업로드하게 된다면, S3 버킷 내의 test
폴더에 파일이 업로드 되게된다.
결과적으로, Presigned URL을 적용하고 대용량 파일 업로드 시 간헐적으로 발생하던 Timeout 에러는 확실하게 사라졌다. 아직까지 Presigned URL을 적용한 뒤 문제점 또는 단점을 찾지는 못했다.
마지막 커스텀 훅, 실제 적용 부분은 Ant-Design의 Upload
컴포넌트를 활용했기 때문에 해당 코드에 종속성이 있다. 그래서 Ant-Design을 사용하지 않고 직접 Upload
컴포넌트를 만들어서 사용한다면 이 코드로는 적용이 어려울 것이다. 또한, 백엔드 코드도 Lambda에 맞춰 작성된 코드이기 때문에 실제 이 글을 읽으시는 분들이 다른 컴퓨팅 서비스를 사용한다면 적용하기에는 어려움이 있을 수 있다.
요점은 Presigned URL을 통한 파일 업로드 과정이라고 생각한다. (더 범용성이 좋은 방법들은 아래 참고 링크에서 확인할 수 있다)