이 사이트의 실습은 자바스크립트를 켜야 움직입니다. 아래는 같은 내용을 글로 적은 것입니다.
공부 › DB › 확장과 분산
복제 지연과 CAP
방금 저장했는데 조회하니 옛날 값이 나온다
쓰기는 원본(프라이머리) DB에, 읽기는 복제본(레플리카)에 나눠 보내면 읽기 부하를 분산할 수 있습니다. 하지만 원본의 변경이 복제본에 도착하기까지 시간이 걸립니다. 이 시간 차이를 복제 지연이라고 합니다. 네트워크가 끊겼을 때 응답을 계속할지(가용성), 최신인지 알 수 없는 값은 응답하지 않을지(일관성) 가운데 하나를 골라야 한다는 것이 CAP 정리이며, 두 경우를 모두 봅니다.
개념 모형 · 원리를 보이는 모형
이 실습에 나오는 개념
- 레플리케이션(복제)
- 원본 데이터베이스(source이며 primary라고도 부른다)의 변경 내용을 다른 데이터베이스(replica)에 계속 복사해 같은 데이터를 유지하는 것이다. 읽기 요청을 복제본으로 분산해 부하를 줄이고, 원본에 장애가 나면 복제본을 원본으로 승격해 서비스를 이어 갈 수 있다. 복제에는 약간의 지연이 있어 방금 쓴 데이터가 복제본에서 바로 보이지 않을 수 있다.
- 최종 일관성(Eventual Consistency)
- 데이터를 변경한 직후에는 일부 노드가 이전 값을 보여 줄 수 있지만, 추가 변경이 없으면 시간이 지나 모든 노드가 같은 값을 갖게 된다는 보장이다. 즉시 일관성을 포기하는 대신 가용성과 확장성을 얻는다. 분산 데이터베이스와 마이크로서비스 간 데이터 동기화에서 흔히 채택된다.
- CAP 정리
- 분산 데이터 저장소는 일관성(모든 노드가 같은 데이터를 보여 줌), 가용성(요청에 항상 응답함), 분할 내성(노드 간 통신이 끊겨도 동작함)의 세 가지를 동시에 모두 보장할 수 없다는 정리다. 네트워크 단절은 피할 수 없으므로, 단절이 일어났을 때 일관성과 가용성 중 어느 쪽을 우선할지를 선택하게 된다.
- readOnly 트랜잭션
- @Transactional(readOnly = true)는 그 트랜잭션이 데이터를 변경하지 않음을 알리는 설정이다. JPA(Hibernate)에서는 변경 내용을 DB에 반영하는 flush를 생략하는 등의 최적화가 적용되고, 읽기용 DB와 쓰기용 DB를 나눠 쓰도록 따로 구성한 경우 읽기용으로 보내는 기준으로도 쓰인다. 조회 메서드에 붙여 의도를 명확히 한다.
이렇게 바꿔 보세요
- 처음 설정으로 — 복제 1초, 저장 0.3초 뒤 복제본 조회. 옛 값이 나옵니다.
- 저장 직후 조회만 원본으로 — 방금 쓴 사용자는 원본에서 읽게 합니다.
- 시간이 지난 뒤 조회 — 복제가 끝난 뒤에는 복제본도 새 값을 줍니다.
- 복제가 5초씩 밀릴 때 — 2초 뒤에 읽어도 옛 값입니다.
- 네트워크 단절: 계속 응답 — 서비스는 되지만 옛 값을 돌려줍니다.
- 네트워크 단절: 응답 거부 — 틀린 값은 주지 않지만 조회가 실패합니다.
처음 설정으로 실행하면
- 회원의 닉네임은 "철수"이고, 원본과 복제본이 같은 값을 가지고 있습니다.
- 0초: 사용자가 닉네임을 "민수"로 바꿉니다. 원본 DB에 저장되고 커밋됩니다. 사용자에게는 "저장 완료"가 응답됩니다. 복제본에는 아직 전달되지 않았습니다.
- 0.3초: 조회를 복제본으로 보냅니다. 복제가 아직 도착하지 않아 복제본에는 옛 값 "철수"가 있습니다. 이 시차가 복제 지연입니다. 사용자는 방금 바꾼 닉네임이 반영되지 않은 화면을 봅니다.
- 1초: 뒤늦게 복제가 도착해 복제본도 "민수"가 됩니다. 이후의 조회는 새 값을 봅니다.
실행 결과
- 복제는 보통 비동기라서 원본과 복제본 사이에 시차가 있습니다. 시간이 지나면 같아지지만 그 전에는 다를 수 있습니다(최종 일관성). 대응 방법은 자기가 방금 쓴 데이터는 원본에서 읽게 하는 것, 또는 쓰기 직후의 화면은 응답으로 받은 값을 그대로 쓰는 것입니다.
코드
// readOnly 트랜잭션은 복제본으로, 그 외는 원본으로 보내도록 데이터소스를 따로 구성해 둔 경우
@Transactional
public void changeNickname(long id, String name) { // -> 원본
memberRepository.findById(id).orElseThrow().setNickname(name);
}
@Transactional(readOnly = true)
public Member getMember(long id) { // -> 복제본
return memberRepository.findById(id).orElseThrow();
}
이 모형의 전제
원본 1대와 복제본 1대, 비동기 복제를 가정했습니다. 복제 시간은 설정한 값으로 일정하다고 보았지만 실제로는 부하에 따라 출렁이고, 평소 0.1초 미만이다가 순간적으로 수 초 이상 벌어지기도 합니다. CAP 정리는 네트워크 단절 상황에서의 선택에 관한 것이며, 실제 시스템은 데이터 종류마다 다른 선택을 섞어 씁니다.
앞 실습: 버퍼 풀과 페이지 · 다음 실습: 샤딩