Project/Newsfeed

[프로젝트] AOP로 'API 수행 시간/회원 별 총 API 사용시간 누적 저장' 기능 다르게 구현하기와 그에 따른 고민

쉬지마 이굥진 2024. 4. 10. 00:50

내가 구현하고 싶은/구현해야 할 기능은 두 가지가 있었다. 

회원 별 API 사용 시간 측정(저장) 기능과 API 수행시간 측정 기능이다. 이 기능들은 '핵심기능' 이라기 보단 '부가기능'에 가까웠으므로, 모듈화해서 부가기능 중심으로 설계, 구현하는게 맞다고 본 것이 AOP를 사용한 결정적 이유겠다.

두 가지 기능의 성격이 살짝 달라서 고민 후에 한 개는 @Pointcut으로 조인포인트를 설정해서 구현하고, 한 개는 애너테이션을 직접 만들어서 구현했다.

 

본 포스팅에서는 각 기능을 구현한 방법과 왜 이렇게 구현했는지에 대한 고민, 이후 고려해야 할 점들 등에 대해서 기술했다.

 

목차
    - 회원 별 총 API 사용시간 누적 저장 구현
         구현 과정
    - API 수행 시간 측정 구현
         구현 전 고민 
         구현 과정
    -고려한 점
         각 방식의 장/단점
         이에 따른 유지보수 상황 고민

 

✏️1. 회원 별 총 API 사용시간 누적 저장

 

먼저 이 기능은 마케팅팀으로부터 애플리케이션 사용 시간 통계 요구를 받았다고 가정한 후 구현을 시작했다.

가끔 뉴스 같은 걸 보면, 예를 들어 'A'라는 어플 유저들이 1분기에는 20000시간을 사용했는데 2분기에는 10000시간 사용으로 줄었더라 - 이런 뉴스를 본 기억이 있었다. 이럴 때 이건 무슨 기준으로, 어떻게 계산한거지? 라는 생각이 들었었는데, 이 기능을 구현 해 보고 싶어서 이걸 API 사용 시간으로 생각하고 만들어보았다. 

 

구현 과정

1-1.  회원 별 API 사용시간을 저장하기 위한 Entity 만들어주기 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 기능 : 회원 별 api 사용 시간 측정용 Entity
@Entity
@Getter
@Setter
@NoArgsConstructor
@Table(name = "api_use_time")
public class ApiUseTime {
 
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
 
    @OneToOne
    @JoinColumn(name = "user_id", nullable = false)
    private User user;
 
    @Column(nullable = false)
    private Long totalTime;
 
    public ApiUseTime(User user, Long totalTime) {
        this.user = user;
        this.totalTime = totalTime;
    }
 
    public void addUseTime(long useTime) {
        this.totalTime += useTime;
    }
}
cs

 

User 엔티티와 1:1로 매핑 해주고, 총 사용시간을 저장하기 위한 totalTime 컬럼을 만들어 줬다.

 

1-2.  AOP 클래스 만들기 - 포인트컷 expression 방식

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
// 기능 : 부가 기능을 추가하기 위한 AOP 클래스
@Slf4j(topic = "UseTimeAop")
@Aspect
@Component
public class UseTimeAop {
 
    private final ApiUseTimeRepository apiUseTimeRepository;
 
    public UseTimeAop(ApiUseTimeRepository apiUseTimeRepository) {
        this.apiUseTimeRepository = apiUseTimeRepository;
    }
 
    @Pointcut("execution(* com.sparta.newsfeed.controller.BoardController.*(..))")
    private void board() {}
    @Pointcut("execution(* com.sparta.newsfeed.controller.CommentController.*(..))")
    private void comment() {}
    @Pointcut("execution(* com.sparta.newsfeed.controller.FollowController.*(..))")
    private void follow() {}
 
 
    @Around("board() || comment() || follow()")
    public Object execute(ProceedingJoinPoint joinPoint) throws Throwable {
        // 측정 시작 시간
        long startTime = System.currentTimeMillis();
 
        try {
            // 핵심 기능 수행
            Object output = joinPoint.proceed();
            return output;
        } finally {
            // 측정 종료 시간
            long endTime = System.currentTimeMillis();
            // 수행시간 = 종료 시간 - 시작 시간
            long runTime = endTime - startTime;
 
            // 로그인 회원이 없는 경우, 수행시간 기록하지 않음
            Authentication auth = SecurityContextHolder.getContext().getAuthentication();
            if (auth != null && auth.getPrincipal().getClass() == UserDetailsImpl.class) {
                // 로그인 회원 정보
                UserDetailsImpl userDetails = (UserDetailsImpl) auth.getPrincipal();
                User loginUser = userDetails.getUser();
 
                // API 사용시간 및 DB에 기록
                ApiUseTime apiUseTime = apiUseTimeRepository.findByUser(loginUser).orElse(null);
                if (apiUseTime == null) {
                    // 로그인 회원의 기록이 없으면
                    apiUseTime = new ApiUseTime(loginUser, runTime);
                } else {
                    // 로그인 회원의 기록이 이미 있으면
                    apiUseTime.addUseTime(runTime);
                }
 
                log.info("[API 사용 시간] Username : " + loginUser.getUsername() + ", Total Time : " + apiUseTime.getTotalTime() + " ms");
                apiUseTimeRepository.save(apiUseTime);
            }
        }
    }
}
cs

 

일단 횡단 관심사를 정의하는 이 클래스가 AOP 프레임워크에 의해 관리되게 하려면 @Aspect 애너테이션 적용이 필요하다.

그런데 이 애너테이션은 스프링 Bean 클래스에만 적용 가능하므로, @Component 애너테이션을 붙임으로써 Bean으로 만들어줬다.

 

그 다음 엔티티에 총 사용 시간을 누적 저장하기 위한 Repository 클래스를 만들어서 생성자 주입 해줬다.

 

또, 내가 생각하기에 사용자의 유의미한 활동이라고 생각하는 Controller의 모든 API 사용 시간을 저장해야 했기에 @Pointcut 애너테이션으로 각 컨트롤러 패키지의 모든 클래스에 AOP가 적용되도록 조인포인트를 설정해줬다.

 

@Around 애너테이션으로 '핵심기능' 수행 전과 후에 AOP가 적용되도록 한 다음 측정 로직 구현! API 수행시간은 결국 메서드 종료 시간에서 시작 시간을 뺀 거겠지 🤗

 

1-3. 확인

이렇게 로직 짠 뒤에 해당 패키지의 API를 실행해서 확인해준다.

유저 아이디와 누적 시간이 잘 출력된다.
DB에도 잘 반영됐다.


✏️2. API 수행 시간 측정

이 기능 같은 경우는, 원래는 만들 계획이 없었으나 부하 테스트를 진행하려면 특정 API가 수행되는 시간을 알아야 했기에 추후에 추가한 기능이다.

 

구현 전 고민

이 기능을 만들기 전 어떤 방식으로 구현할까에 대해 고민을 했다.

상단의 AOP 기능을 포인트컷 expression 방식으로 구현을 해봤었기 때문에, 기능 구현 시간 단축을 위해선 똑같은 방식으로 구현하는 편이 좋다고 생각했다.

 

그러나 사실 API 수행 시간 측정 기능이 모든 패키지 단위로 필요한 것도 아니었고, 부하 테스트를 위해 수행 시간을 알아야하는 API에만 적용하면 되는 기능이라.. 포인트컷 expression 방식으로 구현하는 건 모든 메서드의 실행 시간을 측정하게 되서 불필요한 로깅이 발생되고 나중에 비즈니스 로직이 복잡해졌을 때 성능에 부정적인 영향을 줄 수 있겠다고 생각했다. 

 

따라서 메서드 단위로 적용할 수 있는 애너테이션을 커스텀한 후, 애너테이션을 기반으로 Pointcut을 지정하는 방식으로 구현을 해봐야겠다고 결정하고 구현 시작!!

 

구현 과정

2-1.  ExeTimer 애너테이션 커스텀하기

1
2
3
4
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface ExeTimer {
}
cs

 

 

먼저, 타이머 인터페이스를 애너테이션화해서 @Target으로 애너테이션 적용 대상을 메서드로 지정해줬다.

그 후 @Retention으로 애너테이션 유지 기간을 런타임 시점까지로 설정해줬다.

 

2-2. AOP 클래스 만들기

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
@Aspect
@Component
public class TimeMeasurementAop {
 
    private static final Logger logger = LoggerFactory.getLogger(TimeMeasurementAop.class);
 
    // 조인 포인트를 어노테이션으로 설정
    @Pointcut("@annotation(com.sparta.newsfeed.aop.annotation.ExeTimer)")
    private void timer(){}
 
 
    @Around("timer()")
    public Object AssumeExecutionTime(ProceedingJoinPoint joinPoint) throws Throwable {
 
        StopWatch stopWatch = new StopWatch();
 
        try {
            stopWatch.start();
            return joinPoint.proceed();
        } finally {
            stopWatch.stop();
 
            long totalTimeMillis = stopWatch.getTotalTimeMillis();
 
            MethodSignature signature = (MethodSignature) joinPoint.getSignature();
            String methodName = signature.getMethod().getName();
 
            logger.info("실행 메서드: {}, 총 실행시간 = {}ms", methodName, totalTimeMillis);
        }
        // 조인포인트의 메서드 실행
    }
}
cs

 

포인트컷 애너테이션으로 조인 포인트를 애너테이션으로 설정하고, 1번에서 커스텀한 @ExeTimer 애너테이션이 붙은 메서드들을 대상으로 지정해줬다.

 

2-3. 메서드에 애너테이션 붙여주기

마지막으로 이 애너테이션을 시간 측정 기능이 필요한 메서드 위에 붙여주면 된다.나는 글 조회 API와 팔로우 API 실행 시간만 필요했으므로 이 두 개의 메서드에만 붙여줬다. 

1
2
3
4
5
6
// 글 상세 보기
    @ExeTimer
    @GetMapping("/boards/{id}")
    public ResponseEntity<List<BoardResponseDto>> getOneBoard(@PathVariable Long id){
        return ResponseUtil.response(boardService.getOneBoard(id));
    }
cs
1
2
3
4
5
6
7
8
9
// 팔로우 등록
    @ExeTimer
    @PostMapping("/follow/{followingId}"
    public ResponseEntity<GlobalResponseDto> create(@PathVariable Long followingId, @AuthenticationPrincipal final UserDetailsImpl userDetails) {
        Long followerId = userDetails.getUser().getId();
        followService.create(followingId, followerId);
 
        return ResponseUtil.response(StatusCode.FOLLOW_OK);
    }
cs

 

2-4. 확인

마지막으로 애너테이션을 붙인 메서드를 실행시켜주면,

실행 메서드 이름과 실행 시간이 잘 출력됨을 확인했다.

 


✏️ 고려한 점 

두 번째 AOP 클래스를 구현하면서, 각 방식들의 장/단점에 대해 생각해봤다. 개발할 때는 어떤 코드가 더 효율적일지 장단점이나 유지보수 용이성 등등을 생각해가면서 구현해야 나중이 편하다고 생각하기 때문이다.

 

2번 방식의 장/단점

- 장점

  특정 애너테이션을 명시적으로 붙여줌으로써 실행 시간을 측정하고자 하는 메서드를 개발자가 세밀하게 제어할 수 있다.

- 단점

  모든 메서드에 일일이 애너테이션을 붙여줘야 한다. 이는 유지 보수 시간을 증가시키고, 실수로 애너테이션 삽입/삭제를 누락할 위험 이 있다.

 

1번 방식의 장/단점

- 장점

  특정 패키지 내의 모든 메서드에 대해 별 다른 설정 없이도 측정하도록 할 수 있어 유지 보수 시간을 줄일 수 있다. 

- 단점

  특정 패키지 내의 모든 메서드를 대상으로 하기 때문에, 필요하지 않은 메서드까지 실행 시간을 측정하게 될 수 있다.

  포스팅 상단에 작성해 둔 것 처럼, 이는 불필요한 로깅이 발생될 수 있고 곧 성능에 부정적인 영향을 줄 수 있다.

 

 

의사결정

2번 기능의 경우, 측정하려는 메서드가 명확했고 그 가짓수가 얼마 되지 않았기 때문에 애너테이션을 커스텀해서 조인포인트를 애너테이션으로 설정하는 방식으로 구현했다.

지금은 이렇게 구현했지만, 만약 서비스의 규모가 더 커지고, 성능 테스트를 위해서 실행 시간을 측정해야 하는 API 가짓수가 많아진다면 유지보수의 용이성을 위해 방식을 1번 방식으로 바꿀 필요성이 있을 것 같다.