Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ dependencies {
runtimeOnly 'com.mysql:mysql-connector-j'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
implementation 'org.flywaydb:flyway-core'
implementation 'org.flywaydb:flyway-mysql'

testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'io.rest-assured:rest-assured'
Expand Down
96 changes: 96 additions & 0 deletions src/main/java/com/recyclestudy/common/log/ApiLogFilter.java
Original file line number Diff line number Diff line change
@@ -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"));
}
}
Original file line number Diff line number Diff line change
@@ -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) + "...";
}
}
20 changes: 20 additions & 0 deletions src/main/java/com/recyclestudy/common/log/MDCKey.java
Original file line number Diff line number Diff line change
@@ -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;
}

13 changes: 8 additions & 5 deletions src/main/java/com/recyclestudy/email/DeviceAuthEmailSender.java
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -20,17 +22,18 @@ 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);

log.info("인증 메일 발송 성공: {}", email);
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) {
Expand Down
9 changes: 5 additions & 4 deletions src/main/java/com/recyclestudy/email/EmailSender.java
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -16,21 +17,21 @@ 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("메일 발송 성공: email={}", targetEmail);
log.info("[MAIL_SENT] 메일 발송 성공: email={}", targetEmail.toMaskedValue());

} catch (MessagingException e) {
log.error("메일 발송 실패: email={}", targetEmail, e);
log.error("[MAIL_SEND_FAILED] 메일 발송 실패: email={}", targetEmail, e);
throw new EmailSendException("메일 전송 중 오류가 발생했습니다.", e);
}
}
Expand Down
7 changes: 4 additions & 3 deletions src/main/java/com/recyclestudy/email/ReviewEmailSender.java
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ public void sendReviewMail() {
ReviewSendInput.from(targetDate, targetTime));

final List<ReviewSendElement> 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;
Expand All @@ -60,14 +60,15 @@ 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) {
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);
return false;
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,54 +1,63 @@
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<ErrorResponse> 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<ErrorResponse> 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<ErrorResponse> 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<ErrorResponse> 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<ErrorResponse> 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);
}

@ExceptionHandler(MissingServletRequestParameterException.class)
public ResponseEntity<ErrorResponse> 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<ErrorResponse> handleException(final Exception e) {
log.error("[INTERNAL_ERROR] 예기치 못한 에러 발생", e);
final ErrorResponse response = ErrorResponse.from("예기치 못한 에러가 발생했습니다");
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ public ResponseEntity<MemberSaveResponse> 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);
Expand Down
Loading