Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[#34] ExceptionHandler 추가하고 Custom Exception으로 변환 #35

Merged
merged 2 commits into from
Sep 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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));
}
}
7 changes: 5 additions & 2 deletions src/main/java/com/dnd/dndtravel/auth/service/JwtProvider.java
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -25,7 +26,7 @@ public static <T> T decodePayload(String token, Class<T> targetClass) {
try {
return objectMapper.readValue(payload, targetClass);
} catch (Exception e) {
throw new RuntimeException("Error decoding token payload", e);
throw new AppleTokenDecodingException(e);
}
}
}
125 changes: 125 additions & 0 deletions src/main/java/com/dnd/dndtravel/common/CommonExceptionHandler.java
Original file line number Diff line number Diff line change
@@ -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 예외클래스가 많아지면 해당클래스가 길어질것으로 예상, 개선필요해보이고 보안 때문에 상태코드별로 애매하게 동일한 메시지를 전달해주고, 스웨거 문서로 상세 오류를 전달해주는데 이 구조가 적절한건지 고민해봐야한다.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

특정 예외 상황에 대한 상세한 오류 메시지는 로그로 남기고, 사용자나 스웨거에는 일반적인 메시지를 전달하는 건 어떨까용 ?
개발자는 상세하게 로그를 봐야 문제 발생 시 빠른 대처가 가능할 것 같다는 생각이 듭니다!!

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

음 .. 클라이언트가 구체적인 예외를 알아야 하는 경우가 예를 들면 어떤 것들이 있을 수 있을까여 ?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

사용자의 방문기록을 저장하는 API에서는 클라이언트 입장에서 상태코드 400을 받는 경우의수가 엄청 다양해요.
(방문기록 자체의 검증실패, 유저나 지역이 존재하지 않는경우, 방문기록자체가 없는경우, 요청 헤더에 토큰 형식이 잘못된 경우 등..)

HTTP 바디에는 일반적인 메시지가 전달되고 이는 스웨거에서도 그렇게 표기됩니다. 주석으로 적어놨던 스웨거 문서로 상세오류를 전달한다는건 description 같은 부가설명을 적는부분이에요 스웨거 한번보시면 이해하실듯 해요

@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<String> runtimeException() {
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(BAD_REQUEST_MESSAGE);
}

@ExceptionHandler(MemberNotFoundException.class)
public ResponseEntity<String> runtimeException(MemberNotFoundException e) {
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(BAD_REQUEST_MESSAGE);
}

@ExceptionHandler(MemberAttractionNotFoundException.class)
public ResponseEntity<String> runtimeException(MemberAttractionNotFoundException e) {
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(BAD_REQUEST_MESSAGE);
}

@ExceptionHandler(RegionNotFoundException.class)
public ResponseEntity<String> runtimeException(RegionNotFoundException e) {
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(BAD_REQUEST_MESSAGE);
}

@ExceptionHandler(AppleTokenDecodingException.class)
public ResponseEntity<String> runtimeException(AppleTokenDecodingException e) {
return ResponseEntity
.status(HttpStatus.UNAUTHORIZED)
.body("토큰 인증에 실패했습니다");
}

@ExceptionHandler(JwtTokenExpiredException.class)
public ResponseEntity<String> runtimeException(JwtTokenExpiredException e) {
return ResponseEntity
.status(HttpStatus.UNAUTHORIZED)
.body("토큰 인증에 실패했습니다");
}

@ExceptionHandler(JwtTokenDecodingException.class)
public ResponseEntity<String> runtimeException(JwtTokenDecodingException e) {
return ResponseEntity
.status(HttpStatus.UNAUTHORIZED)
.body("토큰 인증에 실패했습니다");
}
Comment on lines +55 to +74
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

음 .. 이 부분은 중복이 크게 띄니까 handleTokenExceptions 같은 이름으로 합치는 것이 어떨까요 ?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

코드로 어떻게 합칠수있나요?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ExceptionHandler(handleTokenExceptions.class)
	public ResponseEntity<String> runtimeException(handleTokenExceptions e) {
		return ResponseEntity
			.status(HttpStatus.UNAUTHORIZED)
			.body("토큰 인증에 실패했습니다");
	}

이런 느낌으로용 !

Copy link
Member Author

@youngreal youngreal Sep 4, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

handleTokenExceptions 추상클래스 를 만들어서 위 3개의 예외를 상속하는 방식 말씀하시는건가요?? 위 3개의 예외의 응답이 완전 똑같다는 확신이 생기면 어떻게든 줄일수 있을것같은데 클라랑 조금더 얘기해보고 변경해봐도 괜찮을것 같아요


@ExceptionHandler(RefreshTokenInvalidException.class)
public ResponseEntity<String> runtimeException(RefreshTokenInvalidException e) {
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(BAD_REQUEST_MESSAGE);
}

@ExceptionHandler(PhotoEmptyException.class)
public ResponseEntity<String> runtimeException(PhotoEmptyException e) {
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(BAD_REQUEST_MESSAGE);
}

@ExceptionHandler(PhotoDeleteFailException.class)
public ResponseEntity<String> runtimeException(PhotoDeleteFailException e) {
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(INTERNAL_SERVER_ERROR_MESSAGE);
}

@ExceptionHandler(PhotoUploadFailException.class)
public ResponseEntity<String> runtimeException(PhotoUploadFailException e) {
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(INTERNAL_SERVER_ERROR_MESSAGE);
}

@ExceptionHandler(PhotoInvalidException.class)
public ResponseEntity<String> runtimeException(PhotoInvalidException e) {
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(BAD_REQUEST_MESSAGE);
}


@ExceptionHandler(RuntimeException.class)
public ResponseEntity<String> runtimeException(RuntimeException e) {
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(INTERNAL_SERVER_ERROR_MESSAGE);
}

@ExceptionHandler(Exception.class)
public ResponseEntity<String> runtimeException(Exception e) {
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(INTERNAL_SERVER_ERROR_MESSAGE);
}
}
Original file line number Diff line number Diff line change
@@ -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));
}
}
Original file line number Diff line number Diff line change
@@ -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));
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.dnd.dndtravel.map.exception;

public class PhotoEmptyException extends RuntimeException{
private static final String MESSAGE = "이미지가 존재하지 않음";

public PhotoEmptyException() {
super(MESSAGE);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.dnd.dndtravel.map.exception;

public class PhotoInvalidException extends RuntimeException {

public PhotoInvalidException(String message) {
super(message);
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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));
}
}
21 changes: 12 additions & 9 deletions src/main/java/com/dnd/dndtravel/map/service/MapService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<MemberRegion> memberRegions = memberRegionRepository.findByMemberId(memberId);
Expand All @@ -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()));

Expand All @@ -90,7 +92,7 @@ public void recordAttraction(RecordDto recordDto, long memberId) {
@Transactional(readOnly = true)
public List<AttractionRecordResponse> 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<MemberAttraction> memberAttractions = memberAttractionRepository.findByMemberId(memberId);
if (memberAttractions.isEmpty()) {
return List.of();
Expand All @@ -115,16 +117,17 @@ public List<AttractionRecordResponse> 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);
}

// 방문기록 수정
@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());
Expand All @@ -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<Photo> photos = photoRepository.findByMemberAttractionId(memberAttraction.getId());
photoRepository.deleteAll(photos);
Expand Down
Loading
Loading