Web/Spring

[Spring/스프링] 스프링 부트 파일 업로드 및 화면 출력

poopooreum 2024. 9. 5. 17:33
반응형

안녕하세요!

오늘은 스프링 부트에서 이미지나 파일을 업로드하고 화면에 출력하는 방법에 대해 업로드해보겠습니다

스프링 부트에서 파일 업로드 전송 방식에는 두 가지 방식이 있는데요, 아래에서 천천히 살펴보겠습니다

 

파일 업로드 전송 방식

파일 업로드 전송 방식은 크게 다음과 같이 2가지로 나뉩니다.

1. HTML Form 전송

2. HTTP API 전송

 

HTML Form 전송 방식

1. application/x-www-form-urlencoded

이 방법은 저희가 흔히 form에서 데이터를 전송하는 방식입니다. 로그인이나 회원가입이나 등등 보통 form전송은 이 방식으로 전달이 됩니다.Form 태그에 별도의 설정을 하지 않았으면 Content-Type이 기본값으로 설정이 되고 HTTP Body에 쿼리 파라미터 형식으로 보내지게 됩니다. 즉 이 방식은 문자를 전송하는 방식입니다. 그러나 파일을 업로드하기 위해서는 문자가 아닌 바이너리 데이터를 전송해야 합니다. 보통 사진을 업로드할 때 파일만 전송하는 것이 아닌 제목이나 내용이나 등등 보통의 문자들까지 전송을 하는 경우가 많습니다. 그래서 파일 업로드를 할 때는 밑에 방법을 사용하게 됩니다.

 

2. multipart/form-data

이 방식을 사용하려면 Form 태그에 다음과 같이 enctype="multipart/form-data" 를 설정합니다. 이제 여기서 등록 버튼을 누르면 Post 형식으로 제출이 되는데, 여러 파일과 내용들을 함께 전송할 수 있어서 multipart라고 불립니다.

파일 업로드 전송 방식을 알아봤으니 이제는 직접 업로드를 해봐야겠죠?

 

 

 

 

main.html

<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <meta name="viewport"
          content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>상품 등록 페이지</title>
</head>
<body>
<form method="post" action="/product/register" enctype="multipart/form-data">
    <table class="table">
        <tr><td>판매자: <label>
            <span th:text="${id}"></span>님
        </label></td></tr>
        <tr><td>상품 이름: <label>
            <input type="text" name="name" required/>
        </label></td></tr>
        <tr><td>가격 : <label>
            <input type="text" name="price" required/>
        </label></td></tr>
        <tr><td>세일 여부: <label>
            <input type="number" name="isSale" required placeholder="세일 중이라면 1, 아니라면 0을 입력해주세요."/>
        </label></td> </tr>
        <tr><td>재고: <label>
            <input type="number" name="stock" required/>
        </label></td> </tr>
        <tr><td>사진: <label>
            <input type="file" name="imgFile" multiple="multiple" required/>
        </label></td> </tr>
    </table>
    <input type="submit" value="가입하기" />
    <a href="http://localhost:8080">돌아가기</a>
</form>
</body>
</html>

 

이제부터가 핵심적인 부분입니다.

우선 데이터들을 담아올 DTO가 필요하므로 ProductDTO를 생성해줍니다.

package com.example.shopping.dto;

import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
public class ProductDTO {

    private Long isSale;
    private Long likeCount;
    private Long price;
    private Integer stock;
    private String photoUrl;
    private String name;

}

그리고 다음은 Product엔티티입니다.

파일 업로드에 관한 글이므로 스프링 부트 구조나, JPA, 데이터베이스들에 대한 설명은 생략하겠습니다.

package com.example.shopping.domain;

import jakarta.persistence.*;
import lombok.*;

@Getter
@Setter
@Entity
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "Product", schema = "shop")
public class Product {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "idx", nullable = false)
    private Integer id;

    @Column(name = "name", nullable = false)
    private String name;

    @Column(name = "userId", nullable = false, length = 20)
    private String userId;

    @Column(name = "price", nullable = false)
    private Long price;

    @Column(name = "isSoldOut", nullable = false)
    private Integer isSoldOut;

    @Column(name = "stock", nullable = false)
    private Integer stock;

    @Column(name = "likeCount", nullable = false)
    private Integer likeCount;

    @Column(name = "isSale", nullable = false)
    private Integer isSale;

    @Column(name = "photoName", nullable = false)
    private String photoName;

    @Column(name = "photoPath", nullable = false)
    private String photoPath;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "userIdx", nullable = false)
    private User userIdx;

}

여기서 잘 살펴보면 name이 있고, photoName이 있는데 name은 사용자가 입력하는 상품이름이고 photoName은 컴퓨터가 저장하게 되는 사진이름입니다. 서로 다른 객체를 만들기 위해서 파일들을 구분해준다고 생각하시면 될 것 같습니다.

 

그 다음은 컨트롤러입니다.
컨트롤러여서 비즈니스 로직은 담겨 있지 않습니다. 이 다음에 나오는 서비스 부분이 중요합니다.

@PostMapping("/product/register")
public String productRegister(ProductDTO productDTO, MultipartFile imgFile) throws IOException {


    // 존재하는 유저인지 검사
    String id = (String) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
    boolean isUserExist = userService.checkUserExist(id);
    if(!isUserExist) {
        log.error("존재하지 않는 유저입니다. 회원가입을 해주세요");
        return "redirect:/auth/signup";
    }

    // 판매자인지 검사
    boolean isSeller = userService.checkSeller(id);
    if(isSeller){
        log.error("판매자만 상품 등록을 할 수 있습니다.");
        return "redirect:/my";
    }


    productService.save(productDTO,imgFile);
    log.info("상품 등록 완료");
    return "redirect:/product/register";
}

 

public class ProductServiceImpl implements ProductService  {

    private final ProductRepository productRepository;
    private final UserRepository userRepository;

    @Override
    public void save(ProductDTO productDTO, MultipartFile imgFile) throws IOException {


        String oriImgName = imgFile.getOriginalFilename();
        String imgName = "";

        String projectPath = System.getProperty("user.dir") + "/src/main/resources/static/files/";

        // UUID 를 이용하여 파일명 새로 생성
        // UUID - 서로 다른 객체들을 구별하기 위한 클래스
        UUID uuid = UUID.randomUUID();

        String savedFileName = uuid + "_" + oriImgName; // 파일명 -> imgName

        imgName = savedFileName;

        File saveFile = new File(projectPath, imgName);

        imgFile.transferTo(saveFile);

        String id = SecurityContextHolder.getContext().getAuthentication().getPrincipal().toString();
        Optional<User> userOptional = userRepository.findById1(id);
        if(userOptional.isEmpty()){
            log.error("등록할 수 있는 유저가 아닙니다.");
            return;
        }
        User user = userOptional.get();
        Product product = Product.builder()
                .userIdx(user)
                .userId(user.getId1())
                .isSale(Math.toIntExact(productDTO.getIsSale()))
                .isSoldOut(0)
                .likeCount(0)
                .price(productDTO.getPrice())
                .stock(productDTO.getStock())
                .name(productDTO.getName())
                .photoName(imgName)
                .photoPath("/files/"+imgName)
                .build();

        productRepository.save(product);
    }

    @Override
    public List<Product> getProductList() {
        return productRepository.findAll();
    }
}

우선, 스프링에서는 MultipartFile이라는 것을 이용하여 편하게 파일을 다룰 수 있도록 인터페이스를 제공하고 있습니다.
imgName=""으로 설정을 한 후, 파일이 저장되는 위치를 path에 설정을 해줍니다.
위에서 객체를 구분하기 위해 파일명을 다르게 한다고 언급했었는데, UUID를 이용하여 파일명을 생성해줍니다. UUID는 현재 시간을 기준으로 랜덤값을 생성해주기 때문에 중복값이 생길 수가 없습니다.
파일명을 만들었으니, new File()을 통해 파일을 생성해줍니다. 그러면 이 단계에서 /resources/static/files라는 폴더가 생기고 안에 파일이 생성이 됩니다.

product에 저장이 된 모습입니다.

 

 

그러면 이제는 화면에 이미지를 출력해 보겠습니다.

메인 컨트롤러에서 상품리스트를 가져와서 html파일에 넘겨줍니다.

@GetMapping("/")
public String index(Model model) {
    Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
    String id = principal.toString();
    boolean isUserExist = userService.checkUserExist(id);
    List<Product>productList = productService.getProductList();
    if(isUserExist) {
        model.addAttribute("id",id);
    }
    else{
        model.addAttribute("id",null);
    }

    model.addAttribute("productList",productList);
    return "main";
}

 

<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <meta name="viewport"
          content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>메인 화면</title>
</head>
<body>
안녕하세요
<span th:if="${id==null}">
    <a href="http://localhost:8080/auth">로그인/회원가입하기</a>
    </span>
<span th:unless="${id==null}">
        <a href="http://localhost:8080/my">의 정보</a>
</span>
<table class="table">
    <thead class="thead-light">
    <tr class="text-center">
        <th scope="col"></th>
        <th scope="col">가격</th>
    </tr>
    </thead>
    <tbody>
    <tr th:each="product : ${productList}">
        <td>
            <div th:text="${product.name}" th:name="idx"></div>
            age
        </td>
        <td>
            <div th:text="${product.price}"></div>
        </td>
        <td>
            <img th:src="@{${product.getPhotoPath()}}" alt="사진">
<!--            <img th:src="${'file:///Users/pooreum/study/spring_study/shopping/src/main/resources/static' + product.getPhotoPath()}" alt="사진">수정하기-->
        </td>
    </tr>
    </tbody>
</table>
<form method="post" action="/my/logout">
    <input type="submit" name="로그아웃" value="로그아웃">
</form>
</body>
</html>

이미지 경로에 product의 path를 가져옵니다. 그리고 추가로 해줘야 하는 설정들이 있습니다.
설정을 안해줘도 이미지가 화면에 출력되기는 하지만, 한 가지 에러사항이 발생하게 됩니다.
상품을 등록하고 나서 메인화면으로 돌아가게 되면 상품의 이미지가 출력이 되지 않습니다. 그리고 서버를 재시작을 하면 화면에 이미지가 출력이 됩니다. 처음엔 문법 오류인가 싶어서 찾아보기도 하고 서버가 죽었나 싶어서 몇 번 재시작도 해봤는데 잘 안되더라구요 이것저것 검색해보다가

  • 새로운 이미지를 업로드 했을 때 서버를 재시작하지 않으면 페이지에 적용되지 않는 것" 에 대해 물어보는 글을 보고 로컬에 저장되는 정적 리소스는 서버를 재시작해야 적용된다는 것을 알게 되었습니다.
  • 사실 어찌보면 당연한 얘기인데 서버는 로컬 지역에서 무슨 이벤트가 발생하고 있는지 알지 못합니다. 즉 저렇게 등록을 한다고 한들 서버에는 반영이 되지 않는다는 얘기입니다.
  • 서버를 내렸다가 다시 올리면 그제서야 프로젝트 소스가 서버 배포 경로에 복사가 되면서 이미지를 불러올 수 있는 것이죠.

 

 

해결

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class WebSecurityConfig implements WebMvcConfigurer {

  ...
    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler("/**")
                .addResourceLocations("file:src/main/resources/static/");
    }
  ...
}
  • 서버를 자동으로 재시작하게 하거나, 정적 리소스 요청에 대해 기본 경로를 설정해주는 방법입니다.
  • 두 가지 정도를 찾아봤는데 방식은 아래와 같습니다.

 

pring-boot-devtools 라이브러리

  •  정적 리소스에 변화가 생기게 되면 얘가 알아서 서버를 재시작해주는 건데 빌드 속도도 느려지게 될 것 같고 이 하나의 에러 때문에        DI를 주입하는 것은 별로라고 생각했습니다.

 

ResourceHandler란?

  • 정적인 리소스에 대한 요청을 처리하는 핸들러
  • 정적 파일들의 경로를 잡아주는 메서드이다
  • addResourceHandler에 정의한 루트로 들어오는 모든 정적 리소스 요청을 addResourceLocations에서 정의한 경로에서 찾는다는 의미이다.

       

반응형