들어가며
이벤트 발행 시, 트랜잭션을 어떻게 처리할 것인가에 대해 고민하던 중 Spring에서 트랜잭션 처리 후에 이벤트를 작동시키는 마법 같은 클래스가 있다는 것을 알게 되었습니다.
이에 해당 어노테이션이 무엇인지, 어떤 역할을 하는지 파악하려합니다.
먼저, 이벤트가 무엇인지 정의 및 설명하며 시작하겠습니다.
Event란?
시스템에서 발생한 의미 있는 사건 또는 상태 변화를 뜻합니다.
‘사용자 가입 완료’, ‘주문 생성’, ‘상품 재고 부족’등이 이벤트라고 불릴 수 있습니다.
즉, 이벤트는 특정 비즈니스 프로세스가 완료되거나 중요한 상태 변경이 일어났음을 나타냅니다. (예: '회원가입이라는 비즈니스 프로세스가 성공적으로 완료되었다').
이벤트는 다음과 같은 정보들을 포함합니다.
- 발생한 사실: 어떤 일이 발생했는가? (예: 회원 가입 완료)
- 관련 데이터: 해당 사건과 관련된 정보 (예: 유저 정보, 이메일 등)
- 발생 시각: 언제 사건이 발생했는지
- 이벤트 소스 (선택적): 누가, 무엇이 이벤트를 발생시켰는지
시스템의 한 부분(발행자, Publisher)에서 이벤트가 발생하면, 이 이벤트에 관심 있는 다른 부분(구독자, Subscriber 또는 리스너, Listener)들이 이를 감지하고 필요한 처리를 수행하게 됩니다.
Event를 왜 사용해야 하는가?
시스템 간의 결합도를 낮추고 유연성과 확장성을 높이기 용이합니다.
- 낮은 결합도
- 이벤트를 발행하는 컴포넌트는 어떤 컴포넌트가 이벤트를 구독하는지 알 필요가 없습니다.
- 반대로 구독자는 발행자가 누구인지 직접적으로 의존하지 않아도 됩니다.
- 각 컴포넌트의 독립적인 개발과 유지보수를 용이하게 합니다.
- 확장성
- 새로운 기능(예: 회원가입 후 특정 작업 추가)이 필요할 때, 기존 코드를 수정하는 대신 새로운 이벤트 리스너를 추가하여 시스템을 쉽게 확장할 수 있습니다.
- 만약 추후에 회원가입 후, 이메일 발송, 분석 시스템에 신규 유저 정보 전달 등 수행할 작업이 추가된다고 한다면, 코드 수정 없이 해당 이벤트를 구독하는 새로운 리스너들만 추가하면 된다.
- 비동기 처리
- 이벤트를 사용하면 특정 작업(예: 쿠폰 발급, 이메일 발송)을 즉시 처리하지 않고 나중에 또는 백그라운드에서 비동기적으로 처리할 수 있어, 시스템 전체의 응답성을 향상할 수 있습니다.
- 관심사 분리 (SoC)
- 각 컴포넌트는 자신의 핵심 책임에만 집중하고, 부가적인 작업은 이벤트를 통해 다른 컴포넌트에 위임할 수 있습니다.
비동기 이벤트와 데이터베이스 간의 정합성 고민
이벤트를 비동기적으로 처리할 때, 특히 데이터베이스 트랜잭션과 연관될 경우 데이터 정합성 문제가 발생할 수 있습니다.
회원가입 시 쿠폰을 발급하는 시나리오를 예로 들어보겠습니다.
- UserService의 SignUp 메서드 (
@Transactional
로 실행 중)가 시작 - 데이터베이스에 사용자 정보가 저장(아직 트랜잭션 커밋 전).
UserSignUpEvent
발행.- CouponService의(일반적인) 이벤트 리스너가 이벤트를 즉시 감지하고, "오! 뉴비다!" 하며 데이터베이스에 해당 사용자를 위한 환영 쿠폰을 발급하고 저장 (이 작업도 별도 트랜잭션이거나 즉시 커밋될 수 있음).
- 그런데 UserService의
SignUp
메서드 로직 중 후반부에서 예상치 못한 예외 (예: 내부 검증 실패, 외부 API 호출 실패 등)가 발생. - 결국 UserService의 트랜잭션은 롤백. 데이터베이스에서 방금 저장하려던 사용자 정보는 사라짐
- 데이터베이스에는 존재하지 않는 유령 회원을 위한 쿠폰이 발급되어 남아있게 되며, 데이터 불일치 상태 발생.
이벤트가 있을 때 트랜잭션을 어떻게 묶어야 하는가?
핵심은 이벤트 기반 후속 작업이 주 작업의 트랜잭션 결과에 따라 실행되도록 보장하는 것입니다. 즉, 회원 가입이라는 주 작업이 데이터베이스에 최종적으로 확정(커밋)되었을 때만 후속 작업(쿠폰 발급 등)이 수행되어야 합니다.
- 트랜잭션 성공 시: 이벤트 리스너가 실행되어야 한다. (회원 가입 시, 회원 정보가 DB에 성공적으로 커밋된 후에 쿠폰이 발급되어야 함)
- 트랜잭션 실패 시: 이벤트 리스너가 실행되지 않거나, 실패에 따른 별도의 보상 트랜잭션 또는 알림이 실행되어야 한다.
이벤트 처리 로직을 주 트랜잭션의 생명주기에 동기화시키는 것이 중요합니다.
이벤트 발행 자체는 트랜잭션 내에서 이루어지되, 해당 이벤트를 처리하는 리스너의 실행 시점은 트랜잭션의 최종 상태 이후로 제어할 필요가 있습니다.
TransactionalEventListener란?
드디어, 이번 글의 목적이었던 TransactionalEventListener에 대해 설명해 보겠습니다.
스프링 프레임워크의 TransactionalEventListener를 사용하면, 이벤트 리스너가 트랜잭션의 특정 단계(Phase)에 맞춰 실행되도록 선언적으로 설정할 수 있어 앞서 언급된 정합성 문제를 효과적으로 해결할 수 있습니다.
Phase 종류
- AFTER_COMMIT (ex: 주문 완료 후 재고 감소)
- 주 트랜잭션이 성공적으로 커밋된 후에만 실행
- 데이터 정합성이 중요한 후속 처리 가능
- 안전하고 일반적인 상태
- AFTER_ROLLBACK (ex: 주문 실패 시 임시 데이터 정리)
- 주 트랜잭션이 롤백된 후에 실행
- 주 트랜잭션이 실패했을 때의 보상 작업
- 롤백 상황에 대한 알림이나 로깅
- 해당 리스너 자체에서 예외가 발생해도 주 트랜잭션은 이미 롤백된 상태
- AFTER_COMPLETION (ex: 사용된 리소스 정리, 로그 기록)
- 커밋되거나 롤백된 후, 즉 완료된 후 실행
- 트랜잭션 결과와 상관없이 항상 수행되어야 하는 작업
- 리소스 정리, 통계 수집, 모니터링 등
- BEFORE_COMMIT (ex: 최종 데이터 유효성 검사)
- 커밋되기 전에 실행, 리스너에서 오류가 발생하면 주 트랜잭션도 롤백됨
- 주 트랜잭션과 함께 원자적으로 처리되어야 하는 작업
- 가능한 한 빠르고 안전한 작업만 수행
- 외부 메시징 시스템(RabbitMQ, Kafka, SQS)에 메시지를 발송하는 것은 정합성을 보장하지 않음
- Outbox Pattern, AFTER_COMMIT, Saga Pattern, SPC 등의 방법 사용 필요
예제
public class UserSignUpEvent {
private final String userId;
private final String email;
}
@Service
public class UserService {
private final UserRepository userRepository;
private final ApplicationEventPublisher eventPublisher;
@Transactional
public User signUp(String username, String email) {
User user = new User(username, email);
User savedUser = userRepository.save(user);
UserSignUpEvent event = new UserSignUpEvent(savedUser.getId(), savedUser.getEmail());
// 아직 커밋 되기 이전이므로, 발행되었지만 처리는 지연될 수 있음
eventPublisher.publishEvent(event);
return savedUser; // 트랜잭션이 성공적으로 커밋되면, 이 시점 이후에 AFTER_COMMIT 리스너가 동작
}
}
@Component
public class CouponService {
private CouponRepository couponRepository;
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
// 쿠폰 저장도 별도의 트랜잭션으로 처리해 정합성을 보장
@Transactional
public void handleSignUpEvent(UserSignUpEvent event) {
Coupon welcomeCoupon = new Coupon(event.getUserId(), "WELCOME_COUPON_CODE", ...);
couponRepository.save(welcomeCoupon);
// 만약 여기서 예외 발생 시, 쿠폰 발급 트랜잭션만 롤백됨 (회원가입은 이미 성공)
}
}
- 어떻게 동작하여 문제를 해결하는가?:
- UserService에서 사용자 정보가 DB에 저장되고 (아직 커밋 전),
SignUpEvent
가 발행됩니다. @TransactionalEventListener
는AFTER_COMMIT
으로 설정되어 있으므로, 아직handleSignUpEvent
메서드를 호출하지 않고 대기합니다.- UserService의 SignUp메서드가 모든 로직을 성공적으로 마치고, 주 트랜잭션이 데이터베이스에 최종 커밋됩니다. (회원 정보 영구 저장 완료!)
- 주 트랜잭션 커밋이 완료된 후, 스프링은 등록된
AFTER_COMMIT
리스너인CouponService
의handleSignUpEvent
메서드를 호출 - 이제
CouponService
는 안전하게 쿠폰을 발급합니다. 쿠폰 발급 로직 자체도@Transactional
로 선언되어 있으므로, 쿠폰 발급 중 문제가 생기면 쿠폰 발급 작업만 롤백됩니다 (이미 가입된 회원 정보에는 영향 없음).
- UserService에서 사용자 정보가 DB에 저장되고 (아직 커밋 전),
- 만약
UserService
트랜잭션이 롤백된다면?:AFTER_COMMIT
단계는 영원히 오지 않으므로,handleSignUpEvent
메서드는 절대 호출되지 않습니다. 따라서 유령 쿠폰이 발급될 걱정이 없습니다.
CouponService
의handleSignUpEvent
메서드에서 예외가 발생하면?UserService
의 회원 가입 트랜잭션은 이미 성공적으로 커밋된 상태이므로 영향을 받지 않습니다.handleSignUpEvent
메서드가@Transactional
로 선언되어 있다면, 해당 쿠폰 발급 트랜잭션만 롤백됩니다.- 이 실패는 별도로 처리해야 합니다. 예를 들어, Spring의
@Retryable
을 사용하여 몇 번 더 시도하거나, 실패한 쿠폰 발급 요청을 별도의 큐에 저장하여 나중에 수동/자동으로 처리하는 등의 후속 조치가 필요할 수 있음.
TransactionalEventListener 작동?
해당 라이브러리 코드를 열어보면서, 어떻게 작동하는지 한 번 파악해보려고 합니다.
코드를 뜯어본 경험이 많지 않아서, 잘못된 정보가 있을 수 있습니다. 틀린 점이 있다면 댓글 부탁드립니다.
TransactionalEventListener
모든 것은 개발자가 리스너 메서드에 이 어노테이션을 붙이는 것에서 시작합니다.
@Target({ElementType.METHOD, ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@EventListener
public @interface TransactionalEventListener {
// Default 값이 AFTER_COMMIT으로 되어 있음
TransactionPhase phase() default TransactionPhase.AFTER_COMMIT;
...
// Default 값이 false으로 되어 있음
// 트랜잭션이 진행 중이지 않을 때 이벤트를 처리해야 하는지 여부를 결정
// true로 설정하면 트랜잭션이 없을 경우 일반 @EventListener처럼 즉시 실행
@AliasFor(annotation = EventListener.class, attribute = "defaultExecution")
boolean fallbackExecution() default false;
}
이 어노테이션은 리스너가 어떤 트랜잭션 단계(phase)에 반응할지, 트랜잭션이 없을 때 어떻게 동작할지(fallbackExecution) 등을 정의합니다.
+ phase의 기본값은 AFTER_COMMIT이고, fallbackExecution은 기본적으로 false입니다. 즉, 별다른 설정 없이 사용하면 트랜잭션 커밋 후에만 실행되고, 트랜잭션이 없으면 무시됩니다
TransactionalApplicationListenerMethodAdapter
Spring 컨테이너는 @TransactionalEventListener가 붙은 메서드를 발견하면, 해당 메서드를 감싸는 TransactionalApplicationListenerMethodAdapter 인스턴스를 생성합니다. 이 어댑터는 이벤트 수신 시 초기 처리를 담당합니다.
public class TransactionalApplicationListenerMethodAdapter extends ApplicationListenerMethodAdapter
implements TransactionalApplicationListener<ApplicationEvent> {
private final TransactionPhase transactionPhase;
private final List<SynchronizationCallback> callbacks = new CopyOnWriteArrayList<>();
public TransactionalApplicationListenerMethodAdapter(String beanName, Class<?> targetClass, Method method) {
super(beanName, targetClass, method); // 부모 클래스 ApplicationListenerMethodAdapter 생성자 호출
TransactionalEventListener eventAnn =
AnnotatedElementUtils.findMergedAnnotation(getTargetMethod(), TransactionalEventListener.class);
if (eventAnn == null) {
throw new IllegalStateException("No TransactionalEventListener annotation found on method: " + method);
}
this.transactionPhase = eventAnn.phase(); // 여기서 사용자가 지정한 phase (예: AFTER_COMMIT)를 읽어 저장
}
...
@Override
public void onApplicationEvent(ApplicationEvent event) {
// 1. TransactionalApplicationListenerSynchronization.register를 호출하여 동기화 시도
if (TransactionalApplicationListenerSynchronization.register(event, this, this.callbacks)) {
// 동기화 등록 성공 시 (트랜잭션 활성 상태)
if (logger.isDebugEnabled()) {
logger.debug("Registered transaction synchronization for " + event);
}
// 여기서 끝. 실제 리스너 실행은 트랜잭션 완료 후로 "지연"됨.
}
else if (isDefaultExecution()) { // 2. 동기화 등록 실패했고, fallbackExecution == true 인 경우
// ... (AFTER_ROLLBACK 특별 경고 로깅) ...
if (getTransactionPhase() == TransactionPhase.AFTER_ROLLBACK && logger.isWarnEnabled()) {
logger.warn("Processing " + event + " as a fallback execution on AFTER_ROLLBACK phase");
}
processEvent(event); // 리스너 즉시 실행
}
else {
// 3. 동기화 등록 실패했고, fallbackExecution == false 인 경우
if (logger.isDebugEnabled()) {
logger.debug("No transaction is active - skipping " + event);
}
}
}
}
onApplicationEvent 메서드는 이 모든 로직의 분기점입니다. 트랜잭션이 있으면 동기화를 등록하고(실행 지연), 없으면 fallbackExecution 설정에 따라 즉시 실행하거나 무시합니다.
TransactionalApplicationListenerSynchronization
TransactionalApplicationListenerMethodAdapter가 동기화 등록을 요청하면, TransactionalApplicationListenerSynchronization 클래스가 그 요청을 받아 처리합니다. 이 클래스는 현재 트랜잭션에 "콜백"을 등록하는 역할을 합니다.
abstract class TransactionalApplicationListenerSynchronization<E extends ApplicationEvent> implements Ordered {
private final E event;
// 이벤트를 실제로 처리할 리스너 (TransactionalApplicationListenerMethodAdapter 인스턴스)
private final TransactionalApplicationListener<E> listener;
private final List<TransactionalApplicationListener.SynchronizationCallback> callbacks;
public TransactionalApplicationListenerSynchronization(E event, TransactionalApplicationListener<E> listener,
List<TransactionalApplicationListener.SynchronizationCallback> callbacks) {
this.event = event;
this.listener = listener;
this.callbacks = callbacks;
}
...
public void processEventWithCallbacks() {
this.callbacks.forEach(callback -> callback.preProcessEvent(this.event));
try {
// listener (즉, TransactionalApplicationListenerMethodAdapter)의 processEvent를 호출하여
// 최종적으로 사용자가 정의한 @TransactionalEventListener 메서드를 실행합니다.
this.listener.processEvent(this.event);
}
catch (RuntimeException | Error ex) {
this.callbacks.forEach(callback -> callback.postProcessEvent(this.event, ex));
throw ex;
}
this.callbacks.forEach(callback -> callback.postProcessEvent(this.event, null));
}
// TransactionalApplicationListenerSynchronization.register 내부
public static <E extends ApplicationEvent> boolean register(
E event, TransactionalApplicationListener<E> listener,
List<TransactionalApplicationListener.SynchronizationCallback> callbacks) {
// 플랫폼 트랜잭션(일반적인 @Transactional) 확인
if (org.springframework.transaction.support.TransactionSynchronizationManager.isSynchronizationActive() &&
org.springframework.transaction.support.TransactionSynchronizationManager.isActualTransactionActive()) {
// PlatformSynchronization 객체를 생성하여 TransactionSynchronizationManager에 등록
org.springframework.transaction.support.TransactionSynchronizationManager.registerSynchronization(
new PlatformSynchronization<>(event, listener, callbacks)); // 'listener'를 통해 phase 정보가 전달됨
return true; // 등록 성공
}
// ... (반응형 트랜잭션 처리 로직) ...
return false; // 활성 트랜잭션이 없거나 동기화가 불가능하면 false 반환
}
// Phase 확인 및 Commit / Rollback 상태 체크
private static class PlatformSynchronization<AE extends ApplicationEvent>
extends TransactionalApplicationListenerSynchronization<AE>
implements org.springframework.transaction.support.TransactionSynchronization {
public PlatformSynchronization(AE event, TransactionalApplicationListener<AE> listener,
List<TransactionalApplicationListener.SynchronizationCallback> callbacks) {
super(event, listener, callbacks);
}
// BEFORE_COMMIT phase 처리: 트랜잭션이 커밋되기 "직전"에 호출됩니다.
@Override
public void beforeCommit(boolean readOnly) {
if (getTransactionPhase() == TransactionPhase.BEFORE_COMMIT) { // Phase 확인!
processEventWithCallbacks(); // 조건 맞으면 리스너 실행 로직 호출
}
}
// AFTER_COMMIT, AFTER_ROLLBACK, AFTER_COMPLETION phase 처리:
// 트랜잭션이 "완료된 후"(커밋 또는 롤백) 호출됩니다.
@Override
public void afterCompletion(int status) { // 'status'가 커밋/롤백 정보를 담고 있음
TransactionPhase phase = getTransactionPhase(); // Phase 확인!
if (phase == TransactionPhase.AFTER_COMMIT && status == STATUS_COMMITTED) {
// Phase가 AFTER_COMMIT 이고, 트랜잭션이 성공적으로 커밋(STATUS_COMMITTED)되었으면
processEventWithCallbacks();
}
else if (phase == TransactionPhase.AFTER_ROLLBACK && status == STATUS_ROLLED_BACK) {
// Phase가 AFTER_ROLLBACK 이고, 트랜잭션이 롤백(STATUS_ROLLED_BACK)되었으면
processEventWithCallbacks();
}
else if (phase == TransactionPhase.AFTER_COMPLETION) {
// Phase가 AFTER_COMPLETION 이면, 커밋/롤백 여부와 관계없이 (트랜잭션 완료 후)
processEventWithCallbacks();
}
}
}
// ... (반응형 트랜잭션 처리 로직) ...
}
그렇다면 afterCompletion(int status)에 어떻게 값을 전달하는 걸까요?
AbstractPlatformTransactionManager
public abstract class AbstractPlatformTransactionManager
implements PlatformTransactionManager, ConfigurableTransactionManager, Serializable {
...
// 트랜잭션 커밋을 처리하는 내부 메서드
private void processCommit(DefaultTransactionStatus status) throws TransactionException {
try {
...
try {
...
}
catch (UnexpectedRollbackException ex) {
// afterCompletion 콜백을 STATUS_ROLLED_BACK 상태로 호출
triggerAfterCompletion(status, TransactionSynchronization.STATUS_ROLLED_BACK);
...
}
// 커밋 중 다른 트랜잭션 관련 예외(TransactionException)가 발생한 경우
catch (TransactionException ex) {
...
else {
// 롤백하지 않는 경우, 상태를 알 수 없음(STATUS_UNKNOWN)으로 처리
triggerAfterCompletion(status, TransactionSynchronization.STATUS_UNKNOWN);
...
}
...
}
...
}
...
finally {
// 중요! 성공적인 커밋 후 최종적으로 afterCompletion 콜백을 STATUS_COMMITTED 상태로 호출.
triggerAfterCompletion(status, TransactionSynchronization.STATUS_COMMITTED);
...
}
}
finally {
cleanupAfterCompletion(status);
}
}
private void triggerAfterCompletion(DefaultTransactionStatus status, int completionStatus) {
if (status.isNewSynchronization()) {
// 현재 트랜잭션에 등록된 모든 동기화 객체들을 가져옴
List<TransactionSynchronization> synchronizations = TransactionSynchronizationManager.getSynchronizations();
TransactionSynchronizationManager.clearSynchronization();
if (!status.hasTransaction() || status.isNewTransaction()) {
...
invokeAfterCompletion(synchronizations, completionStatus);
}
...
}
}
protected final void invokeAfterCompletion(List<TransactionSynchronization> synchronizations, int completionStatus) {
TransactionSynchronizationUtils.invokeAfterCompletion(synchronizations, completionStatus);
}
...
AbstractPlatformTransactionManager의 processCommit을 보면, 실제 DB 커밋 성공 후 finally 블록에서 triggerAfterCompletion(status, TransactionSynchronization.STATUS_COMMITTED)를 호출하는 것을 볼 수 있습니다.
해당 부분이 AFTER_COMMIT 리스너에게 신호를 줍니다.
만약 커밋 중 예외가 발생하거나 명시적 롤백이 있다면 다른 status 값(주로 STATUS_ROLLED_BACK)으로 triggerAfterCompletion이 호출됩니다.
아래 TransactionSynchronization 인터페이스를 확인해 보면, status로 사용될 상수들이 정의되어 있는 것을 볼 수 있습니다.
public interface TransactionSynchronization extends Ordered, Flushable {
/** Completion status in case of proper commit. */
int STATUS_COMMITTED = 0;
/** Completion status in case of proper rollback. */
int STATUS_ROLLED_BACK = 1;
/** Completion status in case of heuristic mixed completion or system errors. */
int STATUS_UNKNOWN = 2;
...
}
TransactionSynchronizationUtils
AbstractPlatformTransactionManager가 결정한 completionStatus와 등록된 TransactionSynchronization 객체 리스트는 TransactionSynchronizationUtils 클래스의 invokeAfterCompletion 메서드로 전달되어 최종적으로 각 콜백이 실행됩니다
public abstract class TransactionSynchronizationUtils {
...
public static void invokeAfterCompletion(@Nullable List<TransactionSynchronization> synchronizations,
int completionStatus) { // completionStatus는 AbstractPlatformTransactionManager가 결정하여 전달한 값
if (synchronizations != null) {
// 등록된 모든 TransactionSynchronization 객체에 대해 반복
for (TransactionSynchronization synchronization : synchronizations) {
try {
// 각 동기화 객체의 afterCompletion 메서드를 실제 트랜잭션 완료 status와 함께 호출.
// 이때 TransactionalApplicationListenerSynchronization$PlatformSynchronization 객체의
// afterCompletion 메서드가 호출되면서, 그 안에서 phase와 status를 비교하여 리스너를 실행함.
synchronization.afterCompletion(completionStatus);
}
...
}
}
}
이 invokeAfterCompletion 메서드가 루프를 돌면서 TransactionalApplicationListenerSynchronization$PlatformSynchronization 객체의 afterCompletion(int status)를 호출하고, 여기서 status가 STATUS_COMMITTED이고 리스너의 phase가 AFTER_COMMIT이면 드디어 우리가 정의한 이벤트 리스너 메서드가 실행됩니다.
예제 코드
sandbox-spring/src/main/java/com/pli/sandbox/domain/coupon/event/UserSignUpEventListener.java at main · chordpli/sandbox-spring
Contribute to chordpli/sandbox-spring development by creating an account on GitHub.
github.com
'Server > Spring&Spring Boot' 카테고리의 다른 글
[Spring] Spotless + Pre Commit (0) | 2023.08.20 |
---|---|
[Spring] @Controller, @Service, @Repository 어노테이션 차이점. 나만의 @Component 어노테이션 생성 (4) | 2023.04.18 |
[Spring Security] UserDetails를 User에 구현하여 사용하기. (0) | 2023.04.14 |
[Refactor] boolean을 사용하여 메서드 정리 (0) | 2023.04.01 |
[Spring] @Builder 사용시, 초기화해야할 필드가 존재할 때 발생하는 에러. @Builder will ignore the initializing expression entirely (0) | 2023.03.25 |