안녕하세요 브로콜리입니다.
오늘은 최근 디베이트 타이머에서 담당했던 데이터 마이그레이션 업무의 쿼리 계획에 대해 이야기해보고자 합니다.
먼저 도메인 용어부터 짚고 넘어가겠습니다.
토론 테이블 : 토론 시간표의 정보를 담은 테이블 (30초 알림 유무, 논제, 시간표 이름 등등)
토론 타임박스 : 토론 시간표를 구성하는 순서 하나하나
예를 들어 다음 보이는 시간표 하나하나는 토론 테이블이 됩니다. 지금은 2가지 토론 테이블이 있네요.
그중 창의와 소통 테이블 내로 들어가보면 각 테이블을 구성하는 타임박스들이 존재했습니다.
Situation : 의회식 토론 형식의 제거 > 커스텀 토론으로 통합
그럼 상황을 더 자세히 살펴보겠습니다.
디베이트 타이머는 의회식 토론과 커스텀 토론의 두가지 타입의 토론이 있었고, 각 토론 형식마다 토론 테이블과 토론 타임박스가 각각 따로 관리되었습니다.
그러나, DB에 쌓이는 데이터를 보며, 대부분이 유저가 의회식 토론 형식이 아닌 커스텀 토론 형식을 사용한다는 사실을 알게되었습니다. 그도 그럴것이 커스텀 토론 형식에서 사실상 의회식 토론 기능을 모두 포괄하고 있었기에 굳이 의회식 토론 형식으로 시간표를 만들 이유가 없었습니다.
실제로 유저 테스트를 진행했던 고려대 코기토에서도 의회식 토론임에도 불구하고 의회식 토론 형식이 아닌 커스텀 형식으로 토론 시간표를 만들었다는 점을 알게 되었습니다.
이에 따라 토론 시간표 형식에서 의회식 토론을 없애고 커스텀 토론 형식 하나로 통합하기로 기획이 정해졌습니다.
Task : 토론 테이블 데이터를 하나로 통합해라!
저에게 주어진 일은 운영 DB에 쌓인 의회식 토론 데이터를 커스텀 토론(사용자 지정 토론)으로 통합하는 일이었습니다.
의회식 토론 테이블은 프론트 단에서 더이상 생성이 되지않도록 막아둔 상황이었습니다.
이에 먼저 몇가지 기준점과 요구사항을 구체화했습니다.
1) 다운타임을 1초 내외로 하여 유저 불편함을 최소화하여 데이터를 이전하고 싶다
2) 데이터 정합성을 유지하는 안전한 방식을 택하고 싶다
Action : 마이그레이션 쿼리 실행 계획
이에 따라 제가 세운 계획은 다음과 같습니다.
1) migration_table 만들기
먼저 두 테이블 데이터를 합칠 migration_table을 생성하였습니다. migration_table의 스키마는 이전 대상인 커스텀 토론 테이블과 같은 스키마에 old_id(기존 PK)와 type(토론 형식)의 컬럼만을 더한 형태의 스키마로 생성하였습니다.
CREATE TABLE migration_table (
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
old_id BIGINT NOT NULL, #기존의 PK
table_type ENUM('PARLIAMENTARY', 'CUSTOMIZE') NOT NULL, #토론 형식
finish_bell TINYINT(1) NOT NULL,
warning_bell TINYINT(1) NOT NULL,
member_id BIGINT NOT NULL,
agenda VARCHAR(255),
name VARCHAR(255) NOT NULL,
pros_team_name VARCHAR(255) NOT NULL,
cons_team_name VARCHAR(255) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
used_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
2) migration table에 두 테이블 정보 옮기기
먼저 의회식 토론 데이터를 migration_table로 옮겼습니다. 다만 의회식 토론 정보에는 없는 컬럼들이 커스텀 토론 테이블에는 있는 경우가 있었습니다. 따라서 다음과 같은 부분을 의식하여 insert 문을 작성하였습니다.
1) pros_team_name에는 의회식 토론 형식의 디폴트 찬성팀 이름인 '찬성'을 삽입한다
2) cons_team_name에는 의회식 토론 형식의 디폴트 반대팀 이름인 '반대'를 삽입한다
3) old_id 에는 의회식 토론 테이블의 PK가 저장된다
4) type에는 'PARLIAMENTARY'를 넣어 이전한다.
이를 그림으로 나타내면 다음과 같은 이전 방식입니다.
이후, 커스텀 토론 테이블도 type에는 'CUSTOMIZE' , old_id에는 pk 값을 넣어 한곳으로 통합해주었습니다.
3) migration_box 만들기
이제 통합할 타임박스들을 한 곳에 모아줄 migration_box 테이블을 만들었습니다. migration_box의 경우 커스텀 토론 타임박스( customize_time_box)와 동일한 스키마를 지니도록 하였습니다.
CREATE TABLE migration_box (
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
sequence INT NOT NULL,
speaker VARCHAR(255),
time INT NOT NULL,
time_per_speaking INT,
time_per_team INT,
table_id BIGINT NOT NULL,
stance ENUM('CONS', 'NEUTRAL', 'PROS') NOT NULL,
speech_type VARCHAR(255) NOT NULL,
box_type ENUM('NORMAL', 'TIME_BASED') NOT NULL
);
4) migration_box에 각 타입별 타임박스 통합하기
이제 migration_box에 타입별 타임박스를 하나로 합치는 작업이 필요했습니다.
4-1) 의회식 토론 타임박스 (parliamentary_time_box) -> migration_box 이전
의회식 토론 타임박스 이전에서 다음과 같은 부분을 고려했습니다.
1) 의회식 토론 박스의 박스 유형은 각각 speech_type 문자열로 매핑되어야 합니다.
- OPENING → “입론”
- CROSS → “교차조사”
- REBUTTAL → “반론
- TIME_OUT → “작전시간”
- CLOSING → “최종발언”
2) box_type은 'NORMAL'로 매핑되어야 합니다.
3) time_per_speaking과 time_per_team에는 null이 삽입됩니다. (의회식 토론에는 팀별, 발언별 시간제한이 없으므로)
4) migration_table의 type이 'PARLIAMNETARY'인 행의 old_id와 매핑된 행의 PK를 migration_box의 table_id로 매핑
이를 그림으로 표현하면 다음과 같습니다.
실제 SQL 문은 다음과 같이 case when 문을 활용한 분기와 join을 통해 작성하였습니다.
INSERT INTO migration_box (
sequence,
speaker,
time,
time_per_speaking,
time_per_team,
table_id,
stance,
speech_type,
box_type
)
SELECT
ptb.sequence,
CAST(ptb.speaker AS CHAR),
ptb.time,
NULL,
NULL,
mt.id AS table_id, -- migration_table의 PK
ptb.stance,
CASE ptb.type -- type 매핑
WHEN 'OPENING' THEN '입론'
WHEN 'CROSS' THEN '교차조사'
WHEN 'REBUTTAL' THEN '반론'
WHEN 'TIME_OUT' THEN '작전시간'
WHEN 'CLOSING' THEN '최종발언'
END AS speech_type,
'NORMAL' AS box_type
FROM parliamentary_time_box ptb
JOIN migration_table mt
ON mt.old_id = ptb.table_id -- 의회식 타임박스의 table_id와 migration_table의 old_id 일치 행
AND mt.table_type = 'PARLIAMENTARY';
4-2) 커스텀 토론 타임박스 (customize_time_box) -> migration_box 이전
커스텀 토론 타임박스의 경우는 의회식 토론 타임박스 이전에 비해 수월했습니다.
migration_table에서 type이 'CUSTOMIZE'이고 old_id와 customize_time_box의 table_id가 일치하는 행의 PK를 table_id로 변환해주면 되었습니다.
INSERT INTO migration_box (
sequence,
speaker,
time,
time_per_speaking,
time_per_team,
table_id,
stance,
speech_type,
box_type
)
SELECT
ctb.sequence,
ctb.speaker,
ctb.time,
ctb.time_per_speaking,
ctb.time_per_team,
mt.id AS table_id, -- migration_table의 PK
ctb.stance,
ctb.speech_type,
ctb.box_type
FROM customize_time_box ctb
JOIN migration_table mt
ON mt.old_id = ctb.table_id
AND mt.table_type = 'CUSTOMIZE';
5) migration_table에서 type과 old_id 없애주기
이제 마이그레이션을 위해 추가했던 type 과 old_id 컬럼을 migration_table 에서 없애주었습니다.
즉, customize_table과 스키마 형태를 동일하게 맞추어주었습니다.
ALTER TABLE migration_table
DROP COLUMN old_id,
DROP COLUMN table_type;
6) 의회식 토론 테이블 데이터 삭제하기
migration table로 테이블을 변경하기 이전에 통합된 데이터와 중복되지 않기 위해 의회식 토론 테이블의 데이터를 모두 삭제해주었습니다.
DELETE FROM parliamentary_time_box;
DELETE FROM parliamentary_table;
7) rename으로 다운타임이 1초가 되지 않게 migration
이후, 테이블 명을 바꿔치기하는 rename으로 매우 짧은 시간 안에 쿼리 대상이 되는 customize_table과 customize_time_box 테이블을 바꿨습니다.
- migration_table > customize_table
- 기존의 customize_table > customize_table_legacy
- migration_box > customize_time_box
- 기존의 customize_time_box > customize_time_box_legacy
RENAME TABLE
customize_table TO customize_table_legacy,
migration_table TO customize_table,
customize_time_box TO customize_time_box_legacy,
migration_box TO customize_time_box;
8) 외래키 제약조건 다시 추가하기
이후, 외래키 제약 조건을 다시 추가하여 customize_time_box의 table_id와 customize_table id 간의 외래키 관계를 유지할 수 있도록 하였습니다.
alter table customize_table
add constraint customize_table_to_member
foreign key (member_id)
references member (id);
alter table customize_time_box
add constraint customize_time_box_to_time_based_table
foreign key (table_id)
references customize_table (id);
Result : 성공적인 마이그레이션
결국 유저가 마이그레이션 과정에서 다운타임인 시간은 7-8단계의 rename 과정 이외에는 존재하지 않았습니다.
이후 운영환경에서의 테스트 결과, 데이터가 잘 이전된 것을 볼 수 있었습니다.
복잡한 요구사항들을 하나씩 분할정복하면서 마이그레이션 단계에서 점진적인 쿼리 설계가 얼마나 중요한지 체감했습니다. 또한 그 과정에서 고려하지 못한 질문들에 대한 아쉬움도 남습니다.
-> rename으로 인해 잡히는 테이블 단위의 락은 유저 요청에 어떠한 영향을 미치는가?
-> 만약 데이터 이전 작업 중에 새로운 테이블이 insert 되었다면 누락되는 것 아닌가?
=> 해당 고민들은 운영환경에서 새벽시간 대의 작업이라는 다소 불완전한 결론으로 이어졌고, 새로운 data의 출현가능성이 없음을 전제하였음이 사실입니다. 다만 더 나은 해결책은 없었는지 조금 더 고민해보고픈 생각이 드는 시간들이었습니다.
그럼에도 불구하고 처음으로 운영환경 DB를 마이그레이션 해보면서 정확히, 데이터가 변경되고 잘 이전된 걸 확인하였을 때 느껴지는 쾌감은 있었습니다. 또한 case when, join과 같은 복잡한 쿼리들을 계획해내면서 마이그레이션 테이블을 설계하는 재미도 있었습니다.
데이터 마이그레이션에 대한 제 경험기록은 여기까지입니다.
그럼 오늘도 다들 행복하세요!
'프로젝트 > 디베이트 타이머' 카테고리의 다른 글
[디베이트 타이머] 초기 실 사용자 100명을 유치하기까지 (5) | 2025.05.14 |
---|---|
[디베이트 타이머 - 5차 스프린트] 2차 유저 테스트와 디자이너 합류 (0) | 2025.02.11 |
[디베이트 타이머 - 3차 스프린트] Backend 기초 인프라 세팅하기 (2) | 2025.01.21 |
deleteAll vs deleteAllInBatch : 성능차이 + 영속성 컨텍스트 반영여부 (0) | 2025.01.01 |
CI 과정에서 jacoco를 활용한 테스트 커버리지 확인하기 (0) | 2024.12.29 |