Spring AOP 스터디 - (1) AOP의 필요성과 기본적인 동작 원리

kindof

·

2022. 10. 18. 11:56

0. 들어가면서

이번 글부터는 몇 개의 연재글을 통해 Spring AOP에 대해 공부해보려고 합니다.

벌써 몇 번째 글을 수정하고 다시 쓰고 하는 것 같은데, 그만큼 Spring AOP를 이해하고 설명하는 데는 꽤 시간이 많이 드는 것 같습니다.


어쨌든, 이번 글에서는 Spring AOP의 기본적인 개념과 필요성을 예제 코드로 확인해보고 AOP와 관련된 기본적인 용어를 정리해보겠습니다.

그리고 다음 글에서부터는 예제 코드에 Spring AOP를 적용해보면서 어떻게 코드가 개선될 수 있으며, 이로 인해 얻을 수 있는 장점은 무엇인지 눈으로 확인해보겠습니다.

1. AOP와 객체지향

Aspect-Oriented Programming (AOP)complements Object-Oriented Programming (OOP)by providing another way of thinking about program structure. The key unit of modularity in OOP is the class, whereas in AOP the unit of modularity is the aspect. Aspects enable the modularization of concerns such as transaction management that cut across multiple types and objects…


스프링 공식 문서에서는 'AOP가 OOP를 더 잘 사용할 수 있도록 보완(Complement) 해주는 하나의 패러다임'이라고 소개하고 있습니다.

그리고 OOP의 큰 원칙 중 하나에는 단일 책임 원칙(Single Responsibility Principle)이 있는데요.
단일 책임 원칙은 하나의 모듈은 하나의 책임만을 가지고 한다는 원칙을 의미합니다.

이 맥락에서 아래와 같은 구조의 코드를 볼까요?

public class MyService {

    private void BusinessA() {
        commonMethodA();
        System.out.println("여기가 비즈니스 로직 A 핵심입니다!");
        commonMethodB();
    }

    private void BusinessB() {
        commonMethodA();
        System.out.println("여기가 비즈니스 로직 B 핵심입니다!");
        commonMethodB();
    }


    private void BusinessC() {
        commonMethodA();
        System.out.println("여기가 비즈니스 로직 C 핵심입니다!");
        commonMethodB();
    }

    private static void commonMethodA(){
        System.out.println("공통 로직 1번");
    }

    private static void commonMethodB() {
        System.out.println("공통 로직 2번");
    }
}

코딩을 해보지 않은 사람이라도 뭔가 반복되는 로직이 거슬린다는 인상을 받을 것 같습니다.


즉, 위와 같은 구조의 코드는 "객체지향적이지 못하다"는 인상을 주는 동시에 "뭔가 공통의 로직만 처리해줄 수 있는 존재가 있으면 좋겠다"는 필요성을 느끼게 합니다.

대충 무슨 말을 하고 싶은지 이해하셨을 것 같지만, 위 코드를 조금만 더 구체화해서 예제를 작성해보겠습니다.


* AOP를 설명하는 데 집중하기 위해 디테일한 구현은 생략합니다.

[User.java]

@Getter @Setter
public class User {
    private String email;
    private String nickname;
}

유저는 이메일과 닉네임만을 갖습니다.

[UserController.java]

@RestController
@RequiredArgsConstructor
@Slf4j
public class UserController {
    private final UserService userService;


    @PostMapping("/user")
    public String userSave(String email, String name) {
        StopWatch stopWatch = new StopWatch();
        stopWatch.start();

        // 회원 가입(실제 로직은 더 복잡)
        User user = new User();
        user.setEmail(email);
        user.setNickname(name);
        userService.save(user);

        stopWatch.stop();
        log.info("[userSave] takes {} ms.", stopWatch.getTotalTimeMillis());

        return "OK";
    }

    @GetMapping("/users")
    public List<User> users() {

        StopWatch stopWatch = new StopWatch();
        stopWatch.start();

        List<User> userList = userService.getUserList();

        stopWatch.stop();
        log.info("[users] takes {} ms.", stopWatch.getTotalTimeMillis());
        return userList;
    }

    @GetMapping("/user/{email}")
    public User user(@PathVariable String email) {
        StopWatch stopWatch = new StopWatch();
        stopWatch.start();

        User user = null;
        try{
            user = userService.getUserByEmail(email);
        } catch (UserNotFoundException e) {
            log.error("[Method: user] call failed: {}", e.getMessage());
        }

        stopWatch.stop();
        log.info("[userById] takes {} ms.", stopWatch.getTotalTimeMillis());

        return user;
    }
}

[UserService.java]

@Service
@RequiredArgsConstructor
public class UserService {
    private final UserRepository userRepository;

    public void save(User user) {
        userRepository.save(user);
    }

    public List<User> getUserList(){
        return userRepository.findAll();
    }

    public User getUserByEmail(String email) throws UserNotFoundException{
        User user = userRepository.findByEmail(email);
        if(user == null){
            throw new UserNotFoundException("해당 유저가 존재하지 않습니다");
        }
        return user;
    }
}

[UserRepository.java]

@Repository
public class UserRepository {

	// 유저 정보를 저장하는 해시맵
    private static HashMap<String, User> userHashMap = new HashMap<>();

    public void save(User user) {
        userHashMap.put(UUID.randomUUID().toString(), user);
    }

    public List<User> findAll(){
        return new ArrayList<>(userHashMap.values());
    }

    public User findByEmail(String email) {

        for (Map.Entry<String, User> entry : userHashMap.entrySet()) {
            if (entry.getValue().getEmail().equals(email)) {
                return entry.getValue();
            }
        }
        return null;

    }
}


회원은 이메일과 닉네임을 가지고 있으며 컨트롤러에서 회원 가입과 회원들의 목록, 이메일로 회원을 조회하는 기능을 구현했습니다.

아주 간단한 코드지만, 아래 빨간색 박스로 표시한 것처럼 UserController에서 많은 양의 코드가 같은 내용(시간 측정)을 처리하는 데 반복적으로 사용되는 것을 확인할 수 있습니다.

많은 부분들이 중복된다.


뿐만 아니라, 위에서 다루지 않았지만 실제 업무에서는 많이 사용되는 아래와 같은 코드들 역시 비슷한 성격을 가지는데요.

  • 메서드의 호출 시간을 측정하기 위한 코드
  • 클라이언트의 행동을 추적하기 위한 로깅 코드
  • 트랜잭션을 처리하기 위한 코드
  • 메서드 실행 시점에 권한을 체크하기 위한 코드
  • 메서드 실행을 위해 유저 정보를 가져오는 데 필요한 코드
  • 메서드에서 반복적으로 처리해주어야 하는 예외 코드
  • ...


AOP는 이렇게 "공통적이고 특정한 관심사에 대한 로직을 따로 관리하고 필요한 곳에서 편하게 사용할 수 있도록 도와줌으로써 좀 더 객체지향적인 프로그래밍을 할 수 있도록 돕는 것을 목표로 한다"는 게 지금까지 설명의 전부입니다.

OOP/AOP -&amp;amp;amp;nbsp;https://linked2ev.github.io/gitlog/2019/09/22/springboot-mvc-14-%EC%8A%A4%ED%94%84%EB%A7%81%EB%B6%80%ED%8A%B8-MVC-Spring-AOP-%EC%84%A4%EC%A0%95/



여기까지 AOP의 컨셉과 필요성에 대해 이해하셨다면, 이제 Spring AOP 관련된 용어에 대해서 정리해보겠습니다.

2. Spring AOP 용어

맨 처음에 AOP 관련 용어를 보면 헷갈리기도 다 외워야하는건가? 하는 생각이 들 수 있습니다.

하지만 우리가 수많은 어노테이션들을 사용하지만 그것들을 처음에 일일이 외우지 않는 것처럼, 아래 용어들도 한 번 이해하려고 노력한 뒤 실제 예제를 보면서 체득하는 편이 좋을 것 같습니다.

Aspect : 여러 클래스 전반에 걸친 공통의 관심사를 모듈화한 것 입니다. 바로 아래에서 설명할 Advice, PointCut을 정리한 모듈이라고 이해하면 됩니다.

Target : 하나 이상의 Advice가 적용되는 '대상'입니다.

 

Join Point : ‘어디에서 공통의 관심사를 적용할 것인가?’를 정의하는 개념입니다. Spring AOP에서는 프록시를 기반으로 AOP를 적용하기 때문에 항상 '메서드'만이 Join Point의 대상이 됩니다.

 

PointCut : Join Point를 조금 구체화한 대상으로 이해하시면 됩니다. 위에서 Join Point는 항상 '메서드'라고 말씀드렸는데, PointCut은 그래서 '어떤 메서드?'에 Advice가 적용될 지를 명시합니다.

Advice : 특정 Join Point(메서드)에서 Aspect에 의해 취해지는 행동을 말합니다. Spring AOP에서는 크게 5가지(Before, AfterReturning, AfterThrowing, After, Around) 종류의 Advice가 존재합니다. 각각에 대한 간단한 설명은 아래와 같습니다.

  • Before: 메서드의 비즈니스 로직 실행 이전에 동작
  • AfterReturing: 메서드 내부에 리턴값이 존재한다면, 리턴 이후 동작
  • AfterThrowing: 메서드 실행 중 예외가 발생하면 동작
  • After: 메서드가 끝나고 무조건 동작
  • Around: 메서드 호출 전후에 동작


처음에는 위 개념들이 서로 비슷한 것 같아 조금 헷갈릴 수 있는데요.

아래는 백기선님이 위에서 설명한 개념을 예시로 풀어서 설명해주신 내용입니다.

https://www.inflearn.com/questions/36746

 

3. AOP와 프록시

이제 AOP의 필요성과 개념에 대해 이해했으니 실제로 AOP를 적용해봐야 합니다. 

 

AOP를 적용한다는 말은 1) 핵심 로직과 부가적인 로직을 물리적으로 분리하고, 2) 필요한 시점과 위치에서 부가적인 로직을 실행시키도록 만든다는 것인데요.

 

이 목표를 구현하는 데는 크게 아래 세 가지 방식이 있지만, AspectJ가 제공하는 위빙 방식은 컴파일러 조작이 복잡한 단점이 있어 'Spring에서는 Proxy를 기반으로 하는 AOP를 채택'하고 있습니다.

  • 컴파일 시점에 적용(위빙) : AspectJ가 제공하는 컴파일러가 .java 파일을 .class 파일로 만드는 시점에 부가적인 코드를 직접 집어넣는(위빙) 방식 
  • 클래스 로딩 시점에 적용(위빙) : 클래스 로더에 보관되어 있는 .class 파일을 JVM에 올리는 시점에 부가적인 코드를 직접 집어넣는(위빙) 방식 
  • 런타임에 적용(프록시) : 아래에서 설명

 

스프링은 기본적으로 IOC, DI를 핵심 컨셉으로 가져갑니다. 그리고 이 컨셉을 그대로 가져가서 AOP를 구현하는 방식으로 '프록시 객체'를 사용하는데요.

CGLIB - Proxy


스프링 부트에서는 어떤 클래스를 Bean으로 등록할 때(AOP 적용을 위한 프록시 객체를 만들 때), 기본적으로 CGLIB라는 바이트 코드 조작 라이브러리를 사용해서 Target 클래스를 상속받은 임의의 다른 클래스를 만들고, 그 다른 클래스를 스프링 빈으로 등록합니다.

Springboot 2.0부터는 프록시를 만들 때 CGLIB를 기본으로 사용한다.


그리고 CGlib는 내부에서 MethodMatcher 인터페이스의 matches() 메서드를 통해 PointCut(Advice가 적용될 메서드)을 확인하고, MethodInterceptor 인터페이스가 구현하는 intercept() 메서드를 통해 프록시 객체에서 타겟 객체를 호출하기 전/후로 비지니스 로직을 추가하는 등의 작업을 하도록 만들어줍니다.

정리하자면, 클라이언트가 Target Object의 메서드를 호출했을 때 AOP를 적용한 대상이라면 CGlib 라이브러리에 의해 프록시로 감싸진 객체가 호출되게 될 것이고, 해당 타겟이 matches() 로직을 타서 PointCut 대상임을 알게 되고, 해당 메서드를 intercept하여 메서드 실행 전, 후 등에서 Advice로 추가한 내용을 실행시키는 것입니다.

4. 정리

이번 글에서는 간략하게 Spring AOP가 무엇인지와 왜 필요한지에 대해 감을 잡아보고 AOP의 중심이 되는 몇 가지 용어들에 대해 정리했습니다.

이제 다음 글에서는 위에서 작성한 코드에 AOP를 적용해보도록 하겠습니다.