From 7ffcefbec2c8abb75ca04557509dbca9b00ae0f1 Mon Sep 17 00:00:00 2001 From: huouvcti Date: Mon, 2 Jun 2025 21:10:41 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=EC=98=88=EC=99=B8=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 전역 예외 처리 핸들러 구현 - 커스텀 응답 예외 추가 - 에러 enum 추가 - security 관련 예외 응답 처리 수정 --- .../example/feeda/config/SecurityConfig.java | 25 +--- .../account/controller/AccountController.java | 13 +- .../domain/account/sevice/AccountService.java | 90 +------------ .../account/sevice/AccountServiceImpl.java | 96 ++++++++++++++ .../comment/service/CommentLikeService.java | 12 +- .../comment/service/CommentService.java | 21 ++- .../follow/service/FollowsServiceImpl.java | 16 +-- .../domain/post/service/PostServiceImpl.java | 16 +-- .../profile/controller/ProfileController.java | 6 +- .../profile/service/ProfileService.java | 121 ++---------------- .../profile/service/ProfileServiceImpl.java | 116 +++++++++++++++++ .../exception/CustomResponseException.java | 17 +++ .../exception/GlobalExceptionHandler.java | 67 ++++++++++ .../exception/JwtValidationException.java | 13 -- .../feeda/exception/enums/ResponseError.java | 46 +++++++ .../exception/enums/ServletResponseError.java | 28 ++++ .../com/example/feeda/filter/JwtFilter.java | 17 +-- .../handler/CustomAccessDeniedHandler.java | 38 ++++++ .../CustomAuthenticationEntryPoint.java | 39 ++++++ .../example/feeda/security/jwt/JwtUtil.java | 2 +- 20 files changed, 517 insertions(+), 282 deletions(-) create mode 100644 src/main/java/com/example/feeda/domain/account/sevice/AccountServiceImpl.java create mode 100644 src/main/java/com/example/feeda/domain/profile/service/ProfileServiceImpl.java create mode 100644 src/main/java/com/example/feeda/exception/CustomResponseException.java create mode 100644 src/main/java/com/example/feeda/exception/GlobalExceptionHandler.java delete mode 100644 src/main/java/com/example/feeda/exception/JwtValidationException.java create mode 100644 src/main/java/com/example/feeda/exception/enums/ResponseError.java create mode 100644 src/main/java/com/example/feeda/exception/enums/ServletResponseError.java create mode 100644 src/main/java/com/example/feeda/security/handler/CustomAccessDeniedHandler.java create mode 100644 src/main/java/com/example/feeda/security/handler/CustomAuthenticationEntryPoint.java diff --git a/src/main/java/com/example/feeda/config/SecurityConfig.java b/src/main/java/com/example/feeda/config/SecurityConfig.java index f0d13ff..80ac914 100644 --- a/src/main/java/com/example/feeda/config/SecurityConfig.java +++ b/src/main/java/com/example/feeda/config/SecurityConfig.java @@ -1,9 +1,10 @@ package com.example.feeda.config; import com.example.feeda.filter.JwtFilter; +import com.example.feeda.security.handler.CustomAccessDeniedHandler; +import com.example.feeda.security.handler.CustomAuthenticationEntryPoint; import com.example.feeda.security.jwt.JwtBlacklistService; import com.example.feeda.security.jwt.JwtUtil; -import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -22,6 +23,8 @@ public class SecurityConfig { private final JwtBlacklistService jwtBlacklistService; private final JwtUtil jwtUtil; + private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint; + private final CustomAccessDeniedHandler customAccessDeniedHandler; @Bean public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception { @@ -43,10 +46,6 @@ public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Excepti .requestMatchers("/error").permitAll() .requestMatchers("/api/**").authenticated() - // 비로그인 시 GET 만 허용 -// .requestMatchers(HttpMethod.GET, "/api/**").permitAll() -// .requestMatchers("/api/**").permitAll() - .anyRequest().denyAll() ) @@ -55,20 +54,8 @@ public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Excepti .exceptionHandling(configurer -> configurer - .authenticationEntryPoint((request, response, authException) -> { - response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); - response.setContentType("application/json;charset=UTF-8"); - - String message = "{\"error\": \"인증 실패: " + authException.getMessage() + "\"}"; - response.getWriter().write(message); - }) - .accessDeniedHandler((request, response, accessDeniedException) -> { - response.setStatus(HttpServletResponse.SC_FORBIDDEN); - response.setContentType("application/json;charset=UTF-8"); - - String message = "{\"error\": \"접근 거부: " + accessDeniedException.getMessage() + "\"}"; - response.getWriter().write(message); - }) + .authenticationEntryPoint(customAuthenticationEntryPoint) + .accessDeniedHandler(customAccessDeniedHandler) ) .build(); diff --git a/src/main/java/com/example/feeda/domain/account/controller/AccountController.java b/src/main/java/com/example/feeda/domain/account/controller/AccountController.java index a9dcf48..2773b65 100644 --- a/src/main/java/com/example/feeda/domain/account/controller/AccountController.java +++ b/src/main/java/com/example/feeda/domain/account/controller/AccountController.java @@ -1,10 +1,11 @@ package com.example.feeda.domain.account.controller; -import com.example.feeda.domain.account.sevice.AccountService; +import com.example.feeda.domain.account.sevice.AccountServiceImpl; import com.example.feeda.domain.account.dto.*; import com.example.feeda.security.jwt.JwtBlacklistService; import com.example.feeda.security.jwt.JwtPayload; import com.example.feeda.security.jwt.JwtUtil; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; @@ -16,12 +17,12 @@ @RequestMapping("/api") @RequiredArgsConstructor public class AccountController { - private final AccountService accountService; + private final AccountServiceImpl accountService; private final JwtBlacklistService jwtBlacklistService; private final JwtUtil jwtUtil; @PostMapping("/accounts") - public ResponseEntity signup(@RequestBody SignUpRequestDTO requestDTO) { + public ResponseEntity signup(@RequestBody @Valid SignUpRequestDTO requestDTO) { return new ResponseEntity<>(accountService.signup(requestDTO), HttpStatus.CREATED); } @@ -29,7 +30,7 @@ public ResponseEntity signup(@RequestBody SignUpRequestDTO requ public ResponseEntity deleteAccount( @RequestHeader("Authorization") String bearerToken, @AuthenticationPrincipal JwtPayload jwtPayload, - @RequestBody DeleteAccountRequestDTO requestDTO + @RequestBody @Valid DeleteAccountRequestDTO requestDTO ) { accountService.deleteAccount(jwtPayload.getAccountId(), requestDTO.getPassword()); @@ -42,14 +43,14 @@ public ResponseEntity deleteAccount( @PatchMapping("/accounts/password") public ResponseEntity updatePassword( @AuthenticationPrincipal JwtPayload jwtPayload, - @RequestBody UpdatePasswordRequestDTO requestDTO + @RequestBody @Valid UpdatePasswordRequestDTO requestDTO ) { return new ResponseEntity<>(accountService.updatePassword(jwtPayload.getAccountId(), requestDTO), HttpStatus.OK); } @PostMapping("/accounts/login") public ResponseEntity login( - @RequestBody LogInRequestDTO requestDTO + @RequestBody @Valid LogInRequestDTO requestDTO ) { UserResponseDTO responseDTO = accountService.login(requestDTO); diff --git a/src/main/java/com/example/feeda/domain/account/sevice/AccountService.java b/src/main/java/com/example/feeda/domain/account/sevice/AccountService.java index 69ef961..9c18c4a 100644 --- a/src/main/java/com/example/feeda/domain/account/sevice/AccountService.java +++ b/src/main/java/com/example/feeda/domain/account/sevice/AccountService.java @@ -1,94 +1,16 @@ package com.example.feeda.domain.account.sevice; import com.example.feeda.domain.account.dto.LogInRequestDTO; +import com.example.feeda.domain.account.dto.SignUpRequestDTO; import com.example.feeda.domain.account.dto.UpdatePasswordRequestDTO; import com.example.feeda.domain.account.dto.UserResponseDTO; -import com.example.feeda.domain.account.dto.SignUpRequestDTO; -import com.example.feeda.domain.account.entity.Account; -import com.example.feeda.domain.account.repository.AccountRepository; -import com.example.feeda.domain.profile.entity.Profile; -import com.example.feeda.domain.profile.repository.ProfileRepository; -import com.example.feeda.security.PasswordEncoder; -import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.server.ResponseStatusException; - - - -@Service -@RequiredArgsConstructor -public class AccountService { - private final AccountRepository accountRepository; - private final PasswordEncoder passwordEncoder; - private final ProfileRepository profileRepository; - - @Transactional - public UserResponseDTO signup(SignUpRequestDTO requestDTO) { - if(accountRepository.findByEmail(requestDTO.getEmail()).isPresent()) { - throw new ResponseStatusException(HttpStatus.CONFLICT, "이미 존재하는 이메일 입니다. : " + requestDTO.getEmail()); - } - - if(profileRepository.findByNickname(requestDTO.getNickName()).isPresent()) { - throw new ResponseStatusException(HttpStatus.CONFLICT, "이미 존재하는 닉네임 입니다. : " + requestDTO.getNickName()); - } - - Account account = new Account(requestDTO.getEmail(), requestDTO.getPassword()); - account.setPassword(passwordEncoder.encode(account.getPassword())); - - Profile profile = new Profile(requestDTO.getNickName(), requestDTO.getBirth(), requestDTO.getBio()); - - // 양방향 연결 - account.setProfile(profile); - profile.setAccount(account); - - Account saveProfile = accountRepository.save(account); - - return new UserResponseDTO(saveProfile); - } - - @Transactional - public void deleteAccount(Long id, String password) { - Account account = getAccountById(id); - - if(!passwordEncoder.matches(password, account.getPassword())) { - throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "비밀번호가 일치하지 않습니다."); - } - - accountRepository.delete(account); - } - - @Transactional - public UserResponseDTO updatePassword(Long id, UpdatePasswordRequestDTO requestDTO) { - Account account = getAccountById(id); - - if(!passwordEncoder.matches(requestDTO.getOldPassword(), account.getPassword())) { - throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "비밀번호가 일치하지 않습니다."); - } - - account.setPassword(passwordEncoder.encode(requestDTO.getNewPassword())); - - // DB 에 변경 사항 강제 반영 - accountRepository.flush(); - - return new UserResponseDTO(account); - } - - public UserResponseDTO login(LogInRequestDTO requestDTO) { - return new UserResponseDTO(accountRepository.findByEmail(requestDTO.getEmail()) - .filter(findAccount -> passwordEncoder.matches(requestDTO.getPassword(), findAccount.getPassword())) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "이메일 또는 비밀번호가 일치하지 않습니다.")) - ); - } +public interface AccountService { + UserResponseDTO signup(SignUpRequestDTO requestDTO); + void deleteAccount(Long id, String password); - /* 유틸(?): 서비스 내에서만 사용 */ + UserResponseDTO updatePassword(Long id, UpdatePasswordRequestDTO requestDTO); - public Account getAccountById(Long id) { - return accountRepository.findById(id).orElseThrow(() -> - new ResponseStatusException(HttpStatus.NOT_FOUND, "해당 id 의 유저가 존재하지 않습니다. : " + id) - ); - } + UserResponseDTO login(LogInRequestDTO requestDTO); } diff --git a/src/main/java/com/example/feeda/domain/account/sevice/AccountServiceImpl.java b/src/main/java/com/example/feeda/domain/account/sevice/AccountServiceImpl.java new file mode 100644 index 0000000..fb2c7aa --- /dev/null +++ b/src/main/java/com/example/feeda/domain/account/sevice/AccountServiceImpl.java @@ -0,0 +1,96 @@ +package com.example.feeda.domain.account.sevice; + +import com.example.feeda.domain.account.dto.LogInRequestDTO; +import com.example.feeda.domain.account.dto.UpdatePasswordRequestDTO; +import com.example.feeda.domain.account.dto.UserResponseDTO; +import com.example.feeda.domain.account.dto.SignUpRequestDTO; +import com.example.feeda.domain.account.entity.Account; +import com.example.feeda.domain.account.repository.AccountRepository; +import com.example.feeda.domain.profile.entity.Profile; +import com.example.feeda.domain.profile.repository.ProfileRepository; +import com.example.feeda.exception.CustomResponseException; +import com.example.feeda.exception.enums.ResponseError; +import com.example.feeda.security.PasswordEncoder; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + + +@Service +@RequiredArgsConstructor +public class AccountServiceImpl implements AccountService { + private final AccountRepository accountRepository; + private final PasswordEncoder passwordEncoder; + private final ProfileRepository profileRepository; + + @Override + @Transactional + public UserResponseDTO signup(SignUpRequestDTO requestDTO) { + if(accountRepository.findByEmail(requestDTO.getEmail()).isPresent()) { + throw new CustomResponseException(ResponseError.EMAIL_ALREADY_EXISTS); + } + + if(profileRepository.findByNickname(requestDTO.getNickName()).isPresent()) { + throw new CustomResponseException(ResponseError.NICKNAME_ALREADY_EXISTS); + } + + Account account = new Account(requestDTO.getEmail(), requestDTO.getPassword()); + account.setPassword(passwordEncoder.encode(account.getPassword())); + + Profile profile = new Profile(requestDTO.getNickName(), requestDTO.getBirth(), requestDTO.getBio()); + + // 양방향 연결 + account.setProfile(profile); + profile.setAccount(account); + + Account saveProfile = accountRepository.save(account); + + return new UserResponseDTO(saveProfile); + } + + @Override + @Transactional + public void deleteAccount(Long id, String password) { + Account account = getAccountById(id); + + if(!passwordEncoder.matches(password, account.getPassword())) { + throw new CustomResponseException(ResponseError.INVALID_PASSWORD); + } + + accountRepository.delete(account); + } + + @Override + @Transactional + public UserResponseDTO updatePassword(Long id, UpdatePasswordRequestDTO requestDTO) { + Account account = getAccountById(id); + + if(!passwordEncoder.matches(requestDTO.getOldPassword(), account.getPassword())) { + throw new CustomResponseException(ResponseError.INVALID_PASSWORD); + } + + account.setPassword(passwordEncoder.encode(requestDTO.getNewPassword())); + + // DB 에 변경 사항 강제 반영 + accountRepository.flush(); + + return new UserResponseDTO(account); + } + + @Override + public UserResponseDTO login(LogInRequestDTO requestDTO) { + return new UserResponseDTO(accountRepository.findByEmail(requestDTO.getEmail()) + .filter(findAccount -> passwordEncoder.matches(requestDTO.getPassword(), findAccount.getPassword())) + .orElseThrow(() -> new CustomResponseException(ResponseError.INVALID_EMAIL_OR_PASSWORD)) + ); + } + + + /* 유틸(?): 서비스 내에서만 사용 */ + + public Account getAccountById(Long id) { + return accountRepository.findById(id).orElseThrow(() -> + new CustomResponseException(ResponseError.ACCOUNT_NOT_FOUND) + ); + } +} diff --git a/src/main/java/com/example/feeda/domain/comment/service/CommentLikeService.java b/src/main/java/com/example/feeda/domain/comment/service/CommentLikeService.java index 236509d..0746e92 100644 --- a/src/main/java/com/example/feeda/domain/comment/service/CommentLikeService.java +++ b/src/main/java/com/example/feeda/domain/comment/service/CommentLikeService.java @@ -7,10 +7,10 @@ import com.example.feeda.domain.comment.repository.CommentRepository; import com.example.feeda.domain.profile.entity.Profile; import com.example.feeda.domain.profile.repository.ProfileRepository; +import com.example.feeda.exception.CustomResponseException; +import com.example.feeda.exception.enums.ResponseError; import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; -import org.springframework.web.server.ResponseStatusException; import java.util.Optional; @@ -25,15 +25,15 @@ public class CommentLikeService { public LikeCommentResponseDTO likeComment(Long commentId, Long profileId) { Optional findCommentLike = commentLikeRepository.findByComment_IdAndProfile_Id(commentId, profileId); if(findCommentLike.isPresent()) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "이미 좋아요한 댓글입니다. : " + commentId); + throw new CustomResponseException(ResponseError.ALREADY_LIKED_COMMENT); } Comment findComment = commentRepository.findById(commentId).orElseThrow(() -> - new ResponseStatusException(HttpStatus.NOT_FOUND, "해당 id 의 게시글이 존재하지 않습니다. : " + commentId) + new CustomResponseException(ResponseError.COMMENT_NOT_FOUND) ); Profile findProfile = profileRepository.findById(profileId).orElseThrow(() -> - new ResponseStatusException(HttpStatus.NOT_FOUND, "해당 id 의 유저가 존재하지 않습니다. : " + profileId) + new CustomResponseException(ResponseError.PROFILE_NOT_FOUND) ); CommentLike commentLike = new CommentLike(findComment, findProfile); @@ -45,7 +45,7 @@ public LikeCommentResponseDTO likeComment(Long commentId, Long profileId) { public void unlikeComment(Long commentId, Long profileId) { Optional findCommentLikeOptional = commentLikeRepository.findByComment_IdAndProfile_Id(commentId, profileId); if(findCommentLikeOptional.isEmpty()) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "아직 좋아요하지 않은 댓글 입니다. : " + commentId); + throw new CustomResponseException(ResponseError.NOT_YET_LIKED_COMMENT); } CommentLike commentLike = findCommentLikeOptional.get(); diff --git a/src/main/java/com/example/feeda/domain/comment/service/CommentService.java b/src/main/java/com/example/feeda/domain/comment/service/CommentService.java index f4e1631..765d8b5 100644 --- a/src/main/java/com/example/feeda/domain/comment/service/CommentService.java +++ b/src/main/java/com/example/feeda/domain/comment/service/CommentService.java @@ -9,14 +9,13 @@ import com.example.feeda.domain.post.repository.PostRepository; import com.example.feeda.domain.profile.entity.Profile; import com.example.feeda.domain.profile.repository.ProfileRepository; +import com.example.feeda.exception.CustomResponseException; +import com.example.feeda.exception.enums.ResponseError; import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.server.ResponseStatusException; import java.util.List; -import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -29,9 +28,9 @@ public class CommentService { @Transactional public CommentResponse createComment(Long postId, Long profileId, CreateCommentRequest request) { Post post = postRepository.findById(postId) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "게시글이 존재하지 않습니다.")); + .orElseThrow(() -> new CustomResponseException(ResponseError.POST_NOT_FOUND)); Profile profile = profileRepository.findById(profileId) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "사용자가 존재하지 않습니다.")); + .orElseThrow(() -> new CustomResponseException(ResponseError.PROFILE_NOT_FOUND)); Comment comment = new Comment(post, profile, request.getContent()); commentRepository.save(comment); @@ -49,22 +48,22 @@ public List getCommentsByPostId(Long postId, String sort) { return comments.stream() .map(CommentResponse::from) - .collect(Collectors.toList()); + .toList(); } public CommentResponse getCommentById(Long commentId) { Comment comment = commentRepository.findById(commentId) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "댓글이 존재하지 않습니다.")); + .orElseThrow(() -> new CustomResponseException(ResponseError.COMMENT_NOT_FOUND)); return CommentResponse.from(comment); } @Transactional public CommentResponse updateComment(Long commentId, Long requesterProfileId, UpdateCommentRequest request) { Comment comment = commentRepository.findById(commentId) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "댓글이 존재하지 않습니다.")); + .orElseThrow(() -> new CustomResponseException(ResponseError.COMMENT_NOT_FOUND)); if (!comment.getProfile().getId().equals(requesterProfileId)) { - throw new ResponseStatusException(HttpStatus.FORBIDDEN, "본인의 댓글만 수정할 수 있습니다."); + throw new CustomResponseException(ResponseError.NO_PERMISSION_TO_EDIT); } comment.updateContent(request.getContent()); @@ -74,13 +73,13 @@ public CommentResponse updateComment(Long commentId, Long requesterProfileId, Up @Transactional public void deleteComment(Long commentId, Long requesterProfileId) { Comment comment = commentRepository.findById(commentId) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "댓글이 존재하지 않습니다.")); + .orElseThrow(() -> new CustomResponseException(ResponseError.COMMENT_NOT_FOUND)); Long authorId = comment.getProfile().getId(); Long postOwnerId = comment.getPost().getProfile().getId(); if (!authorId.equals(requesterProfileId) && !postOwnerId.equals(requesterProfileId)) { - throw new ResponseStatusException(HttpStatus.FORBIDDEN, "삭제 권한이 없습니다."); + throw new CustomResponseException(ResponseError.NO_PERMISSION_TO_DELETE); } commentRepository.delete(comment); diff --git a/src/main/java/com/example/feeda/domain/follow/service/FollowsServiceImpl.java b/src/main/java/com/example/feeda/domain/follow/service/FollowsServiceImpl.java index c13d348..ea37010 100644 --- a/src/main/java/com/example/feeda/domain/follow/service/FollowsServiceImpl.java +++ b/src/main/java/com/example/feeda/domain/follow/service/FollowsServiceImpl.java @@ -7,6 +7,8 @@ import com.example.feeda.domain.profile.dto.ProfileListResponseDto; import com.example.feeda.domain.profile.entity.Profile; import com.example.feeda.domain.profile.repository.ProfileRepository; +import com.example.feeda.exception.CustomResponseException; +import com.example.feeda.exception.enums.ResponseError; import com.example.feeda.security.jwt.JwtPayload; import java.util.List; import java.util.Optional; @@ -14,10 +16,8 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; -import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.server.ResponseStatusException; @Service @Slf4j @@ -38,8 +38,7 @@ public FollowsResponseDto follow(JwtPayload jwtPayload, Long profileId) { Optional follow = followsRepository.findByFollowersAndFollowings(myProfile, followingProfile); if (follow.isPresent()) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, - "이미 팔로우한 계정입니다."); + throw new CustomResponseException(ResponseError.ALREADY_FOLLOWED); } Follows newFollow = Follows.builder() @@ -63,8 +62,7 @@ public void unfollow(JwtPayload jwtPayload, Long followingId) { Optional follows = followsRepository.findByFollowersAndFollowings(myProfile, followingProfile); if (follows.isEmpty()) { - throw new ResponseStatusException(HttpStatus.NOT_FOUND, - "존재하지 않는 팔로우입니다."); + throw new CustomResponseException(ResponseError.FOLLOW_NOT_FOUND); } followsRepository.delete(follows.get()); @@ -131,8 +129,7 @@ private Profile getProfileOrThrow(Long profileId) { profileRepository.findById(profileId); if (optionalProfile.isEmpty()) { - throw new ResponseStatusException( - HttpStatus.NOT_FOUND, "존재하지 않는 프로필입니다. id = " + profileId); + throw new CustomResponseException(ResponseError.PROFILE_NOT_FOUND); } return optionalProfile.get(); @@ -140,8 +137,7 @@ private Profile getProfileOrThrow(Long profileId) { private void validateNotSelf(Profile me, Long profileId) { if (me.getId().equals(profileId)) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, - "본인 프로필은 팔로우/언팔로우 할 수 없습니다"); + throw new CustomResponseException(ResponseError.CANNOT_FOLLOW_SELF); } } } diff --git a/src/main/java/com/example/feeda/domain/post/service/PostServiceImpl.java b/src/main/java/com/example/feeda/domain/post/service/PostServiceImpl.java index 7fa17a7..964c364 100644 --- a/src/main/java/com/example/feeda/domain/post/service/PostServiceImpl.java +++ b/src/main/java/com/example/feeda/domain/post/service/PostServiceImpl.java @@ -8,6 +8,8 @@ import com.example.feeda.domain.post.repository.PostRepository; import com.example.feeda.domain.profile.entity.Profile; import com.example.feeda.domain.profile.repository.ProfileRepository; +import com.example.feeda.exception.CustomResponseException; +import com.example.feeda.exception.enums.ResponseError; import com.example.feeda.security.jwt.JwtPayload; import java.util.List; import java.util.Optional; @@ -16,10 +18,8 @@ import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; -import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.server.ResponseStatusException; @Service @RequiredArgsConstructor @@ -34,7 +34,7 @@ public PostResponseDto createPost(PostRequestDto postRequestDto, JwtPayload jwtP Profile profile = profileRepository.findById(jwtPayload.getProfileId()) .orElseThrow( - () -> new ResponseStatusException(HttpStatus.NOT_FOUND, "존재하지 않는 프로필입니다.")); + () -> new CustomResponseException(ResponseError.PROFILE_NOT_FOUND)); Post post = new Post(postRequestDto.getTitle(), postRequestDto.getContent(), postRequestDto.getCategory(), profile); @@ -53,7 +53,7 @@ public PostResponseDto findPostById(Long id) { Optional optionalPost = postRepository.findById(id); if (optionalPost.isEmpty()) { - throw new ResponseStatusException(HttpStatus.NOT_FOUND, "게시글이 존재하지 않습니다"); + throw new CustomResponseException(ResponseError.POST_NOT_FOUND); } Post findPost = optionalPost.get(); @@ -93,10 +93,10 @@ public Page findFollowingAllPost(Pageable pageable, JwtPayload public PostResponseDto updatePost(Long id, PostRequestDto requestDto, JwtPayload jwtPayload) { Post findPost = postRepository.findById(id) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "존재하지 않는 게시글")); + .orElseThrow(() -> new CustomResponseException(ResponseError.POST_NOT_FOUND)); if (!findPost.getProfile().getId().equals(jwtPayload.getProfileId())) { - throw new ResponseStatusException(HttpStatus.FORBIDDEN, "권한이 없습니다."); + throw new CustomResponseException(ResponseError.NO_PERMISSION_TO_EDIT); } findPost.update(requestDto.getTitle(), requestDto.getCategory(), requestDto.getCategory()); @@ -113,10 +113,10 @@ public PostResponseDto updatePost(Long id, PostRequestDto requestDto, JwtPayload public void deletePost(Long id, JwtPayload jwtPayload) { Post findPost = postRepository.findById(id) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "존재하지 않는 게시글")); + .orElseThrow(() -> new CustomResponseException(ResponseError.POST_NOT_FOUND)); if (!findPost.getProfile().getId().equals(jwtPayload.getProfileId())) { - throw new ResponseStatusException(HttpStatus.FORBIDDEN, "권한이 없습니다."); + throw new CustomResponseException(ResponseError.NO_PERMISSION_TO_DELETE); } postRepository.delete(findPost); diff --git a/src/main/java/com/example/feeda/domain/profile/controller/ProfileController.java b/src/main/java/com/example/feeda/domain/profile/controller/ProfileController.java index be10c44..b134265 100644 --- a/src/main/java/com/example/feeda/domain/profile/controller/ProfileController.java +++ b/src/main/java/com/example/feeda/domain/profile/controller/ProfileController.java @@ -1,7 +1,7 @@ package com.example.feeda.domain.profile.controller; import com.example.feeda.domain.profile.dto.*; -import com.example.feeda.domain.profile.service.ProfileService; +import com.example.feeda.domain.profile.service.ProfileServiceImpl; import com.example.feeda.security.jwt.JwtPayload; import jakarta.validation.Valid; import org.springframework.http.HttpStatus; @@ -13,9 +13,9 @@ @RequestMapping("/api") public class ProfileController { - private final ProfileService profileService; + private final ProfileServiceImpl profileService; - public ProfileController(ProfileService profileService) { + public ProfileController(ProfileServiceImpl profileService) { this.profileService = profileService; } diff --git a/src/main/java/com/example/feeda/domain/profile/service/ProfileService.java b/src/main/java/com/example/feeda/domain/profile/service/ProfileService.java index b6d8d43..098637e 100644 --- a/src/main/java/com/example/feeda/domain/profile/service/ProfileService.java +++ b/src/main/java/com/example/feeda/domain/profile/service/ProfileService.java @@ -1,119 +1,14 @@ package com.example.feeda.domain.profile.service; -import com.example.feeda.domain.follow.repository.FollowsRepository; -import com.example.feeda.domain.profile.dto.*; -import com.example.feeda.domain.profile.entity.Profile; -import com.example.feeda.domain.profile.repository.ProfileRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort; -import org.springframework.http.HttpStatus; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.server.ResponseStatusException; +import com.example.feeda.domain.profile.dto.GetProfileWithFollowCountResponseDto; +import com.example.feeda.domain.profile.dto.ProfileListResponseDto; +import com.example.feeda.domain.profile.dto.UpdateProfileRequestDto; +import com.example.feeda.domain.profile.dto.UpdateProfileResponseDto; -import java.util.List; +public interface ProfileService { + GetProfileWithFollowCountResponseDto getProfile(Long id); -@Service -@RequiredArgsConstructor -public class ProfileService { - private final ProfileRepository profileRepository; - private final FollowsRepository followsRepository; + ProfileListResponseDto getProfiles(String keyword, int page, int size); - /** - * 프로필 단건 조회 기능 - */ - @Transactional(readOnly = true) - public GetProfileWithFollowCountResponseDto getProfile(Long id) { - - Profile profile = profileRepository.findById(id) - .orElseThrow(() -> new ResponseStatusException( - HttpStatus.NOT_FOUND, "해당 회원을 찾을 수 없습니다." - )); - - Long followerCount = followsRepository.countByFollowings_Id(id); - Long followingCount = followsRepository.countByFollowers_Id(id); - - return GetProfileWithFollowCountResponseDto.of( - profile.getId(), - profile.getNickname(), - profile.getBirth(), - profile.getBio(), - followerCount, - followingCount, - profile.getCreatedAt(), - profile.getUpdatedAt() - ); - } - - /** - * 프로필 다건 조회 기능(검색,페이징) - */ - - @Transactional(readOnly = true) - public ProfileListResponseDto getProfiles(String keyword, int page, int size) { - - if (page < 1 || size < 1) { - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, - "페이지 번호는 1 이상, 페이지 크기는 1 이상이어야 합니다." - ); - } - - Pageable pageable = PageRequest.of(page - 1, size, Sort.by("id").ascending()); - - Page profilePage; - if (keyword == null || keyword.trim().isEmpty()) { - profilePage = profileRepository.findAll(pageable); - } else { - profilePage = profileRepository.findByNicknameContaining(keyword, pageable); - } - - List responseDtoList = profilePage.stream() - .map(profile -> GetProfileResponseDto.of( - profile.getId(), - profile.getNickname(), - profile.getBirth(), - profile.getBio(), - profile.getCreatedAt(), - profile.getUpdatedAt() - )) - .toList(); - - return ProfileListResponseDto.of( - responseDtoList, - profilePage.getNumber() + 1, // 다시 1부터 시작하는 번호로 반환 - profilePage.getTotalPages(), - profilePage.getTotalElements() - ); - } - - - /** - * 프로필 수정 기능 - */ - @Transactional - public UpdateProfileResponseDto updateProfile(Long userId, Long profileId, UpdateProfileRequestDto requestDto) { - - Profile profile = profileRepository.findById(profileId) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "회원 없음.")); - - if (!profile.getAccount().getId().equals(userId)) { - throw new ResponseStatusException(HttpStatus.FORBIDDEN, "수정 권한이 없습니다."); - } - - if (requestDto.getNickname() != null || requestDto.getBirth() != null || requestDto.getBio() != null) { - profile.updateProfile( - requestDto.getNickname(), - requestDto.getBirth(), - requestDto.getBio() - ); - } - - profileRepository.save(profile); - - return UpdateProfileResponseDto.from("프로필이 성공적으로 수정되었습니다."); - } + UpdateProfileResponseDto updateProfile(Long userId, Long profileId, UpdateProfileRequestDto requestDto); } diff --git a/src/main/java/com/example/feeda/domain/profile/service/ProfileServiceImpl.java b/src/main/java/com/example/feeda/domain/profile/service/ProfileServiceImpl.java new file mode 100644 index 0000000..0941ac1 --- /dev/null +++ b/src/main/java/com/example/feeda/domain/profile/service/ProfileServiceImpl.java @@ -0,0 +1,116 @@ +package com.example.feeda.domain.profile.service; + +import com.example.feeda.domain.follow.repository.FollowsRepository; +import com.example.feeda.domain.profile.dto.*; +import com.example.feeda.domain.profile.entity.Profile; +import com.example.feeda.domain.profile.repository.ProfileRepository; +import com.example.feeda.exception.CustomResponseException; +import com.example.feeda.exception.enums.ResponseError; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class ProfileServiceImpl implements ProfileService { + private final ProfileRepository profileRepository; + private final FollowsRepository followsRepository; + + /** + * 프로필 단건 조회 기능 + */ + @Override + @Transactional(readOnly = true) + public GetProfileWithFollowCountResponseDto getProfile(Long id) { + + Profile profile = profileRepository.findById(id) + .orElseThrow(() -> new CustomResponseException(ResponseError.PROFILE_NOT_FOUND)); + + Long followerCount = followsRepository.countByFollowings_Id(id); + Long followingCount = followsRepository.countByFollowers_Id(id); + + return GetProfileWithFollowCountResponseDto.of( + profile.getId(), + profile.getNickname(), + profile.getBirth(), + profile.getBio(), + followerCount, + followingCount, + profile.getCreatedAt(), + profile.getUpdatedAt() + ); + } + + /** + * 프로필 다건 조회 기능(검색,페이징) + */ + @Override + @Transactional(readOnly = true) + public ProfileListResponseDto getProfiles(String keyword, int page, int size) { + + if (page < 1 || size < 1) { + throw new CustomResponseException(ResponseError.INVALID_PAGINATION_PARAMETERS); + } + + Pageable pageable = PageRequest.of(page - 1, size, Sort.by("id").ascending()); + + Page profilePage; + if (keyword == null || keyword.trim().isEmpty()) { + profilePage = profileRepository.findAll(pageable); + } else { + profilePage = profileRepository.findByNicknameContaining(keyword, pageable); + } + + List responseDtoList = profilePage.stream() + .map(profile -> GetProfileResponseDto.of( + profile.getId(), + profile.getNickname(), + profile.getBirth(), + profile.getBio(), + profile.getCreatedAt(), + profile.getUpdatedAt() + )) + .toList(); + + return ProfileListResponseDto.of( + responseDtoList, + profilePage.getNumber() + 1, // 다시 1부터 시작하는 번호로 반환 + profilePage.getTotalPages(), + profilePage.getTotalElements() + ); + } + + + /** + * 프로필 수정 기능 + */ + @Override + @Transactional + public UpdateProfileResponseDto updateProfile(Long userId, Long profileId, UpdateProfileRequestDto requestDto) { + + Profile profile = profileRepository.findById(profileId) + .orElseThrow(() -> new CustomResponseException(ResponseError.PROFILE_NOT_FOUND)); + + if (!profile.getAccount().getId().equals(userId)) { + throw new CustomResponseException(ResponseError.NO_PERMISSION_TO_EDIT); + } + + if (requestDto.getNickname() != null || requestDto.getBirth() != null || requestDto.getBio() != null) { + profile.updateProfile( + requestDto.getNickname(), + requestDto.getBirth(), + requestDto.getBio() + ); + } + + profileRepository.save(profile); + + return UpdateProfileResponseDto.from("프로필이 성공적으로 수정되었습니다."); + } +} diff --git a/src/main/java/com/example/feeda/exception/CustomResponseException.java b/src/main/java/com/example/feeda/exception/CustomResponseException.java new file mode 100644 index 0000000..efcf233 --- /dev/null +++ b/src/main/java/com/example/feeda/exception/CustomResponseException.java @@ -0,0 +1,17 @@ +package com.example.feeda.exception; + +import com.example.feeda.exception.enums.ResponseError; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +public class CustomResponseException extends RuntimeException { + + private final HttpStatus httpStatus; + private final String errorMessage; + + public CustomResponseException(ResponseError responseError) { + this.httpStatus = responseError.getHttpStatus(); + this.errorMessage = responseError.getMessage(); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/feeda/exception/GlobalExceptionHandler.java b/src/main/java/com/example/feeda/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..e787dab --- /dev/null +++ b/src/main/java/com/example/feeda/exception/GlobalExceptionHandler.java @@ -0,0 +1,67 @@ +package com.example.feeda.exception; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.ConstraintViolationException; +import org.springframework.context.support.DefaultMessageSourceResolvable; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.BindException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.time.LocalDateTime; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.stream.Collectors; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler({ + MethodArgumentNotValidException.class, + BindException.class, + ConstraintViolationException.class + }) + public ResponseEntity> handleValidationExceptions(MethodArgumentNotValidException ex, HttpServletRequest request) { + String message = ex.getBindingResult().getFieldErrors() + .stream() + .map(DefaultMessageSourceResolvable::getDefaultMessage) + .collect(Collectors.joining(", ")); + + Map body = new LinkedHashMap<>(); + body.put("timestamp", LocalDateTime.now()); + body.put("status", HttpStatus.BAD_REQUEST.value()); + body.put("error", HttpStatus.BAD_REQUEST.getReasonPhrase()); + body.put("message", message); + body.put("path", request.getRequestURI()); + + return new ResponseEntity<>(body, HttpStatus.BAD_REQUEST); + } + + @ExceptionHandler(CustomResponseException.class) + public ResponseEntity> handleCustomResponseException(CustomResponseException ex, HttpServletRequest request) { + Map body = new LinkedHashMap<>(); + body.put("timestamp", LocalDateTime.now()); + body.put("status", ex.getHttpStatus().value()); + body.put("error", ex.getHttpStatus().getReasonPhrase()); + body.put("message", ex.getMessage()); + body.put("path", request.getRequestURI()); + + return new ResponseEntity<>(body, ex.getHttpStatus()); + } + + @ExceptionHandler(TokenNotFoundException.class) + public ResponseEntity> handleTokenNotFoundException(TokenNotFoundException ex, HttpServletRequest request) { + Map body = new LinkedHashMap<>(); + body.put("timestamp", LocalDateTime.now()); + body.put("status", HttpStatus.UNAUTHORIZED.value()); + body.put("error", HttpStatus.UNAUTHORIZED.getReasonPhrase()); + body.put("message", ex.getMessage()); + body.put("path", request.getRequestURI()); + + return new ResponseEntity<>(body, HttpStatus.UNAUTHORIZED); + } +} + + diff --git a/src/main/java/com/example/feeda/exception/JwtValidationException.java b/src/main/java/com/example/feeda/exception/JwtValidationException.java deleted file mode 100644 index 9912176..0000000 --- a/src/main/java/com/example/feeda/exception/JwtValidationException.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.example.feeda.exception; - -import lombok.Getter; - -@Getter -public class JwtValidationException extends RuntimeException { - private final int statusCode; - - public JwtValidationException(String message, int statusCode) { - super(message); - this.statusCode = statusCode; - } -} diff --git a/src/main/java/com/example/feeda/exception/enums/ResponseError.java b/src/main/java/com/example/feeda/exception/enums/ResponseError.java new file mode 100644 index 0000000..af55a48 --- /dev/null +++ b/src/main/java/com/example/feeda/exception/enums/ResponseError.java @@ -0,0 +1,46 @@ +package com.example.feeda.exception.enums; + +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +public enum ResponseError { + // 회원 관리 관련 오류 + EMAIL_ALREADY_EXISTS(HttpStatus.CONFLICT, "이미 존재하는 이메일 입니다."), + INVALID_PASSWORD(HttpStatus.UNAUTHORIZED, "비밀번호가 일치하지 않습니다."), + INVALID_EMAIL_OR_PASSWORD(HttpStatus.UNAUTHORIZED, "이메일 또는 비밀번호가 일치하지 않습니다."), + ACCOUNT_NOT_FOUND(HttpStatus.NOT_FOUND, "계정이 존재하지 않습니다."), + + // 프로필 관련 오류 + NICKNAME_ALREADY_EXISTS(HttpStatus.CONFLICT, "이미 존재하는 닉네임 입니다."), + PROFILE_NOT_FOUND(HttpStatus.NOT_FOUND, "프로필이 존재하지 않습니다."), + + // 팔로우 관련 오류 + ALREADY_FOLLOWED(HttpStatus.BAD_REQUEST, "이미 팔로우한 계정입니다."), + FOLLOW_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 팔로우입니다."), + CANNOT_FOLLOW_SELF(HttpStatus.BAD_REQUEST, "본인 프로필은 팔로우/언팔로우 할 수 없습니다"), + + // 게시글 관련 오류 + POST_NOT_FOUND(HttpStatus.NOT_FOUND, "게시글이 존재하지 않습니다"), + COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "댓글이 존재하지 않습니다"), + ALREADY_LIKED_COMMENT(HttpStatus.BAD_REQUEST, "이미 좋아요한 댓글입니다."), + NOT_YET_LIKED_COMMENT(HttpStatus.BAD_REQUEST, "아직 좋아요하지 않은 댓글 입니다."), + + + // 페이징 관련 오류 + INVALID_PAGINATION_PARAMETERS(HttpStatus.BAD_REQUEST, "페이지 번호는 1 이상, 페이지 크기는 1 이상이어야 합니다."), + + // 권한 관련 오류 + NO_PERMISSION_TO_EDIT(HttpStatus.FORBIDDEN, "수정 권한이 없습니다."), + NO_PERMISSION_TO_DELETE(HttpStatus.FORBIDDEN, "삭제 권한이 없습니다."); + + + + private final HttpStatus httpStatus; + private final String message; + + ResponseError(HttpStatus httpStatus, String message) { + this.httpStatus = httpStatus; + this.message = message; + } +} diff --git a/src/main/java/com/example/feeda/exception/enums/ServletResponseError.java b/src/main/java/com/example/feeda/exception/enums/ServletResponseError.java new file mode 100644 index 0000000..57a16a7 --- /dev/null +++ b/src/main/java/com/example/feeda/exception/enums/ServletResponseError.java @@ -0,0 +1,28 @@ +package com.example.feeda.exception.enums; + +import jakarta.servlet.http.HttpServletResponse; +import lombok.Getter; + +@Getter +public enum ServletResponseError { + // JWT 관련 오류 + INVALID_JWT_SIGNATURE(HttpServletResponse.SC_UNAUTHORIZED, "유효하지 않는 JWT 서명입니다."), + EXPIRED_JWT_TOKEN(HttpServletResponse.SC_UNAUTHORIZED, "만료된 JWT 토큰입니다."), + UNSUPPORTED_JWT(HttpServletResponse.SC_BAD_REQUEST, "지원되지 않는 JWT 토큰입니다."), + INVALID_JWT(HttpServletResponse.SC_BAD_REQUEST, "잘못된 JWT 토큰입니다."), + + // Security 관련 오류 + UNAUTHORIZED(HttpServletResponse.SC_UNAUTHORIZED, "인증이 필요합니다."), + ACCESS_DENIED(HttpServletResponse.SC_FORBIDDEN, "접근이 거부되었습니다."), + + // 내부 서버 오류 + INTERNAL_SERVER_ERROR(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "내부 서버 오류입니다."); + + private final int httpStatus; + private final String message; + + ServletResponseError(int httpStatus, String message) { + this.httpStatus = httpStatus; + this.message = message; + } +} diff --git a/src/main/java/com/example/feeda/filter/JwtFilter.java b/src/main/java/com/example/feeda/filter/JwtFilter.java index 5ca93bd..33d4cd6 100644 --- a/src/main/java/com/example/feeda/filter/JwtFilter.java +++ b/src/main/java/com/example/feeda/filter/JwtFilter.java @@ -1,6 +1,7 @@ package com.example.feeda.filter; -import com.example.feeda.exception.JwtValidationException; +import com.example.feeda.exception.enums.ServletResponseError; +import com.example.feeda.exception.TokenNotFoundException; import com.example.feeda.security.jwt.JwtBlacklistService; import com.example.feeda.security.jwt.JwtPayload; import com.example.feeda.security.jwt.JwtUtil; @@ -47,13 +48,13 @@ protected void doFilterInternal( // JWT 유효성 검사와 claims 추출 Claims claims = jwtUtil.extractClaims(jwt); if (claims == null) { - response.sendError(HttpServletResponse.SC_BAD_REQUEST, "잘못된 JWT 토큰입니다."); + response.sendError(ServletResponseError.INVALID_JWT.getHttpStatus(), ServletResponseError.INVALID_JWT.getMessage()); return; } // 블랙리스트 검증 if (jwtBlacklistService.isBlacklisted(jwt)) { - response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "만료된 JWT 토큰입니다."); + response.sendError(ServletResponseError.EXPIRED_JWT_TOKEN.getHttpStatus(), ServletResponseError.EXPIRED_JWT_TOKEN.getMessage()); return; } @@ -73,14 +74,14 @@ protected void doFilterInternal( chain.doFilter(request, response); - } catch (SecurityException | MalformedJwtException e) { - throw new JwtValidationException("유효하지 않는 JWT 서명입니다.", HttpServletResponse.SC_UNAUTHORIZED); + } catch (SecurityException | MalformedJwtException | TokenNotFoundException e) { + response.sendError(ServletResponseError.INVALID_JWT_SIGNATURE.getHttpStatus(), ServletResponseError.INVALID_JWT_SIGNATURE.getMessage()); } catch (ExpiredJwtException e) { - throw new JwtValidationException("만료된 JWT 토큰입니다.", HttpServletResponse.SC_UNAUTHORIZED); + response.sendError(ServletResponseError.EXPIRED_JWT_TOKEN.getHttpStatus(), ServletResponseError.EXPIRED_JWT_TOKEN.getMessage()); } catch (UnsupportedJwtException e) { - throw new JwtValidationException("지원되지 않는 JWT 토큰입니다.", HttpServletResponse.SC_BAD_REQUEST); + response.sendError(ServletResponseError.UNSUPPORTED_JWT.getHttpStatus(), ServletResponseError.UNSUPPORTED_JWT.getMessage()); } catch (Exception e) { - throw new JwtValidationException("내부 서버 오류", HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + response.sendError(ServletResponseError.INTERNAL_SERVER_ERROR.getHttpStatus(), ServletResponseError.INTERNAL_SERVER_ERROR.getMessage()); } } } diff --git a/src/main/java/com/example/feeda/security/handler/CustomAccessDeniedHandler.java b/src/main/java/com/example/feeda/security/handler/CustomAccessDeniedHandler.java new file mode 100644 index 0000000..a7f77ab --- /dev/null +++ b/src/main/java/com/example/feeda/security/handler/CustomAccessDeniedHandler.java @@ -0,0 +1,38 @@ +package com.example.feeda.security.handler; + +import com.example.feeda.exception.enums.ServletResponseError; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.time.LocalDateTime; +import java.util.LinkedHashMap; +import java.util.Map; + +@Component +public class CustomAccessDeniedHandler implements AccessDeniedHandler { + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException { + response.setStatus(ServletResponseError.ACCESS_DENIED.getHttpStatus()); + response.setContentType("application/json;charset=UTF-8"); + + Map body = new LinkedHashMap<>(); + body.put("timestamp", LocalDateTime.now().toString()); + body.put("status", ServletResponseError.ACCESS_DENIED.getHttpStatus()); + body.put("error", HttpStatus.FORBIDDEN.getReasonPhrase()); + body.put("message", ServletResponseError.ACCESS_DENIED.getMessage()); + body.put("path", request.getRequestURI()); + + String jsonBody = objectMapper.writeValueAsString(body); + + response.getWriter().write(jsonBody); + } +} diff --git a/src/main/java/com/example/feeda/security/handler/CustomAuthenticationEntryPoint.java b/src/main/java/com/example/feeda/security/handler/CustomAuthenticationEntryPoint.java new file mode 100644 index 0000000..5bf4a37 --- /dev/null +++ b/src/main/java/com/example/feeda/security/handler/CustomAuthenticationEntryPoint.java @@ -0,0 +1,39 @@ +package com.example.feeda.security.handler; + +import com.example.feeda.exception.enums.ServletResponseError; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.time.LocalDateTime; +import java.util.LinkedHashMap; +import java.util.Map; + +@Component +public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { + response.setStatus(ServletResponseError.UNAUTHORIZED.getHttpStatus()); + response.setContentType("application/json;charset=UTF-8"); + + Map body = new LinkedHashMap<>(); + body.put("timestamp", LocalDateTime.now().toString()); + body.put("status", ServletResponseError.UNAUTHORIZED.getHttpStatus()); + body.put("error", HttpStatus.UNAUTHORIZED.getReasonPhrase()); + body.put("message", ServletResponseError.UNAUTHORIZED.getMessage()); + body.put("path", request.getRequestURI()); + + String jsonBody = objectMapper.writeValueAsString(body); + + response.getWriter().write(jsonBody); + } +} diff --git a/src/main/java/com/example/feeda/security/jwt/JwtUtil.java b/src/main/java/com/example/feeda/security/jwt/JwtUtil.java index 5ad5357..ba73898 100644 --- a/src/main/java/com/example/feeda/security/jwt/JwtUtil.java +++ b/src/main/java/com/example/feeda/security/jwt/JwtUtil.java @@ -49,7 +49,7 @@ public String extractToken(String tokenValue) { return tokenValue.substring(BEARER_PREFIX.length()); // "Bearer " 제거 후 반환 } - throw new TokenNotFoundException("Not Found Token"); + throw new TokenNotFoundException("토큰을 찾을 수 없습니다."); } public Claims extractClaims(String token) { From ec9e93d2a9593c7db037dc08be9f8cc6f89faf71 Mon Sep 17 00:00:00 2001 From: huouvcti Date: Mon, 2 Jun 2025 21:29:41 +0900 Subject: [PATCH 2/3] =?UTF-8?q?merge:=20develop=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20=EB=82=B4=EC=9A=A9=20pull?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 간단한 리팩토링 --- .../com/example/feeda/domain/post/dto/PostResponseDto.java | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/main/java/com/example/feeda/domain/post/dto/PostResponseDto.java b/src/main/java/com/example/feeda/domain/post/dto/PostResponseDto.java index 00d0824..fb27bbb 100644 --- a/src/main/java/com/example/feeda/domain/post/dto/PostResponseDto.java +++ b/src/main/java/com/example/feeda/domain/post/dto/PostResponseDto.java @@ -31,8 +31,4 @@ public PostResponseDto(Post post, Long likes) { this.createdAt = post.getCreatedAt(); this.updatedAt = post.getUpdatedAt(); } - - public static PostResponseDto toDto(Post post, Long likes) { - return new PostResponseDto(post, likes); - } } From 5f2e6fe107455f03703412b80a564312a6322c5b Mon Sep 17 00:00:00 2001 From: huouvcti Date: Mon, 2 Jun 2025 21:37:53 +0900 Subject: [PATCH 3/3] =?UTF-8?q?reactor:=20=EC=B6=94=EA=B0=80=EB=90=9C=20?= =?UTF-8?q?=EB=82=B4=EC=9A=A9=20=EC=9D=91=EB=8B=B5=EC=98=88=EC=99=B8=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../feeda/domain/post/service/PostServiceImpl.java | 14 ++++++-------- .../feeda/exception/enums/ResponseError.java | 7 +++---- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/src/main/java/com/example/feeda/domain/post/service/PostServiceImpl.java b/src/main/java/com/example/feeda/domain/post/service/PostServiceImpl.java index ee9f6fb..b8c104f 100644 --- a/src/main/java/com/example/feeda/domain/post/service/PostServiceImpl.java +++ b/src/main/java/com/example/feeda/domain/post/service/PostServiceImpl.java @@ -23,10 +23,8 @@ import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; -import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.server.ResponseStatusException; @Service @RequiredArgsConstructor @@ -55,12 +53,12 @@ public PostResponseDto createPost(PostRequestDto postRequestDto, JwtPayload jwtP @Override @Transactional public PostLikeResponseDTO makeLikes(Long id, JwtPayload jwtPayload) { - Post post = postRepository.findById(id).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "게시글이 존재하지 않습니다.")); - Profile profile = profileRepository.findById(jwtPayload.getProfileId()).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "프로필이 존재하지 않습니다.")); + Post post = postRepository.findById(id).orElseThrow(() -> new CustomResponseException(ResponseError.POST_NOT_FOUND)); + Profile profile = profileRepository.findById(jwtPayload.getProfileId()).orElseThrow(() -> new CustomResponseException(ResponseError.PROFILE_NOT_FOUND)); // 중복 좋아요 방지 postLikeRepository.findByPostAndProfile(post, profile).ifPresent(like -> { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "이미 좋아요를 눌렀습니다."); + throw new CustomResponseException(ResponseError.ALREADY_LIKED_POST); }); PostLike savePost = postLikeRepository.save(new PostLike(post, profile)); @@ -71,13 +69,13 @@ public PostLikeResponseDTO makeLikes(Long id, JwtPayload jwtPayload) { @Override public void deleteLikes(Long id, Long profileId) { Post post = postRepository.findById(id) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "게시글이 존재하지 않습니다.")); + .orElseThrow(() -> new CustomResponseException(ResponseError.POST_NOT_FOUND)); Profile profile = profileRepository.findById(profileId) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "존재하지 않는 프로필")); + .orElseThrow(() -> new CustomResponseException(ResponseError.PROFILE_NOT_FOUND)); PostLike postLike = postLikeRepository.findByPostAndProfile(post, profile) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "해당 사용자의 좋아요가 존재하지 않습니다.")); + .orElseThrow(() -> new CustomResponseException(ResponseError.NOT_YET_LIKED_POST)); postLikeRepository.delete(postLike); } diff --git a/src/main/java/com/example/feeda/exception/enums/ResponseError.java b/src/main/java/com/example/feeda/exception/enums/ResponseError.java index af55a48..8109196 100644 --- a/src/main/java/com/example/feeda/exception/enums/ResponseError.java +++ b/src/main/java/com/example/feeda/exception/enums/ResponseError.java @@ -23,9 +23,10 @@ public enum ResponseError { // 게시글 관련 오류 POST_NOT_FOUND(HttpStatus.NOT_FOUND, "게시글이 존재하지 않습니다"), COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "댓글이 존재하지 않습니다"), + ALREADY_LIKED_POST(HttpStatus.BAD_REQUEST, "이미 좋아요한 게시글 입니다."), + NOT_YET_LIKED_POST(HttpStatus.BAD_REQUEST, "아직 좋아요 하지 않은 게시글 입니다."), ALREADY_LIKED_COMMENT(HttpStatus.BAD_REQUEST, "이미 좋아요한 댓글입니다."), - NOT_YET_LIKED_COMMENT(HttpStatus.BAD_REQUEST, "아직 좋아요하지 않은 댓글 입니다."), - + NOT_YET_LIKED_COMMENT(HttpStatus.BAD_REQUEST, "아직 좋아요 하지 않은 댓글 입니다."), // 페이징 관련 오류 INVALID_PAGINATION_PARAMETERS(HttpStatus.BAD_REQUEST, "페이지 번호는 1 이상, 페이지 크기는 1 이상이어야 합니다."), @@ -34,8 +35,6 @@ public enum ResponseError { NO_PERMISSION_TO_EDIT(HttpStatus.FORBIDDEN, "수정 권한이 없습니다."), NO_PERMISSION_TO_DELETE(HttpStatus.FORBIDDEN, "삭제 권한이 없습니다."); - - private final HttpStatus httpStatus; private final String message;