Post

캐시를 적용하기 까지의 험난한 길 파헤치기

캐시를 적용하기 까지의 험난한 길 파헤치기

토스뱅크 캐시 적용기를 읽고 — Redis를 쓰면 정합성 문제가 해결될까?

참고한 아티클: 캐시를 적용하기 까지의 험난한 길 (TPS 1만 안정적으로 서비스하기)

1. 문제 정의

약관 서버의 조회 트래픽이 증가하면서 Database 부하가 커졌고, 이를 줄일 방법이 필요해졌다.

약관 동의 여부는 개인정보를 이용하거나 제3자에게 제공해도 되는지 판단하는 기준이다. 사용자가 동의를 철회했는데 이전 값을 내려주면 문제가 생길 수 있으므로, 조회 성능뿐 아니라 데이터 정합성도 중요하다.

여기서 생각해 볼 수 있는 방법은 두 가지였다.

  1. Replica Database를 두고 읽기 조회를 분산한다.
  2. 캐시를 두고 DB로 들어가는 조회를 줄인다.

아티클에서는 Replica의 복제 지연으로 이전 동의 상태가 조회될 수 있다는 이유로, Redis Cache를 활용하는 방향을 선택했다.


2. 처음 가졌던 의문: Redis Cache도 같은 문제가 생기지 않나?

Replica에 복제 지연이 생길 수 있다는 건 이해했다. 그런데 읽다가 이런 생각이 들었다.

Redis도 DB와 별도로 데이터를 가지고 있는데, DB가 바뀌면 캐시 역시 잘못된 데이터를 내려줄 수 있는 것 아닌가?

그리고 약관이라는 서비스의 특성을 생각해 봤다.

  • 동의 여부를 변경하는 쓰기는 빈번하지 않다.
  • 동의 여부를 확인하는 읽기는 많다.
  • 그렇다면 읽기는 캐시로 처리하고, 쓰기가 일어났을 때 관련 캐시를 무효화해야 하는 것 아닌가?

그래서 Redis를 도입한 뒤에도 DB와 캐시의 값이 어긋나지 않도록 어떻게 관리하는지가 궁금해졌다.


3. 캐시를 지우면 되는 건 맞는데, 언제 지워야 할까?

읽기가 많고 변경이 적으니 캐시를 쓰기 좋은 상황인 건 이해했다. 한 번 조회한 값을 여러 요청에서 쓸 수 있기 때문이다. 그렇다고 변경이 적다는 이유로 정합성 문제가 없어지는 건 아니다. 한 번이라도 이전 동의 상태를 내려주면 문제가 될 수 있다.

내가 생각한 “변경되면 캐시를 지우자”는 방향은 맞았다. 그런데 글을 읽어 보니 언제 지울지, 삭제에 실패하면 어떻게 할지가 더 중요했다.

아티클에서는 DB 커밋 이후에 캐시를 삭제한다. 삭제에 실패하면 Circuit Breaker로 캐시 사용을 막고 DB를 조회한다. Kafka 이벤트를 발행하는 순서도 캐시 삭제 이후로 옮겼다.

처음에는 Redis를 쓰면 복제 지연 문제를 피할 수 있다는 정도로 읽었는데, 결국 캐시를 쓰면서도 최신 값을 내려줄 수 있도록 이런 처리가 필요한 것이었다.

캐시 삭제를 마친 뒤 변경 요청을 완료하도록 만들 수는 있지만, 동시에 들어오는 요청이나 장애까지 생각하면 이것만으로 모든 문제가 해결됐다고 보기는 어렵다. 글에서도 최신 값을 보장하는 기준을 DB 커밋 직후에서 변경 API 처리가 완료된 이후로 조정한다. 어디까지를 요청 처리의 완료로 볼지도 같이 정해야 했던 셈이다.


4. Look-aside는 미리 캐시에 저장해 놓는 방식인가?

캐시를 지우는 건 알겠는데, 그러면 처음에는 언제 채우는 걸까? Look-aside라고 하니 약관 동의 상태를 미리 전부 넣어 두는 방식인가 싶었다.

알고 보니 조회가 들어왔을 때 필요한 값을 채우는 방식이었다. 사용자 A의 약관 동의 여부를 조회한다면 다음과 같이 동작한다.

  1. Redis에서 A의 동의 여부를 찾는다.
  2. 값이 없으면(Cache Miss) DB에서 조회한다.
  3. 조회한 값을 Redis에 저장하고 응답한다.
  4. 이후 같은 값을 조회할 때 캐시에 값이 있으면(Cache Hit) DB까지 가지 않는다.

그러면 앞에서 캐시를 삭제한 뒤의 흐름도 이어진다. 다음 조회가 들어오면 DB에서 읽고 캐시를 다시 채우는 것이다. 이 과정은 Redis가 알아서 하는 게 아니라 애플리케이션의 조회 로직에서 처리한다.

자주 쓰는 값을 미리 넣어 두는 캐시 워밍을 할 수도 있지만, Look-aside를 쓰려면 꼭 미리 채워야 하는 건 아니었다.


5. 이게 그냥 일반적인 캐시 사용 방식 아닌가?

설명을 보고 나니 오히려 이런 생각이 들었다.

Look-aside는 그냥 우리가 일반적으로 알고 있는 캐시 사용 방식 아닌가? 그러면 다른 방식은 뭐가 있지?

내가 알고 있던 “캐시에 없으면 DB에서 가져와서 넣는다”는 방식의 이름이 Look-aside(Cache-aside)였다.

다른 방식도 같이 보니 읽기와 쓰기를 나눠서 봐야 했다. 읽을 때 어떻게 가져올지와 변경한 값을 어떻게 반영할지는 따로 정할 수 있기 때문이다.

읽을 때

방식캐시에 값이 없으면?
Look-aside / Cache-aside애플리케이션이 DB에서 조회하고 캐시에 저장한다.
Read-through캐시 계층의 로더가 DB 조회와 캐시 저장을 처리한다.

Read-through라고 미리 데이터를 넣어 두는 건 아니었다. 둘의 차이는 DB에서 가져오는 일을 누가 하느냐였다. Redis만 두면 Read-through가 되는 것도 아니고, DB에서 값을 가져올 로더가 필요하다.

쓸 때

방식어떻게 반영하나?주의할 부분
Write-through캐시 계층을 통해 쓰고 DB 반영까지 동기적으로 처리한다.DB 반영을 기다려야 하고, 한쪽만 성공했을 때의 처리도 필요하다.
Write-behind / Write-back캐시에 먼저 쓰고 DB에는 나중에 비동기로 반영한다.DB 반영이 늦어지거나 반영 전에 데이터가 유실될 수 있다.
Write-around캐시를 거치지 않고 DB에 쓴다.기존 캐시가 있다면 삭제 등으로 이전 값이 남지 않게 해야 한다.

이번 글은 읽을 때는 Look-aside, 변경할 때는 DB에 쓰고 캐시를 삭제하는 방식으로 이해했다. 쓰기만 보면 Write-around에 가깝다.

방식마다 이름은 있지만, 어떤 걸 고르든 실패하거나 요청이 겹칠 때의 문제는 따로 생각해야 했다.


6. 그러면 왜 Read-through를 쓰지 않았을까?

두 방식의 차이를 보고 내가 든 생각은 이거였다.

캐시 계층의 의존성이 DB를 향하지 않게 하려고 Look-aside를 쓴 건가?

Look-aside에서는 애플리케이션이 Redis와 DB를 각각 호출하니, 캐시 쪽에서는 DB 조회 방법을 몰라도 된다. Read-through에서는 그 일을 캐시 계층의 로더가 맡는다.

다만 로더를 애플리케이션 안에 둘 수도 있다. Read-through라고 Redis 서버가 DB에 직접 접속하는 구조를 떠올릴 필요는 없었다. 내가 궁금했던 의존성 문제는 결국 DB 조회 로직을 어디에 둘 것인가에 더 가까웠다.

글에서는 조회가 많고 변경이 적다는 이유로 Look-aside를 골랐다고 설명한다. 그런데 그 조건은 Read-through에도 해당하지 않나? 둘을 직접 비교한 설명은 없어서, 왜 Look-aside였는지에 대한 궁금증은 아직 남아 있다.

내 생각에는 애플리케이션에서 캐시와 DB를 각각 다루면, 캐시에 문제가 생겼을 때 DB로 조회를 돌리는 흐름이 잘 보일 것 같다. 이 글의 Circuit Breaker 처리와도 이어진다. 물론 Read-through에서도 로더나 래퍼에 그런 처리를 넣을 수 있으니, 이것만으로 선택 이유를 단정할 수는 없다.

일단 “캐시가 DB를 몰라도 되게 하려는 것 아닐까?”라는 생각은 남겨 둔다. 실제로 그 이유로 선택했는지는 글에서 확인하지 못했다.


7. 여기서는 Redis 오류가 한 번만 나도 Circuit Breaker를 여는 건가?

Circuit Breaker를 이해하고 나니 이 부분이 눈에 들어왔다.

보통은 몇 번 실패하면 Open하는 것 같은데, 여기서는 Redis가 실패하면 바로 Open한다는 게 다른 점인가?

정확히는 Circuit Breaker가 꼭 연속 실패 횟수만 보는 건 아니었다. 일정 구간의 실패율이나 느린 호출 비율 등을 조건으로 둘 수도 있다.

이 글의 코드는 onError에서 바로 transitionToForcedOpenState()를 호출한다. 해당 Circuit Breaker에 캐시 호출 오류가 한 번만 기록돼도 강제로 여는 것이다.

왜 한 번에도 이렇게 처리할까 생각해 보니, DB는 변경됐는데 캐시 삭제만 실패한 상황 때문이었다. 실패가 더 쌓일 때까지 기다리면 그동안 이전 동의 상태를 계속 내려줄 수 있다. 그래서 캐시 사용을 막고 애플리케이션에서 DB를 조회하도록 처리한다.

또 하나는 그냥 Open이 아니라 자동으로 닫히지 않는 Forced Open이라는 점이다. Redis와 다시 통신할 수 있게 됐다고, 삭제에 실패했던 값까지 없어지는 건 아니기 때문이다.

결국 이 글에서는 약관 상태를 잘못 내려주지 않기 위해, 한 번의 오류에도 캐시를 차단하고 자동으로 다시 사용하지 않도록 설정한 것으로 이해했다.

This post is licensed under CC BY 4.0 by the author.