개인 피드백/백엔드

프로필 이미지 - 조회, 수정, 삭제 기능 추가

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

조회

 

1. application-dev.yml 파일 수정 (MySQL 로 방언처리)

server:
  servlet:
    encoding:
      charset: utf-8
      force: true
  port: 8080

# 2칸 공백, 탭키 절대 사용 금지!
# 로그 설정 (개발환경용)
# 로그 레벨의 개념
# ERROR > WARN > INFO > DEBUG > TRACE
logging:
  level:
    root: INFO           # 모든 라이브러리는 INFO 이상만 출력
    com.example: DEBUG   # 내 프로젝트는 DEBUG 이상 모두 출력

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

  # 데이터베이스 연결 설정 (H2 인메모리 데이터베이스)
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/myblog?useSSL=false&serverTimezone=Asia/Seoul&characterEncoding=UTF-8
    username: root
    password: root

  # H2 데이터베이스 웹 콘솔 활성화 (개발용)
  # localhost:8080/h2-console로 접속 가능
  h2:
    console:
      enabled: true

  # 초기 데이터 로딩 설정
  sql:
    init:
      # 2.5 이상 버전부터 명시를 해야 insert 처리 됨
      mode: never
      # 애플리케이션 시작시 실행할 SQL 파일 위치
      data-locations:
        - classpath:db/data.sql

  # JPA 설정
  jpa:
    hibernate:
      # create: 애플리케이션 시작시 테이블을 새로 생성
      # 기존 데이터는 모두 삭제됨 (개발용)
      ddl-auto: update
    # SQL 쿼리를 콘솔에 출력 (개발용 디버깅)
    show-sql: true
    properties:
      hibernate:
        # SQL 쿼리를 보기 좋게 포맷팅
        format_sql: true
        # N + 1 문제 해결: LAZY 로딩 시 연관된 엔티티를 배치로 한 번에 가져오기
        # 게시글 목록 조회 시 만약(username 필요하다면)
        # 배치로 묶어서 한 번에 가져옴 (IN 쿼리 사용 )
        default_batch_fetch_size: 100
    # data.sql 파일을 Hibernate 초기화 이후에 실행
    defer-datasource-initialization: true
    # Open Session in View 를 false 로 설정
    # - true (기본값) 뷰 렌더링 까지 세션 유지 (LAZY 로딩 가능)
    # - false : 트랜잭션 종료 시 세션 종료(LAZY 로딩 불가, 명시적 조회 필요(트리거))
    # - false 설정 시 Service 에서 필요한 데이터를 모두 조회 하고 DTO로 변환 해야 함
    open-in-view: false

 

2. UserController (마이페이지요청추가)

    // 마이페이지
    // http://localhost:8080/user/detail
    @GetMapping("/user/detail")
    public String detail(Model model, HttpSession session) {
        User sessionUser = (User) session.getAttribute("sessionUser");

        User user = userService.마이페이지(sessionUser.getId());

        model.addAttribute("user", user);
        return "user/detail";
    }

 

3. UserService (마이페이지 조회 기능 추가 )

    public User 마이페이지(Long sessionUserId) {

        User user = userRepository.findById(sessionUserId)
                .orElseThrow(() -> new Exception404("사용자를 찾을 수 없습니다"));

        // 인가 처리
        if(!user.isOwner(sessionUserId)) {
            throw new Exception403("권한이 없습니다");
        }
        return user;
    }

 

4. user/detail.mustache 파일 신규 추가

{{> layout/header}}

<div class="container p-5">
    <div class="card">
        <div class="card-header"><b>회원정보</b></div>
        <div class="card-body">
            <div class="text-center mb-4">
                {{#user.profileImage}}
                 {{!어 src 경로가 /images/ 이네... 스프링에 등록한 리소드 핸들러가 동작 함 }}
                 <img src="/images/{{user.profileImage}}" alt="프로필사진" class="rounded-circle border"
                         style="width: 200px;height: 200px; object-fit: cover">
                <div class="mt-2">
                    <form action="" method="post" style="display: inline">
                        <button type="submit" class="btn btn-sm btn-danger"
                            onclick="return confirm('프로필 사진을 삭제하시겠습니까?')">프로필삭제</button>
                    </form>
                </div>
                {{/user.profileImage}}
                {{^user.profileImage}}
                    <div class="rounded-circle bg-secondary d-inline-flex align-items-center justify-content-center border"
                         style="width: 200px; height: 200px;">
                        <span class="text-white fs-5">프로필 사진 없음</span>
                    </div>
                </div>
                {{/user.profileImage}}
        </div>
        <div class="mb-3">
            <label for="" class="form-label fw-bold">사용자명</label>
            <p class="form-control-plaintext">{{user.username}}</p>
        </div>
        <div class="mb-3">
            <label for="" class="form-label fw-bold">이메일</label>
            <p class="form-control-plaintext">{{user.email}}</p>
        </div>
        <div class="mb-3">
            <label for="" class="form-label fw-bold">가입일</label>
            <p class="form-control-plaintext">{{user.createdAt}}</p>
        </div>
        <div class="d-grid gap-2 mb-3">
            <a href="/user/update" class="btn btn-primary">회원정보수정화면</a>
            <a href="/board/list" class="btn btn-secondary">목록으로</a>
        </div>
        </div>
    </div>

    
</div>

{{> layout/footer}}
  • 핵심( 정적 리소스 핸들러 설정), WebMvcConfig 에 코드 추가
package org.example.demo_ssr_v1_1._core.config;


import lombok.RequiredArgsConstructor;
import org.example.demo_ssr_v1_1._core.interceptor.LoginInterceptor;
import org.example.demo_ssr_v1_1._core.interceptor.SessionInterceptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

/**
 * Spring MVC 설정 클래스
 * @C, @S, @R, @Com.., @Configuration
 */
// @Component 클래스 내부에서 @Bean 어노테이션을 사용해야 된다면 @Configuration 사용해야 한다.
@Configuration // 내부도 IoC 대상 여부 확인
@RequiredArgsConstructor
public class WebMvcConfig implements WebMvcConfigurer {

    // DI 처리
    private final LoginInterceptor loginInterceptor;
    private final SessionInterceptor sessionInterceptor;

    // ps. 인터셉터는 당연히 여러개 등록 가능 함...
    @Override
    public void addInterceptors(InterceptorRegistry registry) {

        registry.addInterceptor(sessionInterceptor)
                        .addPathPatterns("/**");


        // 1. 설정에 LoginInterceptor 를 등록하는 코드
        // 2. 인터셉터가 동작할 URL 패턴 지정
        // 3. 어떤 URL 요청이 로그인 여부를 필요할지 확인 해야 함.
        //    /board/** <-- 일단 이 엔드포인트 다 검사 시킬 꺼야
        //    /user/**  <-- 일단 이 엔드포인트 다 검사 시킬 꺼야
        //    -> 단, 특정 URL 은 제외 시킬꺼야
        registry.addInterceptor(loginInterceptor)
                // /** <-- 모든 URL 제외 대상이 됨. 일단 사용 안함
                .addPathPatterns("/board/**", "/user/**", "/reply/**")
                .excludePathPatterns(
                        "/login",
                        "/join",
                        "/logout",
                        "/board/list",
                        "/",
                        "/board/{id:\\d+}",
                        "/css/**",
                        "/js/**",
                        "/images/**",
                        "/favicon.io",
                        "/h2-console/**"
                );
                // ||d+ 는 정규표현식으로 1개 이상의 숫자를 의미한다. 
                // /board/1, board/1234 <-- 허용 
                // /board/abc 같은 경우 매칭 되지 않음
    }

    /**
     * 정적 리소스 핸들러
     * 업로드된 이미지 파일을 웹에서 접근할 수 있도록 설정합니다.
     */
    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {

        /**
         *  /images/** 경로로 요청이 들어오면 나의 폴더 images/ 디렉토리에서 찾게 설정합니다
         */
        // 머스태치 이미지 태그에 src 경로에 /images/** 같은 경로로 설정 되어 있다면
        // 스프링이 알아서 내 폴더 file:(프로젝트 루트 디렉토리)안에 images/ 폴더를 찾게 한다.
        registry.addResourceHandler("/images/**")
                .addResourceLocations("file:///D:/uploads/");
        //** file:/// 문법 설명
        // file: 파일 시스템을 가리킨다 의미이다.
        // 파일 시스템에서 절대 경로 를 의미하는 URI 표기법은 -> ///: 이다.
        // file:images/  앞에 슬러시가 없기 때문에 상대 경로를 의미한다.
        // file:///D:upload/ <-- 내 컴퓨터 절대 경로를 의미한다.
    }
}

 

 

수정

 

1. 회원 정보 수정 화면에 enctype="multipart/form-data" 타입 반드시 확인

{{> layout/header}}
<div class="container p-5">

    <!-- 요청을 하면 localhost:8080/join POST로 요청됨
    username=사용자입력값&password=사용자값&email=사용자입력값 -->

    <div class="card">
        <div class="card-header"><b>회원수정을 해주세요</b></div>
        <div class="card-body">

        <div class="text-center mb-4">
            {{#user.profileImage}}
            {{!어 src 경로가 /images/ 이네... 스프링에 등록한 리소드 핸들러가 동작 함 }}
                <img src="/images/{{user.profileImage}}" alt="프로필사진" class="rounded-circle border"
                     style="width: 200px;height: 200px; object-fit: cover">
            {{/user.profileImage}}
            {{^user.profileImage}}
                <div class="rounded-circle bg-secondary d-inline-flex align-items-center justify-content-center border"
                     style="width: 200px; height: 200px;">
                    <span class="text-white fs-5">프로필 사진 없음</span>
                </div>
            </div>
            {{/user.profileImage}}
        </div>


        <!-- 필수! forn 태그에 enctype 타입을 반드시 multipart/form-data 로 변경 해야 한다.       -->
        <form action="/user/update" method="post" enctype="multipart/form-data">
            <div class="mb-3">
                <input type="text" class="form-control" placeholder="Enter username" name="username"
                       value="{{user.username}}">
            </div>
            <div class="mb-3">
                <input type="password" class="form-control" placeholder="Enter password" name="password" required>
            </div>
            <div class="mb-3">
                <input type="email" class="form-control" placeholder="Enter email" name="email" value="{{user.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}}

 

2. form 태그에서 넘겨온 데이터를 받아 주어야 함, UserRequest.UpdateDTO 수정

    @Data
    public static class UpdateDTO {
        private String password;
        private MultipartFile profileImage; // form 에 name속성 이름과 동일해야 함
        private String profileImageFilename; // 추후  user.update 메서드에서 사용 함  

        // username 은 제외: 변경 불가는한 고유 식별자

        public void validate() {
            if(password == null || password.trim().isEmpty()) {
                throw new IllegalArgumentException("비밀번호를 입력해주세요");
            }
            if(password.length() < 4) {
                throw new IllegalArgumentException("비밀번호는 4글자 이상이어야 합니다");
            }
        }
    }

 

3. UserService 회원정보수정 에서 프로필 수정 기능 추가, UserService (UserController 수정 X)

    // 데이터의 수정 ( 더티 체킹 -> 반드시 먼저 조회 -> 조회된 객체의 상태값 변경 --> 자동 반영 )
    // 1. 회원 정보 조회
    // 2. 인가 검사
    // 3. 엔티티 상태 변경 (더티 체킹)
    // 4. 트랜잭션이 일어나고 변경 된 User 엔티티 반환
    @Transactional
    public User 회원정보수정(UserRequest.UpdateDTO updateDTO, Long userId ) {
       User userEntity = userRepository.findById(userId)
                .orElseThrow(() -> new Exception404("사용자를 찾을 수 없습니다"));
       if(!userEntity.isOwner(userId))  {
           throw new Exception403("회원 정보 수정 권한이 없습니다");
       }

       // 추가 - 프로필 이미지 처리
       // 중요 : 우리 프로젝트에서는 이미지 수정도 선택 사항 입니다.
       // 새로운 이미지 파일을 생성하고 기존에 있던 이미지 파일을 삭제해야 한다
       // 추가로 DB 정보도 업데이트 해야 한다.

        String oldProfileImage = userEntity.getProfileImage();
        // 분기 처리 - 이미지명이 있거나 또는 null 값이다.
        if(updateDTO.getProfileImage() != null && !updateDTO.getProfileImage().isEmpty()) {
           // 1. 이미지 파일인지 검증
           if(!FileUtil.isImageFile(updateDTO.getProfileImage())) {
               throw new Exception400("이미지 파일만 업로드 가능합니다");
           }

           // 2. 새 이미지 저장
            try {
                String newProfileImageFilename = FileUtil.saveFile(updateDTO.getProfileImage());
                // 새로 만들어진 파일 이름을 잠시 DTO에 보관 함
                updateDTO.setProfileImageFilename(newProfileImageFilename);

                if(oldProfileImage != null && !oldProfileImage.isEmpty()) {
                    // 기존에 있던 이미지를 삭제 처리 한다.
                   FileUtil.deleteFile(oldProfileImage);
                }
            } catch (IOException e) {
                throw new Exception500("파일 저장에 실패했습니다");
            }
            // end of 파일이 들어 왔을 때 처리
        } else {
            // 새 이미지가 업로드 되지 않았으면 기존 이미지 파일 이름 유지
            updateDTO.setProfileImageFilename(oldProfileImage);
        }
       // 객체 상태값 변경 (트랜잭션이 끝나면 자동으로 commit 및 반영해 줄꺼야)
       userEntity.update(updateDTO);
       return userEntity;
    }

 

4. User엔티티에 update 메서드 기능에서 변경된 프로필 이미지도 새로 받게 설계 해야 한다. User 엔티티

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

 

 

삭제

 

1. UserController (프로필 이미지 삭제 URL 매핑)

// 프로필 이미지 삭제 하기
    @PostMapping("/user/profile-image/delete")
    public String deleteProfileImage(HttpSession session) {
        User sessionUser = (User) session.getAttribute("sessionUser");

        User updateUser = userService.프로필이미지삭제(sessionUser.getId());
        // 왜 user 다시 받을까? -- 세션 정보가 (즉 프로필이 삭제 되었기 때문에)
        // 세션 정보 갱신 처리 해주기 위함이다.
        session.setAttribute("sessionUser", updateUser); // 세션 정보 갱신

        // 일반적으로 POST 요청이 오면 PRG 패턴으로 설계 됨
        // POST -> Redirect 처리 ---> Get 요청
        return "redirect:/user/detail";
    }

 

2. 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 = "D:/uploads/";

    public 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/");
    }

    // 프로필 이미지 삭제 처리
    public static void deleteFile(String filename) throws IOException {
        deleteFile(filename, IMAGES_DIR);
    }

    public static void deleteFile(String filename, String uploadDir) throws IOException {
        // 방어적 코드 (파일 이름이 없다면 삭제할 것이 없음)
        if(filename == null || filename.isEmpty()) {
            return;
        }
        // 삭제할 파일의 전체 경로를 생성해야함 (D:/uploads)
        // file:///D:/uploads/ + a.png (경로 + 파일이름)
        Path filePath = Paths.get(uploadDir, filename);
        if(Files.exists(filePath)) {
            Files.delete(filePath);
        }
        // 파일이 없으면 그냥 종료 됨
    }


}

 

3. UserService 에서 삭제 기능 처리

    @Transactional
    public User 프로필이미지삭제(Long sessionUserId) {
    // 1. 회원 정보 조회
    // 2. 회원 정보와 세션 id 값이 같은지 판단 -> 인가 처리
    // 3. 프로필 이미지가 있다면 삭제 (FileUtil) 헬퍼 클래스 사용 할 예정 (디스크에서 삭제)
    // 4. DB 에서 프로필 이름 null 로 업데이트 처리
    User userEntity = userRepository.findById(sessionUserId)
            .orElseThrow(() -> new Exception404("사용자를 찾을 수 없습니다"));

    if(!userEntity.isOwner(sessionUserId)) {
        throw new Exception403("프로필 이미지 삭제 권한이 없습니다");
    }

    String profileImage = userEntity.getProfileImage();
    if(profileImage != null && !profileImage.isEmpty()) {
        try {
            FileUtil.deleteFile(profileImage);
        } catch (IOException e) {
            System.err.println("프로필 이미지 파일 삭제 실패");
        }
    }

    // 객체 상태값 변경 (트랜 잭션이 끝나는 시점 더티 체킹 된)
    userEntity.setProfileImage(null);

    return userEntity;
    }