동아리 사이트를 개발하는 프로젝트에 참여하게 되었다. 내가 맡은 역할은 게시판 기능 중 이미지를 저장하고 불러오는 부분이었다. Markdown문법을 지원하는 동아리 게시판은 이미지 처리가 중요했다.
구현하기 전 간단하게 어떤 요구 사항이 있는지 파악했다.
요구사항의 1번의 경우 이미지를 업로드하고 업로드한 고유의 path를 형식으로 커서 부분에 기록하고

preview 하는 부분은 고민이 필요했다.
내가 프로젝트에 합류하기 전 백엔드 팀에서 작성했던 코드가 있었다.
@Column(name = "original_image_name", nullable = false)
private String originalImageName;
@Column(name = "upload_image_name")
private String uploadImageName;
@Getter
@Column(name = "upload_image_path", nullable = false)
private String uploadImagePath;
엔티티를 확인했을 때 이미지 저장 위치가 들어있었고,
public ExhibitSummaryResponse createExhibit(
ExhibitCreateRequest request,
List<MultipartFile> imageFiles,
Long studentId
) throws FileUploadException {
서비스에서 게시글을 생성할 때 MultipartFile을 사용해 프론트엔드에서 파일을 직접 백엔드로 전송하여 서버에 이미지를 저장하는 방식이었다.
등의 문제가 있어 파일을 관리하는 Storage-Server의 도입을 고려해야했다.
이미지를 주고 받는 순서가 중요하여 미리 설계를 해보았다.

파일 서버 소개

안녕 나는 파일 서버야~
나는 백엔드에서 프론트로 발급한 Presigned Url의 유효성을 검증하고
파일을 저장하거나 제공(Serve)하는 역할을 수행해~
파일 업로드 순서


Presinged Url을 요청하는 시나리오를 생각해봤다.
httpMethod = "GET", fileId = "파일 A의 ID", expires = "10분 후" 정보를 준비한다. (업로드는 PUT, 다운로드는 GET 사용)
이 정보들과 서버만 아는 비밀 키를 사용하여 위 코드(generateSignature)를 실행해 서명(signature)을 생성한다.
다음과 같은 Presigned URL을 조립하여 클라이언트에게 돌려준다.
https://storage.example.com/api/files/파일A?expires=...&signature=생성된서명값
이렇게 생성된 PresingedUrl을 사용하여 프론트는 파일서버에 요청을 보낸다.
URL에 포함된 fileId, expires 등의 파라미터를 꺼내 서버와 동일한 규칙으로 '서명할 메시지'를 다시 만든다.
자신이 보관하고 있는 동일한 비밀 키를 사용해 받은 요청으로부터 서명을 다시 계산한다.
자체 계산한 서명과 URL에 포함된 signature 값이 일치하는지 비교한다.
일치하면 "아, 이 요청은 변조되지 않았고, 권한 있는 서버가 발급한 유효한 URL이 맞구나!"라고 판단하고 파일을 제공한다. 일치하지 않으면 요청을 거부한다.
signature 생성
public String generateSignature(String httpMethod, String fileId, long expires) {
String messageToSign = httpMethod + "\n" + fileId + "\n" + expires;
try {
Mac mac = Mac.getInstance(HMAC_ALGORITHM);
mac.init(new SecretKeySpec(secretKey.getBytes(StandardCharsets.UTF_8), HMAC_ALGORITHM));
byte[] signatureBytes = mac.doFinal(messageToSign.getBytes(StandardCharsets.UTF_8));
return Base64.getUrlEncoder().withoutPadding().encodeToString(signatureBytes);
} catch (Exception e) {
throw new RuntimeException("서명 생성에 실패했습니다.", e);
}
}
Url 생성(업로드 기준)
// 업로드용 Presigned URL 생성
public String generatePresignedUploadUrl(String fileId) {
SignatureValidator validator = new SignatureValidator(secretKey);
long expires = (System.currentTimeMillis() / 1000) + 300; // 일단 5분
String httpMethod = "PUT";
String signature = validator.generateSignature(httpMethod, fileId, expires);
// 스토리지 서비스의 URL
return String.format(storageServerUrl+"/storage/%s?expires=%d&signature=%s",
fileId,
expires,
signature
);
}
Url 검증
public boolean isValid(String httpMethod, String fileId, long expires, String providedSignature) {
if (System.currentTimeMillis() / 1000 > expires) { // 1초 오차
return false;
}
String expectedSignature = generateSignature(httpMethod, fileId, expires);
return Objects.equals(providedSignature, expectedSignature);
}
검증 로직을 인터셉터에 등록하여 검사한 후 컨트롤러로 전송한다.
파일 요청 처리(업로드)
public ResponseEntity<String> uploadFile(@PathVariable String fileId,
@Parameter(schema = @Schema(type = "string", format = "binary")) @RequestParam("file") MultipartFile file) {
try {
Path targetLocation = this.fileStorageLocation.resolve(fileId);
Files.copy(file.getInputStream(), targetLocation, StandardCopyOption.REPLACE_EXISTING);
System.out.println("Multipart file saved to: " + targetLocation.toAbsolutePath());
return ResponseEntity.ok("File uploaded successfully: " + fileId);
} catch (IOException ex) {
throw new BusinessException(HttpStatus.INTERNAL_SERVER_ERROR, "파일을 저장하는 중 오류가 발생했습니다.");
}
}
body로 보내진 데이터를 저장한다.
파일 업로드
const handleFiles = async (files: FileList | File[]) => {
for (const f of Array.from(files)) {
if (!f.type.startsWith("image/")) continue;
try {
const { data: uploadInfo } = await apiFetch<UploadUrlResponse>('/files/upload', {
method: 'GET',
auth: true,
});
먼저 Backend로부터 PresignedUrl을 발급받는다.
export async function uploadImageRemote(presignedUrl: string, file: File): Promise<boolean> {
console.log(`'${file.name}' 파일 업로드를 시작합니다...`);
console.log("서버로부터 받은 Presigned URL:", presignedUrl);
try {
const response = await fetch(presignedUrl, {
method: 'PUT',
// 파일 객체를 body에 직접 전달한다.
body: file,
headers: {
'Content-Type': file.type,
},
});
업로드 요청을 기준으로 body에 이미지 바이트를 넣고 Storage Server에 PUT 요청을 보낸다.

이미지를 랜더링 하기 위해서는
``로 넣어야 브라우저에서 <img>태그에 이 Presinged Url이 삽입되어 이미지가 랜더링 되지만 다음과 같은 문제가 있었다.
이미지 테그에 들어갈 path를 file의 고유 id를 사용하여 문제를 해결했다.
<img> 테그 안에 삽입한다.
const CustomImageRenderer: FC<ComponentProps<'img'>> = ({ src, alt, ...props }) => {
const [actualSrc, setActualSrc] = useState<string | undefined>(src);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
if (src && src.startsWith("exhibits/images/")) {
if (urlCache.has(src)) {
setActualSrc(urlCache.get(src));
return;
}
const fetchPresignedUrl = async () => {
setIsLoading(true);
try {
const { data: presignedInfo } = await apiFetch<{ presignedUrl: string }>(
`/files/download?fileId=${encodeURIComponent(src)}`,
{
method: 'GET',
auth: true,
}
);
if (presignedInfo) {
console.log("useEffect 실행됨. preSinged :", presignedInfo.presignedUrl);
urlCache.set(src, presignedInfo.presignedUrl);
setActualSrc(presignedInfo.presignedUrl);
랜더링의 경우 ReactMarkdown의 prop으로 전달하여 사용한다.
이렇게 하면 ReactMarkdown 라이브러리로 이미지를 랜더링 할 때마다 내가 정의한 방법대로 랜더링을 수행할 수 있다.

파일 업로드 까지는 빠르게 진행했지만, 게시판에서 이미지를 랜더링하는 부분이 까다로워서 고생했다.
이 방식은 외부에서 Storage Server로 함부로 접근하는 것을 막고 발급한 Presinged Url을 통해서만 접근할 수 있다는 점에서 보안적으로 좋다고 생각한다.![]
이번 프로젝트를 통해서 위와 같은 부분을 알아갈 수 있었다.
유지 보수나 기능 추가는 당분간 계속할 거 같다.
expires 설정은 왜 해야하는지 잘 이해가 안 갑니다!