Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
f8cfd3e
[fix] mysql 초기 설정 오류 수정
youngyin Mar 29, 2025
de4aee4
[feat] k6 적용한 성능 테스트
youngyin Mar 31, 2025
33ba191
[feat] 2중 캐시 적용
youngyin Apr 4, 2025
5f42f62
[docs] 캐시 구현
youngyin Apr 4, 2025
709121f
[feat] 캐시 저장 횟수 커스텀
youngyin Apr 4, 2025
a20b529
[feat] k6 테스트
youngyin Apr 4, 2025
8263034
[docs] cache-save-cond
youngyin Apr 4, 2025
94a07fd
[feat] 예약 API
youngyin Apr 4, 2025
fccd905
[feat] 데이터 초기화
youngyin Apr 4, 2025
5f80a9e
[docs] 예약 api
youngyin Apr 4, 2025
38425f3
[feat] 분산락처리
youngyin Apr 4, 2025
858b629
[docs] 분산락
youngyin Apr 4, 2025
7a11ea5
[feat] testCode
youngyin Apr 6, 2025
53e8b83
[docs] 동시성 제어 테스트
youngyin Apr 6, 2025
35f2278
[feat] 분산락 테스트
youngyin Apr 6, 2025
318a02e
[refactor] test 구조 변경
youngyin Apr 7, 2025
fad1a62
[feat] RateLimit 설계
youngyin Apr 7, 2025
72b571a
[feat] 분산 RateLimit 적용
youngyin Apr 7, 2025
383fa44
[docs] RateLimit 구현 및 설계 결과
youngyin Apr 7, 2025
3191407
[feat] cache를 aop로 분리
youngyin Apr 7, 2025
fb77041
[refactor] 분산락 aop로 구현
youngyin Apr 7, 2025
8d7c79f
[docs] 4주차 리팩토링
youngyin Apr 7, 2025
5634d7a
[fix] 어노테이션 오류 수정, 필요없는 설정 제거
youngyin Apr 14, 2025
5b73bc4
[docs] 주석
youngyin Apr 14, 2025
58e4548
[feat] cache 테스트
youngyin Apr 14, 2025
e850866
[feat] 분산락 적용/테스트
youngyin Apr 18, 2025
5125e9d
[fix] 분산락 -동시성 테스트
youngyin Apr 18, 2025
e126631
[fix] ratelimit 적용하기
youngyin Apr 19, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,4 +65,37 @@
- [캐시 설계](docs/cash-design.md)
- 영화 목록 캐싱 (Movie List Cache)
- 검색 조건별 영화 목록 캐싱 (Filtered Movie List Cache)
- [Caffeine + Redis + DB 기반의 3단 캐시 구조 설계 및 구현](docs/test-cache.md)
- [캐시 테스트](docs/K6-test-result.md)
- [캐시 동기화 기준](docs/test-cache.md)

## 3주차
- [좌석 예약 API 구현](docs/reserve-api.md)
- [동시성 제어 (중복 예약 방지)](docs/lock-structure.md)
- 이해하기 : 분산락의 다양한 방법
- 이해하기 : Redisson 분산 락의 동작 원리
- [이해하기 : 낙관적 락과 비관적 락, JPA락, Redis락](docs/Pessimistic-Optimistic-Lock.md)
- 테스트 : 분산 환경에서 JPA 락을 사용한 동시성 제어
```shell
./gradlew :bootstrap:bootRun --args='--server.port=8081'
./gradlew :bootstrap:bootRun --args='--server.port=8082'
```

- [invokeAll와 CountDownLatch를 사용한 동시성 제어 테스트](docs/invokeAll_vs_CountDownLatch.md)

## 4주차
- [RateLimit 설계, 구현](docs/RateLimit.md)
- (리팩토링) AOP로 기술적 관심사와 비즈니스 로직 분리
```
기술 정책은 비즈니스 로직이 아니다.
예를 들어, "동일 좌석은 동시에 예약되면 안 된다"는 요구는 얼핏 보면 도메인 정책처럼 보이지만, 실제 구현은 분산 락이나 JPA 락 같은 기술적 보호 장치로 해결된다. RateLimit 역시 마찬가지다. 서비스의 핵심 규칙이라기보다, 시스템을 안정적으로 보호하기 위한 기술 정책에 가깝다. 그래서 나는 이런 기술 정책들이 핵심 비즈니스 로직을 침범하지 않도록, 외곽에서 감싸는 구조로 구현하려고 했다.
그 방법으로 AOP를 적극적으로 활용했다. 기술 정책을 어노테이션으로 선언만 하고, 실제 처리는 Aspect에서 감싸도록 구성했다. 핵심 흐름에서는 로직이 깔끔하게 유지되고, 기술 정책은 별도로 분리되어 관리된다. 처리 로직은 다시 어댑터(Out) 단으로 위임되며, 포트를 통해 Hexagonal 구조를 지키도록 했다.

- `@RateLimited`: 조회 API에 IP 기반의 트래픽 제한을 적용했다. 서버 과부하나 DDoS를 방지하는 전형적인 기술 정책이므로 AOP로 처리했다.
- 예약 API의 요청 제한은 비즈니스 로직에 가깝다고 판단해, 유즈케이스 흐름 내에서 명시적으로 Redis를 통해 제한을 검사하도록 구성했다. 도메인 규칙으로서 예약 제한은 서비스 로직 안에 있어야 의미가 있었다.
- `@Cached3Layer`, `@LocalCached`: 캐시 역시 비즈니스 흐름에 침투하지 않도록 어노테이션과 AOP로 처리했다. Caffeine과 Redis를 활용해 3단계 캐시 구조를 구현했다.
- `@DistributedLock`: Redisson 기반의 분산 락을 AOP로 처리하여 중복 예약을 방지했다.
- JPA 기반의 로컬 락은 트랜잭션 경계와 밀접하게 맞물리기 때문에 AOP 대신 별도의 서비스로 분리해 명시적으로 처리했다.
```

- 전체 API 테스트
34 changes: 0 additions & 34 deletions adapter/src/main/kotlin/yin/adapter/in/QueryMovieController.kt

This file was deleted.

9 changes: 9 additions & 0 deletions adapter/src/main/kotlin/yin/adapter/in/aop/DistributedLock.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package yin.adapter.`in`.aop

@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class DistributedLock(
val key: String, // 예: "#seatId"
val waitTimeSeconds: Long = 5,
val timeoutSeconds: Long = 30
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package yin.adapter.`in`.aop

import org.aspectj.lang.ProceedingJoinPoint
import org.aspectj.lang.annotation.Around
import org.aspectj.lang.annotation.Aspect
import org.aspectj.lang.reflect.MethodSignature
import org.slf4j.LoggerFactory
import org.springframework.expression.ExpressionParser
import org.springframework.expression.spel.standard.SpelExpressionParser
import org.springframework.expression.spel.support.StandardEvaluationContext
import org.springframework.stereotype.Component
import yin.application.port.out.DistributedLockPort

@Aspect
@Component
class DistributedLockAspect(
private val lockPort: DistributedLockPort
) {
private val logger = LoggerFactory.getLogger(DistributedLockAspect::class.java)
private val parser: ExpressionParser = SpelExpressionParser()

/**
* 분산 락 처리
*/
@Around("@annotation(distributedLock)")
fun proceedWithLock(joinPoint: ProceedingJoinPoint, distributedLock: DistributedLock): Any {
val key = resolveLockKey(joinPoint, distributedLock.key)
val waitTime = distributedLock.waitTimeSeconds

logger.info("🔒 [LOCK] Try acquire: $key (wait=$waitTime)")

return lockPort.runWithLock(
key = key,
waitSec = distributedLock.waitTimeSeconds,
timeoutSec = distributedLock.timeoutSeconds,
) {
try {
joinPoint.proceed()
} catch (e: Throwable) {
logger.error("🔒 [LOCK] 실행 중 예외 발생 - key=$key", e)
throw e
} finally {
logger.info("🔒 [UNLOCK] Release: $key")
}
}
}

private fun resolveLockKey(joinPoint: ProceedingJoinPoint, keySpEL: String): String {
val method = (joinPoint.signature as MethodSignature).method
val paramNames = method.parameters.map { it.name }
val paramValues = joinPoint.args

val context = StandardEvaluationContext().apply {
paramNames.forEachIndexed { index, name ->
setVariable(name, paramValues[index])
}
}

return parser.parseExpression(keySpEL).getValue(context)?.toString()
?: throw IllegalArgumentException("분산 락 키 생성 실패: $keySpEL")
}
}
9 changes: 9 additions & 0 deletions adapter/src/main/kotlin/yin/adapter/in/aop/Layer3Cached.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package yin.adapter.`in`.aop

@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class Layer3Cached(
val cacheKeyPrefix: String,
val ttlSeconds: Long = 300,
val syncThreshold: Int = 5 // Redis로 승격할 최소 hit count
)
72 changes: 72 additions & 0 deletions adapter/src/main/kotlin/yin/adapter/in/aop/Layer3CachingAspect.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package yin.adapter.`in`.aop

import org.aspectj.lang.ProceedingJoinPoint
import org.aspectj.lang.annotation.Around
import org.aspectj.lang.annotation.Aspect
import org.aspectj.lang.reflect.MethodSignature
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Component
import yin.application.port.out.*

@Aspect
@Component
class Layer3CachingAspect(
private val caffeineRepositoryPort: CaffeineRepositoryPort,
private val redisRepositoryPort: RedisRepositoryPort,
private val cacheKeyGeneratorPort: CacheKeyGeneratorPort,
private val cacheHitCounterPort: CacheHitCounterPort
) {

private val log = LoggerFactory.getLogger(Layer3CachingAspect::class.java)

/**
* 3계층 캐싱을 위한 Aspect
* Caffeine → Redis → DB 순으로 조회
* @param joinPoint ProceedingJoinPoint
* @param layer3Cached Layer3Cached
* @return Any
*/
@Around("@annotation(layer3Cached)")
fun cache(joinPoint: ProceedingJoinPoint, layer3Cached: Layer3Cached): Any {
val methodSignature = joinPoint.signature as MethodSignature
val method = methodSignature.method
val returnType = method.returnType
val cacheKey = cacheKeyGeneratorPort.generate(joinPoint, layer3Cached.cacheKeyPrefix)

// Step 1. Caffeine 조회
val caffeineHit = caffeineRepositoryPort.getIfPresent(cacheKey, returnType)
if (caffeineHit != null) {
log.info("✅ Caffeine Cache HIT [key=$cacheKey]")
cacheHitCounterPort.increment(cacheKey)
return caffeineHit
}

// Step 2. Redis 조회
val redisHit = redisRepositoryPort.getIfPresent(cacheKey, returnType)
if (redisHit != null) {
log.info("✅ Redis Cache HIT [key=$cacheKey]")
caffeineRepositoryPort.put(cacheKey, redisHit)
cacheHitCounterPort.increment(cacheKey)
return redisHit
}

// Step 3. DB (실제 메서드 실행)
log.info("📡 DB QUERY [key=$cacheKey]")
val result = joinPoint.proceed()

// Step 4. Caffeine 저장
caffeineRepositoryPort.put(cacheKey, result)

// Step 5. Redis 저장 조건 체크
val hitCount = cacheHitCounterPort.getHitCount(cacheKey)
if (hitCount >= layer3Cached.syncThreshold) {
redisRepositoryPort.put(cacheKey, result, layer3Cached.ttlSeconds)
log.info("📦 Redis PUT [key=$cacheKey] (hitCount=$hitCount)")
} else {
log.info("🚫 Redis SKIP [key=$cacheKey] (hitCount=$hitCount)")
}

return result
}

}
8 changes: 8 additions & 0 deletions adapter/src/main/kotlin/yin/adapter/in/aop/LocalCached.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package yin.adapter.`in`.aop

@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class LocalCached(
val cacheKeyPrefix: String,
val ttlSeconds: Long = 300
)
47 changes: 47 additions & 0 deletions adapter/src/main/kotlin/yin/adapter/in/aop/LocalCachingAspect.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package yin.adapter.`in`.aop

import org.aspectj.lang.ProceedingJoinPoint
import org.aspectj.lang.annotation.Around
import org.aspectj.lang.annotation.Aspect
import org.aspectj.lang.reflect.MethodSignature
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Component
import yin.application.port.out.CacheKeyGeneratorPort
import yin.application.port.out.CaffeineRepositoryPort

@Aspect
@Component
class LocalCachingAspect(
private val caffeineRepositoryPort: CaffeineRepositoryPort,
private val cacheKeyGeneratorPort: CacheKeyGeneratorPort
) {
private val log = LoggerFactory.getLogger(LocalCachingAspect::class.java)

/**
* Local Caffeine 캐시 처리
* - Caffeine → DB 순서로 조회
* - 캐시가 없으면 DB에서 조회 후 캐시 저장
*/
@Around("@annotation(localCached)")
fun cache(joinPoint: ProceedingJoinPoint, localCached: LocalCached): Any {
val method = (joinPoint.signature as MethodSignature).method
val returnType = method.returnType
val cacheKey = cacheKeyGeneratorPort.generate(joinPoint, localCached.cacheKeyPrefix)

// Step 1. Caffeine 조회
caffeineRepositoryPort.getIfPresent(cacheKey, returnType)?.let {
log.info("✅ [Local Caffeine HIT] key=$cacheKey")
return it
}

// Step 2. DB → 원본 실행
log.info("📡 [DB QUERY] key=$cacheKey")
val result = joinPoint.proceed()

// Step 3. 캐시 저장
caffeineRepositoryPort.put(cacheKey, result)
log.info("📥 [Local Caffeine PUT] key=$cacheKey ttl=${localCached.ttlSeconds}s")

return result
}
}
30 changes: 30 additions & 0 deletions adapter/src/main/kotlin/yin/adapter/in/aop/RateLimitAspect.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package yin.adapter.`in`.aop

import jakarta.servlet.http.HttpServletRequest
import org.aspectj.lang.ProceedingJoinPoint
import org.aspectj.lang.annotation.Around
import org.aspectj.lang.annotation.Aspect
import org.springframework.stereotype.Component
import yin.application.port.out.RateLimitPort

@Aspect
@Component
class RateLimitAspect(
private val rateLimitPort: RateLimitPort,
private val httpServletRequest: HttpServletRequest
) {

@Around("@annotation(rateLimited)")
fun checkRateLimit(joinPoint: ProceedingJoinPoint, rateLimited: RateLimited): Any {
val ip = extractClientIp(httpServletRequest)

println("[checkRateLimit] IP: $ip")
rateLimitPort.checkIpRequestLimit(ip)
return joinPoint.proceed()
}

private fun extractClientIp(request: HttpServletRequest): String {
return request.getHeader("X-Forwarded-For")?.split(",")?.firstOrNull()
?: request.remoteAddr
}
}
5 changes: 5 additions & 0 deletions adapter/src/main/kotlin/yin/adapter/in/aop/RateLimited.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package yin.adapter.`in`.aop

@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class RateLimited
Loading