반응형
반응형
🌏 운영 중단 없는 대규모 스키마 전환과 데이터 마이그레이션 전략
서비스 규모가 커질수록 데이터베이스 스키마 변경과 데이터 마이그레이션은 단순한 기능 추가가 아니라 서비스 안정성과 직결되는 핵심 운영 과제가 됩니다. 특히 다중 서버 환경에서는 코드와 스키마의 버전 불일치로 인해 예상치 못한 장애가 발생할 수 있어, 사전에 철저한 설계와 단계별 검토가 필요합니다.
여기서는 실제 운영 환경에서 진행했던 대규모 스키마 전환과 데이터 마이그레이션 과정을 바탕으로, 발생 가능한 문제와 이를 최소화하기 위한 전략을 정리했습니다.
🌏 스키마 전환 시 발생할 수 있는 주요 문제
✅ 코드-스키마 불일치
- DB에만 있는 컬럼: 코드가 해당 컬럼을 전혀 참조하지 않으면 문제 없음.
- 코드에서만 존재하는 컬럼: DB에 없을 경우 애플리케이션 기동 실패 또는 쿼리 실행 시 SQL 에러 발생하기 때문에 문제가 됩니다.
- Hibernate
validate모드에서는 런타임에 엔티티 매핑에 정의돼 있지만 DB에 없는 컬럼을 비교
- Hibernate
해결 방향: 항상 DB 변경 → 코드 변경 순서로 배포하고, 초기에는 nullable·기본값·미사용 상태로 추가하여 에러가 나지 않도록 합니다.
✅ DDL 실행 시 테이블 락
- 일부 DDL은 테이블 전체 락을 유발해 트래픽 처리 지연 또는 중단 가능
- 심야 시간대 작업 또는 신규 테이블 생성 후 데이터 이전 방식으로 우회하는 방식을 택하는 편이 안전합니다.
- 가능하면
ADD COLUMN과 같은 가벼운 변경부터 점진적으로 진행하면서 계속해서 제대로 무중단 운영이 되는지 확인해야 합니다.
✅ multi-instance 환경에서의 동시 실행
- 여러 서버가 동시에 Flyway를 실행하면, 모든 서버를 동시에 배포할 수는 없기 때문에 일부 서버는 구 스키마, 일부는 신 스키마를 바라보는 불일치 상황 발생하게 됩니다.
- Flyway를 통해서 자동 DDL 실행을 포함한 배포를 하는 경우에 특정 인스턴스(리더)에서만 마이그레이션을 수행하고 나머지는 스킵하도록 환경 변수나 권한 분리하지 않으면
Table already exists또는Duplicate column name같은 DDL 오류를 만날 수 있으니 주의해야 합니다.
✅ 데이터 마이그레이션 중 실시간 요청 처리
- 구조 전환 도중에는 구·신 구조를 모두 참조하는 hybrid 모드를 유지합니다.
- 신규 구조를 우선 사용하되, 마이그레이션이 끝나지 않은 데이터는 기존 구조로 fallback하도록 하고, 추후에 배포가 진행된 시간부터 현재까지의 데이터를 수동 마이그레이션하는 방식 등을 통해서 정합성을 유지할 수 있습니다.
🌏 사례: 의도적인 비정규화 → 정규화된 스키마로
✅ 기존 구조: 의도적인 비정규화
- 의도적인 비정규화를 통해서 여러 번 복사하여 동일한 데이터를 저장해, 빠른 조회는 가능했지만 점점 저장 메모리에 부담이 가는 상황
- 변경 이력 보존은 용이했지만, 데이터 중복으로 저장 공간 급증함
- 요청 1건당 중복 저장 용량 약 1.3KB
- EBS 10GB 중 3GB를 쿠폰 데이터에 할당하면, 하루 1.5만 건 요청 시 약 5개월 내 저장 한계 도달
- OS·로그·기타 DB 메타데이터 영역 고려 시 실제 사용 가능 공간이 더 적을 수 있으니 주의하시기 바랍니다
- 서비스의 상황을 보았을 때, 요청량이 늘어나 데이터 증가 폭이 클 것으로 예상되어, 사전 구조 변경 결정
✅ 정규화 후 구조
- 정규화를 통한 참조 기반 설계
- 중복 제거 및 관리 효율성 향상
🌏 단계별 마이그레이션 전략 (backward-compatible schema)
backward-compatible
"새 버전으로 바뀌더라도, 옛날 버전이 여전히 문제없이 동작할 수 있는 상태를 유지하는 것"
- 스키마 확장
- foreign key 참조 컬럼(
fk_1,fk_2) 추가 - 기존 컬럼 유지, nullable 허용
- foreign key 참조 컬럼(
- 기존 데이터 매핑
- 동일 필드 비교 기반으로 ID 매핑
- 로직 이중 처리
- 서비스 레이어에서 구·신 구조 동시 반영
- 기존 구조와 신규 구조를 동시에 다뤄야 했기 때문에, service 단에서 두 구조를 모두 고려하는 공통 로직을 만들었습니다.
- 예를 들어 생성 요청이 들어오면, 기존/신규 두 구조에 모두 저장하고, 조회 시에는 신규 구조가 우선되되, migration되지 않은 데이터는 fallback으로 old 구조를 참조하도록 했습니다.
- 데이터 마이그레이션
- 작은 단위로 나누어 점진적으로 마이그레이션을 수행합니다.
- DML의 경우 Flyway 스크립트 내에서
BEGIN~COMMIT트랜잭션 블록을 활용해, 각 마이그레이션 단계가 전부 완료되지 않으면 롤백되도록 구성했습니다. 또한 수작업 DML 쿼리는 한 번에 처리하는 건수를 제한하기 위해LIMIT 1000등으로 batch 처리하여 DB 부하를 최소화했습니다.
- 외래 키 제약 추가 (선택)
- 모든 데이터 마이그레이션 후 참조 무결성 보장할 수 있게 fk 제약조건 추가
- 구조 정리
- 기존 테이블 삭제, 불필요한 컬럼 제거
🌏 직접 해보자

💋 목표
- 무중단 배포 + DB 스키마 변경
- 연습용으로 개발 서버에서만 진행
- 이후 운영 서버에 머지할 때는 중단 발생하는 점 참고!
💋 기대하는 완성 후 모습
- Coupon이 cafe_coupon_design_id, cafe_policy_id 참조
- coupon_design, coupon_policy, coupon_stamp_coordinate 테이블 삭제
- cafe_coupon_design, cafe_policy 테이블에 soft delete 대신 isActivate 필드 추가해서 쿠폰 발급할 때 이 필드 사용하도록 바꾸기
- 마지막에 테이블 이름 변경
- cafe_coupon_design → coupon_design
- cafe_policy → coupon_policy
💋 단계별 실행
1. cafe_coupon_design, cafe_policy 테이블에 is_activate 필드 추가
- 배포 1: 필드 isActivate, flyway로 칼럼 is_activate 추가 → PR
- 배포 2: is_activate에 기존 deleted 값으로 채우기 → PR (한 개는 실수로 PR 없이 머지해버림)
2. deleted를 사용하던 코드 모두 isActive 필드를 사용하도록 변경
- PR
- fix PR (테스트 에러 발생으로 빌드 실패)
mysql> describe cafe_policy;
+-----------------+--------------+------+-----+---------+----------------+
| Field | Type | Null | Key | Default | Extra |
+-----------------+--------------+------+-----+---------+----------------+
| deleted | bit(1) | YES | | NULL | |
| expire_period | int | YES | | NULL | |
| max_stamp_count | int | YES | | NULL | |
| cafe_id | bigint | YES | MUL | NULL | |
| created_at | datetime(6) | YES | | NULL | |
| id | bigint | NO | PRI | NULL | auto_increment |
| updated_at | datetime(6) | YES | | NULL | |
| reward | varchar(255) | YES | | NULL | |
| is_activate | bit(1) | YES | | NULL | |
+-----------------+--------------+------+-----+---------+----------------+
9 rows in set (0.01 sec)
mysql> describe cafe_coupon_design;
+-----------------+--------------+------+-----+---------+----------------+
| Field | Type | Null | Key | Default | Extra |
+-----------------+--------------+------+-----+---------+----------------+
| deleted | bit(1) | YES | | NULL | |
| cafe_id | bigint | YES | MUL | NULL | |
| created_at | datetime(6) | YES | | NULL | |
| id | bigint | NO | PRI | NULL | auto_increment |
| updated_at | datetime(6) | YES | | NULL | |
| back_image_url | varchar(255) | YES | | NULL | |
| front_image_url | varchar(255) | YES | | NULL | |
| stamp_image_url | varchar(255) | YES | | NULL | |
| is_activate | bit(1) | YES | | NULL | |
+-----------------+--------------+------+-----+---------+----------------+
9 rows in set (0.00 sec)
3. Coupon 테이블에 cafe_coupon_design_id, cafe_policy_id 필드 추가
- 외래키 제약조건은 자바 코드가 완성된 후에야 설정할 수 있으므로, 아직 설정하지 않음. → PR
4. cafe_coupon_design_id, cafe_policy_id 값을 직접 찾아서 추가
- 기존 coupon_design, coupon_policy 테이블에 cafe_coupon_design_id, cafe_policy_id 값을 포함하고 있지 않아서, 나머지 필드가 모두 동일하면 같은 레코드라고 간주하는 듯한 복잡한 DML문으로 비어있는 cafe_coupon_design_id, cafe_policy_id 값을 채워줌 → PR
mysql> select * from coupon_policy where id=5277;
+----------------+-----------------+----------------------------+------+----------------------------+----------------------+------------------+
| expired_period | max_stamp_count | created_at | id | updated_at | reward_name | deleted |
+----------------+-----------------+----------------------------+------+----------------------------+----------------------+------------------+
| 6 | 10 | 2023-10-17 14:04:39.546933 | 5277 | 2023-10-17 14:04:39.546933 | 아메리카노 1잔 | 0x01 |
+----------------+-----------------+----------------------------+------+----------------------------+----------------------+------------------+
1 row in set (0.01 sec)
mysql> select * from cafe_policy where id=45;
+------------------+---------------+-----------------+---------+----------------------------+----+----------------------------+----------------------+--------------------------+
| deleted | expire_period | max_stamp_count | cafe_id | created_at | id | updated_at | reward | is_activate |
+------------------+---------------+-----------------+---------+----------------------------+----+----------------------------+----------------------+--------------------------+
| 0x01 | 6 | 10 | 38 | 2023-10-17 13:51:03.992427 | 45 | 2023-10-17 13:51:03.992427 | 아메리카노 1잔 | 0x00 |
+------------------+---------------+-----------------+---------+----------------------------+----+----------------------------+----------------------+--------------------------+
1 row in set (0.00 sec)
mysql> select * from coupon_policy where id=5303;
+----------------+-----------------+----------------------------+------+----------------------------+----------------------+------------------+
| expired_period | max_stamp_count | created_at | id | updated_at | reward_name | deleted |
+----------------+-----------------+----------------------------+------+----------------------------+----------------------+------------------+
| 6 | 10 | 2023-11-13 15:52:52.435474 | 5303 | 2023-11-13 15:52:52.435474 | 아메리카노 1잔 | 0x00 |
+----------------+-----------------+----------------------------+------+----------------------------+----------------------+------------------+
1 row in set (0.00 sec)
mysql> select * from cafe_policy where id=20;
+------------------+---------------+-----------------+---------+----------------------------+----+----------------------------+----------------------+--------------------------+
| deleted | expire_period | max_stamp_count | cafe_id | created_at | id | updated_at | reward | is_activate |
+------------------+---------------+-----------------+---------+----------------------------+----+----------------------------+----------------------+--------------------------+
| 0x00 | 6 | 10 | 13 | 2023-10-05 17:20:35.930340 | 20 | 2023-10-05 17:20:35.930340 | 아메리카노 1잔 | 0x01 |
+------------------+---------------+-----------------+---------+----------------------------+----+----------------------------+----------------------+--------------------------+
1 row in set (0.01 sec)
5. 모든 로직이 CouponDesign, CouponPolicy 가 아니라, CafeCouponDesign, CafePolicy 를 참조하도록 변경
- 테스트가 모두 깨지기 때문에 굉장히 힘들었음.
- 특히나 Coupon에서 사용중이던 생성자를 모두 변경해야만 했는데, 과거의 의존성을 가지고있는 생성자를 221곳에서 사용중이어서 정말 오래 걸리는 작업이었음.
- Coupon 테이블에 cafe_coupon_design_id, cafe_policy_id 외래키 제약조건 추가 → PR
6. Coupon에서 CouponDesign, CouponPolicy 의존성 제거
- Coupon 엔티티에서 couponDesign, couponPolicy 엔티티 의존성 제거 → PR
- 쿠폰을 발급할 때, CafeCouponDesign, CafePolicy 필드를 추가하도록 변경
7. coupon에 cafe_coupon_design_id, cafe_policy_id에 외래키 제약조건 추가
-- cafe_coupon_design_id에 대한 외래 키 제약 조건 추가
ALTER TABLE coupon
ADD CONSTRAINT fk_coupon_cafe_coupon_design
FOREIGN KEY (cafe_coupon_design_id) REFERENCES cafe_coupon_design(id);
-- cafe_policy_id에 대한 외래 키 제약 조건 추가
ALTER TABLE coupon
ADD CONSTRAINT fk_coupon_cafe_policy
FOREIGN KEY (cafe_policy_id) REFERENCES cafe_policy(id);
7. Coupon 테이블과 CouponDesign, CouponPolicy 테이블의 의존성 제거
- Coupon 테이블에서 coupon_design_id, coupon_policy_id 제거
8. CouponDesign, CouponPolicy 테이블 제거
🌏 추가 고려 사항
✅ 롤백 전략
- Flyway는 기본적으로 롤백 미지원
- 변경 불가역성을 고려해 코드 롤백 중심으로 대응
- 신규 필드 optional 처리, 구버전 코드 호환 유지
✅ 성능 저하 방지
- 정규화 후 join이 잦아질 수 있으므로, 자주 참조되는 FK 컬럼에 인덱스 사전 설정
- 외래 키 기반으로 자주 join되는 컬럼에 대해서는 인덱스를 명확히 걸어두고, 가능한 경우 covering index도 함께 고려해서 불필요한 row access를 줄일 수 있습니다.
- batch insert, 캐싱을 통한 I/O 최소화
🌏 결론
운영 중단 없는 대규모 스키마 전환을 위해서는 순서, 호환성, 점진성 세 가지 원칙을 지켜야 합니다.
- DB 먼저, 코드 나중
- backward-compatible 구조 유지
- 데이터 마이그레이션은 배치·점진적 수행
이러한 접근을 통해 다중 서버 환경에서도 장애 없이 안정적으로 구조를 전환할 수 있으며, 장기적인 데이터 관리 효율성과 서비스 확장성까지 확보할 수 있습니다.

도움이 되었다면, 공감/댓글을 달아주면 깃짱에게 큰 힘이 됩니다!🌟
비밀댓글과 메일을 통해 오는 개인적인 질문은 받지 않고 있습니다. 꼭 공개댓글로 남겨주세요!
반응형
'PROJECT > Stamp Crush' 카테고리의 다른 글
| [우테코] E2E 테스트에서 데이터 격리 (템플릿 공유합니다!!!) (0) | 2025.10.29 |
|---|---|
| [우테코] 스탬프크러쉬를 마무리하며 (2) | 2023.12.04 |
| [우테코] JWT 방식에서 로그아웃, Refresh Token 만들기(2): 구현을 해보자! (0) | 2023.11.08 |
| [우테코] 무중단 배포 자동화(2): 배포서버에 Github Actions self-hosted runners, Nginx, Docker 설정 (2) | 2023.10.28 |
| [우테코] 무중단 배포 자동화(1): Github Actions workflow 생성, Secrets 설정 (2) | 2023.10.27 |