upsert 가 돌려준 4 는 새로 들어간 건수가 아니었습니다 — MySQL affected rows
Go 로 짜인 서버를 TypeScript 로 다시 짜면서 다시 공부한 정석을 적는 연재의 두 번째 글입니다. 이번 요구는 단순했습니다. 같은 데이터를 두 번 받아도 데이터베이스에는 한 번만 남아야 한다는 것입니다. 바깥 시스템에서 목록을 주기적으로 가져오다 보면, 어제 받은 것을 오늘 또 받는 일이 흔하기 때문입니다.
이 요구는 정석대로 풀었습니다. 그런데 풀고 나서 데이터베이스가 돌려준 숫자를 "새로 들어간 건수"로 읽었다가 틀렸습니다. 그리고 그 숫자는 Go 와 TypeScript 에서 서로 달랐습니다.
두 번 받아도 한 번만 남기려면, 데이터가 원래 가진 이름표가 필요합니다
데이터베이스 표에 행을 넣을 때 흔히 1, 2, 3 처럼 자동으로 늘어나는 번호를 붙입니다. 이런 번호를 대리키라고 부릅니다. 대리키는 넣을 때마다 새로 생기므로, 같은 상품을 두 번 넣으면 번호만 다른 행이 두 개 생깁니다.
그래서 중복을 막는 기준은 데이터가 원래 가진 이름표여야 합니다. 상품 코드처럼 바깥 세상에서 그 대상을 가리키는 값을 자연키라고 부릅니다. 자연키에 UNIQUE 제약을 걸면, 데이터베이스가 같은 이름표를 가진 행이 두 개 생기는 것을 막아 줍니다.
여기에 upsert 를 더합니다. upsert 는 "없으면 넣고(insert), 있으면 고친다(update)"를 한 번의 명령으로 하는 방식입니다. MySQL 에서는 INSERT ... ON DUPLICATE KEY UPDATE 로 씁니다.
CREATE TABLE item (
code VARCHAR(20) PRIMARY KEY, -- 자연키: 상품 코드
price INT NOT NULL
);
INSERT INTO item (code, price)
VALUES ('A', 100), ('B', 250), ('C', 300) AS new
ON DUPLICATE KEY UPDATE price = new.price;
AS new 는 새로 넣으려던 행에 이름을 붙이는 문법이고, MySQL 8.0.19 에 들어왔습니다. 예전에는 VALUES(price) 로 같은 일을 했는데, 이 방식은 8.0.20 부터 폐지 예정(deprecated)이 됐고 행 별칭을 쓰라는 안내가 붙었습니다.
이렇게 하면 같은 목록을 몇 번을 받아도 표에는 상품마다 한 행만 남습니다. 요구는 여기서 풀렸습니다. 문제는 그다음, "이번에 새로 들어온 상품이 몇 개냐"를 세려고 했을 때 생겼습니다.
데이터베이스가 돌려주는 숫자는 "새로 들어간 건수"가 아닙니다
명령을 실행하면 데이터베이스는 영향받은 행 수(affected rows)를 돌려줍니다. 이름만 보면 "바뀐 행의 수"처럼 읽힙니다. 그런데 upsert 에서는 행 하나마다 셈법이 다릅니다. MySQL 문서의 규칙은 이렇습니다.
| 그 행에 일어난 일 | 기본 셈 | CLIENT_FOUND_ROWS 를 켰을 때 |
|---|---|---|
| 새로 들어갔다 | 1 | 1 |
| 이미 있었고 값이 바뀌었다 | 2 | 2 |
| 이미 있었고 값이 그대로다 | 0 | 1 |
CLIENT_FOUND_ROWS 는 "바뀐 행" 대신 "조건에 맞은 행"을 세어 달라고 연결할 때 거는 설정입니다. 표를 보면 어느 쪽이든 결과 숫자는 신규 건수와 다릅니다. 기존 행이 바뀌면 2 가 더해지고, 설정에 따라 그대로인 행도 1 로 셉니다.
직접 재 봤습니다. 상품 A(100원)와 B(200원)가 이미 있는 표에, A 는 같은 값으로, B 는 250원으로, 새 상품 C 는 300원으로 한 번에 upsert 했습니다. 실제로 새로 들어간 행은 C 하나뿐입니다.
MySQL 8.4.11 · 같은 SQL · 같은 데이터
드라이버 설정 | 불변 · 변경 · 신규 (한 행씩) | 세 행 한 번에
Node mysql2 기본값 | 1 · 2 · 1 | 4
Node mysql2 -FOUND_ROWS | 0 · 2 · 1 | 3
Go go-sql-driver 기본값 | 0 · 2 · 1 | 3
Go clientFoundRows=true | 1 · 2 · 1 | 4
실제로 새로 들어간 행 = 1
신규 건수는 1 인데, 돌아온 숫자는 설정에 따라 3 이거나 4 였습니다. 어느 쪽도 "새로 들어간 건수"를 뜻하지 않습니다.
같은 SQL 이 Go 에서는 3, Node 에서는 4 를 돌려줬습니다
여기에 언어를 옮긴 사람에게만 보이는 함정이 하나 더 있었습니다. 드라이버는 프로그램이 데이터베이스와 대화할 때 쓰는 통역 라이브러리인데, 드라이버마다 연결할 때 거는 기본 설정이 다릅니다.
Go 의 go-sql-driver/mysql 은 clientFoundRows 가 기본으로 꺼져 있습니다. 반면 Node 의 mysql2 는 기본 연결 설정에 FOUND_ROWS 가 들어 있습니다. 그래서 위 실험처럼 같은 SQL, 같은 데이터에서 Go 는 3, Node 는 4 를 돌려줍니다.
이 차이는 에러를 내지 않습니다. 옛 코드가 영향받은 행 수로 무언가를 판단하고 있었다면, TypeScript 로 옮기는 순간 그 판단은 조용히 달라집니다. 예를 들어 "0 이면 바뀐 것이 없으니 건너뛴다" 같은 조건은, Node 기본값에서는 그대로인 행도 1 로 세므로 영영 참이 되지 않습니다.
같은 SQL 을 옮겼으니 같은 결과가 나올 것이라는 기대는, 드라이버의 기본 설정까지 같다는 전제 위에서만 맞습니다.
돌이켜 보면 이 함정은 upsert 라는 정석을 들이면서 함께 들어온 것이었습니다. 정석을 들이는 순간, 그 정석이 쓰는 셈법도 같이 들어온다는 생각이 들었습니다.
신규 건수가 필요하면 세는 방법을 바꿉니다
영향받은 행 수로 신규 건수를 거꾸로 계산하려 들면, 계산식이 드라이버 설정에 묶입니다. 그래서 세는 방법 자체를 바꿨습니다.
- 전후 개수의 차이로 셉니다. 같은 트랜잭션 안에서 넣기 전과 넣은 뒤의 행 수를 비교합니다. 다른 곳에서 동시에 넣는 경우까지 정확해야 한다면 범위를 잠그거나 기준 시각을 함께 기록해야 합니다.
- 먼저 이름표를 조회합니다. 넣으려는 자연키 목록으로 이미 있는 것을 먼저 찾고, 없는 것만 신규로 셉니다.
- 세지 않아도 되게 설계합니다. "몇 건이 새로 왔나"가 꼭 필요한 정보인지부터 다시 봅니다. 필요한 것이 "모두 반영됐다"는 사실뿐이라면 개수를 셀 이유가 없습니다.
반대로 영향받은 행 수를 그대로 믿어도 되는 자리도 있습니다. "아직 처리되지 않은 행만 고친다"처럼 조건 자체가 바뀔 행만 고르는 UPDATE 라면, 조건에 맞은 행과 실제로 바뀐 행이 같아서 설정의 차이가 드러나지 않습니다. 멀쩡한 코드까지 고칠 필요는 없다는 뜻입니다.
정리하며
- 중복을 막는 기준은 자연키입니다. 자동 번호가 아니라 데이터가 원래 가진 이름표에 UNIQUE 를 겁니다.
- affected rows 는 신규 건수가 아닙니다. upsert 에서는 바뀐 행이 2 로 세어지고, 그대로인 행은 설정에 따라 0 이거나 1 입니다.
- 언어를 옮기면 드라이버 기본값도 옮겨집니다. 같은 SQL 이라도 결과 숫자를 해석하는 코드는 새 드라이버에서 다시 확인합니다.
이번 실험은 한 번씩 실행한 결과이고, 동시에 여러 요청이 들어오는 상황의 셈법까지 보여 주지는 않습니다. 다음 글에서는 그 동시성 이야기로 넘어가, 같은 요청이 두 번 와도 한 번만 처리하고 같은 응답을 돌려주는 방법을 적어 보겠습니다.
실험 환경: MySQL 8.4.11(로컬 Docker 컨테이너) · Node.js v26.5.0 · mysql2 3.24.4 · Go 1.25.6 · go-sql-driver/mysql v1.10.1 · 2026-09-29. 예제 표와 데이터는 이 글을 위해 새로 만든 것입니다.
관련 글
FOR UPDATE 를 걸어도 정원은 초과될 수 있습니다 — 락이 아니라 읽기 시점의 문제였습니다
9개월 전 그누보드5 위에 직접 얹은 트랜잭션과 FOR UPDATE 를, 이력서 한 줄을 검증하려다 다시 열었습니다. 락은 제대로 걸려 있었습니다. 다만 MyISAM 혼용과 일관 읽기 시점 두 군데가 우연히 안전했고, 그 우연은 상식적인 수정 하나로 걷힐 수 있었습니다.
0 과 빈 문자열과 null 은 다른 말입니다 — Go 의 zero value 에서 TypeScript 로
Go 에 약해서 서버를 TypeScript 로 다시 짜며 가장 먼저 다시 배운 것은 빈 값이었습니다. 0, 빈 문자열, null, 키 없음이 각각 다른 뜻이라는 것과, 설정 파일이 없는 환경 변수를 조용히 빈 문자열로 넣는다는 것을 직접 실험으로 확인했습니다.
MySQL collation 을 적지 않은 테이블은 서버를 따라갑니다 — 같은 DDL, 다른 UNIQUE
CREATE TABLE 에 collation 을 적지 않으면 MySQL UNIQUE 의 중복 기준은 서버 설정을 따라갑니다. collation 만 다른 MySQL 8.4 두 대로 재현한 차이와 RDS 파라미터 그룹·information_schema 로 확인하고 막는 법을 정리했습니다.