문자열·NULL·날짜 함수
게시글 카드의 문구와 작성부터 게시까지 걸린 시간을 함수로 만들면서, CONCAT의 NULL 전파와 CONCAT_WS의 NULL 생략, COALESCE의 대체값을 비교합니다. 바이트 길이와 문자 수, 끝 공백을 다루는 정렬 규칙, 표준 함수와 MySQL 전용 함수의 구별도 다룹니다.
게시글 카드에 "안동민 · SQL WHERE · 2026-07-13" 같은 문구를 만들 때, 재료 가운데 하나라도 NULL이면 문구 전체가 사라질 수 있습니다. 날짜의 차이도 문자열을 잘라서 계산하면 월말이나 시간대가 바뀌는 지점에서 틀립니다. 함수는 편의 문법이 아니라, 입력이 없을 때와 타입을 바꿀 때의 규칙을 함께 가진 연산입니다.
이 글에서는 회원의 이름과 게시글의 제목, 조회수, 작성 시각과 게시 시각으로 카드의 문구와 경과 시간을 만듭니다. 저장된 값을 꾸미는 함수와 검색 조건을 섞지 않고, 표준 SQL의 COALESCE와 제품마다 지원과 문법이 다른 MySQL의 CONCAT_WS, TIMESTAMPDIFF, DATE_FORMAT을 구별해 씁니다.
함수 예제 데이터
날짜와 경과 시간이 앞 장의 INSERT 순서에 따라 달라지지 않도록, 회원 두 명과 게시글 세 개를 전용 테이블에 고정합니다. 회원 이름은 모두 한글 세 글자이고, 3번 게시글만 published_at이 NULL인 초안입니다. 작성 시각과 게시 시각을 값으로 적어 두었으므로, 뒤에서 계산하는 경과 시간도 실행할 때마다 같습니다.
DROP TABLE IF EXISTS ch4_function_post_demo;
DROP TABLE IF EXISTS ch4_function_member_demo;
CREATE TABLE ch4_function_member_demo (
id BIGINT UNSIGNED NOT NULL PRIMARY KEY,
name VARCHAR(80) NOT NULL
) ENGINE = InnoDB;
CREATE TABLE ch4_function_post_demo (
id BIGINT UNSIGNED NOT NULL PRIMARY KEY,
author_id BIGINT UNSIGNED NOT NULL,
title VARCHAR(80) NOT NULL,
view_count BIGINT UNSIGNED NOT NULL,
created_at DATETIME(6) NOT NULL,
published_at DATETIME(6) NULL,
FOREIGN KEY (author_id) REFERENCES ch4_function_member_demo (id)
) ENGINE = InnoDB;
INSERT INTO ch4_function_member_demo (id, name)
VALUES (1, '안동민'), (2, '이하나');
INSERT INTO ch4_function_post_demo
(id, author_id, title, view_count, created_at, published_at)
VALUES
(1, 1, 'SELECT 기초', 30, '2026-07-13 08:00:00', '2026-07-13 08:30:00'),
(2, 1, 'WHERE 조건', 50, '2026-07-13 09:00:00', '2026-07-13 10:00:00'),
(3, 2, 'JOIN 계획', 40, '2026-07-13 11:00:00', NULL);CONCAT은 NULL 하나에 전체가 NULL이 된다
MySQL의 CONCAT은 인자 가운데 하나라도 NULL이면 결과 전체가 NULL입니다. 선택 항목인 게시일을 마지막에 붙인 다음 쿼리는, 아직 게시하지 않은 멀쩡한 초안의 카드까지 사라지게 합니다.
아래의 p와 m은 게시글과 회원 테이블에 붙인 짧은 이름입니다. JOIN ... ON m.id = p.author_id는 게시글의 작성자 키와 같은 id를 가진 회원을 찾아 이름을 붙이는 부분으로, 3번 게시글의 author_id 2는 회원 2인 '이하나'와 이어집니다. 지금은 이 연결의 결과를 함수의 입력으로만 읽고, JOIN의 종류와 행의 수는 5장에서 배웁니다.
SELECT
p.id,
CONCAT(
m.name,
' · ',
p.title,
' · ',
DATE_FORMAT(p.published_at, '%Y-%m-%d')
) AS label
FROM ch4_function_post_demo AS p
JOIN ch4_function_member_demo AS m ON m.id = p.author_id
ORDER BY p.id;id | label
1 | 안동민 · SELECT 기초 · 2026-07-13
2 | 안동민 · WHERE 조건 · 2026-07-13
3 | NULL게시일이 NULL인 3번은 이름과 제목만 남는 것이 아니라 결과 전체가 NULL입니다. NULL은 빈 글자가 아니므로 CONCAT이 알아서 빼 주지 않습니다.
표시에서 빼려면 NULL을 건너뛰는 CONCAT_WS를 쓰고, 부재를 드러내려면 COALESCE(DATE_FORMAT(p.published_at, '%Y-%m-%d'), '미게시')처럼 대체값을 적습니다. 두 방법은 결과가 다릅니다. 앞의 방법은 항목 자체를 생략하고, 뒤의 방법은 값이 없다는 사실을 사용자에게 보여 줍니다.
함수의 NULL, 타입, 지역 규칙
문자열 함수는 결합과 자르기, 공백 정리, 대소문자 변환처럼 결과의 표현을 바꿉니다. 날짜 함수는 저장된 값을 날짜의 단위로 해석하고, DATE_FORMAT 같은 서식 함수는 보여 주기 위한 문자열을 만듭니다. COALESCE(a, b, c)는 왼쪽부터 처음으로 NULL이 아닌 값을 고르므로 후보의 우선순위를 나타냅니다.
함수로 열을 감싸면 검색과 정렬에서 기존 인덱스가 가진 표현과 달라질 수 있으므로, SELECT의 표시와 WHERE의 탐색을 구별합니다. COALESCE와 LOWER, UPPER, TRIM, SUBSTRING, CAST, EXTRACT는 표준 SQL 계열에서 널리 쓰입니다. 반면 CONCAT_WS와 IFNULL, DATE_FORMAT, TIMESTAMPDIFF는 MySQL 전용이거나 제품마다 문법의 차이가 큰 함수입니다.
문자열의 연결도 그렇습니다. 표준 SQL의 연결 연산자는 ||지만, MySQL은 기본 설정에서 ||를 논리 OR로 해석하므로 CONCAT을 씁니다. 이 교재는 MySQL 전용 함수를 쓸 때 그렇다고 밝히고, 다른 DBMS로 옮겨야 하는 코드에서는 표준의 대안을 먼저 씁니다.
생략할 것과 대체할 것
카드의 필수 항목은 언제나 잇고, 선택 항목인 게시일은 NULL이면 생략합니다. 작성부터 게시까지 걸린 시간은 게시된 글에서만 계산하고, 단위를 별칭에 남깁니다.
SELECT
p.id,
CONCAT_WS(
' · ',
m.name,
p.title,
CONCAT(p.view_count, '회'),
DATE_FORMAT(p.published_at, '%Y-%m-%d')
) AS post_label,
CASE
WHEN p.published_at IS NULL THEN NULL
ELSE TIMESTAMPDIFF(MINUTE, p.created_at, p.published_at)
END AS elapsed_minutes
FROM ch4_function_post_demo AS p
JOIN ch4_function_member_demo AS m ON m.id = p.author_id
ORDER BY p.id;id | post_label | elapsed_minutes
1 | 안동민 · SELECT 기초 · 30회 · 2026-07-13 | 30
2 | 안동민 · WHERE 조건 · 50회 · 2026-07-13 | 60
3 | 이하나 · JOIN 계획 · 40회 | NULL게시일이 없는 3번도 필수 항목은 그대로 남고, 경과 시간은 계산할 수 없음을 뜻하는 NULL입니다. TIMESTAMPDIFF(MINUTE, a, b)는 a부터 b까지를 분 단위로 세고 남는 부분은 버리며, 여기서는 DATETIME 값끼리의 단순한 차이이므로 시간대의 변환까지 고려한 실제 경과 시간과는 구별합니다.
view_count는 게시글이 조회된 누적 횟수이고 elapsed_minutes는 작성부터 게시까지 벽시계로 잰 차이입니다. 둘 다 숫자지만 단위와 뜻이 전혀 다르므로, 함수로 숫자를 만들었다고 같은 종류의 값이 되는 것은 아닙니다.
CONCAT_WS가 무엇을 생략하는지는 정확히 알아 둡니다. 구분자 뒤의 인자 가운데 NULL은 건너뛰지만 빈 문자열은 건너뛰지 않으며, 구분자 자체가 NULL이면 결과가 NULL입니다.
SELECT
CONCAT_WS(' · ', 'a', NULL, 'b') AS null_item,
CONCAT_WS(' · ', 'a', '', 'b') AS empty_item,
CONCAT_WS(NULL, 'a', 'b') AS null_separator;null_item | empty_item | null_separator
a · b | a · · b | NULL빈 문자열은 하나의 항목으로 남아 구분자가 두 번 이어 붙습니다. 다음 도표는 NULL 항목과 빈 문자열, NULL 구분자가 각각 어떤 결과를 만드는지 정리한 것입니다.
STRING · NULL / EMPTY / SEPARATOR
NULL 항목은 생략해도 빈 문자열은 남는다
MySQL CONCAT_WS의 첫 인자는 구분자입니다. 뒤의 NULL 항목, 빈 문자열, NULL 구분자는 서로 다른 결과를 만듭니다.
| 표현 | 결과 | 적용한 규칙 |
|---|---|---|
| CONCAT('A', NULL, 'B') | NULL | NULL 인자 전파 |
| CONCAT_WS('/', 'A', NULL, 'B') | A/B | NULL 항목 생략 |
| CONCAT_WS('/', 'A', '', 'B') | A//B | 빈 문자열 유지 |
| CONCAT_WS(NULL, 'A', 'B') | NULL | NULL 구분자는 결과도 NULL |
- CONCAT('A', NULL, 'B')
- 결과: NULL적용한 규칙: NULL 인자 전파
- CONCAT_WS('/', 'A', NULL, 'B')
- 결과: A/B적용한 규칙: NULL 항목 생략
- CONCAT_WS('/', 'A', '', 'B')
- 결과: A//B적용한 규칙: 빈 문자열 유지
- CONCAT_WS(NULL, 'A', 'B')
- 결과: NULL적용한 규칙: NULL 구분자는 결과도 NULL
A//B의 두 구분자는 빈 문자열 항목이 유지된 증거입니다. 항목 생략과 ‘미게시’ 같은 대체 문구는 서로 다른 표시 정책입니다.
길이와 공백을 확인한다
MySQL에서 LENGTH는 바이트의 수를, CHAR_LENGTH는 문자의 수를 셉니다. utf8mb4는 문자를 1바이트에서 4바이트로 부호화하는 UTF-8이어서 한글 한 글자는 3바이트이므로, 다국어 이름의 길이 제한을 LENGTH로 검사하면 글자 수를 잘못 셉니다.
SELECT id, name, LENGTH(name) AS bytes, CHAR_LENGTH(name) AS chars
FROM ch4_function_member_demo
ORDER BY id;id | name | bytes | chars
1 | 안동민 | 9 | 3
2 | 이하나 | 9 | 3검색용 문자열을 만들기 전에는 앞뒤 공백이 실제 데이터에 얼마나 있는지 먼저 진단하고, UPDATE로 덮어쓰기 전에 원래 값과 바꾼 결과를 함께 봅니다. 아래 쿼리는 아무것도 바꾸지 않는 SELECT로, 원래 이름과 TRIM한 이름, 두 길이를 나란히 보여 줍니다. WHERE의 name <> TRIM(name)은 TRIM한 결과가 원래 값과 달라지는 행, 곧 앞뒤에 공백이 붙은 행만 고릅니다.
SELECT
id,
name AS original_name,
TRIM(name) AS trimmed_name,
CHAR_LENGTH(name) AS original_length,
CHAR_LENGTH(TRIM(name)) AS trimmed_length
FROM ch4_function_member_demo
WHERE name <> TRIM(name);지금의 데이터에는 앞뒤 공백이 있는 이름이 없으므로 결과는 빈 집합입니다. 이 교재가 쓰는 utf8mb4_0900_ai_ci는 끝의 공백도 다른 문자처럼 비교하는 NO PAD 정렬 규칙이어서, 이 조건으로 앞뒤의 공백을 모두 찾을 수 있습니다. 그러나 utf8mb4_general_ci 같은 PAD SPACE 정렬 규칙은 같다는 비교에서 끝의 공백을 무시하므로, 정렬 규칙과 관계없이 찾으려면 CHAR_LENGTH(name) <> CHAR_LENGTH(TRIM(name))처럼 길이를 비교합니다. TRIM은 기본으로 공백 문자만 지우고 탭이나 줄바꿈은 남긴다는 점도 기억해 둡니다.
결과가 나온다면 입력 경로와 기존 데이터의 보정, 앞으로 걸 제약을 함께 정합니다. SELECT에서 매번 TRIM해 보이는 것만 고치면 저장된 값은 계속 오염되고, 다른 소비자는 공백이 붙은 원래 값을 받습니다.
저장, 비교, 표시를 나눈다
날짜를 DATE_FORMAT으로 문자열로 바꾸면 사람이 읽기는 쉽지만, 날짜 타입이 가진 정렬과 범위의 뜻을 잃습니다. API에서는 되도록 ISO 형식에 시간대 정보까지 가진 원래 값을 넘기고, 지역에 맞춘 표시는 클라이언트가 맡는 편이 안전할 수 있습니다. 데이터베이스에서 월별 그룹의 키를 만들 때도 표시용 문자열과 정렬용 키를 나눕니다.
함수를 시험할 때는 한글과 영문, 이모지, 앞뒤 공백, 빈 문자열, NULL을 모두 넣습니다. 날짜의 차이에는 같은 시각, 작성보다 앞선 게시 시각이라는 오류, 자정을 넘겨 게시한 글, 오래 초안으로 둔 글을 넣습니다. 서버 세션의 time_zone은 TIMESTAMP의 해석에 영향을 주므로, 연결의 설정을 기록하고 DATETIME과 TIMESTAMP를 고른 이유를 스키마 문서에 남깁니다.
함수로 개인정보를 가린 결과만 조회한다고 원래 값을 읽을 권한이 사라지는 것은 아닙니다. 그 SQL을 실행하는 계정은 원래의 열도 읽을 수 있으므로, 가려야 하는 값은 뷰의 권한이나 별도의 노출 계층으로 다룹니다(8장 6편).
문자열의 정규화는 로그인 이메일과 표시 이름, 검색 키마다 요구가 다릅니다. 모든 문자열을 LOWER와 TRIM으로 맞추는 공통 규칙은 원래의 표기를 해칠 수 있으므로, 저장하는 원래 값과 비교용으로 정규화한 값, 표시용 값을 나눌지 판단합니다. 회원 이름의 앞뒤 공백은 입력 오류라면 보정할 대상이지만, 게시글 본문의 줄바꿈과 공백은 작성한 내용이므로 같은 TRIM 규칙을 적용하지 않습니다.
COALESCE의 후보들은 타입이 다르면 MySQL이 공통의 타입을 추론해, 예상하지 못한 문자열과 숫자의 변환이 일어날 수 있습니다. 대체값의 리터럴도 열과 같은 뜻의 타입으로 적고, 날짜의 서식 문자열을 외부에서 받는다면 지원하는 기호와 결과의 길이를 제한합니다.
| 따져 볼 것 | 질문 |
|---|---|
| 부재 | NULL을 생략할지 대체 문구로 보여 줄지 정했는가 |
| 타입 | 함수의 입력과 결과의 타입이 받는 쪽의 규칙과 맞는가 |
| 표준 | MySQL 전용 함수를 옮길 때의 비용을 밝혔는가 |
| 탐색 | WHERE의 열을 함수로 감싸 인덱스의 경로를 바꾸지 않았는가 |
| 지역 | 정렬 규칙과 time_zone의 규칙을 분명히 했는가 |
보여 주기 위한 함수는 SELECT의 결과 표현식에 두고, 저장의 무결성과 검색을 위한 정규화는 입력과 스키마의 단계에서 해결하는 것을 기본으로 합니다. 같은 함수가 되풀이되면 복사하기보다 뜻이 드러나는 이름의 뷰나 생성 열을 검토합니다.
정리
CONCAT은 NULL 하나에 결과 전체가 NULL이 되고,CONCAT_WS는 구분자 뒤의 NULL을 건너뜁니다.COALESCE는 처음으로 NULL이 아닌 후보를 고르며, 빈 문자열은 NULL이 아니므로 따로 다룹니다.LENGTH는 바이트를,CHAR_LENGTH는 문자를 세며, 끝 공백의 비교는 정렬 규칙의 PAD 속성에 따라 다릅니다.- 날짜는 계산할 수 있는 타입으로 두고, 서식을 입힌 문자열은 결과를 내보낼 때만 만듭니다.
- 표준 함수와 MySQL 전용 함수의 범위를 코드에서 드러냅니다.
연습 문제
1. 게시일이나 미게시를 붙인 요약
회원 이름과 게시글 제목, 조회수를 /로 이어 보세요. 게시된 글에는 YYYY-MM-DD 형식의 게시일을, 게시되지 않은 글에는 '미게시'를 붙입니다.
해설 보기
날짜의 표시에는 MySQL의 DATE_FORMAT을 쓰고, 게시일이 없을 때의 문구는 COALESCE로 정합니다.
SELECT
CONCAT_WS(
' / ',
m.name,
p.title,
CONCAT(p.view_count, '회'),
COALESCE(DATE_FORMAT(p.published_at, '%Y-%m-%d'), '미게시')
) AS post_summary
FROM ch4_function_post_demo AS p
JOIN ch4_function_member_demo AS m ON m.id = p.author_id
ORDER BY p.id;post_summary
안동민 / SELECT 기초 / 30회 / 2026-07-13
안동민 / WHERE 조건 / 50회 / 2026-07-13
이하나 / JOIN 계획 / 40회 / 미게시게시일이 없는 3번도 나머지 항목이 그대로 남고, 생략 대신 '미게시'가 붙었습니다. 본문의 카드와 달리 여기서는 부재를 사용자에게 보여 주는 규칙을 고른 것입니다.
2. 이모지가 든 제목의 길이
제목 'SQL 😀'의 LENGTH와 CHAR_LENGTH는 각각 얼마인가요? 제목을 80자로 제한하는 규칙은 어느 함수로 검사해야 하나요?
해설 보기
'SQL'과 공백은 한 글자에 1바이트씩 4바이트이고, 이모지 😀는 utf8mb4에서 4바이트이므로 LENGTH는 8입니다. 문자는 다섯 개이므로 CHAR_LENGTH는 5입니다. VARCHAR(80)의 80은 문자 수이고 1장의 ck_posts_title도 CHAR_LENGTH로 검사하므로, 글자 수의 제한은 CHAR_LENGTH로 확인합니다. 바이트 수는 저장 공간이나 인덱스의 크기 제한처럼 바이트가 기준인 규칙에서만 따집니다.
3. 날짜 문자열로 정렬하면
카드에 보여 주려고 만든 DATE_FORMAT(published_at, '%m/%d/%Y') 문자열로 게시글을 정렬하면 어떤 문제가 생기나요?
해설 보기
문자열은 앞 글자부터 사전 순서로 비교되므로, '12/31/2025'가 '01/02/2026'보다 뒤에 오는 것처럼 연도가 바뀌는 지점에서 날짜의 순서와 어긋납니다. 하루 안의 시각도 문자열에서 사라져, 같은 날 게시된 글끼리의 순서를 정할 수 없습니다. 정렬은 published_at 자체로 하고, 서식을 입힌 문자열은 결과에 보여 줄 열로만 씁니다.
다음 글 집계 함수와 GROUP BY에서는 여러 행을 하나의 통계로 줄이는 집계 함수와, 어떤 단위로 묶을지 정하는 GROUP BY를 다룹니다.