Devin.KR
로그인

DB별 SQL 방언 비교 - MySQL PostgreSQL Oracle SQL Server 페이징 UPSERT (SQL 중급 12단원)

개발자 조회 2

이 단원에서 배우는 것

7단원부터 11단원까지 각 단원 끝에 "DB별 차이" 표를 하나씩 붙여 왔다. 조인, 집합 연산, 실행계획, 윈도우 함수, 격리수준까지 문법은 대체로 표준을 따르지만 조금씩 어긋났다. 이번 마지막 단원은 그 어긋남이 가장 심한 다섯 축 — 페이징 · 문자열 · 날짜 · UPSERT · 시퀀스 — 을 한자리에 모은다. 이직하거나 DB 를 옮길 때 처음 막히는 지점이 정확히 여기다.

  • 같은 요구사항을 네 DB 문법으로 각각 쓸 수 있다
  • 표준에 있는데 안 되는 것과, 표준에 없는데 되는 것을 구분한다
  • DB 를 옮길 때 조용히 결과가 달라지는 지점(빈 문자열, 대소문자, 나눗셈)을 미리 안다

개념

SQL 표준은 있지만 어느 DB 도 전부 구현하지 않고, 표준에 없는 기능은 각자 만들었다. 그래서 "표준 SQL 로만 쓰면 이식성이 확보된다"는 말은 현실에서 절반만 맞다. 실용적인 태도는 이렇다.

  • 조인 · 서브쿼리 · 집합 연산 · 윈도우 함수처럼 표준이 잘 지켜지는 영역은 표준대로 쓴다
  • 페이징 · 날짜 함수 · UPSERT 처럼 애초에 갈라진 영역은 대상 DB 문법을 정확히 쓰고, 옮길 때 고쳐야 할 목록으로 관리한다
  • 이식성을 위해 억지로 표준을 고집하다 느려지는 쿼리를 만들지 않는다

아래 예제는 1단원의 emp · dept 테이블을 그대로 쓴다. 부서는 10 인사팀부터 50 총무팀까지 다섯 개가 이미 들어 있으므로, 새로 넣는 예제에서는 60번을 쓴다.

표준 SQL 문법과 예제

방언을 보기 전에, 네 DB 어디에 붙여 넣어도 그대로 도는 것부터 정리해 둔다. 아래 문법은 DB 를 골라 가며 쓸 필요가 없다.

-- 조건 분기는 CASE 하나면 된다. IF() · IIF() · DECODE() 는 전부 방언이다
SELECT emp_name, salary,
       CASE WHEN salary >= 6000 THEN '상'
            WHEN salary >= 4500 THEN '중'
            ELSE '하' END AS 등급
FROM emp;

-- NULL 대체는 COALESCE. IFNULL · NVL · ISNULL 은 각자 다른 이름이다
SELECT emp_name, COALESCE(bonus, 0) AS bonus FROM emp;

-- 타입 변환은 CAST
-- 단, 문자열을 DATE 로 바꾸는 것만은 Oracle 이 NLS_DATE_FORMAT(기본 DD-MON-RR)을 따라가므로
-- 아래 문장은 Oracle 에서만 ORA-01861 로 죽는다. Oracle 은 DATE '2026-08-25' 리터럴이나 TO_DATE 를 쓴다.
SELECT CAST('2026-08-25' AS DATE) AS d, CAST(salary AS DECIMAL(12,2)) AS sal FROM emp;

-- 조건부 집계: 6단원의 집계와 CASE 를 합치면 피벗이 된다
SELECT d.dept_name,
       COUNT(e.emp_id) AS 인원,
       SUM(CASE WHEN e.salary >= 5000 THEN 1 ELSE 0 END) AS 고연봉자
FROM dept d
LEFT JOIN emp e ON e.dept_id = d.dept_id
GROUP BY d.dept_id, d.dept_name;

7단원부터 10단원까지 배운 조인 · 서브쿼리 · 집합 연산 · 윈도우 함수도 대부분 이 범주에 들어간다. 문제는 아래 다섯 축이다.

DB별 차이

1. 페이징 — 상위 N건, N건 건너뛰기

항목MySQL · MariaDBPostgreSQLOracleSQL Server
상위 5건LIMIT 5LIMIT 5FETCH FIRST 5 ROWS ONLY (12c+)SELECT TOP 5 ...
10건 건너뛰고 5건LIMIT 5 OFFSET 10LIMIT 5 OFFSET 10OFFSET 10 ROWS FETCH NEXT 5 ROWS ONLYOFFSET 10 ROWS FETCH NEXT 5 ROWS ONLY (2012+)
구버전 방식LIMIT 10, 5 (순서 주의)-ROWNUM 을 인라인 뷰로 두 번 감쌈ROW_NUMBER() 로 감쌈

표준은 OFFSET ... FETCH 다. PostgreSQL · Oracle 12c+ · SQL Server 2012+ · MariaDB 10.6+ 가 이 문법을 받아들이므로 그 넷 사이를 오갈 코드라면 표준 쪽이 낫다. 다만 MySQL 은 8.0 에서도 FETCH 절이 없어 LIMIT 만 쓸 수 있다. MySQL 이 대상에 끼어 있으면 표준 문법으로 통일하겠다는 계획은 포기해야 한다. Oracle 11g 이하의 ROWNUM 방식은 아직도 현업에 많이 남아 있다.

-- Oracle 11g 이하: ORDER BY 를 안쪽에서 먼저 끝내야 한다
SELECT * FROM (
  SELECT a.*, ROWNUM rn FROM (
    SELECT emp_id, emp_name, salary FROM emp ORDER BY salary DESC
  ) a WHERE ROWNUM <= 15
) WHERE rn > 10;

가운데를 한 번 더 감싼 이유는 ROWNUM 이 정렬 에 매겨지기 때문이다. WHERE ROWNUM > 10 을 바로 쓰면 첫 행이 조건에 걸려 영원히 0건이 나온다.

2. 문자열

항목MySQL · MariaDBPostgreSQLOracleSQL Server
연결CONCAT(a, b)a || ba || ba + b, CONCAT(a,b)
부분 문자열SUBSTRING(s, 1, 3)SUBSTRING(s FROM 1 FOR 3)SUBSTR(s, 1, 3)SUBSTRING(s, 1, 3)
길이CHAR_LENGTH(s)LENGTH(s)LENGTH(s)LEN(s) (끝 공백 제외)
NULL 대체IFNULL / COALESCECOALESCENVL / COALESCEISNULL / COALESCE
대소문자 비교기본 collation 이 구분 안 함구분함구분함기본 collation 이 구분 안 함

COALESCE 는 네 DB 모두 지원하는 표준이므로 NVL·IFNULL·ISNULL 대신 이것만 쓰면 된다. MySQL 에서 a || b 는 기본 sql_mode 에서 문자열 연결이 아니라 OR 연산이라 에러 없이 0 이나 1 을 돌려준다. Oracle 코드를 MySQL 로 옮길 때 가장 흔한 사고다.

3. 날짜

항목MySQL · MariaDBPostgreSQLOracleSQL Server
현재 시각NOW()now(), CURRENT_TIMESTAMPSYSDATE, SYSTIMESTAMPGETDATE(), SYSDATETIME()
오늘 날짜CURDATE()CURRENT_DATETRUNC(SYSDATE)CAST(GETDATE() AS DATE)
7일 더하기DATE_ADD(d, INTERVAL 7 DAY)d + INTERVAL '7 day'd + 7DATEADD(DAY, 7, d)
날짜 차이(일)DATEDIFF(d1, d2)d1 - d2d1 - d2DATEDIFF(DAY, d2, d1) (인자 순서 반대)
포맷DATE_FORMAT(d, '%Y-%m-%d')to_char(d, 'YYYY-MM-DD')TO_CHAR(d, 'YYYY-MM-DD')FORMAT(d, 'yyyy-MM-dd')
DATE 타입에 시간없음 (DATETIME 이 따로 있음)없음있음 (초 단위까지 저장)없음 (2008+)

마지막 줄이 중요하다. Oracle 의 DATE 는 시분초를 포함하므로 WHERE order_date = DATE '2026-08-25' 는 정확히 자정인 행만 찾는다. 하루치를 조회하려면 범위로 써야 한다. 그리고 9단원에서 본 대로 TRUNC(order_date) = ... 로 쓰면 인덱스를 못 탄다.

-- Oracle: 하루치 조회
SELECT * FROM orders
WHERE order_date >= DATE '2026-08-25'
  AND order_date <  DATE '2026-08-26';

4. UPSERT — 있으면 수정, 없으면 삽입

-- MySQL / MariaDB
INSERT INTO dept (dept_id, dept_name, location) VALUES (60, '보안팀', '서울')
ON DUPLICATE KEY UPDATE dept_name = VALUES(dept_name), location = VALUES(location);
-- MySQL 8.0.20+ 는 VALUES() 대신 별칭 사용을 권장
INSERT INTO dept (dept_id, dept_name, location) VALUES (60, '보안팀', '서울') AS new
ON DUPLICATE KEY UPDATE dept_name = new.dept_name, location = new.location;

-- PostgreSQL 9.5+
INSERT INTO dept (dept_id, dept_name, location) VALUES (60, '보안팀', '서울')
ON CONFLICT (dept_id) DO UPDATE
SET dept_name = EXCLUDED.dept_name, location = EXCLUDED.location;

-- Oracle / SQL Server (표준 MERGE)
MERGE INTO dept d
USING (SELECT 60 AS dept_id, '보안팀' AS dept_name, '서울' AS location FROM dual) s
   ON (d.dept_id = s.dept_id)
WHEN MATCHED THEN UPDATE SET d.dept_name = s.dept_name, d.location = s.location
WHEN NOT MATCHED THEN INSERT (dept_id, dept_name, location)
     VALUES (s.dept_id, s.dept_name, s.location);

SQL Server 는 FROM dual 을 빼고 마지막에 세미콜론을 반드시 붙인다. MySQL · MariaDB 에는 INSERT ... ON DUPLICATE KEY UPDATE 외에 REPLACE INTO 도 있지만, 이건 기존 행을 삭제하고 다시 넣는 동작이라 자동증가 값이 바뀌고 외래키가 걸린 행이 연쇄 삭제될 수 있다. 편해 보여서 쓰다가 데이터가 사라지는 대표적인 함정이다.

5. 시퀀스와 자동증가

항목MySQL · MariaDBPostgreSQLOracleSQL Server
자동 증가 컬럼AUTO_INCREMENTGENERATED ... AS IDENTITY 또는 serial12c+ IDENTITY, 이전엔 시퀀스+트리거IDENTITY(1,1)
시퀀스 객체MariaDB 10.3+ 만 지원CREATE SEQUENCECREATE SEQUENCE2012+ 지원
다음 값NEXT VALUE FOR s (MariaDB)nextval('s')s.NEXTVALNEXT VALUE FOR s
방금 넣은 값LAST_INSERT_ID()currval() / RETURNING idRETURNING id INTOSCOPE_IDENTITY()

PostgreSQL 의 INSERT ... RETURNING id 는 삽입과 조회를 한 번에 끝내는 편리한 문법이다. MySQL 에는 없다. SQL Server 에서 @@IDENTITY 대신 SCOPE_IDENTITY() 를 쓰는 이유는, 트리거가 다른 테이블에 넣은 값까지 @@IDENTITY 가 잡아 오기 때문이다.

실무에서 자주 틀리는 것

1. Oracle 은 빈 문자열이 NULL 이다

-- Oracle
INSERT INTO dept VALUES (70, '테스트', '');
SELECT COUNT(*) FROM dept WHERE location IS NULL;   -- 1  (빈 문자열이 NULL 로 저장됨)
SELECT COUNT(*) FROM dept WHERE location = '';      -- 0  (= '' 는 항상 UNKNOWN)

다른 세 DB 는 빈 문자열과 NULL 이 명확히 다르다. "값을 지우려고 빈 문자열을 넣었는데 NOT NULL 제약에 걸린다"는 신고가 Oracle 이관 프로젝트에서 반드시 나온다. 애플리케이션에서 IS NULL 체크와 = '' 체크를 섞어 쓰지 않는 것으로 예방한다.

2. 정수 나눗셈

SELECT 7 / 2;

MySQL 은 3.5000 을, Oracle 은 3.5 를 돌려준다(Oracle 은 정수 전용 타입이 없어 전부 NUMBER 다). 반면 PostgreSQL 과 SQL Server 는 정수끼리 나누면 소수점을 버리고 3 이다. 비율 계산에서 소수점이 통째로 사라지는 사고가 여기서 난다. salary * 100.0 / total 처럼 어느 한쪽을 명시적으로 소수로 만들어 계산한다. 0 으로 나눌 때도 MySQL 은 NULL 을 돌려주고 나머지는 에러를 낸다.

3. 식별자 대소문자와 인용부호

항목MySQL · MariaDBPostgreSQLOracleSQL Server
인용부호백틱 또는 "(ANSI_QUOTES 모드)""[] 또는 "
따옴표 없는 이름OS 에 따라 다름(리눅스는 구분)소문자로 변환대문자로 변환구분 안 함

PostgreSQL 에서 CREATE TABLE "Emp" 로 만들면 이후 모든 쿼리에서 큰따옴표를 붙여야 한다. 그냥 처음부터 소문자와 밑줄만 쓰는 것이 답이다. MySQL 은 리눅스에서 테이블 이름 대소문자를 구분하지만 macOS·Windows 에서는 구분하지 않아, 개발 PC 에서 잘 돌던 쿼리가 리눅스 서버에서 Table doesn't exist 로 죽는다.

4. 깊은 페이징을 OFFSET 으로 버틴다

9단원에서 봤듯 OFFSET 100000 은 10만 행을 읽고 버린다. 네 DB 모두 마찬가지다. 무한 스크롤이나 API 페이징이라면 마지막으로 본 값을 조건으로 넘기는 방식이 답이다.

-- 1페이지
SELECT emp_id, emp_name, hire_date FROM emp
ORDER BY hire_date DESC, emp_id DESC LIMIT 20;

-- 다음 페이지 (마지막 행이 2019-07-15, 1002 였다면)
SELECT emp_id, emp_name, hire_date FROM emp
WHERE (hire_date, emp_id) < ('2019-07-15', 1002)
ORDER BY hire_date DESC, emp_id DESC LIMIT 20;

행 값 비교 (a, b) < (x, y) 는 MySQL·MariaDB·PostgreSQL 이 지원한다. Oracle 과 SQL Server 는 WHERE hire_date < ? OR (hire_date = ? AND emp_id < ?) 로 풀어 쓴다. 정렬 컬럼에 동점이 있을 수 있으므로 고유한 컬럼을 정렬 기준 끝에 반드시 붙인다. 그러지 않으면 페이지 경계에서 행이 중복되거나 누락된다.

스스로 확인하기

  1. 급여 상위 6~10위 사원을 뽑는 쿼리를 MySQL 과 Oracle 12c 문법으로 각각 써라.
  2. 사원 이름과 부서명을 김유신(개발팀) 형태로 붙이는 식을 네 DB 문법으로 써라. 부서가 없는 홍범도는 홍범도(미배정) 이 되어야 한다.
  3. dept 테이블에 (60, '보안팀', '서울') 을 넣되 이미 있으면 갱신하는 문장을 PostgreSQL 문법으로 써라.
-- 1  MySQL / MariaDB
SELECT emp_id, emp_name, salary FROM emp
ORDER BY salary DESC, emp_id LIMIT 5 OFFSET 5;

-- 1  Oracle 12c+
SELECT emp_id, emp_name, salary FROM emp
ORDER BY salary DESC, emp_id
OFFSET 5 ROWS FETCH NEXT 5 ROWS ONLY;

-- 2  MySQL / MariaDB
SELECT CONCAT(e.emp_name, '(', COALESCE(d.dept_name, '미배정'), ')') AS label
FROM emp e LEFT JOIN dept d ON d.dept_id = e.dept_id;
-- PostgreSQL / Oracle
SELECT e.emp_name || '(' || COALESCE(d.dept_name, '미배정') || ')' AS label
FROM emp e LEFT JOIN dept d ON d.dept_id = e.dept_id;
-- SQL Server
SELECT e.emp_name + '(' + COALESCE(d.dept_name, '미배정') + ')' AS label
FROM emp e LEFT JOIN dept d ON d.dept_id = e.dept_id;

-- 3
INSERT INTO dept (dept_id, dept_name, location) VALUES (60, '보안팀', '서울')
ON CONFLICT (dept_id) DO UPDATE
SET dept_name = EXCLUDED.dept_name, location = EXCLUDED.location;

1번에서 ORDER BYemp_id 를 덧붙인 이유는 급여 동점 시 페이지마다 순서가 흔들리는 것을 막기 위해서다. 2번에서 LEFT JOIN 을 쓴 이유는 7단원에서 확인한 대로 부서가 NULL 인 홍범도를 빠뜨리지 않기 위해서다. 여섯 단원을 관통한 원칙 세 가지만 남긴다. NULL 을 항상 의심하고, 결과가 맞아도 실행계획을 확인하고, 쓰기는 트랜잭션 경계를 먼저 정한다.

참고: MySQL ON DUPLICATE KEY UPDATE 문서, PostgreSQL INSERT / ON CONFLICT 문서