"인프런의 스프링 입문 - 코드로 배우는 스프링 부트, 웹 MVC, DB 접근 기술 강의를 듣고 작성한 글 입니다."
처음 AOP를 이론적으로 공부하려고 하면 온갖 용어들과 디테일한 부분들 때문에 멘붕에 빠진다.
C 언어로 따지면 포인터 급의 진입장벽을 자랑한다.
하지만 AOP는 언제, 왜 쓰는지 알면 전혀 어려운게 아니다.
기본적인 개념은 이번 강의 정리글을 통해 익혀두고 나중에 필요하면 디테일한 용어들 까지 곁들여 가며 공부해보자.
AOP 가 필요한 상황?
모든 메소드의 호출 시간을 측정해야 하는 상황이면 어떻게 해야할까?
만약 악덕 상사가 나타나 '요즘 시스템에 문제가 있는것 같은데 모든 메소드의 호출 시간을 남겨보라' 는 지시를 내린다는 상황을 가정해보자.
이걸 수행하기 위해 helloController 든 memberService 든 memberRepository 든, 모든 스프링 빈 객체에 시간 측정 로직을 작성한다고 하자.
그런데 이 로직을 초 단위로 결과가 나오게끔 했는데, 다시 한번 악덕 상사가 '초 단위로 하면 결과를 잘 알아보기 힘드니 밀리세컨드 단위로 측정하라' 고 하면 어떨까?
그럼 또 다시 수많은 메소드들을 하나하나 살펴보면서 코드들을 전부 수정해야 한다.
이제부터 이와 같은 상황을 가정하여 MemberService 에서 회원가입 비즈니스 로직을 제공하는 join 메소드에 시간 측정 로직을 작성해보자.
- MemberService.java
public Long join(Member member){
long start = System.currentTimeMillis();
// 로직이 끝날때 시간을 찍어줘야 하기 때문에 try - finally 를 사용한다.
try{
validateDuplicateMember(member);
memberRepository.save(member);
return member.getId();
}finally {
long finish = System.currentTimeMillis();
long timeMs = finish - start;
System.out.println("join = " + timeMs + "ms");
}
}
위와 같이 join 메소드를 수정해 준 후 회원가입 테스트 코드를 동작시켜 보면 다음과 같은 출력을 확인할 수 있다.
join = 174ms (강사님이랑 다르게 시간이 꽤 오래 걸렸다. 강사님은 7ms 라는 결과가 나왔다.)
그런데 여기서 문제점, 악덕 상사는 모든 메소드들의 호출 시간을 기록하라고 했으니 다른 메소드들에도 위의 join 메소드와 같은 수정을 해주어야 한다.
메소드 호출 시간을 측정하는 코드는 모두 동일하게 적용하더라도 결국 모든 메소드의 내부 코드들을 try - finally 형식으로 바꿔줘야 하기 때문에 굉장히 반복적이고 많은 양의 작업을 수행해야 한다.
이런 방식의 문제점은 다음과 같다.
1. 회원가입, 회원조회 에서 시간을 측정하는 기능은 핵심 관심사항이 아니다.
2. 시간을 측정하는 로직은 공통 관심사항(cross-cutting concern) 이다. (반대는 핵심 관심사항(core concern) 라고 한다.)
3. 시간을 측정하는 로직과 핵심 비즈니스 로직이 섞여서 유지 보수를 하기가 어렵다.
4. 시간을 측정하는 로직을 별도의 공통 로직으로 만들기 매우 어렵다.
5. 시간을 측정하는 로직을 변경할 때 모든 로직을 찾아가면서 변경해야 한다.
이와 같은 문제점들이 있음에도 불구하고 어쨌든 몇백개는 족히 넘어가는 메소드들의 시간 측정 작업을 다 했다고 치자.
이제 스프링 고수가 오더니 이런말을 해준다.
'어? 그거 AOP 쓰면 쉽게 할 수 있는데요?'
AOP : Aspect Oriented Programming - 관점 지향 프로그래밍
몇백개에서 약 천개가 넘어가는 메소드들의 시간 측정처럼 공통된 로직을 추가해줘야 하는 문제를 해결해주는 기술을 AOP 라고 한다.
- 공통 관심사항(cross-cutting concern) 과 핵심 관심사항(core concern) 을 분리해준다.
* 이전 방식의 경우 시간 측정로직을 작성해놓은 메소드에 전부 다 추가 해주었는데, AOP 를 사용하면 이와는 다르게 시간 측정로직과 같은 것을 한 곳에 모아둔 다음 내가 원하는 곳에 적용시키는 방식으로 처리해 줄 수 있다.
aop 패키지를 만들고 거기에 TimeTraceAop 클래스를 만들어 준 다음 아래와 같이 코드를 작성해준다.
- TimeTraceAop.java
@Aspect // 이 어노테이션이 있어야 이 클래스를 AOP 로 사용 가능해진다.
public class TimeTraceAop {
@Around("execution(* hello.hellospring..*(..))") // hellospring 패키지 하위에 있는 클래스들에 모두 적용시킨다는 뜻
public Object execute(ProceedingJoinPoint joinPoint) throws Throwable {
long start = System.currentTimeMillis();
System.out.println("START : " + joinPoint.toString());
try {
return joinPoint.proceed(); // 다음 메소드로 진행시켜 주는 메소드
}finally {
long finish = System.currentTimeMillis();
long timeMs = finish - start;
System.out.println("END : " + joinPoint.toString() + " " + timeMs + "ms");
}
}
}
위와 같이 코드를 작성해주고 나면 이제 스프링 빈으로 객체를 등록해줄 차례이다.
스프링 빈으로 등록할 경우 @Component 어노테이션을 사용하여 컴포넌트 스캔을 해도 무방하나, AOP 의 경우 자바 코드로 직접 등록을 해주는 쪽이 선호된다고 한다.
SpringConfig 클래스 파일을 다음과 같이 작성해주자.
- SpringConfig.java
@Configuration
public class SpringConfig {
private final MemberRepository memberRepository;
@Autowired
public SpringConfig(MemberRepository memberRepository) {
this.memberRepository = memberRepository;
}
@Bean
public MemberService memberService(){
return new MemberService(memberRepository);
}
@Bean
public TimeTraceAop timeTraceAop(){
return new TimeTraceAop();
}
}
다른 개발자들과 협업할 때 이렇게 자바 코드로 직접 TimeTraceAop 객체를 스프링 빈으로 등록해주면 협업하고 있는 다른 개발자들이 설정 파일을 보고 AOP 가 쓰인다는 것을 확인할 수 있을 것이다.
(강의 중에 강사님께서 스프링 빈 등록 방식을 컴포넌트 스캔 방식으로 바꾸셨는데, 어차피 스프링 빈으로 등록시켜 주는 동작 자체는 똑같이 수행되니 굳이 똑같이 수정하지는 말자.)
그 와중에 발생한 에러....
그런데 AOP를 컴포넌트 스캔 방식이 아닌 자바 코드로 직접 스프링 빈으로 등록해준 후, 강의 대로 어플리케이션을 동작시켜 보았는데 다음과 같은 에러가 발생했다.
The dependencies of some of the beans in the application context form a cycle:
┌─────┐
| timeTraceAop defined in class path resource [hello/hellospring/SpringConfig.class]
└─────┘
알아보니 이 에러는 스프링 빈들 간에 의존성 주입을 해줄 때 서로 다른 객체들 간에 Bean A -> Bean B -> Bean A 와 같이 의존성 주입 관계가 서로 물고 물려 순환 형태를 형성하는 경우 발생하는 순환 참조 에러였다.
(B에서 A를, A에서 B를 의존성 주입 받아서 서로 물고 물리는 형태)
이 문제가 어떻게 발생하게 되었을까?
우선 강의 중 작성되었던 @Around 어노테이션을 자세히 보자.
@Around("execution(* hello.hellospring..*(..))")
이 어노테이션 에서 AOP 가 적용되는 패키지들을 지정해주고 있는데, 이를 통해 AOP 가 적용되는 패키지 내부에 SpringConfig 클래스가 존재한다.
다시 SpringConfig 클래스를 보자.
- SpringConfig.java
@Bean
public TimeTraceAop timeTraceAop(){
return new TimeTraceAop();
}
SpringConfig 클래스가 AOP 적용을 받는데 막상 SpringConfig 클래스를 보니 여기서는 또 AOP 클래스를 스프링 빈으로 등록시켜 주고 있다.
SpringConfig 클래스를 통해 AOP 를 스프링 빈으로 등록하고 보니 AOP 에서는 또 SpringConfig 를 적용대상으로 지정하고 있고, 그래서 AOP 를 적용하러 또 SpringConfig 에 가봤더니 아까와 마찬가지로 SpringConfig 는 AOP 클래스를 스프링 빈으로 등록하는 메소드를 가지고 있고............ 이게 계속 반복이다.
결과적으로 SpringConfig 클래스와 AOP 클래스간에 서로 물고 물리는 순환 참조가 형성된 것이다.
이를 해결하려면 AOP 를 적용할 때 아래와 같이 SpringConfig 클래스를 제외시키면 된다.
- SpringConfig.java
@Aspect // 이 어노테이션이 있어야 이 클래스를 AOP 로 사용 가능해진다.
public class TimeTraceAop {
// hellospring 패키지 하위에 있는 클래스들에 모두 적용시킨다는 뜻
// SpringConfig 클래스는 제외시킨다.(순환 참조오류 방지)
@Around("execution(* hello.hellospring..*(..)) && !target(hello.hellospring.SpringConfig)")
public Object execute(ProceedingJoinPoint joinPoint) throws Throwable {
long start = System.currentTimeMillis();
System.out.println("START : " + joinPoint.toString());
try {
return joinPoint.proceed(); // 다음 메소드로 진행시켜 주는 메소드
}finally {
long finish = System.currentTimeMillis();
long timeMs = finish - start;
System.out.println("END : " + joinPoint.toString() + " " + timeMs + "ms");
}
}
}
이와 같이 작성해주고 나면 어플리케이션이 오류 없이 정상적으로 잘 동작하는 것을 확인할 수 있다.
- SpringConfig 클래스에서 자바로 직접 AOP 클래스를 스프링 빈으로 등록해주는 코드를 주석처리 하고
- 컴포넌트 스캔 방식을 활용하면 마찬가지로 어플리케이션이 잘 동작하면서
- AOP 를 적용시킬 때 자기자신으로 되돌아오는 곳이 없기 때문에 순환 참조오류가 발생하지 않는다.
- 컴포넌트 스캔을 활용한 이후 어플리케이션을 동작시키면, AOP 클래스에 작성해줬던 시스템 출력문이 로그상에 잘 나타나게 되는데
- 자바 코드로 AOP를 등록해 줄 때는 이게 뜨지 않는다.
- 강사님 쪽에서 띄워진 로그를 보면 SpringConfig 에서 memberService() 메소드를 통해 MemberService 객체를 스프링 빈으로 등록해주면서 AOP 로 적용시킨 시간 측정코드가 동작하는 것 같은데
- 컴포넌트 스캔의 경우 SpringConfig 클래스에도 AOP를 순환 참조오류 없이 적용시키는 것이 가능하기 때문에
- memberService() 메소드에 대해서 시간 측정 출력로그가 나타난 것이라는 생각이 든다.(아마 확실할 거다.)
URL 로 접속해보자.
이제 여기서 localhost:8080 URL 로 접속해서 회원목록 링크를 눌러 기능을 수행시킨 뒤 출력로그가 어떻게 되는지 살펴보면 다음과 같은 로그가 출력된 것을 확인해볼 수 있다.
START : execution(String hello.hellospring.controller.HomeController.home())
END : execution(String hello.hellospring.controller.HomeController.home()) 5ms
START : execution(String hello.hellospring.controller.MemberController.list(Model))
START : execution(List hello.hellospring.service.MemberService.findMembers())
START : execution(List org.springframework.data.jpa.repository.JpaRepository.findAll())
Hibernate: select member0_.id as id1_0_, member0_.name as name2_0_ from member member0_
END : execution(List org.springframework.data.jpa.repository.JpaRepository.findAll()) 124ms
END : execution(List hello.hellospring.service.MemberService.findMembers()) 130ms
END : execution(String hello.hellospring.controller.MemberController.list(Model)) 144ms
위의 로그에서 hibernate 가 SQL 쿼리를 생성해주는 로그를 제외하고 살펴보면, 수행되는 각 메소드마다 AOP 가 잘 적용되서 시간 측정로직이 잘 동작하는 것을 확인해 볼 수 있다.
(물론 기존에 join 메소드에 작성해 줬던 시간 측정로직은 제거해준다.)
2편에서 계속....
'Spring basic' 카테고리의 다른 글
스프링 핵심원리 : 기본편 - 스프링과 좋은 객체지향 프로그래밍에 대하여.... (0) | 2022.02.09 |
---|---|
스프링 입문 : 코드로 배우는 스프링 부트, 웹 MVC, DB 접근 기술 - AOP #2 (0) | 2021.11.17 |
스프링 입문 : 코드로 배우는 스프링 부트, 웹 MVC, DB 접근 기술 - Spring Data JPA (0) | 2021.11.16 |
스프링 입문 : 코드로 배우는 스프링 부트, 웹 MVC, DB 접근 기술 - JPA (0) | 2021.11.15 |
스프링 입문 : 코드로 배우는 스프링 부트, 웹 MVC, DB 접근 기술 - 스프링 JdbcTemplate (0) | 2021.11.11 |