λ³Έλ¬Έ λ°”λ‘œκ°€κΈ°
λ°±μ—”λ“œ/spring&springboot

[내일배움캠프_Spring Boot 심화주차] Spring AOP

by alswlfl 2026. 7. 1.

My 셀렉샡 Top5 νšŒμ› μ°ΎκΈ° 섀계 및 κ΅¬ν˜„

μš”κ΅¬μ‚¬ν•­

  • 2μ£Όκ°„ 'My 셀렉샡'을 κ°€μž₯ 많이 μ‚¬μš©ν•˜λŠ” Top5 νšŒμ›λ“€μ—κ²Œ μ„ λ¬Ό 증정을 μœ„ν•΄, νšŒμ›λ³„ 'My 셀렉샡' μ‚¬μš©μ‹œκ°„ μ €μž₯
  • 'My 셀렉샡' μ„œλ²„ μ‚¬μš© μ‹œκ°„μ„ κΈ°μ€€μœΌλ‘œ μ„€μ •
    • μ„œλ²„ μ‚¬μš© μ‹œκ°„: 'My 셀렉샡'의 μƒν’ˆ 및 폴더와 κ΄€λ ¨λœ API μ‚¬μš©μ‹œκ°„μ˜ 총 ν•©
      1. μƒν’ˆ 쑰회 API("GET /api/search") μˆ˜ν–‰μ‹œκ°„
      2. κ΄€μ‹¬μƒν’ˆ 등둝 API("POST /api/products") μˆ˜ν–‰μ‹œκ°„
      3. 폴더 μ €μž₯ API("POST /api/folders") μˆ˜ν–‰μ‹œκ°„
      4. ...
  • API μ‚¬μš© μ‹œκ°„ = Controller에 μš”μ²­μ΄ λ“€μ–΄μ˜¨ μ‹œκ°„ - 응닡이 λ‚˜κ°„ μ‹œκ°„

슀크래치 파일(Scratch File)

슀크래치 νŒŒμΌμ€ ν”„λ‘œμ νŠΈμ™€ 상관없이 μ–΄λ–€ νŠΉμ •ν•œ method ν˜Ήμ€ μ½”λ“œλ₯Ό μˆ˜ν–‰μ‹œμΌœλ³΄κ³  싢을 λ•Œ μ‚¬μš©ν•˜λŠ” 파일

슀크래치 파일 μœ„μΉ˜

class Scratch {
    public static void main(String[] args) {
        // μΈ‘μ • μ‹œμž‘ μ‹œκ°„
        long startTime = System.currentTimeMillis();

        // ν•¨μˆ˜ μˆ˜ν–‰
        long output = sumFromOneTo(1_000_000_000);

        // μΈ‘μ • μ’…λ£Œ μ‹œκ°„
        long endTime = System.currentTimeMillis();

        long runTime = endTime - startTime;
        System.out.println("μ†Œμš”μ‹œκ°„: " + runTime);
    }

    private static long sumFromOneTo(long input) {
        long output = 0;

        for (int i = 1; i < input; ++i) {
            output = output + i;
        }

        return output;
    }
}

My μ…€λ ‰ 샡 ν”„λ‘œμ νŠΈμ— μ„œλ²„ μ‚¬μš© μ‹œκ°„ μΆ”κ°€ν•˜κΈ°

ApiUseTime Entity와 Repository 생성

package com.sparta.myselectshop.entity;

import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@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;
    }
}
package com.sparta.myselectshop.repository;

import com.sparta.myselectshop.entity.ApiUseTime;
import com.sparta.myselectshop.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface ApiUseTimeRepository extends JpaRepository<ApiUseTime, Long> {
    Optional<ApiUseTime> findByUser(User user);
}

 

κ΄€μ‹¬μƒν’ˆ μ €μž₯ 뢀뢄에 적용

@RestController
@RequiredArgsConstructor
@RequestMapping("/api")
public class ProductController {

    private final ProductService productService;
    private final ApiUseTimeRepository apiUseTimeRepository;

    @PostMapping("/products")
    public ProductResponseDto createProduct(@RequestBody ProductRequestDto requestDto, @AuthenticationPrincipal UserDetailsImpl userDetails) {
        // μΈ‘μ • μ‹œμž‘ μ‹œκ°„
        long startTime = System.currentTimeMillis();

        try {
            // 응닡 보내기
            return productService.createProduct(requestDto, userDetails.getUser());
        } finally {
            // μΈ‘μ • μ’…λ£Œ μ‹œκ°„
            long endTime = System.currentTimeMillis();
            // μˆ˜ν–‰μ‹œκ°„ = μ’…λ£Œ μ‹œκ°„ - μ‹œμž‘ μ‹œκ°„
            long runTime = endTime - startTime;

            // 둜그인 νšŒμ› 정보
            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);
            }

            System.out.println("[API Use Time] Username: " + loginUser.getUsername() + ", Total Time: " + apiUseTime.getTotalTime() + " ms");
            apiUseTimeRepository.save(apiUseTime);
        }
    }

	...
}

μ‚¬μš© μ‹œκ°„ μˆ˜ν–‰ κ²°κ³Ό

λΆ€κ°€ κΈ°λŠ₯ λͺ¨λ“ˆν™”μ˜ ν•„μš”μ„±

  • 'Top5 νšŒμ› μ°ΎκΈ°' κΈ°λŠ₯이 μΆ”κ°€λœ 것을 'My 셀렉샡' νšŒμ›λ“€μ€ μ•Œ μˆ˜λ„ μ—†κ³  μ•Œ ν•„μš”λ„ μ—†μŒ
  • 핡심 κΈ°λŠ₯: 각 API 별 μˆ˜ν–‰ν•΄μ•Ό ν•  λΉ„μ¦ˆλ‹ˆμŠ€ 둜직
    ex) μƒν’ˆ ν‚€μ›Œλ“œ 검색, κ΄€μ‹¬μƒν’ˆ 등둝, νšŒμ› κ°€μž…, κ΄€μ‹¬μƒν’ˆμ— 폴더 μΆ”κ°€, ...

핡심 κΈ°λŠ₯

  • λΆ€κ°€ κΈ°λŠ₯: 핡심기λŠ₯을 λ³΄μ‘°ν•˜λŠ” κΈ°λŠ₯
    ex) νšŒμ› νŒ¨ν„΄ 뢄석을 μœ„ν•œ 둜그 기둝, API μˆ˜ν–‰μ‹œκ°„ μ €μž₯, ...

보쑰 κΈ°λŠ₯

문제점

  • λͺ¨λ“  '핡심 κΈ°λŠ₯'의 Controller에 'λΆ€κ°€ κΈ°λŠ₯' μ½”λ“œλ₯Ό μΆ”κ°€ν–ˆμ„ λ•Œ
    • '핡심 κΈ°λŠ₯'이 100개라면, 100개의 '핡심 κΈ°λŠ₯' λͺ¨λ‘μ— λ™μΌν•œ λ‚΄μš©μ˜ μ½”λ“œ μΆ”κ°€ ν•„μš”
    • '핡심 κΈ°λŠ₯'이 λ‚˜μ€‘μ— μΆ”κ°€λœλ‹€λ©΄, 항상 'λΆ€κ°€ κΈ°λŠ₯' μΆ”κ°€λ₯Ό 신경써야함
  • '핡심 κΈ°λŠ₯' μˆ˜μ • μ‹œ
    • 같은 ν•¨μˆ˜ 내에 '핡심 κΈ°λŠ₯'κ³Ό 'λΆ€κ°€ κΈ°λŠ₯'이 μ„žμ—¬ 있음
    • '핡심 κΈ°λŠ₯'이해λ₯Ό μœ„ν•΄ 'λΆ€κ°€ κΈ°λŠ₯'κΉŒμ§€ 이해 ν•„μš”ν•¨

λΆ€κ°€ κΈ°λŠ₯ λͺ¨λ“ˆν™”

AOP(Aspect Oriented Programming)λ₯Ό 톡해 λΆ€κ°€ κΈ°λŠ₯ λͺ¨λ“ˆν™”

  • λΆ€κ°€ κΈ°λŠ₯은 핡심 κΈ°λŠ₯κ³ΌλŠ” 관점(Aspect), 관심이 닀름
  • λ”°λΌμ„œ 핡심 κΈ°λŠ₯κ³Ό 뢄리해 λΆ€κ°€ κΈ°λŠ₯ μ€‘μ‹¬μœΌλ‘œ 섀계 및 κ΅¬ν˜„ κ°€λŠ₯

λΆ€κ°€ κΈ°λŠ₯ λͺ¨λ“ˆν™”


Spring AOP

Spring AOP

1. μ–΄λ“œλ°”μ΄μŠ€: λΆ€κ°€ κΈ°λŠ₯을 핡심 κΈ°λŠ₯에 μ–Έμ œ μˆ˜ν–‰ν•  건지 μ •ν•˜λŠ” 것

  • 핡심 κΈ°λŠ₯이 μˆ˜ν–‰λ˜κΈ° μ „
  • 핡심 κΈ°λŠ₯이 μˆ˜ν–‰λœ ν›„
  • 핡심 κΈ°λŠ₯이 μˆ˜ν–‰λ˜κΈ° μ „κ³Ό ν›„
  • 핡심 κΈ°λŠ₯이 잘 μˆ˜ν–‰λΌμ„œ λ°˜ν™˜λœ κ°’ μ‚¬μš©μ—¬λΆ€
  • 핡심 κΈ°λŠ₯ 였λ₯˜ λ°œμƒ μ‹œ μˆ˜ν–‰μ—¬λΆ€

2. 포인트컷: 적용 μœ„μΉ˜(지점)

Spring AOP μ• λ„ˆν…Œμ΄μ…˜

@Aspect

  • Spring Bean(빈) ν΄λž˜μŠ€μ—λ§Œ 적용 κ°€λŠ₯
  • 즉, AOP μ„€μ •ν•˜λ €λŠ” ν΄λž˜μŠ€μ— μ„€μ •

μ–΄λ“œλ°”μ΄μŠ€ μ’…λ₯˜

  • @Around: '핡심 κΈ°λŠ₯' μˆ˜ν–‰ μ „κ³Ό ν›„(@Before+@After)
  • @Before: '핡심 κΈ°λŠ₯' 호좜 μ „ (ex: Client의 μž…λ ₯κ°’ Validation μˆ˜ν–‰)
  • @After: '핡심 κΈ°λŠ₯' μˆ˜ν–‰ 성곡/μ‹€νŒ¨ 여뢀와 상관없이 μ–Έμ œλ‚˜ λ™μž‘ (try, catch의 finally() 처럼 λ™μž‘)
  • @AfterReturning: '핡심 κΈ°λŠ₯' 호좜 성곡 μ‹œ(λ©”μ„œλ“œμ˜ Return κ°’ μ‚¬μš© κ°€λŠ₯)
  • @AfterThrowing: '핡심 κΈ°λŠ₯' 호좜 μ‹€νŒ¨ μ‹œ, 즉 μ˜ˆμ™Έ(Exception)κ°€ λ°œμƒν•œ 경우만 λ™μž‘ (ex: μ˜ˆμ™Έκ°€ λ°œμƒν–ˆμ„ λ–„ κ°œλ°œμžμ—κ²Œ emailμ΄λ‚˜ SMS 보냄)

포인트컷

 

[포인트 μ»· Expression ν˜•νƒœ]

execution(modifiers-pattern? return-type-pattern declaring-type-pattern ? 
	method-name-pattern(param-pattern) throws-pattern?)
  • '?' λŠ” μƒλž΅ κ°€λŠ₯
  • modifiers-pattern: public, private, *
  • return-type-pattern: void, String, List<String>, *
  • declaring-type-pattern
    • 클래슀λͺ… (νŒ¨ν‚€μ§€λͺ… ν•„μš”)
    • ex) com.sparta.myselectshop.controller.* - controller νŒ¨ν‚€μ§€μ˜ λͺ¨λ“  ν΄λž˜μŠ€μ— 적용
    • ex) com.sparta.myselectshop.controller.. - controller νŒ¨ν‚€μ§€ 및 ν•˜μœ„ νŒ¨ν‚€μ§€μ˜ λͺ¨λ“  ν΄λž˜μŠ€μ— 적용
  • method-name-pattern(param-pattern)
    • ν•¨μˆ˜λͺ…
      - ex) addFolders - addFolders() λ©”μ„œλ“œμ—λ§Œ 적용
      - ex) add* - add둜 μ‹œμž‘ν•˜λŠ” λͺ¨λ“  λ©”μ„œλ“œμ— 적용
    • νŒŒλΌλ―Έν„° νŒ¨ν„΄(param-pattern)
      - ex) com.sparta.myselectshop.dto.FolderRequestDto - FolderRequestDto 인수 (arguments)만 적용
      - (): 인수 μ—†μŒ
      - (*): 인수 1개 (νƒ€μž… μƒκ΄€μ—†μŒ)
      - (..): 인수 0~N개 (νƒ€μž… μƒκ΄€μ—†μŒ)
  • @Pointcut
    • 포인트컷 μž¬μ‚¬μš© κ°€λŠ₯
    • 포인트컷 κ²°ν•©(combine) κ°€λŠ₯
@Component
@Aspect
public class Aspect {
	@Pointcut("execution(* com.sparta.myselectshop.controller.*.*(..))")
    private void forAllController() {}
    
    @Pointcut("execution(String com.sparta.myselectshop.controller.*.*(..))")
    private void forAllViewController() {}
    
    @Around("forAllController() && !forAllViewController()")
    public void saveRestApiLog() {
    	...
    }
    
    @Around("forAllController()")
    public void saveAllApiLog() {
    	...
    }
}

 

ν”„λ‘œμ νŠΈμ— μ μš©ν•˜κΈ°

  • aop > UseTimeAop 클래슀 생성
package com.sparta.myselectshop.aop;

import com.sparta.myselectshop.entity.ApiUseTime;
import com.sparta.myselectshop.entity.User;
import com.sparta.myselectshop.repository.ApiUseTimeRepository;
import com.sparta.myselectshop.security.UserDetailsImpl;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;

@Slf4j(topic = "UseTimeAop")
@Aspect
@Component // Bean ν΄λž˜μŠ€μ— 적용
@RequiredArgsConstructor // Bean μ£Όμž…
public class UseTimeAop {

    private final ApiUseTimeRepository apiUseTimeRepository;

    @Pointcut("execution(* com.sparta.myselectshop.controller.ProductController.*(..))")
    private void product() {} // product λΆ€λΆ„
    @Pointcut("execution(* com.sparta.myselectshop.controller.FolderController.*(..))")
    private void folder() {} // folder λΆ€λΆ„
    @Pointcut("execution(* com.sparta.myselectshop.naver.controller.NaverApiController.*(..))")
    private void naver() {} // naver λΆ€λΆ„

    @Around("product() || folder() || naver()")
    public Object execute(ProceedingJoinPoint joinPoint) throws Throwable {
        // μΈ‘μ • μ‹œμž‘ μ‹œκ°„
        long startTime = System.currentTimeMillis();

        try {
            // 핡심기λŠ₯ μˆ˜ν–‰
            Object output = joinPoint.proceed(); // controller λ©”μ„œλ“œ μˆ˜ν–‰
            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 Use Time] Username: " + loginUser.getUsername() + ", Total Time: " + apiUseTime.getTotalTime() + " ms");
                apiUseTimeRepository.save(apiUseTime);
            }
        }
    }
}

μˆ˜ν–‰ ν™”λ©΄

μŠ€ν”„λ§ μ‹€μ œ λ™μž‘

  • 핡심 κΈ°λŠ₯ 사이에 'ν”„λ‘μ‹œ'κ°€ μ‘΄μž¬ν•¨

μŠ€ν”„λ§ μ‹€μ œ λ™μž‘

[AOP 적용 μ „]

AOP 적용 μ „ 흐름

[AOP 적용 ν›„]

AOP 적용 ν›„ 흐름

  • Spring이 ν”„λ‘μ‹œ(κ°€μ§œ ν˜Ήμ€ λŒ€λ¦¬) 객체λ₯Ό 쀑간에 μ‚½μž…ν•΄μ€Œ
  • DispatcherServletκ³Ό ProductController μž…μž₯μ—μ„œλŠ” λ³€ν™”κ°€ μ „ν˜€ μ—†μŒ
  • ν˜ΈμΆœλ˜λŠ” ν•¨μˆ˜μ˜ input, ouput이 μ™„μ „ 동일
  • "joinPoing.proceed()"에 μ˜ν•΄μ„œ μ›λž˜ ν˜ΈμΆœν•˜λ €κ³  ν–ˆλ˜ ν•¨μˆ˜, 인수(argument)κ°€ 전달됨