우테코 5기

데이터베이스 호환성을 고려한 무중단 배포 진행하기

teo_99 2023. 10. 29. 20:41

배경

현재 하루스터디라는 서비스를 개발하고 있습니다. 하루스터디는 효율적인 학습 사이클을 제공하는 서비스로, 학습 전 후로 계획 및 회고 작성을 권장함으로써 학습 방법에 대한 빠른 피드백을 가능하게 해 줍니다.
 

하루스터디 학습 사이클

이런 학습 사이클은 여럿이서도 진행할 수도 있고, 혼자서도 진행할 수 있습니다. 그런데 사실 이전까지는 여럿이서 하루스터디를 사용하더라도 진행상황이 동기화되지 않았습니다. 누구는 계획 단계, 누구는 회고 단계일 수 있다는 이야기입니다. 
 
그리고 진행상황을 동기화해 달라는 사용자 피드백에 따라 새로운 버전에서는 동기화된 버전을 제공할 예정이었습니다. 하지만 문제가 여럿 발생했습니다.
 

스터디 중간에 버전이 바뀐다면?

우선 이전 버전에서는 '진행도'라는 개념이 사용자마다 존재했습니다. 따라서 누구는 계획을 작성하고, 누구는 회고를 작성할 수 있었던 것입니다. 그런데 새로 배포될 버전에서는 진행도라는 개념이 스터디마다 하나씩만 존재합니다. 방장이 전체 진행도를 제어하는 구조이기 때문입니다. 따라서 새로운 기능을 무중단으로 배포하려 했을 때, 다음과 같은 고려사항들이 있었습니다.

  • 배포 시점에 이전 버전을 사용하던 사용자는 그대로 스터디를 마칠 수 있어야 합니다.
  • 이전 버전에서 기록되던 정보들은 실시간으로 새로운 버전에서도 바로 확인할 수 있어야 합니다. 사용자가 스터디를 마친 뒤 새로운 버전에서 곧바로 기록을 조회할 수도 있기 때문입니다.

이 문제를 해결할 수 있는 가장 간단한 방법은 API 호환성 보장이라고 생각했습니다. 어쨌거나 이전 버전의 하루스터디를 사용하는 사용자는 성공적으로 스터디를 마칠 수 있게 지원해 주면서, 최신 버전의 하루스터디를 사용하는 사용자를 모두 지원할 수 있어야 했기 때문입니다. 따라서 다음과 같은 롤링 배포 방식을 고려하게 되었습니다. 
 

롤링 배포 적용

롤링 배포 방식은 다른 버전의 WAS를 여러 대 띄워두고, 트래픽을 점진적으로 V2에 옮기는 방식입니다. 만약 V1에 x%의 트래픽이, V2에 100-x%의 트래픽이 발생한다고 한다면 x를 점진적으로 늘려가면서 V1의 WAS에게는 트래픽이 전달되지 않도록 합니다. 이후 V2에 100%의 트래픽이 전송되고 있다면 V1 WAS를 V2로 업데이트합니다.
 
이런 롤링 배포 방식을 고려하게 된 이유는 앞서 말했듯이 기존에 스터디를 진행 중이던 사용자가 스터디를 정상적으로 마칠 수 있도록 보장해줘야 했기 때문입니다. 한 번에 트래픽을 새로운 버전으로 전환시키는 블루-그린 방식은 적절하지 않다고 생각했습니다. 
 
V1 WAS의 경우 기존 API를 사용하고, V2 WAS의 경우에는 모든 API path에 'v2'를 붙였습니다. 이에 따라 기존 사용자는 자연스레 WAS V1.0를 사용해 스터디를 성공적으로 마칠 수 있고, 신규 사용자는 WAS V2.0에 접근해 새로운 버전을 사용하게 됩니다.
 

데이터베이스 호환성은 어떻게 보장할까?

그렇지만 문제가 되는 부분은 데이터베이스였습니다. 새로운 버전은 데이터베이스 스키마가 완전히 달랐기 때문에, 하나의 데이터베이스로 두 가지 버전의 API를 지원할 수는 없었기 때문입니다. 따라서 몇 가지 해결책을 고민하게 되었습니다.

해결책 1. 스케줄 활용하기

가장 처음 팀원들과 함께 떠올렸던 해결책은 데이터베이스 스케줄을 활용해 마이그레이션을 수행하는 것이었습니다. 데이터베이스 스케쥴을 아래와 같은 형태로 작성해서 주기적으로 신규 레코드를 확인하고 V2 테이블에 삽입합니다. 

그렇지만 이 방법의 경우 완전한 해결책이 아니라고 판단했는데, 그 이유는 다음과 같습니다.

  • 레코드 삽입이 아닌 수정/삭제의 경우 변경사항을 추적하기 어렵습니다.
  • 레코드가 어디까지 삽입되었는지 매번 기록을 해두어야 합니다.

따라서 대안을 고민하게 되었습니다. 테이블 단위로, 혹은 레코드 단위로 복제하는 기능이 있는지 조사했습니다.
 

해결책 2. 트리거 활용하기

MySQL에서는 트리거라는 장치를 제공합니다. 트리거는 어떤 테이블에 대해 특정 이벤트가 발생했을 때 후처리를 가능하게 해주는 기술입니다. 예를 들어 어떤 테이블에 INSERT 문이 발생한다면 특정 SQL 구문을 실행하도록 구성할 수 있습니다.

InnoDB 스토리지 엔진을 사용하는 경우에는 트리거와 트리거를 발생시킨 문(statement)이 함께 하나의 트랜잭션으로 통합되어 실행됩니다. 따라서 롤백 상황에도 유연하게 대응할 수 있고, V1 테이블에는 정보가 있지만 V2 테이블에는 정보가 없는 상황도 발생하지 않아 데이터 정합성에 문제가 생길 염려는 없습니다.

For transactional tables, failure of a statement should cause rollback of all changes performed by the statement. Failure of a trigger causes the statement to fail, so trigger failure also causes rollback. - MySQL 8.0 reference

스터디 진행도가 바뀌는 경우에는 가령 다음과 같이 트리거를 작성해서, 새로운 V2 테이블에서도 하나의 트랜잭션으로 동기화되도록 설정했습니다. pomodoro_progress 테이블에 대한 UPDATE DML이 발생하는 경우, V2 테이블(study)에 대한 동기화까지 수행합니다.

DELIMITER $$

CREATE TRIGGER pomodoro_progress_update_trigger
AFTER UPDATE
ON pomodoro_progress
FOR EACH ROW

BEGIN
	UPDATE study SET step = NEW.pomodoro_status, current_cycle = NEW.current_cycle, last_modified_date = NEW.last_modified_date
		WHERE id = 
			(SELECT study_id 
				FROM pomodoro_study_to_study 
				WHERE pomodoro_study_id = OLD.pomodoro_study_id);
END $$

DELIMITER ;

비슷한 흐름으로 버저닝이 필요한 나머지 모든 테이블에 대해 트리거를 설정해 줬습니다. 
 

트리거가 걸리기 직전에 발생한 DML은 어떻게 처리할까?

이렇게 트리거를 통해 신규 삽입되는 데이터는 적절하게 처리할 수 있었지만, 트리거가 적용되려는 시점에 삽입, 수정된 데이터는 어떻게 처리할 수 있을지 고민이 생겼습니다. 만약 이를 제대로 처리하지 못하는 경우, V1 테이블과 V2 테이블의 불일치가 발생하기 때문에 정합성에 어긋나게 됩니다.

 따라서 트리거 설정 시 시간을 측정해, 해당 시간 이전으로 삽입된 데이터들에 대해서는 따로 마이그레이션을 진행합니다. MySQL에서의 사용자 정의 변수는 커넥션 단위로 공유되므로, 이를 사용하면 될 것입니다.

  • trigger 설정 스크립트
# 시작 종료 시간을 측정하기 위한 사용자 정의 변수 선언
SET @start_time = 0;
SET @end_time = 0;

delimiter &&
create event if not exists migration_trigger
ON SCHEDULE AT '2023-10-19 21:48:00'
DO
BEGIN
    SET @start_time = now();
    # 테이블 마이그레이션 및 트리거 설정
    # ...
    SET @end_time = now();
END &&
delimiter ;
  • 후처리 스크립트
delimiter &&
create event if not exists post_process
ON SCHEDULE AT '2023-10-19 21:49:00'
DO
BEGIN
    SET autocommit = false;
		INSERT INTO study
			SELECT id, name, total_cycle, time_per_cycle, total_cycle, 'DONE' as step, created_date, last_modified_date 
             	 	      FROM pomodoro_study WHERE last_modified_date > @start_time AND last_modified_date < @end_time; 

		UPDATE study s
			JOIN pomodoro_progress p ON s.id = p.study_id
			SET s.step = p.step, s.current_cycle = p.current_cycle
			WHERE p.last_modified_date > @start_time AND p.last_modified_date < @end_time;
       // ...

    commit;
    SET autocommit = true;
END &&
delimiter ;

 
 

V1 WAS 업데이트

이제 기존 V1 WAS를 새로운 버전으로 업데이트합니다. 다만 스터디를 진행 중인 사용자가 아직 있을 수 있으므로, Grafana를 이용해 트래픽을 모니터링하고 중단 시점을 결정했습니다. 스터디의 최대 진행 시간은 비즈니스 정책 상 10시간 40분이므로 넉넉하게 잡아 하루동안 트래픽이 발생하지 않는 경우 중단시키고 V2로 업데이트했습니다.

 

결론

하루스터디는 '스터디를 진행한다'는 비즈니스 특성 때문에 무중단 배포가 상당히 까다로웠던 것 같습니다. 기존에 스터디를 진행하고 있던 사용자가 스터디를 정상적으로 마칠 수 있도록 기다려주어야 하기 때문에 API 버저닝뿐만 아니라 데이터베이스 호환성까지 고려할 수밖에 없었습니다.
 
사실 처음 시도해 보는 방식이었고, 빠르게 배포를 했어야 하는 상황이었어서 중간 과정에서 많은 어려움을 겪었던 것 같습니다. 하지만 해당 경험을 토대로 앞으로 무중단 배포를 할 때 쉽게 호환성에 대한 설계를 해볼 수 있을 것 같습니다.
 

참고자료

MySQL 8.0 - Trigger Syntax