SQL 튜터 세션 정리

SQL
프로그래머스
CONCAT, 서브쿼리, SUBSTRING, IFNULL, NOT IN, Anti-Join, 조건부 집계 등 활용 (5문제)
Published

2026.04.17

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_ID

CTE vs 인라인 뷰 — 언제 무엇을 쓸까?

방식 특징
CTE 가독성 좋음, 복잡한 쿼리에 유리, 재사용 가능
인라인 뷰 (FROM 절 서브쿼리) TOTAL_SALES 같은 집계값도 함께 SELECT 가능
IN + 서브쿼리 목록 필터링에 적합, 단 집계값은 못 가져옴

HAVING에서 alias 사용 주의

-- ⚠️ MySQL에서는 동작하지만 표준 SQL에서는 오류
HAVING TOTAL_SALES >= 700000  -- SELECT alias 참조

-- ✅ 안전하고 표준적인 작성법
HAVING SUM(PRICE) >= 700000

SQL 실행 순서: 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은 FalseNULL의 비교 결과를 알 수 없어 아무 결과도 반환하지 않음

안티 조인 (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 반드시 필요


다음에 공부할 것