개인 피드백/백엔드

이미지 업로드 기능 - 프로필 이미지

개발자의 첫 걸음 2025. 12. 24. 18:00

1. yml 추가

spring:
  servlet:
    multipart:
      enabled: true           # 파일 업로드 활성화
      max-file-size: 10MB     # 개별 파일 최대 크기
      max-request-size: 10MB  # 전체 요청 최대 크기

 

2. User 엔티티에 컬럼 추가 (사용자 프로필 파일 이름)

public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(unique = true)
    private String username;
    private String password;
    private String email;

    @CreationTimestamp
    private Timestamp createdAt;

    //@Column(nullable = false)
    private String profileImage;
    
    ...

 

2-1. h2-console확

 

3. join-form.mustach

{{> layout/header}}

<div class="container p-5">
    <!--
        파일 업로드를 위한 form 태그 설정
        enctype="multipart/form-data : 파일 형식을 포함한 MIME 타입을 의미한다
        즉, 이 속성 없이는 파일이 서버로 전송되지 않는다 !!!
        일반 텍스트 데이터는 - application/x-www-form-urlencoded
        파일 데이터는 - multipart/form-data 필요 하다.
    -->
    <div class="card">
        <div class="card-header"><b>회원가입을 해주세요</b></div>
        <div class="card-body">
            <form action="/join" method="post" enctype="multipart/form-data">
                <div class="mb-3">
                    <input type="text" class="form-control" placeholder="Enter username" name="username">
                </div>
                <div class="mb-3">
                    <input type="password" class="form-control" placeholder="Enter password" name="password">
                </div>
                <div class="mb-3">
                    <input type="email" class="form-control" placeholder="Enter email" name="email">
                </div>
                <div class="mb-3">
                   <label for="profileImage" class="form-label">프로필 사진(선택사항)</label>
                   <input type="file" class="form-control" id="profileImage" name="profileImage" accept="image/*">
                   <small class="form-text text-muted mt-1">이미지 파일만 업로드 가능합니다(JPG, PNG, GIF)</small>
                </div>
                <button type="submit" class="btn btn-primary form-control">회원가입</button>
            </form>
        </div>
    </div>
</div>

{{> layout/footer}}

 

 

3-1. joinProc 확인

    // 회원가입 기능 요청
    // http://localhost:8080/join
    @PostMapping("/join")
    public String joinProc(UserRequest.JoinDTO joinDTO) {
        joinDTO.validate();
        userService.회원가입(joinDTO);
        return "redirect:/login";
    }

 

 

4. UserRequest.JoinDTO 에서 MultipartFile 멤버 할당 확인

    @Data
    public static class JoinDTO {
        private String username;
        private String password;
        private String email;
        // MultipartFile - Spring 에서 파일 업로드를 처리하기 위한 인터페이스
        // 우리 프로젝트에서는 선택 사항이라 회원 가입시 null 또는 empty 상태가 될 수 있음
        private MultipartFile profileImage;
        
        public void validate() {
            if(username == null  || username.trim().isEmpty()) {
                throw new IllegalArgumentException("사용자명을 입력해주세요");
            }
            if(password == null || password.trim().isEmpty()) {
                throw new IllegalArgumentException("비밀번호를 입력해주세요");
            }
            if(email == null || email.trim().isEmpty()) {
                throw new IllegalArgumentException("이메일을 입력해주세요");
            }
            if(email.contains("@") == false) {
                throw new IllegalArgumentException("올바른 이메일 형식이 아닙니다");
            }
        }

        // JoinDTO 를 User 타입으로 변환 시키는 기능
        public User toEntity(String profileImageFileName) {
            return User.builder()
                    .username(this.username)
                    .password(this.password)
                    .email(this.email)
                    // DB에 MultipartFile 를 저장할 수 없다 (파일 이름만 저장할 예정)
                    .profileImage(profileImageFileName)
                    .build();
        }

    }  // end of inner class

 

 

5. User 엔티티 생성자 수정 - profileImage 생성자에 설계

package org.example.demo_ssr_v1_1.user;

import jakarta.persistence.*;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.CreationTimestamp;

import java.sql.Timestamp;

// 엔티티 화면 보고 설계해 보세요.
@NoArgsConstructor
@Data
@Table(name = "user_tb")
@Entity
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(unique = true)
    private String username;
    private String password;
    private String email;

    @CreationTimestamp
    private Timestamp createdAt;

    //@Column(nullable = false)
    private String profileImage; // 추가 

    @Builder
    public User(Long id, String username, String password,
                String email, Timestamp createdAt, String profileImage) {
        this.id = id;
        this.username = username;
        this.password = password;
        this.email = email;
        this.createdAt = createdAt;
        this.profileImage = profileImage;  // 추가 
    }

    // 회원정보 수정 비즈니스 로직 추가
    // 추후 DTO  설계
    public void update(UserRequest.UpdateDTO updateDTO) {
        // 유효성 검사
        updateDTO.validate();
        this.password = updateDTO.getPassword();
        // 더티 체킹 (변경 감지)
        // 트랜잭션이 끝나면 자동으로 update 쿼리 진행
    }

    // 회원 정보 소유자 확인 로직
    public boolean isOwner(Long userId) {
        return this.id.equals(userId);
    }

}

 

 

FileUtil 유틸 클래스 만들어서 나중에 재활용 하기

package org.example.demo_ssr_v1_1._core.utils;

import org.springframework.web.multipart.MultipartFile;

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

// 이녀석은 IoC 대상 아님 static 메서드로 만들 예정
public class FileUtil {

    // 프로젝트 루트 폴더 아래에 images/ 폴더를 생성할 예정 (프로필 이미지만 넣을 예정)
    public static final String IMAGES_DIR = "images/";

    private static String saveFile(MultipartFile file) throws IOException {
        return saveFile(file, IMAGES_DIR);
    }

    public static String saveFile(MultipartFile file, String uploadDir) throws IOException {
        // 1. 유효성 검사
        if (file == null || file.isEmpty()) {
            return null; // 파일이 없으면 null (왜 선택사항이므로 에러 아님)
        }

        // 2. 업로드 디렉토리 생성
        // Path : 파일 시스템 경로를 나타내는 객체
        // paths.get() : 문자열 경로를 Path 객체로 변환 시켜주는 메서드
        Path uploadPath = Paths.get(IMAGES_DIR); // new Path() 내부에서
        // 디렉토리가 있으면 새로운 폴더를 생성하지 않고 없으면 자동 생성
        if (!Files.exists(uploadPath)) {
            // 디렉토리 생성인데 상위 까지 알아서 다 만들어 줌
            // Root/Image/user/a/aaa.png
            Files.createDirectories(uploadPath);
        }

        // 3. 원본 파일명 가져오기
        // getOriginalFilename() -> 사용자가 입력한 파일 이름
        String originalFilename = file.getOriginalFilename();
        if(originalFilename == null || originalFilename.isEmpty()) {
            throw  new IOException("파일명이 없습니다");
        }

        // 4. UUID를 사용한 고유한 파일명 생성
        // 왜 UUID 를 사용하나요?
        // 사용자들은 같은 이름에 파일명을 서버에 저장 시키고자 할 수 있다. 그럼 원본 파일 사라짐
        String uuid = UUID.randomUUID().toString(); // 12kjhsdkjfhas934-sadkjhasdfk
        String savedFileName = uuid + "_" + originalFilename;
        // 결과 예시 : 123123asdf_abc.png

        // 5. 파일을 디스크(물리적 저장 장치)에 저장
        Path filePath = uploadPath.resolve(savedFileName);

        // 실제 파일 생성
        Files.copy(file.getInputStream(), filePath);

        return savedFileName;
    }

    // 유효성 검사 기능
    public static boolean isImageFile(MultipartFile file) {
        // 파일 이미지가 없으면 이미지가 아님
        if(file == null || file.isEmpty()) {
            return false;
        }
        // Content-Type 가져오기
        // 예시 : "image/jpg", "image/png", "image/gif"
        String contentType = file.getContentType();
        // Content-Type 가 image/ 로 시작하는지 확인
        return contentType != null && contentType.startsWith("image/");
    }

}

 

UserService

    @Transactional
    public User 회원가입(UserRequest.JoinDTO joinDTO) {

        // 1. 사용자명 중복 체크
        if(userRepository.findByUsername(joinDTO.getUsername()).isPresent()) {
          // isPresent -> 있으면 true 반환 , 없으면 false 반환
          throw new Exception400("이미 존재하는 사용자 이름입니다");
        }

        // User 엔티티에 저장할 때는 String 이어야 하고 null 값도 가질 수 있음
        String profileImageFileName = null;

        // 2. 회원 가입시 파일이 넘어 왔는 확인
        if(joinDTO.getProfileImage() != null) {

            // 2.1 유효성 검사 (이미지 파일 이어야 함)
            try {
                if(!FileUtil.isImageFile(joinDTO.getProfileImage())) {
                    throw new Exception400("이미지 파일만 업로드 가능합니다");
                }
                profileImageFileName = FileUtil.saveFile(joinDTO.getProfileImage());
            } catch (Exception e) {
                throw new RuntimeException(e);
            }

        }

        User user = joinDTO.toEntity(profileImageFileName);
        return userRepository.save(user);
    }

 

결과 확인