F-2: MySQL 함수·조인·계층형 변환 — 인스타 쿼리를 통째로 옮기기
목차 20
안녕하세요, 홍순구 튜터입니다. 지난 시간엔 인스타그램 스키마를 Oracle 에서 MySQL 8.4 로 옮기면서 자료형(VARCHAR2→VARCHAR·DATE→DATETIME), 자동 채번(시퀀스→AUTO_INCREMENT), 피드 페이지네이션(ROWNUM→LIMIT), NULL 메우기(NVL→IFNULL)까지 방언의 기초를 한 바퀴 돌았죠. 그러면서 마지막에 "함수와 복잡한 쿼리는 더 많이 갈린다" 고 예고했어요. 오늘이 바로 그 본론이에요.
오늘은 날짜를 예쁘게 출력하던 TO_CHAR 가 MySQL 의 DATE_FORMAT 으로, 대댓글 트리를 따라가던 CONNECT BY(D-3)가 WITH RECURSIVE 로 바뀌는 모습을 직접 옮겨 봐요. 함수·조인·계층형까지 통째로요. 그리고 시작은 좀 의외의 함정으로 열게요. 바로 "빈칸이 빈칸이 아닌" 이야기예요. Oracle 과 MySQL 이 빈 문자열을 서로 다르게 보거든요. 이 차이를 모르면 멀쩡한 쿼리가 회사를 옮기는 순간 슬그머니 틀려요.
오늘의 여정
① 빈칸이 빈칸이 아니다 — 빈 문자열과 NULL
② 날짜를 글자로 — TO_CHAR vs DATE_FORMAT
③ 문자열 잇기와 조건 분기 — || 와 DECODE
④ 한쪽이 비어도 다 보여주기 — (+) vs ANSI JOIN
⑤ 대댓글 트리 — CONNECT BY vs WITH RECURSIVE
⑥ 태그를 한 줄로 — LISTAGG vs GROUP_CONCAT
⑦ 통째로 옮기고 다음 과목으로 — 변환 한 장 정리
💡 오늘 수업의 핵심 — "Oracle 함수·외부조인·계층형 쿼리를 MySQL 8.4 로 옮기며, 빈 문자열 함정·DATE_FORMAT·CONCAT·ANSI JOIN·WITH RECURSIVE·GROUP_CONCAT 이라는 방언 차이를 정리한다"
🎯 학습 목표
- 빈 문자열을 Oracle 은 NULL 로, MySQL 은 빈 문자열로 본다는 차이를 안다. (SQLD 방언 함정 ★빈출)
TO_CHAR↔DATE_FORMAT,||↔CONCAT,DECODE↔CASE,(+)↔ANSI JOIN 변환을 안다.CONNECT BY↔WITH RECURSIVE,LISTAGG↔GROUP_CONCAT으로 계층형·집계 쿼리를 옮긴다.
Step 1: "빈칸이 빈칸이 아니다 — 빈 문자열과 NULL"
지난 시간 NULL 을 메우는 NVL·IFNULL 을 다뤘죠. 오늘은 그 NULL 과 빈 문자열('')의 관계부터 짚어요. 둘이 같아 보이지만, 두 데이터베이스가 이걸 정반대로 봐요. 여기서 사고가 자주 나요.
먼저 용어를 맞출게요. 빈 문자열(empty string) 은 따옴표 안에 아무것도 없는 '' 예요. 길이가 0 인 글자죠. 반면 NULL 은 "값이 아예 없음" 이에요. C-3 에서 NULL 은 빈칸도 0 도 아닌 "미지의 상태" 라고 했죠. 그런데 Oracle 은 이 둘을 같다고 봐요. Oracle 에 빈 문자열을 넣으면 그게 NULL 로 저장돼요. MySQL 은 다르다고 봐요. 빈 문자열은 길이 0 짜리 멀쩡한 값이고, NULL 이 아니에요.
우리 회원 중 자기소개(bio)가 빈 회원이 있죠. 여기에 시험 삼아 자기소개를 ''(빈 문자열)로 넣은 회원을 하나 추가했다가, "자기소개가 NULL 인 회원" 을 세 보면 두 데이터베이스의 답이 갈려요.
-- sql/migration/oracle_to_mysql/06_queries_f2_mysql.sql
-- MySQL: '' 는 NULL 이 아니다
SELECT ('' IS NULL) AS empty_is_null, -- 0 (거짓)
('' = '') AS empty_eq_empty, -- 1 (참)
LENGTH('') AS len_empty; -- 0
MySQL 에선 '' IS NULL 이 거짓(0)이에요. 빈 문자열은 길이 0 인 값일 뿐 NULL 이 아니라는 뜻이죠. 그래서 빈 자기소개를 넣은 회원은 bio IS NULL 로 셀 때 빠져요. 원래 비어 있던 4명만 세어져요.
그런데 Oracle 에서 똑같이 빈 문자열을 넣고 세면 5명 이 나와요. Oracle 은 빈 문자열을 NULL 로 저장하니, 방금 넣은 회원까지 "소개가 NULL" 로 함께 세어지거든요.
같은 INSERT, 다른 해석
Oracle : bio 에 '' → NULL 로 저장 → IS NULL 이 셈 (소개 없는 회원 5명)
MySQL : bio 에 '' → 빈 문자열 그대로 → IS NULL 이 안 셈 (소개 없는 회원 4명)
같은 데이터, 같은 쿼리인데 답이 4 와 5 로 갈렸어요. 이게 Oracle 로 짠 쿼리를 MySQL 로 옮길 때(혹은 그 반대) 가장 조용하게 사람을 잡는 함정이에요. 에러도 안 나고 결과만 슬쩍 달라지니까요.
⚠️ Oracle 은 빈 문자열(
'')을 NULL 로 저장하지만, MySQL 은 빈 문자열을 길이 0 의 값으로 본다(NULL 아님). "빈 값을 어떻게 셀까" 를IS NULL로만 짜면 두 방언에서 결과가 갈린다. ★빈출
💡 한 줄 정리 — Oracle 에서
''= NULL, MySQL 에서''≠ NULL. 빈 문자열까지 비었다고 보려면 MySQL 에선bio IS NULL OR bio = ''처럼 둘 다 확인해야 한다.
🙋 학생 질문 — "튜터님, 그럼 MySQL 에선 빈 값을 어떻게 한 번에 걸러요?"
두 조건을 같이 쓰면 돼요. WHERE bio IS NULL OR bio = '' 처럼요. MySQL 에선 NULL 과 빈 문자열이 서로 다른 값이라, "내용이 사실상 비어 있다" 를 잡으려면 두 경우를 모두 확인해야 해요. 한 단계 더 깔끔하게 하려면 WHERE COALESCE(bio, '') = '' 도 자주 써요. NULL 이면 빈 문자열로 바꾼 뒤 빈 문자열인지 한 번에 보는 방식이죠. 반대로 Oracle 에선 '' 가 곧 NULL 이라 WHERE bio IS NULL 한 줄이면 둘 다 걸려요. 이 차이를 모르고 Oracle 버릇대로 MySQL 에 IS NULL 만 쓰면, 빈 문자열로 들어온 데이터가 슬쩍 빠져나가요.
Step 2: "날짜를 글자로 — TO_CHAR vs DATE_FORMAT"
C-2 에서 날짜를 원하는 모양의 글자로 바꾸는 TO_CHAR 를 배웠죠. TO_CHAR(created_at, 'YYYY-MM-DD') 처럼요. MySQL 도 똑같은 일을 하는데, 함수 이름이 DATE_FORMAT 이고 포맷을 적는 방식이 달라요. Oracle 은 YYYY·MM 같은 글자로, MySQL 은 %Y·%m 처럼 퍼센트 기호로 적어요.
게시물의 작성 시각을 여러 모양으로 출력해 볼게요.
-- sql/migration/oracle_to_mysql/06_queries_f2_mysql.sql
-- Before(Oracle): TO_CHAR(created_at, 'YYYY-MM-DD HH24:MI:SS')
SELECT post_id,
DATE_FORMAT(created_at, '%Y-%m-%d') AS ymd, -- 2026-06-01
DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:%s') AS full_ts, -- 2026-06-01 00:00:00
DATE_FORMAT(created_at, '%Y년 %m월 %d일') AS kor,
DATE_FORMAT(created_at, '%Y-%m') AS ym
FROM post WHERE post_id IN (1, 12, 50) ORDER BY post_id;
첫 게시물의 작성일이 2026-06-01, 시각까지 붙이면 2026-06-01 00:00:00, 한글로 꾸미면 2026년 06월 01일 로 나와요. 지난 시간에 DATE→DATETIME 으로 옮긴 덕에 시각(00:00:00)까지 살아 있죠.
포맷 글자를 한 표로 맞춰 볼게요. 옮길 때 이 표만 있으면 헷갈리지 않아요.
| 의미 | Oracle (TO_CHAR) |
MySQL (DATE_FORMAT) |
|---|---|---|
| 연(4자리) | YYYY |
%Y |
| 월 | MM |
%m |
| 일 | DD |
%d |
| 시(24시간) | HH24 |
%H |
| 분 | MI |
%i |
| 초 | SS |
%s |
| 요일(약어) | DY |
%a |
여기서 제일 잘 틀리는 게 분 이에요. Oracle 은 분이 MI 인데, MySQL 은 분이 %i 예요. 그런데 MySQL 의 %m 은 분이 아니라 월 이거든요. 손이 가는 대로 분을 %m 으로 적으면, 분 자리에 엉뚱하게 월이 찍혀요.
-- ⚠️ %i(분) vs %m(월) : 같은 시각(13시 45분 30초)을 두 포맷으로
SELECT DATE_FORMAT('2026-06-01 13:45:30', '%H:%i:%s') AS correct_i, -- 13:45:30 (분)
DATE_FORMAT('2026-06-01 13:45:30', '%H:%m:%s') AS wrong_m; -- 13:06:30 (%m=월!)
%i 로 적으면 13:45:30 으로 분(45)이 제대로 나오는데, %m 으로 적으면 13:06:30 으로 분 대신 6월의 06 이 찍혀요. 시각이 통째로 틀어지는 거죠. "MySQL 에서 분은 %i, %m 은 월" 이라고 단단히 기억해 두세요.
반대로 글자를 날짜로 되돌리는 것도 짝이 있어요. Oracle 의 TO_DATE('2026-06-01', 'YYYY-MM-DD') 는 MySQL 에선 STR_TO_DATE('2026-06-01', '%Y-%m-%d') 예요. 출력은 DATE_FORMAT, 입력은 STR_TO_DATE 로 짝을 지어 외우면 편해요.
⚠️ MySQL
DATE_FORMAT에서 분은%i다.%m은 월이라, 분을%m으로 적으면 분 대신 월이 찍힌다. Oracle 의MI(분)·MM(월) 구분과 같은 함정이 기호만 바뀌어 그대로 있다. ★빈출
💡 한 줄 정리 — 날짜를 글자로는 Oracle
TO_CHAR, MySQLDATE_FORMAT(%Y-%m-%d). 글자를 날짜로는TO_DATE↔STR_TO_DATE. 분은%i(%m은 월).
🙋 학생 질문 — "튜터님, 포맷 기호를 다 외워야 하나요?"
전부 외울 필요는 없어요. 실무에선 %Y-%m-%d(날짜)와 %H:%i:%s(시각) 이 두 덩어리를 가장 많이 써서, 이것만 익혀 두면 대부분 해결돼요. 나머지는 그때그때 찾아 쓰면 되고요. 다만 시험과 실무 양쪽에서 발목 잡는 %i(분) vs %m(월) 하나는 꼭 기억해 두세요. 이건 찾아보기 전에 이미 틀려서 결과가 나와 버리는 함정이라, 알고 있지 않으면 검산할 때까지 모르고 지나가요. "분은 %i" 한 가지만 기억해 두면 큰 사고는 피해요.
Step 3: "문자열 잇기와 조건 분기 — || 와 DECODE"
함수 두 개를 더 옮겨 볼게요. 둘 다 우리가 이미 Oracle 로 써 봤던 거예요. 문자열을 잇는 || 와, 조건에 따라 값을 고르는 DECODE 죠. 그런데 이 둘은 MySQL 에서 특히 조심해야 해요. 하나는 있는데 뜻이 다르고, 하나는 아예 없거든요.
먼저 문자열 잇기예요. Oracle 에선 nickname || '님' 처럼 || 로 글자를 이어 붙였죠. MySQL 에도 || 기호가 있긴 한데, 여기선 글자를 잇는 게 아니라 논리 OR(또는)을 뜻해요. 그래서 MySQL 에서 nickname || '님' 을 적으면, 글자가 이어지는 대신 두 값을 참/거짓으로 따져서 엉뚱한 숫자가 나와요.
-- sql/migration/oracle_to_mysql/06_queries_f2_mysql.sql
-- Before(Oracle): nickname || '님' → '김재훈님' (연결)
SELECT nickname,
(nickname || '님') AS pipe_result, -- 0 (MySQL 에선 || 가 OR!)
CONCAT(nickname, '님') AS concat_result -- 김재훈님
FROM member WHERE member_id IN (1, 2) ORDER BY member_id;
nickname || '님' 의 결과가 김재훈님 이 아니라 0 으로 나왔죠. MySQL 이 || 를 OR 로 해석해서, 글자 '김재훈' 과 '님' 을 각각 거짓으로 보고 "거짓 또는 거짓 = 0" 을 돌려준 거예요. MySQL 에서 글자를 이으려면 반드시 CONCAT 을 써야 해요. CONCAT(nickname, '님') 이라야 김재훈님 이 나와요. Oracle 의 || 가 MySQL 에선 CONCAT 이라고 짝지어 두세요.
다음은 조건 분기예요. C-3 에서 Oracle 의 DECODE 를 배웠죠. DECODE(MOD(member_id,2), 0, '짝수회원', '홀수회원') 처럼 값을 짝지어 고르는 함수였어요. 그런데 DECODE 는 Oracle 전용 이라 MySQL 엔 아예 없어요. 대신 우리가 함께 배운 표준 CASE 를 쓰면 돼요. CASE 는 Oracle·MySQL 어디서나 통하는 표준어니까요. MySQL 만의 짧은 IF 함수도 있고요.
-- Before(Oracle): DECODE(MOD(member_id,2), 0, '짝수회원', '홀수회원')
SELECT member_id,
CASE MOD(member_id, 2) WHEN 0 THEN '짝수회원' ELSE '홀수회원' END AS by_case,
IF(MOD(member_id, 2) = 0, '짝수회원', '홀수회원') AS by_if
FROM member ORDER BY member_id; -- 짝수 6명 · 홀수 6명
CASE 와 IF 둘 다 같은 결과를 내요. 짝수 회원 6명, 홀수 회원 6명으로 갈리죠. CASE 는 표준이라 어디서나 통하고, IF 는 MySQL 사투리라 짧게 쓰기 좋아요. 옮길 일을 생각하면 CASE 가 안전한 기본값이에요.
⚠️ MySQL 의
||는 문자열 연결이 아니라 논리 OR 다. 글자를 이으려면CONCAT을 써야 한다. 그리고DECODE는 Oracle 전용이라 MySQL 엔 없으니 표준CASE(또는IF)로 옮긴다. ★빈출
💡 한 줄 정리 — 문자열 잇기는 Oracle
||→ MySQLCONCAT(MySQL 의||는 OR). 조건 분기는 OracleDECODE→ 표준CASE(또는 MySQLIF).
🙋 학생 질문 — "튜터님, MySQL 에서도 || 를 연결로 쓰게 바꿀 수 있나요?"
바꿀 수는 있어요. MySQL 에는 동작 방식을 정하는 sql_mode 라는 설정이 있는데, 거기에 PIPES_AS_CONCAT 을 켜면 || 가 연결로 동작해요. 하지만 추천하진 않아요. 그 설정은 서버 전체나 연결 단위로 동작을 바꾸는 거라, 같은 쿼리가 설정에 따라 다르게 돌아가는 혼란을 만들거든요. 다른 사람이 내 쿼리를 자기 환경에서 돌리면 결과가 달라질 수 있어요. 그냥 처음부터 CONCAT 을 쓰는 게 안전하고, 읽는 사람도 "아, 글자를 잇는구나" 하고 바로 알아요. 설정에 기대지 말고 함수로 분명하게 적는 습관이 좋아요.
Step 4: "한쪽이 비어도 다 보여주기 — (+) vs ANSI JOIN"
C-5 에서 외부 조인(OUTER JOIN)을 배웠죠. 한쪽 테이블에 짝이 없어도 그 행을 살려 두는 조인이요. 그때 표준 ANSI 문법(LEFT JOIN ... ON)과 Oracle 만의 옛 문법인 (+) 기호를 같이 봤어요. 이 둘을 MySQL 로 옮기면 어떻게 될까요?
좋은 소식이 있어요. 우리가 평소에 쓰던 ANSI JOIN(LEFT JOIN·INNER JOIN)은 MySQL 에서도 그대로 통해요. 표준어라서요. 문제는 Oracle 만의 (+) 예요. 이건 MySQL 에 아예 없어요. 그래서 (+) 로 짠 외부 조인은 MySQL 로 옮길 때 반드시 ANSI LEFT JOIN 으로 고쳐 써야 해요.
글이 하나도 없는 회원까지 포함해서, 회원별 게시물을 이어 볼게요. C-5 에서 본 그 쿼리예요.
-- sql/migration/oracle_to_mysql/06_queries_f2_mysql.sql
-- Before(Oracle): FROM member m, post p WHERE m.member_id = p.member_id(+)
SELECT COUNT(*) AS left_join_rows -- 53
FROM member m LEFT JOIN post p ON m.member_id = p.member_id;
결과는 53행 이에요. 게시물 50개에, 글이 하나도 없는 회원 3명(7·8·10)이 짝 없이도 살아남아서 더해진 거죠. Oracle 에서 (+) 로 짜도 똑같이 53행이 나와요. 두 문법이 같은 일을 한 거예요. 글 없는 회원이 누군지도 그대로 확인돼요.
-- 글이 하나도 없는 회원도 LEFT JOIN 으로 살아남는다 → 윤태양·한나래·이민수
SELECT m.member_id, m.nickname, COUNT(p.post_id) AS post_cnt
FROM member m LEFT JOIN post p ON m.member_id = p.member_id
GROUP BY m.member_id, m.nickname
HAVING COUNT(p.post_id) = 0
ORDER BY m.member_id;
게시물 0개 회원 7(윤태양)·8(한나래)·10(이민수)이 그대로 잡혀요. C-5 에서 봤던 그 경계 회원들이죠.
Oracle (+) ANSI LEFT JOIN
FROM m, p FROM m LEFT JOIN p
WHERE m.id = p.id(+) ON m.id = p.id
(+) 붙은 p 쪽은 '없어도 됨' LEFT 쪽(m)을 다 살림
MySQL 엔 (+) 가 없음 두 방언 다 통하는 표준
한 가지 더 짚을 게 있어요. C-5 에서 양쪽을 다 살리는 FULL OUTER JOIN 도 봤죠. 그런데 MySQL 은 FULL OUTER JOIN 도 없어요. MySQL 에서 양쪽을 다 살리려면 LEFT JOIN 결과와 RIGHT JOIN 결과를 UNION 으로 합쳐서 흉내 내요. Oracle 의 (+) 가 한쪽 방향만 되던 것처럼, MySQL 도 외부 조인엔 나름의 한계가 있는 셈이에요.
⚠️ Oracle 의
(+)외부 조인 문법은 MySQL 에 없다. 반드시 ANSILEFT/RIGHT JOIN ... ON으로 옮긴다. MySQL 은FULL OUTER JOIN도 없어서LEFT ∪ RIGHT(UNION)로 흉내 낸다.
💡 한 줄 정리 — ANSI JOIN(
LEFT/INNER ... ON)은 두 방언 다 통한다. Oracle 전용(+)는 MySQL 에 없으니 ANSILEFT JOIN으로 옮긴다. 그래서 처음부터 표준 ANSI 로 짜 두면 옮길 게 없다.
🙋 학생 질문 — "튜터님, 그럼 Oracle 에서도 처음부터 (+) 말고 LEFT JOIN 으로 짜는 게 낫겠네요?"
맞아요. 실무에선 Oracle 에서도 (+) 보다 ANSI LEFT JOIN 을 권해요. 이유가 둘이에요. 첫째, 오늘처럼 다른 데이터베이스로 옮길 때 ANSI 로 짜 뒀으면 고칠 게 없어요. (+) 로 짜 뒀으면 전부 손봐야 하고요. 둘째, (+) 는 조건이 복잡해지면 어느 쪽을 살리는 건지 읽기 어렵고, 표현하지 못하는 조인(예: FULL OUTER)도 있어요. (+) 는 Oracle 의 오래된 문법이라 옛날 코드에서 자주 마주치지만, 새로 짤 땐 ANSI JOIN 이 표준이고 더 명확해요. SQLD 에서도 "(+) 를 ANSI 로 바꾸면?" 을 자주 물으니, 둘을 짝지어 읽는 눈을 길러 두세요.
Step 5: "대댓글 트리 — CONNECT BY vs WITH RECURSIVE"
오늘의 가장 큰 산이에요. D-3 에서 대댓글 트리를 따라 내려가는 계층형 질의를 배웠죠. Oracle 의 START WITH·CONNECT BY 로요. 그런데 이 CONNECT BY 는 Oracle 만의 강력한 사투리예요. MySQL 엔 없어요. MySQL 은 같은 일을 표준 문법인 재귀 CTE(WITH RECURSIVE)로 해요.
잠깐 개념부터요. CTE(Common Table Expression)는 D-2 에서 본 WITH 절, 즉 "쿼리 안에서 잠깐 쓰는 이름 붙인 임시 결과" 였죠. 재귀 CTE 는 그 임시 결과가 자기 자신을 다시 불러서 한 단계씩 더 내려가는 거예요. 루트 댓글에서 시작해서, 그 자식을 찾고, 또 그 자식의 자식을 찾고... 트리 끝까지 반복하는 거죠. CONNECT BY 가 한 줄로 하던 일을, 재귀 CTE 는 "시작점 + 한 단계 내려가는 규칙" 두 부분으로 나눠서 적어요.
게시물 1 의 대댓글 트리(깊이 4)를 MySQL 로 펼쳐 볼게요.
-- sql/migration/oracle_to_mysql/06_queries_f2_mysql.sql
-- Before(Oracle): START WITH parent_comment_id IS NULL
-- CONNECT BY PRIOR comment_id = parent_comment_id
WITH RECURSIVE comment_tree AS (
SELECT comment_id, parent_comment_id, content, 1 AS lv,
CAST(LPAD(comment_id, 5, '0') AS CHAR(200)) AS path
FROM post_comment
WHERE post_id = 1 AND parent_comment_id IS NULL -- 시작점(루트)
UNION ALL
SELECT c.comment_id, c.parent_comment_id, c.content, ct.lv + 1,
CONCAT(ct.path, '/', LPAD(c.comment_id, 5, '0'))
FROM post_comment c
JOIN comment_tree ct ON c.parent_comment_id = ct.comment_id -- 부모-자식 연결(재귀)
WHERE c.post_id = 1
)
SELECT lv, CONCAT(REPEAT(' ', lv - 1), content) AS tree
FROM comment_tree
ORDER BY path;
UNION ALL 의 위가 시작점(루트 댓글, parent_comment_id IS NULL)이고, 아래가 한 단계 내려가는 규칙(자기 자신인 comment_tree 를 다시 조인)이에요. 이 구조가 START WITH(시작점)와 CONNECT BY(연결 규칙)에 정확히 대응해요. 결과는 게시물 1 의 댓글 7건, 가장 깊은 곳이 4단계예요.
게시물 1 댓글 트리 (깊이 4)
[1] 첫 게시물 축하해요! (루트)
└ [2] 감사합니다 민지님
└ [3] 별말씀을요 ㅎㅎ
└ [4] 앞으로 자주 올게요! ← 깊이 4 · 잎
└ [2] 민지님 말 맞아요 ㅎㅎ
[1] 시작이 좋네요 ㅎㅎ
[1] 잘 보고 가요
옮기면서 손볼 데가 세 군데 있어요. 첫째, WITH 뒤에 RECURSIVE 를 꼭 붙여요. Oracle 은 WITH 만 써도 재귀인 걸 알아서 추론하지만, MySQL 은 WITH RECURSIVE 라고 분명히 적어 줘야 해요. 둘째, Oracle 의 LEVEL 의사컬럼(깊이를 알려주던 자동 컬럼)이 MySQL 엔 없어요. 그래서 lv 라는 컬럼을 직접 두고 한 단계 내려갈 때마다 +1 로 키워요. 셋째, 깊이 우선 순서를 맞추는 방법이 달라요.
세 번째를 조금 더 볼게요. D-3 에서 트리 순서를 보존하려고 ORDER SIBLINGS BY 를 썼죠. MySQL 엔 그게 없어요. 대신 위 쿼리처럼 부모의 경로 뒤에 자기 번호를 이어 붙인 path 문자열 을 만들고, 마지막에 ORDER BY path 로 정렬해요. 00001 → 00001/00003 → 00001/00003/00017 처럼 경로가 쌓이니, 이 글자 순서로 정렬하면 부모 바로 밑에 자식이 오는 깊이 우선 순서가 자연스럽게 맞아요. 번호를 LPAD 로 0 채워 길이를 맞춘 건, 글자 정렬이 숫자 정렬과 어긋나지 않게 하려는 장치예요.
⚠️ MySQL 엔
CONNECT BY가 없다.WITH RECURSIVE(키워드RECURSIVE필수)로 옮긴다.LEVEL은 직접 키운lv컬럼으로,ORDER SIBLINGS BY(깊이 우선)는 경로 문자열을 만들어ORDER BY path로 대신한다. ★빈출
💡 한 줄 정리 — 계층형은 Oracle
CONNECT BY→ 표준WITH RECURSIVE.UNION ALL위가 시작점, 아래가 재귀 규칙. 깊이는lv +1, 깊이 우선 순서는path정렬로 맞춘다.
🙋 학생 질문 — "튜터님, 재귀 CTE 가 무한히 돌면 어떡해요?"
좋은 걱정이에요. 데이터에 순환(A 의 부모가 B 이고, B 의 부모가 다시 A)이 있으면 재귀가 끝없이 돌 수 있어요. 그래서 안전장치가 있어요. MySQL 은 cte_max_recursion_depth 라는 설정으로 재귀 깊이의 상한을 두는데, 기본이 1000 단계예요. 그 이상 내려가면 에러로 멈춰서 서버가 폭주하진 않아요. D-3 에서 Oracle 은 NOCYCLE 로 순환을 건너뛰었던 것 기억하시죠? 목적은 같아요. "끝없이 도는 사고를 막는다" 는 거죠. 우리 댓글 데이터는 부모가 항상 자기보다 먼저 달린 댓글이라 순환이 없어서 깔끔하게 4단계에서 멈춰요. 실무에선 트리가 너무 깊어지지 않게 데이터를 설계하는 것도 함께 신경 써요.
Step 6: "태그를 한 줄로 — LISTAGG vs GROUP_CONCAT"
마지막 함수예요. D-6 에서 한 게시물의 해시태그 여러 개를 '여행, 제주, 바다' 처럼 한 줄 문자열로 모으는 LISTAGG 를 배웠죠. 여러 행을 한 칸으로 합치는 집계였어요. MySQL 은 같은 일을 GROUP_CONCAT 으로 해요. 이름은 다르지만 하는 일은 같아요.
게시물별 해시태그를 한 줄로 모아 볼게요.
-- sql/migration/oracle_to_mysql/06_queries_f2_mysql.sql
-- Before(Oracle): LISTAGG(h.name, ', ') WITHIN GROUP (ORDER BY h.hashtag_id)
SELECT p.post_id,
GROUP_CONCAT(h.name ORDER BY h.hashtag_id SEPARATOR ', ') AS tags
FROM post p
JOIN post_hashtag ph ON p.post_id = ph.post_id
JOIN hashtag h ON ph.hashtag_id = h.hashtag_id
WHERE p.post_id IN (1, 12, 40)
GROUP BY p.post_id
ORDER BY p.post_id; -- post 12 → '여행, 제주, 바다'
게시물 12 의 태그가 여행, 제주, 바다 로 한 줄에 모였어요. D-6 의 LISTAGG 와 똑같은 결과죠. 두 함수의 문법을 짝지어 보면 이래요. LISTAGG 의 WITHIN GROUP (ORDER BY ...) 가 GROUP_CONCAT 에선 함수 괄호 안 의 ORDER BY ... 로 들어가요. 그리고 구분자는 LISTAGG 가 두 번째 인자로 받지만, GROUP_CONCAT 은 끝에 SEPARATOR ', ' 로 적어요. 이걸 빼면 기본 구분자가 공백 없는 쉼표(,)라 여행,제주,바다 처럼 붙어 나와요.
NULL 도 D-6 과 똑같이 동작해요. 태그가 하나도 없는 게시물을 LEFT JOIN 으로 끌어오면, 그 게시물의 GROUP_CONCAT 결과는 NULL 이에요. 그래서 C-3 에서 배운 COALESCE 로 메워 줘요.
-- 태그 없는 게시물은 GROUP_CONCAT 도 NULL → COALESCE 로 '태그 없음'
SELECT p.post_id,
COALESCE(GROUP_CONCAT(h.name ORDER BY h.name SEPARATOR ', '), '태그 없음') AS tags
FROM post p
LEFT JOIN post_hashtag ph ON p.post_id = ph.post_id
LEFT JOIN hashtag h ON ph.hashtag_id = h.hashtag_id
GROUP BY p.post_id
ORDER BY p.post_id;
태그가 없는 게시물 22개가 태그 없음 으로 채워져요. LISTAGG 가 개별 NULL 은 건너뛰고 그룹 전체가 비었을 때만 NULL 을 내던 그 동작이, GROUP_CONCAT 에서도 똑같아요.
GROUP_CONCAT 엔 한 가지 조심할 함정이 있어요. 모은 문자열이 기본 1024 바이트 를 넘으면 거기서 잘려요. 태그 몇 개야 괜찮지만, 한 그룹에 수백 개를 모으면 뒤가 소리 없이 잘려 나가요. 한글은 한 글자가 3바이트라 더 빨리 차고요. 길게 모아야 하면 SET SESSION group_concat_max_len = 100000; 처럼 상한을 미리 늘려요.
SELECT @@group_concat_max_len AS gc_max_len; -- 1024 (기본값)
⚠️
GROUP_CONCAT은 기본 1024 바이트에서 결과가 잘린다(에러 없이 조용히). 길게 모을 땐group_concat_max_len을 늘려야 한다. OracleLISTAGG도 한도(보통 4000 바이트)를 넘으면 에러가 나니, 둘 다 길이 한도가 있다는 걸 기억한다.
💡 한 줄 정리 — 여러 행을 한 줄 문자열로는 Oracle
LISTAGG→ MySQLGROUP_CONCAT.WITHIN GROUP (ORDER BY)는 괄호 안ORDER BY, 구분자는SEPARATOR. 기본 1024 바이트 한도에 주의한다.
🙋 학생 질문 — "튜터님, 태그를 한 줄로 모으면 보기엔 좋은데 실무에서도 이렇게 저장해요?"
보여줄 땐 한 줄이 편하지만, 저장은 한 줄로 하지 않아요. 우리 데이터도 태그를 post_hashtag 라는 별도 표에 한 줄에 하나씩(정규화) 담아 뒀죠. GROUP_CONCAT 은 그걸 화면에 보여줄 때만 잠깐 한 줄로 모으는 거예요. 만약 처음부터 '여행, 제주, 바다' 라는 한 덩어리 문자열로 저장하면, "여행 태그가 달린 게시물 찾기" 같은 검색이 어려워지고(문자열을 잘라 뒤져야 하니), 태그 하나만 지우기도 까다로워져요. B 카테고리에서 배운 정규화의 이유와 같아요. "저장은 잘게 나눠서, 보여줄 땐 모아서" 가 원칙이에요. GROUP_CONCAT·LISTAGG 는 그 "보여줄 때" 를 담당하는 도구예요.
Step 7: "통째로 옮기고 다음 과목으로 — 변환 한 장 정리"
오늘과 지난 시간에 옮긴 방언을 한 장으로 모아 볼게요. Oracle 로 짠 인스타 쿼리를 MySQL 로 옮길 때 손보는 데가 결국 이 표 하나로 정리돼요. 시험 직전이든 실무에서 변환할 때든, 이 표만 떠올리면 길을 잃지 않아요.
| 하는 일 | Oracle | MySQL |
|---|---|---|
| 가변 문자 | VARCHAR2(n) |
VARCHAR(n) |
| 날짜+시각 | DATE |
DATETIME |
| 자동 채번 | 시퀀스 NEXTVAL |
AUTO_INCREMENT |
| 행 제한 | ROWNUM/FETCH FIRST |
LIMIT n OFFSET m |
| NULL 대체 | NVL |
IFNULL/COALESCE |
| 빈 문자열 | '' = NULL |
'' ≠ NULL |
| 날짜 포맷 | TO_CHAR |
DATE_FORMAT |
| 문자열 연결 | || |
CONCAT |
| 조건 분기 | DECODE |
CASE/IF |
| 외부 조인 | (+) |
LEFT/RIGHT JOIN |
| 계층형 | CONNECT BY |
WITH RECURSIVE |
| 집계 문자열 | LISTAGG |
GROUP_CONCAT |
윗줄 다섯 개는 지난 시간(F-1)에, 아랫줄 일곱 개는 오늘 옮긴 거예요. 이 표만큼은 익혀 두면, 어떤 인스타 쿼리가 와도 한 줄씩 짚어 가며 옮길 수 있어요. 핵심은 "표준어(CONCAT·COALESCE·CASE·ANSI JOIN·WITH RECURSIVE)로 짜 두면 옮길 게 적다" 는 거예요. 사투리(DECODE·(+)·CONNECT BY)로 짜면 옮길 때마다 손이 가고요.
마지막으로 다리를 하나 놓을게요. 지난 시간에 자동 채번이 시퀀스냐 AUTO_INCREMENT 냐로 갈린다고 했죠. 이 차이는 다음 백엔드 과목에서 다시 만나요. 거기선 테이블의 자동 채번을 프로그램 객체의 식별자 전략으로 연결하는데, AUTO_INCREMENT 를 쓰면 @GeneratedValue 의 IDENTITY 전략, 시퀀스를 쓰면 SEQUENCE 전략으로 이어져요. 오늘 두 방언의 채번 차이를 잡아 둔 게, 다음 과목의 그 선택을 한결 쉽게 만들어 줘요.
💡 한 줄 정리 — 자료형·채번·행 제한·NULL·빈 문자열·날짜 포맷·연결·조건·외부 조인·계층형·집계, 이 열두 가지만 방언에 맞게 손보면 Oracle 인스타 쿼리를 MySQL 로 통째로 옮길 수 있다. 표준어로 짜 두면 옮길 게 적다.
🙋 학생 질문 — "튜터님, 그럼 인스타를 두 데이터베이스로 동시에 돌릴 수도 있어요?"
기술적으로는 같은 스키마를 두 데이터베이스에 둘 수 있지만, 보통 한 서비스는 하나를 골라 써요. 두 개를 동시에 운영하면 데이터를 양쪽에 맞춰 두는 일이 아주 번거롭거든요. 그럼 오늘 한 일은 뭐냐면, "이 서비스가 Oracle 에서 MySQL 로 갈아타야 한다" 거나 "회사를 옮겼더니 MySQL 을 쓴다" 같은 상황에서, 같은 쿼리를 막힘없이 옮기는 힘을 기른 거예요. 데이터베이스는 한 번 고르면 오래 쓰지만, 개발자는 여러 회사·여러 프로젝트를 거치며 둘 다 만나요. 그래서 "옮기는 눈" 이 자산이 되는 거죠. SQLD 는 Oracle 로 합격하고, 실무에선 MySQL 도 자유롭게 — 오늘로 두 발을 다 걸친 셈이에요.
마무리
오늘은 Oracle 로 짠 인스타 쿼리를 MySQL 8.4 로 통째로 옮겼어요. 빈 문자열과 NULL 의 미묘한 차이부터 시작해서, 날짜 포맷(DATE_FORMAT), 문자열 연결(CONCAT)과 조건 분기(CASE), 외부 조인(ANSI JOIN), 대댓글 트리(WITH RECURSIVE), 해시태그 집계(GROUP_CONCAT)까지요. 지난 시간 자료형과 채번을 옮긴 데 이어, 함수·조인·계층형이라는 더 까다로운 대목을 마저 옮겨서 인스타 쿼리를 MySQL 로 완전히 가져왔어요.
오늘 배운 핵심 세 가지
- 💡 빈 문자열 함정 — Oracle 은
''를 NULL 로, MySQL 은 빈 문자열로 본다.IS NULL만으로 빈 값을 세면 두 방언에서 결과가 갈린다. - 💡 함수·조인 방언 —
TO_CHAR→DATE_FORMAT(분은%i),||→CONCAT(||는 OR),DECODE→CASE,(+)→ANSILEFT JOIN. 표준어로 짜 두면 옮길 게 적다. - 💡 계층형·집계 —
CONNECT BY→WITH RECURSIVE(시작점 + 재귀 규칙),LISTAGG→GROUP_CONCAT(SEPARATOR·1024바이트 한도).
다음 시간 예고
이렇게 F 카테고리에서 같은 인스타그램을 Oracle 과 MySQL 두 방언으로 자유롭게 오가는 힘을 길렀어요. 그런데 데이터베이스는 지금도 계속 진화하고 있어요. 다음 시간엔 마지막으로, 요즘 데이터베이스가 어디까지 왔는지를 둘러봐요. 표(행과 열)에 딱 맞지 않는 데이터를 담는 JSON 컬럼, 그리고 SQL 표준(SQL:2023)에 새로 들어온 기능들, 나아가 이미지나 문장을 숫자 벡터로 바꿔 "비슷한 것" 을 찾는 벡터 검색까지요. 우리가 배운 관계형 SQL 이 모던 세계에서 어떻게 확장되는지를 보면서, 데이터베이스 과목을 마무리해요.
과제
오늘 옮긴 방언을 직접 손으로 다뤄 보는 과제예요. 쿼리를 실행하기 전에 결과를 먼저 예측한 뒤, MySQL 에서 실행해 맞혀 보세요.
[기초] 빈 문자열과 날짜 포맷 옮기기
(가) 자기소개(bio)가 비어 있는 회원을 MySQL 에서 세어 보세요. IS NULL 만 썼을 때와 IS NULL OR bio = '' 를 썼을 때 결과가 같은지 확인하고, 왜 Oracle 에선 둘이 항상 같은지 한 줄로 적으세요. (나) 게시물의 작성일을 2026년 06월 01일 모양으로 출력하는 쿼리를, Oracle TO_CHAR 와 MySQL DATE_FORMAT 두 가지로 각각 적으세요. (다) 작성 시각을 시:분:초 로 뽑되, 분을 %i 와 %m 으로 각각 적어 보고 결과가 어떻게 달라지는지 확인하세요.
[응용] 외부 조인과 집계 옮기기
(가) 글이 하나도 없는 회원까지 포함해 회원별 게시물 수를 MySQL LEFT JOIN 으로 구하세요. 같은 쿼리를 Oracle (+) 로는 어떻게 적을지 나란히 비교하고, 두 결과 행 수가 같은지 확인하세요. (나) 게시물별 해시태그를 GROUP_CONCAT 으로 한 줄로 모으되, 태그가 없는 게시물은 태그 없음 으로 나오게 COALESCE 를 씌우세요. (다) 같은 집계를 Oracle LISTAGG 로 적으면 어디가 달라지는지(구분자·정렬 위치) 한 줄로 정리하세요.
[심화] 대댓글 트리 직접 변환하기
(가) 게시물 1 의 대댓글 트리를 MySQL WITH RECURSIVE 로 펼쳐, 들여쓰기된 트리와 각 댓글의 깊이(lv)를 함께 출력하세요. (나) 같은 트리를 Oracle CONNECT BY 로 적을 때와 비교해, MySQL 로 옮기며 손본 세 가지(RECURSIVE 키워드·LEVEL 대체·깊이 우선 정렬)를 각각 한 줄로 설명하세요. (다) 만약 ORDER BY path 를 빼면 결과 순서가 어떻게 흐트러질지 예측해 보고, 왜 경로 문자열로 정렬해야 트리 모양이 보존되는지 생각해 보세요.
생각해볼 주제
1. 표준어로 짤까, 그 데이터베이스 사투리로 짤까
오늘 본 것처럼 CONCAT·CASE·COALESCE·ANSI JOIN 은 어디서나 통하는 표준어이고, DECODE·(+)·CONNECT BY 는 Oracle 사투리예요. 표준어로만 짜면 옮길 때 편하지만, 사투리가 더 짧거나 그 데이터베이스에 최적화돼 있을 때도 있어요. "이 서비스는 앞으로도 이 데이터베이스만 쓴다" 와 "언젠가 옮길지 모른다" 중 무엇을 가정하느냐에 따라 선택이 갈려요. 두 경우에 각각 어떤 선택이 합리적일지 견줘 보세요.
2. 빈 문자열을 NULL 로 볼 것인가, 다른 값으로 볼 것인가
Oracle 은 빈 문자열을 NULL 로 보고, MySQL 은 다른 값으로 봐요. 둘 다 나름의 일관된 철학이에요. "값이 없다는 건 빈칸이나 미입력이나 결국 같다" 는 쪽(Oracle)과 "빈칸도 엄연히 입력된 값이다" 는 쪽(MySQL)이죠. 어느 쪽이 데이터를 다루기에 더 헷갈림이 적을지, 그리고 이 차이가 두 데이터베이스를 오갈 때 왜 위험한 함정이 되는지 생각해 보세요.
3. 계층형 데이터는 데이터베이스에서 풀까, 애플리케이션에서 풀까
대댓글 트리 같은 계층형 데이터를 CONNECT BY·WITH RECURSIVE 로 데이터베이스에서 직접 펼칠 수도 있고, 평평한 댓글 목록만 가져온 뒤 프로그램 코드에서 트리로 조립할 수도 있어요. 데이터베이스에서 풀면 쿼리 한 번에 끝나지만 재귀 비용이 들고, 애플리케이션에서 풀면 데이터를 단순하게 가져오는 대신 조립 코드가 필요해요. 댓글이 얕을 때와 아주 깊을 때 각각 어느 쪽이 유리할지 떠올려 보세요.
✅ 예시 답안정답 보기
과제와 생각해볼 주제의 예시답안이에요. 정답을 그대로 베끼기보다 "이 Oracle 문법을 MySQL 로 어떻게 옮기는가", "두 방언의 결과가 정말 같은가(혹은 어디서 갈리는가)" 를 떠올리는 감각을 기르는 게 목표예요.
🎯 [과제 1 예시답안] 빈 문자열과 날짜 포맷 옮기기
채점 포인트
| 항목 | 배점 | 핵심 |
|---|---|---|
(가) IS NULL vs IS NULL OR bio = '' |
40% | MySQL 에선 갈릴 수 있고, Oracle 은 항상 같음 |
(나) TO_CHAR↔DATE_FORMAT |
35% | YYYY-MM-DD↔%Y-%m-%d |
(다) %i vs %m |
25% | 분은 %i, %m 은 월 |
풀이 예시
(가) 빈 자기소개 세기 — 두 조건이 같을까
-- MySQL
SELECT COUNT(*) AS is_null_only FROM member WHERE bio IS NULL; -- 4
SELECT COUNT(*) AS is_null_or_empty FROM member WHERE bio IS NULL OR bio = ''; -- 4 (지금 데이터 기준)
지금 우리 데이터엔 빈 문자열('')로 들어간 자기소개가 없고 전부 NULL 이라, 두 쿼리 다 4명으로 같아요. 그런데 누군가 자기소개를 ''(빈 문자열)로 넣으면 이야기가 달라져요. MySQL 에선 '' 가 NULL 이 아니라서, IS NULL 은 그대로 4명이지만 IS NULL OR bio = '' 는 5명이 돼요. 반면 Oracle 은 '' 를 NULL 로 저장하니, 두 조건이 항상 똑같이 5명을 세요. 그래서 Oracle 에선 bio = '' 를 굳이 더할 필요가 없고, MySQL 에선 빈 문자열까지 잡으려면 두 조건을 같이 써야 해요.
(나) 날짜를 한글 모양으로
-- Oracle
SELECT TO_CHAR(created_at, 'YYYY년 MM월 DD일') AS kor FROM post WHERE post_id = 1;
-- MySQL
SELECT DATE_FORMAT(created_at, '%Y년 %m월 %d일') AS kor FROM post WHERE post_id = 1;
둘 다 첫 게시물의 작성일을 2026년 06월 01일 로 출력해요. Oracle 은 YYYY·MM·DD, MySQL 은 %Y·%m·%d 로 적는다는 차이만 있어요.
(다) 분을 %i 와 %m 으로
SELECT DATE_FORMAT('2026-06-01 13:45:30', '%H:%i:%s') AS correct, -- 13:45:30
DATE_FORMAT('2026-06-01 13:45:30', '%H:%m:%s') AS wrong; -- 13:06:30
%i 로 적으면 분(45)이 제대로 나오지만, %m 으로 적으면 분 대신 6월의 06 이 찍혀 13:06:30 이 돼요. MySQL 에서 분은 %i, %m 은 월이라는 걸 보여주는 결과예요.
💡 튜터의 한마디 — 빈 문자열과 NULL 은 "비슷해 보여서 더 위험한" 함정이에요. Oracle 버릇대로 MySQL 에 IS NULL 만 쓰면 빈 문자열로 들어온 데이터가 슬쩍 빠져요. 날짜 포맷은 %i(분) 하나만 기억해도 큰 사고는 피해요.
🎯 [과제 2 예시답안] 외부 조인과 집계 옮기기
채점 포인트
| 항목 | 배점 | 핵심 |
|---|---|---|
(가) LEFT JOIN 회원별 게시물 수 + (+) 비교 |
40% | 글 0 회원도 살림(53행) |
(나) GROUP_CONCAT + COALESCE |
35% | 태그 없는 게시물 메우기 |
(다) LISTAGG↔GROUP_CONCAT 차이 |
25% | 정렬 위치·구분자 |
풀이 예시
(가) 글 없는 회원까지 포함한 회원별 게시물 수
-- MySQL (ANSI LEFT JOIN)
SELECT m.member_id, m.nickname, COUNT(p.post_id) AS post_cnt
FROM member m LEFT JOIN post p ON m.member_id = p.member_id
GROUP BY m.member_id, m.nickname
ORDER BY m.member_id;
-- Oracle 옛 문법 (+) — 같은 결과
-- SELECT m.member_id, COUNT(p.post_id)
-- FROM member m, post p
-- WHERE m.member_id = p.member_id(+)
-- GROUP BY m.member_id;
결과는 12명 전부 나오고, 합계 행 수는 53(게시물 50 + 글 0 회원 3명)이에요. 글이 하나도 없는 회원 7(윤태양)·8(한나래)·10(이민수)도 post_cnt = 0 으로 살아남아요. INNER JOIN 이면 이 3명이 사라지지만, LEFT JOIN 은 왼쪽(member)을 다 살리니까요. Oracle (+) 로 짜도 똑같이 53행이 나와요.
(나) 해시태그를 한 줄로, 없으면 "태그 없음"
SELECT p.post_id,
COALESCE(GROUP_CONCAT(h.name ORDER BY h.hashtag_id SEPARATOR ', '), '태그 없음') AS tags
FROM post p
LEFT JOIN post_hashtag ph ON p.post_id = ph.post_id
LEFT JOIN hashtag h ON ph.hashtag_id = h.hashtag_id
GROUP BY p.post_id
ORDER BY p.post_id;
게시물 1은 안녕, 일상, 첫게시물, 12는 여행, 제주, 바다 로 모이고, 태그가 없는 게시물 3·7 은 태그 없음 으로 채워져요. GROUP_CONCAT 은 그룹 전체가 비면 NULL 을 내는데, COALESCE 로 그 NULL 을 메운 거예요.
(다) LISTAGG 와 무엇이 다른가
같은 집계를 Oracle 은 LISTAGG(h.name, ', ') WITHIN GROUP (ORDER BY h.hashtag_id) 로 적어요. 두 가지가 달라요. 첫째, 정렬을 LISTAGG 은 WITHIN GROUP (ORDER BY ...) 라는 별도 절에 적지만, GROUP_CONCAT 은 함수 괄호 안에 ORDER BY ... 로 넣어요. 둘째, 구분자를 LISTAGG 은 두 번째 인자(, )로, GROUP_CONCAT 은 끝의 SEPARATOR ', ' 로 적어요. SEPARATOR 를 빼면 기본 구분자가 공백 없는 쉼표라 여행,제주,바다 처럼 붙어요.
💡 튜터의 한마디 — ANSI JOIN(LEFT JOIN ... ON)으로 짜 두면 Oracle·MySQL 어디서나 그대로 통해요. Oracle (+) 는 옛 문법이라 옮길 때 손이 가고요. 집계 문자열도 "정렬과 구분자를 어디에 적느냐" 만 다르지, 하는 일은 같다는 걸 기억하세요.
🎯 [과제 3 예시답안] 대댓글 트리 직접 변환하기
채점 포인트
| 항목 | 배점 | 핵심 |
|---|---|---|
(가) WITH RECURSIVE 로 트리 펼치기 |
45% | 시작점 + 재귀 규칙, 깊이 lv |
(나) CONNECT BY 대비 손본 3가지 |
35% | RECURSIVE·LEVEL·정렬 |
(다) ORDER BY path 를 빼면 |
20% | 트리 순서가 깨짐 |
풀이 예시
(가) 게시물 1 의 댓글 트리
WITH RECURSIVE comment_tree AS (
SELECT comment_id, parent_comment_id, content, 1 AS lv,
CAST(LPAD(comment_id, 5, '0') AS CHAR(200)) AS path
FROM post_comment
WHERE post_id = 1 AND parent_comment_id IS NULL
UNION ALL
SELECT c.comment_id, c.parent_comment_id, c.content, ct.lv + 1,
CONCAT(ct.path, '/', LPAD(c.comment_id, 5, '0'))
FROM post_comment c
JOIN comment_tree ct ON c.parent_comment_id = ct.comment_id
WHERE c.post_id = 1
)
SELECT lv, CONCAT(REPEAT(' ', lv - 1), content) AS tree
FROM comment_tree
ORDER BY path;
게시물 1 의 댓글 7건이, 들여쓰기된 트리로 나와요. 가장 깊은 "앞으로 자주 올게요!" 가 lv = 4 예요.
lv 1 첫 게시물 축하해요!
lv 2 감사합니다 민지님
lv 3 별말씀을요 ㅎㅎ
lv 4 앞으로 자주 올게요!
lv 2 민지님 말 맞아요 ㅎㅎ
lv 1 시작이 좋네요 ㅎㅎ
lv 1 잘 보고 가요
(나) CONNECT BY 에서 손본 세 가지
RECURSIVE키워드 — Oracle 은WITH만 써도 재귀인 걸 추론하지만, MySQL 은WITH RECURSIVE라고 분명히 적어야 해요.LEVEL대체 — Oracle 의 깊이 의사컬럼LEVEL이 MySQL 엔 없어서,lv컬럼을 두고 한 단계 내려갈 때마다+1로 직접 키워요.- 깊이 우선 정렬 — Oracle 의
ORDER SIBLINGS BY가 없어서, 부모 경로 뒤에 자기 번호를 이어 붙인path문자열을 만들고ORDER BY path로 깊이 우선 순서를 맞춰요.
(다) ORDER BY path 를 빼면
ORDER BY path 를 빼면 행이 재귀가 도는 순서(보통 같은 깊이끼리 묶여 나오는 순서)로 나와서, 부모 바로 밑에 자식이 오는 트리 모양이 깨져요. 자식이 부모와 멀찍이 떨어져 출력되죠. path 는 00001 → 00001/00003 → 00001/00003/00017 처럼 조상의 경로를 품고 있어서, 이 글자 순서로 정렬하면 부모 뒤에 그 자식이 자연스럽게 따라붙어요. 그래서 트리 모양이 보존돼요.
💡 튜터의 한마디 — 재귀 CTE 는 "시작점(UNION ALL 위) + 한 단계 내려가는 규칙(아래)" 두 덩어리로 보면 어렵지 않아요. CONNECT BY 가 한 줄로 하던 일을 둘로 나눠 적는 것뿐이에요. 깊이 우선 순서를 path 로 만드는 요령 하나만 익혀 두면, 어떤 트리든 표준 문법으로 펼칠 수 있어요.
생각해볼 주제
1. 표준어로 짤까, 그 데이터베이스 사투리로 짤까
문제 상황 요약
CONCAT·CASE·COALESCE·ANSI JOIN·WITH RECURSIVE 는 어디서나 통하는 표준어이고, DECODE·(+)·CONNECT BY 는 Oracle 사투리예요. 표준어로 짜면 옮길 때 편하지만, 사투리가 더 짧거나 그 데이터베이스에 최적화돼 있을 때도 있어요. 어느 쪽을 택할까요?
튜터의 가이드 및 해설
판단의 무게추는 "그 가정이 틀렸을 때 치를 대가" 예요. "이 서비스는 앞으로도 이 데이터베이스만 쓴다" 는 가정이 맞다면, 사투리로 빠르게 짜고 그 제품의 기능을 마음껏 쓰는 게 생산적일 수 있어요. 하지만 그 가정이 틀려서 갑자기 다른 데이터베이스로 옮기게 되면, DECODE·(+)·CONNECT BY 로 짠 쿼리를 전부 CASE·LEFT JOIN·WITH RECURSIVE 로 뜯어고쳐야 해요.
반대로 처음부터 표준어로 짜 뒀는데 끝까지 한 데이터베이스만 썼다면, 잃은 건 약간의 편의뿐이에요. 한쪽의 대가(전면 재작성)가 다른 쪽(사소한 편의 손실)보다 훨씬 크다는 점이 선택의 기울기를 만들어요. 그래서 불확실하면 표준이 안전한 기본값이에요.
🎯 SQLD는 이렇게 나온다
"표준(ANSI)과 제품 고유 문법을 분별하는 능력이 핵심입니다.
CONCAT·COALESCE·CASE·ANSIJOIN·WITH RECURSIVE는 표준이고,DECODE·(+)·CONNECT BY는 Oracle 고유입니다. 시험은 'Oracle(+)를 ANSI 로 바꾸면?', 'DECODE와 같은 표준 함수는?(답:CASE)' 처럼 사투리↔표준 변환을 묻습니다. 이식성을 중시하면 표준, 단일 DBMS 최적화를 중시하면 고유 문법이 유리하다는 트레이드오프까지 답할 수 있어야 합니다."
💡 실무에선
대부분의 팀은 "표준을 기본으로, 성능이 꼭 필요한 일부만 그 DB 사투리로" 라는 절충을 택해요.
2. 빈 문자열을 NULL 로 볼 것인가, 다른 값으로 볼 것인가
문제 상황 요약
Oracle 은 빈 문자열('')을 NULL 로 보고, MySQL 은 길이 0 의 다른 값으로 봐요. 둘 다 나름 일관된 철학이에요. 어느 쪽이 데이터를 다루기에 덜 헷갈릴까요? 그리고 이 차이가 왜 위험한 함정이 될까요?
튜터의 가이드 및 해설
두 철학을 먼저 봐요. Oracle 은 "값이 없는 것과 빈칸은 결국 같다" 고 봐요. 미입력이나 빈 문자열이나 "내용이 없다" 는 점에선 한 가지로 다루자는 거죠. MySQL 은 "빈칸도 엄연히 입력된 값" 이라고 봐요. 사용자가 일부러 빈 값을 넣은 것과, 아예 아무것도 안 넣은 것(NULL)은 다르다는 입장이에요.
어느 쪽이 절대적으로 옳다기보다, 각자 일관되면 그 안에서 다루기 편해요. 진짜 위험은 두 데이터베이스를 오갈 때 생겨요. Oracle 에서 '' 로 넣은 값이 NULL 로 저장됐는데, 같은 로직을 MySQL 로 옮기면 '' 가 빈 문자열로 남아 IS NULL 검사를 빠져나가요. 에러도 없이 결과 건수만 슬쩍 달라지죠. 그래서 이 차이는 "알고 있어야만 피할 수 있는" 함정이에요.
🎯 SQLD는 이렇게 나온다
"Oracle 은 빈 문자열을 NULL 로 처리하고, 다른 표준 DBMS(MySQL·PostgreSQL 등)는 빈 문자열을 NULL 과 구분합니다. 그래서 Oracle 에서
WHERE col = ''는 NULL 비교가 되어 항상 거짓이 되고,IS NULL로 잡아야 합니다. 시험은 'Oracle 에서 빈 문자열을 입력하면?(답: NULL 로 저장)' 처럼 이 동작을 묻습니다. NULL 은=·!=로 비교할 수 없고IS NULL·IS NOT NULL로만 판별한다는 원칙과 묶어 외워 두세요."
💡 실무에선
MySQL 에서 "사실상 비었다" 를 잡으려면 COALESCE(col, '') = '' 처럼 NULL 과 빈 문자열을 한 번에 다루는 패턴을 자주 써요.
3. 계층형 데이터는 데이터베이스에서 풀까, 애플리케이션에서 풀까
문제 상황 요약
대댓글 트리 같은 계층형 데이터를 WITH RECURSIVE·CONNECT BY 로 데이터베이스에서 직접 펼칠 수도 있고, 평평한 댓글 목록만 가져온 뒤 프로그램 코드에서 트리로 조립할 수도 있어요. 댓글이 얕을 때와 아주 깊을 때, 각각 어느 쪽이 유리할까요?
튜터의 가이드 및 해설
두 방식의 거래를 봐요. 데이터베이스에서 재귀 쿼리로 풀면 쿼리 한 번에 트리가 완성돼서 코드가 단순해요. 대신 재귀를 도는 비용이 데이터베이스에 실리고, 트리가 깊으면 그만큼 무거워져요. 애플리케이션에서 풀면, 댓글을 평평하게 한 번에 가져오는 단순한 쿼리(부담이 적음)만 쓰고, 부모-자식 연결은 프로그램 메모리에서 조립해요. 대신 그 조립 코드를 직접 짜야 하고요.
기준은 "트리의 깊이와 크기, 그리고 어디에 부하를 둘 여유가 있는가" 예요. 댓글이 얕고(한두 단계) 양이 적으면 재귀 쿼리 한 방이 깔끔해요. 댓글이 아주 깊거나 많고 데이터베이스가 이미 바쁘면, 단순 조회로 데이터만 가져와 애플리케이션에서 조립하는 쪽이 데이터베이스 부담을 덜어 줘요. 정답이 하나로 정해진 게 아니라, 데이터 모양과 시스템 상황에 맞춰 고르는 설계 판단이에요.
🎯 SQLD는 이렇게 나온다
"계층형 질의는 Oracle
START WITH·CONNECT BY, 표준은 재귀 CTE(WITH RECURSIVE)로 같은 트리를 만듭니다. 시험은 두 문법의 대응(PRIOR·LEVEL·SYS_CONNECT_BY_PATH가 재귀 CTE 에서 어떻게 표현되는지)과, 재귀가 순환 데이터에서 멈추지 않는 문제(NOCYCLE/ 재귀 깊이 제한)를 묻습니다. '계층형을 표준 문법으로 바꾸면?' 의 답이WITH RECURSIVE라는 걸 기억하세요."
💡 실무에선
트리가 얕으면 재귀 쿼리로 간단히, 깊거나 트래픽이 크면 평평하게 조회한 뒤 애플리케이션에서 조립하는 쪽으로 기울어요.