상세 컨텐츠

본문 제목

[Spring] Aspected-Oriented Programming(AOP) 개념과 종류 정리

프로그래밍/기타

by jisooo 2024. 2. 28. 22:52

본문

 

 

 

AOP (Aspected Oriented Programming) 이란?

 

애플리케이션의 핵심 기능을 담고 있지는 않지만, 애플리케이션을 구성하는 중요한 한 요소이고,

핵심 기능에 부가되어 의미를 갖는 특별한 모듈을 가리킨다.

 

AOP는 문제를 해결하기 위한 핵심 관심사와 전체에 적용되는 공통 모듈 사항을 기준으로 프로그래밍함으로써

공통 모듈을 여러 코드에 쉽게 적용할 수 있도록 도와주는 역할을 한다.

 

ex) 공통 트랜잭션 처리, 공통 인증 / 인가, 로그 처리 등등..

 

 

 

이러한 부가 기능들을 한 곳에 모아서 독립적인 모듈의 Aspect로 정의한다.

독립된 측면에 존재하는 부가 기능을 Aspect로 모듈화시킨 덕분에, 

핵심 비즈니스는 순수하게 그 기능을 담은 코드로만 존재하고 독립적으로 살펴볼 수 있도록 한다.

 

Aspect는 부가될 기능을 정의한 코드인 어드바이스와,

어드바이스를 어디에 적용할지를 결정하는 포인트컷을 함께 갖고 있다.

 

애플리케이션의 여러 다른 측면에 존재하는 부가 기능들은 결국 위 이미지와 같이

핵심 기능과 함께 어우러져서 동작하게 된다.

하나 이상의 부가 기능이 핵심 기능과 함께 동시에 동작할 수 있다.

 

결국 런타임시에 왼쪽 그림처럼 각 부가 기능의 Aspect는 자기가 필요한 위치에 Dynamic하게 참여하게 된다.

하지만 설계과 개발은 오른쪽 그림처럼 다른 특성을 띈 Aspect들을 독립적인 관점으로 작성할 수 있게 된다.

 

그래서 AOP를 관심사의 분리라는 측면에서 "관점 지향 프로그래밍"이라고도 한다.

 

 

 

스프링 프레임워크에서 우리가 자주 사용하는 어노테이션인 @Transactional이 바로

이 AOP 기술을 사용하고 있다.

 

 

 

 

 

부가 기능의 모듈화

여기저기 흩어져서 중복될 수 있는 트랜잭션 경계 설정 코드를 공통 관심사로 정하여 독립된 모듈로 만들 수 있다.

클래스를 만들지 않고도, 새로운 구현 기능을 가진 오브젝트를 런타임시 다이나믹하게 만들어주는 Dynamic Proxy 기능이 있다.

Spring의 IOC / DI 컨테이너의 빈 생성 작업을 가로채서, 빈 오브젝트를 프록시 오브젝트로 대체해주는 빈 후속 처리 작업이 있다.

여기서 클라이언트는 프록시의 존재를 알 필요가 없고, 그냥 빈 오브젝트를 주입받는다고 생각하면 된다.

 

 

 

 

AOP의 구성 요소

  • 조인포인트 : AOP를 사용해 추가 로직을 삽입할 수 있는 애플리케이션의 특정 지점
  • 어드바이스 : 특정 조인포인트에 실행되는 코드로 애플리케이션 클래스 내 메서드로 정의됨.
  • 포인트컷 : 애스팩트가 적용되어야 하는 곳을 판별할 수 있는 패턴을 기술
  • 애스팩트 : 클래스에 캡슐화된 어드바이스와 포인트컷의 조합
  • 위빙 : 애플리케이션 코드의 적절한 위치에 애스팩트를 삽입하는 과정

 

 

 

 

 

 

 

 

프록시를 이용한 AOP

 

스프링은 Ioc / DI 컨테이너와 Dynamic Proxy, Decorator 패턴, 프록시 패턴, 자동 프록시 생성 기법,

빈 오브젝트와 후처리 조작 기법 등의 다양한 기술을 조합하여 AOP를 지원하고 있다.

프록시로 만들어서 DI로 연결된 빈 사이에 적용해 타겟의 메서드 호출 과정에 참여하여 메서드에 부가 기능을 제공해주도록 만든다.

독립적으로 개발한 부가 기능 모듈을 다양한 타겟 오브젝트의 메서드에 다이나믹하게 적용해주기 위해

가장 중요한 역할을 맡고 있는 것이 바로 이 프록시다.

 

이 프록시는 DI를 통해 타겟 대신 클라이언트에 빈이 주입되며, 클라이언트의 메서드 호출을 대신 받아서 타겟에 위임한다.

위임하는 과정에서 부가 기능을 실행하게 된다.

 

AOP의 부가 기능 적용을 어느 단계에서 위빙이 이루어지는지에 따라 정적 AOP와 동적 AOP로 구분할 수 있다.

 

그리고 Spring에서 프록시 객체를 생성하는 방식은 동적 AOP 방식을 사용하며,

타겟 객체가 인터페이스를 구현하고 있느냐 없느냐의 여부에 따라

JDK Dynamic Proxy, CGLIB Proxy 라는 2가지 프록시를 생성하는 방식을 사용한다.

 

 

 

 

 

 

 

 

정적 AOP

빌드 프로세스에서 별도 단계로 위빙이 이루어지며, 애플리케이션의 실제 바이트 코드를 수정한다.

즉 위빙의 결과물이 자바 바이트 코드이다.

aspect를 조금이라도 수정하게 되면 전체 애플리케이션을 다시 컴파일해야한다는 특징이 있다.

컴파일시에 코드를 삽입하는 방법이며, AspectJ에서 사용하는 방식이다.

자바 소스를 컴파일시 알맞은 위치에 공통 코드를 삽입하면, 컴파일 결과 AOP가 적용된 클래스 파일이 생성된다.

 

동적 AOP

Spring AOP가 동작하는 방식이다.

정적 AOP와 달리 런타임시에 동적으로 위빙 프로세스가 수행된다.

Advice가 적용된 모든 객체에 대한 프록시를 생성하여 필요에 따라 advice를 호출할 수 있도록 하는 방식이다.

메인 애플리케이션을 다시 컴파일 하지 않아도, 애플리케이션에서 전체 aspect를 쉽게 수정할 수 있다.

AOP 라이브러리는 JVM이 클래스를 로딩할 때 클래스 정보를 변경할 수 있는 에이전트를 제공한다.

이 에이전트는 로딩한 클래스의 바이너리 정보를 변경하여 알맞은 위치에 공통 코드를 삽입한 새로운 클래스 바이너리 코드를 사용하도록 한다.

스프링에서는 Runtime weaving을 통해 CGLIB Proxy 또는 JDK Dynamic Proxy를 생성한다.

 

 

 

 

 

 

JDK Dynamic Proxy (Interface)

인터페이스 기반의 프록시만 생성이 가능한 방식이다.

프록시 타입은 타겟 객체가 구현하고 있는 인터페이스를 똑같이 구현한다.

프록시를 적용하려는 모든 타겟 객체는 적어도 하나의 인터페이스를 구현해야 한다.

즉, 타겟이 최소한 하나의 인터페이스를 구현하고 있다면, JDK Dynamic Proxy가 적용된다.

 

프록시의 invoke() 메서드에 들어가기 전까지는 advice가 적용된 메서드와 적용되지 않은 메서드를 구분하지 못한다.

따라서 프록시 advice가 적용되지 않는 메서드에 대해서도 invoke() 메서드가 호출되어 모든 검사가 이루어지고,

리플렉션을 이용해 대상이 아닌 메서드를 호출한다. 

=> 모든 메서드가 호출될 때 마다 런타임 성능에 오버헤드를 준다. 

 

 

 

public class ExamDynamicHandler implements InvocationHandler {
    private ExamInterface target; // 타깃 객체에 대한 클래스를 직접 참조하는것이 아닌 Interface를 이용
     
    public ExamDynamicHandler(ExamInterface target) {
        this.target = target;
    }
 
    @Override
    public Object invoke(Object proxy, Method method, Object[] args)
            throws Throwable {
        // TODO Auto-generated method stub
        // 메소드에 대한 명세, 파라미터등을 가져오는 과정에서 Reflection 사용
        String ret = (String)method.invoke(target, args); //타입 Safe하지 않는 단점이 있다.
        return ret.toUpperCase(); //메소드 기능에 대한 확장
    }
}

 

 

 

 

 

 

 

 

 

CGLIB Proxy (class)

 

타겟 객체가 어떤 인터페이스도 구현하고 있지 않다면 CGLIB 프록시가 적용된다.

JDK Dynamic Proxy와 달리 클래스 기반의 프록시 객체를 생성한다.

즉 프록시의 타입은 타겟 객체 클래스의 하위 클래스로 구현된다.

 

상속을 이용하는 만큼 final이나 private과 같이 상속에 대해 override를 지원하지 않는 경우에는 

프록시에서 해당 메서드에 대한 aspect를 적용할 수 없다는 제약 사항이 있다.

 

각 프록시에 대해 동적으로 새 클래스에 대한 바이트 코드를 생성하고, 이미 생성된 클래스를 사용할 수 있으면 재사용한다.

 

CGLIB 프록시 객체가 처음 생성되면, 각 메서드를 어떻게 처리할 건지 스프링에 물어본다.

JDK Dynamic Proxy에서는 각 메서드에 대해 invoke() 메서드를 매번 호출하여 프록시 적용을 결정하는 반면,

CGLIB는 프록시 대상 메서드인지에 대한 검사를 최초에 한번만 수행한다.

 

Advice가 적용되지 않는 메서드는 타겟 메서드를 직접 호출하여 적절한 바이트코드를 생성하여 프록시를 이용한 성능 오버헤드를 줄인다.

(JDK Dynamic Proxy의 경우에는 리플렉션을 이용한다.)

 

 

 

 

 

 

 

내부 메서드 호출시 프록시가 동작하지 않는 이슈

 

 

프록시 객체를 이용한 특징때문에, Spring AOP를 적용하고자 할 때는

몇가지 제약사항이 있는데, 그 중 한가지가 "내부 메서드" 호출시 부가 기능이 동작하지 않는다는 점이다.

 

이 점을 기억하면 Spring에서 특히 @Transactional 어노테이션을 사용할 때

트랜잭션이 적용되지 않는 불상사를 예방할 수 있다.

 

위 그림을 기반으로 작성된 아래 예시 코드를 보자.

 

 

public class AccountServiceImpl implements AccountService { // Target 클래스
	private final AccountMapper mapper;
    private final AccountRepository accountRepository;
    private final AccountTraceRepository accountTraceRepository;
	
    @Override
	public void saveAccount(AccountCommand command) {
        var account = mapper.map(command);
        this.saveAccountAndTrace(account);
	}
    
	@Transactional
	public void saveAccountAndTrace(Account account) {
		accountRepository.save(account);
        accountTraceRepository.save(account.getTrace());
	}
}

public class AccountServiceImplProxy implements AccountService { // Proxy 클래스
	private final TransactionManager manager = TransactionManager.getInstance();
	private final UserService target; // AccountServiceImpl 실제 타겟 객체를 주입받는다.
	
    @Override
    public void saveAccount(AccountCommand command) {
    	target.saveAccount(); // 타겟의 메서드를 직접 호출하여 트랜잭션이 적용되지 않는다!
    }
    
    
    @Override
    public void saveAccountAndTrace() {
		try {
        	manager.begin();
            target.saveAccountAndTrace(); // 실제 타겟 객체의 메서드를 호출한다.
            manager.commit();
        } catch (RuntimeException e) {
        	manager.rollback();
        }
        // ...
        // ...
	}
}

 

 

위 코드를 보면, 타겟 클래스 AccountServiceImpl가 있고, AccountService 인터페이스를 구현하고 있다.

AccountServiceImpl의 메서드에 AOP를 적용하고자하는 @Transactional 어노테이션이 달려있다.

이때 Spring은 타겟 클래스가 구현하고 있는 AccountService 인터페이스를 똑같이 구현하는 프록시 객체 AccountServiceImpl 클래스를 만든다.

프록시 클래스 안에는 실제 타겟 객체의 메서드를 호출할 수 있도록, 타겟 객체 타입을 멤버로 갖고 있다.

 

 

타겟 클래스에서 saveAccount() 메서드 내부에서 @Transactional 어노테이션이 달린 saveAccountAndTrace() 메서드를 호출하고 있다.

 

 

saveAccountAndTrace() 메서드를 직접 호출하면 트랜잭션 부가기능이 프록시 객체의 메서드에 적용되어

트랜잭션이 정상적으로 동작하지만,

saveAccount() 메서드를 호출하면, 프록시 객체에서는 saveAccount() 메서드는 타겟 객체의 saveAccount() 메서드를 직접 호출하여

트랜잭션 부가기능이 동작하지 않는다.

 

 

 

 

 

그렇다면 아래와 같이 내부 메서드를 호출하는 메서드에 트랜잭션 어노테이션을 달면 어떻게 동작할까?

 

public class AccountServiceImpl implements AccountService { // Target 클래스
	private final AccountMapper mapper;
    private final AccountRepository accountRepository;
    private final AccountTraceRepository accountTraceRepository;
	
    @Transactional
	public void saveAccount(AccountCommand command) {
        var account = mapper.map(command);
        this.saveAccountAndTrace(account);
	}
    
	public void saveAccountAndTrace(Account account) {
		accountRepository.save(account);
        accountTraceRepository.save(account.getTrace());
	}
}

public class AccountServiceImplProxy implements AccountService { // Proxy 클래스
	private final TransactionManager manager = TransactionManager.getInstance();
	private final UserService target; // AccountServiceImpl 실제 타겟 객체를 주입받는다.
	
    @Override
    public void saveAccount(AccountCommand command) {
    	try {
        	manager.begin();
            target.saveAccount(); // 실제 타겟 객체의 메서드를 호출한다.
            manager.commit();
        } catch (RuntimeException e) {
        	manager.rollback();
        }
        // ...
        // ...
    }
    

    @Override
    public void saveAccountAndTrace() {
		target.saveAccountAndTrace(); 
	}
}

 

 

요렇게 내부 메서드를 호출하는 saveAccount() 메서드 자체에 @Transactional 어노테이션을 달면

프록시 객체에서는 saveAccount() 메서드에 부가기능을 구현하고 실제 타겟의 saveAccount()를 호출하게 되므로

트랜잭션이 적용된다.

 

 

 

 

 

 

 

 

 

 

private 메서드에 프록시가 동작하지 않는 이슈

 

 

public class AccountServiceImpl implements AccountService { // Target 클래스
	private final AccountMapper mapper;
    private final AccountRepository accountRepository;
    private final AccountTraceRepository accountTraceRepository;
	
    @Transactional
	private void saveAccount(AccountCommand command) {
        var account = mapper.map(command);
        this.saveAccountAndTrace(account);
	}
    
	private void saveAccountAndTrace(Account account) {
		accountRepository.save(account);
        accountTraceRepository.save(account.getTrace());
	}
}

public class AccountServiceImplProxy implements AccountService { // Proxy 클래스
	private final TransactionManager manager = TransactionManager.getInstance();
	private final UserService target; // AccountServiceImpl 실제 타겟 객체를 주입받는다.
	
    @Override
    public void saveAccount(AccountCommand command) {
    	try {
        	manager.begin();
            target.saveAccount(); // 실제 타겟 객체의 메서드가 private이라 호출할수 없으므로 에러 발생!
            manager.commit();
        } catch (RuntimeException e) {
        	manager.rollback();
        }
        // ...
        // ...
    }
    

    @Override
    public void saveAccountAndTrace() {
		target.saveAccountAndTrace();  // 실제 타겟 객체의 메서드가 private이라 호출할수 없으므로 에러 발생!
	}
}

 

 

또한 프록시 객체는 위에서 설명했듯이,

타겟 타입의 멤버 변수를 갖고 있어서, 각 부가 기능을 적용하는 과정에서 타겟 메서드를 직접 호출해야 한다.

하지만 타겟 메서드가 private으로 선언되어있으면, 프록시 객체에서 타겟 메서드를 호출할 수 없으므로

@Transactional 어노테이션과 마찬가지로 프록시를 사용할 수 없게 된다.

관련글 더보기

댓글 영역