diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 28b48599..9d1886ff 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -83,6 +83,9 @@ jobs: sudo docker rm -f $(docker ps -qa) sudo docker pull ${{ secrets.DOCKER_REPO }}/eatssu-prod sudo docker run -d -p 9000:9000 \ + --log-driver=json-file \ + --log-opt max-size=20m \ + --log-opt max-file=5 \ -e EATSSU_DB_URL_PROD="${{ secrets.EATSSU_DB_URL_PROD }}" \ -e EATSSU_DB_USERNAME="${{ secrets.EATSSU_DB_USERNAME }}" \ -e EATSSU_DB_PASSWORD="${{ secrets.EATSSU_DB_PASSWORD }}" \ @@ -107,6 +110,9 @@ jobs: sudo docker rm -f $(docker ps -qa) sudo docker pull ${{ secrets.DOCKER_REPO }}/eatssu-dev sudo docker run -d -p 9000:9000 \ + --log-driver=json-file \ + --log-opt max-size=20m \ + --log-opt max-file=5 \ -e EATSSU_DB_URL_DEV="${{ secrets.EATSSU_DB_URL_DEV }}" \ -e EATSSU_DB_USERNAME="${{ secrets.EATSSU_DB_USERNAME }}" \ -e EATSSU_DB_PASSWORD="${{ secrets.EATSSU_DB_PASSWORD }}" \ diff --git a/Dockerfile b/Dockerfile index c68d1129..5bd8e119 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,4 +22,4 @@ COPY --from=builder /home/gradle/project/build/libs/*.jar app.jar EXPOSE 9000 # 애플리케이션 실행 -ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/build.gradle b/build.gradle index 6cad98bb..9799a10a 100644 --- a/build.gradle +++ b/build.gradle @@ -80,6 +80,7 @@ def generated = 'src/main/generated' tasks.withType(JavaCompile) { options.getGeneratedSourceOutputDirectory().set(file(generated)) + options.compilerArgs += ['-parameters'] } sourceSets { @@ -88,4 +89,4 @@ sourceSets { clean { delete file('src/main/generated') -} \ No newline at end of file +} diff --git a/src/main/java/ssu/eatssu/domain/auth/dto/AppleLoginRequest.java b/src/main/java/ssu/eatssu/domain/auth/dto/AppleLoginRequest.java index 2d5416ad..8752f2d8 100644 --- a/src/main/java/ssu/eatssu/domain/auth/dto/AppleLoginRequest.java +++ b/src/main/java/ssu/eatssu/domain/auth/dto/AppleLoginRequest.java @@ -1,9 +1,11 @@ package ssu.eatssu.domain.auth.dto; import io.swagger.v3.oas.annotations.media.Schema; +import ssu.eatssu.global.log.annotation.LogMask; @Schema(title = "애플 로그인 및 회원가입") public record AppleLoginRequest( + @LogMask @Schema(description = "identityToken", example = "eyJraWQiOiJXNldjT0tCIiwiYWxnIjoi...") String identityToken ) { diff --git a/src/main/java/ssu/eatssu/domain/auth/dto/KakaoLoginRequest.java b/src/main/java/ssu/eatssu/domain/auth/dto/KakaoLoginRequest.java index eacf8e65..140f0191 100644 --- a/src/main/java/ssu/eatssu/domain/auth/dto/KakaoLoginRequest.java +++ b/src/main/java/ssu/eatssu/domain/auth/dto/KakaoLoginRequest.java @@ -3,14 +3,17 @@ import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; +import ssu.eatssu.global.log.annotation.LogMask; @Schema(title = "카카오 로그인 및 회원가입") public record KakaoLoginRequest( + @LogMask @NotBlank(message = "이메일을 입력해주세요.") @Email(message = "올바른 이메일 주소를 입력해주세요.") @Schema(description = "이메일", example = "test@email.com") String email, + @LogMask @Schema(description = "providerId", example = "10378247832195") String providerId ) { diff --git a/src/main/java/ssu/eatssu/domain/auth/dto/ValidRequest.java b/src/main/java/ssu/eatssu/domain/auth/dto/ValidRequest.java index 748028e6..0a70ed4d 100644 --- a/src/main/java/ssu/eatssu/domain/auth/dto/ValidRequest.java +++ b/src/main/java/ssu/eatssu/domain/auth/dto/ValidRequest.java @@ -2,9 +2,11 @@ import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; +import ssu.eatssu.global.log.annotation.LogMask; @Schema(title = "유효한 토큰 확인") public record ValidRequest( + @LogMask @NotBlank(message = "토큰을 입력해주세요") @Schema(description = "토큰", example = "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ7XCJpZFwiOjcsXCJlbWFpbFwiOlwidGVzdEBlbWFpbC5jb21cIixcInJvbGVcIjpcIlJPTEVfVVNFUlwifSIsImF1dGgiOiJST0xFX1VTRVIiLCJleHAiOjE3NDQzNzQ0MjB9.mhIWYX_Vj3xW1eXuVflbzpH6vLTcC9b1twbIcqovVjDVnS7tjegu3nQHGXUsUa_WG2DIAtJMFZT_Q1XcVq1jPw") String token diff --git a/src/main/java/ssu/eatssu/domain/inquiry/dto/CreateInquiryRequest.java b/src/main/java/ssu/eatssu/domain/inquiry/dto/CreateInquiryRequest.java index 318664e3..703dcbd7 100644 --- a/src/main/java/ssu/eatssu/domain/inquiry/dto/CreateInquiryRequest.java +++ b/src/main/java/ssu/eatssu/domain/inquiry/dto/CreateInquiryRequest.java @@ -3,6 +3,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import lombok.Getter; import lombok.NoArgsConstructor; +import ssu.eatssu.global.log.annotation.LogMask; @Schema(title = "문의 남기기") @NoArgsConstructor @@ -10,8 +11,10 @@ public class CreateInquiryRequest { @Schema(description = "답장 받을 이메일", example = "sandy1017@gmail.com") + @LogMask private String email; @Schema(description = "문의 내용", example = "어쩌고 저쩌고 문의 남깁니다") + @LogMask private String content; } diff --git a/src/main/java/ssu/eatssu/domain/inquiry/service/InquiryService.java b/src/main/java/ssu/eatssu/domain/inquiry/service/InquiryService.java index 66c7c7c0..7a38ab08 100644 --- a/src/main/java/ssu/eatssu/domain/inquiry/service/InquiryService.java +++ b/src/main/java/ssu/eatssu/domain/inquiry/service/InquiryService.java @@ -1,6 +1,7 @@ package ssu.eatssu.domain.inquiry.service; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import ssu.eatssu.domain.auth.security.CustomUserDetails; @@ -10,6 +11,7 @@ import ssu.eatssu.domain.user.entity.User; import ssu.eatssu.domain.user.repository.UserRepository; import ssu.eatssu.global.handler.response.BaseException; +import ssu.eatssu.global.log.event.LogEvent; import static ssu.eatssu.global.handler.response.BaseResponseStatus.NOT_FOUND_USER; @@ -20,14 +22,23 @@ public class InquiryService { private final UserRepository userRepository; private final InquiryRepository inquiryRepository; + private final ApplicationEventPublisher eventPublisher; public Inquiry createUserInquiry(CustomUserDetails userDetails, CreateInquiryRequest request) { User user = userRepository.findById(userDetails.getId()) - .orElseThrow(() -> new BaseException(NOT_FOUND_USER)); + .orElseThrow(() -> new BaseException(NOT_FOUND_USER)); Inquiry inquiry = new Inquiry(request.getContent(), user, request.getEmail()); + Inquiry saved = inquiryRepository.save(inquiry); - return inquiryRepository.save(inquiry); + eventPublisher.publishEvent(LogEvent.of(String.format( + "Inquiry created: id=%d, userId=%d, status=%s", + saved.getId(), + user.getId(), + saved.getStatus() + ))); + + return saved; } } diff --git a/src/main/java/ssu/eatssu/domain/menu/presentation/dto/response/MenusInMealResponse.java b/src/main/java/ssu/eatssu/domain/menu/presentation/dto/response/MenusInMealResponse.java index cc58ea3d..a6222c75 100644 --- a/src/main/java/ssu/eatssu/domain/menu/presentation/dto/response/MenusInMealResponse.java +++ b/src/main/java/ssu/eatssu/domain/menu/presentation/dto/response/MenusInMealResponse.java @@ -6,6 +6,7 @@ import lombok.NoArgsConstructor; import ssu.eatssu.domain.menu.entity.Meal; import ssu.eatssu.domain.menu.entity.MealMenu; +import ssu.eatssu.domain.menu.entity.Menu; import java.util.List; @@ -18,9 +19,8 @@ public class MenusInMealResponse { @Schema(description = "식단 속 메뉴 목록", example = "[]") private List briefMenus; - public static MenusInMealResponse from(Meal meal) { - List menusInformation = meal.getMealMenus().stream() - .map(MealMenu::getMenu) + public static MenusInMealResponse from(List menus) { + List menusInformation = menus.stream() .map(BriefMenuResponse::new) .toList(); diff --git a/src/main/java/ssu/eatssu/domain/menu/service/MealService.java b/src/main/java/ssu/eatssu/domain/menu/service/MealService.java index 4fc0859d..75cd6445 100644 --- a/src/main/java/ssu/eatssu/domain/menu/service/MealService.java +++ b/src/main/java/ssu/eatssu/domain/menu/service/MealService.java @@ -39,8 +39,15 @@ public class MealService { public MenusInMealResponse getMenusInMealByMealId(Long mealId) { Meal meal = mealRepository.findById(mealId) .orElseThrow(() -> new BaseException(BaseResponseStatus.NOT_FOUND_MEAL)); + List menus = meal.getMealMenus().stream() + .map(MealMenu::getMenu) + .toList(); + + if (menus.isEmpty()) { + log.warn("Meal[{}] has no menus.", mealId); + } - return MenusInMealResponse.from(meal); + return MenusInMealResponse.from(menus); } public List getMealDetailsByDateAndRestaurantAndTimePart( diff --git a/src/main/java/ssu/eatssu/domain/partnership/service/PartnershipService.java b/src/main/java/ssu/eatssu/domain/partnership/service/PartnershipService.java index b82a94db..63e5dd98 100644 --- a/src/main/java/ssu/eatssu/domain/partnership/service/PartnershipService.java +++ b/src/main/java/ssu/eatssu/domain/partnership/service/PartnershipService.java @@ -1,6 +1,8 @@ package ssu.eatssu.domain.partnership.service; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import ssu.eatssu.domain.auth.security.CustomUserDetails; @@ -19,6 +21,7 @@ import ssu.eatssu.domain.user.entity.User; import ssu.eatssu.domain.user.repository.UserRepository; import ssu.eatssu.global.handler.response.BaseException; +import ssu.eatssu.global.log.event.LogEvent; import java.util.List; import java.util.Optional; @@ -40,6 +43,7 @@ public class PartnershipService { private final UserRepository userRepository; private final PartnershipLikeRepository partnershipLikeRepository; private final PartnershipRestaurantRepository partnerShipRestaurantRepository; + private final ApplicationEventPublisher eventPublisher; @Transactional public void createPartnership(CreatePartnershipRequest request) { @@ -68,22 +72,30 @@ public List getAllPartnerships(CustomUserDetails customUser @Transactional public void togglePartnershipLike(Long partnershipId, CustomUserDetails userDetails) { Partnership partnership = partnershipRepository.findById(partnershipId) - .orElseThrow(() -> new BaseException(NOT_FOUND_PARTNERSHIP)); + .orElseThrow(() -> new BaseException(NOT_FOUND_PARTNERSHIP)); User user = userRepository.findById(userDetails.getId()) - .orElseThrow(() -> new BaseException(NOT_FOUND_USER)); + .orElseThrow(() -> new BaseException(NOT_FOUND_USER)); PartnershipRestaurant partnershipRestaurant = partnership.getPartnershipRestaurant(); - Optional optionalPartnershipLike = partnershipLikeRepository.findByUserAndPartnershipRestaurant( - user, - partnershipRestaurant); + Optional optionalPartnershipLike = + partnershipLikeRepository.findByUserAndPartnershipRestaurant(user, partnershipRestaurant); + if (optionalPartnershipLike.isPresent()) { PartnershipLike partnershipLike = optionalPartnershipLike.get(); partnershipRestaurant.getLikes().remove(partnershipLike); partnershipLikeRepository.delete(partnershipLike); + + eventPublisher.publishEvent(LogEvent.of( + String.format("User[%d] canceled like on PartnershipRestaurant[%d]", + user.getId(), partnershipRestaurant.getId()))); } else { PartnershipLike partnershipLike = new PartnershipLike(user, partnershipRestaurant); partnershipRestaurant.getLikes().add(partnershipLike); partnershipLikeRepository.save(partnershipLike); + + eventPublisher.publishEvent(LogEvent.of( + String.format("User[%d] liked PartnershipRestaurant[%d]", + user.getId(), partnershipRestaurant.getId()))); } } diff --git a/src/main/java/ssu/eatssu/domain/report/service/ReportService.java b/src/main/java/ssu/eatssu/domain/report/service/ReportService.java index f0d7050b..4105629e 100644 --- a/src/main/java/ssu/eatssu/domain/report/service/ReportService.java +++ b/src/main/java/ssu/eatssu/domain/report/service/ReportService.java @@ -1,6 +1,7 @@ package ssu.eatssu.domain.report.service; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import ssu.eatssu.domain.auth.security.CustomUserDetails; @@ -14,6 +15,7 @@ import ssu.eatssu.domain.user.entity.User; import ssu.eatssu.domain.user.repository.UserRepository; import ssu.eatssu.global.handler.response.BaseException; +import ssu.eatssu.global.log.event.LogEvent; import static ssu.eatssu.global.handler.response.BaseResponseStatus.NOT_FOUND_REVIEW; import static ssu.eatssu.global.handler.response.BaseResponseStatus.NOT_FOUND_USER; @@ -26,18 +28,33 @@ public class ReportService { private final ReviewRepository reviewRepository; private final UserRepository userRepository; private final ReportRepository reportRepository; + private final ApplicationEventPublisher eventPublisher; public Report reportReview(CustomUserDetails userDetails, ReportCreateRequest request) { User user = userRepository.findById(userDetails.getId()) - .orElseThrow(() -> new BaseException(NOT_FOUND_USER)); + .orElseThrow(() -> new BaseException(NOT_FOUND_USER)); Review review = reviewRepository.findById(request.reviewId()) - .orElseThrow(() -> new BaseException(NOT_FOUND_REVIEW)); + .orElseThrow(() -> new BaseException(NOT_FOUND_REVIEW)); Report report = Report.create(user, review, request, ReportStatus.PENDING); - return reportRepository.save(report); + reportRepository.save(report); + + eventPublisher.publishEvent(LogEvent.of( + String.format( + "Report created: reportId=%d, reviewId=%d, userId=%d, reportType=%s, status=%s", + report.getId(), + report.getReview().getId(), + report.getUser().getId(), + report.getReportType(), + report.getStatus() + ) + )); + + return report; } + public ReportTypeList getReportType() { return ReportTypeList.get(); } diff --git a/src/main/java/ssu/eatssu/domain/review/service/ReviewServiceV2.java b/src/main/java/ssu/eatssu/domain/review/service/ReviewServiceV2.java index 81d69178..66a4bd39 100644 --- a/src/main/java/ssu/eatssu/domain/review/service/ReviewServiceV2.java +++ b/src/main/java/ssu/eatssu/domain/review/service/ReviewServiceV2.java @@ -1,6 +1,8 @@ package ssu.eatssu.domain.review.service; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; @@ -35,6 +37,7 @@ import ssu.eatssu.domain.user.entity.User; import ssu.eatssu.domain.user.repository.UserRepository; import ssu.eatssu.global.handler.response.BaseException; +import ssu.eatssu.global.log.event.LogEvent; import java.util.Collections; import java.util.List; @@ -49,8 +52,9 @@ import static ssu.eatssu.global.handler.response.BaseResponseStatus.NOT_FOUND_USER; import static ssu.eatssu.global.handler.response.BaseResponseStatus.REVIEW_PERMISSION_DENIED; -@RequiredArgsConstructor +@Slf4j @Service +@RequiredArgsConstructor public class ReviewServiceV2 { private final UserRepository userRepository; private final ReviewRepository reviewRepository; @@ -58,6 +62,7 @@ public class ReviewServiceV2 { private final MealRepository mealRepository; private final MealMenuRepository mealMenuRepository; private final ReviewImageRepository reviewImageRepository; + private final ApplicationEventPublisher eventPublisher; /** * meal에 대한 리뷰 생성 @@ -65,10 +70,10 @@ public class ReviewServiceV2 { @Transactional public void createMealReview(CustomUserDetails userDetails, CreateMealReviewRequest request) { User user = userRepository.findById(userDetails.getId()) - .orElseThrow(() -> new BaseException(NOT_FOUND_USER)); + .orElseThrow(() -> new BaseException(NOT_FOUND_USER)); Meal meal = mealRepository.findById(request.getMealId()) - .orElseThrow(() -> new BaseException(NOT_FOUND_MEAL)); + .orElseThrow(() -> new BaseException(NOT_FOUND_MEAL)); Review review = request.toReviewEntity(user, meal); @@ -76,11 +81,20 @@ public void createMealReview(CustomUserDetails userDetails, CreateMealReviewRequ for (MenuLikeRequest menuLike : request.getMenuLikes()) { Menu menu = menuRepository.findById(menuLike.getMenuId()) - .orElseThrow(() -> new BaseException(NOT_FOUND_MENU)); + .orElseThrow(() -> new BaseException(NOT_FOUND_MENU)); review.addReviewMenuLike(menu, menuLike.getIsLike()); } reviewRepository.save(review); + + eventPublisher.publishEvent(LogEvent.of( + String.format("MealReview created: reviewId=%d, mealId=%d, userId=%d, images=%d, menuLikes=%d", + review.getId(), + meal.getId(), + user.getId(), + request.getImageUrls().size(), + request.getMenuLikes().size()) + )); } /** @@ -89,10 +103,10 @@ public void createMealReview(CustomUserDetails userDetails, CreateMealReviewRequ @Transactional public void createMenuReview(CustomUserDetails userDetails, CreateMenuReviewRequest request) { User user = userRepository.findById(userDetails.getId()) - .orElseThrow(() -> new BaseException(NOT_FOUND_USER)); + .orElseThrow(() -> new BaseException(NOT_FOUND_USER)); Menu menu = menuRepository.findById(request.getMenuId()) - .orElseThrow(() -> new BaseException(NOT_FOUND_MENU)); + .orElseThrow(() -> new BaseException(NOT_FOUND_MENU)); Review review = request.toReviewEntity(user, menu); review.addReviewMenuLike(menu, request.getMenuLike().getIsLike()); @@ -102,6 +116,15 @@ public void createMenuReview(CustomUserDetails userDetails, CreateMenuReviewRequ reviewImageRepository.save(reviewImage); menu.addReview(review); + + eventPublisher.publishEvent(LogEvent.of( + String.format("MenuReview created: reviewId=%d, menuId=%d, userId=%d, isLike=%s, imageUrl=%s", + review.getId(), + menu.getId(), + user.getId(), + request.getMenuLike().getIsLike(), + request.getImageUrl()) + )); } /** @@ -164,6 +187,9 @@ public SliceResponse findMealReviewList(Long mealId, Long la Meal meal = mealRepository.findById(mealId).orElseThrow(() -> new BaseException(NOT_FOUND_MEAL)); List menus = mealMenuRepository.findMenusByMeal(meal); + if (menus.isEmpty()) { + log.warn("No menus found for mealId={}", mealId); + } List validMenus = menus.stream() .filter(menu -> !MenuFilterUtil.isExcludedFromReview( @@ -176,12 +202,14 @@ public SliceResponse findMealReviewList(Long mealId, Long la if (validMenus.isEmpty()) { + log.warn("No valid menus found for mealId={}", mealId); return SliceResponse.empty(); } List validMenuIds = validMenus.stream().map(ValidMenuForViewResponse.MenuDto::getMenuId).toList(); List mealIds = mealMenuRepository.findMealIdsByMenuIds(validMenuIds); if (mealIds.isEmpty()) { + log.warn("No related mealIds found for validMenuIds={} in mealId={}", validMenuIds, mealId); return SliceResponse.empty(); } @@ -256,6 +284,10 @@ public MenuReviewsV2Response findMenuReviews(Long menuId) { .average() .orElse(0.0); + if (!reviews.isEmpty() && averageRating == 0.0) { + log.warn("All reviews for menuId={} have null/invalid ratings", menuId); + } + Integer likeCount = menu.getLikeCount(); ReviewRatingCount reviewRatingCount = ReviewRatingCount.from(reviews); @@ -278,6 +310,9 @@ public MealReviewsV2Response findMealReviews(Long mealId) { Meal meal = mealRepository.findById(mealId).orElseThrow(() -> new BaseException(NOT_FOUND_MEAL)); List reviews = reviewRepository.findAllByMeal(meal); List menus = mealMenuRepository.findMenusByMeal(meal); + if (menus.isEmpty()) { + log.warn("No menus found for mealId={}", meal.getId()); + } List validMenus = menus.stream() .filter(menu -> !MenuFilterUtil.isExcludedFromReview( @@ -288,6 +323,10 @@ public MealReviewsV2Response findMealReviews(Long mealId) { .build()) .toList(); + if (validMenus.isEmpty()) { + log.warn("No valid menus for review found in mealId={}", mealId); + } + Double averageRating = Optional.ofNullable(reviews) .orElse(Collections.emptyList()) .stream() @@ -301,6 +340,10 @@ public MealReviewsV2Response findMealReviews(Long mealId) { .average() .orElse(0.0); + if (!reviews.isEmpty() && averageRating == 0.0) { + log.warn("All reviews have null/invalid ratings for mealId={}", mealId); + } + Integer likeCount = Optional.ofNullable(menus) .orElse(Collections.emptyList()) .stream() @@ -354,6 +397,11 @@ public void updateReview(CustomUserDetails userDetails, Long reviewId, UpdateMea review.update(request.getContent(), request.getRating(), menuLikes); reviewRepository.save(review); + + eventPublisher.publishEvent(LogEvent.of( + String.format("Review updated: reviewId=%d, userId=%d, newRating=%d", + review.getId(), user.getId(), request.getRating()) + )); } /** @@ -372,6 +420,11 @@ public void deleteReview(CustomUserDetails userDetails, Long reviewId) { } review.resetMenuLikes(); + + eventPublisher.publishEvent(LogEvent.of( + String.format("Review deleted: reviewId=%d, userId=%d", review.getId(), user.getId()) + )); + reviewRepository.delete(review); } diff --git a/src/main/java/ssu/eatssu/domain/user/service/UserService.java b/src/main/java/ssu/eatssu/domain/user/service/UserService.java index 9bd2f1e5..630b7832 100644 --- a/src/main/java/ssu/eatssu/domain/user/service/UserService.java +++ b/src/main/java/ssu/eatssu/domain/user/service/UserService.java @@ -3,6 +3,7 @@ import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Component; import org.springframework.stereotype.Service; @@ -23,6 +24,7 @@ import ssu.eatssu.domain.user.entity.User; import ssu.eatssu.domain.user.repository.UserRepository; import ssu.eatssu.global.handler.response.BaseException; +import ssu.eatssu.global.log.event.LogEvent; import java.util.List; import java.util.UUID; @@ -44,6 +46,7 @@ public class UserService { private final DepartmentRepository departmentRepository; private final UserProperties userProperties; private final CollegeRepository collegeRepository; + private final ApplicationEventPublisher eventPublisher; public User join(String email, OAuthProvider provider, String providerId) { String credentials = createCredentials(provider, providerId); @@ -61,6 +64,11 @@ public void updateNickname(CustomUserDetails userDetails, NicknameUpdateRequest } user.updateNickname(request.nickname()); + + eventPublisher.publishEvent(LogEvent.of( + String.format("User nickname updated: userId=%d, newNickname=%s", + user.getId(), request.nickname())) + ); } public MyPageResponse findMyPage(CustomUserDetails userDetails) { @@ -77,6 +85,11 @@ public boolean withdraw(CustomUserDetails userDetails) { user.getUserInquiries().forEach(inquiry -> inquiry.clearUser()); userRepository.delete(user); + eventPublisher.publishEvent(LogEvent.of( + String.format("User withdrawn: userId=%d", + user.getId()) + )); + return true; } @@ -141,4 +154,4 @@ private boolean isForbiddenNickname(String nickname) { return userProperties.getForbiddenNicknames().stream() .anyMatch(forbidden -> forbidden.equalsIgnoreCase(nickname)); } -} \ No newline at end of file +} diff --git a/src/main/java/ssu/eatssu/global/config/AsyncConfig.java b/src/main/java/ssu/eatssu/global/config/AsyncConfig.java new file mode 100644 index 00000000..ca9284c6 --- /dev/null +++ b/src/main/java/ssu/eatssu/global/config/AsyncConfig.java @@ -0,0 +1,9 @@ +package ssu.eatssu.global.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; + +@Configuration +@EnableAsync +public class AsyncConfig { +} diff --git a/src/main/java/ssu/eatssu/global/log/ControllerLogAspect.java b/src/main/java/ssu/eatssu/global/log/ControllerLogAspect.java new file mode 100644 index 00000000..2de93aad --- /dev/null +++ b/src/main/java/ssu/eatssu/global/log/ControllerLogAspect.java @@ -0,0 +1,127 @@ +package ssu.eatssu.global.log; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Pointcut; +import org.aspectj.lang.reflect.MethodSignature; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Component; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; +import ssu.eatssu.domain.auth.security.CustomUserDetails; +import ssu.eatssu.global.log.annotation.LogMask; + +import java.lang.reflect.Field; +import java.util.HashMap; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + + +@Aspect +@Component +@Slf4j +@RequiredArgsConstructor +public class ControllerLogAspect { + + private final ObjectMapper objectMapper; + + @Pointcut("within(@org.springframework.web.bind.annotation.RestController *)") + public void restController() {} + + @Around("restController()") + public Object logApi(ProceedingJoinPoint joinPoint) throws Throwable { + long start = System.currentTimeMillis(); + + HttpServletRequest request = + ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest(); + String uri = request.getRequestURI(); + String method = request.getMethod(); + + MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature(); + String[] paramNames = methodSignature.getParameterNames(); + Object[] args = joinPoint.getArgs(); + + // 요청자 + String userIdLog = IntStream.range(0, args.length) + .filter(i -> args[i] instanceof CustomUserDetails) + .mapToObj(i -> { + CustomUserDetails user = (CustomUserDetails) args[i]; + return "userId=" + user.getId(); + }) + .findFirst() + .orElse("userId=anonymous"); + + // 나머지 요청 인자 + String otherArgsJson = IntStream.range(0, args.length) + .filter(i -> !(args[i] instanceof HttpServletRequest)) + .filter(i -> !(args[i] instanceof CustomUserDetails)) + .filter(i -> !(args[i] instanceof org.springframework.validation.BindingResult)) + .mapToObj(i -> { + String name = (paramNames != null && i < paramNames.length) ? paramNames[i] : "arg" + i; + Object arg = args[i]; + try { + String value; + if (arg != null) { + Map safeMap = toSafeMap(arg); + value = objectMapper.writeValueAsString(safeMap); + } else { + value = "null"; + } + if (value.length() > 200) value = value.substring(0, 200) + "...(truncated)"; + return name + "=" + value; + } catch (Exception e) { + return name + "=" + String.valueOf(arg); + } + }) + .collect(Collectors.joining(", ")); + + String argsJson = userIdLog + (otherArgsJson.isEmpty() ? "" : ", " + otherArgsJson); + + log.info("REQUEST {} {} args={}", method, uri, argsJson); + + try { + Object result = joinPoint.proceed(); + long time = System.currentTimeMillis() - start; + + String resultJson; + try { + resultJson = objectMapper.writeValueAsString(result); + if (resultJson.length() > 600) { + resultJson = resultJson.substring(0, 600) + "...(truncated)"; + } + } catch (Exception e) { + resultJson = String.valueOf(result); + } + + log.info("RESPONSE {} {} ({} ms) result={}", method, uri, time, resultJson); + return result; + } catch (Throwable e) { + long time = System.currentTimeMillis() - start; + log.error("EXCEPTION {} {} ({} ms) cause={}", method, uri, time, e.getMessage(), e); + throw e; + } + } + + private Map toSafeMap(Object arg) { + Map result = new HashMap<>(); + for (Field field : arg.getClass().getDeclaredFields()) { + field.setAccessible(true); + try { + Object value = field.get(arg); + if (field.isAnnotationPresent(LogMask.class)) { + value = "***"; + } + result.put(field.getName(), value); + } catch (IllegalAccessException e) { + result.put(field.getName(), "ERROR"); + } + } + return result; + } +} diff --git a/src/main/java/ssu/eatssu/global/log/MDCLoggingFilter.java b/src/main/java/ssu/eatssu/global/log/MDCLoggingFilter.java new file mode 100644 index 00000000..e1d6ce9e --- /dev/null +++ b/src/main/java/ssu/eatssu/global/log/MDCLoggingFilter.java @@ -0,0 +1,30 @@ +package ssu.eatssu.global.log; + +import jakarta.servlet.*; +import jakarta.servlet.http.HttpServletRequest; +import org.slf4j.MDC; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.util.UUID; + +@Component +@Order(Ordered.HIGHEST_PRECEDENCE) +public class MDCLoggingFilter implements Filter { + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + String requestId = ((HttpServletRequest) request).getHeader("X-RequestID"); + if (requestId == null) { + requestId = UUID.randomUUID().toString().replace("-", ""); + } + MDC.put("requestId", requestId); + try { + chain.doFilter(request, response); + } finally { + MDC.clear(); + } + } +} diff --git a/src/main/java/ssu/eatssu/global/log/annotation/LogMask.java b/src/main/java/ssu/eatssu/global/log/annotation/LogMask.java new file mode 100644 index 00000000..bec4c56d --- /dev/null +++ b/src/main/java/ssu/eatssu/global/log/annotation/LogMask.java @@ -0,0 +1,11 @@ +package ssu.eatssu.global.log.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ElementType.FIELD, ElementType.RECORD_COMPONENT}) +@Retention(RetentionPolicy.RUNTIME) +public @interface LogMask { +} diff --git a/src/main/java/ssu/eatssu/global/log/event/LogEvent.java b/src/main/java/ssu/eatssu/global/log/event/LogEvent.java new file mode 100644 index 00000000..c817d949 --- /dev/null +++ b/src/main/java/ssu/eatssu/global/log/event/LogEvent.java @@ -0,0 +1,7 @@ +package ssu.eatssu.global.log.event; + +public record LogEvent(String message) { + public static LogEvent of(String message) { + return new LogEvent(message); + } +} diff --git a/src/main/java/ssu/eatssu/global/log/event/LogEventListener.java b/src/main/java/ssu/eatssu/global/log/event/LogEventListener.java new file mode 100644 index 00000000..bd7b5e08 --- /dev/null +++ b/src/main/java/ssu/eatssu/global/log/event/LogEventListener.java @@ -0,0 +1,18 @@ +package ssu.eatssu.global.log.event; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +@RequiredArgsConstructor +@Slf4j +public class LogEventListener { + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleLogEvent(LogEvent event) { + log.info(event.message()); + } +} diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 00000000..28f81a9d --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + ${LOG_PATTERN} + + + + + + true + + + + + + + +