JPA를 사용하다 보면 바로 N+1의 문제에 마주치고 바로 Fetch Join을 접하게 된다. 처음 Fetch Join을 접했을 때 왜 일반 Join으로 해결하면 안되는지에 대해 명확히 정리가 안된 채로 Fetch Join을 사용했다. 어떤 문제 때문에 일반 join으로 N+1을 해결하지 못하는지를 시작으로 해서 Fetch Join, Join의 차이점을 정리해보겠다.
Join, Fetch Join 차이점 요약
일반 Join
Fetch Join과 달리 연관 Entity에 Join을 걸어도 실제 쿼리에서 SELECT 하는 Entity는 오직 JPQL에서 조회하는 주체가 되는 Entity만 조회하여 영속화
조회의 주체가 되는 Entity만 SELECT 해서 영속화하기 때문에 데이터는 필요하지 않지만 연관 Entity가 검색조건에는 필요한 경우에 주로 사용됨
Fetch Join
조회의 주체가 되는 Entity 이외에 Fetch Join이 걸린 연관 Entity도 함께 SELECT 하여 모두 영속화
Fetch Join이 걸린 Entity 모두 영속화하기 때문에 FetchType이 Lazy인 Entity를 참조하더라도 이미 영속성 컨텍스트에 들어있기 때문에 따로 쿼리가 실행되지 않은 채로 N+1문제가 해결됨
N+1 문제
단순 테이블 조회 쿼리를 하나 생성하였는데, 연관관계에 있는 데이터를 모두 불러오기 위해서 select 쿼리가 테이블의 레코드 수(혹은 호출한 데이터 수) 만큼 더 생성되는 문제를 의미한다.
즉시로딩은 최초 테이블 데이터를 모두 불러온 후 바로 연관관계가 있는 엔티티를 조회하는 방식을,
지연로딩은 최초 테이블 데이터를 모두 불러온 후 필요한 시점에 연관관계가 있는 엔티티를 조회하는 방식을 채택한다.
즉시로딩은 워딩 그대로 조회 시점에 연관관계에 있는 엔티티가 모두 조회되어 N+1 문제가 생긴다는 것을 직관적으로 알 수 있지만 지연로딩은 얼핏보면 로딩 시점에 쿼리가 더 나가지 않아 N+1 문제가 발생하지 않을 것으로 보인다. 하지만 지연로딩 N+1 문제를 피해갈 수는 없는데, 그 이유는 연관관계에 있는 엔티티가 조회 시점에 영속화되지 않았기 때문이다.
일반적으로 JPA는 JPQL 로 생성된 쿼리문을 데이터베이스와 connection 을 만든 후에 전송하고, 이에 대한 결과 데이터를 전달받아 실제 엔티티 객체를 생성하고 이를 영속화 한다.
예시
@Entity
class Post {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
Long id;
String title;
@OneToMany(mappedBy = "post", fetch = FetchType.LAZY)
List<Comment> cpmments = ArrayList()
}
@Entity
class Comment {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
Long id;
String content;
@ManyToOne(fetch = FetchType.LAZY)
Post post;
}
@Service
@RequiredArgsConstructor
public class PostService {
private final PostRepository postRepository;
@Transactional(readOnly = true)
public void getMockposts() {
postRepository.findAll().forEach(post -> {
post.getpPostComments().forEach(cpmments ->
System.out.println(cpmments.getContent())
);
});
}
}
위 코드에서 N+1 문제가 발생할 수 있다. 예를 들어, 100개의 게시글이 있다고 가정하면, PostRepository의 findAll() 메서드가 호출될 때 모든 게시글을 조회한다. 이때, 각 게시글의 댓글을 조회하기 위해 추가로 100번의 쿼리가 실행다. 이는 총 101번의 쿼리 실행으로 이어지며, 매우 비효율적인 방법이다. 즉, 다음과 같이 SQL이 실행되면서 N+1 문제가 발생한 것을 알 수 있다.
SELECT * FROM post;
SELECT * FROM comment WHERE post_id = 1;
SELECT * FROM comment WHERE post_id = 2;
SELECT * FROM comment WHERE post_id = 3;
...
SELECT * FROM comment WHERE post_id = 99;
SELECT * FROM comment WHERE post_id = 100;
해결 방법
1. Fetch Join (@Query 어노테이션 사용)
Fetch Join은 N+1 문제를 방지하고 성능을 최적화할 수 있는 수단으로서데이터베이스에서 연관된 엔터티를 함께 로딩하는 방법이다.
Fetch Join을 사용하면 조회 성능을 최적화 할 수 있다.
연관된 엔티티를 쿼리 시 함께 조회하는 방법이다.
SQL 쿼리에 직접 Join fetch를 명시한다.
조회의 주체가 되는 엔티티 외에 Fetch Join이 걸린 연관 엔티티도 같이 영속화 시켜준다.
@Repository
public interface PostRepository extends JpaRepository<Post, String> {
@Query("SELECT p from Post p JOIN FETCH p.comments")
List<Post> findAllPostsWithComments();
}
"JOIN FETCH p.comments" 구문을 통해서 게시글을 조회할 때 댓글 엔티티도 함께 조회한다.
join fetch는 Fetch Join을 사용하여 연관된 데이터를 조회하겠다는 뜻이다.
2. Entity Graph
EntityGraph를 사용하면 Fetch Join를 사용한 경우와 마찬가지로 연관된 엔티티나 컬렉션을 같이 조회 할 수 있다.
JPQL과 함께 사용하며 attributePaths에 쿼리 수행 시 조회할 필드를 지정해준다.
지정된 필드는 지연 로딩이 아닌 즉시 로딩으로 조회된다.
EntityGraph는 Fetch Join과는 다르게 Outer Join이 사용되어 동작한다
@Repository
public interface PostRepository extends JpaRepository<Post, String> {
@EntityGraph(
attributePaths = {"comments"},
type = EntityGraph.EntityGraphType.LOAD
)
@Query("SELECT p FROM post p")
List<Post> findAllPostsWithComments();
}
Fetch Join 및 EntityGraph 사용 시 발생하는 카테시안 곱 문제
Fetch Join 또는 EntityGraph를 사용한 경우엔 카테시안 곱(Cartesian Product)으로 인하여 중복 문제가 발생할 수 있습니다. 카테시안 곱은 연관관계의 엔티티에 Join 규칙을 사용하지 않아 결합 조건이 주어지지 않았을 때 모든 데이터의 경우의 수를 결합(M * N)하여 반환하는 문제입니다. 즉, 조인 조건이 없는 경우에 대한 두 테이블의 결합 결과를 반환해야 하기 때문에 존재하는 모든 행의 개수를 곱한 결과를 반환하게 됩니다. 카테시안 곱 문제를 해결하기 위해서는 다음과 같은 방법을 사용합니다.
1. JPQL에 DISTINCT 사용하기
JPQL을 이용한 쿼리를 사용할 때 다음과 같이 DISTINCT를 사용하여 중복을 제거합니다.
@Repository
public interface PostRepository extends JpaRepository<Post, String> {
@Query("SELECT DISTINCT p from Post p JOIN FETCH p.comments")
List<Post> findAllPostsWithComments();
@EntityGraph(
attributePaths = {"comments"},
type = EntityGraph.EntityGraphType.LOAD
)
@Query("SELECT DISTINCT p FROM post p")
List<Post> findAllPostsWithComments();
}
2. 연관관계의 필드 타입에 Set 사용하기
Set은 중복을 허용하지 않기 때문에 중복 데이터가 들어가지 않지만 순서가 보장되지 않는다.
이러한 경우 다음과 같이 연관관계의 필드에 LinkedHashSet을 사용하여 중복을 제거하고 순서를 보장 해줄 수 있다.
@Getter
@NoArgsConstructor
@Entity(name = "posts")
public class post {
@OneToMany(mappedBy = "post")
private Set<Comment> comments = new LinkedHashSet<>();
}
// service
/**
* Oauth2 로그인 시 추가 정보 입력
*/
@Override
public Optional<User> updateOauth2(String username, Oauth2LoginDto oauth2LoginDto) {
User user = User.builder()
.phoneNumber(oauth2LoginDto.getPhoneNumber())
.gender(oauth2LoginDto.getGender())
.birthdate(oauth2LoginDto.getBirthdate())
.build();
userRepository.save(user);
return Optional.of(user);
}
아래와 같은 오류가 발생했다.
org.springframework.dao.DataIntegrityViolationException: not-null property references a null or transient value : com.example.omg_project.domain.user.entity.User.nam
저 오류에 대해서 검색하니
DataIntegrityViolationException 예외는 JPA가 엔티티를 저장하는 도중 User 엔티티의 name 필드가 null로 설정되어 있는 것을 감지했기 때문에 발생한 것이다. name 필드는 not-null 제약 조건이 설정되어 있어서 이로 인해 Hibernate가 엔티티를 데이터베이스에 저장할 수 없었다.
라고 한다.
DataIntegrityViolationException 예외는 JPA가 엔티티를 저장하는 도중 User 엔티티의 name 필드가 null로 설정되어 있는 것을 감지했기 때문에 발생한 것이다. name 필드는 not-null 제약 조건이 설정되어 있어서 이로 인해 Hibernate가 엔티티를 데이터베이스에 저장할 수 없었다.
빌더 패턴을 사용해서 구현할 거면 아래와 같이 기존 사용자가 notnull로 갖고 있는 필드들에 대해서 다 값을 복사해줘야 한다는 것이다.. 그래서 아니 세터보다 빌더를 사용하라면서 왜 저렇게 굳이 저래야 하나..? 했는데 열심히 구글링을 해서 빌더와 세터의 차이에 대해서 확실히 이해가 갔다.
/**
* Oauth2 로그인 시 추가 정보 입력
*/
@Override
public Optional<User> updateOauth2(String username, Oauth2LoginDto oauth2LoginDto) {
User user = User.builder()
.name(user.getName()) // 기존에 있던 값 추가
.phoneNumber(oauth2LoginDto.getPhoneNumber())
.gender(oauth2LoginDto.getGender())
.birthdate(oauth2LoginDto.getBirthdate())
.build();
userRepository.save(user);
return Optional.of(user);
}
이러한 문제가 발생된 원인은 내가 빌더패턴을 정확하게 이해하지 못해서 발생한 대참사이다. 빌더는 객체를 수정하는 것이 아니라 생성자처럼 객체를 생성하는 기능을 수행한다고 한다.
정확하게 알아야 할 개념
빌더패턴은 객체의 생성을 담당하는 패턴으로, 일반적으로 빌더를 사용하여 객체를 새로 생성한다. 빌더를 사용하여 객체를 생성할 때, 이전 객체의 값을 수정하는 것이 아니라 새로운 객체를 생성하는 것이다.
이것이 빌더 패턴의 주요 특징 중 하나이며, 이를 이해하는 것이 중요하다.
빌더 패턴은 객체 생성 시에 여러 속성을 가진 객체를 편리하게 생성하기 위해 사용된다. 따라서 객체의 속성을 일부 변경하거나 해당 속성을 지정하여 새로운 객체를 생성해야한다고 한다.
따라서 내가 dto로 받아온 값을 User 엔티티의 빌더 패턴으로 엔티티 객체로 변환하려고 하는 행위(?)는 잘못되었다고 할 수 있다.
하지만 빌더 패턴을 사용해서 객체의 데이터를 수정하는 것이 아예 불가능한 것은 아니라고 한다.
그렇다면 빌더 패턴으로 어떻게 값을 수정할 수 있는가?
// entity
@Getter @Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder(toBuilder = true) // 기존 인스턴스를 기반으로 새로운 객체를 빌드할 수 있게 함
public class Oauth2LoginDto {
private String phoneNumber; // 연락처
private LocalDate birthdate; // 생년월일
private String gender; // 성별
}
이처럼 toBuilder 속성을 true 로 설정해줄 경우 toBuilder() 메서드를 사용할 수 있게되는데
이 메서드를 사용하면 이미 생성되어있는 객체의 값을 toBuilder() 메서드를 활용하여 특정 필드의 값만을 변경해줄 수 있다고 한다.
결론
toBuilder 메서드를 활용하면 객체의 값을 일부 수정할 수는 있지만 내가 구현하려는 로직에서는 사용하기에 적합하지 않은 것 같다는 생각이 들었다.
어쨌든 빌더패턴이라는 것이 매개변수가 많아 생성자에 정의된 순서대로 데이터를 넣기 어려워 이를 편하게 하기 위한 디자인패턴인 건데 즉, 객체를 생성하는 것이 주된 목적인데 나는 객체의 값을 수정하는 로직을 만들어야 하므로 객체의 값을 부분적으로 수정하기 위해서는 굳이 엔티티 객체로 바꿀 필요는 없을 것 같고, dto에서 값을 꺼내서 entity에 setter 를 사용해서 update를 해주는 방식으로 구현하는 것이 적합한 것 같다.
✅ 정리
세터 사용 방법: 기존 사용자 객체를 업데이트하는 방식이므로, 코드가 간결하고 오류 발생 가능성이 적다. 또한, 필수적으로 설정해야 하는 필드를 실수로 놓칠 가능성도 적다.
빌더 사용 방법: 만약 객체의 다른 필드를 변경하거나 새로운 객체로 교체해야 하는 상황이 아니라면, 굳이 빌더 패턴을 사용할 필요는 없다. 이 방법은 필요 이상으로 복잡할 수 있으며, 잘못된 필드 값을 복사하거나 누락할 위험이 있다.
즉, 빌더 패턴은 새로운 객체를 생성하거나 객체의 불변성을 유지해야 할 때 더 유용하다. 하지만 여기서는 기존 객체에 OAuth2 유저의 성별, 생일, 연락처의 정만 업데이트하는 것이므로 세터방식을 사용하는 것이 더 자연스럽고 안전하다.
@Transactional 을 안 붙여서 오류가 나는 경우가 종종 있어서 이번 기회에 정리해놓을려고 한다.
@Transactional
Q. @Transactional 어노테이션 붙이는 이유?
데이터베이스의 상태를 변경하는 메서드나 복수의 데이터베이스 작업을 묶어서 처리할 때 사용한다.
데이터를 데이터베이스에 저장하거나 삭제, 수정하는 메서드처럼 데이터베이스의 상태를 변경하는 경우에 사용한다.
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
private final RoleRepository roleRepository;
private final PasswordEncoder passwordEncoder;
@Transactional
public void registerUser(String username, String email, String password, String passwordCheck, String usernick) throws IOException {
Role role = roleRepository.findByRoleName(RoleName.ROLE_USER)
.orElseThrow(() -> new UserNotFoundException("User 역할이 없습니다."));
User user = new User();
user.setRole(Collections.singleton(role)); // singleton -- 단일 역할 설정
user.setUserName(username);
user.setEmail(email);
user.setPassword(passwordEncoder.encode(password));
user.setUserNick(usernick);
userRepository.save(user);
}
@Transactional
메서드가 실행되는 동안 트랜잭션을 시작하고, 메서드가 정상적으로 완료되면 커밋(commit)을 하고, 예외가 발생하면 롤백(rollback)을 수행한다.
이 어노테이션을 사용하여 데이터베이스 관련 작업을 안전하게 처리할 수 있다.
기본 값 : @Transactional(readOnly = false)
특징: 위의 예시에서는 registerUser 메서드에 적용되어 있으며, readOnly = false로 설정되어 있어 해당 메서드는 읽기/쓰기 모두 가능한 트랜잭션을 사용합니다.
readOnly 속성의 차이점
readOnly = true
이 옵션을 설정하면, 트랜잭션 동안 데이터를 수정하는 쿼리(INSERT, UPDATE, DELETE 등)를 수행할 수 없다.
오직 읽기 전용 쿼리(SELECT)만 가능
데이터를 조회만 할때, 전체 사용자, 게시글 조회 등등
이는 데이터베이스에게 현재 트랜잭션이 데이터를 변경하지 않을 것임을 알려 성능 최적화를 도와준다.
읽기 전용 트랜잭션은 데이터베이스의 트랜잭션 로그를 기록하지 않아도 되어 I/O 부하를 줄일 수 있다.
readOnly = false (기본값)
읽기 및 쓰기 작업 모두를 수행할 수 있는 트랜잭션
데이터베이스에 변경 사항이 발생하면 트랜잭션 로그 기록
쓰기 작업이 필요한 경우에는 이 옵션을 사용
삭제, 등록, 수정 등 데이터베이스의 상태를 변경할 때 사용한다.
결론
데이터베이스에서 삽입, 삭제, 수정 등 데이터베이스의 내용이 변하게 된다면 @Transactional 붙여야 한다.!!
기존 이미지 업로드는 로컬에서 임의로 파일을 만들어서 구현했지만 이제는 AWS S3를 사용해서 안전하게 이미지를 저장해볼려고 한다. 용도 : 회원정보를 수정할 때 프로필 이미지를 등록
1. Spring Boot에서 S3에 파일을 업로드하는 세 가지 방법
S3에 파일을 업로드하는 방법에는 3가지가 있다.
Stream 업로드
MultipartFile 업로드
AWS Multipart 업로드
1.1 Stream 업로드
Stream 업로드 방식은 파일을 chunk 단위로 읽어서 서버에 전송하는 방식이다. 직렬화된 데이터를 순차적으로 보내므로, 대용량 파일을 안정적으로 전송할 수 있지만, 각 chunk를 순차적으로 전송하기 때문에 전체 파일을 한 번에 업로드하는 방식보다 더 많은 시간이 소요될 수 있다.
1.2 MultipartFile 업로드
MultipartFile 업로드는 Spring에서 제공하는 MultipartFile 인터페이스를 이용하여 파일을 업로드하는 방식이다. 대부분의 웹 개발 프레임워크 및 라이브러리에서 기본적으로 지원하는 방식이므로 구현이 간단하다. 또한 사용자가 파일을 선택하고 업로드 버튼을 클릭하는 것으로 파일을 서버에 업로드할 수 있다. 드어마 파일 전체를 메모리에 로드하므로, 대용량 파일을 처리할 때 메모리 부담이 있을 수 있다. 또한 파일을 서버에 업로드할 때 보안 취약성이 발생할 수 있다.
1.3 AWS Multipart 업로드
AWS Multipart 업로드는 AWS S3에서 제공하는 파일 업로드 방식으로 업로드할 파일을 작은 part로 나누어 개별적으로 업로드한다. 파일의 바이너리가 Spring Boot를 거치지 않고 AWS S3에 다이렉트로 업로드되기 때문에 서버의 부하를 고려하지 않아도 된다. 업로드 중에 오류가 발생해도 해당 부분만 재시도할 수 있다. 그러나 복잡한 구현을 필요로 하고 여러 요청을 병렬로 보내므로, 이에 따른 네트워크 및 데이터 전송 비용이 발생할 수 있다.
원래 Presigned URL을 이용하는 3번 방식을 통해 구현해보고 싶었으나 다음과 같은 이유로 2번을 사용하려고 한다.
3번의 경우 클라이언트로 부터 파일 업로드 요청을 받으면 프론트는 다시 백엔드로 그 파일을 전달해주고 백엔드에서 다시 S3로 업로드하는 불필요한 과정이 발생한다.
프로젝트 규모가 현재 크지 않고, 빠르게 기능 구현을 해야하는 상황에서 나만의 호기심으로 사용해보기엔 구현 복잡도가 높다.
따라서 현실과 타협해Multipart 파일을 S3에 업로드하는 방법을 통해 이미지 업로드 기능을 구현하려고 한다.
spring:
servlet:
multipart:
maxFileSize: 10MB # 파일 하나의 최대 크기
maxRequestSize: 30MB # 한 번에 최대 업로드 가능 용량
cloud:
aws:
credentials:
access-key: {your access-key}
secret-key: {your secret-key}
s3:
bucket: {your bucket name}
region:
static: ap-northeast-2
stack:
auto: false
MultipartFile를 사용하기로 했으니 적절한 파일 크키와 용량을 지정하고 S3 연동을 위한 설정을 해준다.
S3Config
@Configuration
public class S3Config {
@Value("${cloud.aws.credentials.access-key}")
private String accessKey;
@Value("${cloud.aws.credentials.secret-key}")
private String secretKey;
@Value("${cloud.aws.region.static}")
private String region;
@Bean
public AmazonS3Client amazonS3Client() {
BasicAWSCredentials awsCredentials= new BasicAWSCredentials(accessKey, secretKey);
return (AmazonS3Client) AmazonS3ClientBuilder.standard()
.withRegion(region)
.withCredentials(new AWSStaticCredentialsProvider(awsCredentials))
.build();
}
}
버전이 올라가면서 작성법이 많이 달라져서 고생을 좀 했지만 엑세스와 시크릿 키를 통해 접근을 허용해주면 된다.
이미지 S3 업로드 구현
@Slf4j
@Service
@RequiredArgsConstructor
public class S3Service {
private final AmazonS3 amazonS3;
@Value("${cloud.aws.s3.bucket}")
private String bucket;
private final String DIR_NAME = "{your image file name";
public String upload(String fileName, MultipartFile multipartFile) throws IOException {
File uploadFile = convert(multipartFile)
.orElseThrow(() -> new IllegalArgumentException("MultipartFile -> File 전환 실패"));
String newFileName = DIR_NAME + "/" + fileName + getExtension(multipartFile.getOriginalFilename());
String uploadImageUrl = putS3(uploadFile, newFileName);
removeNewFile(uploadFile); // 로컬에 생성된 파일 삭제
return uploadImageUrl; // 업로드된 파일의 S3 URL 주소 반환
}
private String putS3(File uploadFile, String fileName) {
amazonS3.putObject(new PutObjectRequest(bucket, fileName, uploadFile)
.withCannedAcl(CannedAccessControlList.PublicRead));
return amazonS3.getUrl(bucket, fileName).toString();
}
private void removeNewFile(File targetFile) {
if (targetFile.delete()) {
log.info("파일이 삭제되었습니다.");
} else {
log.info("파일이 삭제되지 못했습니다.");
}
}
private Optional<File> convert(MultipartFile file) throws IOException {
File convertFile = new File(file.getOriginalFilename());
if (convertFile.createNewFile()) {
try (FileOutputStream fos = new FileOutputStream(convertFile)) {
fos.write(file.getBytes());
}
return Optional.of(convertFile);
}
return Optional.empty();
}
private String getExtension(String fileName) {
return fileName.substring(fileName.lastIndexOf("."));
}
S3 에 이미지를 업로드 하는 클래스이다.
각 함수들에 대해서 설명해보겠다.
1. upload 함수
public String upload(String fileName, MultipartFile multipartFile) throws IOException {
File uploadFile = convert(multipartFile)
.orElseThrow(() -> new IllegalArgumentException("MultipartFile -> File 전환 실패"));
String newFileName = DIR_NAME + "/" + fileName + getExtension(multipartFile.getOriginalFilename());
String uploadImageUrl = putS3(uploadFile, newFileName);
removeNewFile(uploadFile); // 로컬에 생성된 파일 삭제
return uploadImageUrl; // 업로드된 파일의 S3 URL 주소 반환
}
}
여기서 MutipartFile을 File 형태로 변환해주어야 한다.
이 과정에서 로컬에서 파일이 복사되어 저장되기 때문에 로컬에 있는 이미지를 삭제해주어야 한다.
@Override
public User updateProfileImage(Long userId, MultipartFile file) throws IOException {
User user = userRepository.findById(userId)
.orElseThrow(() -> new IllegalArgumentException("유효하지 않은 사용자 ID"));
String fileName = userId.toString(); // 사용자 ID를 파일명으로 사용
String imageUrl = s3Service.upload(fileName, file); // S3에 이미지 업로드 및 URL 반환
user.setFilename(file.getOriginalFilename()); // 파일 이름 저장
user.setFilepath(imageUrl); // 파일 경로(URL) 저장
return userRepository.save(user); // 사용자 정보 업데이트
}
S3에 올라갈 때 사용자 Id를 파일명으로 지정하여 올리기로 결정했고
클라이언트에서 사용자가 이미지를 등록했을 때 동작할 서비스 클래스 코드이다.
그리고 엔티티에 파일경로를 저장하기로 해서 user 엔티티에 2개의 칼럼을 추가해준다.
User.java
@Entity
@Table(name = "users")
@Getter @Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class User {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String username; // 이메일
@Column(nullable = false)
private String password;
private String filename; // 파일 이름
private String filepath; // 파일 경로
}
Controller.java
@RestController
@Slf4j
@RequiredArgsConstructor
public class S3Controller {
private final S3Service s3Service;
private final UserService userService;
@PostMapping("/api/s3/image")
public ResponseEntity<String> uploadProfileImage(
@RequestParam("file") MultipartFile file,
@RequestParam("userId") Long userId) throws IOException {
User user = userService.updateProfileImage(userId, file);
log.info("Uploaded file URL: " + user.getFilepath());
return ResponseEntity.ok(user.getFilepath());
}
지정한 저 앤드포인트로 요청을 보내서 서버측에서 요청을 처리할 예정이다.
HTML & JS
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>메인 홈 입니다.</title>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script>
// 프로필 이미지 업로드
$('#profile-form').on('submit', function(event) {
event.preventDefault();
let formData = new FormData(this);
formData.append("userId", $('#userId').val()); // 유저 ID 추가
$.ajax({
type: 'POST',
url: '/api/s3/image',
data: formData,
processData: false,
contentType: false,
success: function(response) {
alert('프로필 이미지가 성공적으로 업로드되었습니다.');
$('#profile-image').attr('src', response);
},
error: function(error) {
alert('프로필 이미지 업로드에 실패했습니다.');
}
});
});
});
</script>
</head>
<body>
<h2>로그인에 성공하셨어요 축하합니다.</h2>
<p>안녕하세요, 당신의 이메일 주소 :: <span th:text="${user.username}"></span>님</p>
<p>안녕하세요, 당신의 이름 :: <span th:text="${user.name}"></span></p>
<!-- 프로필 이미지 편집 섹션 -->
<h3>프로필 이미지 편집</h3>
<input type="hidden" id="userId" th:value="${user.id}"/>
<form id="profile-form">
<input type="file" name="file" accept="image/*" required />
<button type="submit">프로필 이미지 업로드</button>
</form>
<img id="profile-image" th:src="${user.filepath != null ? user.filepath : '/files/lioon.png'}" alt="Profile Image" style="max-width: 150px; max-height: 150px;" />
</body>
</html>
자바스크립트 파일로 지정한 앤드포인트로 요청을 보내면 S3에 업로드가 될 것이다.
기본 사진은 미리 로컬에 지정해둔 사진으로 보여질 수 있도록 했다. 그 후 사용자가 프로필을 변경하면 변경될 수 있도록..