안동민 개발노트 아이콘

안동민 개발노트

4장 : SQL 기초 — DML

SELECT 기본

SELECT는 데이터를 조회하는 명령이며, 개발자가 가장 자주 사용하는 SQL입니다. SELECT 구문의 구조를 정확히 이해하면 복잡한 쿼리도 체계적으로 작성할 수 있습니다.

많은 애플리케이션에서 데이터베이스 작업의 대부분은 조회입니다. INSERT, UPDATE, DELETE가 데이터를 바꾸는 명령이라면, SELECT는 사용자가 볼 결과와 서비스가 판단할 근거를 가져오는 명령입니다.


SELECT 구문 구조

SELECT 전체 구조
SELECT   [DISTINCT] 컬럼 또는 표현식  -- 5. 출력할 컬럼 선택, 중복 제거
FROM     테이블                       -- 1. 대상 테이블 결정
[WHERE   조건]                        -- 2. 행 필터링
[GROUP BY 컬럼]                       -- 3. 그룹화
[HAVING  조건]                        -- 4. 그룹 필터링
[ORDER BY 컬럼 [ASC|DESC]]            -- 6. 정렬
[LIMIT   숫자 [OFFSET 숫자]]          -- 7. 결과 제한(MySQL/PostgreSQL/SQLite 계열)

결과 제한 문법은 DBMS마다 다릅니다. MySQL, PostgreSQL, SQLite는 LIMIT를 많이 쓰고, SQL Server는 TOP 또는 OFFSET ... FETCH, Oracle 12c 이후는 FETCH FIRST n ROWS ONLY를 사용할 수 있습니다. 정렬 없이 행 수만 제한하면 어떤 행이 먼저 나올지 안정적이지 않으므로, 화면이나 API에서는 보통 ORDER BY와 함께 씁니다.

작성 순서 vs 실행 순서

SQL은 작성 순서와 논리 처리 순서가 다릅니다. FROM이 먼저 결과의 출발 집합을 만들고, SELECT는 거의 마지막에 출력 모양을 정합니다. 실제 DBMS 실행 계획은 최적화 과정에서 순서가 바뀔 수 있지만, 문법 규칙을 이해할 때는 이 논리 순서가 기준이 됩니다.

이 순서를 알면 다음 질문에 답할 수 있습니다.

실행 순서로 설명되는 규칙들
Q: WHERE에서 별칭(AS)을 쓸 수 없는 이유는?
A: WHERE(2번)이 SELECT(5번)보다 먼저 실행되므로, 별칭이 아직 없음

Q: HAVING에서 집계 함수를 쓸 수 있는 이유는?
A: HAVING(4번)은 GROUP BY(3번) 이후 실행되어 그룹이 이미 형성됨

Q: ORDER BY에서 별칭을 쓸 수 있는 이유는?
A: ORDER BY(6번)는 SELECT(5번) 이후 실행되므로 별칭이 존재함

가장 간단한 SELECT

기본 SELECT
-- 모든 컬럼, 모든 행 조회
SELECT * FROM products;

-- 특정 컬럼만 조회
SELECT name, price FROM products;

-- 상수, 계산식도 가능
SELECT name, price, price * 1.1 AS 부가세포함가격 FROM products;

-- 테이블 없이도 가능 (DBMS에 따라)
SELECT 1 + 1;                    -- PostgreSQL, MySQL, SQL Server
SELECT 1 + 1 FROM DUAL;          -- Oracle 구버전 관습(DUAL: 더미 테이블)
SELECT CURRENT_DATE;             -- 오늘 날짜

SELECT * 은 피해야 하는가

SELECT *는 콘솔에서 빠르게 구조를 확인할 때는 편리합니다. 하지만 애플리케이션 코드, API 응답, 배치처럼 반복 실행되는 쿼리에서는 필요한 컬럼을 명시하는 것이 안전합니다. 테이블에 큰 컬럼이 추가되거나 컬럼 순서가 바뀌어도 쿼리의 의미가 흔들리지 않기 때문입니다.


조건 검색 (WHERE)

WHERE 절은 FROM에서 가져온 행들 중에서 조건을 만족하는 행만 필터링합니다.

비교 연산자

비교 연산자
-- 기본 비교
SELECT * FROM products WHERE price > 10000;         -- 초과
SELECT * FROM products WHERE price >= 10000;        -- 이상
SELECT * FROM products WHERE price < 50000;         -- 미만
SELECT * FROM products WHERE price <= 50000;        -- 이하
SELECT * FROM products WHERE category = '전자기기';  -- 같음
SELECT * FROM products WHERE stock <> 0;             -- 다름 (표준 SQL)
SELECT * FROM products WHERE stock != 0;             -- 많은 DBMS에서 지원

BETWEEN: 범위 검색

BETWEEN
-- 가격이 10000 이상 50000 이하 (양쪽 포함)
SELECT * FROM products WHERE price BETWEEN 10000 AND 50000;

-- 동일한 표현
SELECT * FROM products WHERE price >= 10000 AND price <= 50000;

-- 날짜 범위
SELECT * FROM orders
WHERE order_date BETWEEN '2024-01-01' AND '2024-03-31';

BETWEEN은 양쪽 경계를 포함(이상, 이하)합니다. 날짜 검색에서 자주 사용되지만, 컬럼이 날짜+시간 타입이면 마지막 날의 00:00:00까지만 포함될 수 있습니다. 이런 경우에는 order_date >= '2024-01-01' AND order_date < '2024-04-01'처럼 다음 날 미만 조건을 쓰는 편이 안전합니다.

IN: 목록 검색

IN
-- 특정 값 목록에 포함되는지 확인
SELECT * FROM products
WHERE category IN ('전자기기', '도서', '식품');

-- 동일한 표현 (OR로 풀어쓰기)
SELECT * FROM products
WHERE category = '전자기기' OR category = '도서' OR category = '식품';

-- NOT IN: 목록에 없는 것
SELECT * FROM products
WHERE category NOT IN ('전자기기', '도서');

IN은 값의 개수가 많을 때 OR보다 간결합니다. 서브쿼리와 결합하면 더 강력해집니다.

NOT IN은 목록이나 서브쿼리 결과에 NULL이 섞이면 기대와 다르게 아무 행도 통과하지 않을 수 있습니다. NULL 가능성이 있는 서브쿼리에는 NOT EXISTS를 쓰거나, 서브쿼리 안에서 NULL을 먼저 제거하는 편이 안전합니다.

LIKE: 패턴 매칭

LIKE
-- %: 0개 이상의 임의 문자
SELECT * FROM products WHERE name LIKE '삼성%';      -- '삼성'으로 시작
SELECT * FROM products WHERE name LIKE '%노트북%';    -- '노트북' 포함
SELECT * FROM products WHERE name LIKE '%Pro';        -- 'Pro'로 끝남

-- _: 정확히 1개의 임의 문자
SELECT * FROM products WHERE name LIKE '___';         -- 정확히 3글자
SELECT * FROM products WHERE name LIKE '_철_';        -- 가운데 '철'

-- 이스케이프: %나 _를 문자 그대로 검색
SELECT * FROM products WHERE name LIKE '%50!%%' ESCAPE '!';
-- '50%' 를 포함하는 이름 검색

LIKE의 대소문자 구분 여부는 DBMS와 컬레이션(collation)에 따라 달라집니다. 대소문자를 엄격히 구분해야 하거나 다국어 검색이 중요하면 컬레이션, 함수 기반 인덱스, 전문 검색 기능까지 함께 검토해야 합니다.

IS NULL / IS NOT NULL

NULL 비교
-- NULL 검사는 반드시 IS NULL / IS NOT NULL 사용
SELECT * FROM products WHERE category IS NULL;
SELECT * FROM products WHERE category IS NOT NULL;

-- 주의: 아래는 UNKNOWN이 되어 WHERE에서 통과하지 않음
SELECT * FROM products WHERE category = NULL;     -- 잘못됨!
SELECT * FROM products WHERE category != NULL;    -- 잘못됨!

NULL 비교 주의: WHERE category = NULL은 TRUE가 아니라 UNKNOWN을 반환합니다. WHERE는 TRUE인 행만 통과시키므로, NULL 비교에는 IS NULL 또는 IS NOT NULL을 사용해야 합니다.

NULL의 3가지 의미
1. 값이 없음 (Unknown) — 학생의 전화번호를 모름
2. 해당 없음 (Not Applicable) — 미혼인 사람의 배우자 이름
3. 아직 입력하지 않음 (Not Yet) — 아직 채점하지 않은 시험 점수

NULL과 연산

NULL이 포함된 연산 결과
NULL + 100       = NULL      (숫자 연산)
NULL || '안녕'    = NULL      (문자열 연결, PostgreSQL 등)
NULL = NULL      = UNKNOWN   (비교 연산)
NULL != NULL     = UNKNOWN   (비교 연산)
NULL AND TRUE    = UNKNOWN   (논리 연산)
NULL OR TRUE     = TRUE      (OR만 예외적으로 TRUE 가능)

문자열 연결에서 NULL을 어떻게 처리하는지는 DBMS마다 차이가 있습니다. 결과 문자열을 확실히 만들고 싶다면 COALESCE(column, '')처럼 NULL을 먼저 대체한 뒤 연결하는 습관이 안전합니다.


논리 연산자

WHERE 절에서 여러 조건을 결합할 때 AND, OR, NOT을 사용합니다.

AND, OR, NOT
-- AND: 모든 조건을 만족
SELECT * FROM products
WHERE category = '전자기기' AND price < 100000;

-- OR: 하나라도 만족
SELECT * FROM products
WHERE category = '전자기기' OR category = '도서';

-- NOT: 조건의 부정
SELECT * FROM products WHERE NOT category = '식품';
SELECT * FROM products WHERE category <> '식품';  -- 동일한 의미(표준)

-- 복합 조건 (괄호로 우선순위 명확하게!)
SELECT * FROM products
WHERE (category = '전자기기' OR category = '도서')
  AND price < 50000;

연산자 우선순위


DISTINCT와 별칭

DISTINCT: 중복 제거

DISTINCT
-- 중복 제거: 유일한 카테고리만 조회
SELECT DISTINCT category FROM products;

-- 여러 컬럼에 DISTINCT: 조합이 유일한 행
SELECT DISTINCT category, brand FROM products;
-- category와 brand의 조합이 같은 행은 하나만 남음

-- COUNT와 DISTINCT 결합
SELECT COUNT(DISTINCT category) AS 카테고리수 FROM products;

DISTINCT는 출력되는 값의 조합을 기준으로 중복을 제거합니다. NULL도 “알 수 없는 값”이지만, 중복 제거 관점에서는 같은 출력값처럼 묶입니다.

DBMS에 따라 DISTINCT 쿼리의 ORDER BY에는 SELECT 목록에 포함된 표현식만 허용되는 경우가 있습니다. 중복 제거 후 어떤 기준으로 정렬할지 애매해질 수 있기 때문입니다.

별칭 (AS)

별칭
-- 컬럼 별칭
SELECT name AS 상품명, price AS 가격 FROM products;

-- AS 생략 가능
SELECT name 상품명, price 가격 FROM products;

-- 계산식에 별칭
SELECT name, price * 0.9 AS 할인가격 FROM products;
SELECT name, price * stock AS 총재고가치 FROM products;

-- 공백 포함 별칭 (따옴표 필요)
SELECT name AS "상품 이름", price AS "정가(원)" FROM products;

-- 테이블 별칭 (조인에서 강력 권장)
SELECT p.name, p.price
FROM products p
WHERE p.stock > 0;

별칭을 따옴표로 감싸면 공백이나 특수문자를 넣을 수 있지만, DBMS에 따라 대소문자 보존과 인용 부호 규칙이 달라집니다. 실무에서는 가능하면 공백 없는 영문 별칭을 쓰고, 출력 라벨은 애플리케이션 계층에서 다듬는 편이 관리하기 쉽습니다.


조건식과 CASE

WHERE 절 외에도 SELECT 절에서 조건에 따라 다른 값을 출력할 수 있습니다.

CASE 표현식
-- 검색형 CASE: 조건식 사용
SELECT name, price,
    CASE
        WHEN price >= 100000 THEN '고가'
        WHEN price >= 50000 THEN '중가'
        WHEN price >= 10000 THEN '저가'
        ELSE '초저가'
    END AS 가격등급
FROM products;

-- 단순형 CASE: 단일 컬럼의 값 비교
SELECT name, category,
    CASE category
        WHEN '전자기기' THEN 'Electronics'
        WHEN '도서' THEN 'Books'
        WHEN '식품' THEN 'Food'
        ELSE 'Other'
    END AS category_en
FROM products;

CASE에서 ELSE를 생략하면 어떤 WHEN에도 걸리지 않은 행의 결과는 NULL입니다. THEN과 ELSE에 들어가는 값의 타입은 서로 호환되도록 맞추는 것이 좋습니다.

NULL 처리 함수

COALESCE, NVL, IFNULL
-- COALESCE: 첫 번째 NOT NULL 값 반환 (표준 SQL)
SELECT name, COALESCE(phone, email, '연락처 없음') AS contact
FROM users;

-- NVL: NULL이면 대체값 반환 (Oracle)
SELECT name, NVL(phone, '미등록') AS phone FROM users;

-- IFNULL: MySQL
SELECT name, IFNULL(phone, '미등록') AS phone FROM users;

-- ISNULL: SQL Server
SELECT name, ISNULL(phone, '미등록') AS phone FROM users;

-- NULLIF: 두 값이 같으면 NULL, 다르면 첫 번째 값
SELECT NULLIF(price, 0) AS safe_price FROM products;
-- price가 0이면 NULL 반환 (0으로 나누기 방지에 유용)

산술 연산과 표현식

SELECT 절에서 직접 계산할 수 있습니다.

산술 연산
-- 컬럼 간 사칙연산
SELECT name,
       price,
       stock,
       price * stock AS 총재고가치
FROM products;

-- 정수 나눗셈 주의 (DBMS에 따라 다름)
SELECT 7 / 2;          -- PostgreSQL: 3 (정수 나눗셈)
SELECT 7 / 2;          -- MySQL: 3.5000
SELECT 7.0 / 2;        -- PostgreSQL: 3.5 (하나라도 실수면 실수)
SELECT 7 / 2.0;        -- 3.5

-- 나머지, 절대값
SELECT MOD(7, 2);      -- 1 (나머지)
SELECT ABS(-100);      -- 100 (절대값)
SELECT ROUND(3.756, 1); -- 3.8 (소수점 첫째 자리까지)
SELECT CEIL(3.1);       -- 4 (올림, SQL Server는 CEILING)
SELECT FLOOR(3.9);      -- 3 (내림)

문자열 함수

자주 쓰는 문자열 함수
-- 길이
SELECT LENGTH('Hello');          -- 5 (PostgreSQL, MySQL)
SELECT LEN('Hello');             -- 5 (SQL Server)

-- 부분 문자열
SELECT SUBSTR('Hello World', 1, 5);     -- 'Hello' (Oracle, PostgreSQL)
SELECT SUBSTRING('Hello World', 1, 5);  -- 'Hello' (MySQL, SQL Server)

-- 대소문자 변환
SELECT UPPER('hello');           -- 'HELLO'
SELECT LOWER('HELLO');           -- 'hello'

-- 공백 제거
SELECT TRIM('  hello  ');        -- 'hello'
SELECT LTRIM('  hello');         -- 'hello'
SELECT RTRIM('hello  ');         -- 'hello'

-- 문자열 연결
SELECT CONCAT('Hello', ' ', 'World');        -- 'Hello World'
SELECT 'Hello' || ' ' || 'World';           -- 'Hello World' (Oracle, PostgreSQL)

-- 치환
SELECT REPLACE('Hello World', 'World', 'SQL'); -- 'Hello SQL'

-- 위치 찾기
SELECT POSITION('World' IN 'Hello World');     -- 7 (PostgreSQL)
SELECT INSTR('Hello World', 'World');          -- 7 (Oracle, MySQL)

날짜 함수

자주 쓰는 날짜 함수
-- 현재 날짜/시간
SELECT CURRENT_DATE;              -- 2024-06-15
SELECT CURRENT_TIMESTAMP;         -- 2024-06-15 14:30:00
SELECT NOW();                     -- MySQL, PostgreSQL

-- 날짜 부분 추출
SELECT EXTRACT(YEAR FROM order_date) AS 주문년도
FROM orders;
SELECT EXTRACT(MONTH FROM order_date) AS 주문월
FROM orders;

-- 날짜 연산
SELECT order_date + INTERVAL '7 days' AS 일주일후    -- PostgreSQL
FROM orders;
SELECT DATE_ADD(order_date, INTERVAL 7 DAY) AS 일주일후  -- MySQL
FROM orders;
SELECT DATEADD(day, 7, order_date) AS 일주일후       -- SQL Server
FROM orders;

-- 날짜 차이
SELECT AGE(NOW(), created_at) AS 경과시간 FROM users;  -- PostgreSQL
SELECT DATEDIFF(NOW(), created_at) AS 경과일 FROM users; -- MySQL

형변환 (CAST)

CAST와 형변환
-- 표준: CAST
SELECT CAST(price AS VARCHAR(10)) FROM products;
SELECT CAST('123' AS INTEGER);
SELECT CAST(order_date AS VARCHAR(10)) FROM orders;

-- PostgreSQL 축약:: 
SELECT price::VARCHAR FROM products;

-- 암시적 형변환 경고
SELECT * FROM products WHERE price = '10000';
-- 문자열 '10000'이 자동으로 숫자로 변환됨
-- 인덱스가 무효화될 수 있으므로 명시적 형변환 권장

형변환 타입 이름은 DBMS별로 조금씩 다릅니다. Oracle은 문자열 타입에 VARCHAR2를 주로 쓰고, PostgreSQL은 :: 축약 문법을 지원하지만 다른 DBMS에서는 사용할 수 없습니다.


WHERE 조건의 성능 고려

WHERE 절의 작성 방식에 따라 성능이 크게 달라집니다.

인덱스 활용이 어려워질 수 있는 조건들
-- 1. 컬럼에 함수 적용 → 일반 인덱스 효율 저하 가능(MySQL/SQL Server 예)
SELECT * FROM orders WHERE YEAR(order_date) = 2024;
-- 개선: WHERE order_date >= '2024-01-01' AND order_date < '2025-01-01'

-- 2. 컬럼에 연산 적용 → 일반 인덱스 효율 저하 가능
SELECT * FROM products WHERE price * 1.1 > 50000;
-- 개선: WHERE price > 50000 / 1.1

-- 3. 타입 불일치 → 암시적 형변환으로 인덱스 효율 저하 가능
SELECT * FROM users WHERE phone = 01012345678;  -- phone이 VARCHAR인 경우
-- 개선: WHERE phone = '01012345678'

-- 4. 서로 다른 컬럼의 OR 조건 → 인덱스 활용이 어려울 수 있음
SELECT * FROM products WHERE category = 'A' OR price > 50000;
-- 대안 검토: UNION으로 분리 (각 조건에 맞는 인덱스 활용 가능성)
WHERE 조건 작성 원칙
1. 가능하면 조건 컬럼을 그대로 둔다 (함수, 연산은 표현식 인덱스가 없으면 불리할 수 있음)
2. 데이터 타입을 맞춘다 (암시적 형변환 방지)
3. 같은 컬럼의 여러 값 비교는 IN이 더 간결하고 최적화에 유리한 경우가 많다
4. 부정 조건(!=, NOT IN)은 선택도가 낮으면 인덱스 효율이 떨어질 수 있다
5. 복합 인덱스는 등치 조건, 범위 조건, 정렬 기준을 함께 보고 설계한다

실전 예제

종합 예제: 상품 조회
-- 전자기기 카테고리에서
-- 재고가 있고 (stock > 0)
-- 가격이 10만원 이하인 상품을
-- 가격 높은 순으로 조회
SELECT name AS 상품명,
       price AS 가격,
       stock AS 재고,
       price * stock AS 재고가치,
       CASE
           WHEN stock >= 100 THEN '충분'
           WHEN stock >= 10 THEN '보통'
           ELSE '부족'
       END AS 재고상태
FROM products
WHERE category = '전자기기'
  AND stock > 0
  AND price <= 100000
ORDER BY price DESC;
종합 예제: 사용자 조회
-- 활성 사용자 중에서
-- 이메일이 gmail인 사용자를
-- 가입일 최신 순으로 5명만 조회
SELECT user_id,
       username,
       email,
       COALESCE(phone, '미등록') AS phone,
       created_at AS 가입일
FROM users
WHERE status = 'active'
  AND email LIKE '%@gmail.com'
  AND deleted_at IS NULL
ORDER BY created_at DESC
LIMIT 5;

위 예제의 LIMIT 5는 MySQL/PostgreSQL 계열 문법입니다. SQL Server라면 SELECT TOP 5 ... ORDER BY created_at DESC 또는 OFFSET 0 ROWS FETCH NEXT 5 ROWS ONLY, Oracle이라면 FETCH FIRST 5 ROWS ONLY처럼 바꿔야 합니다.


핵심 정리

다음 절에서는 결과의 정렬과 제한을 다루겠습니다.