From db11585bfdb596470d44d26dd0bfc5b049fedbb4 Mon Sep 17 00:00:00 2001 From: jhan0121 Date: Wed, 31 Dec 2025 19:43:00 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../recyclestudy/common/log/ApiLogFilter.java | 96 +++++++++++++++++++ .../common/log/ControllerLoggingAspect.java | 88 +++++++++++++++++ .../com/recyclestudy/common/log/MDCKey.java | 20 ++++ .../email/DeviceAuthEmailSender.java | 2 +- .../com/recyclestudy/email/EmailSender.java | 4 +- .../recyclestudy/email/ReviewEmailSender.java | 5 +- .../exception/GlobalControllerAdvice.java | 9 ++ .../member/service/MemberService.java | 10 +- .../service/NotificationHistoryService.java | 3 + .../review/service/ReviewService.java | 10 +- src/main/resources/logback-spring.xml | 44 +++++++++ 11 files changed, 284 insertions(+), 7 deletions(-) create mode 100644 src/main/java/com/recyclestudy/common/log/ApiLogFilter.java create mode 100644 src/main/java/com/recyclestudy/common/log/ControllerLoggingAspect.java create mode 100644 src/main/java/com/recyclestudy/common/log/MDCKey.java create mode 100644 src/main/resources/logback-spring.xml diff --git a/src/main/java/com/recyclestudy/common/log/ApiLogFilter.java b/src/main/java/com/recyclestudy/common/log/ApiLogFilter.java new file mode 100644 index 0000000..03eade5 --- /dev/null +++ b/src/main/java/com/recyclestudy/common/log/ApiLogFilter.java @@ -0,0 +1,96 @@ +package com.recyclestudy.common.log; + +import jakarta.servlet.Filter; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.Optional; +import java.util.UUID; +import lombok.extern.slf4j.Slf4j; +import org.slf4j.MDC; +import org.springframework.stereotype.Component; + +import static com.recyclestudy.common.log.MDCKey.CLIENT_IP; +import static com.recyclestudy.common.log.MDCKey.HOST; +import static com.recyclestudy.common.log.MDCKey.HTTP_METHOD; +import static com.recyclestudy.common.log.MDCKey.QUERY_STRING; +import static com.recyclestudy.common.log.MDCKey.REQUEST_URI; +import static com.recyclestudy.common.log.MDCKey.TRACE_ID; +import static com.recyclestudy.common.log.MDCKey.USER_AGENT; + +@Slf4j +@Component +public class ApiLogFilter implements Filter { + + private static final String REQUEST_ID_HEADER = "X-Request-Id"; + + @Override + public void doFilter( + final ServletRequest servletRequest, + final ServletResponse servletResponse, + final FilterChain filterChain + ) throws IOException, ServletException { + final HttpServletRequest request = (HttpServletRequest) servletRequest; + final HttpServletResponse response = (HttpServletResponse) servletResponse; + + final String traceId = Optional.ofNullable(request.getHeader(REQUEST_ID_HEADER)) + .filter(header -> !header.isBlank()) + .orElseGet(this::generateTraceId); + + populateMDC(traceId, request); + response.setHeader(REQUEST_ID_HEADER, traceId); + + final long startTime = System.currentTimeMillis(); + logRequest(request); + + int statusForLog = 200; + try { + filterChain.doFilter(servletRequest, servletResponse); + statusForLog = response.getStatus(); + } catch (final Exception ex) { + statusForLog = 500; + throw ex; + } finally { + logResponse(response, startTime, statusForLog); + MDC.clear(); + } + } + + private void logRequest(final HttpServletRequest request) { + final String uri = request.getRequestURI(); + final String method = request.getMethod(); + final String ip = request.getRemoteAddr(); + + final String queryString = request.getQueryString(); + final String userAgentHeader = request.getHeader("User-Agent"); + final String query = (queryString != null ? "?" + queryString : ""); + final String userAgent = (userAgentHeader != null ? userAgentHeader : "-"); + + log.info("[REQ] layer=filter | ip={} | method={} | uri={}{} | userAgent={}", ip, method, uri, query, userAgent); + } + + private void logResponse(final HttpServletResponse response, final long startTime, final int status) { + final long duration = System.currentTimeMillis() - startTime; + final String contentType = Optional.ofNullable(response.getContentType()).orElse("-"); + + log.info("[RES] layer=filter | status={} | duration={}ms | contentType={}", status, duration, contentType); + } + + private String generateTraceId() { + return UUID.randomUUID().toString().substring(0, 8); + } + + private void populateMDC(final String traceId, final HttpServletRequest request) { + MDC.put(TRACE_ID.getKey(), traceId); + MDC.put(HOST.getKey(), request.getHeader("host")); + MDC.put(HTTP_METHOD.getKey(), request.getMethod()); + MDC.put(REQUEST_URI.getKey(), request.getRequestURI()); + MDC.put(QUERY_STRING.getKey(), request.getQueryString()); + MDC.put(CLIENT_IP.getKey(), request.getRemoteAddr()); + MDC.put(USER_AGENT.getKey(), request.getHeader("User-Agent")); + } +} diff --git a/src/main/java/com/recyclestudy/common/log/ControllerLoggingAspect.java b/src/main/java/com/recyclestudy/common/log/ControllerLoggingAspect.java new file mode 100644 index 0000000..62d7e91 --- /dev/null +++ b/src/main/java/com/recyclestudy/common/log/ControllerLoggingAspect.java @@ -0,0 +1,88 @@ +package com.recyclestudy.common.log; + +import jakarta.servlet.http.HttpServletRequest; +import java.util.Arrays; +import java.util.Optional; +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.stereotype.Component; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +@Aspect +@Component +@Slf4j +public class ControllerLoggingAspect { + + private static final int MAX_LOG_LENGTH = 500; + + @Pointcut("execution(* com.recyclestudy..controller..*(..))") + public void controllerMethods() { + } + + @Around("controllerMethods()") + public Object logController(final ProceedingJoinPoint joinPoint) throws Throwable { + final MethodSignature signature = (MethodSignature) joinPoint.getSignature(); + final String className = signature.getDeclaringType().getSimpleName(); + final String methodName = signature.getName(); + final Object[] args = joinPoint.getArgs(); + + final HttpServletRequest request = getCurrentHttpRequest(); + final String httpMethod = request != null ? request.getMethod() : "N/A"; + final String uri = request != null ? request.getRequestURI() : "N/A"; + + final long startTime = System.currentTimeMillis(); + logRequest(className, methodName, httpMethod, uri, args); + + final Object result = joinPoint.proceed(); + logResponse(className, methodName, httpMethod, uri, result, startTime); + + return result; + } + + private void logRequest( + final String className, + final String methodName, + final String httpMethod, + final String uri, + final Object[] args + ) { + log.info("[REQ] layer=controller | method={}.{} | httpMethod={} | uri={} | args={}", + className, methodName, httpMethod, uri, Arrays.toString(args)); + } + + private void logResponse( + final String className, + final String methodName, + final String httpMethod, + final String uri, + final Object result, + final long startTime + ) { + final long duration = System.currentTimeMillis() - startTime; + final String resultStr = formatResult(result); + + log.info("[RES] layer=controller | method={}.{} | httpMethod={} | uri={} | duration={}ms | result={}", + className, methodName, httpMethod, uri, duration, resultStr); + } + + private HttpServletRequest getCurrentHttpRequest() { + return Optional.ofNullable(RequestContextHolder.getRequestAttributes()) + .filter(ServletRequestAttributes.class::isInstance) + .map(ServletRequestAttributes.class::cast) + .map(ServletRequestAttributes::getRequest) + .orElse(null); + } + + private String formatResult(final Object result) { + if (result == null) { + return "null"; + } + final String resultStr = result.toString(); + return resultStr.length() <= MAX_LOG_LENGTH ? resultStr : resultStr.substring(0, MAX_LOG_LENGTH) + "..."; + } +} diff --git a/src/main/java/com/recyclestudy/common/log/MDCKey.java b/src/main/java/com/recyclestudy/common/log/MDCKey.java new file mode 100644 index 0000000..1410c9a --- /dev/null +++ b/src/main/java/com/recyclestudy/common/log/MDCKey.java @@ -0,0 +1,20 @@ +package com.recyclestudy.common.log; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum MDCKey { + + TRACE_ID("traceId"), + HOST("host"), + HTTP_METHOD("httpMethod"), + REQUEST_URI("requestUri"), + QUERY_STRING("queryString"), + CLIENT_IP("clientIp"), + USER_AGENT("userAgent"); + + private final String key; +} + diff --git a/src/main/java/com/recyclestudy/email/DeviceAuthEmailSender.java b/src/main/java/com/recyclestudy/email/DeviceAuthEmailSender.java index 63d8507..e55a819 100644 --- a/src/main/java/com/recyclestudy/email/DeviceAuthEmailSender.java +++ b/src/main/java/com/recyclestudy/email/DeviceAuthEmailSender.java @@ -26,7 +26,7 @@ public void sendDeviceAuthMail(final String email, final String deviceId) { emailSender.send(email, "[Recycle Study] 디바이스 인증을 완료해주세요.", message); - log.info("인증 메일 발송 성공: {}", email); + log.info("[AUTH_MAIL_SENT] 인증 메일 발송 성공: {}", email); } private String createAuthUrl(final String email, final String deviceId) { diff --git a/src/main/java/com/recyclestudy/email/EmailSender.java b/src/main/java/com/recyclestudy/email/EmailSender.java index c6e500a..6b85157 100644 --- a/src/main/java/com/recyclestudy/email/EmailSender.java +++ b/src/main/java/com/recyclestudy/email/EmailSender.java @@ -27,10 +27,10 @@ public void send(final String targetEmail, final String subject, final String co javaMailSender.send(mimeMessage); - log.info("메일 발송 성공: email={}", targetEmail); + log.info("[MAIL_SENT] 메일 발송 성공: email={}", targetEmail); } catch (MessagingException e) { - log.error("메일 발송 실패: email={}", targetEmail, e); + log.error("[MAIL_SEND_FAILED] 메일 발송 실패: email={}", targetEmail, e); throw new EmailSendException("메일 전송 중 오류가 발생했습니다.", e); } } diff --git a/src/main/java/com/recyclestudy/email/ReviewEmailSender.java b/src/main/java/com/recyclestudy/email/ReviewEmailSender.java index 3844cec..e8c96c1 100644 --- a/src/main/java/com/recyclestudy/email/ReviewEmailSender.java +++ b/src/main/java/com/recyclestudy/email/ReviewEmailSender.java @@ -40,7 +40,7 @@ public void sendReviewMail() { ReviewSendInput.from(targetDate, targetTime)); final List elements = targetReviewCycle.elements(); - log.info("복습 메일 발송 시작: 대상 {}명", elements.size()); + log.info("[REVIEW_MAIL_SENT] 복습 메일 발송 시작: date={}, time={}, size={}", targetDate, targetTime, elements.size()); int successCount = 0; int failCount = 0; @@ -60,7 +60,7 @@ public void sendReviewMail() { } } - log.info("복습 메일 발송 처리 완료: 성공 {}명, 실패 {}명", successCount, failCount); + log.info("[REVIEW_MAIL_SENT] 복습 메일 발송 처리 완료: success={}, fail={}", successCount, failCount); } private boolean sendToTargetEmail(final Email targetEmail, final String message) { @@ -68,6 +68,7 @@ private boolean sendToTargetEmail(final Email targetEmail, final String message) emailSender.send(targetEmail.getValue(), "[Recycle Study] 오늘의 복습 목록이 도착했습니다", message); return true; } catch (final Exception e) { + log.error("[REVIEW_MAIL_SEND_FAILED] 복습 메일 발송 실패: email={}", targetEmail.getValue(), e); return false; } } diff --git a/src/main/java/com/recyclestudy/exception/GlobalControllerAdvice.java b/src/main/java/com/recyclestudy/exception/GlobalControllerAdvice.java index 967b60b..fef6437 100644 --- a/src/main/java/com/recyclestudy/exception/GlobalControllerAdvice.java +++ b/src/main/java/com/recyclestudy/exception/GlobalControllerAdvice.java @@ -1,41 +1,48 @@ package com.recyclestudy.exception; import com.recyclestudy.exception.dto.ErrorResponse; +import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +@Slf4j @RestControllerAdvice public class GlobalControllerAdvice { @ExceptionHandler(NotFoundException.class) public ResponseEntity handleNotFound(final NotFoundException e) { + log.warn("[NOT_FOUND] {}", e.getMessage()); final ErrorResponse response = ErrorResponse.from(e.getMessage()); return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response); } @ExceptionHandler(BadRequestException.class) public ResponseEntity handleBadRequest(final BadRequestException e) { + log.warn("[BAD_REQUEST] {}", e.getMessage()); final ErrorResponse response = ErrorResponse.from(e.getMessage()); return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); } @ExceptionHandler(DeviceActivationExpiredException.class) public ResponseEntity handleDeviceActivationExpired(final DeviceActivationExpiredException e) { + log.warn("[DEVICE_ACTIVATION_EXPIRED] {}", e.getMessage()); final ErrorResponse response = ErrorResponse.from(e.getMessage()); return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); } @ExceptionHandler(UnauthorizedException.class) public ResponseEntity handleUnauthorized(final UnauthorizedException e) { + log.warn("[UNAUTHORIZED] {}", e.getMessage()); final ErrorResponse response = ErrorResponse.from(e.getMessage()); return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(response); } @ExceptionHandler(IllegalArgumentException.class) public ResponseEntity handleIllegalArgument(final IllegalArgumentException e) { + log.warn("[ILLEGAL_ARGUMENT] {}", e.getMessage()); final ErrorResponse response = ErrorResponse.from(e.getMessage()); return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); } @@ -43,12 +50,14 @@ public ResponseEntity handleIllegalArgument(final IllegalArgument @ExceptionHandler(MissingServletRequestParameterException.class) public ResponseEntity handleMissingServletRequestParameter( final MissingServletRequestParameterException e) { + log.warn("[MISSING_PARAMETER] {}", e.getMessage()); final ErrorResponse response = ErrorResponse.from(e.getMessage()); return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); } @ExceptionHandler(Exception.class) public ResponseEntity handleException(final Exception e) { + log.error("[INTERNAL_ERROR] 예기치 못한 에러 발생", e); final ErrorResponse response = ErrorResponse.from("예기치 못한 에러가 발생했습니다"); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response); } diff --git a/src/main/java/com/recyclestudy/member/service/MemberService.java b/src/main/java/com/recyclestudy/member/service/MemberService.java index 1c1efc4..f6d120d 100644 --- a/src/main/java/com/recyclestudy/member/service/MemberService.java +++ b/src/main/java/com/recyclestudy/member/service/MemberService.java @@ -20,11 +20,13 @@ import java.util.List; import java.util.Optional; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor +@Slf4j public class MemberService { private final MemberRepository memberRepository; @@ -41,6 +43,7 @@ public MemberSaveOutput saveDevice(final MemberSaveInput input) { final Device notSavedDevice = Device.withoutId(member, deviceIdentifier, false, activationExpiredDateTime); final Device device = deviceRepository.save(notSavedDevice); + log.info("[DEVICE_SAVED] 디바이스 저장 성공: {}", device.getIdentifier()); return MemberSaveOutput.from(device); } @@ -68,6 +71,7 @@ public void authenticateDevice(final Email email, final DeviceIdentifier deviceI device.verifyOwner(email); device.activate(LocalDateTime.now(clock)); + log.info("[DEVICE_AUTH] 디바이스 인증 성공: email={}, device={}", email, deviceIdentifier); } @Transactional @@ -77,6 +81,7 @@ public void deleteDevice(final DeviceDeleteInput input) { .formatted(input.deviceIdentifier().getValue()))); device.verifyOwner(input.email()); deviceRepository.deleteByIdentifier(input.targetDeviceIdentifier()); + log.info("[DEVICE_DELETED] 디바이스 삭제 성공: {}", input.targetDeviceIdentifier()); } private Member saveNewMember(final Email email) { @@ -86,8 +91,11 @@ private Member saveNewMember(final Email email) { return memberOptional.get(); } + log.info("[MEMBER_SAVED] 신규 유저 이메일 등록 시작: {}", email); final Member notSavedMember = Member.withoutId(email); - return memberRepository.save(notSavedMember); + final Member savedMember = memberRepository.save(notSavedMember); + log.info("[MEMBER_SAVED] 신규 유저 이메일 등록 성공: {}", email); + return savedMember; } private void checkExistedMember(final Email email) { diff --git a/src/main/java/com/recyclestudy/review/service/NotificationHistoryService.java b/src/main/java/com/recyclestudy/review/service/NotificationHistoryService.java index 1e9e61a..f7b458c 100644 --- a/src/main/java/com/recyclestudy/review/service/NotificationHistoryService.java +++ b/src/main/java/com/recyclestudy/review/service/NotificationHistoryService.java @@ -7,11 +7,13 @@ import com.recyclestudy.review.repository.ReviewCycleRepository; import java.util.List; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor +@Slf4j public class NotificationHistoryService { private final NotificationHistoryRepository notificationHistoryRepository; @@ -26,5 +28,6 @@ public void saveAll(final List reviewCycleIds, final NotificationStatus st .toList(); notificationHistoryRepository.saveAll(histories); + log.info("[NOTIFY_HIST_UPDATED] 알림 이력 상태 변경: status={}, count={}", status, histories.size()); } } diff --git a/src/main/java/com/recyclestudy/review/service/ReviewService.java b/src/main/java/com/recyclestudy/review/service/ReviewService.java index 19b174c..7f5b74c 100644 --- a/src/main/java/com/recyclestudy/review/service/ReviewService.java +++ b/src/main/java/com/recyclestudy/review/service/ReviewService.java @@ -1,5 +1,6 @@ package com.recyclestudy.review.service; +import com.recyclestudy.common.BaseEntity; import com.recyclestudy.exception.UnauthorizedException; import com.recyclestudy.member.domain.Device; import com.recyclestudy.member.repository.DeviceRepository; @@ -18,11 +19,13 @@ import java.time.LocalDateTime; import java.util.List; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor +@Slf4j public class ReviewService { private final ReviewRepository reviewRepository; @@ -39,6 +42,7 @@ public ReviewSaveOutput saveReview(final ReviewSaveInput input) { final Review review = Review.withoutId(device.getMember(), input.url()); final Review savedReview = reviewRepository.save(review); + log.info("[REVIEW_SAVED] 복습 주제 저장 성공: reviewId={}", savedReview.getId()); final LocalDate current = LocalDate.now(clock); final List scheduledAts = ReviewCycleDuration.calculate(current); @@ -51,6 +55,7 @@ public ReviewSaveOutput saveReview(final ReviewSaveInput input) { final List savedScheduledAts = savedReviewCycles.stream() .map(ReviewCycle::getScheduledAt) .toList(); + log.info("[REVIEW_CYCLE_SAVED] 복습 주기 저장 성공: reviewCycle={}", savedReviewCycles); savePendingNotificationHistory(savedReviewCycles); @@ -67,6 +72,9 @@ private void savePendingNotificationHistory(final List savedReviewC final List notificationHistories = savedReviewCycles.stream() .map(reviewCycle -> NotificationHistory.withoutId(reviewCycle, NotificationStatus.PENDING)) .toList(); - notificationHistoryRepository.saveAll(notificationHistories); + final List savedNotificationHistories + = notificationHistoryRepository.saveAll(notificationHistories); + log.info("[NOTIFY_HIST_SAVED] 전송 현황 등록 성공: status={}, notificationHistoryId={}", + NotificationStatus.PENDING, savedNotificationHistories.stream().map(BaseEntity::getId).toList()); } } diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..b3e8a28 --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + ${CONSOLE_LOG_PATTERN} + utf8 + + + + + ${LOG_PATH}/app.log + + ${FILE_LOG_PATTERN} + utf8 + + + ${LOG_PATH}/app-%d{yyyy-MM-dd}.%i.log.gz + 10MB + 180 + 3GB + + + + + + + + + + + + + + + + From 601aed6b4520a1b23691980e51d5bd64da96a029 Mon Sep 17 00:00:00 2001 From: jhan0121 Date: Wed, 31 Dec 2025 20:12:55 +0900 Subject: [PATCH 2/5] =?UTF-8?q?chore:=20=EC=8B=A0=EA=B7=9C=20=EC=9C=A0?= =?UTF-8?q?=EC=A0=80=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20=EB=93=B1=EB=A1=9D=20?= =?UTF-8?q?=EC=8B=9C=EC=9E=91=20=EB=A1=9C=EA=B7=B8=20=ED=83=9C=EA=B7=B8?= =?UTF-8?q?=EB=AA=85=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/recyclestudy/member/service/MemberService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/recyclestudy/member/service/MemberService.java b/src/main/java/com/recyclestudy/member/service/MemberService.java index f6d120d..08ce659 100644 --- a/src/main/java/com/recyclestudy/member/service/MemberService.java +++ b/src/main/java/com/recyclestudy/member/service/MemberService.java @@ -91,7 +91,7 @@ private Member saveNewMember(final Email email) { return memberOptional.get(); } - log.info("[MEMBER_SAVED] 신규 유저 이메일 등록 시작: {}", email); + log.info("[MEMBER_REGISTER_START] 신규 유저 이메일 등록 시작: {}", email); final Member notSavedMember = Member.withoutId(email); final Member savedMember = memberRepository.save(notSavedMember); log.info("[MEMBER_SAVED] 신규 유저 이메일 등록 성공: {}", email); From d332e8a80b7790a88d81ca9b8a1e6597bde4af20 Mon Sep 17 00:00:00 2001 From: jhan0121 Date: Wed, 31 Dec 2025 20:29:32 +0900 Subject: [PATCH 3/5] =?UTF-8?q?feat:=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20?= =?UTF-8?q?=EB=A7=88=EC=8A=A4=ED=82=B9=20=EA=B8=B0=EB=8A=A5=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/recyclestudy/member/domain/Email.java | 20 ++++++++++++++++ .../member/service/MemberService.java | 6 ++--- .../recyclestudy/member/domain/EmailTest.java | 23 +++++++++++++++++++ 3 files changed, 46 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/recyclestudy/member/domain/Email.java b/src/main/java/com/recyclestudy/member/domain/Email.java index 6c9dddb..3333414 100644 --- a/src/main/java/com/recyclestudy/member/domain/Email.java +++ b/src/main/java/com/recyclestudy/member/domain/Email.java @@ -42,4 +42,24 @@ private static void validateEmailFormat(final String emailValue) { throw new IllegalArgumentException("유효하지 않은 이메일 형식입니다."); } } + + public String toMaskedValue() { + final String[] split = value.split("@"); + final String maskedLocalPart = maskLocalPart(split[0]); + return maskedLocalPart + "@" + split[1]; + } + + private String maskLocalPart(final String localPart) { + int length = localPart.length(); + + if (length <= 2) { + return localPart.charAt(0) + "*"; + } + + int visibleLength = Math.min(3, length / 2); + String visiblePart = localPart.substring(0, visibleLength); + String maskedPart = "*".repeat(length - visibleLength); + + return visiblePart + maskedPart; + } } diff --git a/src/main/java/com/recyclestudy/member/service/MemberService.java b/src/main/java/com/recyclestudy/member/service/MemberService.java index 08ce659..2d202d7 100644 --- a/src/main/java/com/recyclestudy/member/service/MemberService.java +++ b/src/main/java/com/recyclestudy/member/service/MemberService.java @@ -71,7 +71,7 @@ public void authenticateDevice(final Email email, final DeviceIdentifier deviceI device.verifyOwner(email); device.activate(LocalDateTime.now(clock)); - log.info("[DEVICE_AUTH] 디바이스 인증 성공: email={}, device={}", email, deviceIdentifier); + log.info("[DEVICE_AUTH] 디바이스 인증 성공: email={}, device={}", email.toMaskedValue(), deviceIdentifier); } @Transactional @@ -91,10 +91,10 @@ private Member saveNewMember(final Email email) { return memberOptional.get(); } - log.info("[MEMBER_REGISTER_START] 신규 유저 이메일 등록 시작: {}", email); + log.info("[MEMBER_REGISTER_START] 신규 유저 이메일 등록 시작: {}", email.toMaskedValue()); final Member notSavedMember = Member.withoutId(email); final Member savedMember = memberRepository.save(notSavedMember); - log.info("[MEMBER_SAVED] 신규 유저 이메일 등록 성공: {}", email); + log.info("[MEMBER_SAVED] 신규 유저 이메일 등록 성공: {}", email.toMaskedValue()); return savedMember; } diff --git a/src/test/java/com/recyclestudy/member/domain/EmailTest.java b/src/test/java/com/recyclestudy/member/domain/EmailTest.java index 0e6edf3..d3d454a 100644 --- a/src/test/java/com/recyclestudy/member/domain/EmailTest.java +++ b/src/test/java/com/recyclestudy/member/domain/EmailTest.java @@ -3,6 +3,7 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import org.junit.jupiter.params.provider.NullAndEmptySource; import org.junit.jupiter.params.provider.ValueSource; @@ -72,4 +73,26 @@ void throwExceptionWhenInvalidFormat(final String invalidValue) { .isInstanceOf(IllegalArgumentException.class) .hasMessage("유효하지 않은 이메일 형식입니다."); } + + @ParameterizedTest + @CsvSource({ + "a@test.com, a*@test.com", + "ab@test.com, a*@test.com", + "abc@test.com, a**@test.com", + "john@test.com, jo**@test.com", + "hello@test.com, he***@test.com", + "longname@test.com, lon*****@test.com", + "test1234@test.com, tes*****@test.com" + }) + @DisplayName("toMaskedValue 메서드를 활용하여 이메일을 마스킹할 수 있다") + void toMaskedValue(final String originValue, final String expectedValue) { + // given + final Email email = Email.from(originValue); + + // when + final String actual = email.toMaskedValue(); + + // then + assertThat(actual).isEqualTo(expectedValue); + } } From 4be1dd21ab784a05a5c4e5bc935c544c21dfe6fd Mon Sep 17 00:00:00 2001 From: jhan0121 Date: Wed, 31 Dec 2025 20:33:31 +0900 Subject: [PATCH 4/5] =?UTF-8?q?refactor:=20=EB=B3=B5=EC=8A=B5=20=EC=A3=BC?= =?UTF-8?q?=EA=B8=B0=20=EC=A0=80=EC=9E=A5=20=EB=A1=9C=EA=B7=B8=20=ED=8F=AC?= =?UTF-8?q?=EB=A7=B7=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/recyclestudy/review/service/ReviewService.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/recyclestudy/review/service/ReviewService.java b/src/main/java/com/recyclestudy/review/service/ReviewService.java index 7f5b74c..2b36188 100644 --- a/src/main/java/com/recyclestudy/review/service/ReviewService.java +++ b/src/main/java/com/recyclestudy/review/service/ReviewService.java @@ -55,7 +55,8 @@ public ReviewSaveOutput saveReview(final ReviewSaveInput input) { final List savedScheduledAts = savedReviewCycles.stream() .map(ReviewCycle::getScheduledAt) .toList(); - log.info("[REVIEW_CYCLE_SAVED] 복습 주기 저장 성공: reviewCycle={}", savedReviewCycles); + log.info("[REVIEW_CYCLE_SAVED] 복습 주기 저장 성공: reviewCycleId={}", + savedReviewCycles.stream().map(BaseEntity::getId).toList()); savePendingNotificationHistory(savedReviewCycles); From 44f934c0a2775542ebb24693d786f92f53a8e64f Mon Sep 17 00:00:00 2001 From: jhan0121 Date: Wed, 31 Dec 2025 20:55:08 +0900 Subject: [PATCH 5/5] =?UTF-8?q?refactor:=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20?= =?UTF-8?q?=EC=A0=84=EC=86=A1=20=EA=B8=B0=EB=8A=A5=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EA=B0=9D=EC=B2=B4=20=ED=8C=8C=EB=9D=BC=EB=AF=B8?= =?UTF-8?q?=ED=84=B0=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../recyclestudy/email/DeviceAuthEmailSender.java | 11 +++++++---- .../java/com/recyclestudy/email/EmailSender.java | 7 ++++--- .../com/recyclestudy/email/ReviewEmailSender.java | 2 +- .../member/controller/MemberController.java | 2 +- .../email/DeviceAuthEmailSenderTest.java | 14 ++++++++------ .../com/recyclestudy/email/EmailSenderTest.java | 9 +++++---- .../recyclestudy/email/ReviewEmailSenderTest.java | 10 +++++----- .../member/controller/MemberControllerTest.java | 2 +- 8 files changed, 32 insertions(+), 25 deletions(-) diff --git a/src/main/java/com/recyclestudy/email/DeviceAuthEmailSender.java b/src/main/java/com/recyclestudy/email/DeviceAuthEmailSender.java index e55a819..531de08 100644 --- a/src/main/java/com/recyclestudy/email/DeviceAuthEmailSender.java +++ b/src/main/java/com/recyclestudy/email/DeviceAuthEmailSender.java @@ -1,5 +1,7 @@ package com.recyclestudy.email; +import com.recyclestudy.member.domain.DeviceIdentifier; +import com.recyclestudy.member.domain.Email; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; @@ -20,8 +22,8 @@ public class DeviceAuthEmailSender { private String baseUrl; @Async - public void sendDeviceAuthMail(final String email, final String deviceId) { - final String authUrl = createAuthUrl(email, deviceId); + public void sendDeviceAuthMail(final Email email, final DeviceIdentifier deviceIdentifier) { + final String authUrl = createAuthUrl(email, deviceIdentifier); final String message = createMessage(authUrl); emailSender.send(email, "[Recycle Study] 디바이스 인증을 완료해주세요.", message); @@ -29,8 +31,9 @@ public void sendDeviceAuthMail(final String email, final String deviceId) { log.info("[AUTH_MAIL_SENT] 인증 메일 발송 성공: {}", email); } - private String createAuthUrl(final String email, final String deviceId) { - return String.format("%s/api/v1/device/auth?email=%s&identifier=%s", baseUrl, email, deviceId); + private String createAuthUrl(final Email email, final DeviceIdentifier deviceIdentifier) { + return String.format("%s/api/v1/device/auth?email=%s&identifier=%s", + baseUrl, email.getValue(), deviceIdentifier.getValue()); } private String createMessage(final String authUrl) { diff --git a/src/main/java/com/recyclestudy/email/EmailSender.java b/src/main/java/com/recyclestudy/email/EmailSender.java index 6b85157..0b141a3 100644 --- a/src/main/java/com/recyclestudy/email/EmailSender.java +++ b/src/main/java/com/recyclestudy/email/EmailSender.java @@ -1,6 +1,7 @@ package com.recyclestudy.email; import com.recyclestudy.exception.EmailSendException; +import com.recyclestudy.member.domain.Email; import jakarta.mail.MessagingException; import jakarta.mail.internet.MimeMessage; import lombok.RequiredArgsConstructor; @@ -16,18 +17,18 @@ public class EmailSender { private final JavaMailSender javaMailSender; - public void send(final String targetEmail, final String subject, final String content) { + public void send(final Email targetEmail, final String subject, final String content) { try { final MimeMessage mimeMessage = javaMailSender.createMimeMessage(); final MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, false, "UTF-8"); - helper.setTo(targetEmail); + helper.setTo(targetEmail.getValue()); helper.setSubject(subject); helper.setText(content, true); javaMailSender.send(mimeMessage); - log.info("[MAIL_SENT] 메일 발송 성공: email={}", targetEmail); + log.info("[MAIL_SENT] 메일 발송 성공: email={}", targetEmail.toMaskedValue()); } catch (MessagingException e) { log.error("[MAIL_SEND_FAILED] 메일 발송 실패: email={}", targetEmail, e); diff --git a/src/main/java/com/recyclestudy/email/ReviewEmailSender.java b/src/main/java/com/recyclestudy/email/ReviewEmailSender.java index e8c96c1..048c663 100644 --- a/src/main/java/com/recyclestudy/email/ReviewEmailSender.java +++ b/src/main/java/com/recyclestudy/email/ReviewEmailSender.java @@ -65,7 +65,7 @@ public void sendReviewMail() { private boolean sendToTargetEmail(final Email targetEmail, final String message) { try { - emailSender.send(targetEmail.getValue(), "[Recycle Study] 오늘의 복습 목록이 도착했습니다", message); + emailSender.send(targetEmail, "[Recycle Study] 오늘의 복습 목록이 도착했습니다", message); return true; } catch (final Exception e) { log.error("[REVIEW_MAIL_SEND_FAILED] 복습 메일 발송 실패: email={}", targetEmail.getValue(), e); diff --git a/src/main/java/com/recyclestudy/member/controller/MemberController.java b/src/main/java/com/recyclestudy/member/controller/MemberController.java index 0ab392c..a805f51 100644 --- a/src/main/java/com/recyclestudy/member/controller/MemberController.java +++ b/src/main/java/com/recyclestudy/member/controller/MemberController.java @@ -32,7 +32,7 @@ public ResponseEntity saveMember(@RequestBody final MemberSa final MemberSaveInput input = request.toInput(); final MemberSaveOutput output = memberService.saveDevice(input); - deviceAuthEmailSender.sendDeviceAuthMail(output.email().getValue(), output.identifier().getValue()); + deviceAuthEmailSender.sendDeviceAuthMail(output.email(), output.identifier()); final MemberSaveResponse response = MemberSaveResponse.from(output); return ResponseEntity.status(HttpStatus.CREATED).body(response); diff --git a/src/test/java/com/recyclestudy/email/DeviceAuthEmailSenderTest.java b/src/test/java/com/recyclestudy/email/DeviceAuthEmailSenderTest.java index e9138a4..cb58fdb 100644 --- a/src/test/java/com/recyclestudy/email/DeviceAuthEmailSenderTest.java +++ b/src/test/java/com/recyclestudy/email/DeviceAuthEmailSenderTest.java @@ -1,5 +1,7 @@ package com.recyclestudy.email; +import com.recyclestudy.member.domain.DeviceIdentifier; +import com.recyclestudy.member.domain.Email; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -38,14 +40,14 @@ void setUp() { @DisplayName("디바이스 인증 메일을 발송한다") void sendDeviceAuthMail_success() { // given - final String email = "test@test.com"; - final String deviceId = "device-123"; + final Email email = Email.from("test@test.com"); + final DeviceIdentifier deviceIdentifier = DeviceIdentifier.from("device-123"); final String expectedHtml = "인증 링크"; given(templateEngine.process(eq("auth_email"), any(Context.class))).willReturn(expectedHtml); // when - deviceAuthEmailSender.sendDeviceAuthMail(email, deviceId); + deviceAuthEmailSender.sendDeviceAuthMail(email, deviceIdentifier); // then verify(emailSender).send( @@ -59,14 +61,14 @@ void sendDeviceAuthMail_success() { @DisplayName("올바른 인증 URL이 템플릿에 전달된다") void sendDeviceAuthMail_correctAuthUrl() { // given - final String email = "test@test.com"; - final String deviceId = "device-123"; + final Email email = Email.from("test@test.com"); + final DeviceIdentifier deviceIdentifier = DeviceIdentifier.from("device-123"); final ArgumentCaptor contextCaptor = ArgumentCaptor.forClass(Context.class); given(templateEngine.process(eq("auth_email"), any(Context.class))).willReturn(""); // when - deviceAuthEmailSender.sendDeviceAuthMail(email, deviceId); + deviceAuthEmailSender.sendDeviceAuthMail(email, deviceIdentifier); // then verify(templateEngine).process(eq("auth_email"), contextCaptor.capture()); diff --git a/src/test/java/com/recyclestudy/email/EmailSenderTest.java b/src/test/java/com/recyclestudy/email/EmailSenderTest.java index b78599a..ae563cc 100644 --- a/src/test/java/com/recyclestudy/email/EmailSenderTest.java +++ b/src/test/java/com/recyclestudy/email/EmailSenderTest.java @@ -1,6 +1,7 @@ package com.recyclestudy.email; import com.recyclestudy.exception.EmailSendException; +import com.recyclestudy.member.domain.Email; import jakarta.mail.MessagingException; import jakarta.mail.internet.MimeMessage; import org.junit.jupiter.api.DisplayName; @@ -31,7 +32,7 @@ class EmailSenderTest { @DisplayName("메일을 성공적으로 발송한다") void send_success() { // given - final String to = "test@test.com"; + final Email targetEmail = Email.from("test@test.com"); final String subject = "테스트 제목"; final String content = "테스트 내용"; final MimeMessage mimeMessage = mock(MimeMessage.class); @@ -39,7 +40,7 @@ void send_success() { given(javaMailSender.createMimeMessage()).willReturn(mimeMessage); // when - emailSender.send(to, subject, content); + emailSender.send(targetEmail, subject, content); // then verify(javaMailSender).createMimeMessage(); @@ -50,7 +51,7 @@ void send_success() { @DisplayName("메일 발송 실패 시 EmailSendException을 던진다") void send_fail_throwsException() throws MessagingException { // given - final String to = "test@test.com"; + final Email targetEmail = Email.from("test@test.com"); final String subject = "테스트 제목"; final String content = "테스트 내용"; final MimeMessage mimeMessage = mock(MimeMessage.class); @@ -60,7 +61,7 @@ void send_fail_throwsException() throws MessagingException { .given(mimeMessage).setRecipient(any(), any()); // when & then - assertThatThrownBy(() -> emailSender.send(to, subject, content)) + assertThatThrownBy(() -> emailSender.send(targetEmail, subject, content)) .isInstanceOf(EmailSendException.class) .hasMessage("메일 전송 중 오류가 발생했습니다."); } diff --git a/src/test/java/com/recyclestudy/email/ReviewEmailSenderTest.java b/src/test/java/com/recyclestudy/email/ReviewEmailSenderTest.java index 4c1bd86..40e4b43 100644 --- a/src/test/java/com/recyclestudy/email/ReviewEmailSenderTest.java +++ b/src/test/java/com/recyclestudy/email/ReviewEmailSenderTest.java @@ -73,7 +73,7 @@ void sendReviewMail_success() { // then verify(emailSender).send( - eq("user@test.com"), + eq(targetEmail), eq("[Recycle Study] 오늘의 복습 목록이 도착했습니다"), eq("복습 목록") ); @@ -102,9 +102,9 @@ void sendReviewMail_multipleRecipients() { reviewEmailSender.sendReviewMail(); // then - verify(emailSender, times(2)).send(any(), any(), any()); - verify(emailSender).send(eq("user1@test.com"), any(), any()); - verify(emailSender).send(eq("user2@test.com"), any(), any()); + verify(emailSender, times(2)).send(any(Email.class), any(), any()); + verify(emailSender).send(eq(Email.from("user1@test.com")), any(), any()); + verify(emailSender).send(eq(Email.from("user2@test.com")), any(), any()); } @Test @@ -119,7 +119,7 @@ void sendReviewMail_noRecipients() { reviewEmailSender.sendReviewMail(); // then - verify(emailSender, never()).send(any(), any(), any()); + verify(emailSender, never()).send(any(Email.class), any(), any()); } @Test diff --git a/src/test/java/com/recyclestudy/member/controller/MemberControllerTest.java b/src/test/java/com/recyclestudy/member/controller/MemberControllerTest.java index f636e76..37e9f5e 100644 --- a/src/test/java/com/recyclestudy/member/controller/MemberControllerTest.java +++ b/src/test/java/com/recyclestudy/member/controller/MemberControllerTest.java @@ -75,7 +75,7 @@ void saveMember() { .body("email", equalTo(email)) .body("identifier", equalTo(identifier)); - verify(deviceAuthEmailSender).sendDeviceAuthMail(email, identifier); + verify(deviceAuthEmailSender).sendDeviceAuthMail(Email.from(email), DeviceIdentifier.from(identifier)); } @Test