스프링 이미지 업로드(2)

2023. 11. 7. 20:16Springboot

1. Requirements

  • Spring boot version 2.4.5  

 2. Stacks

  Springboot

  Mybatis

  BootStrap

  • Loombook
  • Spring Data JPA 
  • Spring Boot DevTools
  • Spring Security
  • Spring Web
  • Spring boot version 2.4.5  

 

3. Code

       1. host.jsp

           - localhost:8080/image/host으로 접속했을 때 나오는 첫 페이지(이는 임시이며 바뀔 예정)

           - 해당 밑에 있는 코드는 인증된 정보에 접근하는 방법이며 필자는 모든 jsp파일의 header.jsp에

             적어줌으로써 다른 jsp파일은 안 적혀있습니다.

          - ${principal}에 해당하는 변수를 사용할 시 꼭 필요합니다.

          - 해당 jsp는 form형태를 post방식으로 보냅니다.

          - 해당 페이지는 Springboot package com.pjh.company.config부분에 void configure(HttpSecurity http)에

            http.authorizeRequest(). antMatchers("/host.**")를 통해 로그인이 되지 않으면 접근 불가능하게 설계되어 있습니다.

          - 해당 페이지는 버튼을 누르면 모달이 켜지는 방식으로 설계되어 있습니다.

          - 이는 추후에 업로드를 버튼을 누르면 db 저장되어 있는 정보를 카드형식으로 출력될 예정입니다.

          - <style></style> 관련하여 추후 변경 예정입니다.

          - img태그의 onerror에 해당하는 jpg파일은 커스텀하여 만든 이미지 파일입니다.

            이에 defaultFileUploadPicture.jpg는 직접 설정해줘야 하며 src/main/resources/static/images 폴더 밑에 넣어줘야 합니다.

          - 해당 이미지 업로드 버튼, Details버튼을 누를 시에 모달창이 비활성화되는 오류가 있었는데, 

            해당 오류 상황에 대한 해결방법을 링크로 올려놓겠습니다.

            https://stackoverflow.com/questions/10636667/bootstrap-modal-appearing-under-background

 

Bootstrap modal appearing under background

I have used the code for my modal straight from the Bootstrap example, and have included only the bootstrap.js (and not bootstrap-modal.js). However, my modal is appearing underneath the grey fade (

stackoverflow.com

 

<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%>
<%@ taglib prefix="sec" uri="http://www.springframework.org/security/tags"%>
<sec:authorize access="isAuthenticated()">
	<!--인증된 정보에 대한 접근 방법-->
	<sec:authentication property="principal" var="principal"/>
</sec:authorize>

<!DOCTYPE html>
<html lang="en">

<head>
    
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>pppp</title>
    <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.1/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-4bw+/aepP/YC94hEpVNVgiZdgIC5+VKNBQNGCHeKRQN+PtmoHDEXuppvnDJzQIu9" crossorigin="anonymous">
    <link rel="shortcut icon" sizes="76x76" type="image/x-icon"
        href="https://a0.muscache.com/airbnb/static/logotype_favicon-21cc8e6c6a2cca43f061d2dcabdf6e58.ico">
</head>

<style>
    .modal {
        display: none;
        z-index : 1050;
        left: 0;
        top: 0;
        width: 100%;
        height: 100%;
        overflow: auto;
        background-color: rgba(0, 0, 0, 0.4);
    }

    .modal-content {
        /*position: absolute;*/
        left: 50%;
        top: 50%;
        transform: translate(-50%, -50%);
    }

    .modal-dialog {
        /*position: absolute; 화면은 얘떄문에*/
        left: 50%;
        top: 50%;
        transform: translate(-50%, -50%);
        margin: 0; /* Remove any margin for centering */
    }

    .modal-backdrop {
        z-index: -1;
    }
    
    .imageBox {
        width: 300px;
        height: 200px;
    }

    .popularContainer {
        margin-top: 20px;
    }

    .card-body {
        display: flex;
        flex-direction: column;
        align-items: center;
        text-align: center;
    }

    .card-body img {
        width: 300px !important;
        height: 300px !important;
        object-fit: contain;
        margin-top: 10px;
        margin-bottom: 20px;
    }
    
    .card-body button {
        width: 100%;
        border: 1px solid black;
        padding: 5px 10px;
        border-radius: 5px;
        margin-top: 10px;
    }

    .row {
        margin-right: 0;
        margin-left: 0;
    }

    .col-md-4 {
        padding-right: 15px;
        padding-left: 15px;
    }
</style>

<body>
    <!--principalId 담아두는 곳-->
	<input type="hidden" id="principalId" value="${principal.user.id}">
    <!--principalId는 이후에 layout header에 모두 넣을 예정-->
    <button id="openModal">파일 업로드 모달 열기</button>
    <div class="popularContainer">
        <!-- 인기게시글 갤러리(GRID배치) -->
        <div class="popularGallery">
            <div class="row">
                <c:forEach var="image" items="${images}">
                    <div class="col-md-4 mb-4"> <!-- Add a margin at the bottom and right of the column -->
                        <div class="card">
                            <div class="card-body">
                                <h5 class="card-title">Title: ${image.title}</h5>
                                <p class="card-text">Description: ${image.description}</p>
                                <img class="card-img-top" 
                                src="/upload/${image.postImageUrl}" 
                                onerror="this.src='/images/defaultFIleUploadPicture.png'" alt="Image">
                                <button type="button" class="btn btn-outline-white" onclick="openImageDetailsModal('${image.id}','${image.user.username}');">Details</button>
                                <button class="btn btn-outline-white" onclick="openImageDetailsModal('${image.id}','${image.user.username}');">Test</button>
                                <button class="btn btn-outline-white" onclick="openImageDetailsModal('${image.id}','${image.user.username}');">Test</button>
                            </div>
                        </div>
                    </div>
                </c:forEach>
            </div>
        </div>
    </div>
    <!-- 이미지 업로드 모달 -->
    <div class="modal fade" id="imageUploadModal" tabindex="-1" aria-labelledby="exampleModalLabel" aria-hidden="true" data-backdrop="static">
        <div class="modal-dialog" style="position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); width: 50%;">
            <div class="modal-content">
                <div class="modal-header">
                    <h1 class="modal-title fs-5" id="imageUploadModalLabel">Image Upload</h1>
                    <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
                </div>
                <form id="imageUploadForm" action="/image/host/${principal.user.id}" method="post" enctype="multipart/form-data">
                    <div class="modal-body">
                        <div class="mb-3">
                            <label for="title" class="col-form-label">Title:</label>
                         </div>
                        <div class="mb-3">
                            <label for="description" class="col-form-label">Description:</label>
                            <textarea name="description" id="description"></textarea>
                        </div>
                        <div class="mb-3">
                            <label for="file" class="col-form-label">Select Image:</label>
                            <input type="file" name="file" id="file">
                        </div>
                    </div>
                    <div class="modal-footer">
                        <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
                        <button type="submit" class="btn btn-primary">Upload</button>
                    </div>
                </form>
            </div>
        </div>
    </div>
    <!--이미지 업로드 모달 끝-->

    <!-- 이미지 정보 모달 -->
    <div class="modal fade" id="imageDetailsModal" tabindex="-1" aria-labelledby="exampleModalLabel" aria-hidden="true" data-backdrop="static" data-keyboard="false">
        <div class="modal-dialog" style="position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); width: 50%;">
            <div class="modal-content">
                <div class="modal-header">
                    <h1 class="modal-title fs-5" id="imageDetailsModalLabel">Image Details</h1>
                    <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
                </div>
                <div class="modal-body">
                    <div class="mb-3">
                        <label for="imageUsername" class="col-form-label">Username : </label>
                        <span id="imageUsername"></span>
                    </div>  
                    <div class="mb-3">
                        <label for="imageTitle" class="col-form-label" >Title : </label>
                        <span id="imageTitle"></span>
                    </div>
                    <div class="mb-3">
                        <label for="imageDescription" class="col-form-label">Description :</label>
                        <span id="imageDescription"></span>
                    </div>
                </div>
                <div class="modal-footer">
                    <button type="button" class="btn btn-secondary" onclick="closeImageDetailsModal();">Close</button>
                </div>
            </div>
        </div>
    </div>
    <!-- 이미지 정보 모달 끝-->
</body>
</html>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.1/dist/js/bootstrap.bundle.min.js"></script>
<script src="/js/host.js" ></script>

 

       2. host.js

           - javascript에서 받은 form형태를 fetch의 비동기 통신을 통하여 post방법으로 controller로 보냅니다.

           - 해당 $. fetch({}) 안에 존재하는 url부분은 홑따옴표('')가 아닌 빽틱(``)입니다. 

           - openImageDetailsModal() 함수는 이미지의 상세 보기 함수입니다.

$(document).ready(function () {
    // HTML 문서를 로드할 때마다 실행합니다.
    const principalId = $('#principalId').val();
    console.log("principalId : ", principalId);
	
    // 파일 업로드 모달 열기
    $("#openModal").click(function () {
        $("#imageUploadModal").modal("show");
    });

    // 모달이 숨겨질 때 form 초기화
    $("#imageUploadModal").on("hidden.bs.modal", function () {
        $("#imageUploadForm")[0].reset();
    });

    // 파일 업로드 폼 제출 시
    $("#imageUploadForm").submit(function (e) {
        e.preventDefault(); // 폼 기본 동작 방지

        // FormData 생성
        var formData = new FormData(this);

        // POST 요청 보내기
        $.ajax({
            type: "POST",
            url: `/image/host/${principalId}`,
            data: formData,
            processData: false,
            contentType: false,
            success: function (data) {
                // 업로드 성공 시 실행할 코드
                console.log("Upload successful", data);
                $("#imageUploadModal").modal("hide"); // 모달 닫기
            },
            error: function (error) {
                // 업로드 실패 시 실행할 코드
                console.error("Upload failed", error);
            },
        });
    });

    // ... (나머지 코드 생략)
});

// 여기는 잘 되니까 냅둠
function openImageDetailsModal(imageId, username) {
	console.log("도달 잘하는지?");
    // 이미지 ID를 문자열로 전달
    console.log("imageid : ", imageId);
    console.log("username : ", username);

    // ajax통신은 안되고 fetch수행은 됨.
    // 이유는 잘모르겠음 cross-origin?일수도
    fetch(`/api/user/${imageId}/host`)
        .then(response => {
            if (response.ok) {
                return response.json(); // JSON 데이터를 파싱
            }
            throw new Error("Network response was not ok");
        })
        .then(data => {
            console.log("Data received:", data);
            $('#imageUsername').text(username);
            $('#imageTitle').text(data.data.title);
            $('#imageDescription').text(data.data.description);
            $('#imageDetailsModal').modal('show');
        })
        .catch(error => {
            alert("해당사항 접근 권한 제어 또는 json반환데이터 오류");
            // 추후 변경예정
            console.error("Error:", error);
        });
}

// 모달을 닫기 위한 코드
function closeImageDetailsModal() {
    $('#imageDetailsModal').modal('hide');
}

 

      3. application.yml

          - springboot를 실행할 때 기본적으로 필요한 요소를 적음

          - datasource는 mariadb를 직접 지정해야 사용할 수 있음

          - 변경점 없음

server:
  port: 8080(자신이 7070으로 설정했다면 localhost:7070으로 들어가야함)				
  servlet:
    context-path: /
    encoding:
      charset: utf-8
      enabled: true
    
spring:
  mvc:
    view:
      prefix: (src/main밑에 있는 폴더명에 저장)
      suffix: .jsp
      
  datasource:
    driver-class-name: org.mariadb.jdbc.Driver
    url: jdbc:mariadb://localhost:(mybatis 연결포트)/(자신이 연결한 데이터베이스 이름)?serverTimezone=Asia/Seoul
    username: (자신이 만든 아이디)
    password: (자신이 만든 비밀번호)
    
  jpa:
    open-in-view: true
    hibernate:
      ddl-auto: update
      naming:
        physical-strategy: org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl
    show-sql: true
      
  servlet:
    multipart:
      enabled: true
      max-file-size: 2MB

  security:
    user:
      name: test
      password: 1234

   

 

 4. ImageController.java

          - ImageController.java에서! imageUploadDto.getFile(). isEmpty()일 때 

          - 오류상황을 표기하기 위해 exception처리를 합니다.

          - showImageList(Model) : 모든 사진리스트의 출력을 위해 만든 메서드입니다.

          - model.addAtrribute(String, List <Image>)의 파라미터 String은 jsp에서 받는 파라미터의 이름과 일치해야 합니다.

          - imageUpload(ImageUploadDto, PrincipalDetails) : 모든 사진리스트의 출력을 위해 만든 메서드입니다.

          - 오류상황을 표기하기 위해 exception처리를 합니다.

package com.pjh.web;

import java.util.List;

import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;

import com.pjh.config.auth.PrincipalDetails;
import com.pjh.domain.image.Image;
import com.pjh.handler.ex.CustomValidationException;
import com.pjh.service.ImageService;
import com.pjh.web.dto.image.ImageUploadDto;

import lombok.RequiredArgsConstructor;

@RequiredArgsConstructor
@Controller
public class ImageController {
	
	private final ImageService imageService;
	
	@GetMapping("/image/host")
	public String showImageList(
			Model model) {
		List<Image> images = imageService.사진리스트();
		model.addAttribute("images", images);
		return "/image/host";
		// userController에서 관리할 필요가 없음
	}
	@PostMapping("/image/host/{principalId}")
	public String imageUpload(ImageUploadDto imageUploadDto,
			@AuthenticationPrincipal PrincipalDetails principalDetails) {
		// 호스트 페이지의 이미지 첨부
		// 추후 변경 예정.. 객체의 역할에 부합하지 않음.
		if(imageUploadDto.getFile().isEmpty()) {
			throw new CustomValidationException("이미지가 첨부되지 않았습니다.", null);
		}
		int principalId = principalDetails.getUser().getId();
		imageService.사진업로드(imageUploadDto, principalDetails);
		
		return "redirect:/image/host";
		// 파일을 리턴하기 때문에 apiContrller메 사용하지 않음.
	}
}

 

 

 5. CustomValidationException.java

          - ImageController.java에서! imageUploadDto.getFile(). isEmpty()일 때 

            오류상황을 표기하기 위해 exception처리를 합니다.

package com.pjh.handler.ex;

import java.util.Map;

public class CustomValidationException extends RuntimeException{
	
	// 객체 구분 할때
	private static final long serialVersionUID = 1L;
	
	private Map<String, String> errorMap;
	
	public CustomValidationException(String message, Map<String,String> errorMap){
		super(message);
		this.errorMap = errorMap;
	}
	
	
	public Map<String, String> getErrorMap(){
		return this.errorMap;
	}
}

 

  6. ControllerExceptionHandler.java

             - 모든 Exception을 처리해 주는 핸들러입니다.

             - CustomValidationException을 새로 정의했기에 새롭게 만들어줍니다.

package com.pjh.handler;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestController;

import com.pjh.handler.ex.CustomApiException;
import com.pjh.handler.ex.CustomValidationApiException;
import com.pjh.handler.ex.CustomValidationException;
import com.pjh.util.Script;
import com.pjh.web.dto.CMRespDto;

@RestController				// 데이터 리턴
@ControllerAdvice			// 모든 exception을 낚아챔
public class ControllerExceptionHandler {
	
	@ExceptionHandler(CustomValidationException.class)
	// RuntimeException이 발생하면 모든 페이지가 낚아챔
	public String validationException(CustomValidationException e) {
		return Script.back(e.getErrorMap().toString());
		
		//return new CMRespDto(-1, e.getMessage(), e.getErrorMap());
		// 제네릭 타입 리턴은 <?>가 편하다
		// 응답에 실패했고 에러메세지와 각각의 fieldError와 defaultMessage 리턴
	}
	
	@ExceptionHandler(CustomValidationApiException.class)
	public ResponseEntity<?> validationApiException(CustomValidationApiException e){
		return new ResponseEntity<>(new CMRespDto<>(-1, e.getMessage(), e.getErrorMap()),HttpStatus.BAD_REQUEST);
	}
	
	@ExceptionHandler(CustomApiException.class)
	public ResponseEntity<?> apiException(CustomApiException e){
		return new ResponseEntity<>(new CMRespDto<>(-1, e.getMessage(),null), HttpStatus.BAD_REQUEST);
	}
}

 

       7. CMRespDto.java

             - 해당 파일은 공통적으로 발생하는 응답에 대한 dto입니다.

            - 변경점 없음

package com.pjh.comapny.web.dto;

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

@AllArgsConstructor
@NoArgsConstructor
@Data
public class CMRespDto<T> {
	private int code;	// 1(성공), -1(실패)
	private String message;
	private T data;
}

        

    8. ImageUploadDto.java

             - host.jsp에서 form형식에 담긴 title, description, multipartfile을 처리하기 위해 만들어진 dto입니다.

package com.pjh.web.dto.image;

import org.springframework.web.multipart.MultipartFile;

import com.pjh.domain.image.Image;
import com.pjh.domain.user.User;

import lombok.Data;

@Data
public class ImageUploadDto {
	
	private MultipartFile file;
	
	private String title;
	private String description;
	private String postImageUrl;
	
	
	public Image toEntity(String postImageUrl, User user) {
		return Image.builder()
				.title(title)
				.description(description)
				.postImageUrl(postImageUrl)		// uuid_getFile().getOriginalFileName()
				.user(user)
				.build();
	}
}

 

        9. ImageService.java

             - @Service 비즈니스 로직을 구현을 위해 생성하는 service 어노테이션입니다.
             - @Transactional DB에 트랜잭션(일의 최소 단위) 처리를 springboot에서 구현한 형태로 수행할 수 있게 합니다.
             - UUID를 지정하여 같은 이미지 파일의 업로드 중복을 엄청나게 높은 확률로 제거합니다.

               ex) 1.jpg가 업로드가 중복될 수 있기에 UUID.getRandom()를 활용하여

                   e5 ab7114-de57-4ae5-9963-93351602 bb72_1.jpg로 저장하게 만듦.

 

package com.pjh.service;

import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.UUID;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import com.pjh.config.auth.PrincipalDetails;
import com.pjh.domain.image.Image;
import com.pjh.domain.image.ImageRepository;
import com.pjh.web.dto.image.ImageUploadDto;

import lombok.RequiredArgsConstructor;

@RequiredArgsConstructor			// DI
@Service
public class ImageService {
	
	private final ImageRepository imageRepository;
	
	@Value("${file.path}")
	private String uploadFolder;
	// application.yml에 해당하는 file : path :로 들어옴
	
	public void 사진업로드(ImageUploadDto imageUploadDto, PrincipalDetails principalDetails) {
		UUID uuid = UUID.randomUUID();
		String imageFileName = uuid+"_"+imageUploadDto.getFile().getOriginalFilename();
		// 업로드하려는 파일 네임이 String으로 들어옴
		
		System.out.println("이미지 파일이름 : "+imageFileName);
		Path imageFilePath = Paths.get(uploadFolder+imageFileName);
		// 실제 저장 장소
		
		try {
			Files.write(imageFilePath, imageUploadDto.getFile().getBytes());
		}catch(Exception e) {
			e.printStackTrace();
		}
		// 통신이 일어나거나 I/O이 일어날때 -> 예외가 발생할 수 있음.
		
		Image image = imageUploadDto.toEntity(imageFileName, principalDetails.getUser());
		// toEntity() -> db에 해당 toEntity화한 imageFileName, princiaplDetails.getUser()를 저장하겠다.
		// db에 업로드를 진행한 유저아이디 + 업로드한 파일을 저장
		imageRepository.save(image);
		
		// System.out.println(imageEntity.toString());
		// 무한 참조 조심, User객체에 Image -> Image객체에 User 
		
		
	}
}

       

    10. ImageRepository

            - DB와 직접 맞닿아 있는 인터페이스입니다.

            - DB에 처리할 SQL문을 직접 사용할 수 있고 extends 하여 JpaRepository의 crud형태의 기본적인 연산이 지원됩니다.

package com.pjh.domain.image;

import org.springframework.data.jpa.repository.JpaRepository;

public interface ImageRepository extends JpaRepository<Image, Integer>{

}

 

     11.WebMvcConfig.java

            - web에 대한 설정 파일

            - @Value("${}")에 해당하는 파라미터는 ImageService.java와 동일해야 하며,

              application.yml의 파일경로가 존재해야 하고 경로 안에 해당 폴더가 있어야 함.

            - jsp파일에서 /upload/**로 시작하는 img src문이 있다면 함수 호출이 됨.

            - setCachePeriod(60*10*6) 1시간 캐시 

package com.pjh.config;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import org.springframework.web.servlet.resource.PathResourceResolver;

@Configuration
public class WebMvcConfig implements WebMvcConfigurer{	// web설정파일
	
	@Value("${file.path}")
	private String uploadFolder;
	
	@Override
		public void addResourceHandlers(ResourceHandlerRegistry registry) {
			WebMvcConfigurer.super.addResourceHandlers(registry);
			
			registry
			.addResourceHandler("/upload/**")				// jsp페이지에서 /upload/** 패턴이 나오면 함수 호출
			.addResourceLocations("file:///"+uploadFolder)
			.setCachePeriod(60*10*6)						// 1시간 cache
			.resourceChain(true)
			.addResolver(new PathResourceResolver());
	}
	// 이거 없으면 이미지 onerror화면 출력
}

 

오류 해결

   1. 이미지 업로드 및 details 버튼을 클릭시에 모달 외부, 내부의 흐린한 화면 및 클릭 비활성화

       1) modal에 해당하는 div태그를 맨 밑으로 내린다.

       2) modal에 해당하는 style 속성(부모 속성 포함)을 position을 하나하나씩 주석처리하여 확인

       3) 이도 안되면 .modal-backdrop에 해당하는 z-index를 절대적으로 낮춘다.(bootstrap 환경만 사용)

    .modal-backdrop {
        z-index: -1;
    }

 

'Springboot' 카테고리의 다른 글

Spring boot의 인증 방식  (0) 2023.11.10
스프링 유저 프로필 사진  (5) 2023.11.09
스프링 메모장  (1) 2023.11.01
스프링 이미지 업로드(1)  (1) 2023.10.30
스프링 회원 수정  (1) 2023.10.23