package org.example.boardback.service.impl;
import jakarta.persistence.EntityNotFoundException;
import lombok.RequiredArgsConstructor;
import org.example.boardback.dto.board.file.BoardFileListDto;
import org.example.boardback.dto.board.file.BoardFileUpdateRequestDto;
import org.example.boardback.entity.board.Board;
import org.example.boardback.entity.file.BoardFile;
import org.example.boardback.entity.file.FileInfo;
import org.example.boardback.exception.FileStorageException;
import org.example.boardback.repository.board.BoardRepository;
import org.example.boardback.repository.file.BoardFileRepository;
import org.example.boardback.repository.file.FileInfoRepository;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.awt.*;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
import java.util.Objects;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class BoardFileServiceImpl {
private final FileServiceImpl fileService;
private final BoardFileRepository boardFileRepository;
private final BoardRepository boardRepository;
private final int MAX_ATTACH = 5;
private final FileInfoRepository fileInfoRepository;
@Transactional
public void uploadBoardFiles(Long boardId, List<MultipartFile> files) {
Board board = boardRepository.findById(boardId)
.orElseThrow(() -> new EntityNotFoundException("Board not found with id: " + boardId));
if (files.size() > MAX_ATTACH) throw new IllegalArgumentException("최대 " + MAX_ATTACH + "개까지 업로드 가능");
int order = 0;
for (MultipartFile mf : files) {
FileInfo info = fileService.saveBoardFile(boardId, mf);
BoardFile boardFile = BoardFile.of(board, info, order++);
boardFileRepository.save(boardFile);
}
}
public List<BoardFileListDto> getFilesByBoard(Long boardId) {
final String baseURL = "/api/file/download/";
List<BoardFile> boardFiles = boardFileRepository.findByBoardIdOrderByDisplayOrderAsc(boardId);
return boardFiles.stream()
.map(BoardFile::getFileInfo)
.filter(Objects::nonNull) // FileInfo가 null일 가능성이 있는 경우 (안정성 강화)
.map(fileInfo -> BoardFileListDto.fromEntity(fileInfo, baseURL))
.toList();
}
/** 파일 정보를 DB에서 조회*/
public FileInfo getFileInfo(Long fileId) {
return fileInfoRepository.findById(fileId)
.orElseThrow(() -> new FileStorageException("파일 정보를 찾을 수 없습니다."));
}
public Path loadFile(Long fileId) {
FileInfo fileInfo = getFileInfo(fileId);
Path path = Paths.get(fileInfo.getFilePath());
if (!Files.exists(path) || !Files.isReadable(path)) {
throw new FileStorageException("파일이 존재하지 않거나 읽을 수 없습니다.");
}
return path;
}
/** 파일 다운로드에 필요한 헤더 생성 (파일명 인코딩, MIME 타입 결정 포함) */
public HttpHeaders createDownloadHeaders(FileInfo info, Path path) {
HttpHeaders headers = new HttpHeaders();
// Content-Type 결정 (DB에 없으면 자동 검사)
String contentType = info.getContentType();
if (contentType == null) {
try {
contentType = Files.probeContentType(path);
} catch (Exception ignore) {}
}
headers.setContentType(MediaType.parseMediaType(
// octet-stream: 웹 서버에서 특정 파일 형식을 알 수 없을 때 사용하는 기본 MIME
// MIME: Multipurpose Internet Mail Extensions 타입
contentType != null ? contentType : "application/octet-stream"
));
// 파일명 인코딩
String encodedName = URLEncoder.encode(
info.getOriginalName(), StandardCharsets.UTF_8
// %20: 웹 사이트에서 띄어쓰기를 의미하는 퍼센트 인코딩 문자
).replace("+", "%20");
headers.add(HttpHeaders.CONTENT_DISPOSITION,
"attachment; fileName=\""
+ info.getOriginalName().replace("\"", "")
+ "\";"
+ "filename*=utf-8''" + encodedName
);
// Content-Length 포함 (다운로드 진행률 표시용)
headers.setContentLength(info.getFileSize());
return headers;
}
public void deleteBoardFile(Long fileId) {
BoardFile boardFile = boardFileRepository.findByFileInfoId(fileId)
.orElseThrow(() -> new FileStorageException("해당 파일은 게시글에 존재하지 않습니다."));
FileInfo fileInfo = boardFile.getFileInfo();
boardFileRepository.delete(boardFile);
fileService.deleteFile(fileInfo);
}
/**
* 수정 요청 시 전달 데이터
* boardId
* keepFileIds: List<Long> - 유지할 기존 파일 ID 목록
* newFiles: List<MultipartFile> - 새로 업로드한 파일 목록
*
* [ 서버 처리 순서 ]
* 1. 기존 파일 목록 조회
* 2. 삭제 대상 선정 > 디스크/DB 삭제
* 3. 신규 파일 저장 > 디스크/DB board_files 추가
* 4. display_order 다시 정렬
* */
@Transactional
public void updateBoardFiles(Long boardId, BoardFileUpdateRequestDto dto) {
List<Long> keepIds = dto.getKeepFileIds() == null
? List.of()
: dto.getKeepFileIds();
List<MultipartFile> newFiles = dto.getNewFiles();
// 1. 현재 DB에 저장된 파일 목록 조회
List<BoardFile> currentFiles = boardFileRepository.findByBoardIdOrderByDisplayOrderAsc(boardId);
// 2. 삭제 대상 선정
List<BoardFile> deleteTargets = currentFiles.stream()
// 유지할 기존 File 목록(id)에서 현재 파일 목록을 순회하여
// 유지할 기존 File 목록(id)에 해당 파일의 info id값이 포함되어 있지 않을 경우
// 해당(포함되어 있지 않은) 파일을 새로운 배열에 담기
.filter(boardFile -> !keepIds.contains(boardFile.getFileInfo().getId()))
.toList();
// 3. 삭제 처리
for (BoardFile bf: deleteTargets) {
fileService.deleteFile(bf.getFileInfo()); // 디스크 + file_infos 삭제
boardFileRepository.delete(bf); // board_files 삭제
}
// 4. 신규 파일 추가
if (newFiles != null && !newFiles.isEmpty()) {
Board board = boardRepository.findById(boardId)
.orElseThrow(() -> new EntityNotFoundException("해당 id의 게시글이 없습니다."));
int maxOrder = boardFileRepository.findByBoardIdOrderByDisplayOrderAsc(boardId)
.stream() // List<BoardFile>
.mapToInt(b -> b.getDisplayOrder())
.max()
.orElse(-1);
for (MultipartFile mf : newFiles) {
FileInfo info =fileService.saveBoardFile(boardId, mf);
BoardFile bf = BoardFile.of(board, info, ++maxOrder);
boardFileRepository.save(bf);
}
}
}
}
현재 board관련 수업 중 BoardFileServiceImpl의 코드의 내용이다.
프로그램은 정상적으로 실행되지만 포스트맨 검증에서 이렇게 에러가 났다.

코드내용이 이상했으면 실행 시 바로 오류가 나는데 아무리 봐도 이상이 없어 보였다.
그리하여 옆에 같이 배우는 동료와 확인해본 결과 한 가지를 수정하니 실행이 되었다.
// 3. 삭제 처리
for (BoardFile bf: deleteTargets) {
fileService.deleteFile(bf.getFileInfo()); // 디스크 + file_infos 삭제
boardFileRepository.delete(bf); // board_files 삭제
}
바로 이 삭제 처리 내 코드순서를 바꾸는 간단한 방법이었는데, 코드의 해석을 해보니 왜 바꿔야 했는지 알 수 있었다.
// 3. 삭제 처리
for (BoardFile bf: deleteTargets) {
fileService.deleteFile(bf.getFileInfo()); // 부모
boardFileRepository.delete(bf); // 자식
}
부모 자식관계일 경우는 부모를 먼저 삭제하면 DB foreign key 제약 조건에 걸리게 된다.
둘을 바꾼다면 이렇게 된다.
// 3. 삭제 처리
for (BoardFile bf: deleteTargets) {
boardFileRepository.delete(bf); // 자식
fileService.deleteFile(bf.getFileInfo()); // 부모
}
오늘 배우면서 느낀 점은 코드를 단순 오류 없이 잘 작성했다고 긴장을 푸는 것이 아니라,
기존 규칙에 맞게 잘 작성하고 검증을 했을 때 이상이 없을 때 긴장을 풀면 안 될 것 같다.
'개인 피드백 > 백엔드' 카테고리의 다른 글
| 프로필 이미지 - 조회, 수정, 삭제 기능 추가 (0) | 2025.12.25 |
|---|---|
| 이미지 업로드 기능 - 프로필 이미지 (0) | 2025.12.24 |
| 세션 인터셉터 만들기 (0) | 2025.12.24 |