Repository: 1seok2jo/OhTalkHae
Pull request #27: Feature/#20 사용자 프로필 사진 업로드 및 변경 기능 구현
Author: login: seongjunin98
Labels: name: enhancement
description: New feature or request
Created: 2025-05-02T08:47:47Z | Merged: 2025-05-07T07:20:56Z

사용자 프로필 사진 업로드 및 변경 기능을 구현했습니다.

주요 변경 사항:
- User 엔티티에 프로필 사진 경로 (`profileImagePath`) 필드 추가 (Lombok 적용).
- UserRepository를 통해 사용자 정보 업데이트 기능 지원.
- 이미지 파일 저장 및 사용자 정보 업데이트 로직을 담당하는 `UserProfileService` 구현.
- 클라이언트의 이미지 업로드 요청을 처리하고 결과를 반환하는 `UserProfileController` API 엔드포인트 (`/api/users/{userId}/profile/image`, POST) 구현.


Diff:
diff --git a/src/main/java/oneseoktwojo/ohtalkhae/domain/profile/User.java b/src/main/java/oneseoktwojo/ohtalkhae/domain/profile/User.java
new file mode 100644
index 0000000..1046805
--- /dev/null
+++ b/src/main/java/oneseoktwojo/ohtalkhae/domain/profile/User.java
@@ -0,0 +1,22 @@
+package oneseoktwojo.ohtalkhae.domain.profile;
+
+import jakarta.persistence.Entity;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import lombok.Getter;
+import lombok.Setter;
+
+@Getter
+@Setter
+@Entity
+public class User {
+
+    @Id
+    @GeneratedValue(strategy = GenerationType.IDENTITY)
+    private Long id;
+    private String nickname;
+    private String profileImagePath;
+}
+
+
diff --git a/src/main/java/oneseoktwojo/ohtalkhae/domain/profile/UserProfileController.java b/src/main/java/oneseoktwojo/ohtalkhae/domain/profile/UserProfileController.java
new file mode 100644
index 0000000..102fb3f
--- /dev/null
+++ b/src/main/java/oneseoktwojo/ohtalkhae/domain/profile/UserProfileController.java
@@ -0,0 +1,23 @@
+package oneseoktwojo.ohtalkhae.domain.profile;
+
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.io.IOException;
+
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/api/users/{userId}/profile")
+public class UserProfileController {
+
+    private final UserProfileService userProfileService;
+
+    @PostMapping("/image")
+    public ResponseEntity<String> uploadProfileImage(@PathVariable Long userId, @RequestParam("image") MultipartFile file) throws IOException {
+        String uploadedFileName = userProfileService.uploadProfileImage(userId, file);
+        return ResponseEntity.ok("프로필 사진이 성공적으로 업로드되었습니다. 파일 이름: " + uploadedFileName);
+    }
+}
\ No newline at end of file
diff --git a/src/main/java/oneseoktwojo/ohtalkhae/domain/profile/UserProfileService.java b/src/main/java/oneseoktwojo/ohtalkhae/domain/profile/UserProfileService.java
new file mode 100644
index 0000000..dbb87df
--- /dev/null
+++ b/src/main/java/oneseoktwojo/ohtalkhae/domain/profile/UserProfileService.java
@@ -0,0 +1,44 @@
+package oneseoktwojo.ohtalkhae.domain.profile;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.StandardCopyOption;
+import java.util.UUID;
+import lombok.RequiredArgsConstructor;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.web.multipart.MultipartFile;
+
+@Service
+@RequiredArgsConstructor
+@Transactional
+public class UserProfileService {
+
+    @Value("${file.upload.path}")
+    private String uploadPath;
+
+    private final UserRepository userRepository;
+
+    public String uploadProfileImage(Long userId, MultipartFile file) throws IOException {
+        if (file == null || file.isEmpty()) {
+            throw new IllegalArgumentException("업로드할 파일이 없습니다.");
+        }
+
+        String originalFilename = file.getOriginalFilename();
+        String fileExtension = originalFilename.substring(originalFilename.lastIndexOf("."));
+        String newFilename = UUID.randomUUID().toString() + fileExtension;
+        Path targetPath = Paths.get(uploadPath, newFilename);
+
+        Files.copy(file.getInputStream(), targetPath, StandardCopyOption.REPLACE_EXISTING);
+
+        User user = userRepository.findById(userId)
+                .orElseThrow(() -> new IllegalArgumentException("해당 사용자를 찾을 수 없습니다. ID: " + userId));
+        user.setProfileImagePath(newFilename);
+        userRepository.save(user);
+
+        return newFilename;
+    }
+}
\ No newline at end of file
diff --git a/src/main/java/oneseoktwojo/ohtalkhae/domain/profile/UserRepository.java b/src/main/java/oneseoktwojo/ohtalkhae/domain/profile/UserRepository.java
new file mode 100644
index 0000000..4c2bd7b
--- /dev/null
+++ b/src/main/java/oneseoktwojo/ohtalkhae/domain/profile/UserRepository.java
@@ -0,0 +1,6 @@
+package oneseoktwojo.ohtalkhae.domain.profile;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface UserRepository extends JpaRepository<User, Long> {
+}
diff --git a/src/main/java/oneseoktwojo/ohtalkhae/global/exception/ExController.java b/src/main/java/oneseoktwojo/ohtalkhae/global/exception/ExController.java
index e105a97..dcafd8b 100644
--- a/src/main/java/oneseoktwojo/ohtalkhae/global/exception/ExController.java
+++ b/src/main/java/oneseoktwojo/ohtalkhae/global/exception/ExController.java
@@ -2,6 +2,7 @@
 
 import lombok.extern.slf4j.Slf4j;
 import oneseoktwojo.ohtalkhae.global.dto.ApiResponse;
+import org.springframework.http.HttpStatus;
 import org.springframework.http.converter.HttpMessageNotReadableException;
 import org.springframework.security.core.AuthenticationException;
 import org.springframework.validation.FieldError;
@@ -9,6 +10,7 @@
 import org.springframework.web.bind.annotation.ExceptionHandler;
 import org.springframework.web.bind.annotation.RestControllerAdvice;
 
+import java.io.IOException;
 import java.util.HashMap;
 import java.util.Map;
 
@@ -34,6 +36,16 @@ public ApiResponse<?> handleHttpMessageNotReadable(HttpMessageNotReadableExcepti
         log.debug(ex.getMessage());
         return ApiResponse.error(400, "Invalid Request Body");
     }
+    
+    @ExceptionHandler(IllegalArgumentException.class)
+    public ApiResponse<?> handleIllegalArgumentException(IllegalArgumentException ex) {
+        return ApiResponse.error(HttpStatus.BAD_REQUEST.value(), ex.getMessage());
+    }
+
+    @ExceptionHandler(IOException.class)
+    public ApiResponse<?> handleIOException(IOException ex) {
+        return ApiResponse.error(HttpStatus.INTERNAL_SERVER_ERROR.value(), "프로필 사진 업로드에 실패했습니다 : " + ex.getMessage());
+    }
 
     @ExceptionHandler(AuthenticationException.class)
     public ApiResponse<?> handleGeneralException(Exception ex) {
@@ -41,4 +53,4 @@ public ApiResponse<?> handleGeneralException(Exception ex) {
         return ApiResponse.error(401, "Unauthorized");
     }
 //    @ExceptionHandler
-}
+}
\ No newline at end of file
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index dd009a6..255d580 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -24,3 +24,6 @@ spring:
     properties:
       hibernate:
         format_sql: true
+  file:
+    upload:
+      path: ./upload/profile/


Reviews:
user: login: changi1122
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-02T10:50:24Z
user: login: changi1122
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-02T10:56:36Z
user: login: changi1122
body: 고생하셨습니다👍. structure 폴더 밑에 문서 파일들은 삭제했었는데 다시 생긴 거 같아서 삭제하면 좋을 거 같아요.
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-02T11:00:03Z
user: login: hueyjeong
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-04T03:29:09Z
user: login: seongjunin98
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-04T05:57:14Z
user: login: seongjunin98
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-04T06:57:58Z
user: login: seongjunin98
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-04T06:59:44Z
user: login: Yoon-11
body: 고생하셨습니다😁
예외처리 충돌만 해결하면 될것같네요~!
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-06T17:51:43Z
user: login: karlislepark
body: 고생하셨습니다~
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-07T07:13:21Z
user: login: jam9582
body: 강의시간에 나왔던 내용들을 알차고 깔끔하게 사용하고 계셔서 보면서 복습이 저절로 되는 기분이었습니다! 예외처리 conflict 나는 것만 다듬어주면 완벽한 merge가 되겠어요
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-07T07:17:06Z

Review comments:
diff_hunk: @@ -0,0 +1,29 @@
+package oneseoktwojo.ohtalkhae.domain.profile;
+
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.io.IOException;
+
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/api/users/{userId}/profile")
+public class UserProfileController {
+
+    private final UserProfileService userProfileService;
+
+    @PostMapping("/image")
+    public ResponseEntity<String> uploadProfileImage(@PathVariable Long userId, @RequestParam("image") MultipartFile file) {
+        try {
+            String uploadedFileName = userProfileService.uploadProfileImage(userId, file);
+            return ResponseEntity.ok("프로필 사진이 성공적으로 업로드되었습니다. 파일 이름: " + uploadedFileName);
+        } catch (IllegalArgumentException e) {
+            return ResponseEntity.badRequest().body(e.getMessage());
+        } catch (IOException e) {
+            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("프로필 사진 업로드에 실패했습니다 : " + e.getMessage());
+        }
path: src/main/java/oneseoktwojo/ohtalkhae/domain/profile/UserProfileController.java
user: login: changi1122
body: Controller에서 `IllegalArgumentException`, `IOException` 예외를 try ~ catch로 처리하지 말고 예외를 던지고, `global.exception.ExController.java` 파일에 `@ControllerAdvice`로  `IllegalArgumentException`, `IOException` 이 예외 각각을 처리하는 코드를 작성하면 더 좋아질 거 같아요😊.

그렇게 하면 장점이, 비슷한 예외 던지는 다른 메서드들에서 try ~ catch를 중복되게 적을 필요가 없어집니다. 이번에 바로 수정하실 필요는 없을 거 같아요~
created_at: 2025-05-02T10:50:24Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,44 @@
+package oneseoktwojo.ohtalkhae.domain.profile;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.StandardCopyOption;
+import java.util.UUID;
+import lombok.RequiredArgsConstructor;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.web.multipart.MultipartFile;
+
+@Service
+@RequiredArgsConstructor
+@Transactional
+public class UserProfileService {
+
+    @Value("${file.upload.path}")
+    private String uploadPath;
+
+    private final UserRepository userRepository;
+
+    public String uploadProfileImage(Long userId, MultipartFile file) throws IOException {
+        if (file.isEmpty()) {
+            throw new IllegalArgumentException("업로드할 파일이 없습니다.");
+        }
path: src/main/java/oneseoktwojo/ohtalkhae/domain/profile/UserProfileService.java
user: login: changi1122
body: file 자체가 null이면 `NullPointerException`이 발생할 수 있어서, 먼저 file이 null 인지 확인하는 조건을 `||` 연산자로 추가해야 할 거 같아요.
created_at: 2025-05-02T10:56:36Z
author_association: COLLABORATOR
path: src/main/java/oneseoktwojo/ohtalkhae/domain/profile/User.java
user: login: hueyjeong
body: User 엔티티가 domain/user/User.java의 엔티티와 같다면 그 쪽에 필드를 추가하시는 식으로 하면 될 것 같습니다.
created_at: 2025-05-04T03:25:47Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,29 @@
+package oneseoktwojo.ohtalkhae.domain.profile;
+
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.io.IOException;
+
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/api/users/{userId}/profile")
+public class UserProfileController {
+
+    private final UserProfileService userProfileService;
+
+    @PostMapping("/image")
+    public ResponseEntity<String> uploadProfileImage(@PathVariable Long userId, @RequestParam("image") MultipartFile file) {
+        try {
+            String uploadedFileName = userProfileService.uploadProfileImage(userId, file);
+            return ResponseEntity.ok("프로필 사진이 성공적으로 업로드되었습니다. 파일 이름: " + uploadedFileName);
+        } catch (IllegalArgumentException e) {
+            return ResponseEntity.badRequest().body(e.getMessage());
+        } catch (IOException e) {
+            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("프로필 사진 업로드에 실패했습니다 : " + e.getMessage());
+        }
path: src/main/java/oneseoktwojo/ohtalkhae/domain/profile/UserProfileController.java
user: login: seongjunin98
body: 그렇게 하는게 더 좋겠네요!
그 기억해서 후에 수정하겠습니다.
created_at: 2025-05-04T05:57:14Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,44 @@
+package oneseoktwojo.ohtalkhae.domain.profile;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.StandardCopyOption;
+import java.util.UUID;
+import lombok.RequiredArgsConstructor;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.web.multipart.MultipartFile;
+
+@Service
+@RequiredArgsConstructor
+@Transactional
+public class UserProfileService {
+
+    @Value("${file.upload.path}")
+    private String uploadPath;
+
+    private final UserRepository userRepository;
+
+    public String uploadProfileImage(Long userId, MultipartFile file) throws IOException {
+        if (file.isEmpty()) {
+            throw new IllegalArgumentException("업로드할 파일이 없습니다.");
+        }
path: src/main/java/oneseoktwojo/ohtalkhae/domain/profile/UserProfileService.java
user: login: seongjunin98
body: 조언 감사합니다.
수정했습니다. 재검토 부탁드립니다!
created_at: 2025-05-04T06:57:58Z
author_association: COLLABORATOR
path: src/main/java/oneseoktwojo/ohtalkhae/domain/profile/User.java
user: login: seongjunin98
body: 넵 이해했습니다!
조언 감사합니다
created_at: 2025-05-04T06:59:44Z
author_association: COLLABORATOR

PR comments:
user: login: hueyjeong
created_at: 2025-05-04T03:13:36Z
body: 머지가 메인 브렌치로 되게 되어 있어서 develop 브랜치로 변경하겠습니다.
author_association: COLLABORATOR
user: login: hueyjeong
created_at: 2025-05-04T03:28:59Z
body: 수고하셨습니다. 
충돌 없으니 지금은 이대로 괜찮을 것 같고 추후에 user 엔티티나 user 리포지토리 만드신 부분은 user 도메인의 user를 사용하시면 될 것 같습니다.
author_association: COLLABORATOR