From 2872e8130fad3e4b34288e2a6aa9b36a5f115563 Mon Sep 17 00:00:00 2001 From: RosieOh Date: Sat, 24 Oct 2026 21:23:12 +0900 Subject: [PATCH] =?UTF-8?q?fix=20:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EC=9E=98=EB=AA=BB=EC=9D=84=20=EC=84=9C=EB=B2=84=20=EC=9E=98?= =?UTF-8?q?=EB=AA=BB=EC=9C=BC=EB=A1=9C=20=EB=B3=B4=EA=B3=A0=ED=95=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8A=94=EB=8B=A4=20(#158)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 입력 오류와 권한 거부가 500 으로 나갔다. CareServiceException 은 상태 코드를 문자열로 들고 다니고, 전역 핸들러가 그 문자열을 보고 상태를 정한다. 매핑은 네 가지(UNAUTHORIZED, FORBIDDEN, USER_NOT_FOUND, KAKAO*)뿐이고 나머지는 전부 INTERNAL_SERVER_ERROR 다. throw 47곳 중 39곳이 코드를 주지 않으므로 그대로 500 이 된다. 두 갈래로 나타났다. 1. 정상적인 거절이 장애로 보고된다 예약 8곳이 그랬다. 이미 취소한 예약을 또 취소(409), 완료된 예약 수정(409), 끝나는 시각이 시작보다 앞(400), 과거 시간(400), 자리 없음(409) 이 전부 500 이었다. 마지막이 특히 나쁘다 - 정원이 찬 인기 시설일수록 500 이 더 쌓여 정상 동작이 장애 지표를 오염시킨다. 상태가 맞지 않아 거절하는 경우를 위해 BOOKING_STATE_CONFLICT(F005, 409)를 더했다. 요청 자체는 멀쩡하고 대상의 상태가 맞지 않은 것이라 400 이 아니다. 2. catch (Exception e) 가 안쪽의 403·404 를 삼킨다 알림 상세 조회는 소유권 검사가 403 을 던지는데 같은 try 를 감싼 catch 가 받아 500 으로 바꿨다. 클라이언트는 "권한이 없다" 와 "서버가 고장났다" 를 구분할 수 없고, 로그에는 서버 오류로 쌓여 권한 탐색 시도가 장애로 보인다. 요청 경로의 24곳에 도메인 예외를 먼저 다시 던지는 절을 넣었다. 삼키던 IllegalArgumentException 중 "찾을 수 없습니다" 는 ResourceNotFoundException 으로 바꿔 404 가 되게 했다. 건드리지 않은 것: - 백그라운드 동기화(external/publicdata)는 감싸는 것이 맞다. HTTP 응답이 없다. - EmailVerificationService 의 발송 실패 catch 는 쿨다운 키를 지운다. 재던지기를 넣으면 그 정리를 건너뛰어 사용자가 1분간 재시도조차 못 한다. - LegalDocumentService 는 클래스패스 파일만 읽어 도메인 예외가 나올 수 없다. 넣어 봐야 죽은 코드다. 먼저 실패 테스트로 현재 상태를 드러냈다 - 알림 403/404 두 개, 예약 409/400 다섯 개. 일곱 개 모두 500 쪽으로 실패하는 것을 확인한 뒤 고쳤다. 기존 CareFacilityBookingServiceTest 세 개가 깨졌고 그게 맞다. 그 테스트가 고정하던 것이 바로 이 버그였다 - 거부한다는 사실은 맞게 봤지만 거부가 500 으로 나간다는 것까지 같이 굳혀 두고 있었다. 타입만 바꾸지 않고 상태 코드까지 단언하도록 올렸다. 클래스가 아니라 계약을 지켜야 다시 돌아가지 않는다. --- .../carecode/core/exception/ErrorCode.java | 2 + .../chatbot/controller/ChatbotController.java | 5 + .../chatbot/service/ChatbotService.java | 24 +++ .../community/service/CommunityService.java | 4 +- .../service/CareFacilityBookingService.java | 18 +- .../domain/health/service/HealthService.java | 8 +- .../NotificationPreferenceService.java | 16 ++ .../service/NotificationService.java | 22 ++- .../domain/user/service/UserService.java | 5 + .../CareFacilityBookingErrorStatusTest.java | 179 ++++++++++++++++++ .../CareFacilityBookingServiceTest.java | 24 ++- .../service/NotificationErrorStatusTest.java | 95 ++++++++++ 12 files changed, 378 insertions(+), 24 deletions(-) create mode 100644 src/test/java/com/carecode/domain/facility/service/CareFacilityBookingErrorStatusTest.java create mode 100644 src/test/java/com/carecode/domain/notification/service/NotificationErrorStatusTest.java diff --git a/src/main/java/com/carecode/core/exception/ErrorCode.java b/src/main/java/com/carecode/core/exception/ErrorCode.java index 086a3dd..cf75591 100644 --- a/src/main/java/com/carecode/core/exception/ErrorCode.java +++ b/src/main/java/com/carecode/core/exception/ErrorCode.java @@ -43,6 +43,8 @@ public enum ErrorCode { BOOKING_NOT_FOUND("F002", "예약을 찾을 수 없습니다", HttpStatus.NOT_FOUND), BOOKING_ALREADY_EXISTS("F003", "이미 예약된 시간입니다", HttpStatus.CONFLICT), BOOKING_ACCESS_DENIED("F004", "예약에 대한 권한이 없습니다", HttpStatus.FORBIDDEN), + /** 요청 자체는 멀쩡한데 예약의 현재 상태가 맞지 않다(이미 취소됨, 이미 완료됨). 409. */ + BOOKING_STATE_CONFLICT("F005", "예약 상태가 맞지 않아 처리할 수 없습니다", HttpStatus.CONFLICT), // ===== 정책 관련 에러 (P000) ===== POLICY_NOT_FOUND("P001", "정책을 찾을 수 없습니다", HttpStatus.NOT_FOUND), diff --git a/src/main/java/com/carecode/domain/chatbot/controller/ChatbotController.java b/src/main/java/com/carecode/domain/chatbot/controller/ChatbotController.java index eccf025..7a161d5 100644 --- a/src/main/java/com/carecode/domain/chatbot/controller/ChatbotController.java +++ b/src/main/java/com/carecode/domain/chatbot/controller/ChatbotController.java @@ -4,6 +4,7 @@ import com.carecode.core.annotation.RateLimit; import com.carecode.core.web.BaseController; import com.carecode.domain.user.app.CurrentUserFacade; +import com.carecode.core.exception.CareCodeException; import com.carecode.core.exception.CareServiceException; import com.carecode.domain.chatbot.dto.request.ChatbotMessageRequest; import com.carecode.domain.chatbot.dto.response.ChatbotMessageResponse; @@ -56,6 +57,10 @@ public ResponseEntity sendMessage( } catch (CareServiceException e) { log.error("챗봇 처리 오류: {}", e.getMessage()); throw e; + } catch (CareCodeException e) { + // 누구의 잘못인지 이미 정해진 예외다. 감싸면 전부 500 이 된다. + // (CareServiceException 은 바로 위에서 이미 다시 던진다.) + throw e; } catch (Exception e) { log.error("챗봇 처리 중 예상치 못한 오류: {}", e.getMessage(), e); throw new CareServiceException("챗봇 처리 중 오류가 발생했습니다: " + e.getMessage(), e); diff --git a/src/main/java/com/carecode/domain/chatbot/service/ChatbotService.java b/src/main/java/com/carecode/domain/chatbot/service/ChatbotService.java index cd014df..973e33e 100644 --- a/src/main/java/com/carecode/domain/chatbot/service/ChatbotService.java +++ b/src/main/java/com/carecode/domain/chatbot/service/ChatbotService.java @@ -1,6 +1,7 @@ package com.carecode.domain.chatbot.service; import com.carecode.core.annotation.LogExecutionTime; +import com.carecode.core.exception.CareCodeException; import com.carecode.core.exception.CareServiceException; import com.carecode.core.exception.ResourceNotFoundException; import com.carecode.domain.chatbot.dto.request.ChatbotMessageRequest; @@ -140,6 +141,9 @@ public ChatbotMessageResponse processMessage(ChatbotMessageRequest request) { .timestamp(LocalDateTime.now()) .build(); + } catch (CareCodeException | CareServiceException e) { + // 누구의 잘못인지 이미 정해진 예외다. 감싸면 전부 500 이 된다. + throw e; } catch (Exception e) { log.error("챗봇 메시지 처리 중 오류 발생: {}", e.getMessage(), e); // 상세한 오류 정보 로깅 @@ -171,6 +175,8 @@ public List getChatHistory(String userId, String .map(this::convertToHistoryResponse) .toList(); + } catch (CareCodeException | CareServiceException e) { + throw e; } catch (Exception e) { log.error("대화 기록 조회 중 오류 발생: {}", e.getMessage(), e); throw new CareServiceException("대화 기록 조회 중 오류가 발생했습니다.", e); @@ -192,6 +198,8 @@ public List getSessions(String userId, int page, int .map(this::convertToSessionResponse) .toList(); + } catch (CareCodeException | CareServiceException e) { + throw e; } catch (Exception e) { log.error("세션 목록 조회 중 오류 발생: {}", e.getMessage(), e); throw new CareServiceException("세션 목록 조회 중 오류가 발생했습니다.", e); @@ -211,6 +219,8 @@ public void processFeedback(Long messageId, boolean isHelpful) { message.setIsHelpful(isHelpful); chatMessageRepository.save(message); + } catch (CareCodeException | CareServiceException e) { + throw e; } catch (Exception e) { log.error("메시지 피드백 처리 중 오류 발생: {}", e.getMessage(), e); throw new CareServiceException("메시지 피드백 처리 중 오류가 발생했습니다.", e); @@ -230,6 +240,8 @@ public List getMessagesByIntentType(String userId return messages.stream() .map(this::convertToHistoryResponse) .collect(Collectors.toList()); + } catch (CareCodeException | CareServiceException e) { + throw e; } catch (Exception e) { log.error("의도 타입별 메시지 조회 중 오류 발생: {}", e.getMessage(), e); throw new CareServiceException("의도 타입별 메시지 조회 중 오류가 발생했습니다.", e); @@ -249,6 +261,8 @@ public List getMessagesByDateRange(String userId, return messages.stream() .map(this::convertToHistoryResponse) .collect(Collectors.toList()); + } catch (CareCodeException | CareServiceException e) { + throw e; } catch (Exception e) { log.error("기간별 메시지 조회 중 오류 발생: {}", e.getMessage(), e); throw new CareServiceException("기간별 메시지 조회 중 오류가 발생했습니다.", e); @@ -268,6 +282,8 @@ public List getMessagesByHelpfulStatus(String use return messages.stream() .map(this::convertToHistoryResponse) .collect(Collectors.toList()); + } catch (CareCodeException | CareServiceException e) { + throw e; } catch (Exception e) { log.error("도움됨 여부별 메시지 조회 중 오류 발생: {}", e.getMessage(), e); throw new CareServiceException("도움됨 여부별 메시지 조회 중 오류가 발생했습니다.", e); @@ -287,6 +303,8 @@ public List searchMessagesByKeyword(String userId return messages.stream() .map(this::convertToHistoryResponse) .collect(Collectors.toList()); + } catch (CareCodeException | CareServiceException e) { + throw e; } catch (Exception e) { log.error("키워드로 메시지 검색 중 오류 발생: {}", e.getMessage(), e); throw new CareServiceException("키워드로 메시지 검색 중 오류가 발생했습니다.", e); @@ -306,6 +324,8 @@ public List getSessionsByStatus(String userId, ChatSe return sessions.stream() .map(this::convertToSessionResponse) .collect(Collectors.toList()); + } catch (CareCodeException | CareServiceException e) { + throw e; } catch (Exception e) { log.error("상태별 세션 조회 중 오류 발생: {}", e.getMessage(), e); throw new CareServiceException("상태별 세션 조회 중 오류가 발생했습니다.", e); @@ -325,6 +345,8 @@ public List getSessionsByDateRange(String userId, Loc return sessions.stream() .map(this::convertToSessionResponse) .collect(Collectors.toList()); + } catch (CareCodeException | CareServiceException e) { + throw e; } catch (Exception e) { log.error("기간별 세션 조회 중 오류 발생: {}", e.getMessage(), e); throw new CareServiceException("기간별 세션 조회 중 오류가 발생했습니다.", e); @@ -340,6 +362,8 @@ public long getSessionCountByUser(String userId) { User user = resolveUser(userId); return chatSessionRepository.countByUser(user); + } catch (CareCodeException | CareServiceException e) { + throw e; } catch (Exception e) { log.error("사용자별 세션 수 조회 중 오류 발생: {}", e.getMessage(), e); throw new CareServiceException("사용자별 세션 수 조회 중 오류가 발생했습니다.", e); diff --git a/src/main/java/com/carecode/domain/community/service/CommunityService.java b/src/main/java/com/carecode/domain/community/service/CommunityService.java index 1bf4e09..285023d 100644 --- a/src/main/java/com/carecode/domain/community/service/CommunityService.java +++ b/src/main/java/com/carecode/domain/community/service/CommunityService.java @@ -1,5 +1,7 @@ package com.carecode.domain.community.service; +import com.carecode.core.exception.BusinessException; +import com.carecode.core.exception.ErrorCode; import com.carecode.core.exception.CareServiceException; import com.carecode.core.exception.CommentAccessDeniedException; import com.carecode.core.exception.PostAccessDeniedException; @@ -366,7 +368,7 @@ private User getCurrentUser() { // 이메일 형식 검증 if (userEmail == null || !userEmail.contains("@")) { log.error("getCurrentUser() - 유효하지 않은 이메일 형식: {}", userEmail); - throw new CareServiceException("유효하지 않은 사용자 정보입니다."); + throw new BusinessException(ErrorCode.UNAUTHORIZED, "유효하지 않은 사용자 정보입니다."); } return userRepository.findByEmailAndDeletedAtIsNull(userEmail) diff --git a/src/main/java/com/carecode/domain/facility/service/CareFacilityBookingService.java b/src/main/java/com/carecode/domain/facility/service/CareFacilityBookingService.java index 72b82c9..1da9891 100644 --- a/src/main/java/com/carecode/domain/facility/service/CareFacilityBookingService.java +++ b/src/main/java/com/carecode/domain/facility/service/CareFacilityBookingService.java @@ -2,7 +2,6 @@ import com.carecode.core.annotation.LogExecutionTime; import com.carecode.core.exception.BusinessException; -import com.carecode.core.exception.CareServiceException; import com.carecode.core.exception.ErrorCode; import com.carecode.core.exception.ResourceNotFoundException; import com.carecode.domain.facility.dto.response.BookingResponse; @@ -155,11 +154,11 @@ public void cancelBooking(Long bookingId, String userId) { if (booking.getStatus() == CareFacilityBooking.BookingStatus.CANCELLED || booking.getStatus() == CareFacilityBooking.BookingStatus.REJECTED) { - throw new CareServiceException("이미 취소되었거나 반려된 예약입니다."); + throw new BusinessException(ErrorCode.BOOKING_STATE_CONFLICT, "이미 취소되었거나 반려된 예약입니다."); } if (booking.getStatus() == CareFacilityBooking.BookingStatus.COMPLETED) { - throw new CareServiceException("이미 완료된 예약은 취소할 수 없습니다."); + throw new BusinessException(ErrorCode.BOOKING_STATE_CONFLICT, "이미 완료된 예약은 취소할 수 없습니다."); } booking.cancel("사용자에 의해 취소됨"); @@ -178,11 +177,11 @@ public BookingResponse updateBooking(Long bookingId, UpdateBookingRequest reques if (booking.getStatus() == CareFacilityBooking.BookingStatus.CANCELLED || booking.getStatus() == CareFacilityBooking.BookingStatus.REJECTED) { - throw new CareServiceException("취소되었거나 반려된 예약은 수정할 수 없습니다."); + throw new BusinessException(ErrorCode.BOOKING_STATE_CONFLICT, "취소되었거나 반려된 예약은 수정할 수 없습니다."); } if (booking.getStatus() == CareFacilityBooking.BookingStatus.COMPLETED) { - throw new CareServiceException("완료된 예약은 수정할 수 없습니다."); + throw new BusinessException(ErrorCode.BOOKING_STATE_CONFLICT, "완료된 예약은 수정할 수 없습니다."); } // 예약 시간이 바뀌면 다시 검증한다. 종료 시각만 바뀌어도 겹침 여부가 달라지므로 둘 다 비교한다. @@ -235,13 +234,13 @@ private void validateBookingTime(CareFacility facility, LocalDateTime endTime, Long excludeBookingId) { if (startTime == null || endTime == null) { - throw new CareServiceException("예약 시작/종료 시간은 필수입니다."); + throw new BusinessException(ErrorCode.INVALID_INPUT, "예약 시작/종료 시간은 필수입니다."); } if (!endTime.isAfter(startTime)) { - throw new CareServiceException("예약 종료 시간은 시작 시간보다 뒤여야 합니다."); + throw new BusinessException(ErrorCode.INVALID_INPUT, "예약 종료 시간은 시작 시간보다 뒤여야 합니다."); } if (startTime.isBefore(LocalDateTime.now())) { - throw new CareServiceException("과거 시간으로는 예약할 수 없습니다."); + throw new BusinessException(ErrorCode.INVALID_INPUT, "과거 시간으로는 예약할 수 없습니다."); } long overlapping = bookingRepository.countOverlappingBookings( @@ -252,7 +251,8 @@ private void validateBookingTime(CareFacility facility, : DEFAULT_CONCURRENT_BOOKING_LIMIT; if (overlapping >= capacity) { - throw new CareServiceException("해당 시간에 예약 가능한 자리가 없습니다. 다른 시간을 선택해주세요."); + throw new BusinessException(ErrorCode.BOOKING_ALREADY_EXISTS, + "해당 시간에 예약 가능한 자리가 없습니다. 다른 시간을 선택해주세요."); } } diff --git a/src/main/java/com/carecode/domain/health/service/HealthService.java b/src/main/java/com/carecode/domain/health/service/HealthService.java index 2247c98..4ef3d0c 100644 --- a/src/main/java/com/carecode/domain/health/service/HealthService.java +++ b/src/main/java/com/carecode/domain/health/service/HealthService.java @@ -84,7 +84,8 @@ public HealthRecordResponse createHealthRecord(HealthCreateHealthRecordRequest r return healthRecordMapper.toResponse(savedRecord); } catch (NumberFormatException e) { log.error("잘못된 아동 ID 형식: {}", request.getChildId()); - throw new CareServiceException("잘못된 아동 ID 형식입니다: " + request.getChildId(), e); + throw new BusinessException(ErrorCode.INVALID_CHILD_ID, + "잘못된 아동 ID 형식입니다: " + request.getChildId()); } catch (CareCodeException e) { throw e; } catch (CareServiceException e) { @@ -216,6 +217,9 @@ public List getHealthRecordsByDateRangeAsc(Long childId, L // 우리가 던진 예외는 자기 상태 코드를 들고 있다. 아래 catch 에 걸리면 404·403 이 // 전부 500 으로 덮이고, 남의 자녀 접근 거부가 서버 오류로 보고된다. throw e; + } catch (CareServiceException e) { + // CareCodeException 은 바로 위에서 이미 다시 던진다. 이쪽 체계도 같이 통과시킨다. + throw e; } catch (Exception e) { log.error("기간별 건강 기록 조회 실패: {}", e.getMessage(), e); throw new CareServiceException("기간별 건강 기록 조회 중 오류가 발생했습니다.", e); @@ -240,6 +244,8 @@ public List getHealthRecordsByType(Long childId, HealthRec // 우리가 던진 예외는 자기 상태 코드를 들고 있다. 아래 catch 에 걸리면 404·403 이 // 전부 500 으로 덮이고, 남의 자녀 접근 거부가 서버 오류로 보고된다. throw e; + } catch (CareServiceException e) { + throw e; } catch (Exception e) { log.error("타입별 건강 기록 조회 실패: {}", e.getMessage(), e); throw new CareServiceException("타입별 건강 기록 조회 중 오류가 발생했습니다.", e); diff --git a/src/main/java/com/carecode/domain/notification/service/NotificationPreferenceService.java b/src/main/java/com/carecode/domain/notification/service/NotificationPreferenceService.java index 879823d..46cc54c 100644 --- a/src/main/java/com/carecode/domain/notification/service/NotificationPreferenceService.java +++ b/src/main/java/com/carecode/domain/notification/service/NotificationPreferenceService.java @@ -1,6 +1,7 @@ package com.carecode.domain.notification.service; import com.carecode.core.annotation.LogExecutionTime; +import com.carecode.core.exception.CareCodeException; import com.carecode.core.exception.CareServiceException; import com.carecode.core.exception.ResourceNotFoundException; import com.carecode.domain.notification.dto.request.NotificationRegisterPushTokenRequest; @@ -49,6 +50,9 @@ public List getUserPreferences(String userId) { return preferences.stream() .map(this::convertToDto) .collect(Collectors.toList()); + } catch (CareCodeException | CareServiceException e) { + // 누구의 잘못인지 이미 정해진 예외다. 감싸면 전부 500 이 된다. + throw e; } catch (Exception e) { log.error("사용자별 알림 설정 조회 실패: {}", e.getMessage(), e); throw new CareServiceException("알림 설정 조회 중 오류가 발생했습니다.", e); @@ -68,6 +72,8 @@ public NotificationSettingsResponse getPreferenceByType(String userId, Notificat return preference.map(this::convertToDto) .orElseGet(() -> convertToDto(createDefaultPreference(user, notificationType))); + } catch (CareCodeException | CareServiceException e) { + throw e; } catch (Exception e) { log.error("알림 타입별 설정 조회 실패: {}", e.getMessage(), e); throw new CareServiceException("알림 설정 조회 중 오류가 발생했습니다.", e); @@ -93,6 +99,8 @@ public NotificationSettingsResponse savePreference(String userId, NotificationSe NotificationPreference savedPreference = preferenceRepository.save(preference); return convertToDto(savedPreference); + } catch (CareCodeException | CareServiceException e) { + throw e; } catch (Exception e) { log.error("알림 설정 저장 실패: {}", e.getMessage(), e); throw new CareServiceException("알림 설정 저장 중 오류가 발생했습니다.", e); @@ -118,6 +126,8 @@ public NotificationSettingsResponse updateChannelPreference(String userId, Strin NotificationPreference savedPreference = preferenceRepository.save(preference); return convertToDto(savedPreference); + } catch (CareCodeException | CareServiceException e) { + throw e; } catch (Exception e) { log.error("채널별 설정 업데이트 실패: {}", e.getMessage(), e); throw new CareServiceException("채널별 설정 업데이트 중 오류가 발생했습니다.", e); @@ -154,6 +164,8 @@ public void disableAllNotifications(String userId) { preference.setInAppEnabled(false); preferenceRepository.save(preference); } + } catch (CareCodeException | CareServiceException e) { + throw e; } catch (Exception e) { log.error("모든 알림 설정 비활성화 실패: {}", e.getMessage(), e); throw new CareServiceException("알림 설정 비활성화 중 오류가 발생했습니다.", e); @@ -178,6 +190,8 @@ public void resetToDefault(String userId) { for (Notification.NotificationType type : Notification.NotificationType.values()) { createDefaultPreference(user, type); } + } catch (CareCodeException | CareServiceException e) { + throw e; } catch (Exception e) { log.error("알림 설정 초기화 실패: {}", e.getMessage(), e); throw new CareServiceException("알림 설정 초기화 중 오류가 발생했습니다.", e); @@ -195,6 +209,8 @@ public List getEnabledPreferencesByType(Notificati return preferences.stream() .map(this::convertToDto) .collect(Collectors.toList()); + } catch (CareCodeException | CareServiceException e) { + throw e; } catch (Exception e) { log.error("알림 타입별 활성화된 설정 조회 실패: {}", e.getMessage(), e); throw new CareServiceException("활성화된 설정 조회 중 오류가 발생했습니다.", e); diff --git a/src/main/java/com/carecode/domain/notification/service/NotificationService.java b/src/main/java/com/carecode/domain/notification/service/NotificationService.java index 26ccb7f..3321632 100644 --- a/src/main/java/com/carecode/domain/notification/service/NotificationService.java +++ b/src/main/java/com/carecode/domain/notification/service/NotificationService.java @@ -1,6 +1,7 @@ package com.carecode.domain.notification.service; import com.carecode.core.annotation.LogExecutionTime; +import com.carecode.core.exception.CareCodeException; import com.carecode.core.exception.BusinessException; import com.carecode.core.exception.CareServiceException; import com.carecode.core.exception.ResourceNotFoundException; @@ -55,13 +56,16 @@ public List getNotificationsByUserId(String userId) { try { User user = userRepository.findByUserId(userId) - .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다: " + userId)); - + .orElseThrow(() -> new ResourceNotFoundException("사용자를 찾을 수 없습니다: " + userId)); + List notifications = notificationRepository.findByUserIdOrderByCreatedAtDesc(user.getId()); - + return notifications.stream() .map(this::convertToResponseDto) .collect(Collectors.toList()); + } catch (CareCodeException | CareServiceException e) { + // 누구의 잘못인지 이미 정해진 예외다. 여기서 감싸면 전부 500 이 된다. + throw e; } catch (Exception e) { log.error("사용자별 알림 목록 조회 실패: {}", e.getMessage()); throw new CareServiceException("사용자별 알림 목록 조회 중 오류가 발생했습니다.", e); @@ -75,10 +79,13 @@ public NotificationInfoResponse getNotificationById(Long notificationId, String try { Notification notification = notificationRepository.findById(notificationId) - .orElseThrow(() -> new IllegalArgumentException("알림을 찾을 수 없습니다: " + notificationId)); + .orElseThrow(() -> new ResourceNotFoundException("알림을 찾을 수 없습니다: " + notificationId)); assertNotificationOwnedByUser(notification, actorUserId); - + return convertToResponseDto(notification); + } catch (CareCodeException | CareServiceException e) { + // 소유권 검사가 던지는 403 이 여기를 지난다. 감싸면 권한 탐색이 장애로 보인다. + throw e; } catch (Exception e) { log.error("알림 상세 조회 실패: {}", e.getMessage()); throw new CareServiceException("알림 상세 조회 중 오류가 발생했습니다.", e); @@ -96,7 +103,7 @@ public NotificationInfoResponse createNotification(NotificationCreateRequest req throw new BusinessException(ErrorCode.FORBIDDEN, "다른 사용자를 대상으로 알림을 생성할 수 없습니다."); } User user = userRepository.findByUserId(request.getUserId()) - .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다: " + request.getUserId())); + .orElseThrow(() -> new ResourceNotFoundException("사용자를 찾을 수 없습니다: " + request.getUserId())); // 전략 패턴을 사용하여 알림 생성 NotificationStrategy strategy = strategyFactory.getStrategy(request.getNotificationType()); @@ -117,6 +124,9 @@ public NotificationInfoResponse createNotification(NotificationCreateRequest req notificationDispatcher.dispatchAsync(savedNotification); return convertToResponseDto(savedNotification); + } catch (CareCodeException | CareServiceException e) { + // "남을 대상으로 알림을 만들 수 없다"(403)가 여기를 지난다. + throw e; } catch (Exception e) { log.error("알림 생성 실패: {}", e.getMessage()); throw new CareServiceException("알림 생성 중 오류가 발생했습니다.", e); diff --git a/src/main/java/com/carecode/domain/user/service/UserService.java b/src/main/java/com/carecode/domain/user/service/UserService.java index a601149..b548e61 100644 --- a/src/main/java/com/carecode/domain/user/service/UserService.java +++ b/src/main/java/com/carecode/domain/user/service/UserService.java @@ -4,6 +4,8 @@ import com.carecode.core.analytics.EventType; import com.carecode.core.annotation.LogExecutionTime; import com.carecode.core.annotation.RequireAuthentication; +import com.carecode.core.exception.CareServiceException; +import com.carecode.core.exception.CareCodeException; import com.carecode.core.exception.UserNotFoundException; import com.carecode.domain.user.dto.request.PasswordChangeRequestDto; import com.carecode.domain.user.dto.request.SignUpRequest; @@ -161,6 +163,9 @@ public Map getKakaoUserInfo(String accessToken) { } else { throw new RuntimeException("카카오 API 호출 중 오류가 발생했습니다: " + e.getMessage(), e); } + } catch (CareCodeException | CareServiceException e) { + // 누구의 잘못인지 이미 정해진 예외다. 감싸면 전부 500 이 된다. + throw e; } catch (Exception e) { log.error("카카오 사용자 정보 조회 실패: {}", e.getMessage(), e); throw new RuntimeException("카카오 사용자 정보를 가져올 수 없습니다.", e); diff --git a/src/test/java/com/carecode/domain/facility/service/CareFacilityBookingErrorStatusTest.java b/src/test/java/com/carecode/domain/facility/service/CareFacilityBookingErrorStatusTest.java new file mode 100644 index 0000000..1cbc5a4 --- /dev/null +++ b/src/test/java/com/carecode/domain/facility/service/CareFacilityBookingErrorStatusTest.java @@ -0,0 +1,179 @@ +package com.carecode.domain.facility.service; + +import com.carecode.core.exception.BusinessException; +import com.carecode.core.exception.ErrorCode; +import com.carecode.domain.facility.dto.request.CreateBookingRequest; +import com.carecode.domain.facility.entity.CareFacility; +import com.carecode.domain.facility.entity.CareFacilityBooking; +import com.carecode.domain.facility.repository.CareFacilityBookingRepository; +import com.carecode.domain.facility.repository.CareFacilityRepository; +import com.carecode.domain.user.entity.User; +import com.carecode.domain.user.repository.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; + +import java.time.LocalDateTime; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.when; + +/** + * 예약이 거절됐을 때 누구의 잘못인지 상태 코드가 말해야 한다. + * + *

예약 거절은 전부 {@code CareServiceException} 을 코드 없이 던졌다. 전역 핸들러는 그 예외의 + * 코드 문자열로 상태를 정하는데, 코드를 주지 않으면 {@code CARE_SERVICE_ERROR} 가 되고 매핑에 + * 없으므로 500 이 나간다. 그래서 다음이 전부 "서버 오류" 로 보고됐다. + * + *

    + *
  • 이미 취소한 예약을 또 취소 — 사용자가 두 번 눌렀을 뿐이다
  • + *
  • 끝나는 시각이 시작보다 앞 — 입력이 잘못됐다
  • + *
  • 그 시간에 자리가 없음 — 정상적인 거절이다. 다른 시간을 고르면 된다
  • + *
+ * + *

500 은 클라이언트에게 "서버가 고장났으니 다시 해 보라" 는 뜻이고, 운영에는 장애로 쌓인다. + * 정원이 찬 인기 시설일수록 더 많이 쌓이므로 정상 동작이 장애 지표를 오염시킨다. + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("예약 거절의 책임 소재") +class CareFacilityBookingErrorStatusTest { + + private static final String USER_ID = "u-1"; + private static final Long FACILITY_ID = 10L; + + @Mock CareFacilityBookingRepository bookingRepository; + @Mock CareFacilityRepository careFacilityRepository; + @Mock UserRepository userRepository; + + @InjectMocks CareFacilityBookingService bookingService; + + private CareFacility facility; + + @BeforeEach + void setUp() { + facility = CareFacility.builder() + .id(FACILITY_ID) + .name("햇살어린이집") + .capacity(1) + .build(); + } + + private CareFacilityBooking bookingWith(CareFacilityBooking.BookingStatus status) { + CareFacilityBooking booking = new CareFacilityBooking(); + booking.setId(1L); + booking.setUserId(USER_ID); + booking.setFacility(facility); + booking.setStatus(status); + booking.setStartTime(LocalDateTime.now().plusDays(1)); + booking.setEndTime(LocalDateTime.now().plusDays(1).plusHours(1)); + return booking; + } + + /** 상태 때문에 지금 할 수 없는 요청은 409 다. 요청 자체는 멀쩡하고 대상의 상태가 맞지 않다. */ + @Nested + @DisplayName("상태가 맞지 않아 거절할 때는 409") + class StateConflicts { + + @Test + @DisplayName("이미 취소한 예약을 또 취소하면 409 - 두 번 누른 것뿐이다") + void cancellingTwiceIsConflict() { + when(bookingRepository.findById(1L)) + .thenReturn(Optional.of(bookingWith(CareFacilityBooking.BookingStatus.CANCELLED))); + + assertThatThrownBy(() -> bookingService.cancelBooking(1L, USER_ID)) + .isInstanceOf(BusinessException.class) + .satisfies(e -> assertThat(((BusinessException) e).getHttpStatus()) + .isEqualTo(HttpStatus.CONFLICT)); + } + + @Test + @DisplayName("완료된 예약은 취소할 수 없고 409 다") + void cancellingCompletedIsConflict() { + when(bookingRepository.findById(1L)) + .thenReturn(Optional.of(bookingWith(CareFacilityBooking.BookingStatus.COMPLETED))); + + assertThatThrownBy(() -> bookingService.cancelBooking(1L, USER_ID)) + .isInstanceOf(BusinessException.class) + .satisfies(e -> assertThat(((BusinessException) e).getHttpStatus()) + .isEqualTo(HttpStatus.CONFLICT)); + } + } + + /** 입력이 잘못된 요청은 400 이다. 서버가 다시 시도해도 같은 결과다. */ + @Nested + @DisplayName("입력이 잘못됐을 때는 400") + class BadInput { + + private CreateBookingRequest request(LocalDateTime start, LocalDateTime end) { + CreateBookingRequest r = new CreateBookingRequest(); + r.setBookingType("VISIT"); + r.setStartTime(start); + r.setEndTime(end); + r.setChildName("아이"); + r.setChildAge(3); + return r; + } + + private void givenFacilityAndUser() { + when(careFacilityRepository.findById(FACILITY_ID)).thenReturn(Optional.of(facility)); + lenient().when(userRepository.findByUserId(USER_ID)).thenReturn(Optional.of( + User.builder().id(1L).userId(USER_ID).email("a@b.c").name("보호자").build())); + } + + @Test + @DisplayName("끝나는 시각이 시작보다 앞이면 400") + void endBeforeStartIsBadRequest() { + givenFacilityAndUser(); + LocalDateTime start = LocalDateTime.now().plusDays(1); + + assertThatThrownBy(() -> bookingService.createBooking( + FACILITY_ID, request(start, start.minusHours(1)), USER_ID)) + .isInstanceOf(BusinessException.class) + .satisfies(e -> assertThat(((BusinessException) e).getHttpStatus()) + .isEqualTo(HttpStatus.BAD_REQUEST)); + } + + @Test + @DisplayName("과거 시간으로 예약하면 400") + void pastTimeIsBadRequest() { + givenFacilityAndUser(); + LocalDateTime start = LocalDateTime.now().minusDays(1); + + assertThatThrownBy(() -> bookingService.createBooking( + FACILITY_ID, request(start, start.plusHours(1)), USER_ID)) + .isInstanceOf(BusinessException.class) + .satisfies(e -> assertThat(((BusinessException) e).getHttpStatus()) + .isEqualTo(HttpStatus.BAD_REQUEST)); + } + + /** + * 자리가 없는 것은 입력 오류가 아니라 정상적인 거절이다. 409 로 돌려줘야 + * 클라이언트가 "다른 시간을 고르라" 고 안내할 수 있다. + */ + @Test + @DisplayName("그 시간에 자리가 없으면 409 - 정상 거절이지 장애가 아니다") + void noCapacityIsConflict() { + givenFacilityAndUser(); + LocalDateTime start = LocalDateTime.now().plusDays(1); + when(bookingRepository.countOverlappingBookings(anyLong(), any(), any(), any())) + .thenReturn(1L); + + assertThatThrownBy(() -> bookingService.createBooking( + FACILITY_ID, request(start, start.plusHours(1)), USER_ID)) + .isInstanceOf(BusinessException.class) + .satisfies(e -> assertThat(((BusinessException) e).getErrorCode()) + .isEqualTo(ErrorCode.BOOKING_ALREADY_EXISTS)); + } + } +} diff --git a/src/test/java/com/carecode/domain/facility/service/CareFacilityBookingServiceTest.java b/src/test/java/com/carecode/domain/facility/service/CareFacilityBookingServiceTest.java index 5b19406..43a3623 100644 --- a/src/test/java/com/carecode/domain/facility/service/CareFacilityBookingServiceTest.java +++ b/src/test/java/com/carecode/domain/facility/service/CareFacilityBookingServiceTest.java @@ -1,6 +1,7 @@ package com.carecode.domain.facility.service; -import com.carecode.core.exception.CareServiceException; +import com.carecode.core.exception.BusinessException; +import com.carecode.core.exception.ErrorCode; import com.carecode.domain.facility.dto.request.CreateBookingRequest; import com.carecode.domain.facility.entity.CareFacility; import com.carecode.domain.facility.entity.CareFacilityBooking; @@ -17,6 +18,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; import org.mockito.junit.jupiter.MockitoSettings; import org.mockito.quality.Strictness; @@ -24,6 +26,7 @@ import java.util.Optional; import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; @@ -84,8 +87,11 @@ void rejectsBookingWhenCapacityReached() { .thenReturn(2L); // 정원 2 모두 사용 assertThatThrownBy(() -> bookingService.createBooking(FACILITY_ID, futureRequest(), USER_ID)) - .isInstanceOf(CareServiceException.class) - .hasMessageContaining("예약 가능한 자리가 없습니다"); + .isInstanceOf(BusinessException.class) + .hasMessageContaining("예약 가능한 자리가 없습니다") + // 자리가 없는 것은 정상적인 거절이다. 500 이면 장애 지표가 오염된다. + .satisfies(e -> assertThat(((BusinessException) e).getErrorCode()) + .isEqualTo(ErrorCode.BOOKING_ALREADY_EXISTS)); verify(bookingRepository, never()).save(any(CareFacilityBooking.class)); } @@ -116,8 +122,10 @@ void rejectsInvertedInterval() { request.setEndTime(request.getStartTime().minusHours(1)); assertThatThrownBy(() -> bookingService.createBooking(FACILITY_ID, request, USER_ID)) - .isInstanceOf(CareServiceException.class) - .hasMessageContaining("종료 시간은 시작 시간보다"); + .isInstanceOf(BusinessException.class) + .hasMessageContaining("종료 시간은 시작 시간보다") + .satisfies(e -> assertThat(((BusinessException) e).getHttpStatus()) + .isEqualTo(HttpStatus.BAD_REQUEST)); } @Test @@ -128,8 +136,10 @@ void rejectsPastBooking() { request.setEndTime(LocalDateTime.now().minusDays(1).plusHours(2)); assertThatThrownBy(() -> bookingService.createBooking(FACILITY_ID, request, USER_ID)) - .isInstanceOf(CareServiceException.class) - .hasMessageContaining("과거 시간"); + .isInstanceOf(BusinessException.class) + .hasMessageContaining("과거 시간") + .satisfies(e -> assertThat(((BusinessException) e).getHttpStatus()) + .isEqualTo(HttpStatus.BAD_REQUEST)); } private CreateBookingRequest futureRequest() { diff --git a/src/test/java/com/carecode/domain/notification/service/NotificationErrorStatusTest.java b/src/test/java/com/carecode/domain/notification/service/NotificationErrorStatusTest.java new file mode 100644 index 0000000..47e1ade --- /dev/null +++ b/src/test/java/com/carecode/domain/notification/service/NotificationErrorStatusTest.java @@ -0,0 +1,95 @@ +package com.carecode.domain.notification.service; + +import com.carecode.core.exception.BusinessException; +import com.carecode.core.exception.ErrorCode; +import com.carecode.core.exception.ResourceNotFoundException; +import com.carecode.domain.notification.entity.Notification; +import com.carecode.domain.notification.factory.NotificationStrategyFactory; +import com.carecode.domain.notification.repository.NotificationRepository; +import com.carecode.domain.notification.sender.NotificationDispatcher; +import com.carecode.domain.user.entity.User; +import com.carecode.domain.user.repository.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +/** + * 알림 조회가 실패했을 때 누구의 잘못인지 응답이 말해야 한다. + * + *

{@code getNotificationById} 는 전체를 {@code try} 로 감싸고 {@code catch (Exception e)} 로 + * 받아 {@code CareServiceException} 으로 다시 던졌다. 그 예외는 전역 핸들러에서 코드 문자열로 + * 상태를 정하는데, 코드 없이 던지면 {@code CARE_SERVICE_ERROR} 가 되고 매핑에 없으므로 + * 500 이 나간다. + * + *

그래서 안쪽에서 던진 것이 무엇이든 전부 500 이 됐다. + * + *

    + *
  • 남의 알림 — 소유권 검사가 403 을 던지는데 500 으로 나간다. 클라이언트는 "권한이 + * 없다" 와 "서버가 고장났다" 를 구분할 수 없고, 로그에는 서버 오류로 쌓여 권한 탐색 + * 시도가 장애로 보인다.
  • + *
  • 없는 알림 — 404 여야 할 것이 500 이다. 클라이언트가 재시도해도 소용없는데 + * 500 은 "다시 해 보라" 는 뜻으로 읽힌다.
  • + *
+ * + *

이 테스트는 그 두 경우의 상태를 고정한다. + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("알림 조회 실패의 책임 소재") +class NotificationErrorStatusTest { + + private static final String OWNER = "u-owner"; + private static final String STRANGER = "u-stranger"; + + @Mock NotificationRepository notificationRepository; + @Mock UserRepository userRepository; + @Mock NotificationStrategyFactory strategyFactory; + @Mock NotificationDispatcher notificationDispatcher; + + @InjectMocks NotificationService notificationService; + + private Notification ownersNotification; + + @BeforeEach + void setUp() { + User owner = User.builder() + .id(1L).userId(OWNER).email("owner@example.com").name("보호자") + .build(); + + ownersNotification = Notification.builder() + .id(7L) + .user(owner) + .title("예방접종 안내") + .message("다음 접종일이 다가옵니다") + .build(); + } + + @Test + @DisplayName("남의 알림을 요청하면 403 이다 - 500 이면 권한 탐색이 장애로 보인다") + void readingSomeoneElsesNotificationIsForbidden() { + when(notificationRepository.findById(7L)).thenReturn(Optional.of(ownersNotification)); + + assertThatThrownBy(() -> notificationService.getNotificationById(7L, STRANGER)) + .isInstanceOf(BusinessException.class) + .satisfies(e -> assertThat(((BusinessException) e).getErrorCode()) + .isEqualTo(ErrorCode.FORBIDDEN)); + } + + @Test + @DisplayName("없는 알림을 요청하면 404 다 - 500 은 재시도하라는 뜻으로 읽힌다") + void readingMissingNotificationIsNotFound() { + when(notificationRepository.findById(404L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> notificationService.getNotificationById(404L, OWNER)) + .isInstanceOf(ResourceNotFoundException.class); + } +}