이론 학습으로 돌아가기
Spring FrameworkPart 1: Spring 기초

Spring 03: IoC/DI 원리와 Spring Container

Spring의 핵심 개념인 IoC와 DI를 이해하고, Container의 동작 원리를 학습합니다.

약 60분9개 섹션

학습 목표

  • IoC(Inversion of Control)의 개념과 장점 이해
  • DI 방식(생성자, Setter, 필드)의 차이와 권장 방식
  • Spring Container와 Bean 등록/Scope 이해
  • Bean 생명주기와 초기화/소멸 콜백
  • Profile과 환경별 설정 관리
  • Spring Event와 @TransactionalEventListener
  • AOP와 @Transactional 동작 원리

1. IoC (Inversion of Control) 이해하기

"Don't call us, we'll call you." (Hollywood Principle)

— IoC의 핵심 원칙

1.1 제어의 역전이란?

전통적인 프로그래밍에서는 개발자가 직접 객체를 생성하고, 의존성을 연결하고, 메서드를 호출합니다. 프로그램의 흐름을 개발자가 제어합니다. IoC에서는 이 제어권이 프레임워크로 넘어갑니다. 프레임워크가 객체의 생성과 생명주기를 관리하고, 필요한 시점에 개발자의 코드를 호출합니다.

전통적인 방식 - 개발자가 제어

public class OrderService {
    // 개발자가 직접 객체 생성
    private OrderRepository repo = 
        new JpaOrderRepository();
    
    private PaymentService payment = 
        new TossPaymentService();
    
    private InventoryService inventory =
        new InventoryServiceImpl();
    
    public Order createOrder(OrderRequest req) {
        // 개발자가 직접 메서드 호출
        // 모든 흐름을 개발자가 제어
        inventory.checkStock(req.getItems());
        Order order = new Order(req);
        repo.save(order);
        payment.process(order);
        return order;
    }
}

// 사용
public class Main {
    public static void main(String[] args) {
        // 개발자가 직접 인스턴스 생성
        OrderService service = new OrderService();
        service.createOrder(request);
    }
}

IoC 방식 - 프레임워크가 제어

@Service  // 프레임워크가 관리
public class OrderService {
    // 프레임워크가 주입
    private final OrderRepository repo;
    private final PaymentService payment;
    private final InventoryService inventory;
    
    // 생성자 - 프레임워크가 호출
    public OrderService(
            OrderRepository repo,
            PaymentService payment,
            InventoryService inventory) {
        this.repo = repo;
        this.payment = payment;
        this.inventory = inventory;
    }
    
    public Order createOrder(OrderRequest req) {
        // 비즈니스 로직만 집중
        inventory.checkStock(req.getItems());
        Order order = new Order(req);
        repo.save(order);
        payment.process(order);
        return order;
    }
}

// 프레임워크가 알아서 생성하고 주입
// 개발자는 설정만 하면 됨

1.2 IoC의 장점

느슨한 결합 (Loose Coupling)

구현체가 아닌 인터페이스에 의존하여 변경에 유연합니다.

// 인터페이스에 의존
private final PaymentService payment;

// 구현체는 설정으로 결정
// TossPaymentService or KakaoPayService
테스트 용이성

Mock 객체를 쉽게 주입하여 단위 테스트가 가능합니다.

@Test
void createOrder_success() {
    // Mock 주입
    var service = new OrderService(
        mockRepo, mockPayment, mockInventory
    );
    // 테스트
}
코드 재사용

동일한 컴포넌트를 다양한 환경에서 재사용할 수 있습니다.

// 개발 환경: H2 + MockPayment
// 운영 환경: MySQL + TossPayment
// 같은 OrderService 코드 사용
관심사 분리

객체 생성과 비즈니스 로직을 분리합니다.

// OrderService: 비즈니스 로직만
// Spring Container: 객체 생성/관리
// 각자 자기 역할에 집중

1.3 IoC 없이 발생하는 문제

문제 상황: 결제 서비스 변경

// IoC 없이 직접 생성하는 경우
public class OrderService {
    // TossPaymentService를 직접 생성
    private PaymentService payment = new TossPaymentService();
}

public class SubscriptionService {
    // 여기서도 TossPaymentService를 직접 생성
    private PaymentService payment = new TossPaymentService();
}

public class RefundService {
    // 여기서도...
    private PaymentService payment = new TossPaymentService();
}

// 문제: TossPaymentService -> KakaoPayService로 변경하려면?
// 모든 클래스를 찾아서 수정해야 함!

// IoC를 사용하면?
@Configuration
public class PaymentConfig {
    @Bean
    public PaymentService paymentService() {
        // 여기만 수정하면 모든 곳에 적용됨
        return new KakaoPayService();
    }
}
핵심 정리:

IoC는 "객체 생성의 책임"을 개발자에서 프레임워크(Container)로 넘기는 것입니다. 개발자는 "무엇을" 할지만 정의하고, "어떻게" 조립할지는 프레임워크가 결정합니다. 이를 통해 코드의 결합도를 낮추고, 테스트와 유지보수를 쉽게 만듭니다.

2. DI (Dependency Injection) 방식

DI(Dependency Injection, 의존성 주입)는 IoC를 구현하는 방법 중 하나입니다. 객체가 필요로 하는 의존성을 외부에서 주입받습니다. Spring은 세 가지 DI 방식을 지원하며, 각각 장단점이 있습니다.

2.1 생성자 주입 (Constructor Injection) - 권장

@Service
public class OrderService {
    
    // final로 선언 - 불변성 보장
    private final OrderRepository orderRepository;
    private final PaymentService paymentService;
    private final InventoryService inventoryService;
    private final EventPublisher eventPublisher;
    
    // 생성자 주입
    // 생성자가 하나면 @Autowired 생략 가능 (Spring 4.3+)
    public OrderService(
            OrderRepository orderRepository,
            PaymentService paymentService,
            InventoryService inventoryService,
            EventPublisher eventPublisher) {
        this.orderRepository = orderRepository;
        this.paymentService = paymentService;
        this.inventoryService = inventoryService;
        this.eventPublisher = eventPublisher;
    }
    
    public Order createOrder(CreateOrderCommand command) {
        // 모든 의존성이 주입된 상태에서 비즈니스 로직 실행
        inventoryService.checkStock(command.getItems());
        Order order = Order.create(command);
        orderRepository.save(order);
        paymentService.process(order);
        eventPublisher.publish(new OrderCreatedEvent(order));
        return order;
    }
}

// Lombok 사용 시 더 간결하게
@Service
@RequiredArgsConstructor  // final 필드에 대한 생성자 자동 생성
public class OrderService {
    private final OrderRepository orderRepository;
    private final PaymentService paymentService;
    private final InventoryService inventoryService;
    private final EventPublisher eventPublisher;
    
    // 생성자가 자동 생성됨
    // 비즈니스 로직만 작성하면 됨
}

생성자 주입의 장점

1. 불변성 보장 (Immutability)

final 필드로 선언하여 한 번 주입된 의존성이 변경되지 않음을 보장합니다. 멀티스레드 환경에서 안전합니다.

2. 필수 의존성 명시

생성자 파라미터로 필수 의존성이 명확하게 드러납니다. 의존성이 없으면 객체 생성 자체가 불가능합니다.

3. 테스트 용이성

생성자를 통해 Mock 객체를 쉽게 주입할 수 있습니다. 리플렉션 없이 순수 Java로 테스트 가능합니다.

4. 순환 참조 감지

애플리케이션 시작 시점에 순환 참조를 감지하여 오류를 발생시킵니다. 런타임이 아닌 시작 시점에 문제를 발견할 수 있습니다.

2.2 Setter 주입 (Setter Injection)

@Service
public class NotificationService {
    
    // final이 아님 - 변경 가능
    private EmailSender emailSender;
    private SmsSender smsSender;
    
    // Setter 주입
    @Autowired
    public void setEmailSender(EmailSender emailSender) {
        this.emailSender = emailSender;
    }
    
    // 선택적 의존성 - 없어도 됨
    @Autowired(required = false)
    public void setSmsSender(SmsSender smsSender) {
        this.smsSender = smsSender;
    }
    
    public void notify(String message) {
        // emailSender는 필수
        emailSender.send(message);
        
        // smsSender는 선택적 - null 체크 필요
        if (smsSender != null) {
            smsSender.send(message);
        }
    }
}

// 선택적 의존성의 더 나은 처리 방법
@Service
public class NotificationService {
    
    private final EmailSender emailSender;
    private final Optional<SmsSender> smsSender;
    
    public NotificationService(
            EmailSender emailSender,
            Optional<SmsSender> smsSender) {  // Optional로 선택적 의존성
        this.emailSender = emailSender;
        this.smsSender = smsSender;
    }
    
    public void notify(String message) {
        emailSender.send(message);
        smsSender.ifPresent(sender -> sender.send(message));
    }
}

2.3 필드 주입 (Field Injection) - 비권장

@Service
public class OrderService {
    
    @Autowired  // 필드에 직접 주입
    private OrderRepository orderRepository;
    
    @Autowired
    private PaymentService paymentService;
    
    @Autowired
    private InventoryService inventoryService;
    
    // 생성자 없음 - Spring이 리플렉션으로 주입
}
필드 주입의 문제점
1. 테스트 어려움

리플렉션 없이는 Mock 객체를 주입할 수 없습니다. @SpringBootTest를 사용해야 하므로 테스트가 느려집니다.

2. 불변성 없음

final로 선언할 수 없어 의존성이 변경될 수 있습니다.

3. 의존성 숨김

클래스 외부에서 어떤 의존성이 필요한지 파악하기 어렵습니다. 생성자를 보면 바로 알 수 있는 정보가 숨겨집니다.

4. 순환 참조 감지 불가

런타임에 문제가 발생할 수 있습니다.

2.4 의존성 선택 - @Qualifier와 @Primary

같은 타입의 Bean이 여러 개 있을 때, 어떤 것을 주입할지 지정해야 합니다.

// 같은 인터페이스의 여러 구현체
public interface PaymentGateway {
    PaymentResult process(PaymentRequest request);
}

@Component
@Primary  // 기본으로 선택됨
public class TossPaymentGateway implements PaymentGateway {
    @Override
    public PaymentResult process(PaymentRequest request) {
        // Toss 결제 처리
    }
}

@Component("kakao")  // Bean 이름 지정
public class KakaoPayGateway implements PaymentGateway {
    @Override
    public PaymentResult process(PaymentRequest request) {
        // KakaoPay 결제 처리
    }
}

@Component("naver")
public class NaverPayGateway implements PaymentGateway {
    @Override
    public PaymentResult process(PaymentRequest request) {
        // NaverPay 결제 처리
    }
}

// 사용하는 곳
@Service
public class PaymentService {
    
    // 방법 1: @Primary가 붙은 TossPaymentGateway가 주입됨
    public PaymentService(PaymentGateway gateway) {
        this.gateway = gateway;
    }
    
    // 방법 2: @Qualifier로 특정 구현체 지정
    public PaymentService(@Qualifier("kakao") PaymentGateway gateway) {
        this.gateway = gateway;
    }
    
    // 방법 3: 모든 구현체를 List로 주입
    private final List<PaymentGateway> gateways;
    
    public PaymentService(List<PaymentGateway> gateways) {
        this.gateways = gateways;  // 3개 모두 주입됨
    }
    
    // 방법 4: Map으로 주입 (Bean 이름이 key)
    private final Map<String, PaymentGateway> gatewayMap;
    
    public PaymentService(Map<String, PaymentGateway> gatewayMap) {
        this.gatewayMap = gatewayMap;
        // {"tossPaymentGateway": Toss, "kakao": Kakao, "naver": Naver}
    }
    
    public PaymentResult process(String gatewayName, PaymentRequest request) {
        PaymentGateway gateway = gatewayMap.get(gatewayName);
        if (gateway == null) {
            throw new IllegalArgumentException("Unknown gateway: " + gatewayName);
        }
        return gateway.process(request);
    }
}
DI 방식 선택 가이드:
  • - 필수 의존성: 생성자 주입 (권장)
  • - 선택적 의존성: 생성자 + Optional 또는 Setter 주입
  • - 필드 주입: 테스트 코드에서만 제한적 사용
  • - 여러 구현체: @Primary + @Qualifier 또는 Map/List 주입

3. Spring Container와 Bean

3.1 Spring Container란?

Spring Container(ApplicationContext)는 Bean의 생성, 관리, 의존성 주입을 담당합니다. 개발자가 정의한 설정을 바탕으로 객체 그래프를 구성하고, 애플리케이션 전체에서 사용할 수 있도록 관리합니다.

Container의 역할

Bean 생명주기 관리
  • - Bean 인스턴스 생성
  • - 의존성 주입 (DI)
  • - 초기화 콜백 호출
  • - 사용
  • - 소멸 콜백 호출
Bean 조회 및 관리
  • - 타입/이름으로 Bean 조회
  • - 스코프 관리 (Singleton, Prototype 등)
  • - 환경 설정 (Profile, Property)
  • - 이벤트 발행/구독
  • - 국제화 (MessageSource)

3.2 Bean 등록 방법

방법 1: 컴포넌트 스캔 (@Component)

// 스테레오타입 어노테이션 - 모두 @Component의 특수화
@Component      // 일반 컴포넌트
@Service        // 비즈니스 로직 계층
@Repository     // 데이터 접근 계층 (예외 변환 기능 포함)
@Controller     // 웹 MVC 컨트롤러
@RestController // REST API 컨트롤러 (@Controller + @ResponseBody)
@Configuration  // 설정 클래스

// 예시
@Service
public class OrderService {
    // Spring이 자동으로 Bean 등록
    // Bean 이름: "orderService" (클래스명의 첫 글자를 소문자로)
}

@Repository
public class JpaOrderRepository implements OrderRepository {
    // Spring이 자동으로 Bean 등록
    // + DataAccessException으로 예외 변환
}

// 컴포넌트 스캔 범위 설정
@SpringBootApplication
@ComponentScan(basePackages = "com.shop")  // 기본값: 이 클래스의 패키지
public class ShopApplication {
    public static void main(String[] args) {
        SpringApplication.run(ShopApplication.class, args);
    }
}

방법 2: Java Config (@Bean)

@Configuration
public class AppConfig {
    
    // 메서드 이름이 Bean 이름이 됨
    @Bean
    public OrderService orderService(OrderRepository repository) {
        return new OrderService(repository);
    }
    
    @Bean
    public OrderRepository orderRepository(EntityManager em) {
        return new JpaOrderRepository(em);
    }
    
    // 외부 라이브러리 클래스를 Bean으로 등록
    // @Component를 붙일 수 없는 클래스에 사용
    @Bean
    public RestTemplate restTemplate() {
        return new RestTemplateBuilder()
            .setConnectTimeout(Duration.ofSeconds(5))
            .setReadTimeout(Duration.ofSeconds(10))
            .build();
    }
    
    @Bean
    public ObjectMapper objectMapper() {
        return new ObjectMapper()
            .registerModule(new JavaTimeModule())
            .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
    }
    
    // 조건부 Bean 등록
    @Bean
    @ConditionalOnProperty(name = "cache.enabled", havingValue = "true")
    public CacheManager cacheManager() {
        return new CaffeineCacheManager();
    }
    
    // Profile별 Bean 등록
    @Bean
    @Profile("dev")
    public DataSource devDataSource() {
        return new EmbeddedDatabaseBuilder()
            .setType(EmbeddedDatabaseType.H2)
            .build();
    }
    
    @Bean
    @Profile("prod")
    public DataSource prodDataSource() {
        HikariDataSource ds = new HikariDataSource();
        ds.setJdbcUrl("jdbc:mysql://prod-db:3306/shop");
        return ds;
    }
}

3.3 Bean Scope

Scope설명사용 예
singletonContainer당 하나의 인스턴스 (기본값)Service, Repository, Controller
prototype요청할 때마다 새로운 인스턴스 생성상태를 가진 객체, Builder
requestHTTP 요청당 하나의 인스턴스요청 컨텍스트, 요청 로깅
sessionHTTP 세션당 하나의 인스턴스사용자 세션 데이터, 장바구니
applicationServletContext당 하나의 인스턴스애플리케이션 전역 설정

Scope 사용 예시

// Singleton (기본값) - 대부분의 Bean
@Service
public class OrderService {
    // Container에 하나만 존재
    // 상태를 가지면 안 됨 (Stateless)
}

// Prototype - 매번 새로운 인스턴스
@Component
@Scope("prototype")
public class ShoppingCart {
    private List<CartItem> items = new ArrayList<>();
    
    public void addItem(CartItem item) {
        items.add(item);
    }
}

// Request Scope - HTTP 요청마다 새로운 인스턴스
@Component
@Scope(value = WebApplicationContext.SCOPE_REQUEST, 
       proxyMode = ScopedProxyMode.TARGET_CLASS)
public class RequestContext {
    private String requestId;
    private LocalDateTime requestTime;
    private String userId;
    
    @PostConstruct
    public void init() {
        this.requestId = UUID.randomUUID().toString();
        this.requestTime = LocalDateTime.now();
    }
}

// Singleton Bean에서 Prototype Bean 사용 시 주의
@Service
public class OrderService {
    
    // 잘못된 방법 - ShoppingCart가 Singleton처럼 동작
    // private final ShoppingCart cart;
    
    // 올바른 방법 1: ObjectProvider 사용
    private final ObjectProvider<ShoppingCart> cartProvider;
    
    public void addToCart(CartItem item) {
        ShoppingCart cart = cartProvider.getObject();  // 매번 새로운 인스턴스
        cart.addItem(item);
    }
    
    // 올바른 방법 2: ApplicationContext 사용
    private final ApplicationContext context;
    
    public void addToCart2(CartItem item) {
        ShoppingCart cart = context.getBean(ShoppingCart.class);
        cart.addItem(item);
    }
}
Bean 등록 선택 가이드:
  • - @Component: 직접 작성한 클래스, 단순한 Bean
  • - @Bean: 외부 라이브러리, 복잡한 생성 로직, 조건부 등록
  • - 대부분 Singleton: 상태 없는 서비스, Repository
  • - Prototype: 상태를 가지는 객체가 필요할 때

4. Bean 생명주기

4.1 생명주기 단계

1
Bean 인스턴스 생성

Container가 Bean 클래스의 인스턴스를 생성

2
의존성 주입 (DI)

@Autowired, 생성자 주입 등으로 의존성 주입

3
초기화 콜백

@PostConstruct, InitializingBean, init-method

4
사용

애플리케이션에서 Bean 사용

5
소멸 콜백

@PreDestroy, DisposableBean, destroy-method

4.2 초기화와 소멸 콜백

방법 1: @PostConstruct / @PreDestroy (권장)

@Service
@Slf4j
public class CacheService {
    
    private final RedisTemplate<String, Object> redisTemplate;
    private LoadingCache<String, Product> localCache;
    
    public CacheService(RedisTemplate<String, Object> redisTemplate) {
        this.redisTemplate = redisTemplate;
        // 생성자에서는 아직 다른 Bean이 준비 안 됐을 수 있음
        // 복잡한 초기화는 @PostConstruct에서
    }
    
    @PostConstruct  // 의존성 주입 완료 후 호출
    public void init() {
        log.info("CacheService 초기화 시작");
        
        // 로컬 캐시 설정
        this.localCache = Caffeine.newBuilder()
            .maximumSize(10_000)
            .expireAfterWrite(Duration.ofMinutes(10))
            .recordStats()
            .build(this::loadFromRedis);
        
        // 캐시 워밍 (자주 조회되는 데이터 미리 로드)
        warmUpCache();
        
        log.info("CacheService 초기화 완료");
    }
    
    @PreDestroy  // Container 종료 전 호출
    public void cleanup() {
        log.info("CacheService 정리 시작");
        
        // 캐시 통계 로깅
        CacheStats stats = localCache.stats();
        log.info("캐시 통계 - hitRate: {}, evictionCount: {}", 
                 stats.hitRate(), stats.evictionCount());
        
        // 리소스 정리
        localCache.invalidateAll();
        
        log.info("CacheService 정리 완료");
    }
    
    private Product loadFromRedis(String key) {
        return (Product) redisTemplate.opsForValue().get(key);
    }
    
    private void warmUpCache() {
        // 인기 상품 미리 로드
        List<String> popularKeys = List.of("product:1", "product:2", "product:3");
        localCache.getAll(popularKeys);
    }
}

방법 2: @Bean의 initMethod / destroyMethod

@Configuration
public class DataSourceConfig {
    
    @Bean(initMethod = "init", destroyMethod = "close")
    public HikariDataSource dataSource(DataSourceProperties properties) {
        HikariDataSource ds = new HikariDataSource();
        ds.setJdbcUrl(properties.getUrl());
        ds.setUsername(properties.getUsername());
        ds.setPassword(properties.getPassword());
        ds.setMaximumPoolSize(10);
        ds.setMinimumIdle(5);
        ds.setConnectionTimeout(30000);
        return ds;
        // init() 메서드가 자동 호출됨
        // 애플리케이션 종료 시 close() 메서드가 자동 호출됨
    }
}

// 외부 라이브러리 클래스에 @PostConstruct를 붙일 수 없을 때 유용
public class ExternalLibraryClient {
    public void init() {
        // 연결 설정
    }
    
    public void close() {
        // 연결 종료
    }
}

방법 3: InitializingBean / DisposableBean 인터페이스

@Component
public class ConnectionPool implements InitializingBean, DisposableBean {
    
    private List<Connection> connections;
    
    @Override
    public void afterPropertiesSet() throws Exception {
        // 초기화 로직
        connections = new ArrayList<>();
        for (int i = 0; i < 10; i++) {
            connections.add(createConnection());
        }
    }
    
    @Override
    public void destroy() throws Exception {
        // 정리 로직
        for (Connection conn : connections) {
            conn.close();
        }
    }
    
    private Connection createConnection() {
        // 연결 생성
        return null;
    }
}

// 참고: 이 방식은 Spring에 의존하게 되므로
// @PostConstruct/@PreDestroy 사용을 권장

4.3 E-commerce 예제: 상품 캐시 워밍

@Service
@RequiredArgsConstructor
@Slf4j
public class ProductCacheService {
    
    private final ProductRepository productRepository;
    private final CategoryRepository categoryRepository;
    
    private Map<Long, Product> productCache;
    private Map<Long, List<Product>> categoryProductCache;
    
    @PostConstruct
    public void warmUpCache() {
        log.info("상품 캐시 워밍 시작...");
        long startTime = System.currentTimeMillis();
        
        // 1. 인기 상품 캐시
        List<Product> popularProducts = productRepository
            .findTop100ByOrderBySalesCountDesc();
        
        this.productCache = popularProducts.stream()
            .collect(Collectors.toConcurrentMap(
                Product::getId,
                Function.identity()
            ));
        
        log.info("인기 상품 {}개 캐시 완료", productCache.size());
        
        // 2. 카테고리별 상품 캐시
        List<Category> mainCategories = categoryRepository.findMainCategories();
        
        this.categoryProductCache = new ConcurrentHashMap<>();
        for (Category category : mainCategories) {
            List<Product> products = productRepository
                .findTop20ByCategoryOrderBySalesCountDesc(category);
            categoryProductCache.put(category.getId(), products);
        }
        
        log.info("카테고리 {}개의 상품 캐시 완료", categoryProductCache.size());
        
        long elapsed = System.currentTimeMillis() - startTime;
        log.info("상품 캐시 워밍 완료 - 소요시간: {}ms", elapsed);
    }
    
    public Optional<Product> getFromCache(Long productId) {
        return Optional.ofNullable(productCache.get(productId));
    }
    
    public List<Product> getProductsByCategory(Long categoryId) {
        return categoryProductCache.getOrDefault(categoryId, List.of());
    }
    
    // 캐시 갱신 (스케줄러에서 호출)
    @Scheduled(fixedRate = 300000)  // 5분마다
    public void refreshCache() {
        log.info("캐시 갱신 시작");
        warmUpCache();
    }
    
    @PreDestroy
    public void clearCache() {
        log.info("캐시 정리");
        productCache.clear();
        categoryProductCache.clear();
    }
}

4.4 콜백 실행 순서

// 여러 초기화 방법을 함께 사용할 경우 실행 순서
@Component
public class MultiCallbackBean implements InitializingBean, DisposableBean {
    
    // 1. 생성자
    public MultiCallbackBean() {
        System.out.println("1. 생성자");
    }
    
    // 2. @PostConstruct
    @PostConstruct
    public void postConstruct() {
        System.out.println("2. @PostConstruct");
    }
    
    // 3. InitializingBean.afterPropertiesSet()
    @Override
    public void afterPropertiesSet() {
        System.out.println("3. afterPropertiesSet");
    }
    
    // 4. @Bean(initMethod = "customInit")
    public void customInit() {
        System.out.println("4. customInit");
    }
    
    // 소멸 순서도 동일: @PreDestroy -> destroy() -> destroyMethod
}
생명주기 콜백 사용 시점:
  • - @PostConstruct: 캐시 워밍, 연결 풀 초기화, 설정 검증, 외부 서비스 연결
  • - @PreDestroy: 연결 종료, 리소스 해제, 임시 파일 삭제, 통계 로깅
  • - 권장: @PostConstruct/@PreDestroy 사용 (표준 어노테이션)

5. 의존성 주입 실전 패턴

5.1 계층별 의존성 구조

E-commerce 계층 구조

// Controller -> Service -> Repository 계층 구조

// 1. Controller 계층
@RestController
@RequestMapping("/api/orders")
@RequiredArgsConstructor
public class OrderController {
    
    private final OrderService orderService;
    private final OrderQueryService orderQueryService;  // CQRS 패턴
    
    @PostMapping
    public ResponseEntity<OrderResponse> createOrder(
            @Valid @RequestBody CreateOrderRequest request,
            @AuthenticationPrincipal UserDetails user) {
        
        OrderResult result = orderService.createOrder(
            user.getUsername(), 
            request.toCommand()
        );
        
        return ResponseEntity
            .created(URI.create("/api/orders/" + result.getOrderId()))
            .body(OrderResponse.from(result));
    }
    
    @GetMapping("/{orderId}")
    public ResponseEntity<OrderDetailResponse> getOrder(
            @PathVariable Long orderId,
            @AuthenticationPrincipal UserDetails user) {
        
        return orderQueryService.findOrderDetail(orderId, user.getUsername())
            .map(ResponseEntity::ok)
            .orElse(ResponseEntity.notFound().build());
    }
}

// 2. Service 계층
@Service
@RequiredArgsConstructor
@Transactional
public class OrderService {
    
    private final OrderRepository orderRepository;
    private final ProductRepository productRepository;
    private final MemberRepository memberRepository;
    private final PaymentService paymentService;
    private final InventoryService inventoryService;
    private final ApplicationEventPublisher eventPublisher;
    
    public OrderResult createOrder(String username, CreateOrderCommand command) {
        // 1. 회원 조회
        Member member = memberRepository.findByUsername(username)
            .orElseThrow(() -> new MemberNotFoundException(username));
        
        // 2. 상품 조회 및 재고 확인
        List<OrderLine> orderLines = command.getItems().stream()
            .map(item -> {
                Product product = productRepository.findById(item.getProductId())
                    .orElseThrow(() -> new ProductNotFoundException(item.getProductId()));
                
                inventoryService.checkStock(product.getId(), item.getQuantity());
                
                return OrderLine.create(product, item.getQuantity());
            })
            .toList();
        
        // 3. 주문 생성
        Order order = Order.create(member, orderLines, command.getShippingAddress());
        orderRepository.save(order);
        
        // 4. 재고 차감
        orderLines.forEach(line -> 
            inventoryService.decreaseStock(line.getProductId(), line.getQuantity())
        );
        
        // 5. 이벤트 발행
        eventPublisher.publishEvent(new OrderCreatedEvent(order));
        
        return OrderResult.from(order);
    }
}

// 3. Repository 계층
public interface OrderRepository extends JpaRepository<Order, Long> {
    
    @Query("SELECT o FROM Order o " +
           "JOIN FETCH o.member " +
           "JOIN FETCH o.orderLines ol " +
           "JOIN FETCH ol.product " +
           "WHERE o.id = :orderId")
    Optional<Order> findByIdWithDetails(@Param("orderId") Long orderId);
    
    List<Order> findByMemberIdOrderByCreatedAtDesc(Long memberId);
    
    @Query("SELECT o FROM Order o WHERE o.status = :status " +
           "AND o.createdAt < :threshold")
    List<Order> findOldOrdersByStatus(
        @Param("status") OrderStatus status,
        @Param("threshold") LocalDateTime threshold
    );
}

5.2 인터페이스 기반 설계

결제 서비스 인터페이스

// 인터페이스 정의
public interface PaymentService {
    PaymentResult processPayment(PaymentRequest request);
    PaymentResult cancelPayment(String paymentId);
    PaymentStatus getPaymentStatus(String paymentId);
}

// 구현체 1: 카드 결제
@Service
@Primary  // 기본 구현체로 지정
@RequiredArgsConstructor
@Slf4j
public class CardPaymentService implements PaymentService {
    
    private final CardPaymentGateway gateway;
    private final PaymentRepository paymentRepository;
    
    @Override
    @Transactional
    public PaymentResult processPayment(PaymentRequest request) {
        log.info("카드 결제 처리: {}", request.getOrderId());
        
        // 외부 PG사 API 호출
        CardPaymentResponse response = gateway.requestPayment(
            request.getCardNumber(),
            request.getAmount(),
            request.getOrderId()
        );
        
        // 결제 정보 저장
        Payment payment = Payment.builder()
            .orderId(request.getOrderId())
            .amount(request.getAmount())
            .method(PaymentMethod.CARD)
            .status(response.isSuccess() ? PaymentStatus.COMPLETED : PaymentStatus.FAILED)
            .transactionId(response.getTransactionId())
            .build();
        
        paymentRepository.save(payment);
        
        return PaymentResult.from(payment);
    }
    
    @Override
    public PaymentResult cancelPayment(String paymentId) {
        // 취소 로직
        return null;
    }
    
    @Override
    public PaymentStatus getPaymentStatus(String paymentId) {
        return paymentRepository.findByTransactionId(paymentId)
            .map(Payment::getStatus)
            .orElse(PaymentStatus.NOT_FOUND);
    }
}

// 구현체 2: 가상계좌 결제
@Service("virtualAccountPayment")
@RequiredArgsConstructor
public class VirtualAccountPaymentService implements PaymentService {
    
    private final VirtualAccountGateway gateway;
    private final PaymentRepository paymentRepository;
    
    @Override
    @Transactional
    public PaymentResult processPayment(PaymentRequest request) {
        // 가상계좌 발급
        VirtualAccountResponse response = gateway.issueVirtualAccount(
            request.getAmount(),
            request.getOrderId()
        );
        
        Payment payment = Payment.builder()
            .orderId(request.getOrderId())
            .amount(request.getAmount())
            .method(PaymentMethod.VIRTUAL_ACCOUNT)
            .status(PaymentStatus.PENDING)  // 입금 대기
            .virtualAccountNumber(response.getAccountNumber())
            .virtualAccountBank(response.getBankCode())
            .build();
        
        paymentRepository.save(payment);
        
        return PaymentResult.from(payment);
    }
    
    // ... 나머지 메서드
}

여러 구현체 주입받기

@Service
@RequiredArgsConstructor
public class PaymentFacade {
    
    // 방법 1: @Primary가 붙은 기본 구현체 주입
    private final PaymentService defaultPaymentService;
    
    // 방법 2: @Qualifier로 특정 구현체 지정
    @Qualifier("virtualAccountPayment")
    private final PaymentService virtualAccountService;
    
    // 방법 3: 모든 구현체를 Map으로 주입
    private final Map<String, PaymentService> paymentServices;
    
    // 방법 4: 모든 구현체를 List로 주입
    private final List<PaymentService> allPaymentServices;
    
    public PaymentResult processPayment(PaymentRequest request) {
        // Map에서 결제 방식에 맞는 서비스 선택
        String serviceName = request.getPaymentMethod().getServiceName();
        PaymentService service = paymentServices.get(serviceName);
        
        if (service == null) {
            throw new UnsupportedPaymentMethodException(request.getPaymentMethod());
        }
        
        return service.processPayment(request);
    }
}

// 결제 방식 Enum
public enum PaymentMethod {
    CARD("cardPaymentService"),
    VIRTUAL_ACCOUNT("virtualAccountPayment"),
    KAKAO_PAY("kakaoPayService"),
    NAVER_PAY("naverPayService");
    
    private final String serviceName;
    
    PaymentMethod(String serviceName) {
        this.serviceName = serviceName;
    }
    
    public String getServiceName() {
        return serviceName;
    }
}

5.3 순환 의존성 해결

순환 의존성 문제:

A가 B를 의존하고, B가 A를 의존하면 Bean 생성 불가. Spring Boot 2.6부터 기본적으로 순환 의존성을 금지합니다.

순환 의존성 예시와 해결

// 문제: 순환 의존성
@Service
public class OrderService {
    private final MemberService memberService;  // OrderService -> MemberService
}

@Service
public class MemberService {
    private final OrderService orderService;  // MemberService -> OrderService
    // 순환 참조 발생!
}

// 해결 방법 1: 설계 변경 - 공통 서비스 추출
@Service
public class OrderService {
    private final MemberQueryService memberQueryService;  // 조회 전용
}

@Service
public class MemberService {
    private final OrderQueryService orderQueryService;  // 조회 전용
}

@Service
public class MemberQueryService {
    private final MemberRepository memberRepository;
    
    public Member findById(Long id) {
        return memberRepository.findById(id).orElseThrow();
    }
}

// 해결 방법 2: 이벤트 기반 통신
@Service
@RequiredArgsConstructor
public class OrderService {
    private final ApplicationEventPublisher eventPublisher;
    
    public void completeOrder(Long orderId) {
        // 주문 완료 처리
        Order order = findById(orderId);
        order.complete();
        
        // 이벤트 발행 (MemberService가 구독)
        eventPublisher.publishEvent(new OrderCompletedEvent(order));
    }
}

@Service
@RequiredArgsConstructor
public class MemberService {
    
    @EventListener
    public void handleOrderCompleted(OrderCompletedEvent event) {
        // 포인트 적립 등 처리
        Member member = findById(event.getMemberId());
        member.addPoints(event.getEarnedPoints());
    }
}

// 해결 방법 3: @Lazy 사용 (권장하지 않음)
@Service
public class OrderService {
    private final MemberService memberService;
    
    public OrderService(@Lazy MemberService memberService) {
        this.memberService = memberService;  // 프록시 주입, 실제 사용 시 초기화
    }
}

5.4 조건부 Bean 등록

@Configuration
public class NotificationConfig {
    
    // 프로퍼티 값에 따른 조건부 등록
    @Bean
    @ConditionalOnProperty(
        name = "notification.email.enabled",
        havingValue = "true",
        matchIfMissing = false
    )
    public EmailNotificationService emailNotificationService() {
        return new EmailNotificationService();
    }
    
    // 클래스 존재 여부에 따른 조건부 등록
    @Bean
    @ConditionalOnClass(name = "com.google.firebase.messaging.FirebaseMessaging")
    public PushNotificationService pushNotificationService() {
        return new FirebasePushNotificationService();
    }
    
    // Bean 존재 여부에 따른 조건부 등록
    @Bean
    @ConditionalOnMissingBean(NotificationService.class)
    public NotificationService defaultNotificationService() {
        return new LoggingNotificationService();  // 기본 구현
    }
    
    // Profile에 따른 조건부 등록
    @Bean
    @Profile("!prod")  // prod가 아닐 때만
    public NotificationService mockNotificationService() {
        return new MockNotificationService();
    }
}

// 커스텀 Condition
public class OnKafkaEnabledCondition implements Condition {
    
    @Override
    public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) {
        String enabled = context.getEnvironment()
            .getProperty("kafka.enabled", "false");
        return "true".equalsIgnoreCase(enabled);
    }
}

@Configuration
@Conditional(OnKafkaEnabledCondition.class)
public class KafkaConfig {
    // Kafka 관련 Bean들
}
DI 실전 가이드:
  • - 인터페이스 사용: 구현체 교체 용이, 테스트 Mock 주입 가능
  • - 순환 의존성: 설계 문제의 신호, 이벤트나 서비스 분리로 해결
  • - @Primary: 기본 구현체 지정, @Qualifier로 특정 구현체 선택
  • - 조건부 Bean: 환경별 설정, 선택적 기능 활성화에 활용

6. Spring Profile과 환경 설정

6.1 Profile 개념

Profile은 환경별로 다른 설정을 적용할 수 있게 해주는 Spring의 기능입니다. 개발(dev), 테스트(test), 스테이징(staging), 운영(prod) 환경에 따라 다른 Bean 구성과 설정 값을 사용할 수 있습니다.

Profile 활성화 방법

# 1. application.yml에서 설정
spring:
  profiles:
    active: dev

# 2. 환경 변수로 설정
export SPRING_PROFILES_ACTIVE=prod

# 3. JVM 옵션으로 설정
java -jar app.jar -Dspring.profiles.active=prod

# 4. 프로그래밍 방식
SpringApplication app = new SpringApplication(MyApp.class);
app.setAdditionalProfiles("prod");
app.run(args);

# 5. 테스트에서 설정
@SpringBootTest
@ActiveProfiles("test")
class OrderServiceTest {
    // test profile로 실행
}

6.2 Profile별 설정 파일

파일 구조

src/main/resources/
├── application.yml           # 공통 설정
├── application-dev.yml       # 개발 환경
├── application-test.yml      # 테스트 환경
├── application-staging.yml   # 스테이징 환경
└── application-prod.yml      # 운영 환경

application.yml (공통)

spring:
  application:
    name: shop-api
  
  # 기본 profile 설정
  profiles:
    active: dev
    
  # JPA 공통 설정
  jpa:
    open-in-view: false
    properties:
      hibernate:
        default_batch_fetch_size: 100

# 서버 공통 설정
server:
  port: 8080
  servlet:
    context-path: /api

# 로깅 공통 설정
logging:
  pattern:
    console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"

application-dev.yml (개발)

spring:
  datasource:
    url: jdbc:h2:mem:shopdb
    username: sa
    password:
    driver-class-name: org.h2.Driver
  
  h2:
    console:
      enabled: true
      path: /h2-console
  
  jpa:
    hibernate:
      ddl-auto: create-drop
    show-sql: true
    properties:
      hibernate:
        format_sql: true

# 개발용 로깅 - 상세하게
logging:
  level:
    root: INFO
    com.shop: DEBUG
    org.hibernate.SQL: DEBUG
    org.hibernate.type.descriptor.sql: TRACE

# 개발용 외부 서비스 (Mock)
payment:
  gateway:
    url: http://localhost:8081/mock-payment
    
notification:
  email:
    enabled: false  # 개발 시 이메일 발송 비활성화

application-prod.yml (운영)

spring:
  datasource:
    url: jdbc:mysql://${DB_HOST}:3306/shopdb?useSSL=true
    username: ${DB_USERNAME}
    password: ${DB_PASSWORD}
    driver-class-name: com.mysql.cj.jdbc.Driver
    hikari:
      maximum-pool-size: 20
      minimum-idle: 10
      connection-timeout: 30000
      idle-timeout: 600000
      max-lifetime: 1800000
  
  jpa:
    hibernate:
      ddl-auto: validate  # 운영에서는 스키마 변경 금지
    show-sql: false

# 운영 로깅 - 최소화
logging:
  level:
    root: WARN
    com.shop: INFO
  file:
    name: /var/log/shop-api/application.log
  logback:
    rollingpolicy:
      max-file-size: 100MB
      max-history: 30

# 운영 외부 서비스
payment:
  gateway:
    url: https://api.payment-gateway.com
    api-key: ${PAYMENT_API_KEY}
    
notification:
  email:
    enabled: true
    smtp-host: ${SMTP_HOST}

# 운영 보안 설정
management:
  endpoints:
    web:
      exposure:
        include: health,info,metrics
  endpoint:
    health:
      show-details: never

6.3 Profile별 Bean 등록

// 개발 환경 전용 Bean
@Configuration
@Profile("dev")
public class DevConfig {
    
    @Bean
    public DataSource dataSource() {
        return new EmbeddedDatabaseBuilder()
            .setType(EmbeddedDatabaseType.H2)
            .addScript("schema.sql")
            .addScript("dev-data.sql")  // 개발용 테스트 데이터
            .build();
    }
    
    @Bean
    public PaymentService paymentService() {
        return new MockPaymentService();  // Mock 결제 서비스
    }
}

// 운영 환경 전용 Bean
@Configuration
@Profile("prod")
public class ProdConfig {
    
    @Bean
    @ConfigurationProperties("spring.datasource.hikari")
    public HikariDataSource dataSource() {
        return new HikariDataSource();
    }
    
    @Bean
    public PaymentService paymentService(PaymentGatewayClient client) {
        return new RealPaymentService(client);  // 실제 결제 서비스
    }
}

// 여러 Profile에서 사용
@Service
@Profile({"dev", "test"})  // dev 또는 test에서만 활성화
public class MockEmailService implements EmailService {
    
    @Override
    public void send(Email email) {
        log.info("Mock 이메일 발송: {}", email);
        // 실제 발송하지 않음
    }
}

// 특정 Profile 제외
@Service
@Profile("!prod")  // prod가 아닌 모든 환경
public class DebugService {
    // 디버깅용 서비스
}

6.4 @ConfigurationProperties

타입 안전한 설정 바인딩

// application.yml
payment:
  gateway:
    url: https://api.payment.com
    api-key: secret-key
    timeout:
      connect: 5000
      read: 10000
    retry:
      max-attempts: 3
      delay: 1000

// Properties 클래스
@ConfigurationProperties(prefix = "payment.gateway")
@Validated  // 유효성 검증 활성화
public class PaymentGatewayProperties {
    
    @NotBlank
    private String url;
    
    @NotBlank
    private String apiKey;
    
    private Timeout timeout = new Timeout();
    private Retry retry = new Retry();
    
    // Getters, Setters
    
    public static class Timeout {
        private int connect = 5000;
        private int read = 10000;
        // Getters, Setters
    }
    
    public static class Retry {
        private int maxAttempts = 3;
        private long delay = 1000;
        // Getters, Setters
    }
}

// 활성화
@Configuration
@EnableConfigurationProperties(PaymentGatewayProperties.class)
public class PaymentConfig {
    
    @Bean
    public PaymentGatewayClient paymentGatewayClient(
            PaymentGatewayProperties properties) {
        
        return PaymentGatewayClient.builder()
            .baseUrl(properties.getUrl())
            .apiKey(properties.getApiKey())
            .connectTimeout(properties.getTimeout().getConnect())
            .readTimeout(properties.getTimeout().getRead())
            .maxRetries(properties.getRetry().getMaxAttempts())
            .build();
    }
}

// 사용
@Service
@RequiredArgsConstructor
public class PaymentService {
    
    private final PaymentGatewayProperties properties;
    
    public void processPayment() {
        log.info("결제 게이트웨이: {}", properties.getUrl());
        // ...
    }
}
민감 정보 관리:
  • - 비밀번호, API 키는 설정 파일에 직접 작성하지 않음
  • - 환경 변수나 외부 설정 서버(Spring Cloud Config, Vault) 사용
  • - Git에 민감 정보가 포함된 파일 커밋 금지
Profile 활용 가이드:
  • - dev: H2 DB, Mock 서비스, 상세 로깅
  • - test: 테스트 DB, Mock 서비스, 테스트 데이터
  • - staging: 운영과 유사한 환경, 테스트용 외부 서비스
  • - prod: 실제 DB, 실제 서비스, 최소 로깅, 보안 강화

7. Spring Event

7.1 이벤트 기반 아키텍처

Spring Event는 애플리케이션 내에서 컴포넌트 간 느슨한 결합을 유지하면서 통신할 수 있게 해주는 메커니즘입니다. 발행자(Publisher)는 구독자(Listener)를 알 필요 없이 이벤트만 발행하면 됩니다.

이벤트 사용 장점

느슨한 결합
  • - 발행자와 구독자가 서로를 모름
  • - 새로운 기능 추가 시 기존 코드 수정 불필요
  • - 순환 의존성 해결
관심사 분리
  • - 핵심 로직과 부가 로직 분리
  • - 단일 책임 원칙 준수
  • - 테스트 용이성 향상

7.2 이벤트 정의와 발행

이벤트 클래스 정의

// 주문 생성 이벤트
public class OrderCreatedEvent {
    private final Long orderId;
    private final Long memberId;
    private final BigDecimal totalAmount;
    private final List<OrderLineDto> orderLines;
    private final LocalDateTime occurredAt;
    
    public OrderCreatedEvent(Order order) {
        this.orderId = order.getId();
        this.memberId = order.getMember().getId();
        this.totalAmount = order.getTotalAmount();
        this.orderLines = order.getOrderLines().stream()
            .map(OrderLineDto::from)
            .toList();
        this.occurredAt = LocalDateTime.now();
    }
    
    // Getters
}

// 주문 취소 이벤트
public class OrderCancelledEvent {
    private final Long orderId;
    private final Long memberId;
    private final String cancelReason;
    private final BigDecimal refundAmount;
    private final LocalDateTime occurredAt;
    
    // 생성자, Getters
}

// 결제 완료 이벤트
public class PaymentCompletedEvent {
    private final Long orderId;
    private final String paymentId;
    private final BigDecimal amount;
    private final PaymentMethod method;
    private final LocalDateTime paidAt;
    
    // 생성자, Getters
}

이벤트 발행

@Service
@RequiredArgsConstructor
@Transactional
public class OrderService {
    
    private final OrderRepository orderRepository;
    private final ApplicationEventPublisher eventPublisher;
    
    public OrderResult createOrder(CreateOrderCommand command) {
        // 1. 주문 생성 (핵심 로직)
        Order order = Order.create(
            command.getMember(),
            command.getOrderLines(),
            command.getShippingAddress()
        );
        orderRepository.save(order);
        
        // 2. 이벤트 발행 (부가 로직은 리스너에서 처리)
        eventPublisher.publishEvent(new OrderCreatedEvent(order));
        
        return OrderResult.from(order);
    }
    
    public void cancelOrder(Long orderId, String reason) {
        Order order = orderRepository.findById(orderId)
            .orElseThrow(() -> new OrderNotFoundException(orderId));
        
        order.cancel(reason);
        
        // 취소 이벤트 발행
        eventPublisher.publishEvent(new OrderCancelledEvent(order, reason));
    }
}

7.3 이벤트 리스너

@EventListener 사용

// 알림 서비스 - 주문 이벤트 구독
@Component
@RequiredArgsConstructor
@Slf4j
public class OrderNotificationListener {
    
    private final NotificationService notificationService;
    private final MemberRepository memberRepository;
    
    @EventListener
    public void handleOrderCreated(OrderCreatedEvent event) {
        log.info("주문 생성 이벤트 수신: orderId={}", event.getOrderId());
        
        Member member = memberRepository.findById(event.getMemberId())
            .orElseThrow();
        
        // 주문 확인 알림 발송
        notificationService.sendOrderConfirmation(
            member.getEmail(),
            event.getOrderId(),
            event.getTotalAmount()
        );
    }
    
    @EventListener
    public void handleOrderCancelled(OrderCancelledEvent event) {
        log.info("주문 취소 이벤트 수신: orderId={}", event.getOrderId());
        
        // 취소 알림 발송
        notificationService.sendOrderCancellation(
            event.getMemberId(),
            event.getOrderId(),
            event.getCancelReason()
        );
    }
}

// 포인트 서비스 - 주문 이벤트 구독
@Component
@RequiredArgsConstructor
public class PointEventListener {
    
    private final PointService pointService;
    
    @EventListener
    public void handleOrderCreated(OrderCreatedEvent event) {
        // 포인트 적립 (결제 금액의 1%)
        BigDecimal earnedPoints = event.getTotalAmount()
            .multiply(new BigDecimal("0.01"));
        
        pointService.earnPoints(
            event.getMemberId(),
            earnedPoints,
            "주문 적립 - 주문번호: " + event.getOrderId()
        );
    }
    
    @EventListener
    public void handleOrderCancelled(OrderCancelledEvent event) {
        // 적립 포인트 회수
        pointService.revokePoints(
            event.getMemberId(),
            event.getOrderId()
        );
    }
}

// 재고 서비스 - 주문 이벤트 구독
@Component
@RequiredArgsConstructor
public class InventoryEventListener {
    
    private final InventoryService inventoryService;
    
    @EventListener
    public void handleOrderCancelled(OrderCancelledEvent event) {
        // 재고 복구
        event.getOrderLines().forEach(line ->
            inventoryService.increaseStock(
                line.getProductId(),
                line.getQuantity()
            )
        );
    }
}

7.4 @TransactionalEventListener

@EventListener의 문제점:

@EventListener는 이벤트 발행 즉시 실행됩니다. 트랜잭션이 롤백되어도 이미 실행된 리스너의 작업(이메일 발송 등)은 취소되지 않습니다.

트랜잭션 단계별 이벤트 처리

@Component
@RequiredArgsConstructor
@Slf4j
public class TransactionalOrderListener {
    
    private final NotificationService notificationService;
    private final ExternalApiClient externalApiClient;
    
    // 트랜잭션 커밋 후 실행 (기본값)
    // 트랜잭션이 성공적으로 완료된 후에만 실행
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void handleOrderCreatedAfterCommit(OrderCreatedEvent event) {
        log.info("트랜잭션 커밋 후 - 주문 알림 발송");
        
        // 이메일 발송 - 트랜잭션 성공 후에만 발송
        notificationService.sendOrderConfirmation(event);
        
        // 외부 시스템 연동
        externalApiClient.notifyOrderCreated(event);
    }
    
    // 트랜잭션 커밋 전 실행
    // 리스너에서 예외 발생 시 트랜잭션 롤백
    @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
    public void validateBeforeCommit(OrderCreatedEvent event) {
        log.info("트랜잭션 커밋 전 - 추가 검증");
        
        // 재고 최종 확인 등
        if (!inventoryService.validateStock(event.getOrderLines())) {
            throw new InsufficientStockException();  // 트랜잭션 롤백
        }
    }
    
    // 트랜잭션 롤백 후 실행
    @TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
    public void handleRollback(OrderCreatedEvent event) {
        log.warn("트랜잭션 롤백 - 주문 실패 처리: orderId={}", event.getOrderId());
        
        // 실패 로깅, 모니터링 알림 등
        monitoringService.alertOrderFailed(event);
    }
    
    // 트랜잭션 완료 후 실행 (커밋/롤백 상관없이)
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMPLETION)
    public void handleCompletion(OrderCreatedEvent event) {
        log.info("트랜잭션 완료 - 정리 작업");
        
        // 임시 데이터 정리 등
    }
}

TransactionPhase 정리

Phase실행 시점사용 예
BEFORE_COMMIT커밋 직전최종 검증, 추가 DB 작업
AFTER_COMMIT커밋 성공 후 (기본값)알림 발송, 외부 API 호출
AFTER_ROLLBACK롤백 후실패 로깅, 보상 트랜잭션
AFTER_COMPLETION완료 후 (성공/실패 무관)리소스 정리, 통계 수집

7.5 비동기 이벤트 처리

// 비동기 설정 활성화
@Configuration
@EnableAsync
public class AsyncConfig {
    
    @Bean
    public Executor asyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(5);
        executor.setMaxPoolSize(10);
        executor.setQueueCapacity(100);
        executor.setThreadNamePrefix("async-event-");
        executor.initialize();
        return executor;
    }
}

// 비동기 이벤트 리스너
@Component
@RequiredArgsConstructor
@Slf4j
public class AsyncOrderListener {
    
    private final EmailService emailService;
    private final SmsService smsService;
    
    // 비동기로 실행 - 메인 스레드 블로킹 없음
    @Async
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void sendNotificationsAsync(OrderCreatedEvent event) {
        log.info("비동기 알림 발송 시작 - thread: {}", 
                 Thread.currentThread().getName());
        
        try {
            // 이메일 발송 (시간이 오래 걸릴 수 있음)
            emailService.sendOrderConfirmation(event);
            
            // SMS 발송
            smsService.sendOrderNotification(event);
            
        } catch (Exception e) {
            log.error("알림 발송 실패: orderId={}", event.getOrderId(), e);
            // 재시도 로직 또는 Dead Letter Queue로 전송
        }
    }
    
    // 여러 작업을 병렬로 처리
    @Async
    @EventListener
    public void processAnalytics(OrderCreatedEvent event) {
        // 분석 데이터 수집 (비동기)
        analyticsService.trackOrder(event);
    }
}
이벤트 사용 가이드:
  • - @EventListener: 즉시 실행, 트랜잭션 내에서 실행
  • - @TransactionalEventListener: 트랜잭션 결과에 따라 실행
  • - AFTER_COMMIT: 외부 시스템 연동, 알림 발송에 사용
  • - @Async: 시간이 오래 걸리는 작업에 사용

8. AOP와 트랜잭션

8.1 AOP 개념

AOP(Aspect-Oriented Programming)는 횡단 관심사(Cross-Cutting Concerns)를 모듈화하는 프로그래밍 패러다임입니다. 로깅, 보안, 트랜잭션 같은 공통 기능을 비즈니스 로직과 분리하여 관리합니다.

AOP 용어

용어설명
Aspect횡단 관심사를 모듈화한 것 (로깅, 트랜잭션 등)
Join PointAdvice가 적용될 수 있는 지점 (메서드 실행 등)
PointcutJoin Point를 선택하는 표현식
Advice실제 실행되는 코드 (Before, After, Around 등)
TargetAdvice가 적용되는 대상 객체

8.2 AOP 구현

로깅 Aspect

@Aspect
@Component
@Slf4j
public class LoggingAspect {
    
    // Service 계층 모든 메서드에 적용
    @Pointcut("execution(* com.shop.service..*(..))")
    public void serviceLayer() {}
    
    // 메서드 실행 전
    @Before("serviceLayer()")
    public void logBefore(JoinPoint joinPoint) {
        String methodName = joinPoint.getSignature().getName();
        Object[] args = joinPoint.getArgs();
        log.info(">>> {} 호출, 파라미터: {}", methodName, Arrays.toString(args));
    }
    
    // 메서드 정상 완료 후
    @AfterReturning(pointcut = "serviceLayer()", returning = "result")
    public void logAfterReturning(JoinPoint joinPoint, Object result) {
        String methodName = joinPoint.getSignature().getName();
        log.info("<<< {} 완료, 결과: {}", methodName, result);
    }
    
    // 예외 발생 시
    @AfterThrowing(pointcut = "serviceLayer()", throwing = "ex")
    public void logAfterThrowing(JoinPoint joinPoint, Exception ex) {
        String methodName = joinPoint.getSignature().getName();
        log.error("!!! {} 예외 발생: {}", methodName, ex.getMessage());
    }
    
    // 메서드 실행 전후 (가장 강력)
    @Around("serviceLayer()")
    public Object logAround(ProceedingJoinPoint joinPoint) throws Throwable {
        String methodName = joinPoint.getSignature().getName();
        long startTime = System.currentTimeMillis();
        
        try {
            Object result = joinPoint.proceed();  // 실제 메서드 실행
            
            long elapsed = System.currentTimeMillis() - startTime;
            log.info("{} 실행 시간: {}ms", methodName, elapsed);
            
            return result;
        } catch (Exception e) {
            log.error("{} 실행 중 예외: {}", methodName, e.getMessage());
            throw e;
        }
    }
}

성능 측정 Aspect

// 커스텀 어노테이션
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Timed {
    String value() default "";
}

// Aspect 구현
@Aspect
@Component
@RequiredArgsConstructor
public class TimedAspect {
    
    private final MeterRegistry meterRegistry;
    
    @Around("@annotation(timed)")
    public Object measureTime(ProceedingJoinPoint joinPoint, Timed timed) 
            throws Throwable {
        
        String metricName = timed.value().isEmpty() 
            ? joinPoint.getSignature().getName() 
            : timed.value();
        
        Timer.Sample sample = Timer.start(meterRegistry);
        
        try {
            return joinPoint.proceed();
        } finally {
            sample.stop(Timer.builder(metricName)
                .tag("class", joinPoint.getTarget().getClass().getSimpleName())
                .tag("method", joinPoint.getSignature().getName())
                .register(meterRegistry));
        }
    }
}

// 사용
@Service
public class OrderService {
    
    @Timed("order.create")
    public OrderResult createOrder(CreateOrderCommand command) {
        // 실행 시간이 자동으로 측정됨
    }
}

8.3 @Transactional

트랜잭션 기본 사용

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)  // 클래스 레벨: 기본 읽기 전용
public class OrderService {
    
    private final OrderRepository orderRepository;
    private final PaymentService paymentService;
    private final InventoryService inventoryService;
    
    // 읽기 전용 트랜잭션 (클래스 레벨 설정 사용)
    public Order findById(Long orderId) {
        return orderRepository.findById(orderId)
            .orElseThrow(() -> new OrderNotFoundException(orderId));
    }
    
    public List<Order> findByMemberId(Long memberId) {
        return orderRepository.findByMemberId(memberId);
    }
    
    // 쓰기 트랜잭션 (메서드 레벨에서 오버라이드)
    @Transactional  // readOnly = false (기본값)
    public OrderResult createOrder(CreateOrderCommand command) {
        // 1. 재고 차감
        command.getItems().forEach(item ->
            inventoryService.decreaseStock(item.getProductId(), item.getQuantity())
        );
        
        // 2. 주문 생성
        Order order = Order.create(command);
        orderRepository.save(order);
        
        // 3. 결제 처리
        paymentService.processPayment(order);
        
        return OrderResult.from(order);
        // 메서드 종료 시 커밋 (예외 발생 시 롤백)
    }
    
    @Transactional
    public void cancelOrder(Long orderId) {
        Order order = findById(orderId);
        order.cancel();
        
        // 재고 복구
        order.getOrderLines().forEach(line ->
            inventoryService.increaseStock(line.getProductId(), line.getQuantity())
        );
        
        // 결제 취소
        paymentService.cancelPayment(order.getPaymentId());
    }
}

트랜잭션 속성

@Transactional(
    // 전파 속성: 트랜잭션이 이미 있을 때 어떻게 동작할지
    propagation = Propagation.REQUIRED,  // 기본값: 있으면 참여, 없으면 생성
    
    // 격리 수준: 동시 트랜잭션 간 데이터 가시성
    isolation = Isolation.DEFAULT,  // DB 기본값 사용
    
    // 타임아웃: 트랜잭션 최대 실행 시간 (초)
    timeout = 30,
    
    // 읽기 전용: 최적화 힌트
    readOnly = false,
    
    // 롤백 조건: 어떤 예외에서 롤백할지
    rollbackFor = Exception.class,
    noRollbackFor = BusinessException.class
)
public void complexOperation() {
    // ...
}

// 전파 속성 예시
@Service
public class PaymentService {
    
    // REQUIRED: 기존 트랜잭션에 참여 (기본값)
    @Transactional(propagation = Propagation.REQUIRED)
    public void processPayment(Order order) {
        // OrderService의 트랜잭션에 참여
        // 여기서 예외 발생 시 전체 롤백
    }
    
    // REQUIRES_NEW: 항상 새 트랜잭션 생성
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void savePaymentLog(PaymentLog log) {
        // 별도 트랜잭션으로 실행
        // 메인 트랜잭션이 롤백되어도 로그는 저장됨
        paymentLogRepository.save(log);
    }
    
    // NOT_SUPPORTED: 트랜잭션 없이 실행
    @Transactional(propagation = Propagation.NOT_SUPPORTED)
    public PaymentStatus checkExternalStatus(String paymentId) {
        // 외부 API 호출 - 트랜잭션 불필요
        return externalApi.getStatus(paymentId);
    }
}

8.4 트랜잭션 주의사항

Self-Invocation 문제 (매우 중요):

같은 클래스 내에서 @Transactional 메서드를 호출하면 트랜잭션이 적용되지 않습니다. Spring AOP는 프록시 기반이므로, 내부 호출은 프록시를 거치지 않습니다.

Self-Invocation 문제와 해결

@Service
public class OrderService {
    
    // 문제: 내부 호출 시 @Transactional 무시됨
    public void processOrders(List<Long> orderIds) {
        for (Long orderId : orderIds) {
            processOrder(orderId);  // 내부 호출 - 트랜잭션 적용 안 됨!
        }
    }
    
    @Transactional
    public void processOrder(Long orderId) {
        // 트랜잭션이 적용되지 않음
    }
}

// 해결 방법 1: 별도 서비스로 분리 (권장)
@Service
@RequiredArgsConstructor
public class OrderService {
    
    private final OrderProcessor orderProcessor;
    
    public void processOrders(List<Long> orderIds) {
        for (Long orderId : orderIds) {
            orderProcessor.processOrder(orderId);  // 외부 호출
        }
    }
}

@Service
public class OrderProcessor {
    
    @Transactional
    public void processOrder(Long orderId) {
        // 트랜잭션 정상 적용
    }
}

// 해결 방법 2: Self-injection (비권장)
@Service
public class OrderService {
    
    @Autowired
    private OrderService self;  // 자기 자신 주입
    
    public void processOrders(List<Long> orderIds) {
        for (Long orderId : orderIds) {
            self.processOrder(orderId);  // 프록시를 통한 호출
        }
    }
    
    @Transactional
    public void processOrder(Long orderId) {
        // 트랜잭션 적용됨
    }
}

Checked Exception 롤백

// 기본적으로 Checked Exception은 롤백하지 않음
@Transactional
public void createOrder() throws IOException {
    orderRepository.save(order);
    
    // IOException 발생해도 롤백되지 않음 (Checked Exception)
    fileService.saveReceipt(order);
}

// 해결: rollbackFor 지정
@Transactional(rollbackFor = Exception.class)
public void createOrder() throws IOException {
    orderRepository.save(order);
    
    // 이제 IOException도 롤백됨
    fileService.saveReceipt(order);
}

// 또는 RuntimeException으로 감싸기
@Transactional
public void createOrder() {
    try {
        orderRepository.save(order);
        fileService.saveReceipt(order);
    } catch (IOException e) {
        throw new OrderProcessingException("영수증 저장 실패", e);
    }
}
트랜잭션 Best Practices:
  • - 클래스 레벨에 @Transactional(readOnly = true), 쓰기 메서드만 오버라이드
  • - Self-invocation 주의: 별도 서비스로 분리
  • - Checked Exception 롤백: rollbackFor = Exception.class
  • - 트랜잭션 범위 최소화: 외부 API 호출은 트랜잭션 밖에서

9. 정리 및 다음 단계

9.1 핵심 개념 정리

IoC (Inversion of Control)

객체 생성과 생명주기 관리를 개발자가 아닌 Spring Container가 담당. 개발자는 비즈니스 로직에만 집중할 수 있음.

DI (Dependency Injection)

의존성을 외부에서 주입받아 결합도를 낮춤. 생성자 주입을 기본으로 사용하고, 인터페이스 기반 설계로 유연성 확보.

Bean과 Container

@Component, @Service, @Repository로 Bean 등록. @Bean으로 외부 라이브러리 등록. Singleton이 기본, 필요시 Prototype이나 Request Scope 사용.

Bean 생명주기

생성 - 의존성 주입 - 초기화(@PostConstruct) - 사용 - 소멸(@PreDestroy). 캐시 워밍, 연결 풀 초기화 등에 활용.

Profile과 환경 설정

dev, test, prod 환경별 설정 분리. @ConfigurationProperties로 타입 안전한 설정 바인딩. 민감 정보는 환경 변수로 관리.

Spring Event

이벤트 기반으로 컴포넌트 간 느슨한 결합 유지. @TransactionalEventListener로 트랜잭션 결과에 따른 처리. @Async로 비동기 처리.

AOP와 트랜잭션

횡단 관심사(로깅, 보안, 트랜잭션)를 비즈니스 로직과 분리. @Transactional로 선언적 트랜잭션 관리. Self-invocation 문제 주의.

9.2 실무 체크리스트

DI 설계

  • V생성자 주입 사용 (final 필드)
  • V인터페이스 기반 설계
  • V순환 의존성 없음
  • V@Primary/@Qualifier 적절히 사용

트랜잭션

  • V클래스 레벨 readOnly = true
  • V쓰기 메서드만 @Transactional
  • VSelf-invocation 회피
  • VrollbackFor 설정 확인

이벤트

  • V외부 연동은 AFTER_COMMIT
  • V시간 소요 작업은 @Async
  • V이벤트 클래스 불변 설계

설정

  • VProfile별 설정 파일 분리
  • V민감 정보 환경 변수 사용
  • V@ConfigurationProperties 활용

9.3 다음 학습 내용

Spring 04: Spring Boot와 Web MVC

  • - Spring Boot 자동 설정 원리
  • - @RestController와 REST API 설계
  • - 요청/응답 처리와 예외 핸들링
  • - Validation과 데이터 바인딩

Spring 05: Spring Data JPA

  • - JpaRepository와 쿼리 메서드
  • - @Query와 JPQL
  • - 페이징과 정렬
  • - Auditing과 Soft Delete

Spring 06: Entity 설계

  • - Entity 기본 설계 원칙
  • - 연관관계 매핑 (1:N, N:1, N:M)
  • - 상속 매핑 전략
  • - 복합키와 식별 관계
학습 포인트:

이번 세션에서 배운 IoC/DI, Bean 관리, 트랜잭션, 이벤트는 Spring 애플리케이션의 기반이 됩니다. 다음 세션에서 배울 Web MVC와 JPA도 이 개념들 위에서 동작합니다. 특히 @Transactional의 동작 원리와 Self-invocation 문제는 실무에서 자주 마주치는 이슈이므로 반드시 이해하고 넘어가세요.