Spring Boot 면접: 트랜잭션 전파 설명

Spring Boot 트랜잭션 전파 마스터하기: REQUIRED, REQUIRES_NEW, NESTED 등. 코드 예제와 일반적인 함정을 포함한 12가지 면접 질문.

Spring Boot 트랜잭션 전파: 면접 질문과 실용적 예제

트랜잭션 전파는 Spring Boot의 근본적인 개념으로, 기술 면접에서 정기적으로 평가됩니다. @Transactional 어노테이션이 적용된 메서드 간에 트랜잭션이 어떻게 상호작용하는지 이해하면 운영 환경의 미묘한 버그를 피하고 견고한 아키텍처를 설계할 수 있습니다.

준비 조언

면접관들은 비즈니스 컨텍스트에 따라 적절한 전파 수준을 선택하는 능력을 평가합니다. 특정 사례에서 REQUIRED 대신 REQUIRES_NEW를 사용하는 이유를 설명할 수 있는 것이 차이를 만듭니다.

트랜잭션 전파의 기초

질문 1: Spring에서 트랜잭션 전파란 무엇입니까?

전파는 기존 트랜잭션의 컨텍스트 내에서 호출될 때 트랜잭션 메서드의 동작을 정의합니다. 이는 다음 질문에 답합니다: "@Transactional 메서드가 어노테이션이 있는 다른 메서드를 호출할 때 어떤 일이 발생합니까?"

OrderService.javajava
// 전파 개념의 시연
@Service
public class OrderService {

    private final PaymentService paymentService;
    private final OrderRepository orderRepository;

    // 부모 트랜잭션 - 새 트랜잭션 시작
    @Transactional
    public void createOrder(OrderRequest request) {
        // 현재 트랜잭션에 주문 저장
        Order order = orderRepository.save(new Order(request));

        // 다른 @Transactional 메서드 호출
        // 전파가 결정: 같은 트랜잭션 또는 새 트랜잭션?
        paymentService.processPayment(order.getId(), request.getAmount());
    }
}

@Service
public class PaymentService {

    // 기본 전파: REQUIRED
    // createOrder()의 기존 트랜잭션에 합류
    @Transactional
    public void processPayment(Long orderId, BigDecimal amount) {
        // createOrder()와 같은 트랜잭션에서 실행
        // 이 메서드가 실패하면 주문도 롤백
    }
}

Spring은 7가지 전파 수준을 제공하며, 각각 특정 비즈니스 요구사항에 적합합니다. 선택은 데이터 일관성과 성능에 직접적인 영향을 미칩니다.

질문 2: REQUIRED(기본 전파)의 동작을 설명하십시오

REQUIRED는 기본 전파입니다. 트랜잭션이 존재하면 메서드가 합류합니다. 그렇지 않으면 새 트랜잭션이 생성됩니다. 이는 가장 일반적이고 직관적인 동작입니다.

UserService.javajava
// REQUIRED: 기본 동작
@Service
public class UserService {

    private final UserRepository userRepository;
    private final AuditService auditService;

    @Transactional(propagation = Propagation.REQUIRED)
    public void updateUser(Long userId, UserUpdateRequest request) {
        // 존재하지 않으면 트랜잭션 시작
        User user = userRepository.findById(userId).orElseThrow();
        user.setEmail(request.getEmail());
        userRepository.save(user);

        // 감사가 같은 트랜잭션에 합류
        auditService.logUpdate(userId, "EMAIL_CHANGED");
    }
}

@Service
public class AuditService {

    private final AuditLogRepository auditLogRepository;

    @Transactional(propagation = Propagation.REQUIRED)
    public void logUpdate(Long userId, String action) {
        // updateUser()의 트랜잭션에 합류
        // 함께 커밋 또는 롤백
        auditLogRepository.save(new AuditLog(userId, action, Instant.now()));
    }
}

다음 다이어그램은 트랜잭션 흐름을 보여줍니다:

text
updateUser()가 TX-1 시작
    ├── save(user)          → TX-1
    └── logUpdate()         → TX-1에 합류 (REQUIRED)
           └── save(audit)  → TX-1

logUpdate() 실패 시 → TX-1 롤백 → user와 audit 모두 취소

질문 3: REQUIRED 대신 REQUIRES_NEW를 언제 사용합니까?

REQUIRES_NEW는 기존 트랜잭션을 일시 중단하고 독립적인 새 트랜잭션을 생성합니다. 부모 트랜잭션의 결과와 관계없이 작업을 커밋해야 할 때 유용합니다.

PaymentService.javajava
// REQUIRES_NEW: 독립적인 트랜잭션
@Service
public class PaymentService {

    private final PaymentRepository paymentRepository;
    private final PaymentAuditService auditService;

    @Transactional
    public void processPayment(Long orderId, BigDecimal amount) {
        Payment payment = new Payment(orderId, amount);
        paymentRepository.save(payment);

        // 결제가 나중에 실패해도 감사는 반드시 저장되어야 함
        auditService.logPaymentAttempt(orderId, amount);

        // 감사 후 오류 시뮬레이션
        if (amount.compareTo(BigDecimal.ZERO) < 0) {
            throw new InvalidAmountException("Negative amount");
        }
    }
}

@Service
public class PaymentAuditService {

    private final PaymentAuditRepository auditRepository;

    // REQUIRES_NEW: 부모 트랜잭션과 독립적으로 커밋
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void logPaymentAttempt(Long orderId, BigDecimal amount) {
        // 새 트랜잭션 TX-2 생성
        // TX-1 (processPayment)는 일시 중단
        auditRepository.save(new PaymentAudit(orderId, amount, "ATTEMPTED"));
        // TX-2는 여기서 커밋, TX-1과 독립적
    }
}

REQUIRES_NEW를 사용한 트랜잭션 흐름:

text
processPayment()가 TX-1 시작
    ├── save(payment)              → TX-1
    ├── logPaymentAttempt()        → TX-1 일시 중단
    │       └── TX-2 시작          → 새 트랜잭션
    │       └── save(audit)        → TX-2
    │       └── COMMIT TX-2        → audit 저장됨
    │       └── TX-1 재개
    └── throw InvalidAmountException
        └── ROLLBACK TX-1          → 결제 취소, 그러나 audit는 보존
교착 상태 주의

새 트랜잭션이 일시 중단된 트랜잭션에 의해 잠긴 동일한 리소스에 액세스하면 REQUIRES_NEW가 교착 상태를 일으킬 수 있습니다. 부모 트랜잭션과 동일한 테이블을 수정하기 위해 REQUIRES_NEW를 사용하지 마십시오.

질문 4: NESTED 전파를 설명하고 REQUIRES_NEW와 어떻게 다른지 설명하십시오

NESTED는 현재 트랜잭션 내에 세이브포인트를 만듭니다. 메서드가 실패하면 세이브포인트 이후의 변경 사항만 롤백되며, 부모 트랜잭션 전체가 롤백되지 않습니다.

BatchProcessingService.javajava
// NESTED: 부모 트랜잭션 내의 세이브포인트
@Service
public class BatchProcessingService {

    private final ItemRepository itemRepository;
    private final ItemProcessor itemProcessor;

    @Transactional
    public BatchResult processBatch(List<Item> items) {
        BatchResult result = new BatchResult();

        for (Item item : items) {
            try {
                // 각 항목은 세이브포인트로 처리
                itemProcessor.processItem(item);
                result.addSuccess(item.getId());
            } catch (ProcessingException e) {
                // 전체 배치가 아닌 이 항목만 롤백
                result.addFailure(item.getId(), e.getMessage());
            }
        }

        return result; // 성공한 항목 커밋
    }
}

@Service
public class ItemProcessor {

    private final ItemRepository itemRepository;

    // NESTED: 세이브포인트 생성, 부분 롤백 가능
    @Transactional(propagation = Propagation.NESTED)
    public void processItem(Item item) {
        item.setStatus("PROCESSING");
        itemRepository.save(item);

        // 비즈니스 검증
        if (!isValid(item)) {
            throw new ProcessingException("Invalid item");
            // 세이브포인트로 롤백 → 이 항목만
        }

        item.setStatus("COMPLETED");
        itemRepository.save(item);
    }
}

NESTED와 REQUIRES_NEW 비교:

text
NESTED:
├── 부모 TX 내에서 세이브포인트 사용
├── 실패 시 → 세이브포인트로 롤백
├── 부모 TX가 롤백되면 → NESTED도 롤백
└── 더 나은 성능 (새 연결 없음)

REQUIRES_NEW:
├── 완전히 독립적인 트랜잭션 생성
├── 실패 시 → 자식 TX만 롤백
├── 부모 TX가 롤백되면 → 자식 TX는 이미 커밋됨
└── 새 연결 필요
NESTED 지원

NESTED 전파에는 JDBC 세이브포인트 지원이 필요합니다. 대부분의 최신 데이터베이스(PostgreSQL, MySQL, Oracle)는 이를 지원합니다. 사용 전 호환성을 확인하십시오.

고급 전파 유형

질문 5: SUPPORTS와 NOT_SUPPORTED는 언제 사용합니까?

SUPPORTS는 기존 트랜잭션이 있으면 그 안에서 실행하고, 없으면 트랜잭션 없이 실행합니다. NOT_SUPPORTED는 기존 트랜잭션을 일시 중단하고 트랜잭션 없이 실행합니다.

ReportingService.javajava
// SUPPORTS: 선택적 트랜잭션
@Service
public class ReportingService {

    private final ReportRepository reportRepository;

    // SUPPORTS: 트랜잭션이 있거나 없거나 작동
    // 트랜잭션 보장이 필요 없는 읽기에 유용
    @Transactional(propagation = Propagation.SUPPORTS)
    public Report generateReport(Long reportId) {
        // @Transactional 메서드에서 호출 시 → 해당 TX 사용
        // 직접 호출 시 → 트랜잭션 없음 (읽기 OK)
        return reportRepository.generateComplexReport(reportId);
    }
}

@Service
public class ExternalNotificationService {

    private final ExternalApiClient apiClient;

    // NOT_SUPPORTED: 절대 트랜잭션 내부에 없음
    // 느린 외부 호출 동안 TX 차단을 방지
    @Transactional(propagation = Propagation.NOT_SUPPORTED)
    public void sendExternalNotification(String message) {
        // 호출 동안 부모 TX 일시 중단
        apiClient.send(message); // 잠재적으로 느린 HTTP 호출
        // 이후 부모 TX 재개
    }
}
OrderService.javajava
// 결합 사용 예
@Service
public class OrderService {

    private final OrderRepository orderRepository;
    private final ReportingService reportingService;
    private final ExternalNotificationService notificationService;

    @Transactional
    public void completeOrder(Long orderId) {
        Order order = orderRepository.findById(orderId).orElseThrow();
        order.setStatus("COMPLETED");
        orderRepository.save(order);

        // 같은 TX에서 보고서 생성 (SUPPORTS)
        Report report = reportingService.generateReport(orderId);

        // 트랜잭션 외부에서 외부 알림 (NOT_SUPPORTED)
        // 외부 API가 느린 경우 TX 시간 초과 방지
        notificationService.sendExternalNotification(
            "Order " + orderId + " completed"
        );
    }
}

질문 6: MANDATORY와 NEVER를 설명하십시오

MANDATORY는 기존 트랜잭션을 요구하고, 그렇지 않으면 예외를 던집니다. NEVER는 트랜잭션이 없어야 하며, 있으면 예외를 던집니다.

AuditService.javajava
// MANDATORY: 트랜잭션 내부에서 호출되어야 함
@Service
public class AuditService {

    private final AuditRepository auditRepository;

    // MANDATORY: 기존 트랜잭션 없이는 실행 거부
    // 감사가 감사된 작업과 항상 원자적임을 보장
    @Transactional(propagation = Propagation.MANDATORY)
    public void logCriticalAction(String action, Long entityId) {
        // 트랜잭션 없이 호출 시 → IllegalTransactionStateException
        auditRepository.save(new AuditLog(action, entityId, Instant.now()));
    }
}

// CacheService.java
// NEVER: 절대 트랜잭션 내부에 있어서는 안 됨
@Service
public class CacheService {

    private final CacheManager cacheManager;

    // NEVER: 캐시는 트랜잭션에 참여하지 않아야 함
    // 롤백 후 캐시와 DB 간의 불일치 방지
    @Transactional(propagation = Propagation.NEVER)
    public void invalidateCache(String cacheKey) {
        // @Transactional에서 호출 시 → IllegalTransactionStateException
        cacheManager.getCache("entities").evict(cacheKey);
    }
}
SecurityService.javajava
// MANDATORY의 올바른 사용
@Service
public class SecurityService {

    private final AuditService auditService;

    @Transactional
    public void changeUserPassword(Long userId, String newPassword) {
        // 민감한 작업...
        updatePassword(userId, newPassword);

        // 감사는 같은 트랜잭션에 있어야 함
        // MANDATORY가 이 아키텍처 제약을 강제
        auditService.logCriticalAction("PASSWORD_CHANGE", userId);
    }

    // 오류: 트랜잭션 없이 직접 호출
    public void badUsage() {
        // TX가 없어 IllegalTransactionStateException 발생
        auditService.logCriticalAction("TEST", 1L);
    }
}

Spring Boot 면접 준비가 되셨나요?

인터랙티브 시뮬레이터, flashcards, 기술 테스트로 연습하세요.

일반적인 면접 함정

질문 7: 왜 @Transactional이 내부 호출에서 작동하지 않습니까?

가장 흔한 함정 중 하나입니다. 내부 메서드 호출(self-invocation)은 Spring 프록시를 우회하여 트랜잭션 관리를 비활성화합니다.

BrokenService.javajava
// 고전적 오류: self-invocation
@Service
public class BrokenService {

    private final ItemRepository itemRepository;

    public void processItems(List<Long> itemIds) {
        for (Long id : itemIds) {
            // 함정: 내부 호출 → 프록시 우회
            // processItem()의 @Transactional 무시됨
            processItem(id);
        }
    }

    @Transactional
    public void processItem(Long itemId) {
        // 이 트랜잭션은 내부 호출에서 절대 생성되지 않음
        Item item = itemRepository.findById(itemId).orElseThrow();
        item.setStatus("PROCESSED");
        itemRepository.save(item);
    }
}

이 함정을 피하기 위한 솔루션:

java
// 솔루션 1: 자기 주입
@Service
public class FixedServiceWithSelfInjection {

    private final ItemRepository itemRepository;

    @Lazy
    @Autowired
    private FixedServiceWithSelfInjection self; // 자기 주입

    public void processItems(List<Long> itemIds) {
        for (Long id : itemIds) {
            // 프록시를 통한 호출 → @Transactional 작동
            self.processItem(id);
        }
    }

    @Transactional
    public void processItem(Long itemId) {
        // 트랜잭션이 올바르게 생성됨
        Item item = itemRepository.findById(itemId).orElseThrow();
        item.setStatus("PROCESSED");
        itemRepository.save(item);
    }
}

// 솔루션 2: 두 서비스로 분리 (권장)
@Service
public class ItemOrchestrator {

    private final ItemProcessor processor;

    public void processItems(List<Long> itemIds) {
        for (Long id : itemIds) {
            // 다른 빈에 대한 호출 → 프록시 작동
            processor.processItem(id);
        }
    }
}

@Service
public class ItemProcessor {

    private final ItemRepository itemRepository;

    @Transactional
    public void processItem(Long itemId) {
        // 트랜잭션이 올바르게 관리됨
        Item item = itemRepository.findById(itemId).orElseThrow();
        item.setStatus("PROCESSED");
        itemRepository.save(item);
    }
}

질문 8: 예외와 롤백을 어떻게 처리합니까?

기본적으로 Spring은 RuntimeException 및 Error에서만 롤백합니다. Checked 예외는 자동 롤백을 트리거하지 않습니다.

TransactionRollbackDemo.javajava
// 예외 유형에 따른 롤백 동작
@Service
public class TransactionRollbackDemo {

    private final OrderRepository orderRepository;

    // RuntimeException → 자동 롤백
    @Transactional
    public void methodWithRuntimeException() {
        orderRepository.save(new Order());
        throw new RuntimeException("Error"); // ROLLBACK
    }

    // Checked Exception → 기본적으로 롤백 없음
    @Transactional
    public void methodWithCheckedException() throws IOException {
        orderRepository.save(new Order());
        throw new IOException("File error"); // 그래도 커밋!
    }

    // Checked 예외에서 롤백 강제
    @Transactional(rollbackFor = IOException.class)
    public void methodWithRollbackFor() throws IOException {
        orderRepository.save(new Order());
        throw new IOException("Error"); // rollbackFor 덕분에 ROLLBACK
    }

    // 롤백에서 RuntimeException 제외
    @Transactional(noRollbackFor = BusinessException.class)
    public void methodWithNoRollbackFor() {
        orderRepository.save(new Order());
        throw new BusinessException("Warning"); // 예외에도 커밋
    }
}

비즈니스 사례를 위한 권장 구성:

BaseTransactionalService.javajava
// 일관된 트랜잭션 구성
@Service
public abstract class BaseTransactionalService {

    // 모든 예외에서 롤백 (checked 및 unchecked)
    @Transactional(rollbackFor = Exception.class)
    protected void executeInTransaction(Runnable operation) {
        operation.run();
    }
}

// PaymentService.java
@Service
public class PaymentService extends BaseTransactionalService {

    private final PaymentRepository paymentRepository;

    @Transactional(
        rollbackFor = Exception.class,
        noRollbackFor = InsufficientFundsException.class
    )
    public PaymentResult processPayment(PaymentRequest request) {
        Payment payment = new Payment(request);
        paymentRepository.save(payment);

        if (request.getAmount().compareTo(getBalance()) > 0) {
            // 롤백하지 않음 - 시도 기록을 보존하고 싶음
            throw new InsufficientFundsException("Insufficient balance");
        }

        return new PaymentResult(payment.getId(), "SUCCESS");
    }
}

질문 9: 트랜잭션 격리는 전파와 어떻게 작동합니까?

격리와 전파는 상호 보완적입니다. 격리는 동시 트랜잭션 간의 데이터 가시성을 결정합니다.

IsolationDemo.javajava
// 격리 + 전파 결합
@Service
public class IsolationDemo {

    private final AccountRepository accountRepository;

    // READ_COMMITTED: 다른 트랜잭션이 커밋한 데이터 확인
    @Transactional(
        isolation = Isolation.READ_COMMITTED,
        propagation = Propagation.REQUIRED
    )
    public BigDecimal getAccountBalance(Long accountId) {
        // 트랜잭션 중 다시 읽으면 다른 값을 볼 수 있음
        return accountRepository.findById(accountId)
            .map(Account::getBalance)
            .orElse(BigDecimal.ZERO);
    }

    // REPEATABLE_READ: 트랜잭션 동안 같은 읽기 보장
    @Transactional(
        isolation = Isolation.REPEATABLE_READ,
        propagation = Propagation.REQUIRED
    )
    public void transferWithConsistentRead(Long fromId, Long toId, BigDecimal amount) {
        Account from = accountRepository.findById(fromId).orElseThrow();
        Account to = accountRepository.findById(toId).orElseThrow();

        // 다른 트랜잭션이 'from'을 수정해도,
        // 항상 초기 값 (스냅샷)을 봄
        from.debit(amount);
        to.credit(amount);

        accountRepository.save(from);
        accountRepository.save(to);
    }

    // SERIALIZABLE: 최대 격리, 동시성 없음
    @Transactional(
        isolation = Isolation.SERIALIZABLE,
        propagation = Propagation.REQUIRES_NEW
    )
    public void criticalOperation(Long accountId) {
        // 이 데이터에 대한 다른 모든 트랜잭션 차단
        // 절약하여 사용 - 성능 영향
        Account account = accountRepository.findById(accountId).orElseThrow();
        account.performCriticalUpdate();
        accountRepository.save(account);
    }
}

격리 수준 요약 표:

text
| 격리             | Dirty Read | Non-Repeatable | Phantom |
|------------------|------------|----------------|---------|
| READ_UNCOMMITTED | 가능       | 가능           | 가능    |
| READ_COMMITTED   | 아니오     | 가능           | 가능    |
| REPEATABLE_READ  | 아니오     | 아니오         | 가능    |
| SERIALIZABLE     | 아니오     | 아니오         | 아니오  |
성능 영향

격리가 엄격할수록 잠금으로 인해 성능에 영향이 더 큽니다. SERIALIZABLE은 트래픽이 많은 운영 환경에서 상당한 경쟁을 일으킬 수 있습니다.

고급 패턴

질문 10: "transactional outbox" 패턴을 어떻게 구현합니까?

outbox 패턴은 실패 시에도 데이터베이스 수정과 메시지/이벤트 전송 간의 일관성을 보장합니다.

OutboxService.javajava
// Transactional Outbox 패턴
@Service
public class OutboxService {

    private final OutboxRepository outboxRepository;

    // 비즈니스 엔티티와 같은 트랜잭션에 이벤트 저장
    @Transactional(propagation = Propagation.MANDATORY)
    public void saveEvent(String aggregateType, Long aggregateId, String eventType, String payload) {
        OutboxEvent event = OutboxEvent.builder()
            .aggregateType(aggregateType)
            .aggregateId(aggregateId)
            .eventType(eventType)
            .payload(payload)
            .status("PENDING")
            .createdAt(Instant.now())
            .build();
        outboxRepository.save(event);
    }
}

@Service
public class OrderService {

    private final OrderRepository orderRepository;
    private final OutboxService outboxService;

    @Transactional
    public Order createOrder(OrderRequest request) {
        // 주문 생성
        Order order = new Order(request);
        orderRepository.save(order);

        // 같은 트랜잭션의 outbox 이벤트 (MANDATORY)
        // 커밋 성공 → 둘 다 저장
        // 롤백 → 둘 다 저장 안 됨
        outboxService.saveEvent(
            "ORDER",
            order.getId(),
            "ORDER_CREATED",
            toJson(new OrderCreatedEvent(order))
        );

        return order;
    }
}

// OutboxPublisher.java
// 이벤트를 게시하는 별도 프로세스
@Service
public class OutboxPublisher {

    private final OutboxRepository outboxRepository;
    private final MessageBroker messageBroker;

    // 각 게시에 대한 독립 트랜잭션
    @Scheduled(fixedDelay = 1000)
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void publishPendingEvents() {
        List<OutboxEvent> events = outboxRepository
            .findByStatusOrderByCreatedAt("PENDING");

        for (OutboxEvent event : events) {
            try {
                messageBroker.publish(event.getEventType(), event.getPayload());
                event.setStatus("PUBLISHED");
                event.setPublishedAt(Instant.now());
            } catch (Exception e) {
                event.setStatus("FAILED");
                event.setError(e.getMessage());
            }
            outboxRepository.save(event);
        }
    }
}

질문 11: 다양한 전파 동작을 어떻게 테스트합니까?

전파 테스트는 예상되는 트랜잭션 동작을 검증하기 위해 특별한 주의가 필요합니다.

TransactionPropagationTest.javajava
// 전파 동작 테스트
@SpringBootTest
@Transactional
class TransactionPropagationTest {

    @Autowired
    private OrderService orderService;

    @Autowired
    private PaymentService paymentService;

    @Autowired
    private TestEntityManager entityManager;

    @Test
    void required_shouldShareTransaction() {
        // Given
        Order order = orderService.createOrder(new OrderRequest());

        // When - 같은 트랜잭션의 결제 (REQUIRED)
        paymentService.processPayment(order.getId(), BigDecimal.TEN);

        // Then - 커밋 전 둘 다 표시
        entityManager.flush();
        assertThat(entityManager.find(Order.class, order.getId())).isNotNull();
        assertThat(entityManager.find(Payment.class, order.getId())).isNotNull();
    }

    @Test
    void requiresNew_shouldCommitIndependently() {
        // Given
        Long orderId = null;

        try {
            // When - REQUIRES_NEW의 감사
            orderId = orderService.createOrderWithAudit(new OrderRequest());
            throw new RuntimeException("Simulated failure after audit");
        } catch (RuntimeException e) {
            // 메인 트랜잭션 롤백
        }

        // Then - 감사 (REQUIRES_NEW)는 여전히 커밋됨
        assertThat(findAuditLog(orderId)).isNotNull();
        // 그러나 주문은 롤백
        assertThat(findOrder(orderId)).isNull();
    }

    @Test
    void mandatory_shouldThrowWithoutTransaction() {
        // Given - 활성 트랜잭션 없음

        // When/Then - 예외를 던져야 함
        assertThatThrownBy(() -> auditService.logCriticalAction("TEST", 1L))
            .isInstanceOf(IllegalTransactionStateException.class)
            .hasMessageContaining("No existing transaction");
    }
}
PropagationIntegrationTest.javajava
// 실제 롤백 및 커밋과의 통합 테스트
@SpringBootTest
class PropagationIntegrationTest {

    @Autowired
    private OrderService orderService;

    @Autowired
    private OrderRepository orderRepository;

    @Autowired
    private AuditLogRepository auditLogRepository;

    @Test
    void nested_shouldRollbackOnlyNestedOnFailure() {
        // Given
        int initialCount = orderRepository.findAll().size();

        // When - NESTED를 사용한 배치 처리
        BatchResult result = orderService.processBatchWithNested(
            List.of(validItem(), invalidItem(), validItem())
        );

        // Then - 유효한 항목만 저장
        assertThat(result.getSuccessCount()).isEqualTo(2);
        assertThat(result.getFailureCount()).isEqualTo(1);
        assertThat(orderRepository.findAll().size()).isEqualTo(initialCount + 2);
    }
}

질문 12: 트랜잭션 구성의 모범 사례는 무엇입니까?

프로젝트 수준의 일관된 트랜잭션 구성은 놀라움을 피하고 유지 관리를 용이하게 합니다.

TransactionConfig.javajava
// 중앙 집중식 트랜잭션 구성
@Configuration
@EnableTransactionManagement
public class TransactionConfig {

    // 모든 트랜잭션의 기본 시간 초과
    @Bean
    public PlatformTransactionManager transactionManager(DataSource dataSource) {
        DataSourceTransactionManager tm = new DataSourceTransactionManager(dataSource);
        tm.setDefaultTimeout(30); // 트랜잭션당 최대 30초
        return tm;
    }
}

// BaseService.java
// 서비스의 기본 어노테이션
@Service
@Transactional(
    readOnly = true, // 기본적으로 읽기 전용
    rollbackFor = Exception.class // 모든 예외에 대한 롤백
)
public abstract class BaseService {
    // 읽기 메서드는 readOnly = true 상속
}

// OrderService.java
// 일관된 구성을 가진 서비스
@Service
public class OrderService extends BaseService {

    private final OrderRepository orderRepository;

    // readOnly = true 상속
    public Order findById(Long id) {
        return orderRepository.findById(id).orElseThrow();
    }

    // 쓰기에 대한 오버라이드
    @Transactional(readOnly = false)
    public Order createOrder(OrderRequest request) {
        return orderRepository.save(new Order(request));
    }

    // 중요한 경우에 대한 명시적 구성
    @Transactional(
        readOnly = false,
        propagation = Propagation.REQUIRES_NEW,
        isolation = Isolation.SERIALIZABLE,
        timeout = 10
    )
    public void criticalOperation(Long orderId) {
        // 최대 격리 및 짧은 시간 초과 작업
    }
}

모범 사례 체크리스트:

text
트랜잭션 구성 - 체크리스트

✅ 기본적으로 readOnly = true, 쓰기에 대한 명시적 오버라이드
✅ checked 예외를 포함하기 위해 rollbackFor = Exception.class
✅ 작업 유형에 따른 적절한 시간 초과
✅ 내부 호출 피하기 (@Transactional 무시됨)
✅ 독립 커밋이 필요한 경우에만 REQUIRES_NEW
✅ 트랜잭션 컨텍스트를 보장하기 위한 MANDATORY
✅ 롤백 동작에 대한 명시적 테스트
✅ 장기 실행 트랜잭션 모니터링
✅ 비표준 전파 선택 문서화

Spring Boot 면접 준비가 되셨나요?

인터랙티브 시뮬레이터, flashcards, 기술 테스트로 연습하세요.

결론

트랜잭션 전파는 Spring Boot 면접에서 평가되는 근본적인 개념입니다. 기억해야 할 핵심 포인트:

일반적인 전파 유형:

  • ✅ REQUIRED (기본): 트랜잭션에 합류 또는 생성
  • ✅ REQUIRES_NEW: 독립 트랜잭션, 별도 커밋
  • ✅ NESTED: 부분 롤백을 위한 세이브포인트
  • ✅ MANDATORY: 기존 트랜잭션 필요

피해야 할 함정:

  • ✅ Self-invocation: 프록시 우회, @Transactional 무시
  • ✅ Checked 예외: 기본적으로 롤백 없음
  • ✅ 동일 데이터에 대한 REQUIRES_NEW: 교착 상태 위험
  • ✅ 시간 초과 누락: 트랜잭션이 무기한 차단

모범 사례:

  • ✅ 기본적으로 readOnly = true
  • ✅ 체계적으로 rollbackFor = Exception.class
  • ✅ Self-invocation을 피하기 위한 별도 서비스
  • ✅ 롤백 동작을 명시적으로 테스트

트랜잭션 전파를 마스터하는 것은 Spring과 데이터 관리에 대한 깊은 이해를 보여줍니다. 이러한 개념은 견고한 애플리케이션을 설계하고 기술 면접을 성공적으로 통과하는 데 필수적입니다.

연습을 시작하세요!

면접 시뮬레이터와 기술 테스트로 지식을 테스트하세요.

태그

#spring boot
#transactions
#propagation
#java
#interview

공유

관련 기사