Spring Boot

[게시판 만들기 (10)] 파일 첨부_다중파일 첨부

서윤-정 2024. 1. 15. 10:38

 

파일을 첨부하기 위해선 수정하고 생성할 것들이 많다.

 

 

 

 

 

save.html에서 form에서 file을 첨부할 수 있도록 input 을 추가하고 다중 파일 처리를 위해 multiple도 써준다.

 

[ save.html ]

file: <input type="file" name="boardFile" multiple> <br>

 

 

 

 

 

 

 

 

 

 

 

다중 파일 처리를 위한 작업을 한다.

boardFile, originalFileName, storedFileName을 List의 형태로 바꿔준다.

첨부된 파일이 있으면 fileAttached 속성을 1로 설정하고, 

각 파일의 원본 파일 이릠과 저장된 파일 이름을 리스트에 담아 BoardDTO에 설정한다.

이렇게 하면 파일이 첨부되지 않은 경우에 fileAttached만 설정하고, 

첨부된 경우에는 추가로 파일 이름 정보도 설정하는 방식이다.

 

[ BoardDTO ]

package test.SpringBootBoard.board.dto;

import lombok.*;
import org.springframework.web.multipart.MultipartFile;
import test.SpringBootBoard.board.entity.BoardEntity;
import test.SpringBootBoard.board.entity.BoardFileEntity;

import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;

// DTO(Data Transfer Object), VO, Bean,         Entity
@Getter
@Setter
@ToString
@NoArgsConstructor // 기본생성자
@AllArgsConstructor // 모든 필드를 매개변수로 하는 생성자
public class BoardDTO {
    private Long id;
    private String boardWriter;
    private String boardPass;
    private String boardTitle;
    private String boardContents;
    private int boardHits;
    private LocalDateTime boardCreatedTime;
    private LocalDateTime boardUpdatedTime;

    private List<MultipartFile> boardFile; // save.html -> Controller 파일 담는 용도
    private List<String> originalFileName; // 원본 파일 이름
    private List<String> storedFileName; // 서버 저장용 파일 이름
    private int fileAttached; // 파일 첨부 여부(첨부 1, 미첨부 0)

    public BoardDTO(Long id, String boardWriter, String boardTitle, int boardHits, LocalDateTime boardCreatedTime) {
        this.id = id;
        this.boardWriter = boardWriter;
        this.boardTitle = boardTitle;
        this.boardHits = boardHits;
        this.boardCreatedTime = boardCreatedTime;
    }



    public static BoardDTO toBoardDTO(BoardEntity boardEntity) {
        BoardDTO boardDTO = new BoardDTO();
        boardDTO.setId(boardEntity.getId());
        boardDTO.setBoardWriter(boardEntity.getBoardWriter());
        boardDTO.setBoardPass(boardEntity.getBoardPass());
        boardDTO.setBoardTitle(boardEntity.getBoardTitle());
        boardDTO.setBoardContents(boardEntity.getBoardContents());
        boardDTO.setBoardHits(boardEntity.getBoardHits());
        boardDTO.setBoardCreatedTime(boardEntity.getCreatedTime());
        boardDTO.setBoardUpdatedTime(boardEntity.getUpdatedTime());
        if (boardEntity.getFileAttached() == 0) {
            boardDTO.setFileAttached(boardEntity.getFileAttached()); // 0
        } else {
            List<String> originalFileNameList = new ArrayList<>();
            List<String> storedFileNameList = new ArrayList<>();
            boardDTO.setFileAttached(boardEntity.getFileAttached()); // 1
            for(BoardFileEntity boardFileEntity: boardEntity.getBoardFileEntityList()) {
                // 파일 이름을 가져가야 함.
                // orginalFileName, storedFileName : board_file_table(BoardFileEntity)
                // join
                // select * from board_table b, board_file_table bf where b.id=bf.board_id
                // and where b.id=?
//                boardDTO.setOriginalFileName(boardEntity.getBoardFileEntityList().get(0).getOriginalFileName());
//                boardDTO.setStoredFileName(boardEntity.getBoardFileEntityList().get(0).getStoredFileName());
                originalFileNameList.add(boardFileEntity.getOriginalFileName());
                storedFileNameList.add(boardFileEntity.getStoredFileName());
            }
            boardDTO.setOriginalFileName(originalFileNameList);
            boardDTO.setStoredFileName(storedFileNameList);
        }
        return boardDTO;
    }
}

 

 

if (boardEntity.getFileAttached() == 0) : 파일이 첨부되지 않았을 경우

 

boardDTO의 fileAttached 속성을 0으로 설정한다.

 

 

else : 파일이 첨부되어 있을 경우

 

boardDTO의 fileAttached 속성을 1로 설정한다.

 

BoardEntity에 연결된 BoardFileEntityList 목록을 가져온다.

 

각 BoardFileEntity의 originalFileName과 storedFileName을 추출하여 리스트에 추가한다.

 

originalFileNameList와 storedFileNameList를 boardDTO의 각 속성에 설정한다.

 

 

 

 

 

 

 

 

 

 

 

 

BoardService의 save 메서드도 수정한다.

파일이 첨부되어 있는 경우에는 게시글과 파일 정보가 모두 저장되고, 

첨부 파일이 없는 경우에는 게시글만 저장된다.

 

[ BoardService ]

private final BoardFileRepository boardFileRepository;
        public void save(BoardDTO boardDTO) throws IOException {

            // 파일 첨부 여부에 따라 로직 분리
            if (boardDTO.getBoardFile().isEmpty()) {
                // 첨부 파일 없음.
                BoardEntity boardEntity = BoardEntity.toSaveEntity(boardDTO);
                boardRepository.save(boardEntity);
            } else {
                // 첨부 파일 있음.
            /*
                1. DTO에 담긴 파일을 꺼냄
                2. 파일의 이름 가져옴
                3. 서버 저장용 이름을 만듦
                // 내사진.jpg => 839798375892_내사진.jpg
                4. 저장 경로 설정
                5. 해당 경로에 파일 저장
                6. board_table에 해당 데이터 save 처리
                7. board_file_table에 해당 데이터 save 처리
             */
                BoardEntity boardEntity = BoardEntity.toSaveFileEntity(boardDTO);
                Long savedId = boardRepository.save(boardEntity).getId();
                BoardEntity board = boardRepository.findById(savedId).get();

                for(MultipartFile boardFile: boardDTO.getBoardFile()) {
//                  MultipartFile boardFile = boardDTO.getBoardFile(); // 1.
                    String originalFilename = boardFile.getOriginalFilename(); // 2.
                    String storedFileName = System.currentTimeMillis() + "_" + originalFilename; // 3.
                    String savePath = "C:/SpringBootStudy/SpringBootBoard/boardImg/" + storedFileName; // 4. C:/SpringBootStudy/SpringBootBoard/boardImg/9802398403948_내사진.jpg
//                  Mac : String savePath = "/Users/사용자이름/springboot_img/" + storedFileName; // C:/springboot_img/9802398403948_내사진.jpg
                    boardFile.transferTo(new File(savePath)); // 5.


                    BoardFileEntity boardFileEntity = BoardFileEntity.toBoardFileEntity(board, originalFilename, storedFileName);
                    boardFileRepository.save(boardFileEntity);
                }
            }
        }

 

 

if (boardDTO.getBoardFile().isEmpty()) : 첨부 파일이 없는 경우

 

BoardEntity.toSaveEntity(boardDTO) 를 사용하여 BoardDTO 객체에서 BoardEntity 객체를 생성한다.

 

생성된 BoardEntity를 boardRepository 를 통해 저장한다.

 

 

else : 첨부 파일이 있는 경우

 

 BoardEntity.toSaveFileEntity(boardDTO) 를 사용하여 BoardDTO 객체에서 BoardEntity 객체를 생성한다.

 

생성된 BoardEntity 를 boardRepository 를 통해 저장하고, 저장된 게시글의 ID를 saveId 에 저장한다.

 

boardRepository.findById(savedId).get() 를 통해 저장된 게시글을 다시 불러온다.

 

for(MultipartFile boardFile: boardDTO.getBoardFile()) { 를 통해 BoardDTO에서 전달된 첨부 파일 목록을 순회한다.

 

각 첨부 파일에 대해 파일의 원본 이름(originalFileName)을 가져오고, 

저장용 파일 이름(storedFileName)을 생성한다.

 

서버에 저장할 경로를 설정하고(savePath), 해당 경로에 실제 파일을 저장한다.

(실제 자신의 로컬 폴더 경로를 써주어야 한다.)

 

BoardFileEntity.toBoardFileEntity(board, originalFilename, storedFileName) 을 사용하여 

BoardFileEntity 객체를 생성한다.

 

생성된 BoardFileEntity 를 boardFileRepository 를 통해 저장한다.

 

 

 

 

 

 

 

 

 

 

 

 

BoardEntity에 어노테이션을 추가한다.

BoardEntity에 toSaveFileEntity 메서드도 추가한다.

 

[ BoardEntity ]

@Column
private int fileAttached; // 1 or 0

@OneToMany(mappedBy = "boardEntity", cascade = CascadeType.REMOVE, orphanRemoval = true, fetch = FetchType.LAZY)
private List<BoardFileEntity> boardFileEntityList = new ArrayList<>();

 

@OneToMany 어노테이션

boardFileEntityList 는 엔티티 간의 일대다(one-to-many) 관계를 나타내는 어노테이션이다.

 

@OneToMany 어노테이션을 사용하여 한 개의 게시글(BoardEntity)에

여러 개의 파일(BoardFileEntity)이 매핑된다고 선언한다.

 

mappedBy = "boardEntity" 양방향 관계에서 연관관계의 주인이 아닌 쪽에서 이 필드를 참조하면서

매핑된 엔티티를 지정한다. 

즉, BoardFileEntity 엔티티의 boardEntity 필드에 의해 매핑된다는 의미이다.

 

cascade = CascadeType.REMOVE 부모 엔티티가 삭제될 때 연결된 자식 엔티티도 함께 삭제되도록 설정한다.

 

orphanRemoval = true 부모 엔티티에서 자식 엔티티의 참조가 제거되면 해당 자식 엔티티도 삭제되도록 설정한다.

 

fetch = FetchType.LAZY 게시글 엔티티를 로딩할 때 연관된 파일 엔티티들은 필요할 때까지

로딩하지 않도록 설정한다. Lazy 로딩은 성능 최적화를 위해 사용된다.

 

 

public static BoardEntity toSaveFileEntity(BoardDTO boardDTO) {
    BoardEntity boardEntity = new BoardEntity();
    boardEntity.setBoardWriter(boardDTO.getBoardWriter());
    boardEntity.setBoardPass(boardDTO.getBoardPass());
    boardEntity.setBoardTitle(boardDTO.getBoardTitle());
    boardEntity.setBoardContents(boardDTO.getBoardContents());
    boardEntity.setBoardHits(0);
    boardEntity.setFileAttached(1); // 파일 있음.
    return boardEntity;
}

 

새로운 BoardEntity 객체를 생성한다.

boardDTO에서 작성자, 비밀번호, 제목, 내용, 조회수(0으로 초기화), 

파일이 첨부되어 있음을 나타내는 플래그로 1로 설정한다.

 

 

 

 

 

 

 

 

 

 

BoardFileEntity 엔티티를 생성한다.

파일과 관련된 정보를 저장하는 JPA 엔티티 클래스이다.

 

[ BoardFileEntity ] 

package test.SpringBootBoard.board.entity;

import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;


@Entity
@Getter
@Setter
@Table(name = "board_file_table")
public class BoardFileEntity extends BaseEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column
    private String originalFileName;

    @Column
    private String storedFileName;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "board_id")
    private BoardEntity boardEntity;

    public static BoardFileEntity toBoardFileEntity(BoardEntity boardEntity, String originalFileName, String storedFileName) {
        BoardFileEntity boardFileEntity = new BoardFileEntity();
        boardFileEntity.setOriginalFileName(originalFileName);
        boardFileEntity.setStoredFileName(storedFileName);
        boardFileEntity.setBoardEntity(boardEntity);
        return boardFileEntity;
    }
}

 

@Table(name = "board_file_table")

해당 엔티티가 매핑되는 데이터베이스 테이블의 이름을 지정하는 어노테이션이다.

 

    @GeneratedValue(strategy = GenerationType.IDENTITY)

주키의 값을 자동으로 생성하는 전략을 지정하는 어노테이션이다.

GenerationType.IDENTITY는 데이터베이스의 자동 증가 컬럼을 사용하여 값을 생성한다.

 

@ManyToOne(fetch = FetchType.LAZY)

다대일(N:1) 관계를 나타내는 어노테이션이다.

여러 BoardFileEntity가 하나의 BoardEntity에 속한다는 것을 의미한다.

fetch = FetchType.LAZY 는 게시글 엔티티를 로딩할 때 파일 엔티티를 필요할 때까지 로딩하지 않도록 설정한다.

 

@JoinColumn(name = "board_id")

조인 컬럼을 지정하는 어노테이션이다.

board_id 컬럼을 사용하여 BoardEntity와 조인한다.

 

public static BoardFileEntity toBoardFileEntity(BoardEntity boardEntity, String originalFileName, String storedFileName) 

정적 메서드로, 주어진 매개변수를 사용하여 새로운 BoardEntity 객체를 생성하고 반환한다.

 

 

 

 

 

 

 

 

 

 

 

 

 

BoardFileRepository 인터페이스를 생성한다.

여러 기능을 제공하는 JpaRepository를 상속함으로써, 데이터베이스에서 BoardFileEntity 엔티티와

관련된 기본적인 CRUD 연산을 사용할 수 있다.

 

[ BoardFileRepository ]

package test.SpringBootBoard.board.repository;

import org.springframework.data.jpa.repository.JpaRepository;
import test.SpringBootBoard.board.entity.BoardFileEntity;


public interface BoardFileRepository extends JpaRepository<BoardFileEntity, Long> {
}

 

이 인터페이스를 사용하면 데이터베이스 액세스 이외에도 spring data JPA가 자동으로 생성해주는

쿼리 메서드를 사용할 수 있다.

쿼리 메서드를 사용하면 메서드 이름을 통해 쿼리를 생성하고, 

별도의 쿼리문을 작성하지 않아도 된다.

 

 

 

 

 

 

 

 

 

detail.html 에서 파일 관련 코드를 추가한다.


[ detail.html ]

<tr th:if="${board.fileAttached == 1}">
    <th>image</th>
    <td th:each="fileName: ${board.storedFileName}">
        <img th:src="@{|/upload/${fileName}|}" alt="">
    </td>
</tr>

 

<tr th:if="${board.fileAttached == 1}">

fileAttached가 1일 때 (파일이 첨부되어 있을 때) 해당하는 내용을 출력하도록 하는 조건문이다.

th:if 속성을 사용하여 조건을 체크하고, 파일이 첨부되어 있을 때만 아래의 내용이 렌더링된다.

 

<td th:each="fileName: ${board.storedFileName}">

storedFileName에 있는 각 파일에 대해 반복한다.

 

<img th:src="@{|/upload/${fileName}|}" alt="">

th:src 속성을 사용하여 이미지 파일의 경로를 지정하고,

${fileName} 을 통해 파일 이름을 동적으로 가져와서 이미지 경로를 생성한다.

 

 

 

 

 

 

 

 

 

 

아 application.yml에서 update를 create로 바꿔줬다.

파일 테이블이 생성된 후엔 다시 update로 바꾼다.

그런데 create로 생성하니까 board 테이블도 다시 생성되면서 안에 있던 데이터들이 다 사라졌다. 😨

jpa:
  database-platform: org.hibernate.dialect.MySQLDialect
  open-in-view: false
  show-sql: true
  hibernate:
    ddl-auto: update

 

 

 

 

 

 

config 패키지를 생성하고 WebConfig 파일도 만들어준다.

해당 설정은 '/upload/**' 경로로 접근되는 요청을 실제 파일 저장 경로로 매핑한다.

이렇게 함으로써 웹에서 해당 경로의 정적 리소스에 접근할 수 있게 된다.

 

[ WebConfig ]

package test.SpringBootBoard.board.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebConfig implements WebMvcConfigurer {
    private String resourcePath = "/upload/**"; // view 에서 접근할 경로
    private String savePath = "file:///C:/SpringBootStudy/SpringBootBoard/boardImg/"; // 실제 파일 저장 경로(win)
//    private String savePath = "file:///Users/사용자이름/springboot_img/"; // 실제 파일 저장 경로(mac)

    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler(resourcePath)
                .addResourceLocations(savePath);
    }
}

 

resourcePath: 웹 애플리케이션에서 정적 리소스에 접근할 경로로 지정한다.

'/upload/**'로 설정되어 있으므로, 웹 애플리케이션이 '/upload/' 다음에 오는 경로로 접근하면

정적 리소스를 찾을 수 있다.

 

savePath: 실제 파일이 저장된 경로를 지정한다. 

이 경로는 'file:///C:/SpringBootStudy/SpringBootBoard/boardImg/' 로 설정되어 있으며, 

여기에 저장된 파일들이 '/upload/' 경로로 매핑된다. 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

실행 ㄱㄱㄱㄱㄱㄱㄱ

 

 

다중 파일을 선택한다.

 

 

 

잘 들어갔다.

 

 

 

잘 들어간걸 디비버에서 확인했다.

 

 

 

 

file_Attached가 1로 파일이 있다는 것을 알려준다.

 

 

 

로컬 폴더에도 잘 들어왔다.