이전글에서 다루었듯 Gatling을 사용한 서버 부하 테스트를 진행해 보았습니다. 서버 과부하로 인한 Fail도 있었고, 응답 시간이 매우 길어지는 크게 두 가지 문제가 있었습니다. 서버 과부하를 해결하기 위해선 분산 서버를 운영하여 로드 밸런서를 추가 하거나, 메세지 큐를 활용해서 대기하도록 할 수 있습니다. 응답 시간의 경우 데이터베이스 튜닝이나 캐싱을 사용함으로써 해결할 수 있습니다.
이 글에서는 Redis를 캐시 메모리로 사용하여 서버의 성능을 개선하는 방법에 대해 다루고자 합니다. 특히, Java 환경에서 Redisson을 활용하여 Spring 애플리케이션에 Redis 캐시를 적용하는 방법을 중점적으로 설명합니다.
Caching과 Redis
Cache Memory
Cache Memory는 중요하고 자주 액세스하는 정보를 일시적으로 저장하기 위한 컴퓨터 메모리의 한 유형이다. 캐시 메모리에서 읽고 쓰는 것은 데이터베이스의 데이터 저장보다 훨씬 빠르다. 이러한 효과 덕분에 Cache Memory에 데이터를 저장하면 시간이 많이 걸리거나 대량의 트랜잭션을 획기적으로 가속화할 수 있다.
Cache Memory는 CPU와 Main Memory 사이에 위치하여 비교적 빠르게 액세스할 수 있다. 프로세서에 따라 캐시 메모리는 L1과 L2, L3를 포함하여 여러 수준으로 분할될 수 있다. L1 캐시는 L2와 L3 캐시보다 작고 더 빠르게 액세스할 수 있다.
캐싱 전략
캐시는 언제든 사라질 수 있는 데이터 있으며, 너무 크지 않게 관리 되어야 한다. 그리고 캐시를 확인했을때 자신이 필요한 데이터가 있을 수도, 없을 수도 있다는 것을 고려해야 한다. 캐시와 관련된 용어는 다음과 같이 있다.
- 캐시 적중(Cache Hit): 캐시에 접근했을 때 찾고 있는 데이터가 있는 경우를 나타냅니다.
- 캐시 누락(Cache Miss): 캐시에 접근했을 때 찾고 있는 데이터가 없는 경우를 나타냅니다.
- 삭제 정책(Eviction Policy): 캐시에 공간이 부족할때 어떻게 공간을 확보하는지에 대한 정책입니다.
데이터를 얼마나 오래 캐시에 보관할지, 언제 캐시에 저장할지 전략을 적절히 세워 캐시 적중률을 높이고 누락을 최대한 줄여야 한다.
Cache-Aside
Lazy Loading이라고도 하며, 데이터를 조회할 때 항상 캐시를 먼저 확인하는 전략이다. 캐시에 데이터가 있으면 캐시에서 데이터를, 없으면 원본에서 데이터를 가져온 뒤 캐시에 저장한다.
- 필요한 데이터만 캐시에 보관된다. 따라서 메모리 사용량이 효율적이다.
- 부하가 적다.
- 최초로 조회할 때 캐시를 확인하기 때문에 최초의 요청은 상대적으로 오래 걸린다.
- 반드시 원본을 확인하지 않기 때문에, 데이터가 최신이라는 보장이 없다.
Write-Through
데이터를 작성할때 항상 캐시에 작성하고, 원본에도 작성하는 전략이다.
- 캐시의 데이터 상태는 항상 최신 데이터임이 보장된다.
- 자주 사용하지 않는 데이터도 캐시에 중복해서 작성하기 때문에, 시간이 오래 걸린다.
Write-Behind
- 캐시에만 데이터를 작성하고, 일정 주기로 원본을 갱신하는 방식이다.
- 배치 단위로 Write를 실행하여 부하가 적다.
- 캐시에서 데이터가 사라지면 복구할 수 없다.
- 데이터가 DB와 일관성이 떨어질 수 있다.
Redis
Redis는 NoSQL 데이터베이스, 캐시 및 메시지 브로커를 포함한 다양한 사용 사례를 갖춘 오픈 소스 In-memory 데이터베이스이다. 빠른 읽기 및 쓰기 작업으로 잘 알려진 Redis는 이러한 고성능 애플리케이션을 위해 Caching에 크게 의존한다.
Redis 기반 애플리케이션은 캐시에 정보를 저장할 수 있으며, 이를 통해 디스크에서 가져오거나 계산하거나 외부 API(응용 프로그램 프로그래밍 인터페이스)를 쿼리하는 것보다 이 정보를 더 빠르게 가져올 수 있다. 따라서 Redis에서의 캐싱은 응답 시간을 크게 줄이고 데이터베이스 부하를 줄일 수 있다.
Redis는 문자열, 해시, 리스트, 셋, 정렬된 셋 등 여러 데이터 구조를 제공하여 다양한 유형의 데이터를 효율적으로 캐싱할 수 있다. 또한, Redis는 만료 시간, 제거 정책 및 지속성 옵션과 같은 기능을 포함하고 있어 이러한 캐시된 데이터를 효과적으로 관리할 수 있다.
Redisson을 사용한 Java의 Redis 캐시
Redis는 기본적으로 Java와 호환되지 않는다. 대신, Redis를 사용하려는 Java 개발자는 Redisson과 같은 서드 파티 Redis Java 프레임워크를 설치하여 Java로 Redis를 관리할 수 있다.
Redisson은 Java 개발자가 Redis 데이터베이스를 사용하기 쉽게 만들어주는 Redis Java 클라이언트이다. Redisson 라이브러리는 Redis와 호환되도록 친숙한 Java 데이터 구조, 서비스 및 구성 요소를 포함한다.
Redisson을 사용하여 Spring에서 Caching 구현하기
Spring Caching
2012년 Spring 3.1 이후, Spring 프레임워크는 Caching 을 지원하기 시작했다. Spring Caching은 신속한 애플리케이션 개발(RAD) 및 마이크로서비스를 위한 Spring 생태계의 구성 요소인 Spring Boot 의 일부이다. 이를 사용하기 위해서 @EnableCaching 어노테이션으로 활성화해야 한다.
@EnableCaching
@EnableCaching 어노테이션은 Spring 애플리케이션에서 애노테이션 기반 캐시 관리 기능을 활성화하는 데 사용된다. 이 어노테이션을 사용하면 Spring은 캐싱 관련 애노테이션(@Cacheable, @CachePut, @CacheEvict 등)을 처리하기 위한 필요한 설정을 자동으로 구성한다. 특히 @EnableCaching은 기본적으로 Spring AOP를 사용하여 캐싱 기능을 구현한다.
Spring AOP는 프록시 기반의 AOP(Aspect-Oriented Programming) 구현체로, 메서드 호출을 가로채고 추가적인 동작을 수행할 수 있도록 한다. 애노테이션 기반 캐시 관리를 구동하는 데 필요한 Spring 구성 요소(예: CacheInterceptor 및 @Cacheable 메서드가 호출될 때 Intercept를 호출 스택에 엮는 Proxy 또는 AspectJ 기반 Advice)를 등록한다.
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Import(CachingConfigurationSelector.class)
public @interface EnableCaching {
boolean proxyTargetClass() default false;
AdviceMode mode() default AdviceMode.PROXY;
int order() default Ordered.LOWEST_PRECEDENCE;
}
proxyTargetClass, 기본값: false
- CGLIB를 사용한 서브클래스 기반 프록시를 생성할지 여부를 나타낸다.
- 기본적으로는 표준 Java 인터페이스 기반 프록시를 생성한다.
- mode 속성이 AdviceMode.PROXY로 설정된 경우에만 적용된다.
- true로 설정하면, 모든 Spring이 관리하는 Bean에 대해 프록시가 생성되며, 이는 @Cacheable로 표시된 메서드뿐만 아니라 @Transactional 등 다른 프록시가 필요한 빈에도 영향을 미친다.
mode, AdviceMode.PROXY(기본값)
- mode는 어드바이스가 적용되는 방식을 제어한다.
- mode가 AdviceMode.PROXY(기본값)로 설정된 경우, 다른 속성은 프록시의 동작을 제어한다. 프록시 모드에서는 프록시를 통한 호출만 intercept할 수 있으며, 동일 클래스 내의 로컬 호출은 그 방식으로 intercept할 수 없다.
- mode 속성을 AdviceMode.ASPECTJ로 설정하면 AspectJ를 사용한 AOP를 사용할 수 있다.
- AspectJ는 컴파일 타임 또는 load-time weaving을 사용하여 보다 고급 interception을 제공한다.
- 이 경우, spring-aspects 모듈이 class-path에 있어야 하며, 로컬 메서드 호출도 intercept 할 수 있다.
order, 기본값: Ordered.LOWEST_PRECEDENCE
- 특정 JoinPoint에 여러 Advice가 적용될 때, Caching Advisor의 실행 순서를 나타낸다.
CacheManager
@EnableCaching을 사용하면 Spring 애플리케이션 컨텍스트에서 CacheManager Bean을 자동으로 검색하고 사용한다.
CacheManager 인터페이스는 Spring의 중앙 캐시 관리 SPI(Service Provider Interface)로, 다양한 캐시 구현체를 관리하고 제공하는 역할을 한다. SPI는 특정 서비스나 기능을 제공하기 위해 구현되어야 하는 인터페이스의 집합을 의미한다. 주로 프레임워크나 라이브러리에서 확장 가능성을 제공하기 위해 사용된다.
public interface CacheManager {
@Nullable
Cache getCache(String name);
Collection<String> getCacheNames();
}
Cache getCache(String name)
- 지정된 이름의 캐시를 반환한다.
- 캐시가 존재하지 않거나 생성할 수 없는 경우 null을 반환합니다. native provider가 지원하는 경우, 캐시는 런타임에 지연 생성될 수 있다.
Collection getCacheNames()
- 이 CacheManager가 알고 있는 모든 캐시 이름의 Collection을 반환한다.
@Cacheable
@Cacheable 어노테이션은 메서드(또는 클래스의 모든 메서드)를 호출한 결과를 캐싱할 수 있음을 나타낸다. 이 어노테이션은 Spring의 Caching 추상화를 사용하여 메서드 호출 결과를 캐시에 저장하고, 동일한 인수로 메서드가 다시 호출될 때 캐시된 값을 반환한다.
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
public @interface Cacheable {
@AliasFor("cacheNames")
String[] value() default {};
@AliasFor("value")
String[] cacheNames() default {};
String key() default "";
String keyGenerator() default "";
String cacheManager() default "";
String cacheResolver() default "";
String condition() default "";
String unless() default "";
boolean sync() default false;
}
value / cacheNames
- 메서드 호출 결과가 저장되는 캐시의 이름이다. 이는 캐시를 식별하는 데 사용되며, 여러 캐시 이름을 지정할 수 있다.
key
- SpEL 표현식을 사용하여 동적으로 Cache Key를 계산한다. 기본값은 빈 문자열이며, 이는 모든 메서드 매개변수를 Key로 사용함을 의미한다.
keyGenerator
- 사용할 사용자 정의 키 생성기의 Bean 이름이다. key 속성과 상호 배타적이다.
cacheManager
- 기본 CacheResolver를 생성하는 데 사용할 사용자 정의 CacheManager의 Bean 이름입니다. cacheResolver 속성과 상호 배타적이다.
cacheResolver
- 사용할 사용자 정의 CacheResolver의 Bean 이름이다.
condition
- SpEL 표현식을 사용하여 메서드 캐싱을 조건부로 만든다. 조건이 true로 평가되면 결과를 Cache 한다.
unless
- SpEL 표현식을 사용하여 메서드 캐싱을 거부한다. 조건이 true로 평가되면 결과 캐싱을 거부한다. 이는 메서드 호출 후에 평가되므로 result를 참조할 수 있다.
sync
- 여러 스레드가 동일한 키에 대해 값을 로드하려고 할 때 기본 메서드 호출을 동기화한다.
- 동기화 모드가 활성화되면, 하나의 스레드만 데이터 소스로부터 데이터를 가져오고, 다른 스레드들은 캐시가 갱신될 때까지 기다린다.
- 이를 통해 여러 스레드가 동시에 캐시 미스를 처리하지 않도록 한다.
- 동기화에는 몇 가지 제한이 있다:
1. unless()는 지원되지 않는다.
2. 하나의 캐시만 지정할 수 있다.
3. 다른 캐시 관련 작업과 결합될 수 없다.
@CacheEvict
@CacheEvict 어노테이션은 메서드(또는 클래스의 모든 메서드)가 캐시 제거 작업을 트리거함을 나타낸다. 이 어노테이션은 메서드 호출 전후에 Cache Entry를 제거하는 데 사용된다. 이를 통해 캐시에 저장된 데이터의 일관성을 유지할 수 있다.
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
public @interface CacheEvict {
@AliasFor("cacheNames")
String[] value() default {};
@AliasFor("value")
String[] cacheNames() default {};
String key() default "";
String keyGenerator() default "";
String cacheManager() default "";
String cacheResolver() default "";
String condition() default "";
boolean allEntries() default false;
boolean beforeInvocation() default false;
}
value / cacheNames
- 캐시 제거 작업에 사용할 캐시의 이름을 지정한다. 여러 캐시 이름을 지정할 수 있다.
key
- SpEL 표현식을 사한하여 캐시 키를 동적으로 계산한다. 기본값은 빈 문자열이며, 이는 모든 메서드 매개변수가 key로 간주됨을 의미한다.
keyGenerator
- 사용할 사용자 정의 KeyGenerator의 Bean 이름을 지정한다. key 속성과 상호 배타적이다.
cacheManager
- 기본 CacheResolver를 생성하는 데 사용할 사용자 정의 캐시 관리자의 빈 이름을 지정한다. cacheResolver 속성과 상호 배타적이다.
cacheResolver
- 사용할 사용자 정의 CacheResolver의 Bean 이름을 지정한다.
condition
- SpEL 표현식을 사용하여 캐시 제거 작업을 조건부로 만다. 조건이 true로 평가되면 캐시 제거가 수행됩니다. 기본값은 빈 문자열이며, 이는 항상 캐시 제거가 수행됨을 의미한다.
allEntries
- 캐시 내의 모든 항목을 제거할지 여부를 지정한다. 기본값은 false이며, 연관된 키 아래의 값만 제거된다. allEntries를 true로 설정하고 key를 지정하는 것은 허용되지 않는다.
beforeInvocation
- 캐시 제거가 메서드 호출 전에 발생해야 하는지 여부를 지정한다. true로 설정하면 메서드 결과와 상관없이 제거가 발생한다. 기본값은 false이며, 이는 메서드가 성공적으로 호출된 후에 캐시 제거가 발생함을 의미한다.
@CachePut
@CachePut 어노테이션은 메서드(또는 클래스의 모든 메서드)가 호출될 때마다 결과를 캐시에 저장하도록 한다.
이는 @Cacheable 어노테이션과 달리 메서드 호출을 건너뛰지 않고 항상 메서드를 호출하며, 조건에 맞는 경우에만 결과를 캐시에 저장한다. Java 8의 Optional 반환 유형도 자동으로 처리되어, 내용이 있을 경우 캐시에 저장된다.
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
public @interface CachePut {
@AliasFor("cacheNames")
String[] value() default {};
@AliasFor("value")
String[] cacheNames() default {};
String key() default "";
String keyGenerator() default "";
String cacheManager() default "";
String cacheResolver() default "";
String condition() default "";
String unless() default "";
}
value / cacheNames
- 캐시 저장 작업에 사용할 캐시의 이름을 지정한다. 여러 캐시 이름을 지정할 수 있다.
key
- SpEL 표현식을 사용하여 캐시 키를 동적으로 계산한다. 기본값은 빈 문자열이며, 이는 모든 메서드 매개변수가 키로 간주됨을 의미한다.
keyGenerator
- 사용할 사용자 정의 KeyGenerator의 Bean 이름을 지정한다. key 속성과 상호 배타적이다.
cacheManager
- 기본 CacheResolver를 생성하는 데 사용할 사용자 정의 CacheManager의 Bean 이름을 지정한다. cacheResolver 속성과 상호 배타적이다.
cacheResolver
- 사용할 사용자 정의 CacheResolver의 Bean 이름을 지정한다.
condition
- SpEL 표현식을 사용하여 캐시 저장 작업을 조건부로 만든다. 조건이 true로 평가되면 캐시가 업데이트된다. 이 표현식은 저장 작업의 특성상 메서드가 호출된 후에 평가되므로 result를 참조할 수 있다.
unless
- SpEL 표현식을 사용하여 캐시 저장 작업을 거부합니다. 조건이 true로 평가되면 캐시 업데이트를 거부합니다. 기본값은 빈 문자열이며, 이는 캐싱이 절대 거부되지 않음을 의미합니다. 이 표현식도 메서드 호출 후에 평가되므로 result를 참조할 수 있다.
프로젝트에 Redisson Cache 적용하기
Redisson CacheManager 구현
@Configuration
@EnableCaching
public class RedissonConfig {
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
config.useSingleServer()
.setAddress("redis://127.0.0.1:6379");
return Redisson.create(config);
}
@Bean
CacheManager cacheManager(RedissonClient redissonClient) {
Map<String, CacheConfig> config = new HashMap<>();
config.put("surveyListCache", new CacheConfig(
TimeUnit.HOURS.toMillis(1), // TTL 1 hour
TimeUnit.MINUTES.toMillis(30) // Max idle time 30 minutes
));
return new RedissonSpringCacheManager(redissonClient, config);
}
}
@Configuration 어노테이션으로 이 클래스가 Spring의 구성 클래스임을 나타내며, Bean 정의를 포함하고 있음을 나타낸다.
그리고 @EnableCaching 어노테이션을 적용하여 Spring의 캐싱 기능을 활성화한다.
RedissonClient redissonClient()는 RedissonClient를 생성하기 위해 서버 모드와 IP를 구성한다.
CacheManager cacheManager() 에서는 RedissonSpringCacheManager를 리턴한다.
CacheManager의 config로 Redisson에서 사용되는 CacheConfig 클래스를 사용하는데 CacheConfig의 생성자는 아래와 같이 구현되어 있다.
/**
* Creates config object.
*
* @param ttl - time to live for key\value entry in milliseconds.
* If <code>0</code> then time to live doesn't affect entry expiration.
* @param maxIdleTime - max idle time for key\value entry in milliseconds.
* <p>
* if <code>maxIdleTime</code> and <code>ttl</code> params are equal to <code>0</code>
* then entry stores infinitely.
*/
public CacheConfig(long ttl, long maxIdleTime) {
super();
this.ttl = ttl;
this.maxIdleTime = maxIdleTime;
}
CacheConfig는 각 캐시의 TTL(Time to Live)과 Max idle time(최대 유휴 시간)을 설정한다.
- TTL (Time to Live): 캐시 항목이 생성된 후 유효한 기간을 지정한다. TTL이 설정된 캐시 항목은 TTL 시간이 경과하면 자동으로 삭제된다. 이는 캐시 오버플로우를 방지하고, 오래된 데이터를 자동으로 제거하여 메모리를 효율적으로 사용한다. 그리고 데이터 일관성을 유지하기 위해 일정 시간이 지나면 데이터를 삭제하여 최신 상태를 보장한다.
- Max idle time (최대 유휴 시간): Max idle time은 캐시 항목이 마지막으로 접근된 후 유효한 기간을 지정한다. 이 기간 동안 캐시 항목에 접근이 없으면 해당 항목은 자동으로 삭제된다. 이는 자주 사용되지 않는 데이터를 제거하여 메모리를 효율적으로 사용한다. 캐시에 불필요한 데이터가 남아 있지 않도록 한다.
여기서는 surveyListCache라는 캐시 이름으로 TTL 1시간, 최대 유휴 시간 30분을 설정하고 있다.
그리고 RedissonSpringCacheManager(redissonClient, config) 객체를 리턴한다.
RedissonSpringCacheManager 클래스는 Spring의 CacheManager 인터페이스를 구현하여 Redisson 클라이언트를 사용한 캐싱 기능을 제공한다.
Spring cache 어노테이션 적용하기
@Transactional(readOnly = true)
@Cacheable(value = "surveyListCache", key = "#page")
public SurveyListPageDto getAllSurvey(int page) {
// page starts from 1
if (page < 1) {
throw new BadRequestExceptionMapper(ErrorMessage.INVALID_REQUEST);
}
Page<Survey> surveyPage = surveyRepository.findAllInDescendingOrder(
PageRequest.of(page - 1, 8));
if (surveyPage.getTotalElements() != 0 && surveyPage.getTotalPages() < page) {
throw new NotFoundExceptionMapper(ErrorMessage.PAGE_NOT_FOUND);
}
...
이 메서드는 설문조사 목록을 불러오는 기능을 수행한다.
Page surveyPage = surveyRepository.findAllInDescendingOrder(PageRequest.of(page - 1, 8));를 실행할 때 Spring은 아래와 같은 동작을 진행한다.
- 캐시 조회: Spring은 먼저 Redis에 surveyListCache라는 캐시에서 key로 지정된 값(여기서는 page)을 사용하여 캐시를 조회한다.key는 key = "#page"로 지정되어 있으므로, Cacje Key는 메서드 인수 page 값이 된다.
- 캐시 적중: 캐시에 해당 키(page)가 존재하면 메서드를 실행하지 않고 캐시된 값을 반환한다. 이는 데이터베이스 쿼리를 피하고, 메서드의 실행 시간을 절약하여 성능을 향상시킨다.
- 캐시 미스: 캐시에 해당 키가 존재하지 않으면, 메서드를 정상적으로 실행합니다.메서드가 실행된 후, 반환 값을 캐시에 저장합니다. 이때 저장되는 캐시 key는 page 값이다.
@Transactional
@CacheEvict(value = "surveyListCache", allEntries = true)
public SurveyResponseDto updateSurvey(SurveyUpdateRequestDto surveyUpdateRequestDto) {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
Long userId = UserUtil.getUserIdFromAuthentication(authentication);
Survey survey = getSurveyFromSurveyId(surveyUpdateRequestDto.getSurveyId());
// validate survey author from current user
validateSurveyAuthor(userId, survey.getAuthorId());
...
@Transactional
@CacheEvict(value = "surveyListCache", allEntries = true)
public void deleteSurvey(Authentication authentication, Long surveyId) {
User user = UserUtil.getUserFromAuthentication(authentication);
Survey survey = getSurveyFromSurveyId(surveyId);
// validate survey author from current user
validateSurveyAuthor(user.getUserId(), survey.getAuthorId());
위 메서드는 설문조사 목록을 update하거나 delete하는 메서드이다.
@CacheEvict을 적용하여 성공적으로 이 메서드들이 수행되면 surveyListCache 캐시에 있는 모든 데이터를 삭제한다.
이 어노테이션을 적용한 이유는, 설문조사 목록의 변화가 생긴다면 캐시 데이터와 데이터베이스와의 데이터 일관성이 깨지기 때문이다.
성능 비교해보기
위 두 지표는 Gatling을 사용하여 설문조사 목록을 불러오는 요청을 6000개로 동시에 요청하여 서버의 부하를 테스트한 지표이다.
왼쪽의 경우 전반적으로 Response Time이 큰 반면에 오른쪽 Redis Cache를 사용한 경우 약 1/2배로 상대적으로 Response Time이 짧아진 것을 확인할 수 있다.
마치며
Redis를 사용하는 것은 서버 성능을 크게 향상시킬 수 있는 강력한 도구이다. 빠른 데이터 접근 속도와 높은 처리량, 다양한 데이터 구조 지원 등의 장점이 있지만, Redis를 크게 의존한다면 Redis 서버에 문제가 발생했을 때 서비스가 중단될 수 있고, 데이터베이스와 적절한 동기화가 이루어지지 않으면 (적절한 동기화도 쉽지 않다) 일관성 문제가 발생할 수 있다. 또한 너무 많은 데이터를 Redis에 저장하면 메모리 과부하가 생길 수 있다.
따라서 Redis 고가용성 확보, 캐시 정책, 동기화 정책 또한 고려해주어야 한다.
참조
'Development > Spring' 카테고리의 다른 글
[Spring] MVC 패턴과 Spring MVC (0) | 2024.07.22 |
---|---|
[Spring] 서버 모니터링을 위한 Spring Actuator, Prometheus, Grafana 추가하기 (0) | 2024.07.12 |
[Spring] Gatling 으로 서버 부하 테스트하기 (0) | 2024.07.09 |
[Spring] ThreadLocal에 대해 알아보자 + SecurityContextHolder, RequestContextHolder (0) | 2024.05.28 |
[Spring] Spring Security의 Authentication과 SecurityContext 동작, 그리고 Authentication을 얻는 방식 (0) | 2024.05.13 |