조회
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;
}
'개인 피드백 > 백엔드' 카테고리의 다른 글
| 이미지 업로드 기능 - 프로필 이미지 (0) | 2025.12.24 |
|---|---|
| 세션 인터셉터 만들기 (0) | 2025.12.24 |
| Spring 코드 순서의 중요성(자식 <-> 부모) (0) | 2025.11.19 |