본문 바로가기
프로젝트/Recipository

[Dev] 22.12.05. 게시글 작성 통합

by 규글 2022. 12. 5.

 게시글 작성 항목을 작성하기 위해 다음과 같은 단계를 거쳐왔다.

  • Reference link에 대한 List<String> 을 저장하기 위한 방법
  • Image file을 upload 하고 그 저장 경로를 저장하기 위한 방법
  • 게시글의 작성자 정보를 얻기 위한 방법

 

 사실 이것으로 계획했던 모두를 마무리한 것은 아니다. 아직 게시글의 category 항목에 대해서는 다루지 않았다. 이것은 후에 계정 정보를 확인하는 마이 페이지를 구성하면서 관리자 권한으로 category를 추가하도록 한 뒤에 다루는 것이 좋다는 생각이다.

 

 그럼 이제부터 게시글 작성에 대해서 통합해보겠다.

 

 

통합

contentform.html

<!DOCTYPE html>
<html lang="en"
      xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>write.html</title>
    <meta name="_csrf" th:content="${_csrf.token}">
    <meta name="_csrf_header" th:content="${_csrf.headerName}">
</head>
<body>
<div class="container">
    <h1>글쓰기</h1>
    <form action="/user/write" id="contentForm" enctype="multipart/form-data" method="post">
        <label for="title">제목</label>
        <input type="text" id="title" name="title">
        <br>
        <input type="hidden" id="writer" name="writer">
        <label for="content">내용</label>
        <textarea name="content" id="content" cols="30" rows="10"></textarea>
        <br>
        <img src="" alt="" id="image" name="image" style="width: 150px; height: 150px;">
        <a href="" id="imageUploadBtn">이미지 업로드</a>
        <input type="file" id="imageFile" name="imageFile" style="display:none">
        <br>
        <label for="">카테고리</label>
        <br>
        <label for="link">참고 링크</label>
        <ul>
            <li id="linkList">
                <input type="text" class="refLink" id="link" name="link">
            </li>
        </ul>
        <a href="#" id="linkAddBtn">링크 추가</a>
        <br>
        <input type="checkbox" id="bePublic" name="bePublic">
        <label for="bePublic">게시글 공개 여부</label>
        <br>
        <button type="submit" id="submitBtn">작성 완료</button>
        <a href="#">작성 취소</a>
    </form>
</div>
</body>
<script src="/app/contentform.js"></script>
</html>

 게시글 작성 form 이다. 다음의 내용들을 server로 전송한다.

  • 제목 (title)
  • 내용 (content)
  • 음식 이미지 (imageFile)
  • 레시피 참고 링크 (link)
  • 게시글 공개 여부 (bePublic)

 DB에 저장되는 나머지 게시글 번호(content_id), 작성자 이름(writer), 조회 수(viewCount), 좋아요 수(linkCount), 이미지 저장 경로(image_path)는 service logic에서 채워진다. 당장은 조회 수와 좋아요 수는 0으로 설정된다.

 

contentform.js

    // 이미지 업로드 버튼을 눌렀을 때 input type file 강제 클릭
    document.querySelector("#imageUploadBtn").addEventListener("click", function(e){
        e.preventDefault();

        document.querySelector("#imageFile").click();
    });

    // 링크 추가
    document.querySelector("#linkAddBtn").addEventListener("click", function(e){
        e.preventDefault();

        var linkList = document.querySelector("#linkList");

        var input = document.createElement("input");
        input.setAttribute("type", "text");
        input.setAttribute("class", "refLink");
        input.setAttribute("name", "link");

        linkList.appendChild(input);
    });

    // 게시글 작성 form submit
    document.querySelector("#contentForm").addEventListener("submit", function(e){
        e.preventDefault();

        var url = document.querySelector("#contentForm").action;
        var formData = new FormData(this);

        var token = document.querySelector("meta[name=_csrf]").content;
        var header = document.querySelector("meta[name=_csrf_header]").content;

        var promise = fetch(url, {
            method: "POST",
            headers: {
                "header": header,
                "X-Requested-With": "XMLHttpRequest",
//                "Content-Type": "multipart/form-data",
                "X-CSRF-Token": token
            },
            body: formData //JSON.stringify(object)
        });

        promise.then(function(response){
            return response.json();
        }).then(function(data){
            if(data.beSaved){
                alert("게시글 작성을 완료했습니다.");
                location.href = "/";
            } else {
                alert("게시글 작성에 실패했습니다. 문제가 반복된다면 문의 바랍니다.");
            }
        });
    });

    // 이미지 파일을 선택했을 때 동작하는 method (썸네일)
    document.querySelector("#imageFile").addEventListener("change", function(e){
        readImage(e.target, "#image");
    });

    // 이미지를 읽는 function
    function readImage(input, imageID) {
        // 이미지 파일인지 검사
        var fileName = input.files[0].name;
        var fileExt = fileName.substring(fileName.lastIndexOf(".") + 1);
        fileExt = fileExt.toLowerCase();

        if("jpg" != fileExt && "jpeg" != fileExt && "png" != fileExt && "bmp" != fileExt) {
            alert("이미지 파일만 선택할 수 있습니다.");
            return;
        }

        // FileReader 인스턴스 생성
        var reader = new FileReader();
        // 이미지가 로드가 된 경우
        reader.onload = function(e){
            var previewImg = document.querySelector(imageID);
            previewImg.src = e.target.result;
        }
        // reader가 이미지 읽도록 하기
        reader.readAsDataURL(input.files[0]);
    }

 contentform.html 의 input type file을 display: none 으로 설정해두었다. 업로드 버튼을 눌렀을 때 이를 강제로 클릭하게 한다.

 

 contentform.html의 ul > li 로 구성된 link 정보를 작성하는 input element를 추가한다.

 

 이전의 signinform 이나 login process에서의 fetch body와 다르게 body에 JSON이 아니라 form data를 그대로 넘겨준다. 이렇게 넘긴 data를 controller에서 Dto와 MultipartFile로 받도록 했다.

 

 Promise로 server로 비동기 요청을 하여 그 response에 맞게 동작할 수 있도록 구분했다.

 

 업로드 할 이미지를 게시글을 작성하는 form에서 바로 확인할 수 있도록 작성한 script이다. 지난 국비 과정에서 사용했던 logic을 들고와서 조금 바꿔주었다. 파일을 선택했을 때 먼저 해당 파일이 이미지 파일인지를 먼저 체크한 후에 이미지 파일이라면 FileReader 로 이미지를 띄울 element의 src, 즉 이미지의 source를 setting 해주어서 이미지를 볼 수 있게 해주었다.

 

RecipeController.java

package com.example.recipository.controller;

import com.example.recipository.domain.RecipeDto;
import com.example.recipository.domain.SpUser;
import com.example.recipository.service.RecipeServiceImpl;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

import java.util.HashMap;
import java.util.Map;

@RestController
public class RecipeController {
    private final RecipeServiceImpl recipeService;

    public RecipeController(RecipeServiceImpl recipeService) {
        this.recipeService = recipeService;
    }

    @PostMapping("/user/write")
    public ResponseEntity<Object> write(@ModelAttribute RecipeDto recipeDto,
                                        @RequestPart MultipartFile imageFile,
                                        @AuthenticationPrincipal SpUser spUser){

        // 로그인 한 사용자의 username 정보
        String username = spUser.getUsername();

        // 게시글을 작성하는 service logic
        boolean beSaved = recipeService.write(recipeDto, imageFile, username);
        Map<String, Object> map = new HashMap<>();
        map.put("beSaved", beSaved);

        return ResponseEntity.ok().body(map);
    }
}

 Client에서 게시글을 작성하기 위한 ajax 요청을 했을 때 동작하는 controller의 method이다.

  • Input type file을 제외한 나머지 data 항목을 RecipeDto로 받으며, @ModelAttribute annotation은 생략 가능하다.
  • Input type file을 MultipartFile type으로 받으며, @RequestPart annotationㅇ르 붙여주어야 한다.
  • 해당 게시글을 작성하는 사용자의 username data를 얻기 위해 @AuthenticationPrincipal annotation을 명시하여 UserDetails 인 SpUser 객체를 인자로 받는다.

 이들을 모두 service 단으로 넘겨서 DB에 저장하도록 하며, 그 성공 여부에 따라 client가 다른 동작을 할 수 있도록 JSON으로 성공 여부를 ResponseEntity에 담아 return 해주었다.

 

RecipeDto.java

package com.example.recipository.domain;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.util.ArrayList;
import java.util.List;

@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class RecipeDto {
    private Long contentId;
    private String title;
    private String writer;
    private String content;
    private String imagePath;
    private Long viewCount;
    private Long likeCount;
    private List<String> link;
    private String category;
    private boolean bePublic;

    public Recipe toEntity(){
        List<Link> linkList = new ArrayList<>();
        if(link != null) {
            link.forEach(tmp -> {
                Link link = Link.builder()
                        .link(tmp)
                        .build();
                linkList.add(link);
            });
        }

        return Recipe.builder()
                .contentId(contentId)
                .title(title)
                .writer(writer)
                .content(content)
                .imagePath(imagePath)
                .viewCount(viewCount)
                .likeCount(likeCount)
                .link(linkList)
                .category(category)
                .bePublic(bePublic)
                .build();
    }
}

 Client로부터 전달받는 data를 parsing 할 Dto class이다. Response에 대비하여 조회수와 좋아요수까지 field로 구성하였다. 담고 있는 data를 가지고 Recipe Entity로 전환하기 위한 method를 구성했다. 이렇게 변환된 RecipeEntity의 List<Link> 속 Link에는 Recipe data가 존재하지 않아서, 이 List<Link>를 그대로 받아 LinkRepository를 통해 save하려고 한다면 해당 column에는 data가 들어가지 않는다.

 

Recipe.java

package com.example.recipository.domain;

import lombok.*;

import javax.persistence.*;
import java.util.ArrayList;
import java.util.List;

@Getter
@AllArgsConstructor
@NoArgsConstructor
@Builder
@Table(name = "recipe")
@Entity
public class Recipe {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "content_id")
    private Long contentId;
    private String title;
    private String writer;
    private String content;
    private String imagePath;
    private Long viewCount;
    private Long likeCount;
    @Transient
    private List<Link> link;
    private String category;
    private boolean bePublic;

    public RecipeDto toDto(){
        List<String> linkList = new ArrayList<String>();
        if(link != null){
            link.forEach(tmp -> {
                linkList.add(tmp.getLink());
            });
        }

        return RecipeDto.builder()
                .contentId(contentId)
                .title(title)
                .writer(writer)
                .content(content)
                .imagePath(imagePath)
                .viewCount(viewCount)
                .likeCount(likeCount)
                .link(linkList)
                .category(category)
                .bePublic(bePublic)
                .build();
    }

    public void setRecipeAtLink(){
        link.forEach(tmp -> {
            tmp.setRecipe(this);
        });
    }
}

 RecipeDto에서 전환될 Recipe Entity class이다. RecipeDto와는 역으로 RecipeDto로 전환하는 method를 구성했으며, RecipeDto에서 전환되었을 시 List<Link>의 Link에 Recipe data가 setting 되어있지 않으므로 각 Link에 Recipe 자신을 setting 하도록 setRecipeAtLink method를 구성했다. Recipe에서는 Link를 참조하고 있지 않으므로, repository를 통해 Recipe를 save 한다고 Link가 save되지는 않는다.

 

Link.java

package com.example.recipository.domain;

import lombok.*;

import javax.persistence.*;

@Getter
@AllArgsConstructor
@NoArgsConstructor
@Builder
@Table(name = "link")
@Entity
public class Link {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String link;
    @ManyToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL)
    @JoinColumn(name = "content_id")
    private Recipe recipe;

    public void setRecipe(Recipe recipe) {
        this.recipe = recipe;
    }
}

 Link table과 data를 주고받을 Link Entity이다. 하나의 게시글에는 여러 link가 작성될 수 있으므로 Recipe와 Link는 1:N 관계가 있고, Link를 기준으로 @ManyToOne annotation으로 N:1(다대일) 단방향 연관 관계로 구성했다. Link에서만 Recipe를 참조하는 것이 가능하다. 아직 Link에 대한 Dto는 필요하지 않아서 Dto와 변환 method 모두 구성하지 않았다.

 

RecipeServiceImpl.java

package com.example.recipository.service;

import com.example.recipository.domain.Recipe;
import com.example.recipository.domain.RecipeDto;
import com.example.recipository.repository.LinkRepository;
import com.example.recipository.repository.RecipeRepository;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;

import java.io.File;
import java.io.IOException;
import java.util.UUID;

@Service
public class RecipeServiceImpl implements RecipeService {
    private final RecipeRepository recipeRepository;
    private final LinkRepository linkRepository;

    @Value("${file.directory}")
    private String savePath;

    public RecipeServiceImpl(RecipeRepository recipeRepository, LinkRepository linkRepository) {
        this.recipeRepository = recipeRepository;
        this.linkRepository = linkRepository;
    }

    // 게시글을 작성하는 service logic
    @Transactional
    @Override
    public boolean write(RecipeDto recipeDto,
                         MultipartFile imageFile,
                         String username) {
        try {
            if (!imageFile.isEmpty()) {
                // application.properties 에 작성한 save path
                // 각 운영체제에 맞는 separator
                String savePath = this.savePath + File.separator;
                File file = new File(savePath);
                // 해당 경로에 directory가 없을 시 만듦
                if (!file.exists()) {
                    file.mkdir();
                }

                // 넘겨받은 file name
                String originFileName = imageFile.getOriginalFilename();
                // save file name에 사용할 UUID String
                String uuid = UUID.randomUUID().toString();
                // save file name
                String saveFileName = uuid + originFileName;

                try {
                    // directory에 upload file save
                    imageFile.transferTo(new File(savePath + saveFileName));
                    // RecipeDto에 image save path setting
                    recipeDto.setImagePath(savePath + saveFileName);
                } catch (IOException e) {
                    throw new RuntimeException(e);
                }
            }

            // RecipeDto에 SpUser의 username setting
            recipeDto.setWriter(username);
            // RecipeDto에 조회수, 좋아요 0으로 setting
            recipeDto.setViewCount(0L);
            recipeDto.setLikeCount(0L);

            // RecipeDto를 Entity로 전환하고
            Recipe recipe = recipeDto.toEntity();
            // Entity의 List<Link> 의 각 Link에 Recipe setting
            recipe.setRecipeAtLink();

            // 각각 save
            recipeRepository.save(recipe);
            linkRepository.saveAll(recipe.getLink());

            return true;
        } catch (Exception e) {
            return false;
        }
    }
}

 전달받은 image file이 null이 아닌 경우 저장 경로를 setting 하고, 해당 경로에 directory가 없는 경우 만들어준다. 그리고 image의 original name과 save name을 다르게 setting하고, 저장하고자 하는 경로에 image를 저장하고 그 경로를 RecipeDto에 setting 해준다.

 

 Client의 form으로부터 받은 data가 아닌 나머지 writer, viewCount, linkCount에 대해 그 값을 setting 해주고 Dto를 Entity로 변환하고 Link에 Recipe data를 setting 해준다. 그리고 Recipe Entity와 Link Entity 각각의 repository를 통해 save하여 DB에 data를 insert 한다. 이때 Recipe data가 존재하지 않은 상태로 Link를 save하려고 한다면 Recipe의 primary key인 content_id data가 없으므로 Link를 제대로 insert 할 수 없다. 반대로 Link에 Recipe data가 존재하는 상태로 Link만 save 해도 Recipe까지 save되지만, 게시글에 Link 정보를 아예 작성하지 않아서 없는 경우에 Link Repository로 save 하면 아무것도 insert 되지 않으므로 Recipe를 먼저 save 하도록 했다. 

 오류 없이 수행되었을 경우 true를, 오류가 발생한 경우 false를 return 하여 client로 보낼 수 있도록 했다.

 

 

이어지는 작업

 아직 category에 대한 내용이 없지만 이는 후에 추가할 예정이다. 일단 이어지는 작업은 bootstrap을 이용한 최소한의 contentform 작업이며, 그 이후에 바로 게시글을 조회하고 수정, 및 삭제하는 기능을 구현할 것이다.

댓글