Articles

새 커밋이 없으면 다시 분석하지 않기

WhoIsLoafing의 심층 분석은 레포 구조, CI/CD, 이슈와 PR 양식을 AI로 정리해요. 같은 레포를 다시 분석해 달라는 요청이 와도 그사이 푸시가 없으면 AI를 부르지 않도록, 레포의 마지막 푸시 시각을 기준으로 캐시를 나눈 방법을 정리했어요.

WhoIsLoafing의 macOS 앱에는 심층 분석 화면이 있어요. 분석해 둔 레포를 고르면 프로젝트 구조, CI/CD 파이프라인, 이슈 양식, PR 양식, 참여자별로 고친 폴더, 커밋 활동을 탭으로 나눠 보여줘요. 탭마다 GitHub에서 자료를 모으고 AI가 설명을 붙여요.

탭에는 "다시 분석" 버튼이 있어요. 레포가 바뀌었으면 새로 보고 싶으니까요. 그런데 레포가 그대로인데 버튼을 누르면 같은 자료로 같은 AI 호출을 반복하게 돼요. 이 낭비를 어떻게 막았는지 정리했어요.

레포가 바뀌었는지 무엇으로 알까

레포가 바뀌었는지 알려면 기준이 필요해요. 후보는 몇 가지가 있어요.

기준 걸리는 점
기본 브랜치의 마지막 커밋 브랜치를 따로 조회해야 해서 요청이 하나 더 들어요
레포의 수정 시각(updated_at) 설명이나 별 수만 바뀌어도 달라져요
레포의 마지막 푸시 시각(pushed_at) 레포 정보에 들어 있고, 코드가 올라올 때만 바뀌어요

심층 분석은 어차피 레포 정보를 먼저 받아요. 비공개인지 확인하고 기본 브랜치 이름을 알아야 하니까요. 그 응답에 마지막 푸시 시각이 들어 있어서, 요청을 더 하지 않고 기준으로 쓸 수 있었어요. 이 값을 리비전이라고 부르기로 했어요.

val repo = gitHubClient.getRepo(owner, repoName)
if (repo.isPrivate) throw RepoPrivateException()

val revision = repo.pushedAt ?: EMPTY_REVISION

한 번도 푸시된 적 없는 빈 레포는 푸시 시각이 없어요. 그때는 empty라는 고정 값을 써요.

화면이 기억한 리비전을 같이 보내요

앱은 받은 분석 결과를 기기에 저장해 두고, 다음에 탭을 열면 기다리지 않고 바로 보여줘요. 이때 결과와 함께 받은 리비전도 저장해요.

"다시 분석"을 누르면 저장해 둔 리비전을 since로 같이 보내요.

const result = await fetchDeepReport(repo, target, kept?.report.revision)

서버는 지금 레포의 리비전과 받은 값을 비교해요. 같으면 아무것도 계산하지 않고 "바뀐 게 없다"고만 답해요.

if (since != null && since == revision) {
    return GlobalResponse.ok(
        DeepReportResponseDto(repo = repo.fullName, section = section, summary = "", revision = revision, unchanged = true)
    )
}

화면은 이 답을 받으면 보던 결과를 그대로 두고, "기존 분석 이후 새로운 변화가 감지되지 않았어요."라는 안내를 잠깐 띄워요.

if (kept && 'report' in result && result.report.unchanged) {
  showToast('기존 분석 이후 새로운 변화가 감지되지 않았어요.')
  setLoaded((prev) => ({ ...prev, [target]: kept }))
  return
}

버튼을 눌렀는데 아무 일도 없으면 고장 난 것처럼 보여요. 왜 다시 분석하지 않았는지 알려 주는 것까지가 이 기능이에요.

서버도 리비전별로 기억해요

화면이 기억하는 것만으로는 부족해요. 같은 레포를 다른 사람이 처음 열 수도 있고, 같은 사람이 다른 기기에서 열 수도 있어요. 그래서 서버도 결과를 Redis에 저장해요. 저장하는 키에 리비전을 넣었어요.

val key = "$KEY_PREFIX${repo.id}:$revision:${section.value}"
redisJsonCache.get(key, REPORT)?.let { return GlobalResponse.ok(it.copy(revision = revision)) }

키는 레포 id, 리비전, 탭 세 가지로 이뤄져요.

  • 레포 id: 레포 이름은 바뀔 수 있지만 id는 바뀌지 않아요. 이름을 바꾼 레포도 같은 결과를 찾아요.
  • 리비전: 새 푸시가 있으면 리비전이 바뀌니, 자연스럽게 다른 키가 돼요. 예전 결과를 지우는 코드가 필요 없어요.
  • 탭: 탭마다 따로 계산하니 따로 저장해요.

캐시를 "지우는" 대신 "다른 키를 쓰는" 방식이라, 언제 캐시를 비워야 할지 고민할 필요가 없어요. 예전 리비전의 결과는 아무도 찾지 않다가 6시간 뒤에 Redis에서 사라져요.

키 앞에는 analysis:deep:v3:처럼 버전을 붙였어요. 응답 모양을 바꾸면 버전을 올려서, 예전 모양으로 저장된 결과를 읽지 않게 해요.

완성된 결과만 저장해요

탭 하나를 만드는 데는 GitHub 조회와 AI 호출이 여러 번 들어가요. 그중 AI 설명 하나가 실패하면, 숫자는 다 있는데 설명만 빠진 결과가 나와요. 이 결과도 사용자에게는 보여줘요. 아무것도 없는 것보다 나으니까요.

하지만 이런 결과를 캐시에 넣으면, 6시간 동안 모두가 설명이 빠진 결과를 받게 돼요. 다시 분석해도 캐시에서 같은 결과가 나와요. 그래서 계산하는 동안 하나라도 실패하면 표시를 남기고, 완성된 결과만 저장해요.

private inner class Answer(
    val repo: String,
    val section: DeepSection,
) {
    var complete = true
}
} catch (e: Exception) {
    log.error("[structure] 구조 설명 생성 실패: {}/{} ({})", owner, repo, e.message)
    answer.complete = false
}
val report = built.copy(revision = revision, analyzedAt = Instant.now().toString())
if (answer.complete) redisJsonCache.set(key, report, REPORT, ANSWER_TTL)

실패한 결과는 이번에만 보여주고 잊어요. 다음 요청은 처음부터 다시 계산해요.

언제 분석한 결과인지 보여줘요

캐시를 쓰면 지금 보는 결과가 언제 만들어진 것인지 헷갈릴 수 있어요. 그래서 결과에 분석한 시각을 넣고, 화면의 탭마다 마지막 분석 시각을 적어 둬요. 캐시에서 꺼낸 결과는 처음 만든 시각을 그대로 들고 있어서, 사용자는 몇 시간 전 결과인지 알 수 있어요.

정리

  • 레포가 바뀌었는지는 레포 정보에 들어 있는 마지막 푸시 시각으로 판단해요. 요청이 더 들지 않아요.
  • 화면이 기억한 리비전을 같이 보내면, 서버는 바뀐 게 없을 때 계산 없이 바로 답할 수 있어요.
  • 캐시 키에 리비전을 넣으면 캐시를 지울 필요 없이 새 푸시마다 새 키가 돼요.
  • 일부가 실패한 결과는 보여주되 캐시에는 넣지 않아요.
  • 다시 분석하지 않았을 때는 왜 그런지 화면에서 알려줘요.
다음 글 스프링 서버를 네 개의 모듈로 나눈 기준