스프링
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;
}