Spring AOP로 보일러플레이트 제거하기

핵심 로직에만 집중하자

로그 출력, 트랜잭션 경계 설정, 성능 측정 등은 비즈니스 로직과는 무관하지만 반드시 필요한 코드다. 이런 부가 기능을 핵심 코드에 직접 넣으면 유지보수가 어려워진다. 스프링의 AOP(Aspect-Oriented Programming)는 이 문제를 해결해 준다. 핵심 코드를 건드리지 않고도 메서드 실행 전·후·예외 상황에 원하는 동작을 삽입할 수 있다.

핵심 용어 정리

  • Pointcut: 부가 기능을 적용할 조인 포인트(주로 메서드)를 선별하는 표현식
  • Advice: 실제로 실행할 부가 로직(로그, 트랜잭션 등)
  • Aspect: Pointcut + Advice를 하나로 묶은 모듈
  • Join Point: Advice가 적용될 수 있는 지점(메서드 호출, 예외 발생 등)

JDK Dynamic Proxy 실습

스프링 AOP는 기본적으로 프록시 기반이다. JDK가 제공하는 Proxy 클래스로 인터페이스 기반 프록시를 만들어보자.

public interface PaymentService {
    void pay(BigDecimal amount);
}

@Service
public class SimplePaymentService implements PaymentService {
    @Override
    public void pay(BigDecimal amount) {
        System.out.println("결제 금액: " + amount);
    }
}

public class PaymentProxyFactory {
    public static PaymentService createProxy(PaymentService target) {
        return (PaymentService) Proxy.newProxyInstance(
            PaymentProxyFactory.class.getClassLoader(),
            new Class[]{PaymentService.class},
            (proxy, method, args) -> {
                System.out.println("[시작] " + method.getName());
                Object result = method.invoke(target, args);
                System.out.println("[종료] " + method.getName());
                return result;
            }
        );
    }
}

스프링 @Aspect 활용하기

1. 의존성 추가

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aop</artifactId>
</dependency>

2. Aspect 정의

@Aspect
@Component
public class AuditAspect {

    @Pointcut("within(@org.springframework.stereotype.Service *)")
    public void serviceLayer() {}

    @Before("serviceLayer()")
    public void logStart(JoinPoint jp) {
        System.out.printf("[START] %s.%s%n",
            jp.getSignature().getDeclaringTypeName(),
            jp.getSignature().getName());
    }

    @AfterReturning(pointcut = "serviceLayer()", returning = "ret")
    public void logReturn(JoinPoint jp, Object ret) {
        System.out.printf("[END] %s.%s → %s%n",
            jp.getSignature().getDeclaringTypeName(),
            jp.getSignature().getName(),
            ret);
    }

    @AfterThrowing(pointcut = "serviceLayer()", throwing = "ex")
    public void logError(JoinPoint jp, Throwable ex) {
        System.err.printf("[ERROR] %s.%s : %s%n",
            jp.getSignature().getDeclaringTypeName(),
            jp.getSignature().getName(),
            ex.getMessage());
    }

    @Around("serviceLayer()")
    public Object measure(ProceedingJoinPoint pjp) throws Throwable {
        long start = System.nanoTime();
        try {
            return pjp.proceed();
        } finally {
            long end = System.nanoTime();
            System.out.printf("[TIME] %s.%s took %d ms%n",
                pjp.getSignature().getDeclaringTypeName(),
                pjp.getSignature().getName(),
                (end - start) / 1_000_000);
        }
    }
}

3. 활성화

@SpringBootApplication
@EnableAspectJAutoProxy
public class DemoApplication {
    public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);
    }
}

JdbcTemplate으로 DB 접근 간소화

순수 JDBC는 Connection, PreparedStatement, ResultSet 등 반복 코드가 많다. 스프링이 제공하는 JdbcTemplate은 이런 보일러플레이트를 제거하고 SQL만 작성하면 된다.

설정

@Configuration
public class DataConfig {

    @Bean
    public DataSource dataSource() {
        return DataSourceBuilder.create()
                .url("jdbc:mysql://localhost:3306/demo")
                .username("demo")
                .password("demo")
                .driverClassName("com.mysql.cj.jdbc.Driver")
                .build();
    }

    @Bean
    public JdbcTemplate jdbcTemplate(DataSource ds) {
        return new JdbcTemplate(ds);
    }
}

사용 예

@Repository
public class AccountRepository {

    private final JdbcTemplate jdbc;

    public AccountRepository(JdbcTemplate jdbc) {
        this.jdbc = jdbc;
    }

    public void save(Account account) {
        jdbc.update("INSERT INTO account(name, balance) VALUES (?, ?)",
                    account.getName(), account.getBalance());
    }

    public Optional<Account> findById(long id) {
        return jdbc.query(
            "SELECT id, name, balance FROM account WHERE id = ?",
            (rs, rowNum) -> new Account(
                rs.getLong("id"),
                rs.getString("name"),
                rs.getBigDecimal("balance")),
            id
        ).stream().findFirst();
    }

    public List<Account> findAll() {
        return jdbc.query(
            "SELECT id, name, balance FROM account",
            new BeanPropertyRowMapper<>(Account.class));
    }
}

선언적 트랜잭션 관리

트랜잭션 시작·커밋·롤백 코드를 매번 작성할 필요 없이 @Transactional 하나면 충분하다.

Java Config

@Configuration
@EnableTransactionManagement
public class TxConfig {

    @Bean
    public PlatformTransactionManager txManager(DataSource ds) {
        return new DataSourceTransactionManager(ds);
    }
}

서비스 계층

@Service
public class TransferService {

    private final AccountRepository repo;

    public TransferService(AccountRepository repo) {
        this.repo = repo;
    }

    @Transactional
    public void transfer(long fromId, long toId, BigDecimal amount) {
        repo.deduct(fromId, amount);
        repo.add(toId, amount);
        // 예외 발생 시 자동 롤백
    }
}

XML 방식

<tx:advice id="txAdvice" transaction-manager="txManager">
    <tx:attributes>
        <tx:method name="transfer*" propagation="REQUIRED"/>
        <tx:method name="get*" read-only="true"/>
    </tx:attributes>
</tx:advice>

<aop:config>
    <aop:pointcut id="serviceOperation"
                  expression="execution(* com.example.service..*Service.*(..))"/>
    <aop:advisor advice-ref="txAdvice" pointcut-ref="serviceOperation"/>
</aop:config>

스프링 AOP는 단순히 로그 출력용이 아니라 트랜잭션, 보안, 캐싱 등 다양한 크로스 컷팅 컨선(cross-cutting concern)을 깔끔하게 분리할 수 있게 해준다. 핵심 로직에만 집중하고 나머지는 Aspect로 위임하자.

태그: Spring AOP JDK Dynamic Proxy JdbcTemplate Declarative Transaction @Transactional

10월 11일 15:17에 게시됨