본문 바로가기
아티클

PostgreSQL integer out of range 장애 대응기

by imsoncod 2022. 7. 17.

개요

평화로운 날, 서비스에서 장애가 발생했다는 슬랙 제보를 받았다. 간단한 CS건이려니..하고 메시지를 읽었는데, 서비스 이용 자체가 불가능할 정도의 심각한 상황이었다(메인 기능인 채팅이 되지 않음...)

오류 로그를 확인해보려는 찰나에, 웹 클라이언트 개발자분께서 서버로부터 내려오는 에러 메시지를 공유해주셨다(훌륭) 무엇인가 하고 보니...

integer out of range

처음 보는 에러였지만 간단명료해서 쉽게 의미를 파악할 수 있었다, integer의 범위를 벗어났습니다..!

3초간 뭐지? 하다가 과거 뉴스로 보았던 서비스 장애 사례가 떠올랐다. ㅋㅍ이었는지 11ㅂㄱ였는지는 자세히 기억나지 않지만, 한 이커머스 서비스에서 데이터베이스 상품 테이블에 등록되어 있는 상품의 수가 INTEGER타입의 최댓값인 2,147,483,647를 넘어서는 바람에 더 이상 상품을 등록할 수 없어 몇시간 동안 서비스 장애가 발생했다는 내용이었다. 정확히는, 상품 테이블의 INTEGERPK값이 최댓값을 넘어선 것이다.

위 사례를 떠올리고, 바로 DB(PostgreSQL)에서 채팅 데이터를 저장하고 있는 테이블을 확인했다. 역시나, 채팅 데이터의 INTEGERPK값이 2,147,483,647까지 가득 차있었다 ㅠㅠ

원인을 찾았으니, 바로 해결대책을 고민하기 시작했다. 그리고 생각난 여러 해결책들...

1차 대응

대안1. 컬럼의 타입을 BIGINT로 변경한다.

  • BIGINT타입은 INTEGER타입보다 가용범위가 무한할 정도로 크다. 물론, Storaze Size도 2배 더 크지만.. DB Storage Size가 꽤 넉넉하니 괜찮을 것이다.
  • 하지만, 약 20억개의 대용량 데이터가 들어있는 테이블을 대상으로 타입을 변경하면 오랜시간 Lock이 걸리고, 그 동안 다른 트랜잭션이 들어오면 데드락이 발생할 수 있기 때문에 DB로 들어오는 트랜잭션을 차단해야 한다. 트랜잭션의 차단은.. 결국 서비스의 중단/점검을 의미한다. 또한, 데이터의 수가 매우 많아서 타입 변경에 얼마나 오랜 시간이 걸릴지 예측하기 어렵다.

대안2. 컬럼의 타입을 UNSIGNED INTEGER로 변경한다.

  • PK에 음수값이 저장되어 있지 않으니, 임시로나마 2배의 데이터를 저장할 수 있도록 UNSIGNED를 적용한다. Storaze Size도 동일, 하지만 이 방법 역시 타입을 변경하는 작업이니 대안1과 동일한 이슈가 있을 것이다.
  • 그런데 찾아보니, PostgreSQL은 UNSIGNED를 지원하지 않는 듯..?

대안3. 컬럼의 타입을 BIGINT로 변경한 새로운 테이블을 생성한다.

  • 새로운 테이블을 만들고 그 곳에 데이터가 쌓이도록 일부 코드를 수정해주면, 임시로나마 오류를 없애고 빠르게 서비스를 재개할 수 있을 것이다.
  • 하지만 본 방법으로 진행할 경우, 테이블이 변경되어 사용자들이 기존 테이블에 존재하는 채팅 데이터(내역)를 확인할 수 없기 때문에, 데이터를 빠르게 마이그레이션 해주어야 한다.

서비스의 정상화를 최우선으로 고려하여, 대안3으로 결정했다.

기존 테이블(A)와 동일한 스키마의 테이블(B)을 생성하되, PK의 타입은 BIGINT로 지정하도록 한다. 그리고, 코드상에서 기존 테이블(A)을 바라보고 있던 부분을 일괄 수정해주었다.

다행히, 약 1시간 안에 서비스를 정상화시킬 수 있었다.
하지만 사용자들이 과거 채팅 내역을 빠르게 확인할 수 있도록.. 데이터 마이그레이션 작업을 진행해야한다.

2차 대응

기존 테이블을 A, 새로운 테이블을 B라고 칭함.

[1차 대응]이후 B테이블에 임시로 데이터가 쌓이고 있고 서버 코드 또한 B테이블을 참조하고 있어서, 장애 발생 시점 이전의 채팅기록(A테이블에 존재하는 데이터)을 사용자들이 확인하지 못하는 상황이다.

따라서, A -> B 테이블로의 데이터 마이그레이션 작업이 필요하다.

데이터가 약 20억건이어서, 작업중 어떤 이슈가 있을지 알기 어렵기 때문에 운영 DB를 복제한 테스트용 DB를 새로 띄워서 아래 2가지 대안을 테스트하였다.

대안1. INSERT INTO SELECT FROM 쿼리문 사용

"INSERT INTO [B Table] SELECT FROM [A Table]" 쿼리문을 사용하여 데이터를 통째로 옮기는 테스트를 진행해본다.

1. 쿼리 수행 시간 단축을 위한 사전 작업을 진행한다.

-- Transction Logging OFF 
ALTER TABLE A_TABLE SET UNLOGGED; 
ALTER TABLE B_TABLE SET UNLOGGED;  

-- DROP INDEX 
DROP INDEX A_TABLE_ID_INDEX;
DROP INDEX B_TABLE_ID_INDEX;  

-- DROP FK 
ALTER TABLE A DROP CONSTRAINT A_TABLE_FK; 
ALTER TABLE B DROP CONSTRAINT B_TABLE_FK; 

Transaction Logging 기능을 OFF 하는 과정에서 약 50분의 시간이 소요되었다. INDEX, FK 제거는 빠르게 진행되었다.

2. 마이그레이션 쿼리문 실행

INSERT INTO B_TABLE SELECT FROM A_TABLE; 

4시간 30분이 소요되었다.

대안2. AWS S3 Bulk Upload

현재 DB는 AWS RDS인스턴스로 서비스 되고 있다. 이에, 테이블 데이터를 S3로 Export하고 다시 S3 데이터를 Import 할 수 있는 Bulk Upload 기능을 테스트 해보도록 한다.

psql로 DB에 접속한 뒤, 아래 쿼리문을 날려주도록 한다.

SELECT * FROM aws_s3.query_export_to_s3 (     
  'SELECT * FROM A_TABLE', '[S3_BUCKET_NAME]', 'A_TABLE.csv'
); 

약 4시간 뒤.. S3 버킷에 들어가보면, 테이블 데이터가 여러 csv파일로 분리되어 저장되어 있는 것을 확인할 수 있다(약 700GB..!)

아쉽게도 위의 두 대안 모두 매우 무거운 작업으로, 오래동안 Lock이 걸려 중간에 다른 트랜잭션이 들어올 경우 데드락이 발생할 우려가 있다.

따라서, 작업을 진행하기 위해서는 서비스 점검/중단이 불가피하다. 또 그렇게 작업이 마무리 되었다고 해도 20억건이 넘는 데이터에 대한 검증도 금방 끝나는 일이 아니기 때문에(count도 불가능) 점검 시간이 굉장히 길어질 수 있다.

3차 대응

최종 대안(간단 주의)

기존 테이블의 데이터를 마이그레이션 하지 않고 그대로 유지하면서, 아래처럼 서버 코드의 로직을 수정해준다.

B 테이블로부터 채팅 데이터를 조회하다가 더 이상 받아올 데이터가 없으면 A 테이블에서 조회합니다

최초에 충분히 이렇게 대응할 수 있지 않았을까.. 그럼 사용자들이 바로 과거 채팅 내역을 확인할 수 있었을텐데.. 너무 데이터를 한 곳으로 깔끔하게 마이그레이션 해야 한다는 점에 초점을 둔 것 같다.

퍼포먼스 튜닝

이번 장애 대응을 하면서, 한 테이블에 너무 많은 데이터가 있으면 작은 작업도 하기 어렵다는 것을 깨달았다. 앞으로 새로운 테이블에도 데이터는 어마어마하게 쌓일테니.. 이번 기회에 튜닝을 해보도록 하자.

파티셔닝

하나의 테이블에 존재하는 데이터들을 특정 컬럼의 값을 기준으로 여러 파티션으로 나눌 수 있다. 이것을 파티셔닝이라고 한다. 사실 파티션이 아니라 하나의 테이블이라고 생각해도 무방하다.

파티셔닝을 하면 수십억개의 데이터를 여러 테이블로 분산시킬수 있기 때문에, 관리/백업/삭제 등에 용이하다.

이제 채팅 데이터를 createdAt (생성일) 컬럼 기준으로, 별로 파티셔닝 할 것 이다. 년도별로 하면 더 깔끔하겠지만, 1개월치 데이터만으로도 사이즈가 어마어마하기 때문에 하지 않았다.

과정

1. 파티셔닝용 테이블 생성

기존에 존재하는 테이블에는 파티셔닝 기능을 적용할 수 없습니다. 따라서 기존 테이블과 동일한 스키마를 가진 파티셔닝용 테이블을 새로 생성해주어야 합니다.

CREATE TABLE B_TABLE_FOR_PARTITION (   
  [Schema],   
  CONSTRAINT B_TABLE_PK PRIMARY KEY (id, created_at) 
) PARTITION BY RANGE(created_at); 

이때, 파티셔닝의 기준이 될 컬럼을 기본키에 포함시켜야만 합니다.

2. 파티션 생성

위에서 생성한 파티셔닝용 테이블에 월별로 파티션을 생성해줍니다.

CREATE TABLE B_TABLE_PARTITION_2022_01 
PARTITION OF B_TABLE_FOR_PARTITION FOR VALUES FROM('2022-01-01') TO ('2022-02-01');

CREATE TABLE B_TABLE_PARTITION_2022_02 
PARTITION OF B_TABLE_FOR_PARTITION FOR VALUES FROM('2022-02-01') TO ('2022-03-01');  

CREATE TABLE B_TABLE_PARTITION_2022_03 
PARTITION OF B_TABLE_FOR_PARTITION FOR VALUES FROM('2022-03-01') TO ('2022-04-01'); 

...

생성된 파티션들은 파티션용 테이블(부모)의 제약조건을 그대로 물려 받습니다(이름만 바뀜)

3. 데이터 이동

기존 B테이블에 존재하던 데이터를 새로 만든 파티셔닝용 테이블로 옮겨야합니다. B테이블은 생성된지 얼마 되지 않아 데이터가 많지 않지만, 그래도 1,000만개가 넘기 때문에 서비스에 영향을 주지 않도록 주의해야 합니다.

따라서, 10,000개씩 트랜잭션을 나눠서 INSERT 하도록 하겠습니다.

BEGIN   
INSERT INTO B_TABLE_FOR_PARTITION   
SELECT FROM B_TABLE WHERE ID BETWEEN (1, 10000) 
COMMIT;

BEGIN   
INSERT INTO B_TABLE_FOR_PARTITION   
SELECT FROM B_TABLE   WHERE ID BETWEEN (10001, 20000)
COMMIT;  

BEGIN   
INSERT INTO B_TABLE_FOR_PARTITION   
SELECT FROM B_TABLE   WHERE ID BETWEEN (20001, 30000) 
COMMIT; 

... 

위처럼 일일이 쿼리문을 작성하기 귀찮다면, 프로시져나 함수를 사용할수도 있습니다.

4. 데이터 검증

옮겨지지 않은 누락된 데이터가 있는지 검증합니다.

SELECT * FROM B_TABLE "old" WHERE NOT EXISTS  (
  SELECT 1 FROM B_TABLE_FOR_PARTITION "new" WHERE old.id = new.id
); 

0이 나오면 PASS!!

5. 코드 수정

데이터 이동이 끝났으니, 이제 파티셔닝용 테이블을 바라보도록 서버 코드를 수정해줍니다.

6. 기존 테이블 제거

DROP TABLE B_TABLE; 

7. 최종

최종적으로 아래 두 테이블만 남습니다.

  • A_TABLE: 기존 채팅 데이터를 저장하고 있는 테이블
  • B_TABLE_FOR_PARTITION: 새로운 채팅 데이터가 저장되는 테이블
    • B_TABLE_PARTITION_2022_01: 2022년 1월에 저장된 채팅 데이터
    • B_TABLE_PARTITION_2022_02: 2022년 2월에 저장된 채팅 데이터
    • B_TABLE_PARTITION_2022_03: 2022년 3월에 저장된 채팅 데이터

8. 마무리

2022년 1~3월의 파티션만을 만들어두었기 때문에, 4월이 되었을 경우 조건에 맞는 파티션이 존재하지 않아 INSERT시 에러가 발생합니다. 따라서, 장기간까지 미리 파티션을 생성해두거나 주기적으로 파티션을 생성해주어야 합니다.

외에도 혹시 깜빡하고 파티션을 생성하지 않을 경우를 대비해, 아래처럼 MAX RANGE를 포함한 파티션을 생성해두는 것도 좋은 방법입니다.

CREATE TABLE B_TABLE_PARTITION_MAX_RANGE PARTITION OF 
B_TABLE_FOR_PARTITION FOR VALUES FROM('2022-04-01') TO ('MAX_DATE'); 

마치며

여러 방법을 시도하며.. 정말 많은 것을 배울 수 있었던 장애 대응 과정이었다. DB에 관해 더 깊은 지식을 갖고 있었다면 리서치 시간도 줄이고 더 나은 의사결정을 할 수 있었을 것 같아서 약간의 아쉬움이 남는다. 이후 파티셔닝된 테이블을 대상으로 EXPLAIN 쿼리를 날려 어떤 식으로 쿼리가 날아가는지 확인해볼 예정이다. 다음엔 또 어떤 장애가 발생할까...

반응형

댓글