로컬 테스트를 성공한 로직이 개발 서버에서 실패했다
개요
로컬 테스트를 성공한 로직이 개발 서버에서 실패했습니다.
심지어 개발 DB를 로컬 테스트에 붙여 실행해봐도 성공합니다. 이유가 무엇이었을까요?
문제 정의
문제는 DB와 관련된 로직에서 발생했습니다.
문제가 발생한 로직을 간단히 표현해 보았습니다.
1. 객체를 DB에 저장
2. DB에 저장된 객체 개수에 따른 로직 실행 // 객체 개수 오류!
객체를 DB에 저장하고 DB에 저장된 객체 개수에 따른 로직을 실행합니다.
이상할 것이 없는 로직이라 생각했고, 로컬 테스트도 잘 통과했습니다. 하지만 개발서버 테스트에서는 문제가 생겼죠.
테스트는 2번에서 구한 객체 개수가 방금 저장한 객체를 반영하지 못해 실패했습니다.
왜 이런 문제가, 그것도 개발 서버에서만 발생했던 것일까요?
DB Replication (복제)
일반적인 웹 서비스 환경에서 읽기는 쓰기에 비해 큰 비중을 차지하고 그 비율은 8:2 이상입니다.
그렇기 때문에 DBMS는 이런 과도한 읽기 연산에 대한 부담을 줄이기 위해 읽기 전용 Replica DB 서버를 지원합니다.
대부분의 비중을 차지하는 읽기 연산을 여러 Replica DB가 분산하여 처리하면 DB 서버의 부담을 효과적으로 줄일 수 있게 됩니다.
이 방법은 DB 서버의 부담을 덜어주는 효과적인 방법이지만 이 글에서 다루는 문제의 원인이기도 합니다.
각 Replica DB는 쓰기 연산을 담당하는 Main DB로부터 데이터를 동기화해야 합니다.
그리고 MySQL은 기본적으로 이 동기화 작업을 비동기로 처리합니다.
Main 서버 DB에 commit된 내용이 Replica 서버 DB에 반영되는 과정은 다음과 같습니다.
1. main 서버에서 binary log에 변경 사항을 기록한다.
2. main 서버에서 binary log를 읽어 replica 서버로 변경 사항을 전송한다.
3. replica 서버 I/O Thread는 받은 변경 사항을 relay log에 기록한다.
4. replica 서버 SQL Thread는 relay log에서 값을 읽어 replica 서버 DB에 변경 사항을 저장한다.
1~4번 과정이 모두 끝나야 Main DB에 commit된 내용이 Replica DB에 반영되는 것입니다.
이제 처음 문제를 다시 살펴보겠습니다.
1. 객체를 DB에 저장
2. DB에 저장된 객체 개수에 따른 로직 실행 // 객체 개수 오류!
해결 방법
이 문제를 애플리케이션 코드에서 해결하는 방법은 간단합니다.
해당 로직에 대한 읽기 요청을 Replica DB가 아닌 Main DB에서 수행하도록 설정하면 됩니다.
자세한 해결 방법은 환경마다 다르겠지만, Django에서는 어떻게 문제를 해결했는지 적어보겠습니다.
먼저 메서드에 사용할 데코레이터를 정의해줍니다.
데코레이터는 함수 진입 시점에 threading.local()에 지정한 Key를 기록하고 빠져나오는 시점에 Key를 지우는 역할을 수행합니다.
import threading
threadlocal = threading.local()
class read_from_main(object):
def __enter__(self):
setattr(threadlocal, ‘READ_FROM_MAIN’, True)
def __exit__(self, exc_type, exc_value, traceback):
delattr(threadlocal, ‘READ_FROM_MAIN’)
def __call__(self, test_func):
@wraps(test_func)
def inner(*args, **kwargs):
return test_func(*args, **kwargs)
return inner
def get_thread_local(attr, default=False):
return getattr(threadlocal, attr, default)
@read_from_main()
def get_object_count():
pass
다음으로 Django DB 라우터의 db_for_read를 수정해주면 데코레이터 적용 시에는 main에서 데이터를 읽어오도록 할 수 있습니다.
class DBRouter:
def db_for_read(self, model, **hints):
if get_thread_local('READ_FROM_MAIN'):
return 'main'
else:
return 'replica'
기타 읽을거리
- semi-sync replication 방식을 사용하면 commit 시점에 Replica 서버 relay log에 기록이 남는 것을 보장할 수 있습니다.
- relay log에서 replica DB에 반영하는 단계가 남았기 때문에 문제를 완전히 해결하지는 못할 것이라 예상합니다.
- https://hoing.io/archives/3633
- https://dev.mysql.com/doc/refman/8.3/en/replication-semisync.html
MySQL Replication(복제) 구성 및 설정 - Semi Sync - after_sync/after_commit
이번 글은 MySQL 5.5 버전에서 도입된 Semi-sync 형태의 복제 방식과 전달 방식에 따라서 나눠지는 after_commit(ver 5.5) 과 after_sync(ver 5.7.2) 방식에 대해서 설명하고 있습니다.
hoing.io