diff --git a/amumal/.env.example b/amumal/.env.example index 9b05fa9..1aeb648 100644 --- a/amumal/.env.example +++ b/amumal/.env.example @@ -1,11 +1,18 @@ -DB_URL= +DB_HOST= +DB_NAME= DB_USERNAME= DB_PASSWORD= + SERVER_URL= JWT_SECRET= + MAX_FILE_SIZE= MAX_REQUEST_SIZE= -POSTS_FILE_ACCESS_PATH= -PROFILE_FILE_ACCESS_PATH= -SERVER_URL= \ No newline at end of file + +AWS_ACCESS_KEY= +AWS_SECRET_KEY= +AWS_REGION= +AWS_S3_BUCKET= + +DDL_AUTO_SET= \ No newline at end of file diff --git a/amumal/build.gradle b/amumal/build.gradle index 839f33f..664dc7e 100644 --- a/amumal/build.gradle +++ b/amumal/build.gradle @@ -41,6 +41,9 @@ dependencies { // BCrypt implementation 'org.springframework.security:spring-security-crypto' + // Redis + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + // QueryDSL implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta" @@ -49,6 +52,9 @@ dependencies { // Swagger (springdoc-openapi) implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.2' + + // AWS S3 + implementation 'software.amazon.awssdk:s3:2.26.0' } tasks.named('test') { diff --git a/amumal/src/main/java/com/kbt/amumal/AmumalApplication.java b/amumal/src/main/java/com/kbt/amumal/AmumalApplication.java index 356edda..398a0c2 100644 --- a/amumal/src/main/java/com/kbt/amumal/AmumalApplication.java +++ b/amumal/src/main/java/com/kbt/amumal/AmumalApplication.java @@ -4,10 +4,13 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import java.util.TimeZone; + @SpringBootApplication @EnableJpaAuditing public class AmumalApplication { public static void main(String[] args) { + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul")); SpringApplication.run(AmumalApplication.class, args); } } diff --git a/amumal/src/main/java/com/kbt/amumal/domain/auth/controller/AuthController.java b/amumal/src/main/java/com/kbt/amumal/domain/auth/controller/AuthController.java index 95d0b3d..fd70cc7 100644 --- a/amumal/src/main/java/com/kbt/amumal/domain/auth/controller/AuthController.java +++ b/amumal/src/main/java/com/kbt/amumal/domain/auth/controller/AuthController.java @@ -9,29 +9,72 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import java.util.Map; -@Tag(name = "인증", description = "로그인·로그아웃 API") +@Tag(name = "인증", description = "로그인·로그아웃·토큰 재발급 API") @RestController @RequiredArgsConstructor @RequestMapping("/auth") public class AuthController { + private final AuthService authService; - @Operation(summary = "로그인", description = "이메일·비밀번호로 로그인하고 JWT 액세스 토큰을 반환합니다.") + @Operation(summary = "로그인", description = "이메일·비밀번호로 로그인합니다. Access Token은 body, Refresh Token은 HttpOnly 쿠키로 반환됩니다.") @SecurityRequirements @PostMapping("/") - public ApiResponse login(@Valid @RequestBody AuthReqDTO.LoginReq request) { - String userInfo = authService.userLogin(request); + public ResponseEntity> login(@Valid @RequestBody AuthReqDTO.LoginReq request) { + AuthService.LoginResult result = authService.userLogin(request); + + ResponseCookie refreshCookie = buildRefreshCookie(result.refreshToken(), 30L * 24 * 60 * 60); - return ApiResponse.success("로그인 성공", Map.of("accessToken", userInfo)); + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, refreshCookie.toString()) + .body(ApiResponse.success("로그인 성공", Map.of("accessToken", result.accessToken()))); + } + + @Operation(summary = "Access Token 재발급", description = "HttpOnly 쿠키의 Refresh Token으로 새 Access Token을 발급합니다.") + @SecurityRequirements + @PostMapping("/refresh") + public ApiResponse refresh( + @CookieValue(name = "refreshToken", required = false) String refreshToken) { + String newAccessToken = authService.refresh(refreshToken); + return ApiResponse.success("토큰 재발급 성공", Map.of("accessToken", newAccessToken)); } - @Operation(summary = "로그아웃", description = "토큰 유효성을 검증합니다. 실제 토큰 삭제는 클라이언트에서 처리합니다.") + @Operation(summary = "로그아웃", description = "Refresh Token을 Redis에서 삭제하고 쿠키를 만료시킵니다.") @PostMapping("/delete") - public ApiResponse logout(@LoginUserId int userId) { - return ApiResponse.success("로그아웃 성공", null); + public ResponseEntity> logout(@LoginUserId int userId) { + authService.logout(userId); + + // maxAge=0 으로 쿠키 즉시 만료 + ResponseCookie expiredCookie = buildRefreshCookie("", 0); + + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, expiredCookie.toString()) + .body(ApiResponse.success("로그아웃 성공", null)); + } + + /** + * Refresh Token 쿠키 빌더 + * - HttpOnly: JS 접근 차단 + * - Secure: HTTPS 전용 + * - SameSite=None: 크로스 도메인 허용 + * - Domain=.amon.p-e.kr: 서브도메인 간 쿠키 공유 + * - Path=/auth: 토큰 갱신·로그아웃 경로에만 전송 + */ + private ResponseCookie buildRefreshCookie(String value, long maxAgeSeconds) { + return ResponseCookie.from("refreshToken", value) + .httpOnly(true) + .sameSite("None") + .secure(true) + .domain(".amon.p-e.kr") + .path("/") + .maxAge(maxAgeSeconds) + .build(); } -} +} \ No newline at end of file diff --git a/amumal/src/main/java/com/kbt/amumal/domain/auth/service/AuthService.java b/amumal/src/main/java/com/kbt/amumal/domain/auth/service/AuthService.java index b61658d..312a4fa 100644 --- a/amumal/src/main/java/com/kbt/amumal/domain/auth/service/AuthService.java +++ b/amumal/src/main/java/com/kbt/amumal/domain/auth/service/AuthService.java @@ -7,17 +7,28 @@ import com.kbt.amumal.global.error.ErrorCode; import com.kbt.amumal.global.util.JwtUtil; import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; +import java.time.Duration; + @Service @RequiredArgsConstructor public class AuthService { + private final UserRepository userRepository; private final JwtUtil jwtUtil; private final PasswordEncoder passwordEncoder; + private final StringRedisTemplate redisTemplate; + + @Value("${jwt.refresh_expiration_time}") + private long refreshExpTimeMs; - public String userLogin(AuthReqDTO.LoginReq request) { + private static final String REFRESH_PREFIX = "RT:"; + + public LoginResult userLogin(AuthReqDTO.LoginReq request) { User loginUser = userRepository.findByEmail(request.email()) .orElseThrow(() -> new CustomException(ErrorCode.LOGIN_FAILED)); @@ -25,7 +36,41 @@ public String userLogin(AuthReqDTO.LoginReq request) { throw new CustomException(ErrorCode.LOGIN_FAILED); } - // 로그인 성공 시 JWT 발급 - return jwtUtil.createAccessToken(loginUser.getId(), loginUser.getEmail()); + String accessToken = jwtUtil.createAccessToken(loginUser.getId(), loginUser.getEmail()); + String refreshToken = jwtUtil.createRefreshToken(loginUser.getId(), loginUser.getEmail()); + + // 유저당 Refresh Token 1개만 유지 (기존 토큰 덮어쓰기 → 메모리 절약) + redisTemplate.opsForValue().set( + REFRESH_PREFIX + loginUser.getId(), + refreshToken, + Duration.ofMillis(refreshExpTimeMs) + ); + + return new LoginResult(accessToken, refreshToken); } + + public String refresh(String refreshToken) { + if (refreshToken == null || refreshToken.isBlank()) { + throw new CustomException(ErrorCode.TOKEN_MISSING); + } + + // 만료·위변조 시 GlobalExceptionHandler가 TOKEN_EXPIRED / TOKEN_INVALID 반환 + jwtUtil.validateToken(refreshToken); + + int userId = jwtUtil.getId(refreshToken); + String email = jwtUtil.getEmail(refreshToken); + + String savedToken = redisTemplate.opsForValue().get(REFRESH_PREFIX + userId); + if (savedToken == null || !savedToken.equals(refreshToken)) { + throw new CustomException(ErrorCode.TOKEN_INVALID); + } + + return jwtUtil.createAccessToken(userId, email); + } + + public void logout(int userId) { + redisTemplate.delete(REFRESH_PREFIX + userId); + } + + public record LoginResult(String accessToken, String refreshToken) {} } diff --git a/amumal/src/main/java/com/kbt/amumal/domain/comment/repository/commentRepository.java b/amumal/src/main/java/com/kbt/amumal/domain/comment/repository/commentRepository.java index ed2959b..3c7f794 100644 --- a/amumal/src/main/java/com/kbt/amumal/domain/comment/repository/commentRepository.java +++ b/amumal/src/main/java/com/kbt/amumal/domain/comment/repository/commentRepository.java @@ -1,13 +1,19 @@ package com.kbt.amumal.domain.comment.repository; import com.kbt.amumal.domain.comment.entity.Comment; +import com.kbt.amumal.domain.post.dto.CountProjection; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.List; public interface commentRepository extends JpaRepository { - long countByPostIdAndDeletedAtIsNull(int postId); List findByPostIdAndDeletedAtIsNullOrderByCreatedAtAsc(int postId); void deleteByPostId(int postId); void deleteByUserId(int userId); + + // N+1 방지: 여러 게시글의 댓글 수를 DTO Projection으로 한 번에 조회 + @Query("SELECT new com.kbt.amumal.domain.post.dto.CountProjection(c.postId, COUNT(c)) FROM Comment c WHERE c.postId IN :postIds AND c.deletedAt IS NULL GROUP BY c.postId") + List countsByPostIds(@Param("postIds") List postIds); } diff --git a/amumal/src/main/java/com/kbt/amumal/domain/post/dto/CountProjection.java b/amumal/src/main/java/com/kbt/amumal/domain/post/dto/CountProjection.java new file mode 100644 index 0000000..5e98cf1 --- /dev/null +++ b/amumal/src/main/java/com/kbt/amumal/domain/post/dto/CountProjection.java @@ -0,0 +1,3 @@ +package com.kbt.amumal.domain.post.dto; + +public record CountProjection(Integer postId, Long count) {} diff --git a/amumal/src/main/java/com/kbt/amumal/domain/post/entity/Post.java b/amumal/src/main/java/com/kbt/amumal/domain/post/entity/Post.java index 61efd89..6ccf9b7 100644 --- a/amumal/src/main/java/com/kbt/amumal/domain/post/entity/Post.java +++ b/amumal/src/main/java/com/kbt/amumal/domain/post/entity/Post.java @@ -25,6 +25,9 @@ public class Post extends BaseEntity { @Column(length = 500) private String postImageUrl; + @Column(length = 255) + private String postImageOriginalName; + @Column(nullable = false) private int userId; @@ -40,15 +43,13 @@ public void updateContent(String content) { this.content = content; } - public void updatePostImage(String imageUrl) { + public void updatePostImage(String imageUrl, String originalName) { this.postImageUrl = imageUrl; - } - - public void incrementViewCount() { - this.viewCount++; + this.postImageOriginalName = originalName; } public void clearPostImage() { this.postImageUrl = null; + this.postImageOriginalName = null; } } \ No newline at end of file diff --git a/amumal/src/main/java/com/kbt/amumal/domain/post/repository/LikeRepository.java b/amumal/src/main/java/com/kbt/amumal/domain/post/repository/LikeRepository.java index 7188e31..b5fb54d 100644 --- a/amumal/src/main/java/com/kbt/amumal/domain/post/repository/LikeRepository.java +++ b/amumal/src/main/java/com/kbt/amumal/domain/post/repository/LikeRepository.java @@ -1,7 +1,12 @@ package com.kbt.amumal.domain.post.repository; +import com.kbt.amumal.domain.post.dto.CountProjection; import com.kbt.amumal.domain.post.entity.Like; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; public interface LikeRepository extends JpaRepository { boolean existsByUserIdAndPostId(int userId, Integer postId); // 유저+게시글 존재하는지 (좋아요 눌렀는지) @@ -9,4 +14,8 @@ public interface LikeRepository extends JpaRepository { long countByPostId(Integer postId); // 게시글 좋아요 수 void deleteByPostId(Integer postId); void deleteByUserId(int userId); + + // N+1 방지: 여러 게시글의 좋아요 수를 DTO Projection으로 한 번에 조회 + @Query("SELECT new com.kbt.amumal.domain.post.dto.CountProjection(l.postId, COUNT(l)) FROM Like l WHERE l.postId IN :postIds GROUP BY l.postId") + List countsByPostIds(@Param("postIds") List postIds); } \ No newline at end of file diff --git a/amumal/src/main/java/com/kbt/amumal/domain/post/service/PostService.java b/amumal/src/main/java/com/kbt/amumal/domain/post/service/PostService.java index d9a4da3..fd2fbe4 100644 --- a/amumal/src/main/java/com/kbt/amumal/domain/post/service/PostService.java +++ b/amumal/src/main/java/com/kbt/amumal/domain/post/service/PostService.java @@ -2,12 +2,14 @@ import com.kbt.amumal.domain.comment.entity.Comment; import com.kbt.amumal.domain.comment.repository.commentRepository; +import com.kbt.amumal.domain.post.dto.CountProjection; import com.kbt.amumal.domain.post.dto.PostReqDTO; import com.kbt.amumal.domain.post.dto.PostResDTO; import com.kbt.amumal.domain.post.entity.Like; import com.kbt.amumal.domain.post.entity.Post; import com.kbt.amumal.domain.post.repository.LikeRepository; import com.kbt.amumal.domain.post.repository.PostRepository; +import com.kbt.amumal.domain.user.dto.UserProjection; import com.kbt.amumal.domain.user.entity.User; import com.kbt.amumal.domain.user.repository.UserRepository; import com.kbt.amumal.global.common.ImageHandler; @@ -20,6 +22,7 @@ import org.springframework.transaction.support.TransactionSynchronizationManager; import org.springframework.web.multipart.MultipartFile; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.stream.Collectors; @@ -39,10 +42,14 @@ public int create(int id, PostReqDTO.createPost request, MultipartFile postImage registerImageRollbackOnFailure(postImageUrl); + String originalName = (postImage != null && !postImage.isEmpty()) + ? postImage.getOriginalFilename() : null; + Post newPost = postRepository.save(Post.builder() .title(request.title()) .content(request.content()) .postImageUrl(postImageUrl) + .postImageOriginalName(originalName) .userId(id) .build()); @@ -76,7 +83,7 @@ public void afterCompletion(int status) { } }); - post.updatePostImage(newImageUrl); + post.updatePostImage(newImageUrl, postImage.getOriginalFilename()); } } @@ -100,27 +107,28 @@ public PostResDTO.postDetailResponse get(Integer postId) { postRepository.incrementViewCount(postId); // clearAutomatically = true → L1 캐시 초기화 post = postRepository.findById(postId).orElseThrow(); // 증가된 viewCount 반영 - User author = userRepository.findById(post.getUserId()).orElse(null); long likeCount = likeRepository.countByPostId(post.getPostId()); List comments = commentRepository .findByPostIdAndDeletedAtIsNullOrderByCreatedAtAsc(post.getPostId()); - // 댓글 작성자 ID 목록으로 한 번에 조회 (N+1 방지) - List commenterIds = comments.stream() - .map(Comment::getUserId) - .distinct() - .collect(Collectors.toList()); - Map commenterMap = userRepository.findAllById(commenterIds).stream() - .collect(Collectors.toMap(User::getId, u -> u)); + // 게시글 작성자 + 댓글 작성자 ID를 한 번에 모아 DTO Projection으로 조회 (N+1 방지) + List userIds = new ArrayList<>(); + userIds.add(post.getUserId()); + comments.stream().map(Comment::getUserId).distinct().forEach(userIds::add); + + Map userMap = userRepository.findProjectionsByIdIn(userIds).stream() + .collect(Collectors.toMap(UserProjection::id, u -> u)); + + UserProjection author = userMap.get(post.getUserId()); List commentItems = comments.stream() .map(comment -> { - User commenter = commenterMap.get(comment.getUserId()); + UserProjection commenter = userMap.get(comment.getUserId()); return new PostResDTO.commentItem( comment.getCommentId(), comment.getContent(), - commenter != null ? new PostResDTO.userInfo(commenter.getUserId(), commenter.getNickname(), commenter.getProfileImageUrl()) : null, + commenter != null ? new PostResDTO.userInfo(commenter.userId(), commenter.nickname(), commenter.profileImageUrl()) : null, comment.getCreatedAt() ); }) @@ -133,7 +141,7 @@ public PostResDTO.postDetailResponse get(Integer postId) { post.getPostImageUrl(), likeCount, post.getViewCount(), - author != null ? new PostResDTO.userInfo(author.getUserId(), author.getNickname(), author.getProfileImageUrl()) : null, + author != null ? new PostResDTO.userInfo(author.userId(), author.nickname(), author.profileImageUrl()) : null, post.getCreatedAt(), commentItems ); @@ -147,18 +155,23 @@ public PostResDTO.postListResponse getList(Integer cursor, int size) { Integer nextCursor = hasNext ? posts.get(posts.size() - 1).getPostId() : null; - // 게시글 작성자 ID 목록으로 한 번에 조회 (N+1 방지) - List authorIds = posts.stream() - .map(Post::getUserId) - .distinct() - .collect(Collectors.toList()); - Map authorMap = userRepository.findAllById(authorIds).stream() - .collect(Collectors.toMap(User::getId, u -> u)); + List postIds = posts.stream().map(Post::getPostId).collect(Collectors.toList()); + + // 작성자 DTO Projection으로 한 번에 조회 + List authorIds = posts.stream().map(Post::getUserId).distinct().collect(Collectors.toList()); + Map authorMap = userRepository.findProjectionsByIdIn(authorIds).stream() + .collect(Collectors.toMap(UserProjection::id, u -> u)); + + // 카운트 DTO Projection으로 한 번에 조회 + Map likeCounts = likeRepository.countsByPostIds(postIds).stream() + .collect(Collectors.toMap(CountProjection::postId, CountProjection::count)); + Map commentCounts = commentRepository.countsByPostIds(postIds).stream() + .collect(Collectors.toMap(CountProjection::postId, CountProjection::count)); List items = posts.stream().map(post -> { - User user = authorMap.get(post.getUserId()); - long likeCount = likeRepository.countByPostId(post.getPostId()); - long commentCount = commentRepository.countByPostIdAndDeletedAtIsNull(post.getPostId()); + UserProjection user = authorMap.get(post.getUserId()); + long likeCount = likeCounts.getOrDefault(post.getPostId(), 0L); + long commentCount = commentCounts.getOrDefault(post.getPostId(), 0L); return new PostResDTO.postListItem( post.getPostId(), @@ -167,7 +180,7 @@ public PostResDTO.postListResponse getList(Integer cursor, int size) { likeCount, commentCount, post.getViewCount(), - user != null ? new PostResDTO.userInfo(user.getUserId(), user.getNickname(), user.getProfileImageUrl()) : null, + user != null ? new PostResDTO.userInfo(user.userId(), user.nickname(), user.profileImageUrl()) : null, post.getCreatedAt() ); }).collect(Collectors.toList()); diff --git a/amumal/src/main/java/com/kbt/amumal/domain/user/controller/UserController.java b/amumal/src/main/java/com/kbt/amumal/domain/user/controller/UserController.java index a377f3a..6f19a2c 100644 --- a/amumal/src/main/java/com/kbt/amumal/domain/user/controller/UserController.java +++ b/amumal/src/main/java/com/kbt/amumal/domain/user/controller/UserController.java @@ -66,11 +66,11 @@ public ApiResponse updateUserPassword(@LoginUserId int userId, @RequestBody @ return ApiResponse.success("유저 비밀번호 수정 성공", null); } - @Operation(summary = "프로필 이미지 수정") + @Operation(summary = "프로필 이미지 수정", description = "파일을 전송하면 이미지 변경, 파일 없이 요청하면 이미지를 null로 초기화합니다.") @PutMapping(value = "/profileImage", consumes = "multipart/form-data") public ApiResponse updateUserProfileImage( @LoginUserId int userId, - @RequestParam MultipartFile profileImage + @RequestParam(required = false) MultipartFile profileImage ) { userService.updateProfileImage(userId, profileImage); diff --git a/amumal/src/main/java/com/kbt/amumal/domain/user/dto/UserProjection.java b/amumal/src/main/java/com/kbt/amumal/domain/user/dto/UserProjection.java new file mode 100644 index 0000000..dc78c0b --- /dev/null +++ b/amumal/src/main/java/com/kbt/amumal/domain/user/dto/UserProjection.java @@ -0,0 +1,3 @@ +package com.kbt.amumal.domain.user.dto; + +public record UserProjection(Integer id, String userId, String nickname, String profileImageUrl) {} diff --git a/amumal/src/main/java/com/kbt/amumal/domain/user/entity/User.java b/amumal/src/main/java/com/kbt/amumal/domain/user/entity/User.java index 45706f6..ab2abf2 100644 --- a/amumal/src/main/java/com/kbt/amumal/domain/user/entity/User.java +++ b/amumal/src/main/java/com/kbt/amumal/domain/user/entity/User.java @@ -32,6 +32,9 @@ public class User extends BaseEntity { @Column(length = 500) private String profileImageUrl; + @Column(length = 255) + private String profileImageOriginalName; + @PrePersist private void generateUserId() { if (userId == null) { @@ -47,7 +50,8 @@ public void updatePassword(String password) { this.password = password; } - public void updateProfileImage(String profileImageUrl) { + public void updateProfileImage(String profileImageUrl, String originalName) { this.profileImageUrl = profileImageUrl; + this.profileImageOriginalName = originalName; } } diff --git a/amumal/src/main/java/com/kbt/amumal/domain/user/repository/UserRepository.java b/amumal/src/main/java/com/kbt/amumal/domain/user/repository/UserRepository.java index 46fe9fc..1e93029 100644 --- a/amumal/src/main/java/com/kbt/amumal/domain/user/repository/UserRepository.java +++ b/amumal/src/main/java/com/kbt/amumal/domain/user/repository/UserRepository.java @@ -1,9 +1,10 @@ package com.kbt.amumal.domain.user.repository; +import com.kbt.amumal.domain.user.dto.UserProjection; import com.kbt.amumal.domain.user.entity.User; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.Size; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.time.LocalDateTime; import java.util.List; @@ -14,4 +15,8 @@ public interface UserRepository extends JpaRepository { Optional findByNickname(String nickname); Optional findByUserId(String userId); List findByDeletedAtIsNotNullAndDeletedAtBefore(LocalDateTime cutoff); + + // N+1 방지: 여러 유저 정보를 DTO Projection으로 한 번에 조회 + @Query("SELECT new com.kbt.amumal.domain.user.dto.UserProjection(u.id, u.userId, u.nickname, u.profileImageUrl) FROM User u WHERE u.id IN :ids") + List findProjectionsByIdIn(@Param("ids") List ids); } \ No newline at end of file diff --git a/amumal/src/main/java/com/kbt/amumal/domain/user/service/UserService.java b/amumal/src/main/java/com/kbt/amumal/domain/user/service/UserService.java index 229acfd..b6ee66a 100644 --- a/amumal/src/main/java/com/kbt/amumal/domain/user/service/UserService.java +++ b/amumal/src/main/java/com/kbt/amumal/domain/user/service/UserService.java @@ -34,11 +34,15 @@ public String create(UserReqDTO.Signup request, MultipartFile profileImage) { // 이미지 업로드 성공 후 DB 저장에 실패하면 트랜잭션 롤백 콜백에서 파일 정리 registerImageRollbackOnFailure(profileImageUrl); + String originalName = (profileImage != null && !profileImage.isEmpty()) + ? profileImage.getOriginalFilename() : null; + User newUser = userRepository.save(User.builder() .email(request.email()) .password(passwordEncoder.encode(request.password())) .nickname(request.nickname()) .profileImageUrl(profileImageUrl) + .profileImageOriginalName(originalName) .build()); return newUser.getUserId(); @@ -83,6 +87,23 @@ public void updateProfileImage(int id, MultipartFile profileImage) { .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); String oldImageUrl = user.getProfileImageUrl(); + + // null 또는 빈 파일 → 이미지 초기화 + if (profileImage == null || profileImage.isEmpty()) { + user.updateProfileImage(null, null); + if (oldImageUrl != null) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCompletion(int status) { + if (status == TransactionSynchronization.STATUS_COMMITTED) { + fileService.deleteSafely(oldImageUrl); + } + } + }); + } + return; + } + String newImageUrl = fileService.profileSave(profileImage); // 커밋 성공 시 기존 이미지 삭제, 롤백 시 새로 업로드한 이미지 삭제 @@ -97,7 +118,7 @@ public void afterCompletion(int status) { } }); - user.updateProfileImage(newImageUrl); + user.updateProfileImage(newImageUrl, profileImage.getOriginalFilename()); } private String uploadImageIfPresent(MultipartFile file) { diff --git a/amumal/src/main/java/com/kbt/amumal/global/common/ImageHandler.java b/amumal/src/main/java/com/kbt/amumal/global/common/ImageHandler.java index 0854f32..967189b 100644 --- a/amumal/src/main/java/com/kbt/amumal/global/common/ImageHandler.java +++ b/amumal/src/main/java/com/kbt/amumal/global/common/ImageHandler.java @@ -2,32 +2,36 @@ import com.kbt.amumal.global.error.CustomException; import com.kbt.amumal.global.error.ErrorCode; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; import java.io.IOException; import java.io.InputStream; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.nio.file.StandardCopyOption; import java.util.Set; import java.util.UUID; /** - * 이미지 파일 저장/삭제를 담당하는 핸들러. + * 이미지 파일 S3 저장/삭제를 담당하는 핸들러. * * 저장 시 3단계 보안 검증을 순서대로 수행한다. * 1. 파일 확장자 검증 (화이트리스트 방식) * 2. Content-Type 헤더 검증 (MIME 타입) * 3. Magic Bytes 검증 (실제 파일 내용) * - * Content-Type은 클라이언트가 임의로 위조할 수 있으므로 Magic Bytes 검증이 최종 보루가 된다. + * 저장 경로: + * - 프로필: {bucket}/backend/profile/{uuid}_{filename} + * - 게시글: {bucket}/backend/post/{uuid}_{filename} */ @Slf4j @Service +@RequiredArgsConstructor public class ImageHandler { /** 허용하는 파일 확장자 목록 (소문자 기준) */ @@ -38,59 +42,31 @@ public class ImageHandler { "image/jpg", "image/jpeg", "image/png", "image/gif", "image/webp" ); - @Value("${file.upload-dir}") - private String uploadDir; + private static final String PROFILE_PREFIX = "backend/profile/"; + private static final String POST_PREFIX = "backend/post/"; - @Value("${file.profile-access-path}") - private String profileAccessPath; + private final S3Client s3Client; - @Value("${file.posts-access-path}") - private String postsAccessPath; + @Value("${aws.s3.bucket}") + private String bucket; + + @Value("${aws.region}") + private String region; - /** - * 프로필 이미지를 저장하고 접근 URL을 반환한다. - * - * @param file 업로드된 MultipartFile - * @return 저장된 파일의 접근 경로 (예: /profiles/uuid_filename.jpg) - * @throws CustomException 디렉토리 미존재, 유효성 검증 실패, IO 오류 시 - */ public String profileSave(MultipartFile file) { - checkUploadDir(); validateImage(file); - String filename = UUID.randomUUID() + "_" + file.getOriginalFilename(); - Path imageFilePath = Paths.get(uploadDir, filename); - try { - Files.copy(file.getInputStream(), imageFilePath, StandardCopyOption.REPLACE_EXISTING); - } catch (IOException e) { - log.error("프로필 이미지 저장 실패: {}", filename, e); - throw new CustomException(ErrorCode.IMAGE_UPLOAD_FAILED); - } - return profileAccessPath + "/" + filename; + String key = PROFILE_PREFIX + UUID.randomUUID() + extractExtension(file.getOriginalFilename()); + return upload(file, key); } - /** - * 게시글 이미지를 저장하고 접근 URL을 반환한다. - * - * @param file 업로드된 MultipartFile - * @return 저장된 파일의 접근 경로 (예: /profiles/uuid_filename.jpg) - * @throws CustomException 디렉토리 미존재, 유효성 검증 실패, IO 오류 시 - */ public String postSave(MultipartFile file) { - checkUploadDir(); validateImage(file); - String filename = UUID.randomUUID() + "_" + file.getOriginalFilename(); - Path imageFilePath = Paths.get(uploadDir, filename); - try { - Files.copy(file.getInputStream(), imageFilePath, StandardCopyOption.REPLACE_EXISTING); - } catch (IOException e) { - log.error("게시글 이미지 저장 실패: {}", filename, e); - throw new CustomException(ErrorCode.IMAGE_UPLOAD_FAILED); - } - return postsAccessPath + "/" + filename; + String key = POST_PREFIX + UUID.randomUUID() + extractExtension(file.getOriginalFilename()); + return upload(file, key); } /** - * 이미지 URL에 해당하는 파일을 디스크에서 삭제한다. + * S3에서 이미지를 삭제한다. * URL이 null이거나 빈 값이면 아무 작업도 하지 않는다. * 삭제 실패 시 예외를 던지지 않고 경고 로그만 남긴다. * 트랜잭션 콜백 등 실패해도 롤백할 수 없는 컨텍스트에서 사용한다. @@ -99,26 +75,46 @@ public String postSave(MultipartFile file) { */ public void deleteSafely(String imageUrl) { if (imageUrl == null || imageUrl.isBlank()) return; - String filename = Paths.get(imageUrl).getFileName().toString(); - Path filePath = Paths.get(uploadDir, filename); try { - Files.deleteIfExists(filePath); - } catch (IOException e) { - log.warn("이미지 삭제 실패 (고아 파일 가능성): {}", filePath, e); + String key = extractKey(imageUrl); + s3Client.deleteObject(DeleteObjectRequest.builder() + .bucket(bucket) + .key(key) + .build()); + } catch (Exception e) { + log.warn("S3 이미지 삭제 실패 (고아 파일 가능성): {}", imageUrl, e); } } - /** - * 업로드 디렉토리가 실제로 존재하는지 확인한다. - * 서버 설정 오류나 마운트 해제 등의 상황을 저장 전에 조기 감지하기 위해 매 저장마다 호출한다. - */ - private void checkUploadDir() { - Path dirPath = Paths.get(uploadDir); - if (!Files.exists(dirPath) || !Files.isDirectory(dirPath)) { - throw new CustomException(ErrorCode.UPLOAD_DIR_NOT_FOUND); + private String upload(MultipartFile file, String key) { + try { + PutObjectRequest request = PutObjectRequest.builder() + .bucket(bucket) + .key(key) + .contentType(file.getContentType()) + .contentLength(file.getSize()) + .build(); + s3Client.putObject(request, RequestBody.fromBytes(file.getBytes())); + return "https://" + bucket + ".s3." + region + ".amazonaws.com/" + key; + } catch (IOException e) { + log.error("S3 이미지 업로드 실패: {}", key, e); + throw new CustomException(ErrorCode.IMAGE_UPLOAD_FAILED); } } + /** 파일명에서 `.ext` 형식의 확장자를 반환한다. 확장자 없으면 빈 문자열. */ + private String extractExtension(String originalFilename) { + if (originalFilename == null || !originalFilename.contains(".")) return ""; + return "." + originalFilename.substring(originalFilename.lastIndexOf('.') + 1).toLowerCase(); + } + + /** URL에서 S3 객체 키를 추출한다 (`.amazonaws.com/` 이후 부분). */ + private String extractKey(String imageUrl) { + int idx = imageUrl.indexOf(".amazonaws.com/"); + if (idx == -1) return imageUrl; + return imageUrl.substring(idx + ".amazonaws.com/".length()); + } + /** * 확장자 → Content-Type → Magic Bytes 순서로 이미지 유효성을 검증한다. * 앞 단계에서 실패하면 이후 단계는 실행되지 않는다. diff --git a/amumal/src/main/java/com/kbt/amumal/global/config/S3Config.java b/amumal/src/main/java/com/kbt/amumal/global/config/S3Config.java new file mode 100644 index 0000000..200ea26 --- /dev/null +++ b/amumal/src/main/java/com/kbt/amumal/global/config/S3Config.java @@ -0,0 +1,31 @@ +package com.kbt.amumal.global.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; + +@Configuration +public class S3Config { + + @Value("${aws.credentials.access-key}") + private String accessKey; + + @Value("${aws.credentials.secret-key}") + private String secretKey; + + @Value("${aws.region}") + private String region; + + @Bean + public S3Client s3Client() { + return S3Client.builder() + .region(Region.of(region)) + .credentialsProvider(StaticCredentialsProvider.create( + AwsBasicCredentials.create(accessKey, secretKey))) + .build(); + } +} diff --git a/amumal/src/main/java/com/kbt/amumal/global/config/WebConfig.java b/amumal/src/main/java/com/kbt/amumal/global/config/WebConfig.java index cf4a979..46c41fd 100644 --- a/amumal/src/main/java/com/kbt/amumal/global/config/WebConfig.java +++ b/amumal/src/main/java/com/kbt/amumal/global/config/WebConfig.java @@ -3,12 +3,10 @@ import com.kbt.amumal.global.interceptor.AuthInterceptor; import com.kbt.amumal.global.interceptor.LoginUserArgumentResolver; import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Configuration; import org.springframework.web.method.support.HandlerMethodArgumentResolver; import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; -import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import java.util.List; @@ -17,15 +15,6 @@ @RequiredArgsConstructor public class WebConfig implements WebMvcConfigurer { - @Value("${file.upload-dir}") - private String uploadDir; - - @Value("${file.profile-access-path}") - private String profileAccessPath; - - @Value("${file.posts-access-path}") - private String postsAccessPath; - private final AuthInterceptor authInterceptor; private final LoginUserArgumentResolver loginUserArgumentResolver; @@ -40,20 +29,8 @@ public void addCorsMappings(CorsRegistry registry) { "http://localhost:8080" ) .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS") - .allowedHeaders("Content-Type", "Authorization"); - } - - // 정적 리소스 경로 매핑: env의 접근 경로 요청을 실제 업로드 디렉토리로 연결 - @Override - public void addResourceHandlers(ResourceHandlerRegistry registry) { - String location = "file:" + uploadDir + (uploadDir.endsWith("/") ? "" : "/"); - registry.addResourceHandler(profileAccessPath + "/**").addResourceLocations(location); - // /post-images/** 는 API 컨트롤러(/posts/**)와 충돌하지 않는 전용 정적 이미지 경로 - registry.addResourceHandler("/post-images/**").addResourceLocations(location); - if (!postsAccessPath.equals(profileAccessPath) && !postsAccessPath.equals("/post-images")) { - // 기존 /posts/** 경로로 저장된 이미지도 계속 서빙 (하위 호환) - registry.addResourceHandler(postsAccessPath + "/**").addResourceLocations(location); - } + .allowedHeaders("Content-Type", "Authorization") + .allowCredentials(true); // 쿠키 포함 요청 허용 (SameSite=None Secure 쿠키와 함께 사용) } // 인터셉터 - 모든 요청에 AuthInterceptor 적용 (@LoginUserId 없는 경로는 적용 안됨) diff --git a/amumal/src/main/java/com/kbt/amumal/global/error/ErrorCode.java b/amumal/src/main/java/com/kbt/amumal/global/error/ErrorCode.java index 63aa72a..10694dd 100644 --- a/amumal/src/main/java/com/kbt/amumal/global/error/ErrorCode.java +++ b/amumal/src/main/java/com/kbt/amumal/global/error/ErrorCode.java @@ -47,7 +47,6 @@ public enum ErrorCode { // 이미지 INVALID_IMAGE_EXTENSION(HttpStatus.BAD_REQUEST, "유효하지 않은 요청입니다.", "허용되지 않는 이미지 확장자입니다. (jpg, jpeg, png, gif, webp)"), INVALID_IMAGE_MIME_TYPE(HttpStatus.BAD_REQUEST, "유효하지 않은 요청입니다.", "유효하지 않은 이미지 파일입니다."), - UPLOAD_DIR_NOT_FOUND(HttpStatus.INTERNAL_SERVER_ERROR, "서버 에러", "업로드 디렉토리를 찾을 수 없습니다."), IMAGE_UPLOAD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "서버 에러", "이미지 업로드에 실패했습니다."); private final HttpStatus status; diff --git a/amumal/src/main/java/com/kbt/amumal/global/interceptor/LoginUserArgumentResolver.java b/amumal/src/main/java/com/kbt/amumal/global/interceptor/LoginUserArgumentResolver.java index 5371a09..343ba7e 100644 --- a/amumal/src/main/java/com/kbt/amumal/global/interceptor/LoginUserArgumentResolver.java +++ b/amumal/src/main/java/com/kbt/amumal/global/interceptor/LoginUserArgumentResolver.java @@ -11,7 +11,6 @@ @Component public class LoginUserArgumentResolver implements HandlerMethodArgumentResolver { - // @LoginUserId 어노테이션이 붙은 파라미터일 때만 이 Resolver가 동작하도록 true 반환 @Override public boolean supportsParameter(MethodParameter parameter) { return parameter.hasParameterAnnotation(LoginUserId.class); @@ -21,6 +20,11 @@ public boolean supportsParameter(MethodParameter parameter) { @Override public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { + Class type = parameter.getParameterType(); + if (type != int.class && type != Integer.class) { + throw new IllegalStateException("@LoginUserId는 int 또는 Integer 타입에만 사용할 수 있습니다."); + } + HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest(); return request.getAttribute("userId"); // @LoginUserId 파라미터에 자동으로 주입 } diff --git a/amumal/src/main/java/com/kbt/amumal/global/util/JwtUtil.java b/amumal/src/main/java/com/kbt/amumal/global/util/JwtUtil.java index b387b6f..6d2d140 100644 --- a/amumal/src/main/java/com/kbt/amumal/global/util/JwtUtil.java +++ b/amumal/src/main/java/com/kbt/amumal/global/util/JwtUtil.java @@ -8,6 +8,7 @@ import org.springframework.stereotype.Component; import java.security.Key; +import java.time.Duration; import java.time.ZonedDateTime; import java.util.Date; @@ -16,15 +17,18 @@ public class JwtUtil { private final Key key; // 서명에 사용할 HMAC 키 - private final long accessTokenExpTime; // 액세스 토큰 만료 시간 (초) + private final long accessTokenExpTime; // 액세스 토큰 만료 시간 (초) + private final long refreshTokenExpTime; // 리프레시 토큰 만료 시간 (초) public JwtUtil( @Value("${jwt.secret}") String secretKey, - @Value("${jwt.expiration_time}") long accessTokenExpTime + @Value("${jwt.expiration_time}") long accessTokenExpTime, + @Value("${jwt.refresh_expiration_time}") long refreshTokenExpTime ) { byte[] keyBytes = Decoders.BASE64.decode(secretKey); // Base64 디코딩 this.key = Keys.hmacShaKeyFor(keyBytes); // HMAC-SHA 키 생성 this.accessTokenExpTime = accessTokenExpTime; + this.refreshTokenExpTime = refreshTokenExpTime; } // 액세스 토큰 생성 @@ -32,10 +36,15 @@ public String createAccessToken(int id, String email) { return createToken(id, email, accessTokenExpTime); } + // 리프레시 토큰 생성 + public String createRefreshToken(int id, String email) { + return createToken(id, email, refreshTokenExpTime); + } + // JWT 생성 - private String createToken(int id, String email, long expireTime) { + private String createToken(int id, String email, long expireTimeMs) { ZonedDateTime now = ZonedDateTime.now(); - ZonedDateTime tokenValidity = now.plusSeconds(expireTime); + ZonedDateTime tokenValidity = now.plus(Duration.ofMillis(expireTimeMs)); return Jwts.builder() .claim("id", id) @@ -51,7 +60,11 @@ public int getId(String token) { return parseClaims(token).get("id", Integer.class); } - // JWT 유효성 검증 + // 토큰에서 email 추출 + public String getEmail(String token) { + return parseClaims(token).get("email", String.class); + } + public boolean validateToken(String token) { try { Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token); @@ -71,9 +84,9 @@ public boolean validateToken(String token) { } // JWT 페이로드 추출 - public Claims parseClaims(String accessToken) { + public Claims parseClaims(String token) { try { - return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody(); + return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody(); } catch (ExpiredJwtException e) { return e.getClaims(); // 만료된 토큰도 claims 반환 (토큰 재발급 시 userId 필요) } diff --git a/amumal/src/main/resources/application.properties b/amumal/src/main/resources/application.properties index 7b31717..b9b8044 100644 --- a/amumal/src/main/resources/application.properties +++ b/amumal/src/main/resources/application.properties @@ -1,23 +1,55 @@ spring.application.name=amumal -spring.datasource.url=jdbc:mysql://${DB_HOST}:3306/${DB_NAME}?allowPublicKeyRetrieval=true&useSSL=false&serverTimezone=UTC&characterEncoding=UTF-8 +spring.datasource.url=jdbc:mysql://${DB_HOST}:3306/${DB_NAME}?allowPublicKeyRetrieval=true&useSSL=false&serverTimezone=Asia/Seoul&characterEncoding=UTF-8 spring.datasource.username=${DB_USERNAME} spring.datasource.password=${DB_PASSWORD} spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver -spring.jpa.hibernate.ddl-auto=update +spring.jpa.hibernate.ddl-auto=${DDL_AUTO_SET} spring.jpa.show-sql=true spring.jpa.database-platform=org.hibernate.dialect.MySQLDialect - -file.upload-dir=${UPLOAD_DIR} -file.profile-access-path=${PROFILE_FILE_ACCESS_PATH} -file.posts-access-path=${POSTS_FILE_ACCESS_PATH} +spring.jpa.properties.hibernate.jdbc.time_zone=Asia/Seoul spring.servlet.multipart.max-file-size=${MAX_FILE_SIZE} spring.servlet.multipart.max-request-size=${MAX_REQUEST_SIZE} -jwt.expiration_time=86400000 +# AWS S3 +aws.credentials.access-key=${AWS_ACCESS_KEY} +aws.credentials.secret-key=${AWS_SECRET_KEY} +aws.region=${AWS_REGION} +aws.s3.bucket=${AWS_S3_BUCKET} + +# Access Token: 1시간(ms), Refresh Token: 30일(ms) +jwt.expiration_time=3600000 +jwt.refresh_expiration_time=2592000000 jwt.secret=${JWT_SECRET} +server.forward-headers-strategy=framework + +# Redis +spring.data.redis.host=${REDIS_HOST:localhost} +spring.data.redis.port=${REDIS_PORT:6379} +spring.data.redis.password=${REDIS_PASSWORD:} + +# JSON 직렬화 타임존 +spring.jackson.time-zone=Asia/Seoul + springdoc.enable-data-rest=false -server.url=${SERVER_URL:http://localhost:8080} \ No newline at end of file +server.url=${SERVER_URL} + +# 로그 파일 이름 설정 +logging.file.name=/home/ubuntu/KBT/amumal/logs/app.log + +# 로그 레벨 설정 +logging.level.root=INFO +logging.level.com.kbt.amumal=DEBUG + +# 하루 단위로 로그를 쪼개기 -> 최대 7일치만 보관, 나머지 삭제 +logging.logback.rollingpolicy.max-history=7 + +# 파일 한 개당 최대 10MB까지만 커지게 제한 (넘으면 .gz 압축) +logging.logback.rollingpolicy.max-file-size=10MB + +# 패턴 설정 +logging.pattern.console=%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n +logging.pattern.file=%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n \ No newline at end of file