Blog / Java/Kotlin / Redis Lock으로 Git Repository Working Tree 동시성 제어

Redis Lock으로 Git Repository Working Tree 동시성 제어

Spring AI Ops에는 애플리케이션 소스 코드가 필요한 흐름이 두 개 있다.

첫 번째는 사용자가 직접 실행하는 Code Risk 분석이다. 등록된 Git 저장소를 checkout하고, 소스 파일을 모아서 LLM에 정적 분석을 요청한다.

두 번째는 Grafana Alerting 분석이다. Grafana alert가 발생하면 Loki 로그에서 stack trace를 찾고, 해당 stack trace line과 관련된 source snippet을 추출해서 LLM에 같이 전달한다.

처음에는 매번 임시 디렉터리에 Git repository를 새로 clone했다. 구현은 단순하지만, repository가 크거나 alert가 자주 발생하면 같은 저장소를 계속 clone하게 된다. 비용이 꽤 커질 수밖에 없다. 그래서 이번 작업에서는 repository.stored=true일 때 등록된 애플리케이션 repository를 로컬 영구 경로에 저장하고 재사용하도록 바꿨다.

문제는 여기서 시작된다. 로컬 repository를 재사용하려면 여러 요청이 같은 working tree를 동시에 변경할 수 있다.

Redis Lock이 필요한 이유

Git repository의 working tree는 동시에 여러 branch 상태로 존재할 수 없다. 예를 들어 같은 애플리케이션에 대해 다음 두 요청이 거의 동시에 들어올 수 있다.

  • 사용자가 Code Risk 분석을 feature/payment branch로 실행
  • Grafana alert가 발생해서 main deploy branch의 source snippet 추출 필요

두 요청 모두 같은 persistent repository directory를 사용한다면 내부적으로 이런 Git 작업을 수행한다.

text
git fetch
git checkout <branch>
git reset --hard origin/<branch>

이 작업들은 working tree를 변경한다. Lock이 없다면 이런 상황이 가능하다.

text
T1 Code Risk:      checkout feature/payment
T2 Grafana Alert:  checkout main
T1 Code Risk:      reset --hard origin/feature/payment
T2 Grafana Alert:  source snippet 추출

이 경우 T2는 main 기준 snippet을 기대했는데, 중간에 T1이 working tree를 feature/payment 상태로 바꿔버렸을 수 있다. 반대로 Code Risk 분석도 파일 수집 중 다른 요청이 branch를 바꾸면 분석 대상 source가 섞일 수 있다.

그래서 persistent repository를 도입하려면, clone, fetch, checkout, reset, delete처럼 working tree를 변경하는 구간은 반드시 한 번에 하나만 실행되도록 보호해야 한다.

단일 JVM만 생각하면 synchronized도 가능해 보인다. 하지만 Spring AI Ops가 여러 instance로 배포될 수 있으면 JVM 내부 lock은 의미가 없다. 서로 다른 instance가 같은 Redis와 같은 repository storage를 바라볼 수 있기 때문이다. 그래서 Redis 기반 distributed lock을 사용했다.

설계 Flow

전체 설계 원칙은 다음과 같다.

  1. repository.stored=false이거나 repository.local-path가 유효하지 않으면 기존처럼 임시 디렉터리에 clone한다.
  2. repository.stored=true이고 repository.local-path가 유효하면 애플리케이션별 persistent repository path를 계산한다.
  3. Persistent repository를 준비할 때 Redis lock을 획득한다.
  4. Lock 안에서만 clone, fetch, checkout, reset, delete를 수행한다.
  5. 준비가 성공하면 persistent path를 반환한다.
  6. 준비가 실패하면 실패 상태를 Redis에 기록하고, 분석 흐름은 임시 clone으로 fallback한다.

Flow를 간단히 표현하면 다음과 같다.

text
Code Risk / Grafana Alerting
        |
        v
RepositoryService.prepareRepository(appName, gitUrl, branch)
        |
        +-- persistent mode disabled?
        |       |
        |       +-- yes -> temporary clone
        |
        +-- persistent mode enabled
                |
                v
        resolve persistent path
                |
                v
        acquire Redis lock: repository:lock:{applicationName}
                |
                v
        clone or fetch -> checkout -> reset
                |
                v
        release Redis lock
                |
                v
        return persistent path

설정은 application.yml에 두었다.

yaml
repository:
  stored: false
  local-path: ${REPOSITORY_LOCAL_PATH:./data/repository}
  lock:
    ttl-ms: 30000
    wait-timeout-ms: 15000
    retry-interval-ms: 1000

ttl-ms는 worker가 비정상 종료되어 lock을 해제하지 못하는 경우를 대비한 만료 시간이다. wait-timeout-ms는 이미 다른 요청이 repository를 변경 중일 때 얼마나 기다릴지 정한다. retry-interval-ms는 lock 획득 재시도 간격이다.

Persistent Repository Path 설계

먼저 애플리케이션 이름과 Git URL로 deterministic path를 만든다. 같은 app/Git URL이면 항상 같은 경로를 사용하고, Git URL이 다르면 같은 app 이름이어도 충돌하지 않도록 hash를 붙였다.

kotlin
@ConfigurationProperties(prefix = "repository")
data class RepositoryProperties(
    val stored: Boolean = false,
    val localPath: String = "",
) {
    fun persistentStorageRoot(): Path? {
        if (!stored || localPath.isBlank()) {
            return null
        }
        return try {
            Path.of(localPath).toAbsolutePath().normalize()
        } catch (e: InvalidPathException) {
            log.error("Invalid localPath for repository storage: '{}', {}", localPath, e.message)
            null
        }
    }

    fun resolvePersistentRepositoryPath(applicationName: String, gitUrl: String): Path? {
        val root = persistentStorageRoot() ?: return null
        val directoryName = "${sanitizeApplicationName(applicationName)}-${sha256(gitUrl).take(12)}"
        val repositoryPath = root.resolve(directoryName).toAbsolutePath().normalize()
        return repositoryPath.takeIf { isSafePersistentRepositoryPath(it) }
    }

    fun isSafePersistentRepositoryPath(path: Path): Boolean {
        val root = persistentStorageRoot() ?: return false
        val normalizedPath = path.toAbsolutePath().normalize()
        return normalizedPath.startsWith(root) && normalizedPath != root
    }
}

여기서 중요한 건 normalize()startsWith(root) 검증이다. app 이름에 path traversal 문자가 섞여 있어도 repository.local-path 밖으로 나갈 수 없게 막는다. Rename/delete 시 repository directory를 삭제해야 하므로, 삭제 대상 경로 검증은 특히 중요하다.

RedisLockManager 구현

Lock 구현은 별도 Service가 아니라 util.RedisLockManager로 두었다. 이 프로젝트에서는 Service가 다른 Service에 의존하지 않도록 하는 규칙이 있어서, 공통 lock 유틸리티로 분리했다.

핵심은 Redis의 SET key value NX PX 동작이다. Spring Data Redis에서는 setIfAbsent(key, value, ttl)로 표현할 수 있다.

kotlin
@Component
class RedisLockManager(
    private val redisTemplate: StringRedisTemplate,
    @Value("\${repository.lock.ttl-ms:30000}") private val defaultLockTtlMs: Long = 30_000,
    @Value("\${repository.lock.wait-timeout-ms:15000}") private val defaultWaitTimeoutMs: Long = 15_000,
    @Value("\${repository.lock.retry-interval-ms:1000}") private val defaultRetryIntervalMs: Long = 1_000,
) {
    fun repositoryLockKey(applicationName: String): String =
        "$REDIS_KEY_REPOSITORY_LOCK_PREFIX$applicationName"

    fun acquire(
        lockKey: String,
        ttl: Duration = Duration.ofMillis(defaultLockTtlMs),
        waitTimeout: Duration = Duration.ofMillis(defaultWaitTimeoutMs),
        retryInterval: Duration = Duration.ofMillis(defaultRetryIntervalMs),
    ): RedisLock {
        val token = UUID.randomUUID().toString()
        val deadline = System.nanoTime() + waitTimeout.toNanos()
        do {
            val acquired = redisTemplate.opsForValue().setIfAbsent(lockKey, token, ttl) == true
            if (acquired) {
                return RedisLock(lockKey, token)
            }
            if (System.nanoTime() >= deadline) {
                break
            }
            Thread.sleep(retryInterval.toMillis())
        } while (true)

        throw IllegalStateException("Failed to acquire Redis lock '$lockKey' within ${waitTimeout.toMillis()}ms.")
    }
}

Lock value에는 UUID token을 저장한다.

kotlin
data class RedisLock(
    val key: String,
    val token: String,
)

Token이 필요한 이유는 lock 해제 안전성 때문이다. 예를 들어 A worker가 lock을 잡았는데 작업이 오래 걸려 TTL이 만료됐다고 가정한다. 그 사이 B worker가 같은 key로 lock을 다시 획득할 수 있다. 이때 A worker가 작업을 마치고 단순히 DEL key를 호출하면 B worker의 lock을 지워버리게 된다.

그래서 unlock은 Lua script로 처리했다. Redis에 저장된 token이 내가 가진 token과 같을 때만 삭제한다.

kotlin
private val UNLOCK_SCRIPT = DefaultRedisScript(
    """
    if redis.call('get', KEYS[1]) == ARGV[1] then
        return redis.call('del', KEYS[1])
    else
        return 0
    end
    """.trimIndent(),
    Long::class.java,
)

fun release(lock: RedisLock): Boolean {
    val result = redisTemplate.execute(UNLOCK_SCRIPT, listOf(lock.key), lock.token)
    return result == 1L
}

사용하는 쪽에서는 withLock으로 감싸면 된다.

kotlin
fun <T> withLock(
    lockKey: String,
    ttl: Duration = Duration.ofMillis(defaultLockTtlMs),
    waitTimeout: Duration = Duration.ofMillis(defaultWaitTimeoutMs),
    retryInterval: Duration = Duration.ofMillis(defaultRetryIntervalMs),
    block: () -> T,
): T {
    val lock = acquire(lockKey, ttl, waitTimeout, retryInterval)
    try {
        return block()
    } finally {
        release(lock)
    }
}
RepositoryService에 적용하기

Code Risk와 Grafana Alerting은 모두 RepositoryService.prepareRepository()를 사용한다.

kotlin
fun prepareRepository(appName: String, gitUrl: String, branch: String = "", accessToken: String? = null): Path {
    val persistentPath = try {
        preparePersistentRepository(appName, gitUrl, branch, accessToken)
    } catch (e: Exception) {
        log.error(
            "Persistent repository preparation failed. Falling back to temporary clone — app: {}, branch: {}, cause: {}",
            appName,
            branch,
            e.message,
        )
        null
    }
    return persistentPath ?: cloneRepository(appName, gitUrl, branch, accessToken)
}

이 메서드가 fallback boundary다. Persistent repository 준비에 실패해도 분석 자체가 실패하지 않도록 임시 clone으로 fallback한다.

Persistent 준비는 이렇게 lock 안에서만 수행된다.

kotlin
fun preparePersistentRepository(appName: String, gitUrl: String, branch: String, accessToken: String? = null): Path? {
    val repositoryPath = repositoryProperties.resolvePersistentRepositoryPath(appName, gitUrl) ?: return null
    saveRepositoryStatus(RepositoryStatus.running(appName, repositoryPath))

    return runCatching {
        redisLockManager.withLock(redisLockManager.repositoryLockKey(appName)) {
            preparePersistentRepositoryUnderLock(repositoryPath, gitUrl, branch, accessToken)
        }
    }.onSuccess {
        saveRepositoryStatus(RepositoryStatus.success(appName, repositoryPath))
    }.onFailure { e ->
        saveRepositoryStatus(RepositoryStatus.failed(appName, repositoryPath, e))
    }.getOrThrow()
}

Lock 안에서는 세 가지 케이스를 처리한다.

kotlin
private fun preparePersistentRepositoryUnderLock(
    repositoryPath: Path,
    gitUrl: String,
    branch: String,
    accessToken: String?,
): Path {
    Files.createDirectories(repositoryPath.parent)
    if (!Files.exists(repositoryPath) || !isGitRepository(repositoryPath)) {
        deletePersistentRepositoryDirectory(repositoryPath)
        clonePersistentRepository(repositoryPath, gitUrl, branch, accessToken)
        return repositoryPath
    }

    if (readOriginUrl(repositoryPath) != gitUrl) {
        log.info("Persistent repository remote URL changed. Reinitializing path: {}", repositoryPath)
        deletePersistentRepositoryDirectory(repositoryPath)
        clonePersistentRepository(repositoryPath, gitUrl, branch, accessToken)
        return repositoryPath
    }

    syncPersistentRepository(repositoryPath, branch, accessToken)
    return repositoryPath
}
  1. Repository가 없거나 .git이 없으면 새로 clone한다.
  2. 기존 repository의 origin URL이 현재 Git URL과 다르면 삭제 후 다시 clone한다.
  3. 정상 repository라면 fetch/checkout/reset으로 동기화한다.

동기화 구현은 git pull을 쓰지 않았다. 이 repository는 사람이 작업하는 working copy가 아니라 분석용 cache다. Merge commit이나 conflict가 생기는 건 의미가 없다. 항상 remote branch 기준으로 깨끗하게 맞추는 것이 목적이다.

kotlin
private fun syncPersistentRepository(repositoryPath: Path, branch: String, accessToken: String?) {
    Git.open(repositoryPath.toFile()).use { git ->
        git.fetch()
            .setRemote("origin")
            .setRemoveDeletedRefs(true)
            .apply {
                if (!accessToken.isNullOrBlank()) {
                    setCredentialsProvider(UsernamePasswordCredentialsProvider("oauth2", accessToken))
                }
            }
            .call()

        val targetBranch = branch.ifBlank { git.repository.branch }
        require(targetBranch.isNotBlank()) { "Unable to resolve branch for persistent repository sync." }

        val localBranchExists = git.repository.refDatabase.findRef(targetBranch) != null
        val checkout = git.checkout().setName(targetBranch)
        if (!localBranchExists) {
            checkout.setCreateBranch(true).setStartPoint("origin/$targetBranch")
        }
        checkout.call()

        git.reset()
            .setMode(ResetCommand.ResetType.HARD)
            .setRef("origin/$targetBranch")
            .call()
    }
}

이 sequence의 의미는 명확하다.

text
fetch
  remote refs 최신화

checkout/switch branch
  분석 대상 branch로 working tree 전환

reset --hard origin/{branch}
  local working tree를 remote branch와 동일한 상태로 강제 정렬

삭제도 lock 안에서 수행한다. 앱 이름 변경이나 삭제 시 checkout/sync와 delete가 동시에 실행되면 안 되기 때문이다.

kotlin
fun deletePersistentRepository(appName: String, gitUrl: String): Boolean {
    val repositoryPath = repositoryProperties.resolvePersistentRepositoryPath(appName, gitUrl) ?: return false
    redisLockManager.withLock(redisLockManager.repositoryLockKey(appName)) {
        deletePersistentRepositoryDirectory(repositoryPath)
        redisTemplate.delete("$REDIS_KEY_REPOSITORY_STATUS_PREFIX$appName")
    }
    return true
}
Code Risk 분석 flow

Code Risk 분석은 이제 직접 clone하지 않는다. prepareRepository()를 호출한다.

kotlin
fun analyze(appName: String, branch: String) {
    val gitRepoUrl = applicationService.getGitRepoByAppName(appName)
    val accessToken = resolveAccessToken(gitRepoUrl)
    val sourcePath = repositoryService.prepareRepository(appName, gitRepoUrl, branch, accessToken)
    val files = repositoryService.collectSourceFiles(sourcePath)
    val bundle = repositoryService.buildBundle(sourcePath, files)
    val tokenCount = aiModelService.estimateTokenCount(bundle)

    CompletableFuture.runAsync({
        val (markdown, issues) = executeAnalyze(tokenCount, bundle, files, sourcePath)
        val record = repositoryService.saveAnalyzedResult(appName, gitRepoUrl, branch, markdown, issues)
        messageService.pushAnalysisResult(record)
    }, executor)
}

여기서 중요한 점은 repository 준비가 분석 전에 끝난다는 것이다. 즉, source file 목록을 수집하고 bundle을 만드는 시점에는 이미 lock 안에서 branch 동기화가 끝난 상태다.

Lock은 Git working tree를 변경하는 동안만 잡는다. LLM 분석 전체 시간 동안 lock을 잡지는 않는다. 만약 LLM 호출 동안 lock을 잡으면 Grafana Alerting이 source snippet을 추출해야 할 때 불필요하게 오래 기다리게 된다.

Grafana Alerting flow

Grafana Alerting도 같은 entry point를 사용한다. 단, deploy branch가 없으면 source snippet 추출을 하지 않는 기존 정책을 유지했다.

kotlin
private fun getSourcePath(targetApplication: String): Path? {
    val appConfig = applicationService.getGitConfig(targetApplication)
    return if (appConfig != null && appConfig.isValidConfig()) {
        val accessToken = resolveAccessToken(appConfig.gitUrl!!)
        repositoryService.prepareRepository(
            appName = targetApplication,
            gitUrl = appConfig.gitUrl,
            branch = appConfig.deployBranch!!,
            accessToken = accessToken,
        )
    } else {
        null
    }
}

deployBranch가 없으면 null을 반환한다. 그러면 source snippet 없이 alert, log, metric 중심으로만 LLM 분석을 수행한다. 여기서 repository default branch로 fallback하지 않는 것이 의도된 정책이다. 운영 배포 branch가 무엇인지 명확하지 않은 상태에서 source suggestion을 만드는 건 오히려 잘못된 제안을 만들 수 있기 때문이다.

Code Risk 분석 중 Grafana Alerting이 발생하면?

가장 궁금한 시나리오는 다음과 같다.

text
1. 사용자가 my-app의 feature/payment branch로 Code Risk 분석 실행
2. 거의 동시에 Grafana alert 발생
3. Grafana는 my-app의 deployBranch인 main branch에서 source snippet 추출 필요

두 흐름 모두 같은 lock key를 사용한다.

text
repository:lock:my-app
Case 1. Code Risk가 먼저 lock을 획득한 경우
text
Code Risk
  acquire repository:lock:my-app
  fetch
  checkout feature/payment
  reset --hard origin/feature/payment
  release repository:lock:my-app
  collect files
  build bundle
  LLM analysis

Grafana Alerting
  wait for repository:lock:my-app
  acquire repository:lock:my-app
  fetch
  checkout main
  reset --hard origin/main
  release repository:lock:my-app
  extract source snippets
  LLM firing analysis

Code Risk는 lock을 해제한 뒤 파일 수집과 bundle 생성을 시작한다. 여기서 한 가지 주의할 점이 있다. 현재 구현은 lock이 Git 동기화 구간만 보호한다. 이후 collectSourceFiles()buildBundle()은 lock 밖에서 수행된다.

이 설계는 lock 점유 시간을 짧게 유지한다는 장점이 있다. 하지만 같은 persistent working tree를 그대로 읽는 동안 다른 요청이 branch를 바꿀 수 있다는 여지는 있다. 현재는 Git 변경 작업의 충돌을 막는 데 초점을 둔 설계다. 분석 입력 전체의 branch snapshot 격리까지 보장하려면 다음 중 하나를 추가로 고려할 수 있다.

  • 파일 수집과 bundle 생성까지 lock 범위를 넓힌다.
  • Persistent repository에서 분석 전용 임시 worktree/copy를 만든 뒤 lock을 해제한다.
  • JGit worktree 대신 commit tree object를 직접 읽는 방식으로 변경한다.

이번 작업에서는 LLM 호출 시간까지 lock을 잡지 않기 위해 Git mutation 구간만 lock으로 보호했다. 다만 정확한 branch snapshot 격리가 더 중요해지는 경우에는 위 개선안 중 하나를 적용하는 것이 좋다.

Case 2. Grafana Alerting이 먼저 lock을 획득한 경우
text
Grafana Alerting
  acquire repository:lock:my-app
  fetch
  checkout main
  reset --hard origin/main
  release repository:lock:my-app
  extract source snippets
  LLM firing analysis

Code Risk
  wait for repository:lock:my-app
  acquire repository:lock:my-app
  fetch
  checkout feature/payment
  reset --hard origin/feature/payment
  release repository:lock:my-app
  collect files
  build bundle
  LLM analysis

두 경우 모두 Git 작업 자체는 동시에 실행되지 않는다. 그래서 .git/index.lock 충돌, checkout 중 reset 충돌, branch 전환 중 파일 삭제/생성 충돌 같은 문제를 줄일 수 있다.

Case 3. Lock 획득 실패

다른 작업이 오래 걸려 wait-timeout-ms 안에 lock을 얻지 못하면 preparePersistentRepository()는 예외를 던진다. 하지만 public entry point인 prepareRepository()가 이를 잡고 임시 clone으로 fallback한다.

kotlin
val persistentPath = try {
    preparePersistentRepository(appName, gitUrl, branch, accessToken)
} catch (e: Exception) {
    log.error("Persistent repository preparation failed. Falling back to temporary clone ...")
    null
}
return persistentPath ?: cloneRepository(appName, gitUrl, branch, accessToken)

즉, persistent cache 동기화가 실패해도 Code Risk나 Grafana Alerting 분석 자체는 계속 진행될 수 있다. 성능 최적화 기능이 장애 분석 기능 전체의 single point of failure가 되지 않도록 한 것이다.

Summary

Redis lock 적용의 핵심은 "persistent repository cache를 쓰되, Git working tree 변경은 분산 환경에서도 한 번에 하나만 수행한다"이다.

정리하면 다음과 같다.

  • Redis SET NX PX 방식으로 distributed lock을 구현했다.
  • UUID token을 lock value로 저장하고, Lua script로 token이 일치할 때만 unlock한다.
  • Lock key는 repository:lock:{applicationName} 형식으로 application scope를 사용했다.
  • Lock 구간은 clone, fetch, checkout, reset, delete처럼 working tree를 변경하는 작업으로 제한했다.
  • Persistent repository 준비 실패 시 임시 clone으로 fallback해서 분석 기능의 가용성을 유지했다.
  • Code Risk와 Grafana Alerting은 동일한 prepareRepository() entry point를 사용하므로 같은 동시성 정책을 공유한다.

Redis lock이 만능은 아닐 것이다. TTL, timeout, lock 범위, fallback 정책을 함께 설계해야 실서비스에서 안전하게 사용할 수 있다. 이번에는 "성능 최적화로 도입한 persistent repository가 분석 정확성과 서비스 가용성을 해치지 않도록" Redis lock을 적용했다.

Written by
author
풍우래기

여행을 좋아하는 집돌이 개발자입니다.

블로그에 새로운 글이 발행되었습니다.