반응형

스프링을 사용하다보면 @Transactional을 굉장히 많이 사용한다.

 

에러가 발생하면 transaction을 rollback 시켜주기 때문에 사용한다라고는 알고 있지만, 어떠한 방식으로 동작하는지는 제대로 알아보지 않은 것 같다.

 

우선 블로그들과 spring framework의 github를 참고했다.

 

@Transactional을 사용하면 interceptor가 중간에 가져와서 invokeWithinTransaction가 실행되도록 한다.

 

안에서 작성된 코드는 이정도 되는 것 같다.

 

https://github.com/spring-projects/spring-framework/blob/main/spring-tx/src/main/java/org/springframework/transaction/annotation/Transactional.java

protected @Nullable Object invokeWithinTransaction(Method method, @Nullable Class<?> targetClass,
			final InvocationCallback invocation) throws Throwable {

		// If the transaction attribute is null, the method is non-transactional.
		TransactionAttributeSource tas = getTransactionAttributeSource();
		final TransactionAttribute txAttr = (tas != null ? tas.getTransactionAttribute(method, targetClass) : null);
		final TransactionManager tm = determineTransactionManager(txAttr, targetClass);

		if (this.reactiveAdapterRegistry != null && tm instanceof ReactiveTransactionManager rtm) {
			boolean isSuspendingFunction = KotlinDetector.isSuspendingFunction(method);
			boolean hasSuspendingFlowReturnType = isSuspendingFunction &&
					COROUTINES_FLOW_CLASS_NAME.equals(new MethodParameter(method, -1).getParameterType().getName());

			ReactiveTransactionSupport txSupport = this.transactionSupportCache.computeIfAbsent(method, key -> {
				Class<?> reactiveType =
						(isSuspendingFunction ? (hasSuspendingFlowReturnType ? Flux.class : Mono.class) : method.getReturnType());
				ReactiveAdapter adapter = this.reactiveAdapterRegistry.getAdapter(reactiveType);
				if (adapter == null) {
					throw new IllegalStateException("Cannot apply reactive transaction to non-reactive return type [" +
							method.getReturnType() + "] with specified transaction manager: " + tm);
				}
				return new ReactiveTransactionSupport(adapter);
			});

			return txSupport.invokeWithinTransaction(method, targetClass, invocation, txAttr, rtm);
		}

		PlatformTransactionManager ptm = asPlatformTransactionManager(tm);
		final String joinpointIdentification = methodIdentification(method, targetClass, txAttr);

		if (txAttr == null || !(ptm instanceof CallbackPreferringPlatformTransactionManager cpptm)) {
			// Standard transaction demarcation with getTransaction and commit/rollback calls.
			TransactionInfo txInfo = createTransactionIfNecessary(ptm, txAttr, joinpointIdentification);

			Object retVal;
			try {
				// This is an around advice: Invoke the next interceptor in the chain.
				// This will normally result in a target object being invoked.
				retVal = invocation.proceedWithInvocation();
			}
			catch (Throwable ex) {
				// target invocation exception
				completeTransactionAfterThrowing(txInfo, ex);
				throw ex;
			}
			finally {
				cleanupTransactionInfo(txInfo);
			}

			if (retVal != null && txAttr != null) {
				TransactionStatus status = txInfo.getTransactionStatus();
				if (status != null) {
					if (retVal instanceof Future<?> future && future.isDone()) {
						try {
							future.get();
						}
						catch (ExecutionException ex) {
							Throwable cause = ex.getCause();
							Assert.state(cause != null, "Cause must not be null");
							if (txAttr.rollbackOn(cause)) {
								status.setRollbackOnly();
							}
						}
						catch (InterruptedException ex) {
							Thread.currentThread().interrupt();
						}
					}
					else if (vavrPresent && VavrDelegate.isVavrTry(retVal)) {
						// Set rollback-only in case of Vavr failure matching our rollback rules...
						retVal = VavrDelegate.evaluateTryFailure(retVal, txAttr, status);
					}
				}
			}

			commitTransactionAfterReturning(txInfo);
			return retVal;
		}

		else {
			Object result;
			final ThrowableHolder throwableHolder = new ThrowableHolder();

			// It's a CallbackPreferringPlatformTransactionManager: pass a TransactionCallback in.
			try {
				result = cpptm.execute(txAttr, status -> {
					TransactionInfo txInfo = prepareTransactionInfo(ptm, txAttr, joinpointIdentification, status);
					try {
						Object retVal = invocation.proceedWithInvocation();
						if (retVal != null && vavrPresent && VavrDelegate.isVavrTry(retVal)) {
							// Set rollback-only in case of Vavr failure matching our rollback rules...
							retVal = VavrDelegate.evaluateTryFailure(retVal, txAttr, status);
						}
						return retVal;
					}
					catch (Throwable ex) {
						if (txAttr.rollbackOn(ex)) {
							// A RuntimeException: will lead to a rollback.
							if (ex instanceof RuntimeException runtimeException) {
								throw runtimeException;
							}
							else {
								throw new ThrowableHolderException(ex);
							}
						}
						else {
							// A normal return value: will lead to a commit.
							throwableHolder.throwable = ex;
							return null;
						}
					}
					finally {
						cleanupTransactionInfo(txInfo);
					}
				});
			}
			catch (ThrowableHolderException ex) {
				throw ex.getCause();
			}
			catch (TransactionSystemException ex2) {
				if (throwableHolder.throwable != null) {
					logger.error("Application exception overridden by commit exception", throwableHolder.throwable);
					ex2.initApplicationException(throwableHolder.throwable);
				}
				throw ex2;
			}
			catch (Throwable ex2) {
				if (throwableHolder.throwable != null) {
					logger.error("Application exception overridden by commit exception", throwableHolder.throwable);
				}
				throw ex2;
			}

			// Check result state: It might indicate a Throwable to rethrow.
			if (throwableHolder.throwable != null) {
				throw throwableHolder.throwable;
			}
			return result;
		}
	}

 

우선 트랜잭션의 속성과 매니저를 가져온다.

TransactionAttributeSource tas = getTransactionAttributeSource();
TransactionAttribute txAttr = (tas != null ? tas.getTransactionAttribute(method, targetClass) : null);
TransactionManager tm = determineTransactionManager(txAttr, targetClass);

 

특별한 설정이 없다면, 트랜잭션을 처리한다.

PlatformTransactionManager ptm = asPlatformTransactionManager(tm);
...
if (txAttr == null || !(ptm instanceof CallbackPreferringPlatformTransactionManager cpptm)) {

}

 

아래의 메서드로 트랜잭션이 진행된다.

 

  • 트랜잭션 시작 (createTransactionIfNecessary)
  • 비즈니스 로직 실행 (invocation.proceedWithInvocation())
  • 예외 발생 시 롤백 (completeTransactionAfterThrowing)
  • 정상 종료 시 커밋 (commitTransactionAfterReturning)

 

아래의 메서드로 에러 발생시 롤백이 예약된다.

status.setRollbackOnly();

 

 

 

만약, 해당 트랜잭션이 중첩이 되어 있다면

호출한 트랜잭션으로 전파된다.

if (throwableHolder.throwable != null) {
    throw throwableHolder.throwable;
}
return result;

 

간단하게 정리하자면

 

  • Spring이 트랜잭션 어노테이션이 붙은 메서드를 감지
  • 그 메서드를 프록시(Proxy) 객체로 감쌉니다
  • 실제 메서드를 호출할 때, 프록시가 트랜잭션 관련 로직을 앞뒤로 끼워 넣습니다

이런식으로 트랜잭션이 처리된다.

 

반응형

면접에서 나왔던 질문이지만, 내가 대답하지 못했던 부분에 대해서 알아보려고 한다.

 

우선 트랜잭션이 무엇인지, 왜 필요한지는 이미 안다고 생각한다.

 

그리고 당연히 트랜잭션의 레벨이 높아질수록, 동시성이 떨어지기 때문에 성능이 떨어진다.

하지만 그만큼 고립시켜서 동작하기 때문에 일관성은 높아진다.

 

우선 크게

Read Uncommitted, Read Committed, Repeatable Read, Serializable

이렇게 4가지로 나뉘며, 오른쪽으로 갈수록 레벨이 높아진다.

 

  • Read Uncommitted

트랜잭션이 처리중이거나, 아직 commit되지 않은 데이터를 다른 트랜잭션이 읽는 것을 허용한다.

 

 

이렇게 그냥 다른 트랜잭션에서 처리를 하고 있더라도, 데이터를 그냥 조회해서 값을 가져온다.

하지만 조회 후, 데이터가 rollback 되어 버리면 부정확한 데이터를 가져오게 된다.

 

이런 Dirty Read가 발생할 수 있기 때문에, 정확도가 너무 낮아 표준에서 인정하지 않는 격리 수준이라고 한다.

 

  • Read Committed

커밋된 데이터만 조회할 수 있도록 한다.

그렇기에 다른 트랜잭션이 접근 할 수 없어 대기하게 된다.

그렇기에 commit 되기 전의 데이터를 읽는 Dirty Read는 발생하지 않게 된다.

 

하지만, 하나의 트랜잭션에서 다른 트랜잭션의 commit에 따라 데이터의 조회 결과가 달라지는 Non-Repeatable Read 문제가 생기게 된다.

그렇지만 자주 발생하는 문제는 아니며, 대부분의 기본 설정이 이 Read Commited라고 한다.

 

  • Repeatable Read

보통의 RDBMS는 변경 전의 데이터를 Undo 공간에 백업해둔다.

그렇기에 트랜잭션의 번호를 확인해, 자신보다 더 늦은 트랜잭션의 번호가 존재한다면 이 Undo 공간에서 데이터를 조회하게 된다.

그렇기 때문에, 다른 트랜잭션에 의해 변경되더라도 동일한 데이터를 조회할 수 있게 된다.

 

하지만 조회 트랜잭션동안 새로운 데이터가 추가되는 것을 감지하지 못하는 Phantom Read가 발생할 수도 있다고 한다.

그래도 이 Phantom Read는 트랜잭션의 번호를 확인해 무시하는 방법으로 해결 할 수 있으며, 현재의 RDBMS에서는 특정 상황이 아니라면 대부분 발생하지 않는 현상이기에 크게 신경쓰지는 않아도 된다고 한다.

 

  • Serializable

마지막은 가장 강력한 Serializable 레벨이다.

그냥 모든 트랜잭션을 순차적으로 실행시키기에 위에 소개했던 모든 정합성 관련 문제들이 발생하지 않는다.

하지만 동시성이 없어지기에 성능이 매우 떨어진다.

극단적으로 안정성이 필요한 경우가 아니라면 잘 사용하지 않는다고 한다.

반응형

https://github.com/Seungkyu-Han/micro_service_webflux

 

GitHub - Seungkyu-Han/micro_service_webflux: Webflux 환경에서 MSA의 Saga, Outbox, CQRS를 연습해보기 위한 리포지

Webflux 환경에서 MSA의 Saga, Outbox, CQRS를 연습해보기 위한 리포지토리입니다. - Seungkyu-Han/micro_service_webflux

github.com

 

기존의 Saga 패턴에서 더 강화된 내용이라고 생각하면 될 것이다.

 

Saga 패턴은 긴 트랜잭션을 짧은 트랜잭션으로 나누고 process와 rollback을 사용하여 하나씩 나아가는 구조였다.

 

여기서 이벤트를 전송하고, 변경된 내용을 데이터베이스에 저장하게 되는데 만약 이벤트를 전송하고 데이터베이스에서 에러가 발생해 이벤트만 전송되게 된다면 문제가 발생할 수 있다.

 

이런 문제를 해결하기 위해 사용하는 패턴이다.

방법은 먼저 데이터베이스에 변경사항들을 저장하고, 스케줄러를 사용해 한 번에 이벤트를 전송하는 것이다.

이 때 변경사항들은 기존의 데이터베이스가 아닌, 이벤트를 위한 별도의 저장공간을 만들게 된다.

이곳에 보낼 데이터를 미리 저장해두고 나중에 보내기 때문에 보낼 편지함(Outbox)패턴이라고 불리게 된다.

그리고 모든 사항이 완료된 Outbox 데이터들은 데이터베이스의 최적화를 위해 스케줄러를 사용해서 지속적으로 삭제해준다.

 

이러한 방법을 MSA에서 Outbox 패턴이라고 한다.

 

이렇게 별도의 데이터베이스에 저장해두고 한 번에 보내게되며, 실제 서비스에서는 1~2초의 간격으로 스케줄러를 실행한다고 한다.

 

우선 아래에는 직접 작성한 Outbox 패턴으로 설명해보겠다.

 

@Component
class PaymentOutboxScheduler(
    private val paymentOutboxHelper: PaymentOutboxHelper,
    private val paymentRequestMessagePublisher: PaymentRequestMessagePublisher
): OutboxScheduler {

    private val logger = LoggerFactory.getLogger(PaymentOutboxScheduler::class.java)

    @Transactional
    @Scheduled(fixedDelay = 10000, initialDelay = 10000)
    override fun processOutboxMessages() {
        logger.info("결제를 요청하는 스케줄러가 실행되었습니다.")
        paymentOutboxHelper.getPaymentOutboxMessageByOutboxStatusAndOrderStatus(
            OutboxStatus.STARTED,
            listOf(OrderStatus.PENDING, OrderStatus.CANCELLING)
        ).publishOn(Schedulers.boundedElastic()).map{
            paymentOutboxMessage: PaymentOutboxMessage ->
            if(paymentOutboxMessage.payload.orderStatus == OrderStatus.CANCELLING) {

                paymentOutboxMessage.payload.paymentOrderStatus = PaymentOrderStatus.CANCELLING
            }
            paymentRequestMessagePublisher.publish(
                paymentOutboxMessage = paymentOutboxMessage,
                callback = ::updateOutboxStatus
            ).subscribe()
        }.subscribe()
    }

    private fun updateOutboxStatus(paymentOutboxMessage: PaymentOutboxMessage, outboxStatus: OutboxStatus): Mono<Void> {
        paymentOutboxMessage.outboxStatus = outboxStatus
        return paymentOutboxHelper.save(paymentOutboxMessage).then()
    }
}

 

주문 서버에서 결제 서버로 결제를 요청하는 과정이다.

우선 주문이 발생하면 Outbox의 상태가 Start인 값들만 조회한다.

 

처음에 Outbox에 Start로 저장을 하기 때문에 한번도 전송된 적이 없는 데이터를 불러오는 것이다.

 

그렇게 조회된 모든 데이터를 모두 publisher로 전송을 하며, 전송을 하면 callback을 사용해 Outbox의 Status를 Complete로 변경해준다.

 

publisher의 내용이다.

override fun publish(
        paymentOutboxMessage: PaymentOutboxMessage,
        callback: (PaymentOutboxMessage, OutboxStatus) -> Mono<Void>
    ): Mono<Void> {
        return mono{

            val paymentEventPayload = paymentOutboxMessage.payload

            logger.info("{} 주문에 대한 이벤트 전송을 준비 중입니다.", paymentEventPayload.orderId.toString())

            val paymentRequestAvroModel = paymentEventPayloadToPaymentRequestAvroModel(paymentEventPayload)

            reactiveKafkaProducer.send(
                paymentRequestTopic,
                paymentEventPayload.orderId.toString(),
                paymentRequestAvroModel
            ).publishOn(Schedulers.boundedElastic()).map{
                callback(paymentOutboxMessage, OutboxStatus.COMPLETED).subscribe()
            }.doOnError{
                callback(paymentOutboxMessage, OutboxStatus.FAILED).subscribe()
            }.subscribe()

            logger.info("{}의 주문이 메시지 큐로 결제 요청을 위해 전송되었습니다", paymentEventPayload.orderId.toString())

        }.then()
    }

 

이렇게 해당 데이터를 model로 변환하여 kafka로 전송을 하고, 전송 상태의 여부에 따라 callback을 사용하여 outbox의 상태를 변환한다.

 

결제 서버의 내용은 작성하지 않겠지만, 결제 서버에서도 수신 받은 내용에 따라 내용을 처리하고 kafka로 전송할 데이터를 outbox에 저장해주면 된다.

 

이제 여기서 문제가 생기게 된다.

 

스케줄러를 사용하기 때문에 특정 시간에만 동기화가 이루어지게 되며, 해당 스케줄러가 동작하는 시간에만 CPU의 사용량이 늘어나게 된다.

 

이러한 방법을 해결하기 위해 마지막으로 CDC 패턴을 사용한다고 한다.

CDC와 관련된 내용은 CQRS 다음에 작성해보도록 하겠다.

반응형

프로젝트에서 DeepL API를 사용해 PDF를 번역하는 기능이 추가되었다.

https://developers.deepl.com/docs

 

Introduction | DeepL API Documentation

Learn more about the DeepL API's capabilities and common use cases.

developers.deepl.com

 

우선 DeepL의 공식문서이다.

처음에는 늘 그렇듯 curl을 사용해 파일을 보내고, 응답을 받을 것이라고 생각했지만 자바쪽으로 지원해주는 라이브러리가 있었다.

 

 

자바 라이브러리의 주소는 다음과 같다.

https://github.com/DeepLcom/deepl-java

 

GitHub - DeepLcom/deepl-java: Official Java library for the DeepL language translation API.

Official Java library for the DeepL language translation API. - DeepLcom/deepl-java

github.com

 

요즘에는 자료가 많이 없는 기능들을 사용하다보니, 이렇게 깃허브에 직접 찾아들어가 사용방법을 찾아보는 일이 많아졌다.

 

implementation("com.deepl.api:deepl-java:1.9.0")

 

현재 기준으로 가장 최신 버전인 해당 라이브러리를 추가해준다.

 

처음에는 해당 문서처럼 Translator를 생성하려고 했지만, 해당 기능은 Deprecated 되었다고 한다.

(그러면 문서 좀 수정해주지...)

 

지금은 DeepLClient 클래스를 생성하고, 해당 생성자에 DeepL의 API 키를 넘겨주면 된다.

 

DeepLClient(deepLKey)

 

해당 클래스에서도 translateDocument라는 함수를 사용할 것이다.

 

해당 함수를 오버로딩하고 있는 함수들은 다음과 같다.

    public DocumentStatus translateDocument(File inputFile, File outputFile, @Nullable String sourceLang, String targetLang, @Nullable DocumentTranslationOptions options) throws DocumentTranslationException, IOException {
        try {
            if (outputFile.exists()) {
                throw new IOException("File already exists at output path");
            } else {
                InputStream inputStream = new FileInputStream(inputFile);

                DocumentStatus var8;
                try {
                    OutputStream outputStream = new FileOutputStream(outputFile);

                    try {
                        var8 = this.translateDocument(inputStream, inputFile.getName(), outputStream, sourceLang, targetLang, options);
                    } catch (Throwable var12) {
                        try {
                            outputStream.close();
                        } catch (Throwable var11) {
                            var12.addSuppressed(var11);
                        }

                        throw var12;
                    }

                    outputStream.close();
                } catch (Throwable var13) {
                    try {
                        inputStream.close();
                    } catch (Throwable var10) {
                        var13.addSuppressed(var10);
                    }

                    throw var13;
                }

                inputStream.close();
                return var8;
            }
        } catch (Exception exception) {
            outputFile.delete();
            throw exception;
        }
    }

    public DocumentStatus translateDocument(File inputFile, File outputFile, @Nullable String sourceLang, String targetLang) throws DocumentTranslationException, IOException {
        return this.translateDocument((File)inputFile, (File)outputFile, (String)sourceLang, targetLang, (DocumentTranslationOptions)null);
    }

    public DocumentStatus translateDocument(InputStream inputStream, String fileName, OutputStream outputStream, @Nullable String sourceLang, String targetLang, @Nullable DocumentTranslationOptions options) throws DocumentTranslationException {
        DocumentHandle handle = null;

        try {
            handle = this.translateDocumentUpload(inputStream, fileName, sourceLang, targetLang, options);
            DocumentStatus status = this.translateDocumentWaitUntilDone(handle);
            this.translateDocumentDownload(handle, outputStream);
            return status;
        } catch (Exception exception) {
            throw new DocumentTranslationException("Error occurred during document translation: " + exception.getMessage(), exception, handle);
        }
    }

    public DocumentStatus translateDocument(InputStream inputFile, String fileName, OutputStream outputFile, @Nullable String sourceLang, String targetLang) throws DocumentTranslationException {
        return this.translateDocument(inputFile, fileName, outputFile, sourceLang, targetLang, (DocumentTranslationOptions)null);
    }

 

File을 넘기는 함수가 아닌 inpuStream을 넘기는 함수를 사용할 것이며, 사용 가능한 언어의 종류는 DeepL의 공식문서에 나와있다.

 

여기서 sourceLang은 Null이 가능하다.

현재 문서에 대한 정보를 주지 않아도, 번역을 해보니 sourceLang을 지정해 줄 때와 똑같은 결과가 나왔었다.

 

해당 함수를 사용하면 byteOutputStream이 나오게 된다.

해당 byteOutputStream을 byteArray로 바꾸어서 controller에서 응답해주면 된다.

@Service
class DeepLManager(
    @Value("\${deepL.key}")
    private val deepLKey: String
) {

    fun translateDocument(inputStream: InputStream, fileName: String, targetLang: LanguageEnum): ByteArray {
        ByteArrayOutputStream().use{
            byteArrayOutputStream ->
            DeepLClient(deepLKey).translateDocument(
                inputStream,
                fileName,
                byteArrayOutputStream,
                null,
                targetLang.targetLang,
            )
            return byteArrayOutputStream.toByteArray()
        }
    }

}

 

해당 서비스는 DeepL로 부터 번역된 ByteArray를 가져오는 서비스이고

그냥 응답받은 byteArrayOutputStream에서 toByteArray만 호출하면 ByteArray로 변환된다.

 

이렇게 응답받은 ByteArray을

return translateConnector.createTranslate(
            docsId, createTranslateReq
        ).map{
            ByteArrayOutputStream().use{

            }
            ResponseEntity.ok()
                .contentType(
                    MediaType.APPLICATION_OCTET_STREAM
                )
                .headers{
                    header ->
                    header.contentDisposition = ContentDisposition.builder("pdf")
                        .filename("${docsId}-${createTranslateReq.targetLang.name}.pdf")
                        .build()
                }
                .body(it)
        }

 

이런 식으로 controller에서 응답해주면 된다.

이렇게 하면 지정해준 파일의 이름으로 번역된 파일을 다운받을 수 있다.

 

+ 근데 이게 생각보다 돈이 많이 나오는 거 같다.

이렇게 만들고 몇번 요청을 한 후, PM한테 금액을 확인해달라고 부탁하니 벌써 5만원이 나왔다고 한다...

다들 조심해서 사용하는 게 좋을 것 같다.......

'틔움랩' 카테고리의 다른 글

MockBean deprecated와 대체  (0) 2025.01.28
Github action을 통한 Spring CI/CD 설정  (0) 2025.01.25
반응형

MSA에서 굉장히 많이 사용하는 Kafka를 사용하려고 한다.

 

kafka에서는 이벤트를 주고 받을 때, 넘기는 데이터를 class로 정의해야 하는데 이것을 avro를 사용해서 정의해보려고 한다.

 

avro를 사용해서 resource 폴더에 json으로 포멧을 작성하고, avro를 실행하면 java의 클래스로 파일들이 생성되게 된다.

 

우선 gradle에 avro를 추가하자

 

build.gradle.kts를 사용했다.

import com.github.davidmc24.gradle.plugin.avro.GenerateAvroJavaTask

plugins {
    kotlin("jvm") version "1.9.25"
    id("com.github.davidmc24.gradle.plugin.avro") version "1.9.1"
}

repositories {
    mavenCentral()
}

dependencies {
    implementation("org.apache.avro:avro:1.12.0")
}

tasks.test {
    useJUnitPlatform()
}

kotlin {
    jvmToolchain(17)
}

avro {
    setCreateSetters(false)
}

val generateAvro:TaskProvider<GenerateAvroJavaTask> = tasks.register("generateAvro", GenerateAvroJavaTask::class.java) {
    source("src/main/resources/avro")
    setOutputDir(file("src/main/java"))
    stringType.set("String")
    enableDecimalLogicalType = true
}

tasks.named("compileJava").configure {
    dependsOn(generateAvro)
}

 

source로 src/main/resources/avro를 지정해주었다.

해당 폴더에 avsc 파일을 생성한 후에 generateAvro를 gradlew로 실행해주면 된다.

 

avsc 파일의 예시이다.

{
    "namespace": "seungkyu.food.ordering.kafka.order.avro.model",
    "type": "record",
    "name": "PaymentRequestAvroModel",
    "fields": [
        {
            "name": "id",
            "type": {
                "type": "string",
                "logicalType": "uuid"
            }
        },
        {
            "name": "sagaId",
            "type": {
                "type": "string",
                "logicalType": "uuid"
            }
        },
        {
            "name": "customerId",
            "type": {
                "type": "string",
                "logicalType": "uuid"
            }
        },
        {
            "name": "orderId",
            "type": {
                "type": "string",
                "logicalType": "uuid"
            }
        },
        {
            "name": "price",
            "type": {
                "type": "long",
                "logicalType": "long"
            }
        },
        {
            "name": "createdAt",
            "type": {
                "type": "long",
                "logicalType": "timestamp-millis"
            }
        },
        {
            "name": "paymentOrderStatus",
            "type": {
                  "type": "enum",
                  "name": "PaymentOrderStatus",
                  "symbols": ["PENDING", "CANCELLED"]
               }
        }
    ]
}

 

우선 namespace는 생성할 파일의 위치이다.

name으로 해당 클래스의 이름을 지정해준다.

 

fields이다.

name으로 해당 클래스에서 파라미터의 이름을 지정해준다.

type으로 class에서 사용할 타입과, kafka에서 사용할 타입을 명시해준다.

 

이제 gradle로 실행해보자.

지정한 패키지에 해당 클래스들이 생성되었다.

 

들어가서 확인해보니, 이렇게 직접 수정하지 말라고 하는 안내도 있었다.

 

이렇게 생성한 class로 kafka 이벤트를 주고 받을 수 있게 되었다.

'MSA' 카테고리의 다른 글

MSA에 CQRS 패턴 적용하기  (0) 2025.03.01
MSA에 Outbox 패턴 적용하기  (0) 2025.02.28
MSA에 SAGA 패턴 적용하기  (0) 2025.02.24
DDD에서 Hexagonal Architecture로 변경하기  (0) 2025.02.20
DDD의 핵심 요소  (0) 2025.02.14
반응형

https://github.com/Seungkyu-Han/Toge-do-backend

 

GitHub - Seungkyu-Han/Toge-do-backend: Toge-do 앱의 백엔드 리포지토리입니다

Toge-do 앱의 백엔드 리포지토리입니다. Contribute to Seungkyu-Han/Toge-do-backend development by creating an account on GitHub.

github.com

얼마 전에 게임회사의 면접을 보게 되었다.

해당 면접에서 Heap 영역에서의 메모리 단편화를 막기 위해서는 어떤 방법이 좋을까요?라는 질문이 있었다.

 

그 당시에는 답변을 하지 못했지만, 지금와서 생각해보면 이 ObjectPool을 적용하는 것이 답일 것 같다.

 

잊지 않기 위해 현재 진행하고 있는 프로젝트에 ObjectPool을 적용해보려 한다.

 

직접 만들어야 하나... 생각하고 있었는데

다행히 스프링에서 해당 라이브러리가 존재한다.

 

일단 Object Pool은 객체가 필요할 때, 매번 생성하는 것이 아니라 관리하고 있는 pool에서 가져오는 것을 말한다.

이렇게 Pool에서 객체를 가져오게 되면, 우선 객체를 생성하는 데 필요한 비용이 감소한다.

객체를 생성할 때, 메모리를 할당하고 객체를 생성하는 과정은 시간이 굉장히 오래 걸리기 때문에 이 과정을 아낄 수 있다.

또한, 불연속적인 Heap 메모리에 계속 할당하지 않기 때문에 메모리의 단편화가 줄어들게 된다.

찾아보니 게임업계에서 굉장히 많이 사용하는 방법인 것 같다.

 

바로 적용을 해보도록 하자.

 

    //OBJECT POOL
    implementation("org.apache.commons:commons-pool2:2.12.1")

 

해당 라이브러리를 추가해준다.

 

java에서 Object Pool을 관리해주는 라이브러리이다.

 

그 다음에는 Pool에서 관리할 객체를 넣어주도록 하자.

나는 UserDocument를 Pool에서 관리하도록 할 것이다.

 

@Component
class UserPoolComponent: BasePooledObjectFactory<UserDocument>() {

    private var a = 0

    override fun create(): UserDocument {
        a += 1
        return UserDocument(
            name = a.toString(),
            oauth = Oauth()
        )
    }

    override fun wrap(userDocument: UserDocument): PooledObject<UserDocument> {
        return DefaultPooledObject(userDocument)
    }
}

 

우선 create 메서드를 오버라이딩해서 미리 저장해둘 객체들을 만들어준다.

이 create 메서드를 통해 객체를 생성해서 pool에 저장해두게 된다.

 

wrap 메서드를 통해 해당 객체를 관리하도록 하는데, 이것도 DefaultPooledObject를 통해 라이브러리를 사용해 객체를 관리할 수 있다.

wrap을 통해 해당 객체의 대여상태, 유휴상태등을 저장하고 관리하게 된다.

 

그리고는

val userPool = GenericObjectPool(userPoolComponent, GenericObjectPoolConfig<UserDocument>().apply{
        maxTotal = 10
        minIdle = 5
    })

해당 component로 pool을 만들어서 사용한다.

 

해당 pool에서 borrowObject를 통해 객체를 대여하고, returnObject를 통해 객체를 반납한다.

당연하게도 대여한 후에 반납을 꼭 해줘야, 다음 사용자가 사용이 가능하다.

 

그리고 중요한 부분은, 반납하는 순간은 해당 함수의 종료 시점이 아니라 해당 함수를 호출한 함수가 해당 객체의 사용이 끝날 때이다.

호출당한 함수에서 반납을 해버리면, 동시성 문제가 발생 할 수 있다.

 

일단 테스트를 진행해보도록 하자.

 

 

        @Test
        @DisplayName("Object Pool 테스트")
        fun createUserObjectPoolTest(){
            //given

            for (i in 1..10){
                val a = userService.userPool.borrowObject()
                println(a)
            }

            //when

            //then
        }

 

이렇게 10개를 반납하지 않고, 호출해보았다.

객체들의 이름은 알기 쉽도록 1부터 10까지 차례로 생성해보았다.

 

이렇게 반납을 하지 않으니, 계속 객체풀에서 다음 객체가 대여되어 다른 이름을 가지는 것을 볼 수 있다.

 

        @Test
        @DisplayName("Object Pool 테스트")
        fun createUserObjectPoolTest(){
            //given

            for (i in 1..10){
                val a = userService.userPool.borrowObject()
                println(a)
                userService.userPool.returnObject(a)
            }

            //when

            //then
        }

 

이렇게 반납을 하면서 출력을 해보니,

 

하나의 객체만을 사용해서 출력이 되는 것을 볼 수 있다.

 

Object Pool이 객체 생성에 대한 비용을 아낄 수는 있지만, 해당 객체의 사용 종료 시점을 추적해야 하는 등 생각보다 사용이 어렵다.

동시성 문제를 피하기 위해서는 정확하게 추적이 가능할 때만 사용하도록 하자.

반응형

전날에 작성해 놓은 테스트를 추가하기 위해, 아침부터 테스트 코드를 작성하던 중

@MockBean이 적용되지 않는 문제가 발생했다.

 

아예 사용할 수 없도록 되어버린 것 같고, 대체가 어떻게 되었을까 찾아보다가 아래와 같은 글을 발견했다.

 

https://stackoverflow.com/questions/79243535/what-is-the-replacement-for-the-deprecated-mockbeans-in-springboot-3-4-0

 

What is the replacement for the deprecated @MockBeans in SpringBoot 3.4.0?

We defined an annotation for our tests to avoid to declare Mockito Mocks for all the test classes. @Target({ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) @Documented @Inherited @MockBeans(...

stackoverflow.com

 

@MockitoBean

이것으로 대체가 되었다고 한다.

 

 

이렇게 하니 적용이 되는 것을 볼 수 있다.

 

다른 프로젝트에서는 되던 것이, 버전이 바뀌었다고 되지 않아서 깜짝 놀랐었다...

반응형

프로젝트를 시작할 때마다, 가장 먼저하는 CI/CD 설정이다.

기존에는 대부분 Jenkins를 통해 CI/CD 설정을 했었지만, 이번 프로젝트는 별도의 서버를 구성할 여유가 없어 Github에서 제공하는 Github action을 통해 설정을 해보려고 한다.

 

우선 Github의 리포지토리에서 actions 탭을 클릭한다.

그러면 이런 식으로 페이지가 나올텐데, 여기에 gradle을 검색한다.

 

그리고 이 action을 configure해준다.

 

이거 클릭하면 코드의 .github 폴더에 yml 파일이 생길텐데, 해당 파일을 수정해주면 된다.

 

크게 build와 deploy로 나뉜다.

build는 Gradle을 바탕으로 spring을 빌드하는 부분이고, deploy는 빌드된 jar파일을 서버로 전송하여 배포하는 부분이다.

 

name: CI/CD for develop branch

on:
  push:
    branches: [ "develop" ]
  pull_request:
    branches: [ "develop" ]
  workflow_dispatch:
    

jobs:
  build:

    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
    - uses: actions/checkout@v4
    - name: Set up JDK 17
      uses: actions/setup-java@v4
      with:
        java-version: '17'
        distribution: 'temurin'
    - name: Setup Gradle
      uses: gradle/actions/setup-gradle@af1da67850ed9a4cedd57bfd976089dd991e2582

    - name: Grant execute permission for gradlew
      run: chmod +x ./gradlew
      working-directory: ./WAS

    - name: Build with Gradle Wrapper
      run: ./gradlew build
      working-directory: ./WAS
      
    - name: Scp to EC2
      uses: appleboy/scp-action@master
      with:
        host: ${{ secrets.SERVER_IP }}
        username: ${{secrets.SSH_USER}}
        key: ${{secrets.SSH_PRIVATE_KEY}}
        source: |
          WAS/build/libs/{빌드된 파일 이름}.war
        target: was/
      
  deploy:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - name: Setup SSH
        uses: webfactory/ssh-agent@v0.5.4
        with:
          ssh-private-key: ${{secrets.SSH_PRIVATE_KEY}}
          
      - name: Deploy Docker Container
        run: |
          ssh -o StrictHostKeyChecking=no ${{secrets.SSH_USER}}@${{secrets.SERVER_IP}} << 'EOF'
          cd was
          docker stop {컨테이너 이름} && docker rm {컨테이너 이름} && docker rmi {이미지 이름}:latest
          docker build -t {이미지 이름} .
          docker-compose up -d
          EOF

 

우선 작성한 파일은 다음과 같다.

 

step에서 하나씩 뜯어보도록 하자.

  • build
    - name: Set up JDK 17
      uses: actions/setup-java@v4
      with:
        java-version: '17'
        distribution: 'temurin'
    - name: Setup Gradle
      uses: gradle/actions/setup-gradle@af1da67850ed9a4cedd57bfd976089dd991e2582

이 부분은 자바와 gradle을 준비하는 부분이다.

큰 이상이 없다면 아마 고정적으로 사용할 것 같다.

 

    - name: Grant execute permission for gradlew
      run: chmod +x ./gradlew
      working-directory: ./WAS

    - name: Build with Gradle Wrapper
      run: ./gradlew build
      working-directory: ./WAS

이 부분은 빌드하는 부분이다.

gradlew에 실행 권한을 부여하고, ./gradlew build를 통해 스프링부트를 빌드한다.

만약 테스트와 관련된 명령어가 있다면, 이곳에 추가하면 된다.

 

아래를 보면 working-directory가 설정되어 있는 것을 볼 수 있는데, 만약 빌드하는 폴더가 루트 폴더가 아니라면 해당 명령어를 통해 폴더를 지정해주어야 한다.

 

    - name: Scp to EC2
      uses: appleboy/scp-action@master
      with:
        host: ${{ secrets.SERVER_IP }}
        username: ${{secrets.SSH_USER}}
        key: ${{secrets.SSH_PRIVATE_KEY}}
        source: |
          WAS/build/libs/{빌드된 파일}.war
        target: was/

이제 빌드된 jar(war) 파일을 서버로 전송하는 부분이다.

당연히 서버의 ip, 사용자 이름, pem 키가 필요하다.

이런 부분은 yml 파일에 작성할 수 없기 때문에, 해당 Repository의 설정에 환경변수로 추가해주도록 하자.

 

pem키를 환경변수로 추가할 때는 위, 아래 boundary까지 그냥 추가해주면 된다.

source는 빌드된 파일(결과물)이고, target은 해당 서버에서 어떤 폴더에 저장할 것인지를 적어주면 된다.

 

이렇게 작성하면 빌드 후, 파일이 서버에 저장되게 된다.

그럼 이제 해당 파일을 통해 배포를 설정해주어야 한다.

 

 

  • deploy

해당 jar 파일을 docker container를 통해 실행해보자.

 

    needs: build

needs에 build를 추가해서, 해당 작업이 build 이후에 이루어질 수 있도록 해준다.

 

 

      - name: Setup SSH
        uses: webfactory/ssh-agent@v0.5.4
        with:
          ssh-private-key: ${{secrets.SSH_PRIVATE_KEY}}

서버에서 사용할 pem키를 등록해준다.

 

      - name: Deploy Docker Container
        run: |
          ssh -o StrictHostKeyChecking=no ${{secrets.SSH_USER}}@${{secrets.SERVER_IP}} << 'EOF'
          cd was
          docker stop {컨테이너 이름} && docker rm {컨테이너 이름} && docker rmi {이미지 이름}:latest
          docker build -t {이미지 이름} .
          docker-compose up -d
          EOF

 

그리고는 이제 원격 서버에 접속해 해당 파일을 바탕으로 도커를 빌드한다.

원격 서버에서 추가적으로 실행하고 싶은 부분이 있다면, 저 안에 추가해주면 된다.

 

이제 작성이 모두 완료되었으니, 실행해보도록 하자.

위의 on에 workflow_dispatch:를 지정해주면 직접 실행 할 수도 있다.

 

 

이렇게 초록색으로 체크표시가 된다면 성공한 것이다.

서버에서도 배포가 완료된 것을 확인 할 수 있었다.

'틔움랩' 카테고리의 다른 글

Spring에서 DeepL API를 사용해 문서를 번역하기  (0) 2025.02.27
MockBean deprecated와 대체  (0) 2025.01.28
반응형

https://github.com/Seungkyu-Han/Toge-do-backend

 

GitHub - Seungkyu-Han/Toge-do-backend: Toge-do 앱의 백엔드 리포지토리입니다

Toge-do 앱의 백엔드 리포지토리입니다. Contribute to Seungkyu-Han/Toge-do-backend development by creating an account on GitHub.

github.com

프론트 팀원에게 에러가 발생한다고 연락이 왔다.

 

유효하지 않은 토큰으로 채팅을 보내면 에러가 발생한다는 것이었다.

물론 당연한 말이라고 생각이 되지만, 발생하는 에러가 500 에러이기에 해당 에러는 수정을 해야했다.

 

이 부분을 수정하려고 하니 현재 웹소켓도 문제가 있다고 생각되어, 이번 기회에 교체해보려고 한다.

 

현재 서버는

이렇게 연결을 한 후에 토큰을 전송하면서 인증을 하고 사용자 정보를 추출하고 있다.

    @MessageMapping("/{groupId}")
    fun publishChatMessage(
        @Parameter(hidden = true) @Header(HttpHeaders.AUTHORIZATION) accessToken: String,
        @DestinationVariable groupId: String,
        @RequestBody messageReqDto: MessageReqDto
    ): Mono<ResponseEntity<Void>> {
        return chatService.publishMessage(
            groupId = groupId,
            userId = jwtTokenProvider.getUserId(
                headerUtil.extractAccessTokenFromHeader(accessToken)
            )!!,
            message = messageReqDto.message
        ).then(Mono.fromCallable { ResponseEntity(HttpStatus.OK) })
    }

 

 

빠르게 개발하기 위해 이렇게 만들었었는데, 사실 어차피 연결이 완료되면 더 이상 인증할 필요가 없기 때문에 그냥 connect 할 때, 한 번만 인증을 하고 해당 세션을 유지하는 것이 맞을 것이다.

 

그렇기 때문에 이렇게 교체를 해보려고 한다.

 

우선 당연하게 Jwt 토큰을 추출할 component가 존재해야 한다.

 

우선 Stomp Config에 configureClientInboundChannel을 오버라이드 받아, interceptor에 새로운 필터를 작성한다.

@Configuration
@EnableWebSocketMessageBroker
class ChatConfig(
    private val stompErrorHandler: StompErrorHandler,
    private val filterChannelInterceptor: FilterChannelInterceptor
): WebSocketMessageBrokerConfigurer {

    override fun configureClientInboundChannel(registration: ChannelRegistration) {
        registration.interceptors(filterChannelInterceptor)
    }
}

 

이제 여기에 들어가는 FilterChannelInterceptor가 연결을 시작하는 중간에 검증을 하는 역할을 한다.

@Component
class FilterChannelInterceptor(
    private val headerUtil: HeaderUtil = HeaderUtil(),
    private val jwtTokenProvider: JwtTokenProvider
): ChannelInterceptor {

    override fun preSend(message: Message<*>, channel: MessageChannel): Message<*>? {
        val stompHeaderAccessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor::class.java)

        if (stompHeaderAccessor!!.command == StompCommand.CONNECT) {
            try{
                val bearerToken = stompHeaderAccessor.getFirstNativeHeader(HttpHeaders.AUTHORIZATION)
                val token = headerUtil.extractAccessTokenFromHeader(bearerToken!!)
                val userId = jwtTokenProvider.getUserId(token)
                stompHeaderAccessor.sessionAttributes?.put("userId", userId)
            }catch(e: Exception){
                throw AuthException()
            }
        }
        return message
    }
}

 

작성한 filter이다.

 

우선 메시지로부터 StompHeaderAccessor를 가져온다.

그리고 조건문을 이용해 StompCommand의 CONNECT, ERROR, MESSAGE 등등 중에서 연결을 시작할 때인 CONNECT 일 때만 동작하도록 한다.

 

Stomp로 연결을 하더라도, Header를 작성할 수가 있기 때문에 헤더에서 JwtToken을 가져와서 사용자 아이디를 추출한다.

그 다음에 StompHeaderAccesor의 세션에 넣어주는데, 이 때 그냥 유저의 아이디만을 넣어주는 것이 아닌 Authentication을 넣어줄 수도 있다.

 

나는 현재 프로젝트에서 Authentication이 구현되어 있지 않기 때문에 그냥 userId로 정보만 넣어줬다.

 

그리고 토큰에서 사용자 아이디를 추출하는 과정에서 생기는 모든 에러는 인증 에러로 생각하여, 모든 에러를 AuthException으로 바꾸어 throw해주었다.

 

일단 이렇게하면 연결 과정에서 토큰을 사용하여 인증을 수행하고, 실패한다면 연결을 종료할 수 있다.

하지만 사용자에게 인증이 실패했다는 내용은 알려줄 수 없게 된다.

 

그래서 이제 StompErrorHandler를 작성해야 한다.

다시 Stomp Config로 가서, 웹소켓에서 발생하는 모든 에러를 처리할 error handler를 추가해준다.

@Configuration
@EnableWebSocketMessageBroker
class ChatConfig(
    private val stompErrorHandler: StompErrorHandler,
    private val filterChannelInterceptor: FilterChannelInterceptor
): WebSocketMessageBrokerConfigurer {

    override fun registerStompEndpoints(registry: StompEndpointRegistry) {
        registry.setErrorHandler(stompErrorHandler)
    }
}

 

이렇게 추가를 하고, ErrorHandler를 작성해보자.

@Configuration
class StompErrorHandler(
    private val objectMapper: ObjectMapper
): StompSubProtocolErrorHandler(){

    override fun handleClientMessageProcessingError(
        clientMessage: Message<ByteArray>?,
        ex: Throwable
    ): Message<ByteArray>? {

        if (ex is MessageDeliveryException && ex.cause is AuthException){
            val webSocketEventEnum = WebSocketEventEnum.AUTHORIZATION_ERROR

            val stompHeaderAccessor = StompHeaderAccessor.create(StompCommand.ERROR)

            stompHeaderAccessor.message = "Fail to send message"

            val errorBody = objectMapper.writeValueAsString(
                WebSocketEventDto(state = webSocketEventEnum.value, message = webSocketEventEnum.message)
            ).toByteArray(StandardCharsets.UTF_8)

            return MessageBuilder.createMessage(errorBody, stompHeaderAccessor.messageHeaders)
        }
        return super.handleClientMessageProcessingError(clientMessage, ex)
    }
}

 

우선 이때 발생하는 모든 에러는 MessageDeliveryException으로 온다.

그렇기에 에러가 발생하는 이유를 찾기 위해서는 MessageDeliveryException의 cause를 확인해야 한다.

나는 인증에서 생기는 모든 에러를 AuthException으로 처리했기 때문에, 해당 에러인지를 확인하고 그 때 에러메시지를 return하도록 했다.

 

내용을 message, body에 작성할 수 있는데, 나는 에러의 내용을 body에 적어주었다.

 

작성할 내용을 byteArray로 작성하여 넣어주면 된다.

 

이렇게 추가를 해두고, 만료된 토큰을 사용해보니

인증에 실패하면 클라이언트에게 에러메시지가 가는 것을 볼 수 있었다.

반응형

https://github.com/Seungkyu-Han/Toge-do-backend

 

GitHub - Seungkyu-Han/Toge-do-backend: Toge-do 앱의 백엔드 리포지토리입니다

Toge-do 앱의 백엔드 리포지토리입니다. Contribute to Seungkyu-Han/Toge-do-backend development by creating an account on GitHub.

github.com

 

해당 프로젝트 중에 아래와 같은 data class를 object mapper를 사용하여 읽은 후 redis에 String으로 저장하는 코드가 있었다.

package vp.togedo.model.documents.personalSchedule

import org.bson.types.ObjectId
import vp.togedo.model.exception.personalSchedule.PersonalScheduleEndTimeBeforeStartTimeException
import vp.togedo.model.exception.personalSchedule.PersonalScheduleTimeIsNotRangeException

data class PersonalScheduleElement(
    val id: ObjectId = ObjectId.get(),
    val startTime: String,
    val endTime: String,
    val name: String,
    val color: String
){
    /**
     * 해당 개인 스케줄 요소의 시작 시간이 종료 시간보다 앞인지 확인
     * @return true
     * @throws PersonalScheduleEndTimeBeforeStartTimeException 종료 시간이 시작 시간보다 앞에 있음
     */
    private fun isStartTimeBefore(): Boolean{
        if(startTime.length != endTime.length ||
            startTime > endTime)
            throw PersonalScheduleEndTimeBeforeStartTimeException()
        return true
    }

    /**
     * 해당 스케줄의 시간이 범위 내에 있는지 확인
     * @param startTimeRange 시작 범위
     * @param endTimeRange 종료 범위
     * @return true
     * @throws PersonalScheduleTimeIsNotRangeException 유효한 시간 범위가 아님
     */
    private fun isTimeRange(
        startTimeRange: String,
        endTimeRange: String): Boolean{
        if(startTime.length != startTimeRange.length ||
            endTime.length != endTimeRange.length ||
            startTime !in startTimeRange..endTimeRange ||
            endTime !in startTimeRange..endTimeRange){
            throw PersonalScheduleTimeIsNotRangeException()
        }
        return true
    }

    /**
     * 유동 스케줄의 시간이 유효한지 확인
     * @return true
     * @throws PersonalScheduleTimeIsNotRangeException 유효한 시간 범위가 아님
     * @throws PersonalScheduleEndTimeBeforeStartTimeException 종료 시간이 시작 시간보다 앞에 있음
     */
    fun isValidTimeForFlexibleSchedule(): Boolean{
        return isStartTimeBefore() &&
                //00(년)_01(월)_01(일)_00(시)_00(분) ~ 99(년)_12(월)_31(일)_23(시)_59(분)
                isTimeRange(
                    startTimeRange = "0001010000",
                    endTimeRange = "9912312359",)
    }

    /**
     * 고정 스케줄의 시간이 유효한지 확인
     * @return true
     * @throws PersonalScheduleTimeIsNotRangeException 유효한 시간 범위가 아님
     * @throws PersonalScheduleEndTimeBeforeStartTimeException 종료 시간이 시작 시간보다 앞에 있음
     */
    fun isValidTimeForFixedSchedule(): Boolean{
        return isStartTimeBefore() &&
                //1(요일)_00(시)_00(분) ~ 7(요일)_23(시)_59(분)
                isTimeRange(
                    startTimeRange = "10000",
                    endTimeRange = "72359")
    }

}

 

그리고 여기에서 고정 일정을 추가하기 위해 isValidFimeForFixedSchedule만 호출을 하고 저장을 하는데, 계속 isValidTimeForFixedSchedule이 호출되어 Exception이 발생했다.

 

디버깅을 아무리 해보아도, 해당 API에서는 Fix 검증 코드만 사용하고 있었기에 버그를 잡기가 굉장히 어려웠다.

 

그러던 중 해당 에러가, redis 저장과정에서 생긴 다는 것을 알게 되었고 object mapper의 writeValueAsString에서 에러가 발생했다.

 

아니....왜 직렬화만 하라니까 함수를 호출하지?라고 생각을 하며 이유를 찾아보고 있었는데 함수의 이름 때문이었다.

 

직렬화 과정에서 함수 앞에 "is"가 붙어있으면 호출하여 해당 함수로 조건검사를 시도한다는 것이었다.

 

지금 함수명들이 IsValidTime이기 때문에 해당 함수들로 조건검사를 했던 것이다.

 

이 방법을 알고 있지 않았기 때문에 직접 조건검사를 하고 추가를 해주었고, 지금은 이 조건검사를 해제해야 했다.

 

@JsonIgnore

 

함수들에 이 annotation을 붙여주면 된다.

직렬화를 하지 않겠다는 것이다.

 

다음에는 굳이 ignore가 아닌, 직렬화를 조건검사를 한 후에 할 수도 있겠다라는 생각이 들었다.

+ Recent posts