SQL 튜터 세션 정리
SQL
프로그래머스
CONCAT, 서브쿼리, SUBSTRING, IFNULL, NOT IN, Anti-Join, 조건부 집계 등 활용 (5문제)
SQL 튜터 세션 정리
날짜: 2026-04-17
오늘 푼 문제
| # | 문제 | 핵심 개념 |
|---|---|---|
| 1 | 조회수 최고 게시물의 첨부파일 경로 조회 | CONCAT, 서브쿼리 + ORDER BY LIMIT 1 |
| 2 | 게시물 3개 이상 작성자 닉네임, 주소, 전화번호 조회 | CTE(WITH), GROUP BY + HAVING, SUBSTRING, IFNULL |
| 3 | 완료된 중고 거래 총금액 70만원 이상 조회 | GROUP BY, HAVING, CTE, 서브쿼리, 인라인 뷰 JOIN |
| 4 | 게임 아이템 트리 - 최종 아이템(리프 노드) 찾기 | NOT IN + IS NOT NULL, LEFT JOIN 안티 조인 |
| 5 | 자동차 대여 여부 판단 | SUM(CASE WHEN), 조건부 집계, 행 단위 vs 그룹 단위 사고 |
풀이 코드
1. 조회수 최고 게시물의 첨부파일 경로 조회
SELECT CONCAT('/home/grep/src/', BOARD_ID, '/', FILE_ID, FILE_NAME, FILE_EXT) AS FILE_PATH
FROM USED_GOODS_FILE
WHERE BOARD_ID = (
SELECT BOARD_ID
FROM USED_GOODS_BOARD
ORDER BY VIEWS DESC
LIMIT 1
)
ORDER BY FILE_ID DESC;2. 게시물 3개 이상 작성자 닉네임, 주소, 전화번호 조회
WITH T1 AS (
SELECT WRITER_ID, COUNT(WRITER_ID) AS CNT_WRITER_ID
FROM USED_GOODS_BOARD
GROUP BY WRITER_ID
HAVING CNT_WRITER_ID >= 3
)
SELECT USER_ID,
NICKNAME,
CONCAT(CITY, ' ', STREET_ADDRESS1, ' ', IFNULL(STREET_ADDRESS2, '')) AS `전체주소`,
CONCAT(SUBSTRING(TLNO, 1, 3), '-', SUBSTRING(TLNO, 4, 4), '-', SUBSTRING(TLNO, 8, 4)) AS `전화번호`
FROM USED_GOODS_USER, T1
WHERE USED_GOODS_USER.USER_ID = T1.WRITER_ID
ORDER BY USER_ID DESC;3. 완료된 중고 거래 총금액 70만원 이상 조회
-- 방법 1: CTE
WITH T1 AS (
SELECT WRITER_ID, SUM(PRICE) AS TOTAL_SALES
FROM USED_GOODS_BOARD
WHERE STATUS = 'DONE'
GROUP BY WRITER_ID
HAVING SUM(PRICE) >= 700000
)
SELECT USER_ID, NICKNAME, TOTAL_SALES
FROM USED_GOODS_USER U
INNER JOIN T1 ON U.USER_ID = T1.WRITER_ID
ORDER BY TOTAL_SALES;
-- 방법 2: 인라인 뷰 + JOIN
SELECT USER_ID, NICKNAME, T.TOTAL_SALES
FROM USED_GOODS_USER U
INNER JOIN (SELECT WRITER_ID, SUM(PRICE) AS TOTAL_SALES
FROM USED_GOODS_BOARD
WHERE STATUS = 'DONE'
GROUP BY WRITER_ID
HAVING SUM(PRICE) >= 700000) T ON U.USER_ID = T.WRITER_ID
ORDER BY TOTAL_SALES;4. 게임 아이템 트리 - 최종 아이템(리프 노드) 찾기
-- 방법 1: NOT IN 서브쿼리
SELECT ITEM_ID, ITEM_NAME, RARITY
FROM ITEM_INFO
WHERE ITEM_ID NOT IN (
SELECT DISTINCT PARENT_ITEM_ID
FROM ITEM_TREE
WHERE PARENT_ITEM_ID IS NOT NULL
)
ORDER BY ITEM_ID DESC;
-- 방법 2: LEFT JOIN 안티 조인(Anti-Join)
SELECT I.ITEM_ID, I.ITEM_NAME, I.RARITY
FROM ITEM_INFO I
LEFT JOIN ITEM_TREE T ON I.ITEM_ID = T.PARENT_ITEM_ID
WHERE T.ITEM_ID IS NULL
ORDER BY I.ITEM_ID DESC;5. 자동차 대여 여부 판단
- 기준 날짜:
2022-10-16 - 해당 날짜에 대여 중이면 →
'대여중', 아니면 →'대여 가능' - 같은 CAR_ID에 여러 기록 존재 → 하나라도 대여중이면 전체가
'대여중'
SELECT CAR_ID,
CASE WHEN
SUM(CASE
WHEN START_DATE <= '2022-10-16'
AND END_DATE >= '2022-10-16'
THEN 1
ELSE 0
END) > 0
THEN '대여중'
ELSE '대여 가능'
END AS AVAILABILITY
FROM CAR_RENTAL_COMPANY_RENTAL_HISTORY
GROUP BY CAR_ID
ORDER BY CAR_ID DESC;핵심 개념 정리
CONCAT() — 문자열 합치기
CONCAT(문자열1, 문자열2, 문자열3, ...)
-- 예시: 파일 경로 만들기
CONCAT('/home/grep/src/', BOARD_ID, '/', FILE_ID, FILE_NAME, FILE_EXT)
-- → '/home/grep/src/B0001/IMG_000001photo1.jpg'ORDER BY + LIMIT 1 — 최대/최솟값 행 한 개 뽑기
-- 조회수가 가장 높은 게시물의 BOARD_ID
SELECT BOARD_ID
FROM USED_GOODS_BOARD
ORDER BY VIEWS DESC
LIMIT 1
-- MAX()로 찾는 방법도 있지만 ORDER BY + LIMIT 1이 더 직관적
SELECT BOARD_ID
FROM USED_GOODS_BOARD
WHERE VIEWS = (SELECT MAX(VIEWS) FROM USED_GOODS_BOARD)CTE (WITH) — 재사용 가능한 임시 결과셋 정의
WITH 임시명 AS (
SELECT ...
)
SELECT * FROM 임시명;
-- 예시: 게시물 3개 이상 작성자 뽑기
WITH T1 AS (
SELECT WRITER_ID, COUNT(*) AS CNT
FROM USED_GOODS_BOARD
GROUP BY WRITER_ID
HAVING CNT >= 3
)SUBSTRING() — 문자열 특정 위치 잘라내기
SUBSTRING(문자열, 시작위치, 길이)
-- 예시: 전화번호 포맷팅 (01053422914 → 010-5342-2914)
CONCAT(
SUBSTRING(TLNO, 1, 3), '-', -- 010
SUBSTRING(TLNO, 4, 4), '-', -- 5342
SUBSTRING(TLNO, 8, 4) -- 2914
)
-- ⚠️ LTRIM/RTRIM과 헷갈리지 않도록 — 문자열 앞뒤 공백 제거와 다름IFNULL / COALESCE — NULL 처리
-- MySQL 전용
IFNULL(STREET_ADDRESS2, '')
-- 표준 SQL (다른 DB에서도 동작)
COALESCE(STREET_ADDRESS2, '')
-- CONCAT에 NULL이 섞이면 결과 전체가 NULL
-- → Nullable 컬럼은 NULL 처리 후 CONCAT암묵적 JOIN vs 명시적 INNER JOIN
-- 암묵적 JOIN (FROM A, B WHERE A.col = B.col)
FROM USED_GOODS_USER, T1
WHERE USED_GOODS_USER.USER_ID = T1.WRITER_ID
-- 명시적 INNER JOIN (가독성 좋고, 성능 최적화 여지 있음)
FROM USED_GOODS_USER
INNER JOIN T1 ON USED_GOODS_USER.USER_ID = T1.WRITER_IDCTE vs 인라인 뷰 — 언제 무엇을 쓸까?
| 방식 | 특징 |
|---|---|
| CTE | 가독성 좋음, 복잡한 쿼리에 유리, 재사용 가능 |
| 인라인 뷰 (FROM 절 서브쿼리) | TOTAL_SALES 같은 집계값도 함께 SELECT 가능 |
| IN + 서브쿼리 | 목록 필터링에 적합, 단 집계값은 못 가져옴 |
HAVING에서 alias 사용 주의
-- ⚠️ MySQL에서는 동작하지만 표준 SQL에서는 오류
HAVING TOTAL_SALES >= 700000 -- SELECT alias 참조
-- ✅ 안전하고 표준적인 작성법
HAVING SUM(PRICE) >= 700000SQL 실행 순서: FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY HAVING은 SELECT보다 먼저 실행되므로, SELECT에서 정의된 alias를 HAVING에서 참조하는 것은 표준 SQL에서 허용되지 않음
NOT IN의 NULL 함정 (Trap)
-- ❌ PARENT_ITEM_ID에 NULL이 있으면 아무 결과도 반환 안 함
WHERE ITEM_ID NOT IN (SELECT PARENT_ITEM_ID FROM ITEM_TREE)
-- ✅ 반드시 IS NOT NULL 필터 추가
WHERE ITEM_ID NOT IN (
SELECT DISTINCT PARENT_ITEM_ID
FROM ITEM_TREE
WHERE PARENT_ITEM_ID IS NOT NULL
)NOT IN 괄호 안에 NULL이 하나라도 있으면, SQL은 False와 NULL의 비교 결과를 알 수 없어 아무 결과도 반환하지 않음
안티 조인 (Anti-Join) 패턴
-- LEFT JOIN 후 오른쪽 테이블의 기본키가 NULL인 행만 필터링
SELECT I.ITEM_ID
FROM ITEM_INFO I
LEFT JOIN ITEM_TREE T ON I.ITEM_ID = T.PARENT_ITEM_ID
WHERE T.ITEM_ID IS NULL -- ← 기본키(PK)로 검사해야 정확- 매칭 실패 시 오른쪽 테이블의 모든 컬럼이 NULL이 됨
T.PARENT_ITEM_ID IS NULL로 검사하면 원래부터 NULL인 ROOT 아이템까지 잘못 포함될 수 있음- 기본키(T.ITEM_ID) 로 검사하는 것이 정확하고 안전함
- 대용량 데이터에서
NOT IN보다 성능이 유리한 경우가 많음
조건부 집계 패턴 — SUM(CASE WHEN)
SUM(CASE WHEN 조건 THEN 1 ELSE 0 END)| 의미 | 설명 |
|---|---|
| 조건 만족 → 1 | 해당 행 카운트 |
| 조건 불만족 → 0 | 무시 |
| SUM | 조건 만족 행의 총 개수 |
“하나라도 존재” 표현:
SUM(...) > 0 -- 조건 만족 행이 1개 이상 존재대체 방법: MAX(CASE WHEN ... THEN 1 ELSE 0 END), EXISTS
행 단위 vs 그룹 단위 사고
| 구분 | 적용 위치 | 동작 |
|---|---|---|
| WHERE / CASE | 개별 행(row) | 각 행마다 조건 판단 |
| GROUP BY 이후 | 그룹 전체 | 여러 행을 하나의 결과로 집계 |
👉 핵심: CAR_ID별 “하나라도 대여중인 기록이 있는가”를 판단하려면 그룹 단위 집계 필요
틀렸던 포인트 & 교훈
문제 4 (리프 노드)
LEFT JOIN 시 NULL 검사 컬럼 혼동 - 처음에 T.PARENT_ITEM_ID IS NULL로 걸러내려고 했으나, 원래부터 부모가 없는 ROOT 아이템까지 잘못 걸러질 수 있음 - 매칭 여부를 확실히 판별하려면 T.ITEM_ID IS NULL(기본키)로 검사해야 정확
GROUP BY vs DISTINCT 의도 전달 - 단순 중복 제거가 목적이라면 GROUP BY보다 DISTINCT가 의도를 더 명확하게 전달
문제 5 (대여 여부)
행 단위로만 판단함
-- ❌ 각 행만 판단, CAR_ID 전체 상태를 반영 못함
CASE WHEN START_DATE <= '2022-10-16' THEN ...GROUP BY 잘못 사용
-- ❌ 같은 CAR_ID가 여러 행으로 분리됨
GROUP BY CAR_ID, AVAILABILITY집계 함수 누락 - GROUP BY 했는데 “어떻게 합칠지” 정의 안 함 → SUM / MAX / COUNT 반드시 필요