openlang

문자열로 온 count(*)와 121건

할 일 목록 화면이에요. 상단에 총 12건이 떠 있고, 제가 할 일을 하나 추가해요. 서버 응답을 기다리지 않고 숫자부터 올리려고 total + 1을 했는데, 화면에 총 121건이 떴어요. (순간 할 일이 백 개 넘게 생긴 줄 알았어요.)

이유는 이거였어요. PostgreSQL의 count(*)는 bigint이고, Node의 pg 드라이버는 bigint를 기본으로 문자열로 돌려줘요. JavaScript 숫자에는 64비트 정수가 다 안 들어가니까요. 그런데 행 타입은 제가 손으로 이렇게 적어 뒀어요.

type CountRow = { total: number };

const { rows } = await pool.query<CountRow>(
  'SELECT count(*) AS total FROM todos WHERE done = false',
);
setTotal(rows[0].total + 1);

타입이 number라고 하니 에디터는 아무 말도 안 하고, 런타임에서는 '12' + 1이 문자열 이어 붙이기가 돼요.

고치는 방법은 두 가지가 보여요. SQL에서 count(*)::int로 캐스팅하거나, 코드에서 받은 값을 파싱하거나요. 쿼리가 몇 개 안 될 때는 캐스팅이 편해 보이는데, 다들 어느 쪽에 두세요?

이어지는 대화 7

서로의 근거를 따라
  1. 고유 링크

    손으로 적은 CountRow가 진짜 버그예요. 어느 방법을 고르든 그대로 남는 버그고요. pool.query<CountRow>는 문법만 예쁜 단언이에요. total이 숫자인지 확인한 건 아무것도 없고, 제네릭은 컴파일러한테 그렇게 믿으라고 했을 뿐이에요.

    개수라면 캐스팅도 괜찮아요. count(*)::int는 20억을 조금 넘으면 범위 초과 에러로 시끄럽게 실패하는데, 할 일 목록이 거기까지 갈 일은 없어요. 다만 이건 이 열 하나만 고쳐요. 다음 bigint 열은 또 문자열로 오고, 타입은 또 아니라고 말할 거예요. 경계에 작은 파서를 두면 둘 다 막을 수 있어요.

    function toCount(value: unknown): number {
      if (typeof value !== 'string' || !/^\d+$/.test(value)) {
        throw new Error(`not a count: ${String(value)}`);
      }
      const n = Number(value);
      if (!Number.isSafeInteger(n)) {
        throw new Error(`count too large: ${value}`);
      }
      return n;
    }

    행을 이걸로 한 번 통과시키고, 돌려받은 값으로 CountRow를 만들어요. 지금 같은 초기 단계라면 저는 일단 SQL에서 캐스팅하고, 두 번째 쿼리가 필요해질 때 파서를 넣겠어요.

  2. 고유 링크

    두 방법 옆에 놓아 볼 셋째 선택지가 있습니다. bigint로 파싱해서 그대로 두는 것입니다. 그러면 TypeScript가 산술에서 bigint와 number를 섞는 것을 거부하므로 total + 1은 컴파일되지 않고, total + 1n이라고 써야 합니다. 화면에 필요했던 오류가 바로 그것입니다. 비용은 나머지 모든 곳에 있습니다. 서식 지정, 일반 숫자와의 엄격한 비교, JSON으로 넘어가는 모든 것에 변환이 필요합니다. 안전한 정수 범위를 벗어날 일이 없는 개수라면 number가 더 싼 표현이고, 그 결정은 tess.p가 올린 파서에서 내리는 것이 맞습니다.

  3. 고유 링크

    tess.p 말대로 일단 SQL에서 count(*)::int로 캐스팅했어요. 헤더가 13건으로 제대로 올라가요. 그런데 같은 화면 아래쪽에 예상 비용 합계가 있거든요. sum(price)인데 price가 numeric이라 이것도 문자열로 와요. 지금은 화면에 그대로 찍히기만 해서 아무도 몰랐던 거예요. 할 일을 추가할 때 여기도 미리 더해 주려고 parseFloat를 붙이려다 손이 멈췄어요. 돈인데 이래도 되나요?

  4. 고유 링크

    안 됩니다. 드라이버가 numeric을 문자열로 넘기는 이유가 바로 그 값이 십진수이고 이진 부동소수점은 그렇지 않기 때문입니다. parseFloat('19.99')부터가 이미 근삿값이고, 그런 값을 몇 개 더하면 합계 끝에 긴 자릿수가 붙어 나오거나 마지막 센트에서 반올림이 어긋납니다. 데이터베이스가 그 합을 정확하게 계산했으니 표시에는 문자열을 그대로 쓰십시오. 서버 응답 전에 화면에서 더해야 한다면 먼저 정수 센트로 바꿔 정수끼리 더하고, 서버 값이 도착하면 그것으로 바꾸십시오. 돈을 담은 열은 자릿수가 선언된 곳에서 더하는 것이 맞습니다.

  5. 고유 링크

    JSON 쪽은 생각보다 빨리 문제가 돼요. JSON.stringify는 bigint를 만나면 TypeError를 던져요. 그래서 그 합계가 처음으로 응답 본문이나 저장된 상태에 들어가는 순간 런타임에서 실패하고, 컴파일러는 끝까지 아무 말도 안 해요. BigInt.prototype에 toJSON을 달 수는 있지만, 그러면 받는 쪽에 맞든 안 맞든 프로그램의 모든 bigint가 같은 방식으로 직렬화돼요. 여기서는 number가 맞다는 데 동의해요. 변환은 값이 나가는 곳마다가 아니라 파서에 있어야 하고요.

  6. 고유 링크

    알겠어요, parseFloat는 뺐어요. 센트로 바꿔서 더하는 것까지 해 보려다가, 할 일 하나 추가할 때 비용을 미리 올려 주는 기능이 굳이 필요한가 싶어서 접었어요. 비용 합계는 이제 서버 응답이 오면 그때 바뀌고, 그 사이 반 초쯤은 이전 값이 보여요. (반 초 동안 틀린 숫자를 보는 것보다는 나아요.) 숫자를 미리 올리는 건 개수에만 남겼어요.

  7. 고유 링크

    타입 문제에서 살짝 옆길로 새는 얘기예요. 낙관적인 total + 1은 개수를 읽은 뒤로 아무도 할 일을 넣지 않았다고 가정해요. 추가 요청이 새 개수를 돌려주게 하면 그 추측이 잠시 뒤 진짜 숫자로 바뀌어요.

같은 질문의 다른 갈래

  1. 에러 없이 끝난 rows.Next 루프

    database/sql로 만든 작은 도구가 목록을 반만 돌려줬어. 에러 없이. 코드는 이래. 컨텍스트에 데드라인이 있었어. 가져오는 도중에 데드라인이 지나서 rows.Next()가 false를 줬고, 루프가 끝났고, 함수는…

    답글 3sqlgo
  2. 기다린 뒤 WHERE를 다시 보는 UPDATE

    PostgreSQL Read Committed에서 두 세션이 있고, 잔액이 15인 계좌가 하나 있으며, 두 세션이 같은 문장을 실행합니다. 세션 A가 행을 갱신했고 아직 커밋하지 않았습니다. 세션 B는 그 행을 찾고, 갱신…

    답글 0sql
  3. 페이지 보고서에는 count(*) OVER ()일까 Repeatable Read일까

    Read Committed에서는 페이지 보고서가 자기 자신과 어긋날 수 있습니다. 보고 화면이 조건에 맞는 주문 12건을 셉니다. 다른 세션이 조건에 맞는 새 주문을 커밋합니다. 그다음 화면이 첫 페이지를 가져옵니다. SEL…

    답글 9sql