최근 운영 중이던 서버에서 데드락으로 인해 결제가 되지 않는 이슈가 발생하였다.
트랜잭션, 데드락 등의 개념은 항상 이해했다고 생각하면서도 문제 발생 시 바로 원인과 해결법이 떠오르지 않는 개념이라 생각한다. 이번 이슈에 있었던 내용과 함께 새로이 알게 된 내용을 정리해 보려고 한다.

 

데드락은 서로 다른 트랜잭션이 있을때 각 트랜잭션에서 점유된 내용을 대기할 때 교착상태에 이르는 경우라고 할 수 있다.
임의의 테이블 ORDER 테이블이 있다고 가정하자 이때 PK 는 ORDER_ID라고 하겠다
1번 트랜잭션과 2번 트랜잭션이 아래와 같은 상황일 때 데드락은 발생한다.

-- 1번 트랜잭션: ORDER_ID = 1 인 ROW에 대한 쓰기락
UPDATE ORDER
   SET ORDER_STATE = '신규값'
 WHERE ORDER_ID = 1;

                    -- 2번 트랜잭션: ORDER_ID = 2 인 row 에 대한 쓰기락
                    UPDATE ORDER
                       SET ORDER_STATE = '신규값'
                     WHERE ORDER_ID = 2;

-- 1번 트랜잭션: ORDER_ID = 2인 ROW에 대해 접근하나 2번 트랜잭션의 락으로 인한 대기
UPDATE ORDER
   SET ORDER_STATE = '신규값'
 WHERE ORDER_ID = 2;

                    -- 2번 트랜잭션: ORDER_ID = 1인 ROW에 대해 접근하나 
                    --              1번 트랜잭션의 락으로 인한 대기
                    UPDATE ORDER
                       SET ORDER_STATE = '신규값'
                     WHERE ORDER_ID = 1;

 

위의 예시처럼 트랜잭션에서 데이터에 수정이 이루어지면 row 에 lock 이 걸리기 때문에 다른 트랜잭션은 대기하게 되고 이러한 상황이 맞물리며 트랜잭션간 교착상태에 걸릴 경우 락이 걸리게 되는것이다.

이 ORDER 테이블에 ORDER_NO라는 유니크한 값이 있다고 할 때 해당 컬럼에 유니크 키 설정이 안되어있고, 아래와 같은 상황이 발생한 것이 운영중이던 서버에서 발생했던 락 상황이다.

-- 1번 트랜잭션: ORDER_ID = 1 인 ROW에 대한 쓰기락
UPDATE ORDER
   SET ORDER_STATE = '신규값'
 WHERE ORDER_ID = 1;

                    -- 2번 트랜잭션: ORDER_ID = 2 인 row 에 대한 쓰기락
                    UPDATE ORDER
                       SET ORDER_STATE = '신규값'
                     WHERE ORDER_ID = 2;

-- 1번 트랜잭션: ORDER_NO = 1 은 ORDER_ID = 1 인 row 와 같은 하나의 row
UPDATE ORDER
   SET ORDER_STATE = '신규값'
 WHERE ORDER_NO = 1;

                    -- 2번 트랜잭션: ORDER_NO = 2는 
                    --             ORDER_ID = 2인 row와 같은 하나의 row
                    UPDATE ORDER
                       SET ORDER_STATE = '신규값'
                     WHERE ORDER_NO = 2;

 

얼핏 보면 문제 없을 것 같은 상황이다. 1번 트랜잭션은 1번 row 에만, 2번 트랜잭션은 2번 row 에만 접근하고 있기에 서로의 트랜잭션에 영향을 주지 않을 것 같이 예상된다. 당시 운영 중이던 서버에서 발생한 상황도 위와 같았음에도 불구하고 트랜잭션 락에 걸리게 된다. 락에 걸리게 된 이유는 제목에 있는 내용처럼 유니크 키를 설정할 경우에 해결되는 상황이었다.

 

락에 걸린 상황에 대해 설명해보겠다.

각 업데이트 문을 1 ~ 4번 업데이트라고 순서대로 칭하겠다.

1 ~ 2번 업데이트는 평범한 락 획득 과정이다. 3번 업데이트가 발생할 때 무슨 일이 발생할까?

우선 ORDER 테이블의 ORDER_NO = 1 인 값을 테이블에서 찾아야 할 것이다. ORDER_NO는 유니크 키가 아니므로 해당 row 를 찾기 위해 인덱스 스캔이 아닌 풀스캔이 발생할 것이다. ORDER_NO = 1 인 값을 찾기 위해 테이블 전체를 스캔하게 될 것이고 2번 트랜잭션에서 ORDER_ID = 1 인 row에 락을 걸어두었기 때문에 1번 트랜잭션은 대기에 빠지게 된다. 같은 경우로 4번 업데이트 문도 대기에 빠지게 되며 두 트랜잭션은 데드락에 걸리게 되는 것이다.

 

하지만 유니크 키를 걸게 되면 어떻게 될까?
3번 업데이트문과 4번 업데이트문은 풀스캔이 아닌 인덱스 스캔을 하게 될 것이고 해당 row 에 락을 한 것은 동일 트랜잭션이므로 스캔하는 것에 문제가 없게 되고, 두 트랜잭션은 독립적으로 처리되어 데드락 문제가 해결되게 된다.

업데이트 문을 간단히 써두었지만 실제 운영하던 서버 코드에서는 해당 원인을 찾는게 쉽지 않았다.
락이 걸린 순간의 업데이트 문과 연관된 테이블을 체크한 이후 해당 테이블에 관한 트랜잭션의 로직들을 확인하면서 의구심이 가는 부분을 확인했고, 그것이 맞았다.

 

해당 내용에 대해 실제 체크하기 위하여 임시 테이블을 만들고 사용하는 DB 툴인 DBeaver 에서 두개의 세션 스크립트를 열어두고 오토 커밋이 아닌 메뉴얼 커밋을 이용하여 테스트를 진행하였다. 그 결과 위와 같은 원인을 찾을 수 있었다.

 

인덱스, 제약 조건 등은 데이터의 신뢰성 및 DB의 성능을 위해서는 잘 처리해두어야 하지만 가끔 초기 설계 시 누락되거나, 프로젝트 진행 중에 추가된 컬럼들로 인해 올바르게 정리가 되어있지 않을 때가 존재한다. 하지만 이처럼 올바르게 정의되어지지 않은 테이블을 이와 같은 예상치 못한 문제가 발생할 가능성이 있다는 것을 다시 한 번 생각하게 되었다.

유니크 키를 설정한 해당 서버는 현재까지 데드락 이슈가 발생하지 않고 있다.

관리자 페이지 대시보드 관련해서 스칼라 쿼리를 수정해서 성능향상했던 경험을 이야기 해보고자 한다

처음 SQL 문을 배울때는 그냥 모든게 서브쿼리라고 생각했지만 서브쿼리의 위치에 따라 그 종류는 3가지로 나눌 수 있다. 

 

1. 스칼라쿼리: Select 절에서 사용

2. 인라인뷰: From 절에서 사용

3. 단일 행, 다중 행, 다중 열 서브쿼리: 기타 조건절에서 사용

 

그 중 오늘 이야기할 경험은 스칼라 쿼리에 대한 것이다.

스칼라 쿼리는 아래와 같이 Select 절에서 사용되는 서브쿼리이다

SELECT (SELECT COUNT(*) FROM TABLE A WHERE A.MONTH = M.MONTH AND A.COLUMN = 'SOMETHING1')  <--
     , (SELECT COUNT(*) FROM TABLE A WHERE A.MONTH = M.MONTH AND A.COLUMN = 'SOMETHING2')  <-- 스칼라쿼리
     , (SELECT COUNT(*) FROM TABLE A WHERE A.MONTH = M.MONTH AND A.COLUMN = 'SOMETHING3')  <--
  FROM MONTH_DATA M;

 

나의 경험에 대한 이야기를 시작해보자면 그날은 운영서버에 배포가 있던 날이었다.

배포가 끝난 후 운영서버 이곳저곳을 확인하던 중 대시보드의 초기 로딩속도가 이상할정도로 느린것이 보였다.

해당 페이지 진입시 호출되는 API 쿼리를 확인한 결과 위의 쿼리와 비슷한 형태였다.

(DB 는 참고로 MYSQL을 사용중이었다.)

월별 특정 데이터에 대한 COUNT 내용을 각 스칼라 쿼리에서 뽑아 내는 상황이었다. 해당 쿼리를 따로 DB 툴을 사용해서 실행해본 결과 3.XXs의 실행 속도를 보이고 있었다. 최초 작성하였던 팀원은 데이터가 충분하지 않던 개발서버 기준 데이터로 확인하다 보니 쿼리의 속도에 대한 문제를 확인 못한것 같았다. 운영서버의 경우 테이블 A에 양이 꽤나 쌓였고 이부분에서 속도가 느려진것 같다는 생각이 들었다. 

 

여러개의 스칼라 쿼리가 이어져 있었기에 관련한 속도 최적화에 대한 내용들을 찾아보았다. 그 내용들을 대략 정리하자면 아래와 같다.

 

1. 스칼라 쿼리는 캐싱효과가 있어 동일한 값을 반환하는 상황에서는 성능 향상의 가능성이 있다.

2. 스칼라 서브쿼리가 조회하는 쿼리의 조건이 변경되어 캐싱되지 않으면 성능이 떨어질 가능성이 있다.

3. 스칼라 서브쿼리의 조회 데이터가 많다면 성능이 떨어질 수 있다.

 

당시 쿼리를 대략적으로 표현했지만 최종 그결과는 최근 7개월간의 결과를 보여주는 데이터 조회쿼리였기에 총 7 row만 조회되고 있었기에 캐싱 기능의 필요성은 낮았다. 또한 조회되는 row 마다 매번 다른값(월별)을 조회해야했기에 캐싱이 되지도 않았을 것이며, TABLE A 자체의 데이터가 쌓여있다보니 스칼라 서브쿼리 조회 자체에 시간이 많이 소요되었을 것이다. 따라서 스칼라 서브쿼리의 성능 장점은 살릴수 없었고 단점만 부각되는 상황이었다.

 

해당 쿼리를 최적화한 형태는 대략 아래와 같다. 

SELECT COUNT(CASE WHEN A.COLUMN = "SOMETHING1" THEN 1 END)
     , COUNT(CASE WHEN A.COLUMN = "SOMETHING2" THEN 1 END)
     , COUNT(CASE WHEN A.COLUMN = "SOMETHING3" THEN 1 END)
  FROM MONTH_DATA M
  JOIN M.MONTH = A.MONTH
 GROUP BY M.MONTH

 

 우선 TABLE A에 대한 부분을 스칼라 서브쿼리를 조인 서브쿼리로 변경하여 TABLE A 에 전체 테이블 조회의 횟수를 줄였다. 변경 전 쿼리의 경우 TABLE A 에 대한 전체조회를 각 스칼라쿼리가 실행될 때마다 했을 것이다. 그러나 조인을 통하여 그 횟수를 줄였다. 그리고 COUNT 함수의 경우 내부의 값이 NULL 이 아닌 ROW 의 개수를 COUNT 하기때문에 위와 같이 작성할 경우 조건에 해당하지 않은경우 NULL 이 되어 COUNT 되지 않는다는 사실을 이용하여 월별 데이터를 조회하였다.

 

위와 같이 쿼리를 변경한 이후 해당 데이터의 조회속도 DB툴 기준으로 0.03s~0.04s 로 향상되었다. 초기 쿼리가 3초대였던 것을 생각하면 거의 100배에 가까운 속도를 향상시켰다!

해당 경험을 통하여 스칼라 쿼리에 대한 정보들을 찾아보며 쿼리의 실행에 대한 많은 고찰을 할 수 있었다.

 

만일 스칼라쿼리를 쓰고있으며 전체 ROW수가 적다면 나와 같은  스칼라쿼리를 쓰지 않고 JOIN 문을 이용하여 속도향상을 시도해보길 추천한다. 반면에 전체실행쿼리의 ROW 수가 굉장히 많고 스칼라 쿼리 각각의 값이 고정되는 형태라면 전체쿼리를 실행하는동안 스칼라쿼리의 캐싱기능에 도움을 많이 받을 수 있을 것 생각된다. 그런 경우엔 스칼라쿼리를 적극 사용해야할 것이다.

 

 

운영서버 및 개발서버에서 겪었던 일이다.

테이블에 컬럼을 추가하고 테스트를 해야하는 상황이었다. 

운영서버 배포시 소스코드는 추가된 컬럼을 운영서버 테이블에 추가하지 않아 문제가 있던 경험이 있던지라

개발서버 DB, 운영서버 DB 모두 테이블에 컬럼을 추가해두었다.

 

이후 테스트를 위해 특정 데이터의 상태값을 DBeaver 툴을 이용해서 수정하였다.

그런데 아래와 같은 메세지와 함께 Update가 안되는 것이었다

Error Code: 1136. Column count doesn't match value count at row 1

 

해당 에러문을 들고 구글로 얼른 찾아가보았다...

그런데 잉? 

INSERT 문을 사용할 시 컬럼수와 Values 의 값이 맞지 않을때 나오는 에러라고 한다.

나는 데이터 수정을 했는데? 왠 INSERT ? 왜 수정이 안되지? 

DBeaver를 오래 켜두었을때 가끔 에러메세지를 이유없이 뿜어대던 경험이 있었기에 이놈의 DBeaver가 말썽을? 하는 생각이 들었다. 2시간 정도 끙끙대다가 컬럼 추가한것이 문제인가라는 생각에 추가한 테이블 컬럼을 삭제하자 Update 가 잘되었다.

 

퇴근하며 구글링을 하다 원인을 예측할 수 있었고, 다음날 확인한 결과 그 원인은 맞았다...

그리고 그 원인과 함께 운영서버의 데이터가 망가져 있었다 ! ㅠㅠ

 

원인은 바로 트리거 였다. 해당 테이블에 트리거가 걸려있었던 것이다.

트리거 내용은 해당 테이블이 A라 했을때 INSERT, UPDATE 가 일어날 경우 아래와 같은 INSERT 구문이 실행되는 것이었다.

INSERT INTO A_HISTORY
SELECT * FROM A

 

A 테이블에 대한 로그 테이블인 A_HISTORY 에 내역을 저장하기 위한 트리거 였고 * 을 이용하여 INSERT 하고있었다...

그런데 A 테이블에 내가 컬럼을 추가하여 A_HISTORY 테이블과 컬럼숫자가 맞지 않아 에러가 발생한 상황이었다.

슬프게도 운영서버 A 테이블에도 컬럼을 같이 추가하였었기에....

테이블을 되돌리기까지의 2시간동안 운영서버에서 해당 테이블을 UPDATE 하는 API 부분들이 롤백 되어버렸다.

다행이 많은 롤백이 일어나진 않았고, 한 건의 결제 롤백이 있어서 강제로 결제성공 API를 태워 데이터를 원상복구하였다.

 

트리거라는 존재에 대한 고려가 필요하다는 사실을 깨닫는 경험이었다...

운영서버 컬럼추가는 신중하게....

이전엔 컬럼 추가가 누락되어서 실행안되던 경험이 있어서 미리.. 열심히 추가했는데.. ㅠㅠ

이후엔 트리거도 고려한 후에 추가하여야 겠다!

 

한편으론 해당 트리거를 만들어둔 사람이 원망? 되었다 ㅋㅋ....

컬럼을 명시한 것이 아니라 * 을 사용함으로서 이러한 에러 발생의 가능성을 남겨둔것이 아닌가! 하는 생각...

하지만 또 한편으론 임의의 컬럼이 추가 되었을때 이렇게 에러가 발생함으로써 로그테이블에도 해당 컬럼을

추가해야함을 확인할 수 있기도 했다.

 

해당 형태와 같은 트리거를 만들때 컬럼을 명시하는 것이 좋을까 아니면 해당 트리거처럼 * 을 이용하는 것이 좋을까?
컬럼을 명시할 경우 내가 겪은 상황과 같은 문제는 발생하진 않지만 새로이 추가된 컬럼에 대한 히스토리 로그가 남지 않는 문제점이 있다. 반면에 * 을 이용하면 모든 컬럼에 대한 처리가 가능하며 내가 겪은 일과 같이 에러를 발생하면 에러 자체로 문제가 될수 있지만 한편으론 히스토리 테이블에도 컬럼 추가가 필요하단 사실을 깨닫게 할 수 있다.

 

개발에 있어서 방법론적인 부분들에 장단점이 있다라는 사실이 고민의 기로에 서게 만드는것 같다.

이 부분에 대한 정답이 무엇인지 결론을 내리진 못하겠지만

만일 내가 해당 형태의 트리거를 작성하게 된다면 팀원들과 잘 공유하고 인지시켜야 한다라는 생각이 들었다.

또한 테이블에 변경사항이 있을때는 DB 테이블 및 소스 코드뿐만 아니라 DB내의 로직들(프로시져, 트리거) 등을 고려를 잘해야겠다라는 교훈을 얻었다.

+ Recent posts