mysql2로 바꿨더니 평점이 갑자기 깨졌다. DECIMAL이 문자열로 오는 함정
커넥션 풀 쓰려고 드라이버를 mysql에서 mysql2로 바꿨다. 대부분 멀쩡했는데, 맛집 별점 화면을 열자마자 콘솔에 이게 터졌다.
TypeError: avgRating.toFixed is not a function
분명 숫자여야 하는데 toFixed가 없단다. mysql일 땐 잘 되던 게 mysql2로 바꾸니까 깨진 거다. 드라이버만 바꿨지 쿼리도 화면 코드도 그대로라 범인 찾는 데 시간이 좀 걸렸다. 이 글에선 이 함정을 정리해본다.
증상
맛집마다 별점 평균을 AVG로 구해서 소수점 한 자리로 보여주고 있었다. 쿼리는 이랬다.
SELECT fp.*,
(SELECT AVG(star) FROM FavPlaceStars WHERE favplace_id = fp.id) AS avgRating
FROM FavPlace fp그리고 프론트는 이랬다.
// 평균 별점을 4.5 이런 식으로 표시
<span>{avgRating.toFixed(1)}</span>toFixed는 숫자에만 있는 메서드다. 즉 avgRating이 숫자가 아니라는 얘기였다.
원인은 DECIMAL을 문자열로 준다는 것
이유는 황당할 만큼 단순했다. mysql2는 DECIMAL 타입을 기본적으로 문자열로 반환한다. AVG(star)의 결과가 DECIMAL이라, 4.5라는 숫자가 아니라 "4.5"라는 문자열로 넘어온 거다. 문자열엔 toFixed가 없으니 당연히 깨진다.
구 mysql 드라이버는 이걸 숫자로 줬었는데, mysql2는 정밀도 손실을 막으려고 일부러 문자열로 준다. 자바스크립트 number는 64비트 부동소수점이라 소수점 자리가 많거나 큰 값이면 그대로 담을 수 없는데, DECIMAL은 그런 값을 정확히 저장하는 타입이다. 그걸 number로 바꾸는 순간 정확도가 깨질 수 있으니 문자열로 두고 판단을 사용자에게 맡기는 거다. 의도는 좋은데, 모르고 있으면 이렇게 당한다.
해결은 풀 설정 한 줄과 프론트 이중 방어
풀 만들 때 decimalNumbers: true 옵션을 주면 DECIMAL을 숫자로 받게 할 수 있다.
const pool = mysql.createPool({
host: process.env.DB_HOST,
// ...
// mysql2는 DECIMAL을 기본적으로 문자열로 반환한다.
// 숫자로 받아서 프론트의 .toFixed() 같은 게 안 깨지게 함.
decimalNumbers: true,
});별점 평균은 소수점 한두 자리면 충분해서 정밀도 걱정은 없었다. 돈이나 좌표처럼 자리수가 중요한 값이었으면 문자열로 받아서 따로 다뤘을 거다.
그리고 프론트에서도 혹시 모르니 Number()로 한 번 감싸서 이중 방어했다. 백엔드 설정이 어쩌다 바뀌어도 안 깨지게.
<span>{Number(avgRating).toFixed(1)}</span>나중에 여행 플래너에서 좌표를 DECIMAL로 저장할 때도 같은 함정을 한 번 더 만날 뻔했다. 그때는 이 글이 기억나서 쿼리 결과를 Number()로 바꾸는 줄을 미리 넣어뒀다.
갈아타긴 했나
글에는 갈아탔다고 썼는데, 그 프로젝트 package.json을 지금 열어보면 이렇다.
"dependencies": {
"mysql": "^2.18.1", <- 안 지웠다
"mysql2": "^3.15.3"
}새로 짠 코드는 mysql2를 쓰고, 예전 코드는 여전히 mysql을 부른다. 한 프로젝트 안에 드라이버가 두 개 돌고 있는 상태가 몇 달째다.
평점 깨진 것만 고치고 나머지는 안 옮겼다. 안 옮긴 쪽이 잘 돌고 있어서 손댈 이유를 못 찾았는데, 이러면 나중에 또 같은 함정을 밟을 것 같긴 하다. 드라이버가 두 개면 같은 컬럼이 어떤 라우트에선 숫자로, 어떤 라우트에선 문자열로 오는 상태가 되기 때문이다. mysql에서 mysql2로 옮길 계획이 있다면, 옮기기 전에 DECIMAL 컬럼을 쓰는 쿼리부터 찾아두길 추천한다.