티스토리 뷰



안녕하세요.


오랜만에 포스팅입니다.


이제 공부도 열심히하고 꾸준히 해야겟어요...


Facebook에서 생활코딩 글도 자주 보고, 여기 저기 커뮤니티도 많이 보는데요.


생각보다 Summernote를 쓰시는 분이 많네요!


입 맛에 딱 맞는 이미지 업로드 예제가 없는 것 같아서~ 일단 제 입맛에 맞게 Github에 예제 소스를 올려 보았습니다. 


참고한 자료는 다음과 같습니다. 

Spring Boot 에 대해서 기본 지식이 있어야 해요! 없으시다면, 예제 소스에서 필요한 부분만 참고해주세요.

준비물! - STS, Summernote가 뭔지 알아야함!, Spring boot의 기본 지식

먼저, 이미지 업로드도 결국 파일 업로드 입니다! 기본적으로 파일 업로드를 위한 비동기 파일 업로드가 가능해야 하구요.
이미지 출력(파일 다운로드)을 위한 기능이 있어야 하구요.

그리고, Summernote는 웹 에디터니깐 간단하게 게시물이 등록될 수도 있도록 하겟습니다.
쿼리를 작성하거나 하는 부분을 제외하고, 업로드 기능에 집중할 수 있도록, JPA를 활용했습니다. 매우 심플하게 사용했어요..
(JPA에 대한 설명은 제외하도록 하겠습니다.)

그리고, 익숙한 JSP를 활용하도록 하겟습니다.

중간 중간 이미지에 해당하는 화면 스크린샷은 소스코드를 참고해주세요.

기능 목록을 정리해보겟습니다.
  1. 게시물 등록, 조회
  2. 이미지 업로드, 출력
먼저, 게시물 등록 입니다.


기본 화면 입니다. 예제를 실행하면 http://localhost:8080/에 해당합니다.


Article은 Subject(제목)과 Content로 구성 되어 있습니다. JPA 활용을 위해 Entity 부터 작성해봅니다. 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import java.util.Date;
 
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;
 
import lombok.Data;
 
@Entity
@Data
public class Article {
    
    @Id
    @GeneratedValue
    int id;
    String subject;
    
    @Column(length = 100000000)
    String content;
    
    Date regDate;
}
 
cs

lombok을 활용해서, getter/setter 기타 등등 자동으로 생성 되도록 합니다.

Article은 @Id, @GeneratedValue를 통해, 숫자 형태의 키값이 자동으로 생성됩니다. 

- ArticleController 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
import java.util.Date;
import java.util.List;
 
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
 
import kr.hwb.example.entity.Article;
import kr.hwb.example.repository.ArticleRepository;
 
@Controller
@RequestMapping(value = "/article")
public class ArticleController {
    
    @Autowired
    private ArticleRepository articleRepository;
    
    @GetMapping(value = "/{id}")
    public String getArticle(Model model, @PathVariable int id) {
        Article article = articleRepository.findOne(id);
        model.addAttribute("article", article);
        return "/article/detail";
    }
    
    @GetMapping(value = "")
    public String getArticleList(Model model) {
        List<Article> articleList = articleRepository.findAll();
        model.addAttribute("articleList", articleList);
        return "/article/list";
    }
    
    @PostMapping(value = "")
    public String setArticle(Article article) {
        article.setRegDate(new Date());
        
        System.out.println(article);
        
        return "redirect:/article/" + articleRepository.save(article).getId();
    }
}
cs


게시글은  /article 로 시작하는 주소이며, 위에서 부터 상세 조회, 목록, 게시글 등록 입니다.

ArticleRepository는 JPA를 활용하는 Interface 클래스이며, H2 Database에 접근하도록 해주며, 조회, 등록, 삭제 등의 기능들이 자동으로 생성됩니다.


게시글은 소스코드를 참고하시며, 확인해주시고 다음으로 넘어가겟습니다.


위의 메인화면 스크린샷에서 보시는 것처럼 summernote가 적용되어 있습니다. 기본적으로 Summernote는 bootstrap framework를 사용하므로, css 와 js 파일들이 추가 되어 있습니다.


- css와 js

1
2
3
4
5
6
7
<!-- include libraries(jQuery, bootstrap) -->
<link href="http://netdna.bootstrapcdn.com/bootstrap/3.3.5/css/bootstrap.css" rel="stylesheet">
<script src="http://cdnjs.cloudflare.com/ajax/libs/jquery/2.1.4/jquery.js"></script>
<script src="http://netdna.bootstrapcdn.com/bootstrap/3.3.5/js/bootstrap.js"></script>
<!-- include summernote css/js-->
<link href="http://cdnjs.cloudflare.com/ajax/libs/summernote/0.8.2/summernote.css" rel="stylesheet">
<script src="http://cdnjs.cloudflare.com/ajax/libs/summernote/0.8.2/summernote.js"></script>
cs


- 메인화면의 form 소스

1
2
3
4
5
6
7
8
9
10
11
<form id="articleForm" role="form" action="/article" method="post">
  <br style="clear: both">
  <h3 style="margin-bottom: 25px;">Article Form</h3>
  <div class="form-group">
    <input type="text" class="form-control" id="subject" name="subject" placeholder="subject" required>
  </div>
  <div class="form-group">
    <textarea class="form-control" id="summernote" name="content" placeholder="content" maxlength="140" rows="7"></textarea>
  </div>
  <button type="submit" id="submit" name="submit" class="btn btn-primary pull-right">Submit Form</button>
</form>
cs

textarea에 summernote 적용을 하기 위해 id를 summernote로 설정하였습니다.


- summernote 적용과 이미지 업로드를 위한 function

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
<script type="text/javascript">
    $(document).ready(function() {
      $('#summernote').summernote({
        height: 300,
        minHeight: null,
        maxHeight: null,
        focus: true,
        callbacks: {
          onImageUpload: function(files, editor, welEditable) {
            for (var i = files.length - 1; i >= 0; i--) {
              sendFile(files[i], this);
            }
          }
        }
      });
    });
    
    function sendFile(file, el) {
      var form_data = new FormData();
      form_data.append('file', file);
      $.ajax({
        data: form_data,
        type: "POST",
        url: '/image',
        cache: false,
        contentType: false,
        enctype: 'multipart/form-data',
        processData: false,
        success: function(url) {
          $(el).summernote('editor.insertImage', url);
          $('#imageBoard > ul').append('<li><img src="'+url+'" width="480" height="auto"/></li>');
        }
      });
    }
</script>
cs

summernote에 onImageUpload callback 함수를 설정하지 않으면, data형태로 이미지가 에디터에 그대로 삽입됩니다. 문자열이 매우 길어지고, 파일 관리(?)를 할 수 있는 형태가 아니기 때문에, 실제 서비스 사용에서는 다소 무리가 있습니다.


그래서, onImageUpload callback함수를 정의하여, 이미지 파일을 서버에 저장하고, 이미지를 호출 할 수 있는 URL을 리턴 받아서 입력하면, 이미지가 삽입된 것 처럼 보이게 됩니다.


이미지 업로드 경로는 "/image" 로 되어있네요. 그리고 enctype 이 'multipart/form-data' 입니다.

파일을 업로드할 때에는 이 enctype이 매우 중요합니다. 꼭 multipart/form-data로 적어주어야 합니다.


파일 전송이 완료 되었을 경우, 이미지 파일의 url을 리턴 받을 것이고, summernote의 'editor.insertImage' 기능을 통해 이미지를 삽입 될 수 있도록 합니다.


아래의 #imageBoard에 append 되는 부분은, 이미지가 업로드 되면, 하위에 확인차 추가하도록 해놓은 부분입니다.


이제 "/image" 주소의 POST 처리를 받아주는 소스를 상세하게 알아보겟습니다.


ImageController에 handleFileUpload 메소드 입니다.

1
2
3
4
5
6
7
8
9
10
11
    @PostMapping("/image")
    @ResponseBody
    public ResponseEntity<?> handleFileUpload(@RequestParam("file") MultipartFile file) {
        try {
            UploadFile uploadedFile = imageService.store(file);
            return ResponseEntity.ok().body("/image/" + uploadedFile.getId());
        } catch (Exception e) {
            e.printStackTrace();
            return ResponseEntity.badRequest().build();
        }
    }
cs

파일은 MultipartFile로 받을 수 있습니다. 파일을 전송할 때 id를 file로 설정하였으니, @RequestParam에서 file로 받습니다.

MultipartFile은 스프링에서 지원해주는 파일 객체이며, 전송한 파일 데이터가 담겨집니다.


imageService.store() 메소드에 MultipartFile인 file을 넘겨 저장합니다.

imageService.store() 메소드를 확인해보겟습니다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
    public UploadFile store(MultipartFile file) throws Exception {
        try {
            if (file.isEmpty()) {
                throw new Exception("Failed to store empty file " + file.getOriginalFilename());
            }
            
            String saveFileName = UploadFileUtils.fileSave(rootLocation.toString(), file);
            
            if (saveFileName.toCharArray()[0== '/') {
                saveFileName = saveFileName.substring(1);
            }
            
            Resource resource = loadAsResource(saveFileName);
            
            UploadFile saveFile = new UploadFile();
            saveFile.setSaveFileName(saveFileName);
            saveFile.setFileName(file.getOriginalFilename());
            saveFile.setContentType(file.getContentType());
            saveFile.setFilePath(rootLocation.toString() + File.separator + saveFileName);
            saveFile.setSize(resource.contentLength());
            saveFile.setRegDate(new Date());
            saveFile = fileRepository.save(saveFile);
            
            return saveFile;
        } catch (IOException e) {
            throw new Exception("Failed to store file " + file.getOriginalFilename(), e);
        }
    }
cs

UploadFileUtils.fileSave를 통해 파일이 디스크에 저장이 됩니다.

UploadFileUtils는 소스코드를 참고해주시구요. 


fileSave를 하게 되면, 지정한 업로드 경로에 파일이 겹치지 않도록, UUID를 활용한 unique한 파일명이 생성 되고, 날짜별로 폴더를 생성하여, 파일을 저장한뒤, 날짜 경로 + Unique한 파일명 형태로, 파일명을 반환해줍니다.


DB에 저장된 File명을 필요로 하기 때문입니다.


Resource는 디스크의 저장된 파일의 정보를 담고 있는 객체라고 생각하시면 됩니다.(File 객체처럼)

loadAsResource는 날짜 + 파일명으로, 디스크의 위치 해 있는 파일을 읽어서 Resource객체를 반환해줍니다.


그리고, UploadFile 객체를 구성하여, DB에 저장되어지도록 합니다. (FileRepository 참고)

DB에 저장되면서, ID가 자동 생성되고, 저장하면서, UploadFile 객체를 반환하므로, 이를 다시 컨트롤러로 반환해줍니다.


DB에는 다음과 같이 저장됩니다. (h2는 application.yml 에 설정으로, 웹에서 쿼리를 실행해볼 수 있습니다. )

localhost:8080/console로 접근하여, 로그인하면


이렇게 DB에 저장된 정보를 확인할 수 있습니다.


컨트롤러에서는 이미지가 정상적으로 업로드가 되면, 키 값인 ID 포함시켜서, 

ResponseEntity.ok().body("/image/" + uploadedFile.getId());

이미지 출력을 위한 URL을 반환하게 됩니다.


먼저, 확인을 하기 전에 이미지 출력을 위한 메소드를 확인해봅니다. URL은 "/image/{ID}" 형태입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
    @GetMapping("/image/{fileId}")
    @ResponseBody
    public ResponseEntity<?> serveFile(@PathVariable int fileId) {
        try {
            UploadFile uploadedFile = imageService.load(fileId);
            HttpHeaders headers = new HttpHeaders();
            
            Resource resource = imageService.loadAsResource(uploadedFile.getSaveFileName());
            String fileName = uploadedFile.getFileName();
            headers.add(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + new String(fileName.getBytes("UTF-8"), "ISO-8859-1"+ "\"");
 
            if (MediaUtils.containsImageMediaType(uploadedFile.getContentType())) {
                headers.setContentType(MediaType.valueOf(uploadedFile.getContentType()));
            } else {
                headers.setContentType(MediaType.APPLICATION_OCTET_STREAM);
            }
            
            return ResponseEntity.ok().headers(headers).body(resource);
            
        } catch (Exception e) {
            e.printStackTrace();
            return ResponseEntity.badRequest().build();
        }
    }
cs

imageService.load(fileId)를 통해, DB에서 저장된 파일 정보를 불러옵니다. 위에 DB 정보 이미지에 담긴 내용을 모두 읽어와 UploadFile 객체에 담아 반환해줍니다. 


그리고, 이미지 파일을 보여주려면, 디스크에 저장된 이미지 파일도 필요하겠죠?

위에서 설명드린 imageService의 loadAsResource에 저장된 파일이름을 매개변수로하여, 반환된 파일Data를 Resource객체에 담습니다. 


Header 정보는 이미지 일 경우에는 ContentType이 이미지가 되도록, 일반 파일 경우에는 다운로드가 될 수 있도록 설정하는 부분입니다. 

소스코드를 참고해주세요.


이제 ResponseEntity.ok().headers(headers).body(resource);

를 통해 이미지 파일을 사용자가 볼 수 있도록, 이미지 파일 자체를 담아서 반환해줍니다.


내부적으로는 사용자(브라우저)와 서버간에 데이터가 이동할 수 있는 Stream 이 열리고, 데이터가 전송 되고, 완료 후 닫히는 과정이 있습니다만, 스프링에서는 간단하게 자체적으로 처리를 해줍니다. (직접 구현도 가능합니다. 방법이 매우 많음)


정상적인 경우, 업로드 된 이미지가 브라우저로 전달이 되겟죠.


위 이미지 처럼 Summernote 에디터에 이미지가 등록이 되었네요. 아래에도 확인차 추가 되도록한 ImageBoard에 추가가 된걸 확인할 수 있습니다.


적당히 제목과, 내용을 적고 Submit Form 버튼을 클릭해 봅시다.


게시글 상세화면입니다. 페이지마다 적용 된 이미지는 이미지 호출을 위한 URL로 구성되잇으므로, 호출 될 때마다 작성한 이미지 출력기능을 타게 됩니다.


한번 게시글의 DB데이터도 확인해볼까요?



잘 등록이 되었네요. 아무래도 웹 에디터이다 보니, HTML 태그들이 들어간 내용을 볼 수 있습니다.

잘보면 img의 경로가 구현한 "/image/1" 경로를 타게 제대로 되어있네요.


최신 STS를 받으시면, 예제 소스를 바로 구동해 보실 수 잇어요. 구동하는 방법은 따로 적지 않고, github에 작성해볼게요. 


많은 분들에게 도움이 되길 바랍니다! ^-^




댓글