diff --git a/src/main/java/com/dnd/dndtravel/auth/exception/AppleTokenDecodingException.java b/src/main/java/com/dnd/dndtravel/auth/exception/AppleTokenDecodingException.java new file mode 100644 index 0000000..935bc0c --- /dev/null +++ b/src/main/java/com/dnd/dndtravel/auth/exception/AppleTokenDecodingException.java @@ -0,0 +1,9 @@ +package com.dnd.dndtravel.auth.exception; + +public class AppleTokenDecodingException extends RuntimeException { + private static final String MESSAGE = "Apple 토큰 payload 해독실패"; + + public AppleTokenDecodingException(Exception e) { + super(MESSAGE, e); + } +} diff --git a/src/main/java/com/dnd/dndtravel/auth/exception/JwtTokenDecodingException.java b/src/main/java/com/dnd/dndtravel/auth/exception/JwtTokenDecodingException.java new file mode 100644 index 0000000..b3ecc54 --- /dev/null +++ b/src/main/java/com/dnd/dndtravel/auth/exception/JwtTokenDecodingException.java @@ -0,0 +1,11 @@ +package com.dnd.dndtravel.auth.exception; + +import io.jsonwebtoken.JwtException; + +public class JwtTokenDecodingException extends RuntimeException { + private static final String MESSAGE = "Jwt 토큰 해독 실패"; + + public JwtTokenDecodingException(JwtException e) { + super(MESSAGE, e); + } +} diff --git a/src/main/java/com/dnd/dndtravel/auth/exception/JwtTokenExpiredException.java b/src/main/java/com/dnd/dndtravel/auth/exception/JwtTokenExpiredException.java new file mode 100644 index 0000000..6f3af49 --- /dev/null +++ b/src/main/java/com/dnd/dndtravel/auth/exception/JwtTokenExpiredException.java @@ -0,0 +1,11 @@ +package com.dnd.dndtravel.auth.exception; + +import io.jsonwebtoken.ExpiredJwtException; + +public class JwtTokenExpiredException extends RuntimeException { + private static final String MESSAGE = "Jwt 토큰이 만료되었음"; + + public JwtTokenExpiredException(ExpiredJwtException e) { + super(MESSAGE, e); + } +} diff --git a/src/main/java/com/dnd/dndtravel/auth/exception/RefreshTokenInvalidException.java b/src/main/java/com/dnd/dndtravel/auth/exception/RefreshTokenInvalidException.java new file mode 100644 index 0000000..6f18232 --- /dev/null +++ b/src/main/java/com/dnd/dndtravel/auth/exception/RefreshTokenInvalidException.java @@ -0,0 +1,9 @@ +package com.dnd.dndtravel.auth.exception; + +public class RefreshTokenInvalidException extends RuntimeException { + private static final String MESSAGE = "유효하지 않은 RefreshToken 토큰 [refreshToken=%s]"; + + public RefreshTokenInvalidException(String refreshToken) { + super(String.format(MESSAGE, refreshToken)); + } +} diff --git a/src/main/java/com/dnd/dndtravel/auth/service/JwtProvider.java b/src/main/java/com/dnd/dndtravel/auth/service/JwtProvider.java index abe324d..3b43922 100644 --- a/src/main/java/com/dnd/dndtravel/auth/service/JwtProvider.java +++ b/src/main/java/com/dnd/dndtravel/auth/service/JwtProvider.java @@ -11,6 +11,9 @@ import javax.crypto.SecretKey; +import com.dnd.dndtravel.auth.exception.JwtTokenExpiredException; +import com.dnd.dndtravel.auth.exception.JwtTokenDecodingException; + @Component public class JwtProvider { private static final String CLAIM_CONTENT = "memberId"; @@ -54,9 +57,9 @@ public Claims parseClaims(String splitHeader) { .parseSignedClaims(splitHeader); } catch (ExpiredJwtException e) { - throw new RuntimeException("message", e); // 유효하지 않은 토큰 + throw new JwtTokenExpiredException(e); } catch (JwtException e) { - throw new RuntimeException("message", e); // 토큰 해독 실패 + throw new JwtTokenDecodingException(e); } return claims.getPayload(); diff --git a/src/main/java/com/dnd/dndtravel/auth/service/JwtTokenService.java b/src/main/java/com/dnd/dndtravel/auth/service/JwtTokenService.java index b34f286..2e56e50 100644 --- a/src/main/java/com/dnd/dndtravel/auth/service/JwtTokenService.java +++ b/src/main/java/com/dnd/dndtravel/auth/service/JwtTokenService.java @@ -4,6 +4,7 @@ import org.springframework.transaction.annotation.Transactional; import com.dnd.dndtravel.auth.domain.RefreshToken; +import com.dnd.dndtravel.auth.exception.RefreshTokenInvalidException; import com.dnd.dndtravel.auth.repository.RefreshTokenRepository; import com.dnd.dndtravel.auth.service.dto.response.TokenResponse; import com.dnd.dndtravel.auth.service.dto.response.ReissueTokenResponse; @@ -34,7 +35,7 @@ public TokenResponse generateTokens(Long memberId) { @Transactional public ReissueTokenResponse reIssue(String token) { //validation - RefreshToken refreshToken = refreshTokenRepository.findByRefreshToken(token).orElseThrow(() -> new RuntimeException("유효하지 않은 토큰")); + RefreshToken refreshToken = refreshTokenRepository.findByRefreshToken(token).orElseThrow(() -> new RefreshTokenInvalidException(token)); //RTR refreshTokenRepository.delete(refreshToken); diff --git a/src/main/java/com/dnd/dndtravel/auth/service/TokenDecoder.java b/src/main/java/com/dnd/dndtravel/auth/service/TokenDecoder.java index a789b2f..21c3f9f 100644 --- a/src/main/java/com/dnd/dndtravel/auth/service/TokenDecoder.java +++ b/src/main/java/com/dnd/dndtravel/auth/service/TokenDecoder.java @@ -2,6 +2,7 @@ import java.util.Base64; +import com.dnd.dndtravel.auth.exception.AppleTokenDecodingException; import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; @@ -25,7 +26,7 @@ public static T decodePayload(String token, Class targetClass) { try { return objectMapper.readValue(payload, targetClass); } catch (Exception e) { - throw new RuntimeException("Error decoding token payload", e); + throw new AppleTokenDecodingException(e); } } } diff --git a/src/main/java/com/dnd/dndtravel/common/CommonExceptionHandler.java b/src/main/java/com/dnd/dndtravel/common/CommonExceptionHandler.java new file mode 100644 index 0000000..213f090 --- /dev/null +++ b/src/main/java/com/dnd/dndtravel/common/CommonExceptionHandler.java @@ -0,0 +1,125 @@ +package com.dnd.dndtravel.common; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import com.dnd.dndtravel.auth.exception.AppleTokenDecodingException; +import com.dnd.dndtravel.auth.exception.JwtTokenDecodingException; +import com.dnd.dndtravel.auth.exception.JwtTokenExpiredException; +import com.dnd.dndtravel.auth.exception.RefreshTokenInvalidException; +import com.dnd.dndtravel.map.exception.MemberAttractionNotFoundException; +import com.dnd.dndtravel.map.exception.MemberNotFoundException; +import com.dnd.dndtravel.map.exception.PhotoDeleteFailException; +import com.dnd.dndtravel.map.exception.PhotoEmptyException; +import com.dnd.dndtravel.map.exception.PhotoInvalidException; +import com.dnd.dndtravel.map.exception.PhotoUploadFailException; +import com.dnd.dndtravel.map.exception.RegionNotFoundException; +//todo 예외클래스가 많아지면 해당클래스가 길어질것으로 예상, 개선필요해보이고 보안 때문에 상태코드별로 애매하게 동일한 메시지를 전달해주고, 스웨거 문서로 상세 오류를 전달해주는데 이 구조가 적절한건지 고민해봐야한다. +@RestControllerAdvice +public class CommonExceptionHandler { + + private static final String INTERNAL_SERVER_ERROR_MESSAGE = "Internal Server Error"; + private static final String BAD_REQUEST_MESSAGE = "잘못된 요청입니다"; + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity runtimeException() { + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(BAD_REQUEST_MESSAGE); + } + + @ExceptionHandler(MemberNotFoundException.class) + public ResponseEntity runtimeException(MemberNotFoundException e) { + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(BAD_REQUEST_MESSAGE); + } + + @ExceptionHandler(MemberAttractionNotFoundException.class) + public ResponseEntity runtimeException(MemberAttractionNotFoundException e) { + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(BAD_REQUEST_MESSAGE); + } + + @ExceptionHandler(RegionNotFoundException.class) + public ResponseEntity runtimeException(RegionNotFoundException e) { + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(BAD_REQUEST_MESSAGE); + } + + @ExceptionHandler(AppleTokenDecodingException.class) + public ResponseEntity runtimeException(AppleTokenDecodingException e) { + return ResponseEntity + .status(HttpStatus.UNAUTHORIZED) + .body("토큰 인증에 실패했습니다"); + } + + @ExceptionHandler(JwtTokenExpiredException.class) + public ResponseEntity runtimeException(JwtTokenExpiredException e) { + return ResponseEntity + .status(HttpStatus.UNAUTHORIZED) + .body("토큰 인증에 실패했습니다"); + } + + @ExceptionHandler(JwtTokenDecodingException.class) + public ResponseEntity runtimeException(JwtTokenDecodingException e) { + return ResponseEntity + .status(HttpStatus.UNAUTHORIZED) + .body("토큰 인증에 실패했습니다"); + } + + @ExceptionHandler(RefreshTokenInvalidException.class) + public ResponseEntity runtimeException(RefreshTokenInvalidException e) { + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(BAD_REQUEST_MESSAGE); + } + + @ExceptionHandler(PhotoEmptyException.class) + public ResponseEntity runtimeException(PhotoEmptyException e) { + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(BAD_REQUEST_MESSAGE); + } + + @ExceptionHandler(PhotoDeleteFailException.class) + public ResponseEntity runtimeException(PhotoDeleteFailException e) { + return ResponseEntity + .status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(INTERNAL_SERVER_ERROR_MESSAGE); + } + + @ExceptionHandler(PhotoUploadFailException.class) + public ResponseEntity runtimeException(PhotoUploadFailException e) { + return ResponseEntity + .status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(INTERNAL_SERVER_ERROR_MESSAGE); + } + + @ExceptionHandler(PhotoInvalidException.class) + public ResponseEntity runtimeException(PhotoInvalidException e) { + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(BAD_REQUEST_MESSAGE); + } + + + @ExceptionHandler(RuntimeException.class) + public ResponseEntity runtimeException(RuntimeException e) { + return ResponseEntity + .status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(INTERNAL_SERVER_ERROR_MESSAGE); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity runtimeException(Exception e) { + return ResponseEntity + .status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(INTERNAL_SERVER_ERROR_MESSAGE); + } +} diff --git a/src/main/java/com/dnd/dndtravel/map/exception/MemberAttractionNotFoundException.java b/src/main/java/com/dnd/dndtravel/map/exception/MemberAttractionNotFoundException.java new file mode 100644 index 0000000..8082de3 --- /dev/null +++ b/src/main/java/com/dnd/dndtravel/map/exception/MemberAttractionNotFoundException.java @@ -0,0 +1,9 @@ +package com.dnd.dndtravel.map.exception; + +public class MemberAttractionNotFoundException extends RuntimeException { + private static final String MESSAGE = "존재하지 않는 방문기록 [memberAttractionId=%s]"; + + public MemberAttractionNotFoundException(long memberAttractionId) { + super(String.format(MESSAGE, memberAttractionId)); + } +} diff --git a/src/main/java/com/dnd/dndtravel/map/exception/MemberNotFoundException.java b/src/main/java/com/dnd/dndtravel/map/exception/MemberNotFoundException.java new file mode 100644 index 0000000..fd78ecb --- /dev/null +++ b/src/main/java/com/dnd/dndtravel/map/exception/MemberNotFoundException.java @@ -0,0 +1,9 @@ +package com.dnd.dndtravel.map.exception; + +public class MemberNotFoundException extends RuntimeException { + private static final String MESSAGE = "존재하지 않는 유저 [memberId=%s]"; + + public MemberNotFoundException(long memberId) { + super(String.format(MESSAGE, memberId)); + } +} diff --git a/src/main/java/com/dnd/dndtravel/map/exception/PhotoDeleteFailException.java b/src/main/java/com/dnd/dndtravel/map/exception/PhotoDeleteFailException.java new file mode 100644 index 0000000..5b9c3c2 --- /dev/null +++ b/src/main/java/com/dnd/dndtravel/map/exception/PhotoDeleteFailException.java @@ -0,0 +1,11 @@ +package com.dnd.dndtravel.map.exception; + +import com.amazonaws.SdkClientException; + +public class PhotoDeleteFailException extends RuntimeException{ + private static final String MESSAGE = "s3 이미지 삭제 실패 [imagePath=%s]"; + + public PhotoDeleteFailException(String existingFileName, SdkClientException e) { + super(String.format(MESSAGE,existingFileName), e); + } +} diff --git a/src/main/java/com/dnd/dndtravel/map/exception/PhotoEmptyException.java b/src/main/java/com/dnd/dndtravel/map/exception/PhotoEmptyException.java new file mode 100644 index 0000000..307af0e --- /dev/null +++ b/src/main/java/com/dnd/dndtravel/map/exception/PhotoEmptyException.java @@ -0,0 +1,9 @@ +package com.dnd.dndtravel.map.exception; + +public class PhotoEmptyException extends RuntimeException{ + private static final String MESSAGE = "이미지가 존재하지 않음"; + + public PhotoEmptyException() { + super(MESSAGE); + } +} diff --git a/src/main/java/com/dnd/dndtravel/map/exception/PhotoInvalidException.java b/src/main/java/com/dnd/dndtravel/map/exception/PhotoInvalidException.java new file mode 100644 index 0000000..f558cbd --- /dev/null +++ b/src/main/java/com/dnd/dndtravel/map/exception/PhotoInvalidException.java @@ -0,0 +1,8 @@ +package com.dnd.dndtravel.map.exception; + +public class PhotoInvalidException extends RuntimeException { + + public PhotoInvalidException(String message) { + super(message); + } +} diff --git a/src/main/java/com/dnd/dndtravel/map/exception/PhotoUploadFailException.java b/src/main/java/com/dnd/dndtravel/map/exception/PhotoUploadFailException.java new file mode 100644 index 0000000..0c4fc2a --- /dev/null +++ b/src/main/java/com/dnd/dndtravel/map/exception/PhotoUploadFailException.java @@ -0,0 +1,19 @@ +package com.dnd.dndtravel.map.exception; + +import java.io.IOException; + +import org.springframework.web.multipart.MultipartFile; + +import com.amazonaws.SdkClientException; + +public class PhotoUploadFailException extends RuntimeException { + private static final String MESSAGE = "s3 이미지 업로드 실패 [image=%s]"; + + public PhotoUploadFailException(MultipartFile image, IOException e) { + super(String.format(MESSAGE, image), e); + } + + public PhotoUploadFailException(String image, SdkClientException e) { + super(String.format(MESSAGE, image), e); + } +} diff --git a/src/main/java/com/dnd/dndtravel/map/exception/RegionNotFoundException.java b/src/main/java/com/dnd/dndtravel/map/exception/RegionNotFoundException.java new file mode 100644 index 0000000..7dae336 --- /dev/null +++ b/src/main/java/com/dnd/dndtravel/map/exception/RegionNotFoundException.java @@ -0,0 +1,9 @@ +package com.dnd.dndtravel.map.exception; + +public class RegionNotFoundException extends RuntimeException { + private static final String MESSAGE = "존재하지 않는 지역 [region=%s]"; + + public RegionNotFoundException(String region) { + super(String.format(MESSAGE, region)); + } +} diff --git a/src/main/java/com/dnd/dndtravel/map/service/MapService.java b/src/main/java/com/dnd/dndtravel/map/service/MapService.java index 17c6375..7caabce 100644 --- a/src/main/java/com/dnd/dndtravel/map/service/MapService.java +++ b/src/main/java/com/dnd/dndtravel/map/service/MapService.java @@ -15,6 +15,9 @@ import com.dnd.dndtravel.map.domain.MemberAttraction; import com.dnd.dndtravel.map.domain.MemberRegion; import com.dnd.dndtravel.map.domain.Region; +import com.dnd.dndtravel.map.exception.MemberAttractionNotFoundException; +import com.dnd.dndtravel.map.exception.MemberNotFoundException; +import com.dnd.dndtravel.map.exception.RegionNotFoundException; import com.dnd.dndtravel.map.repository.dto.projection.AttractionPhotoProjection; import com.dnd.dndtravel.map.repository.dto.projection.RecordProjection; import com.dnd.dndtravel.map.service.dto.RegionDto; @@ -45,8 +48,7 @@ public class MapService { @Transactional(readOnly = true) public RegionResponse allRegions(long memberId) { - //todo custom ex - Member member = memberRepository.findById(memberId).orElseThrow(() -> new RuntimeException("존재하지 않는 유저")); + Member member = memberRepository.findById(memberId).orElseThrow(() -> new MemberNotFoundException(memberId)); // 유저의 지역 방문기록 전부 가져온다 List memberRegions = memberRegionRepository.findByMemberId(memberId); @@ -70,8 +72,8 @@ public RegionResponse allRegions(long memberId) { @Transactional public void recordAttraction(RecordDto recordDto, long memberId) { // validate - Region region = regionRepository.findByName(recordDto.region()).orElseThrow(() -> new RuntimeException("존재하지 않는 지역")); - Member member = memberRepository.findById(memberId).orElseThrow(() -> new RuntimeException("존재하지 않는 유저")); + Region region = regionRepository.findByName(recordDto.region()).orElseThrow(() -> new RegionNotFoundException(recordDto.region())); + Member member = memberRepository.findById(memberId).orElseThrow(() -> new MemberNotFoundException(memberId)); Attraction attraction = attractionRepository.save(Attraction.of(region, recordDto.attractionName())); @@ -90,7 +92,7 @@ public void recordAttraction(RecordDto recordDto, long memberId) { @Transactional(readOnly = true) public List allRecords(long memberId, long cursorNo, int displayPerPage) { //validation - Member member = memberRepository.findById(memberId).orElseThrow(() -> new RuntimeException("존재하지 않는 유저")); + Member member = memberRepository.findById(memberId).orElseThrow(() -> new MemberNotFoundException(memberId)); List memberAttractions = memberAttractionRepository.findByMemberId(memberId); if (memberAttractions.isEmpty()) { return List.of(); @@ -115,8 +117,9 @@ public List allRecords(long memberId, long cursorNo, i //기록 단건 조회 @Transactional(readOnly = true) public AttractionRecordDetailViewResponse findOneVisitRecord(long memberId, long memberAttractionId) { - Member member = memberRepository.findById(memberId).orElseThrow(() -> new RuntimeException("존재하지 않는 유저")); - MemberAttraction memberAttraction = memberAttractionRepository.findById(memberAttractionId).orElseThrow(() -> new RuntimeException("유효하지 않은 방문 상세 기록")); + //todo memberAttraction 쿼리한방으로 줄일수도 있을것같다. + Member member = memberRepository.findById(memberId).orElseThrow(() -> new MemberNotFoundException(memberId)); + MemberAttraction memberAttraction = memberAttractionRepository.findById(memberAttractionId).orElseThrow(() -> new MemberAttractionNotFoundException(memberAttractionId)); return AttractionRecordDetailViewResponse.from(memberAttraction); } @@ -124,7 +127,7 @@ public AttractionRecordDetailViewResponse findOneVisitRecord(long memberId, long @Transactional public void updateVisitRecord(RecordDto dto, long memberId, long memberAttractionId) { //validation - MemberAttraction memberAttraction = memberAttractionRepository.findByIdAndMemberId(memberAttractionId, memberId).orElseThrow(() -> new RuntimeException("유효하지 않은 방문 상세 기록")); + MemberAttraction memberAttraction = memberAttractionRepository.findByIdAndMemberId(memberAttractionId, memberId).orElseThrow(() -> new MemberAttractionNotFoundException(memberAttractionId)); //update memberAttraction.updateVisitRecord(dto.region(), dto.dateTime(), dto.memo()); @@ -146,7 +149,7 @@ public void updateVisitRecord(RecordDto dto, long memberId, long memberAttractio public void deleteRecord(long memberId, long memberAttractionId) { //validation MemberAttraction memberAttraction = memberAttractionRepository.findByIdAndMemberId(memberAttractionId, memberId) - .orElseThrow(() -> new RuntimeException("유효하지 않은 방문 상세 기록")); + .orElseThrow(() -> new MemberAttractionNotFoundException(memberAttractionId)); List photos = photoRepository.findByMemberAttractionId(memberAttraction.getId()); photoRepository.deleteAll(photos); diff --git a/src/main/java/com/dnd/dndtravel/map/service/PhotoService.java b/src/main/java/com/dnd/dndtravel/map/service/PhotoService.java index 6a7fc1e..c2a6087 100644 --- a/src/main/java/com/dnd/dndtravel/map/service/PhotoService.java +++ b/src/main/java/com/dnd/dndtravel/map/service/PhotoService.java @@ -17,6 +17,12 @@ import com.amazonaws.services.s3.model.ObjectMetadata; import com.amazonaws.services.s3.model.PutObjectRequest; +import com.dnd.dndtravel.map.exception.PhotoDeleteFailException; +import com.dnd.dndtravel.map.exception.PhotoEmptyException; +import com.dnd.dndtravel.map.exception.PhotoInvalidException; +import com.dnd.dndtravel.map.exception.PhotoUploadFailException; + + import lombok.RequiredArgsConstructor; @RequiredArgsConstructor @@ -30,7 +36,7 @@ public class PhotoService { public String upload(MultipartFile image) { if (image.isEmpty() || Objects.isNull(image.getOriginalFilename())) { - throw new RuntimeException("유효하지 않은 이미지"); + throw new PhotoEmptyException(); } validateFileExtension(image.getOriginalFilename()); return uploadImage(image); @@ -45,7 +51,7 @@ public void deleteBeforePhoto(List existingUrls) { try { amazonS3.deleteObject(bucketName, existingFileName); } catch (SdkClientException e) { - throw new RuntimeException("Failed to delete image from S3", e); + throw new PhotoDeleteFailException(existingFileName, e); } } } @@ -54,21 +60,21 @@ private String uploadImage(MultipartFile image) { try { return uploadImageToS3(image); } catch (IOException e) { - throw new RuntimeException("이미지 업로드 예외"); // custom ex (S3Exception) + throw new PhotoUploadFailException(image, e); } } private void validateFileExtension(String filename) { int lastDotIndex = filename.lastIndexOf("."); if (lastDotIndex == -1) { - throw new RuntimeException("파일 확장자가 없음"); + throw new PhotoInvalidException("파일 확장자가 없음"); } String extension = filename.substring(lastDotIndex + 1).toLowerCase(); List allowedExtentionList = Arrays.asList("jpg", "jpeg", "png"); if (!allowedExtentionList.contains(extension)) { - throw new RuntimeException("invalid file extension"); + throw new PhotoInvalidException("invalid file extension"); } } @@ -91,7 +97,7 @@ private String uploadImageToS3(MultipartFile image) throws IOException { //실제로 S3에 이미지 데이터를 넣는 부분이다. amazonS3.putObject(putObjectRequest); // put image to S3 } catch (SdkClientException e) { - throw new RuntimeException("Failed to upload image to S3", e); + throw new PhotoUploadFailException(s3FileName, e); } return amazonS3.getUrl(bucketName, s3FileName).toString();