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
1 change: 1 addition & 0 deletions .github/workflows/cd-api-dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ jobs:
SUPERTONE_API_TOKEN: ${{ secrets.SUPERTONE_API_TOKEN }}
GOOGLE_CLIENT_ID_DEV: ${{ secrets.GOOGLE_CLIENT_ID_DEV }}
GOOGLE_CLIENT_SECRET_DEV: ${{ secrets.GOOGLE_CLIENT_SECRET_DEV }}
WIDGET_SECRET_KEY_DEV: ${{ secrets.WIDGET_SECRET_KEY_DEV }}
run: |
export HOSTNAME=$(hostname)
cd kokomen-backend/docker/dev
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/cd-api-load-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ jobs:
SUPERTONE_API_TOKEN: ${{ secrets.SUPERTONE_API_TOKEN }}
GOOGLE_CLIENT_ID_PROD: ${{ secrets.GOOGLE_CLIENT_ID_PROD }}
GOOGLE_CLIENT_SECRET_PROD: ${{ secrets.GOOGLE_CLIENT_SECRET_PROD }}
WIDGET_SECRET_KEY_PROD: ${{ secrets.WIDGET_SECRET_KEY_PROD }}
MYSQL_HOST: ${{ secrets.MYSQL_HOST }}
run: |
export HOSTNAME=$(hostname)
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/cd-api-prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ jobs:
SUPERTONE_API_TOKEN: ${{ secrets.SUPERTONE_API_TOKEN }}
GOOGLE_CLIENT_ID_PROD: ${{ secrets.GOOGLE_CLIENT_ID_PROD }}
GOOGLE_CLIENT_SECRET_PROD: ${{ secrets.GOOGLE_CLIENT_SECRET_PROD }}
PAYMENT_BASE_URL_PROD: ${{ secrets.PAYMENT_BASE_URL_PROD }}
WIDGET_SECRET_KEY_PROD: ${{ secrets.WIDGET_SECRET_KEY_PROD }}
run: |
export HOSTNAME=$(hostname)
cd kokomen-backend/docker/prod/api
Expand Down
1 change: 1 addition & 0 deletions docker/dev/docker-compose-dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ services:
SUPERTONE_API_TOKEN: ${SUPERTONE_API_TOKEN}
GOOGLE_CLIENT_ID_DEV: ${GOOGLE_CLIENT_ID_DEV}
GOOGLE_CLIENT_SECRET_DEV: ${GOOGLE_CLIENT_SECRET_DEV}
WIDGET_SECRET_KEY_DEV: ${WIDGET_SECRET_KEY_DEV}
networks:
- dev-kokomen-net

Expand Down
1 change: 1 addition & 0 deletions docker/load-test/api/docker-compose-prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ services:
SUPERTONE_API_TOKEN: ${SUPERTONE_API_TOKEN}
GOOGLE_CLIENT_ID_PROD: ${GOOGLE_CLIENT_ID_PROD}
GOOGLE_CLIENT_SECRET_PROD: ${GOOGLE_CLIENT_SECRET_PROD}
WIDGET_SECRET_KEY_PROD: ${WIDGET_SECRET_KEY_PROD}

node:
image: prom/node-exporter
Expand Down
2 changes: 1 addition & 1 deletion docker/prod/api/docker-compose-prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ services:
SUPERTONE_API_TOKEN: ${SUPERTONE_API_TOKEN}
GOOGLE_CLIENT_ID_PROD: ${GOOGLE_CLIENT_ID_PROD}
GOOGLE_CLIENT_SECRET_PROD: ${GOOGLE_CLIENT_SECRET_PROD}
PAYMENT_BASE_URL_PROD: ${PAYMENT_BASE_URL_PROD}
WIDGET_SECRET_KEY_PROD: ${WIDGET_SECRET_KEY_PROD}

nginx:
image: nginx:1.28.0
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import com.samhap.kokomen.answer.domain.Answer;
import com.samhap.kokomen.answer.repository.AnswerRepository;
import com.samhap.kokomen.global.exception.BadRequestException;
import com.samhap.kokomen.global.exception.NotFoundException;
import com.samhap.kokomen.interview.entity.Question;
import java.util.List;
import java.util.Optional;
Expand All @@ -23,12 +24,12 @@ public Answer saveAnswer(Answer answer) {

public Answer readById(Long answerId) {
return answerRepository.findById(answerId)
.orElseThrow(() -> new BadRequestException("존재하지 않는 답변입니다."));
.orElseThrow(() -> new NotFoundException("존재하지 않는 답변입니다."));
}

public Answer readByQuestionId(Long questionId) {
return answerRepository.findByQuestionId(questionId)
.orElseThrow(() -> new IllegalStateException("다음 질문에 대한 답변이 존재하지 않습니다. question_id = " + questionId));
.orElseThrow(() -> new NotFoundException("다음 질문에 대한 답변이 존재하지 않습니다. question_id = " + questionId));
}

public Optional<Answer> findByQuestionId(Long questionId) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.samhap.kokomen.auth.infrastructure;

import com.samhap.kokomen.global.exception.BadRequestException;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
Expand All @@ -15,7 +16,7 @@ public static void logout(HttpServletRequest request, HttpServletResponse respon
Cookie jSessionIdCookie = Arrays.stream(request.getCookies())
.filter(cookie -> "JSESSIONID".equals(cookie.getName()))
.findFirst()
.orElseThrow(() -> new IllegalStateException("JSESSIONID 쿠키가 요청에 존재하지 않습니다."));
.orElseThrow(() -> new BadRequestException("JSESSIONID 쿠키가 요청에 존재하지 않습니다."));
jSessionIdCookie.setValue("");
jSessionIdCookie.setMaxAge(0);
response.addCookie(jSessionIdCookie);
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package com.samhap.kokomen.global.aop;

import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.util.StopWatch;

@Slf4j
@Order(1)
@Aspect
@Component
public class TosspaymentsLoggingAspect {

@Around("execution(* com.samhap.kokomen.payment.external.TosspaymentsClient.*(..))")
public Object logTosspaymentsApiCall(ProceedingJoinPoint joinPoint) throws Throwable {
String methodName = joinPoint.getSignature().getName();
Object[] args = joinPoint.getArgs();

log.info("[토스페이먼츠 API 요청] {} - args: {}", methodName, args);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

요청 인자를 INFO 레벨로 전체 로깅 시 민감 정보 노출 위험

args에는 결제 키, 금액, 사용자 정보 등 민감한 데이터가 포함될 수 있습니다. 전체 인자를 INFO 레벨로 로깅하면 로그 수집 시스템에 PII/결제 정보가 유출될 수 있습니다. DEBUG 레벨로 변경하거나, 민감 필드를 마스킹하는 것을 권장합니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/com/samhap/kokomen/global/aop/TosspaymentsLoggingAspect.java`
at line 22, The current INFO-level call in TosspaymentsLoggingAspect logging
full request arguments (log.info("[토스페이먼츠 API 요청] {} - args: {}", methodName,
args)) risks exposing sensitive payment/PII data; change the log level to DEBUG
or redact sensitive fields before logging by replacing the log.info call with
either log.debug(...) or log.info(...) that logs a sanitized representation
returned from a new helper (e.g., maskSensitiveFields(args) or
sanitizeArgs(args)); implement maskSensitiveFields/sanitizeArgs to detect and
redact known keys (paymentKey, cardNumber, amount, user identifiers) and use
that sanitized value in the logging call so sensitive data is never emitted at
INFO level.


StopWatch stopWatch = new StopWatch();
stopWatch.start();
try {
Object result = joinPoint.proceed();
stopWatch.stop();
log.info("[토스페이먼츠 API 응답] {} - {}ms - response: {}",
methodName, stopWatch.getTotalTimeMillis(), result);
return result;
} catch (Throwable e) {
stopWatch.stop();
log.error("[토스페이먼츠 API 에러] {} - {}ms - error: {}",
methodName, stopWatch.getTotalTimeMillis(), e.getMessage());
throw e;
}
}
Comment on lines +18 to +38
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

예외 발생 시 경과 시간 및 에러 로깅 누락

joinPoint.proceed()에서 예외가 발생하면 stopWatch.stop()과 응답 로그가 실행되지 않습니다. try-catch-finally 패턴을 적용하여 예외 시에도 경과 시간과 에러 정보를 로깅하면 운영 시 디버깅에 유용합니다.

♻️ 예외 처리 추가 제안
     `@Around`("execution(* com.samhap.kokomen.payment.external.TosspaymentsClient.*(..))")
     public Object logTosspaymentsApiCall(ProceedingJoinPoint joinPoint) throws Throwable {
         String methodName = joinPoint.getSignature().getName();
         Object[] args = joinPoint.getArgs();
 
         log.info("[토스페이먼츠 API 요청] {} - args: {}", methodName, args);
 
         StopWatch stopWatch = new StopWatch();
         stopWatch.start();
-        Object result = joinPoint.proceed();
-        stopWatch.stop();
-        log.info("[토스페이먼츠 API 응답] {} - {}ms - response: {}",
-                methodName, stopWatch.getTotalTimeMillis(), result);
-        return result;
+        try {
+            Object result = joinPoint.proceed();
+            stopWatch.stop();
+            log.info("[토스페이먼츠 API 응답] {} - {}ms - response: {}",
+                    methodName, stopWatch.getTotalTimeMillis(), result);
+            return result;
+        } catch (Throwable e) {
+            stopWatch.stop();
+            log.error("[토스페이먼츠 API 에러] {} - {}ms - error: {}",
+                    methodName, stopWatch.getTotalTimeMillis(), e.getMessage());
+            throw e;
+        }
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public Object logTosspaymentsApiCall(ProceedingJoinPoint joinPoint) throws Throwable {
String methodName = joinPoint.getSignature().getName();
Object[] args = joinPoint.getArgs();
log.info("[토스페이먼츠 API 요청] {} - args: {}", methodName, args);
StopWatch stopWatch = new StopWatch();
stopWatch.start();
Object result = joinPoint.proceed();
stopWatch.stop();
log.info("[토스페이먼츠 API 응답] {} - {}ms - response: {}",
methodName, stopWatch.getTotalTimeMillis(), result);
return result;
}
public Object logTosspaymentsApiCall(ProceedingJoinPoint joinPoint) throws Throwable {
String methodName = joinPoint.getSignature().getName();
Object[] args = joinPoint.getArgs();
log.info("[토스페이먼츠 API 요청] {} - args: {}", methodName, args);
StopWatch stopWatch = new StopWatch();
stopWatch.start();
try {
Object result = joinPoint.proceed();
stopWatch.stop();
log.info("[토스페이먼츠 API 응답] {} - {}ms - response: {}",
methodName, stopWatch.getTotalTimeMillis(), result);
return result;
} catch (Throwable e) {
stopWatch.stop();
log.error("[토스페이먼츠 API 에러] {} - {}ms - error: {}",
methodName, stopWatch.getTotalTimeMillis(), e.getMessage());
throw e;
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/com/samhap/kokomen/global/aop/TosspaymentsLoggingAspect.java`
around lines 18 - 31, The logTosspaymentsApiCall method currently calls
joinPoint.proceed() without guarding for exceptions so stopWatch.stop() and the
response/elapsed-time logging are skipped; wrap the joinPoint.proceed() call in
a try-catch-finally: start the StopWatch before proceed, in finally always stop
the StopWatch and log the elapsed time, in the catch log the error (including
exception message/stack) with methodName and elapsed ms, then rethrow the
exception so behavior is unchanged; update references: logTosspaymentsApiCall,
joinPoint.proceed, StopWatch, stopWatch.stop, and log.info.

}
39 changes: 39 additions & 0 deletions src/main/java/com/samhap/kokomen/global/config/RetryConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package com.samhap.kokomen.global.config;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.retry.backoff.ExponentialBackOffPolicy;
import org.springframework.retry.support.RetryTemplate;

@Configuration
public class RetryConfig {

@Value("${retry.tosspayments.max-attempts}")
private int maxAttempts;

@Value("${retry.tosspayments.initial-interval}")
private long initialInterval;

@Value("${retry.tosspayments.multiplier}")
private double multiplier;

@Value("${retry.tosspayments.max-interval}")
private long maxInterval;
Comment on lines +12 to +22
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

@ConfigurationProperties 바인딩 사용 권장

4개의 @Value 어노테이션 대신 @ConfigurationProperties(prefix = "retry.tosspayments")를 사용한 별도 프로퍼티 클래스로 추출하면 타입 안전성, 유효성 검증(@Validated), IDE 자동완성 등의 이점을 얻을 수 있습니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/com/samhap/kokomen/global/config/RetryConfig.java` around lines
12 - 22, Extract the four `@Value` fields from RetryConfig into a dedicated
`@ConfigurationProperties`(prefix = "retry.tosspayments") class (e.g.,
TossPaymentsRetryProperties) containing maxAttempts, initialInterval,
multiplier, and maxInterval, annotate it with `@Validated` if you need validation,
and register it (via `@ConfigurationPropertiesScan` or
`@EnableConfigurationProperties`). Then update RetryConfig to accept the new
properties bean via constructor injection and remove the four `@Value`
annotations/fields so configuration becomes type-safe and supports
validation/IDE autocomplete.


@Bean
public RetryTemplate tosspaymentsConfirmRetryTemplate() {
RetryTemplate retryTemplate = new RetryTemplate();

TosspaymentsConfirmRetryPolicy retryPolicy = new TosspaymentsConfirmRetryPolicy(maxAttempts);
retryTemplate.setRetryPolicy(retryPolicy);

ExponentialBackOffPolicy backOffPolicy = new ExponentialBackOffPolicy();
backOffPolicy.setInitialInterval(initialInterval);
backOffPolicy.setMultiplier(multiplier);
backOffPolicy.setMaxInterval(maxInterval);
retryTemplate.setBackOffPolicy(backOffPolicy);

return retryTemplate;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com.samhap.kokomen.global.config;

import org.springframework.retry.RetryContext;
import org.springframework.retry.policy.SimpleRetryPolicy;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.HttpServerErrorException;
import org.springframework.web.client.ResourceAccessException;

public class TosspaymentsConfirmRetryPolicy extends SimpleRetryPolicy {

public TosspaymentsConfirmRetryPolicy(int maxAttempts) {
super(maxAttempts);
}

@Override
public boolean canRetry(RetryContext context) {
Throwable lastException = context.getLastThrowable();
if (lastException != null && !isRetryableException(lastException)) {
return false;
}
return super.canRetry(context);
}

private boolean isRetryableException(Throwable throwable) {
if (throwable instanceof HttpServerErrorException) {
return true;
}
if (throwable instanceof ResourceAccessException) {
return true;
}
if (throwable instanceof HttpClientErrorException e) {
return e.getStatusCode().value() == 409;
}
return false;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,8 @@ public class BadRequestException extends KokomenException {
public BadRequestException(String message) {
super(message, HttpStatus.BAD_REQUEST);
}

public BadRequestException(String message, Throwable cause) {
super(message, cause, HttpStatus.BAD_REQUEST);
}
Comment on lines +11 to +13
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The constructor BadRequestException(String message, Throwable cause) is added, which is good for preserving the original cause of an exception. This helps in debugging and understanding the root problem when an exception is re-thrown or wrapped.

}
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,13 @@
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.HttpMediaTypeNotSupportedException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.resource.NoResourceFoundException;

// TODO: HttpMessageNotReadableException 예외 처리 추가
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
Expand Down Expand Up @@ -93,6 +93,14 @@ public ResponseEntity<Void> handleNoResourceFound(NoResourceFoundException e) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
}

@ExceptionHandler(HttpMediaTypeNotSupportedException.class)
public ResponseEntity<ErrorResponse> handleHttpMediaTypeNotSupportedException(
HttpMediaTypeNotSupportedException e) {
log.warn("HttpMediaTypeNotSupportedException :: message: {}", e.getMessage());
return ResponseEntity.status(HttpStatus.UNSUPPORTED_MEDIA_TYPE)
.body(new ErrorResponse("지원하지 않는 Content-Type입니다."));
}
Comment on lines +96 to +102
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The new exception handler for HttpMediaTypeNotSupportedException provides a clear and user-friendly error message when an unsupported Content-Type is received. This is a good improvement for API usability.

Comment thread
coderabbitai[bot] marked this conversation as resolved.

@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleException(Exception e) {
log.error("Exception :: status: {}, message: {}, stackTrace: ", HttpStatus.INTERNAL_SERVER_ERROR,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.samhap.kokomen.global.exception;

import org.springframework.http.HttpStatus;

public class InternalServerErrorException extends KokomenException {

public InternalServerErrorException(String message) {
super(message, HttpStatus.INTERNAL_SERVER_ERROR);
}

public InternalServerErrorException(String message, Throwable cause) {
super(message, cause, HttpStatus.INTERNAL_SERVER_ERROR);
}
}
Comment on lines +1 to +14
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The introduction of InternalServerErrorException with constructors that accept a Throwable cause is a good practice. It allows for wrapping lower-level exceptions, preserving the stack trace, and providing a more specific exception type for internal server errors.

Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.samhap.kokomen.global.exception;

import org.springframework.http.HttpStatus;

public class NotFoundException extends KokomenException {

public NotFoundException(String message) {
super(message, HttpStatus.NOT_FOUND);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

public NotFoundException(String message, Throwable cause) {
super(message, cause, HttpStatus.NOT_FOUND);
}
}
Comment on lines +1 to +14
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The new NotFoundException provides a specific exception type for resource not found scenarios, which can lead to clearer error handling and better API responses for clients.

Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.samhap.kokomen.global.exception;

import lombok.Getter;

@Getter
public enum PaymentServiceErrorMessage {

MISSING_REQUEST_PARAMETER("필수 요청 파라미터가 누락되었습니다."),
JSON_PARSE_ERROR("JSON 파싱 오류: 유효하지 않은 값이 전달되었습니다."),
PAYMENT_NOT_FOUND_BY_ID("해당 id의 결제 정보가 존재하지 않습니다."),
PAYMENT_NOT_FOUND_BY_PAYMENT_KEY("해당 paymentKey의 결제 정보가 존재하지 않습니다."),
PAYMENT_RESULT_NOT_FOUND("해당 결제의 결과 정보가 존재하지 않습니다."),
CONFIRM_SERVER_ERROR("결제 처리 중 서버 오류가 발생했습니다."),
CANCEL_SERVER_ERROR("결제 취소 처리 중 서버 오류가 발생했습니다."),
CANCEL_NETWORK_ERROR("결제 취소 처리 중 네트워크 오류가 발생했습니다."),
INVALID_REQUEST("잘못된 요청입니다."),
INVALID_REQUEST_FORMAT("잘못된 요청 형식입니다."),
INTERNAL_SERVER_ERROR("서버에 문제가 발생하였습니다.");

private final String message;

PaymentServiceErrorMessage(String message) {
this.message = message;
}
}
Comment on lines +1 to +25
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Creating an enum PaymentServiceErrorMessage to centralize payment-related error messages is an excellent practice. It improves consistency, maintainability, and readability of error messages across the payment service.

Comment on lines +1 to +25
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

global.exception 패키지 내 결제 특화 열거형 배치 재검토 필요

PaymentServiceErrorMessage의 상수들(PAYMENT_NOT_FOUND_BY_ID, CONFIRM_SERVER_ERROR, CANCEL_SERVER_ERROR 등)은 결제 도메인에 특화된 메시지이므로, global.exception 패키지보다는 payment.service 또는 payment.domain 패키지에 위치하는 것이 응집도 측면에서 더 적절합니다. 이미 payment.domainPaymentErrorMessage가 존재한다면, 두 열거형의 역할 구분 기준도 명확히 정의할 필요가 있습니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/com/samhap/kokomen/global/exception/PaymentServiceErrorMessage.java`
around lines 1 - 25, PaymentServiceErrorMessage is a payment-domain specific
enum that should be moved out of global.exception into a payment-focused package
(e.g., payment.service or payment.domain) or consolidated with the existing
PaymentErrorMessage to avoid duplication; relocate the
PaymentServiceErrorMessage enum file into the chosen package (or merge its
constants into PaymentErrorMessage), update all usages/imports referencing
PaymentServiceErrorMessage, and ensure the enum name and constant semantics
remain consistent (adjust references to PaymentServiceErrorMessage and
PaymentErrorMessage as needed) so exception handling remains coherent and
package cohesion is improved.

Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.samhap.kokomen.global.annotation.Authentication;
import com.samhap.kokomen.global.dto.MemberAuth;
import com.samhap.kokomen.global.exception.InternalServerErrorException;
import com.samhap.kokomen.global.exception.UnauthorizedException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
Expand Down Expand Up @@ -29,7 +30,7 @@ public Object resolveArgument(MethodParameter parameter,
WebDataBinderFactory binderFactory) throws Exception {
Authentication authentication = parameter.getParameterAnnotation(Authentication.class);
if (authentication == null) {
throw new IllegalStateException("MemberAuth 파라미터는 @Authentication 어노테이션이 있어야 합니다.");
throw new InternalServerErrorException("MemberAuth 파라미터는 @Authentication 어노테이션이 있어야 합니다.");
}
boolean authenticationRequired = authentication.required();
HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class);
Expand All @@ -56,7 +57,7 @@ private void validateAuthentication(Long memberId, boolean authenticationRequire
log.error("세션에 MEMBER_ID가 없습니다.");
}
if (memberId == null && authenticationRequired) {
throw new IllegalStateException("세션에 MEMBER_ID가 없습니다.");
throw new UnauthorizedException("세션에 MEMBER_ID가 없습니다.");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.samhap.kokomen.global.infrastructure;

import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;

public class ObjectToStringDeserializer extends JsonDeserializer<String> {

@Override
public String deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
JsonNode node = p.getCodec().readTree(p);

// 이미 문자열인 경우 그대로 반환
if (node.isTextual()) {
return node.asText();
}

// 객체나 배열인 경우 JSON 문자열로 변환
ObjectMapper mapper = new ObjectMapper();
return mapper.writeValueAsString(node);
Comment on lines +12 to +23
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, find and examine the file
find . -name "ObjectToStringDeserializer.java" -type f

Repository: samhap-soft/kokomen-backend

Length of output: 159


🏁 Script executed:

# Search for the file to confirm it exists
git ls-files | grep -i objecttostringdeserializer

Repository: samhap-soft/kokomen-backend

Length of output: 157


🏁 Script executed:

# If found, examine its content
fd ObjectToStringDeserializer.java -x cat -n {}

Repository: samhap-soft/kokomen-backend

Length of output: 1071


🏁 Script executed:

# Check if there are tests for this deserializer
fd -e java | xargs grep -l "ObjectToStringDeserializer" 2>/dev/null

Repository: samhap-soft/kokomen-backend

Length of output: 391


🏁 Script executed:

# Search for `@NotBlank` usage that might depend on this deserializer
rg "@NotBlank" -A 2 -B 2 --max-count=5

Repository: samhap-soft/kokomen-backend

Length of output: 9407


🏁 Script executed:

# Create a test to verify JsonNode and ObjectMapper behavior with null
python3 << 'EOF'
import subprocess
import json

# Create a test Java file to verify Jackson behavior
test_code = """
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;

public class JacksonBehaviorTest {
    public static void main(String[] args) throws Exception {
        ObjectMapper mapper = new ObjectMapper();
        
        // Test null JSON
        String nullJson = "null";
        JsonNode nullNode = mapper.readTree(nullJson);
        
        System.out.println("nullNode.isNull(): " + nullNode.isNull());
        System.out.println("nullNode.isMissingNode(): " + nullNode.isMissingNode());
        System.out.println("nullNode.isTextual(): " + nullNode.isTextual());
        
        String result1 = mapper.writeValueAsString(nullNode);
        System.out.println("mapper.writeValueAsString(nullNode): " + result1);
        System.out.println("Length: " + result1.length());
        
        String result2 = nullNode.toString();
        System.out.println("nullNode.toString(): " + result2);
        System.out.println("Length: " + result2.length());
        
        // Test object JSON
        String objectJson = "{\"key\":\"value\"}";
        JsonNode objectNode = mapper.readTree(objectJson);
        
        System.out.println("\\nobjectNode.toString(): " + objectNode.toString());
        System.out.println("mapper.writeValueAsString(objectNode): " + mapper.writeValueAsString(objectNode));
    }
}
"""

print(test_code)
EOF

Repository: samhap-soft/kokomen-backend

Length of output: 1424


🏁 Script executed:

# Check if there are any existing tests for ObjectToStringDeserializer
fd -e java | xargs grep -l "ObjectToStringDeserializer" | grep -i test

Repository: samhap-soft/kokomen-backend

Length of output: 53


🏁 Script executed:

# Look at how metadata field is used in ConfirmRequest
cat -n src/main/java/com/samhap/kokomen/payment/service/dto/ConfirmRequest.java | head -30

Repository: samhap-soft/kokomen-backend

Length of output: 1607


🏁 Script executed:

# Check the complete ConfirmRequest to understand the context
cat -n src/main/java/com/samhap/kokomen/payment/service/dto/ConfirmRequest.java

Repository: samhap-soft/kokomen-backend

Length of output: 1839


🌐 Web query:

Jackson JsonNode writeValueAsString null behavior NullNode

💡 Result:

  • ObjectMapper.writeValueAsString(null) returns the JSON literal "null" (a Java String containing the characters n u l l).
  • ObjectMapper.writeValueAsString(NullNode.getInstance()) also returns "null". In other words, for serialization output, null and NullNode serialize the same by default. [1]

Why NullNode exists / when it matters:

  • In Jackson’s tree model, NullNode represents an explicit JSON null value (e.g., parsing {"a":null} yields an ObjectNode where "a" maps to NullNode). [1]
  • A Java reference being null often means “no node / missing” (e.g., obj.get("missingField") can return Java null when the property doesn’t exist), which is different from an existing property whose value is JSON null (NullNode). [3]
  • Jackson has configuration around preserving vs skipping null-valued properties in the tree model (e.g., JsonNodeFeature.READ_NULL_PROPERTIES / WRITE_NULL_PROPERTIES in newer Jackson versions), which only makes sense when nulls are represented as NullNode entries inside an ObjectNode. [2]

🌐 Web query:

Jackson JsonNode toString vs writeValueAsString difference

💡 Result:

  • JsonNode.toString()

    • In jackson-databind ≤ 2.9, it was intended as a developer-readable representation and was not guaranteed to be valid JSON (notably, escaping could be wrong). [1]
    • Starting with jackson-databind 2.10, JsonNode.toString() is supported and produces valid JSON, but it does so using an ObjectMapper with default settings (i.e., you don’t get to apply your own ObjectMapper configuration). [1][2]
    • For indentation, Jackson added JsonNode.toPrettyString(). [1]
  • ObjectMapper.writeValueAsString(jsonNode)

    • Serializes the node using that ObjectMapper / ObjectWriter configuration (modules, custom serializers, features like INDENT_OUTPUT, and other serialization options). [1][3]
    • Use this when you need non-default output or consistent application-wide serialization behavior. [1][3]

Rule of thumb: use toString() only if you’re on 2.10+ and default JSON is fine; otherwise prefer writeValueAsString() (or a configured ObjectWriter). [1][3]

Sources: [1] [2] [3]


null 입력이 검증을 우회할 수 있음

JSON null 값이 문자열 "null"로 변환되어 @NotBlank 검증을 우회합니다. 이 문자열은 빈 값이 아니므로 검증을 통과하여 잘못된 데이터가 저장될 위험이 있습니다. 또한 매 호출마다 새로운 ObjectMapper를 생성하면 설정 불일치와 성능 오버헤드가 발생합니다.

node.isNull() 체크를 추가하여 null일 때 Java null을 반환하고, mapper.writeValueAsString(node) 대신 node.toString()을 사용하세요.

🛠️ 제안 수정
     public String deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
         JsonNode node = p.getCodec().readTree(p);

+        if (node.isNull() || node.isMissingNode()) {
+            return null;
+        }
+
         // 이미 문자열인 경우 그대로 반환
         if (node.isTextual()) {
             return node.asText();
         }

         // 객체나 배열인 경우 JSON 문자열로 변환
-        ObjectMapper mapper = new ObjectMapper();
-        return mapper.writeValueAsString(node);
+        return node.toString();
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/com/samhap/kokomen/global/infrastructure/ObjectToStringDeserializer.java`
around lines 12 - 23, The deserialize method in ObjectToStringDeserializer
should treat JSON null as Java null to avoid bypassing `@NotBlank`: add a check if
(node.isNull()) return null; and stop producing the literal "null" string. Also
remove the per-call new ObjectMapper creation and use node.toString() to produce
the JSON text for object/array cases (i.e., replace
mapper.writeValueAsString(node) with node.toString()). Keep these changes inside
the deserialize(JsonParser p, DeserializationContext ctxt) method and ensure
behavior for node.isTextual() remains unchanged.

}
}
Comment on lines +1 to +25
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The ObjectToStringDeserializer is a useful utility for handling flexible JSON structures where a field might be a string, object, or array, and converting it consistently to a string representation. This can prevent deserialization errors and make the API more tolerant to varying data formats.

Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.samhap.kokomen.global.annotation.ExecutionTimer;
import com.samhap.kokomen.global.constant.AwsConstant;
import com.samhap.kokomen.global.exception.BadRequestException;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import software.amazon.awssdk.core.ResponseBytes;
Expand Down Expand Up @@ -69,6 +70,6 @@ private String extractKeyFromCdnUrl(String cdnUrl) {
if (cdnUrl.startsWith(AwsConstant.CLOUD_FRONT_DOMAIN_URL)) {
return cdnUrl.substring(AwsConstant.CLOUD_FRONT_DOMAIN_URL.length());
}
throw new IllegalArgumentException("Invalid CDN URL: " + cdnUrl);
throw new BadRequestException("Invalid CDN URL: " + cdnUrl);
}
}
Loading