개요

스프링을 사용해서 웹 프로젝트를 하고있다. 만약 메소드마다 경과 시간 로그를 찍어야한다고 해보자. 그럼 모든 메소드는System.currentTimeMillis();와 같은 코드를 가지고 있을 것이다. 그러나 생각해보면 비즈니스 로직을 수행하는 것과 로그를 남기는 것은 별개의 관심사일 것이다. 이런 기능들을 모아서 정리하는 방법은 없을까?

AOP

image AOP란 Aspect Oriented Programming의 약자로 관점 지향 프로그래밍이라고 한다.

스프링의 흐름은 한 축으로 진행된다. 컨트롤러 <-> 서비스 <-> 레포지토리의 흐름으로 사용자의 요청을 처리한다.
이에 반해 일괄적으로 로그를 찍는 작업은 그 와 교차하는 축으로 이루어진다.

이런 관점에서 보고 프로그래밍하는 것을 AOP라고 한다.

프록시 패턴

프록시 패턴은 원래 객체에 대한 접근을 제어해서 요청이 객체에 전달되기 전후에 무언가를 수행할 수 있도록 하는 디자인 패턴이다. 대리객체를 사용한다고도 말한다.

예를 들면 신용 카드는 은행 계좌의 프록시다. 또한 은행 계좌는 현금의 프록시다.
직접 현금을 들고다닐 필요 없이 신용 카드를 들고다니며 대신 결제를 수행하는 것이다. image 출처: https://refactoring.guru/ko/design-patterns/proxy

public interface Payment {
    void pay(int amount);
}

public class Cash implements Payment {
    @Override
    public void pay(int amount) {
        System.out.println("Paid " + amount + " in cash.");
    }
}

public class CheckCard implements Payment {
    private Cash cash;
    private int balance;

    public CheckCard(int balance) {
        this.balance = balance;
        this.cash = new Cash();
    }

    @Override
    public void pay(int amount) {
        if (amount <= balance) {
            cash.pay(amount);
            balance -= amount;
            System.out.println("Paid " + amount + " using card. Remaining balance: " + balance);
        } else {
            System.out.println("Insufficient balance on the card.");
        }
    }
}

public class Main {
    public static void main(String[] args) {
        Payment payment = new CreditCard(100);
        payment.pay(30);
        payment.pay(80);
    }
}

payment라는 금액을 지불하는 인터페이스를 선언했다.
payment 즉, 결제 수단에는 여러가지 종류가 있을 수 있다. 현금, 카드 등등.

cash라는 클래스는 payment인터페이스를 구현하여 금액 지불 기능을 수행하고 있다.
CheckCard라는 애도 payment인터페이스를 구현하여 지불 기능을 수행하지만 조금 다른 부분이 있다. 바로 좀 전에 구현했던 Cash클래스를 사용하고 있다는 것이다.

내부 로직을 보면 입력받은 결제 금액을 계좌의 잔고와 비교하고 만약 지불할 수 있다면 이전에 작성했던 Cash 인스턴스를 호출하여 결제를 수행한다.

지금 Cash라는 클래스가 받을 요청을 프록시 객체인 CheckCard라는 클래스가 받아서 처리하고 진짜 객체인 Cash클래스에 전달하고 있다.
이처럼 원래의 객체에 접근하기 전에 다른 대리 객체가 동작하여 원래의 객체에 요청을 보내는 패턴을 프록시 패턴이라고 한다.
image 프록시 패턴에는 3가지 요소가 존재하는데 Subject, Proxy, RealSubject이다. Payment 인터페이스는 Subject, CheckCard는 Proxy, Cash는 RealSubject이 된다.

스프링 AOP는 프록시 패턴을 이용하여 동작한다. 스프링 프로젝트에서 AOP를 사용하기 위해선 spring-starter-aop 의존성을 추가해주어야한다. 알아야하는 개념을 살펴보자.

우선 위에서 알아본 것을 생각해보면 추가 기능을 정의한(로깅, 권한 체크 등등) 클래스가 필요하고 어느 클래스에 적용할 것인지를 스프링에 알려주어야한다.
추가 기능에 해당하는 것을 Aspect라고 하고 Aspect를 적용할 클래스를 Target이라고 한다. 그리고 메소드의 실행 전, 후와 같이 언제 추가 기능을 실행할 것인지를 설정해주기 위한 애노테이션으로 @Before, @After, @AfterReturning, @AfterThrowing, @Around가 있다.

각각 실행 전, 실행 후, 메소드가 실행되고 반환된 후, 예외가 발생했을 경우, 실행 전, 후 또는 예외 발생시 실행하는 애노테이션이다.

아래와 같이 AOP 기능을 정의할 클래스를 생성하고 @Aspect애노테이션을 붙여 추가 기능을 정의한다.

@Aspect
@Component
@Log4j2
public class LoggingAOP {
    @Before("execution(* com.example.sample..*(...))")
    public void logBefore(JoinPoint joinPoint) {
        log.info("Before: " + joinPoint.getSignature().getName());
    }
}

@Transactional도 역시 AOP로, 프록시 패턴을 이용해서 동작한다.
개발자가 작성한 서비스 인터페이스를 구현한 클래스를 만들고 트랜잭션을 여는 코드를 선언한다. 그런 다음 원래의 클래스를 호출하고 처리가 완료되면 트랜잭션을 닫는 코드를 동작시킨다.

이러한 특성 때문에 스프링 AOP를 사용할 때 주의해야할 점이 생긴다.
스프링 AOP는 외부에서 메소드가 호출되는 시점에 프록시 객체를 생성하고 프록시 객체는 부가 기능을 주입해준다.

만약 A라는 클래스가 있고 sampleA메소드에서 트랜잭션 애노테이션이 붙은 sampleB 메소드를 호출한다고 가정해보자.

public class A implements SelfInvocationTest {
    @Override
    public void sampleA() {
        log.info("Sample A");
        sampleB();
    }
    @Override
    @Transactional
    public void sampleB() {
        log.info("Sample B");
    }
}

타겟 오브젝트는 자기 자신을 호출하기 때문에 부가기능이 실행되지 않는다. 가장 좋은 방법은 객체의 책임을 최대한 분리해서 외부 호출을 하는 것이다.

  1. Transactional 애노테이션을 외부에서 호출하는 메소드에 적용하는 방법
    public class A implements SelfInvocationTest {
     @Override
     @Transactional
     public void sampleA() {
         log.info("Sample A");
         sampleB();
     }
     @Override
     public void sampleB() {
         log.info("Sample B");
     }
    }
    
  2. 객체의 책임을 분리하는 방법
    public class A implements SelfInvocationTest {
     @Autowired
     private B b;
    
     @Override
     @Transactional
     public void sampleA() {
         log.info("Sample A");
         b.sampleB();
     }
    }
    

    image 위에서 살펴본 대로 AOP는 프록시 패턴을 이용해서 구현되었기 때문에 spring 3.2 이전까지는 AOP를 적용하기 위해서는 인터페이스를 사용했어야했다.

방식으로는 두 가지가 있다 JDK Proxy, CGLib Proxy.

JDK Proxy는 인터페이스를 기반으로 동작한다. 그러나 JDK Proxy는 프록시를 생성할 때 내부적으로 Relection을 사용하고 있고 이는 비용이 비싼 작업으로 분류된다. 따라서 스프링 3.2부터는 CGLib을 사용하여 프록시 객체를 생성한다. CGLib은 바이트코드를 조작하여 프록시 객체를 생성하고 원본 클래스를 상속받는 방식으로 동작하기 때문에 서비스 클래스를 꼭 인터페이스와 구현체로 작성할 필요는 없다.

여담

만약 여기저기 코드를 들여다보다가 서비스 계층을 인터페이스와 구현체로 나누어서 작성한 경우를 본다면 이는 크게 세 가지 이유일 것이다.

  1. 스프링 3.2 미만 버전을 사용하고 있는 경우
  2. 관습적으로 사용하는 경우
  3. DIP를 적용한 경우

나는 주로 DIP를 이유로 서비스단을 인터페이스와 구현체로 나누어서 작성하곤 한다.
예를 들어 하나의 도메인에서 서비스하는 로직은 서버의 마음이다. 클라이언트와 인접한 컨트롤러에서는 어떤 클래스를 사용하고 있고 어떤 로직을 갖고있는지는 알 필요가 없다.
다소 추상적인 설명이지만 인접한 클래스끼리 세세한 정보를 공유하고 있는 경우 유지보수에 어려움이 생긴다.

만약 포인트를 적립하는 비즈니스 로직이 있다고 해보면 특정 기간에 이벤트를 해서 포인트를 2배로 적립해야한다면 어떻게 해야할까?
이럴 경우 서비스 클래스가 달라질 수 있다. 그럴 때마다 컨트롤러의 코드도 수정되는 것은 좋지 못하다.

소셜 로그인을 구현할 떼에도 어떤 제공업체의 기술을 사용하고 어떤 방식으로 인증하는 지는 컨트롤러의 입장에선 알지 못하도록 하는 것이 바람직하다. 만약 구글의 소셜 로그인으로 변경하게 된다면 컨트롤러의 코드도 변경된다.
따라서 DIP의 의미를 살려 인터페이스의 이름을 SocialLoginService와 같이 짓고 구현체를 KakaoLoginServiceImpl와 같이 짓는 방법을 생각해볼 수 있다.