저고데

[Spring Boot 실전] 11. 게시판 파일 업로드 구현하기 본문

Spring Boot 실전

[Spring Boot 실전] 11. 게시판 파일 업로드 구현하기

진철 2024. 2. 2. 15:15
728x90
반응형
파일 업로드를 들어가기 앞서서

우선 파일 업로드에 앞서서, 이전 코드 리팩토링 내용을 먼저 말하도록 하겠다.

지난 시간에 마지막으로 게시글의 추천, 비추천 기능을 추가하였다.

오랜만에 프로그램 실행 후에 게시글을 생성하였는데, 그대로 500 에러가 발생하였다.

2024-02-01T21:56:56.103+09:00 ERROR 57966 --- [nio-8080-exec-6] o.h.engine.jdbc.spi.SqlExceptionHelper   : Field 'dislike' doesn't have a default value
2024-02-01T21:56:56.109+09:00 ERROR 57966 --- [nio-8080-exec-6] o.a.c.c.C.[.[.[/].[dispatcherServlet]    : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed: org.hibernate.exception.GenericJDBCException: could not execute statement [Field 'dislike' doesn't have a default value] [insert into article (content,dislike_count,like_count,title,user_id) values (?,?,?,?,?)]] with root cause

java.sql.SQLException: Field 'dislike' doesn't have a default value

다음과 같은 상세 오류가 나왔는데, 추천과 비추천에 해당하는 dislike의 값이 할당되지 않아서 그런 것이었다.

@PostMapping("/articles/new")
public String create(@ModelAttribute ArticleForm form, @RequestParam("files") List<MultipartFile> files) {
    Article article = new Article();
    article.setTitle(form.getTitle());
    User temp = (User) session.getAttribute("user");
    article.setUser(temp);
    article.setContent(form.getContent());
    article.setLikeCount(0);
    article.setDislikeCount(0);
    articleService.create(article);

따라서 기존 ArticleController 부분에 각각의 추천, 비추천의 값을 초기화해주는 것으로 코드를 수정하여 문제를 해결할 수 있었다.

파일 업로드 하기

기존에는 일반 블로그 글과 같이 사진도 같이 첨부하여 좀 더 게시판 같은 기능을 구현하려고 했다.

하지만 그것보다 기본적인 첨부 파일 추가 기능을 먼저 구현하기로 하였다.

첨부 파일을 업로드하는 과정은 다음과 같았다.

게시글에서 파일을 업로드하면 파일의 이름과 해당 파일을 저장할 경로가 필요하다.

즉, 게시글에서 파일을 서버에 요청을 하면, 서버에서 해당 파일을 저장할 저장소(디렉토리)가 필요한 것이다.

따라서 사용자들이 해당 게시글의 첨부파일을 다운로드하면 서버의 저장소에서 해당 이름을 가진 첨부 파일을 사용자에게 다시금 보내는 셈이다.

그렇기 때문에 File Entity를 생성할 때는 파일의 이름과 저장 경로가 필요한 것이다.

DB 설계하기

그리고 다음으로 테이블 매핑에 대해서 생각을 해보았다.

기본적으로, 게시글의 경우 "User -> File -> Article"의 형태로 생성이 된다. (유저가 파일을 선택하여 게시글을 생성함)

따라서 그 중간의 File의 경우, User나 Article 중에 하나라도 삭제되면 자동으로 삭제가 되어야한다.

그리고 File 테이블에는 외래키로 Article과 User를 설정하여 어느 Article에 첨부되어 있고 누가 첨부했는지 알 수 있도록 하였다.

@Entity
public class File {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String FilePath;
    private String FileName;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id", referencedColumnName = "id", nullable = false, updatable = false)
    private User user;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "article_id", referencedColumnName = "id", nullable = false, updatable = false)
    private Article article;

}
@Getter
@Setter
@Entity
@ToString
public class Article {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String title;
    private String content;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id", referencedColumnName = "id", nullable = false, updatable = false)
    private User user;

    @OneToMany(mappedBy = "article", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<File> files;
@Getter
@Setter
@Entity
@ToString
public class User {
    @Id
    private String id;
    private String name;
    private String password;
    private String nickname;

    @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<Article> articles;

    @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<File> files;

    public User() {
        this.id = id;
        this.name = name;
        this.password = password;
        this.nickname = nickname;
    }
}

기존의 Entity들도 매핑이 가능하게 수정해주었다.

User의 경우, 한 사용자가 여러 개의 Article과 여러 개의 File을 생성할 수 있기 때문에 일대다 형태로 설계하였다.

Article 역시, 하나의 게시글이 여러 개의 File을 업로드할 수 있기 때문에 일대다 형태로 설계하였다.

반면, File은 게시글 생성 시에 하나의 File이 하나의 User와 하나의 Article에 의해 생성되기 때문에 다대일 형태로 설계하였다.

File Repository, Service 추가
<form action="/articles/new" method="post" enctype="multipart/form-data"> <!-- 여러 형태를 입력할 수 있게 함-->
    <div class="form-group">
        <label for="title">제목</label>
        <input type="text" id="title" name="title" placeholder="제목을 입력하세요." required>
        <br>
        <label for="content">내용</label>
        <textarea id="content" name="content" placeholder="내용을 입력하세요." required></textarea>
        <br>
        <input type="file" name="files" multiple="multiple"/> <!-- 여러 파일을 업도르할 수 있게 함-->
    </div>
    <button type="submit">게시글 작성</button>
</form>

우선 게시글 생성 화면인 createArticleForm.html에서 다음과 같은 코드로 수정하였다.

또한 제목과 내용에만 required 옵션을 설정하여서 파일 업로드는 필수가 아닌 선택으로 가능하게 하였다.

 

public interface FileRepository {
    File save(File file);
    void delete(Long id);
    List<File> findAll();
    Optional<File> findById(Long id);
}
@Repository
public class JpaFileRepository implements FileRepository{

    private final EntityManager em;

    public JpaFileRepository(EntityManager em) {
        this.em = em;
    }

    @Override
    public File save(File file) {
        em.persist(file);
        return file;
    }

    @Override
    public void delete(Long id) {
        File file = findById(id).orElseThrow(() -> new EntityNotFoundException("File not found"));
        em.remove(file);
    }

    @Override
    public List<File> findAll() {
        return em.createQuery("select f from File f", File.class)
                .getResultList();
    }

    @Override
    public Optional<File> findById(Long id) {
        File file = em.find(File.class, id);
        return Optional.ofNullable(file);
    }

}
@Service
@Transactional
public class FileService {
    private final FileRepository fileRepository;

    public FileService(FileRepository fileRepository) {
        this.fileRepository = fileRepository;
    }

    public Long create(File file) {
        fileRepository.save(file);
        return file.getId();
    }

}

그리고 필자가 여기서 많이 해매고 실수하였다.

FileController를 어떻게 작성해야하지?가 주된 원인이었다.

결론부터 말하자면 FileController를 생성하지 않고 기존의 게시글 생성에 관련있는 ArticleController에 FileService를 불러오면 된다.

앞서서 createArtcleForm.html에서 Post 요청을 ArticleController에게 전달한다.

그 사실도 모른채, 바보같이 FileController를 새로 생성하고 Article ID를 어떻게 전달하는지 몰라서 끙끙 앓고 있던 것이다. (진짜 바보같았다.)

@PostMapping("/articles/new")
public String create(@ModelAttribute ArticleForm form, @RequestParam("files") List<MultipartFile> files) {
    Article article = new Article();
    article.setTitle(form.getTitle());
    User temp = (User) session.getAttribute("user");
    article.setUser(temp);
    article.setContent(form.getContent());
    article.setLikeCount(0);
    article.setDislikeCount(0);
    articleService.create(article); // article을 먼저 생성해야 영속성 문제를 해결할 수 있음

    if (!files.isEmpty()) {
        for (MultipartFile file : files) {
            File eachFile = new File();
            eachFile.setArticle(article);
            eachFile.setUser(temp);
            eachFile.setFileName(file.getOriginalFilename());
            eachFile.setFilePath("/Users/shin/Desktop/file_db/");
            fileService.create(eachFile);
        }
    }

    return "redirect:/articles/";
}

따라서 기존의 ArticleController를 다음과 같이 수정하였다.

Spring에서는 file에 해당하는 객체인 MultipartFile을 제공하기 때문에 간단하게 파일을 다룰 수 있다.

User가 File을 업로드했으면, 다량의 File을 순서대로 DB에 저장하고 그렇지 않으면 그 과정을 생략한다.

위의 코드의 주석 부분에서 Article을 먼저 생성하지 않고 File을 먼저 생성하면 영속성 문제로 오류가 발생한다.

외래키를 가지고 있다면 순서가 중요하다

File 엔터티가 Article 엔터티를 참조하고 있고, File 엔터티의 article 속성은 Article 엔터티를 참조하는 외래 키이다.

이런 경우에는 관련된 엔터티를 영속화할 때 순서가 매우 중요하다.

    articleService.create(article);

    if (!files.isEmpty()) {
        for (MultipartFile file : files) {
            File eachFile = new File();
            eachFile.setArticle(article); // 이 부분 !
            eachFile.setUser(temp);
            eachFile.setFileName(file.getOriginalFilename());
            eachFile.setFilePath("/Users/shin/Desktop/file_db/");
            fileService.create(eachFile);
        }
    }

File에는 User와 Article을 외래 키로 설정하고 있기 때문에 setUser, setArticle을 통해서 입력해주어야한다.

따라서 User와 Article이 먼저 저장되고 나서 저장된 User와 Article을 지정해주어야 영속성에 문제가 없다.

만약 File이 User와 Article을 지정하고 해당 User와 Article 값이 수정되어 다시 저장되면 어떨까?

File이 가지고 있는 User와 Article 값이 현재 저장된 User와 Article 값과 달라서 영속성의 문제가 발생할 것이다.

즉, 데이터의 값을 신뢰할 수 없는 상태가 되는 것이다.

따라서 Spring에서의 영속성 관리자는 현 상태에서 Article 엔터티가 아직 영속화되지 않았기 때문에 해당 엔터티의 식별자가 존재하지 않는다고 판단하여 예외를 발생시킨다.

(이래서 ORM과 설계가 매우 중요하다!)

그 결과 테이블에 잘 저장되는 것을 확인할 수 있다.

마치며

오늘은 이렇게 게시판에 파일을 업로드하는 기능을 구현해보고 영속성 관련 문제도 해결해보았다.

사실 데이터베이스 수업 때, SQL 문법만 잘 익히면 되는 것 아닌가하고 수업의 필요성을 잘 못 느꼈었다.

영속성? 정규화? 그런건 그냥 하면 되는거 아닌가라고 생각했던 내가 너무 부끄러워 지는 하루였다 ...

오픈 소스 활동이나 세미나에서 시니어 개발자분들이 항상 하시는 말씀이 있다.

"개발자는 코딩을 하는 사람이 아니다. 코딩은 프로그래밍에서 10 퍼센트도 안된다."

항상 설계와 문제해결 방법 그리고 커뮤니케이션을 중요하게 여겨야한다고 하였는데, 이제야 조금은 알 것 같다.

실습도 좋지만 앞으로는 기본적인 이론도 같이 병행해야겠다는 생각이 들었다.

(서점으로 달려가서 ORM 기본 서적이나 사야겠어요)

728x90
반응형