스프링

AOP & 캐시 적용하기

쿠마냥 2024. 9. 30. 22:34

1. AOP 적용하기

1-1. aop란 무엇인가?

애플리케이션의 핵심 로직에 영향을 주지 않으면서, 공통적인 기능(예: 로깅, 트랜잭션 관리, 보안)을 코드의 다른 부분에 쉽게 적용할 수 있도록 하는 프로그래밍 패러다임

 

1-2. 핵심 개념

  • Aspect: 횡단 관심사(로깅, 보안 등)를 모듈화한 객체. 여러 모듈에 공통으로 적용되는 기능.
  • JoinPoint: AOP에서 적용할 수 있는 시점(예: 메서드 호출, 예외 처리 등). 어디서 끼어들까?
  • Advice: Aspect가 적용될 구체적인 동작(예: 메서드 실행 전, 후에 실행할 동작). 뭘 할까?
  • Pointcut: Advice를 적용할 지점을 정의하는 표현식. 메서드 실행 전? 후? 지정하기

1-3. interceptor, filter와의 차이점은 무엇인가?

AOP, Interceptor, Filter 모두 핵심 비즈니스 로직에 영향을 주지 않으면서, 특정 시점에 동작을 제공하지만 ‘적용 범위와 목적’에 차이가 있다.

  • Filter
    • SpringMVC가 아니라, Servlet 레벨에서 동작.
    • 인증, 인코딩, 요청의 ip주소 기록 등 ServletRequest, ServletResponse 관련 작업 처리
  • Interceptor
    • SpringMVC에서 컨트롤러 진입 전 후에 동작. 필터와 유사하지만 Spring 컨텍스트 내에서만 동작.
    • 컨트롤러로 해당 요청이 들어가기 전 manager 권한 확인, 인증 등 Spring 컨텍스트 내의 요청/응답 흐름 제어에 사용
  • AOP
    • 비즈니스 로직에 관여. 메서드 단위로 동작. 서비스나 DAO 레이어에서 주로 사용.
    • 특정 메서드 실행 전후의 트랜잭션 관리, 로깅, 예외 처리 등에 사용.

1-4. 적용 단계

1) LoggingAspect 작성

@Aspect
@Component
@RequiredArgsConstructor
public class LoggingAspect {

    private static final Logger logger = LoggerFactory.getLogger(LoggingAspect.class);

    @Pointcut("execution(* com.supershy.moviepedia.review.controller..*(..))")
    public void logPointCut() {}

    @Before("logPointCut()")
    public void logMethodCall(JoinPoint jointPoint) {
        Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
        String userName;

        if (principal instanceof UserDetails) {
            userName = ((UserDetails) principal).getUsername();
        } else {
            userName = "Anonymous";
        }
        String methodName = jointPoint.getSignature().getName();
        logger.info("User '{}' called method '{}'", userName, methodName);

    }
}

 

2) logback-spring.xml

되도록이면 이 이름으로 설정! 스프링은 애플리케이션 컨텍스트를 로드하며 해당 이름의 로그 파일을 찾으려고 시도한다.

<?xml version="1.0" encoding="UTF-8"?>
<configuration>
    <!--defaults.xml 기본 설정. console-appender.xml 콘솔에 로그를 출력하는 설정 -->
    <include resource="org/springframework/boot/logging/logback/defaults.xml"/>
    <include resource="org/springframework/boot/logging/logback/console-appender.xml"/>

    <!-- 로그 출력 위치 지정. ConsoleAppender는 터미널에 로그 출력하는 역할-->
    <appender name="MY_CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <!-- 로그 출력 형식 지정
             %d{yyyy-MM-dd HH:mm:ss} 로그 발생 시간
             %-5level 로그 레벨을 다섯자리로 출력
             %logger{20} 로그를 기록하는 클래스나 패키지 이름을 20자로 출력
             %msg 로그 메시지 출력
             {blue} 파란색으로 출력 -->
            <pattern>%clr(%d{yyyy-MM-dd HH:mm:ss} %-5level %logger{20} - %msg%n){blue}</pattern>
        </encoder>
    </appender>

    <!-- 파일에다 로그를 지정하는 설정. RollingFileAppender는 일정 시간이 지나면 새로운 파일로 롤링(회전)하는 역할 -->
    <appender name="ROLLING_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <!-- 파일 지정 경로 -->
        <file>logs/myapp.log</file>
        <!-- 롤링 policy는 시간을 기준으로 삼겠다. 매일 새로운 로그 파일이 생김 -->
        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
            <!-- 로그 이름 설정 방식-->
            <fileNamePattern>logs/myapp.%d{yyyy-MM-dd}.log</fileNamePattern>
        </rollingPolicy>
        <encoder>
            <pattern>%d{yyyy-MM-dd HH:mm:ss} %-5level %logger{20} - %msg%n</pattern>
        </encoder>
    </appender>

    <!-- root는 최상위 로거. 지정되어 있지 않다면 모두 이 root 로거를 지나간다. -->
    <root level="INFO">
        <appender-ref ref="CONSOLE"/>
    </root>
    <!-- 리뷰 패키지에 대해서는 파일로만 출력하겠다.
     additivity는 상위 로거, 즉 root 로거로 전파하겠다는 뜻 -->
    <logger name="com.supershy.moviepedia.review" level="INFO" additivity="true">
        <appender-ref ref="ROLLING_FILE"/>
    </logger>
</configuration>

적용을 하고 보니 interceptor로 구현할 걸 그랬다는 생각이…. 든다.

 

 

2. Ehcache 적용하기

2-1. Ehcache란?

Java 기반의 오픈 소스 캐싱 솔루션. 메모리에 데이터를 저장하여 성능을 향상시키는 데 사용.

 

2-2. 주요 기능

  • 메모리와 디스크에 캐시 저장.
  • TTL(Time To Live), TTI(Time To Idle) 설정을 통한 캐시 만료 제어.
  • 분산 캐시를 지원하여 여러 서버에서 동일한 캐시를 공유 가능.
  • JCache(JSR-107) 표준을 구현하여 Spring과 같은 프레임워크와의 통합이 용이함.

2-3. 스프링에서 캐시를 적용할 때는 크게 두 가지 방법이 있다.

  • 외부 캐시 (Redis, Memcached / 네트워크 필요 + 애플리케이션 간 공유 가능 + 확장성 up)
  • 내부 캐시 (Ehcache, Caffeine / 네트워크 필요 없음 + 간단한 설정 + 단일 어플리케이션)

2-4. 요즘 많이 쓰는 캐시는 Redis와 Caffeine.

그러나 Ehcache는 설정이 간편해서 쉽게 적용 가능. 로컬 컴퓨터로 돌리기에 딱 좋다.

JCache(Java Caching API)를 따르고 있어서, 손쉽게 다른 캐시로 변경 가능. JCache는 JPA와 비슷하게 인터페이스이며, 중간다리 역할을 해서 캐시와의 의존성을 낮춰준다.

 

2-5. 적용방법

1) build.gradle 추가

// cache
	implementation 'org.springframework.boot:spring-boot-starter-cache'
	implementation 'org.ehcache:ehcache'

	// javax xml bind (java 클래스와 xml 매핑하는 데 도움)
	implementation 'javax.xml.bind:jaxb-api:2.3.1'
	implementation 'org.glassfish.jaxb:jaxb-runtime:2.3.1'

 

2) ehcache.xml 생성

resources 폴더 밑에 생성한다.

<config xmlns:xsi='http://www.w3.org/2001/XMLSchema-instance'
        xmlns='http://www.ehcache.org/v3'
        xsi:schemaLocation="http://www.ehcache.org/v3 http://www.ehcache.org/schema/ehcache-core.xsd">

    <!-- 캐시의 이름을 지정한다. 이름은 rankings-->
    <cache alias="rankings">
        <!-- 캐시의 키 타입은 String, 밸류 타입은 Object이다. -->
        <key-type>java.lang.String</key-type>
        <value-type>java.lang.Object</value-type>
        <!-- 캐시 만료 시간을 설정한다. 1시간이 지나면 만료되도록 설정하였다. -->
        <expiry>
            <!-- ttl은 time to live의 약자. 1시간 동안 살아있다?는 뜻이다. -->
            <ttl unit="hours">1</ttl>
            <!-- unit : days, hours, minutes, seconds, millis, micros, nanos 단위까지 설정이 가능하다-->
        </expiry>

        <!-- 캐시의 생성, 삭제를 이벤트로 처리하여 모니터링하는 것도 가능하다
        CacheEventLogger 클래스의 경로를 써준 다음
        CREATED 생성, EXPIRED 만료 될 때마다 ASYNCHRONOUS 비동기적으로 UNORDERED 순서없이 이벤트를 처리한다.-->
        <listeners>
            <listener>
                <class>com.supershy.moviepedia.common.cache.CacheEventLogger</class>
                <event-firing-mode>ASYNCHRONOUS</event-firing-mode>
                <event-ordering-mode>UNORDERED</event-ordering-mode>
                <events-to-fire-on>CREATED</events-to-fire-on>
                <events-to-fire-on>EXPIRED</events-to-fire-on>
            </listener>
        </listeners>

        <!-- 몇 개의 캐시를 저장할 수 있는지 지정한다. 100개의 엔트리를 캐싱할 수 있도록 설정.  -->
        <resources>
            <!-- heap은 힙 메모리에 캐시 데이터를 관리하는 방식으로 ehcache 3.x 버전부터 deprecated 되었다.
            이유는 heap메모리는 jvm 내부에서 사용되기 때문에 가비지 컬렉터의 대상이 되어 캐시가 지워질 수 있기 때문.
            대안으로는 offheap (힙 메모리 외부에 저장), disk persistent(디스크 메모리 사용) 이 있다. -->
            <!-- <heap unit="entries">100</heap> -->
            <offheap unit="MB">100</offheap>
            <!-- <disk persistent="true" unit="MB">500</disk> -->
        </resources>
    </cache>
</config>

 

3) EhcacheConfig 생성

@EnableCaching
@Configuration
public class EhCacheConfig {

    @Bean
    public CacheManager cacheManager() throws URISyntaxException, IOException {
        // ehcache를 의존성 주입하면 자동으로 잡아서 ehcache provider를 반환한다.
        // 만약 프로젝트에 여러 개의 캐시 프로바이더를 쓴다면 명시적으로 지정해 주어야 한다.
        CachingProvider cachingProvider = Caching.getCachingProvider();

        // 캐시 설정 파일 로드
        ClassPathResource resource = new ClassPathResource("ehcache.xml");

        // ehcache에 ehcache.xml의 설정을 주입한 후 JCacheCacheManager 객체 반환
        javax.cache.CacheManager ehCacheManager = cachingProvider.getCacheManager(
                resource.getURL().toURI(),
                getClass().getClassLoader()
        );
        return new JCacheCacheManager(ehCacheManager);
    }
}

 

4) CacheEventLogger 생성

@Slf4j
public class CacheEventLogger implements CacheEventListener<Object, Object> {

    @Override
    public void onEvent(CacheEvent<?, ?> cacheEvent) {
        Object key = cacheEvent.getKey();
        Object oldValue = cacheEvent.getOldValue();
        Object newValue = cacheEvent.getNewValue();

        log.info("Cache event occurred - Key: {}, Old Value: {}, New Value: {}",
                key != null ? key : "null",
                oldValue != null ? oldValue : "null",
                newValue != null ? newValue : "null");
    }
}

 

5) @Cacheable 추가

@Cacheable(value = "rankings", key = "T(java.time.LocalDateTime).now().toString()")
@Override
public MovieListDto getRanking(int page, int size) {
    Pageable pageable = PageRequest.of(page, size);
    Page<Movie> moviePage = movieRepository.findAllWithReviewsAndGenre(pageable);

    List<MovieDto> movieDtoList = moviePage.stream()
        .map(movie -> {
            List<ReviewList> reviewList = movie.getReviews().stream()
                .map(review -> ReviewList.builder()
                    .content(review.getContent())
                    .nickname(review.getMember().getNickname())
                    .build())
                .collect(Collectors.toList());

            return MovieDto.fromEntity(movie, reviewList);
        })
        .collect(Collectors.toList());

    return new MovieListDto(movieDtoList, (int) moviePage.getTotalElements());
}

 

6) implements Serializable

public class MovieListDto implements Serializable {
    private List<MovieDto> movieList;
    private int totalElements;
}

public class MovieDto implements Serializable {

    private Long movieId;
    private String title;
    private String genre;
    private String description;
    private String director;
    private Double reservationRate;
    private String imageUrl;
    private LocalDateTime releaseDate;
    private List<ReviewList> reviewList;  
}

public class ReviewList implements Serializable {

    private String content;
    private String nickname;
}