문자열 가공(UPPER/SUBSTRING/CONCAT)과 날짜 연산(DATEDIFF/DATE_ADD/DATE_FORMAT), 연속 날짜 구간을 다룬다.
링크: https://leetcode.cn/problems/fix-names-in-a-table/
학습 포인트: 첫 글자만 대문자, 나머지는 소문자로 바꾸는 문자열 가공.
Users(user_id PK, name) 테이블이 있다. name은 대소문자가 뒤섞여 있다.
각 이름을 첫 글자만 대문자, 나머지는 소문자로 고쳐서 user_id 순으로 출력하라.
예: aLICE → Alice, bOB → Bob
LEFT(name, 1) — 왼쪽 1글자(첫 글자)를 잘라낸다.UPPER(...) — 첫 글자를 대문자로.SUBSTRING(name, 2) — 2번째 문자부터 끝까지 잘라낸다. MySQL의 문자열 인덱스는 1부터 시작하므로 2가 두 번째 글자다.LOWER(...) — 나머지를 전부 소문자로.CONCAT(...) — 두 조각을 이어 붙인다.LEFT(str, n) / SUBSTRING(str, pos [, len]): MySQL 문자열 위치는 1-based.UPPER / LOWER: 대소문자 변환.CONCAT: 문자열 연결. 인자 중 하나라도 NULL이면 결과가 NULL이 된다.SUBSTRING(name, 1)로 쓰면 전체 문자열이 잘려 소문자가 두 번 붙는다. 반드시 2부터.SUBSTR(name, 2, 100)처럼 길이를 억지로 넣지 않아도 된다. 세 번째 인자를 생략하면 끝까지 반환한다.SUBSTRING은 SUBSTR, MID와 동의어다. 아무거나 써도 된다.SUBSTRING_INDEX + 재귀/함수가 필요하지만, 이 문제는 단일 단어라 단순 처리로 충분하다.링크: https://leetcode.cn/problems/patients-with-a-condition/
학습 포인트: 공백으로 구분된 코드 목록에서 접두사(prefix) 매칭 — LIKE의 함정.
Patients(patient_id PK, patient_name, conditions). conditions는 공백으로 구분된 여러 질병 코드 문자열이다(예: 'DIAB100 MYOP200').
Type I Diabetes 코드는 접두사 DIAB1로 시작한다. conditions 안에 DIAB1로 시작하는 코드가 하나라도 있는 환자를 찾아라.
코드가 하나의 문자열 안에 공백으로 여러 개 나열되어 있으므로, DIAB1이 어떤 코드의 맨 앞에 오는 경우는 두 가지뿐이다.
LIKE 'DIAB1%' — 문자열 전체의 맨 앞이 DIAB1인 경우 (첫 번째 코드).LIKE '% DIAB1%' — 앞에 공백이 있고 그 뒤가 DIAB1인 경우 (두 번째 이후 코드).이 두 조건을 OR로 묶으면 "단어 경계에서 시작하는 DIAB1"만 정확히 잡는다.
%는 0글자 이상, _는 정확히 1글자를 의미하는 LIKE 와일드카드.WHERE conditions LIKE '%DIAB1%' 한 줄만 쓰면 틀린다.
'%DIAB1%'는 문자열 아무 위치에나 DIAB1이 있으면 매칭한다. 예를 들어 'ACADIAB1'이나 'XDIAB100'처럼 다른 코드의 중간에 우연히 DIAB1이 들어가도 오탐(false positive)한다.DIAB1%)" 또는 "공백 뒤(% DIAB1%)"로 시작 위치를 강제해야 한다. 앞 공백 이 단어 경계를 보장하는 핵심이다.' DIAB1%'처럼 공백만 있는 조건 하나만 쓰면, 첫 번째 코드(앞에 공백이 없음)를 놓친다.WHERE conditions REGEXP '\\bDIAB1' (단어 경계 \b). MySQL 8은 REGEXP를 지원한다. 다만 LIKE보다 느리고 인덱스 활용이 어렵다.링크: https://leetcode.cn/problems/user-activity-for-the-past-30-days-i/
학습 포인트: 날짜 범위 필터 + COUNT(DISTINCT), 그리고 컬럼에 함수를 씌우지 않는 이유.
Activity(user_id, session_id, activity_date, activity_type). 2019-07-27을 기준으로 최근 30일(당일 포함) 동안, 날짜별 활동한 고유 사용자 수를 구하라.
"최근 30일"은 2019-06-28 ~ 2019-07-27 구간이다. 활동이 없는 날은 출력하지 않는다.
BETWEEN '2019-06-28' AND '2019-07-27' — 30일 구간을 명시적 상수 범위로 필터한다. BETWEEN은 양 끝을 포함(inclusive)하므로 6/28부터 7/27까지 딱 30일이다.GROUP BY activity_date — 날짜별로 묶는다.COUNT(DISTINCT user_id) — 한 날에 같은 사용자가 여러 세션을 열었을 수 있으므로, 중복 제거하여 고유 사용자만 센다.BETWEEN a AND b는 >= a AND <= b와 동일(양 끝 포함).COUNT(DISTINCT ...): 중복 제거 후 개수. 여기서는 사용자 유니크 카운트가 핵심.WHERE DATEDIFF('2019-07-27', activity_date) < 30 같은 식으로 쓰면 결과가 미묘하게 틀리기 쉽고(경계 오차), 더 중요하게는 activity_date 컬럼에 함수를 씌우면 인덱스를 못 탄다(non-sargable). 옵티마이저가 모든 행을 스캔해야 하므로 대용량에서 느려진다.activity_date BETWEEN 상수 AND 상수는 컬럼을 그대로 두고 상수와 비교하므로 인덱스 범위 스캔이 가능하다. 필터 조건의 컬럼 쪽은 가공하지 않는 것이 실무 원칙이다.COUNT(user_id)(DISTINCT 없이)로 쓰면 세션 수를 세게 되어 오답.WHERE activity_date > DATE_SUB('2019-07-27', INTERVAL 30 DAY) AND activity_date <= '2019-07-27'처럼 범위의 양 끝을 상수로 계산해 컬럼은 가공하지 않는 형태로 만든다.링크: https://leetcode.cn/problems/report-contiguous-dates/
학습 포인트: 연속 날짜 구간 압축 — gaps and islands(날짜 − ROW_NUMBER 그룹핑).
두 테이블이 있다.
Failed(fail_date): 시스템이 실패한 날짜들Succeeded(success_date): 성공한 날짜들기간은 2019-01-01 ~ 2019-12-31이며, 한 날짜는 실패 또는 성공 중 정확히 하나에만 속한다.
같은 상태(failed/succeeded)가 연속된 날짜 구간을 하나로 묶어 period_state, start_date, end_date를 start_date 순으로 출력하라.
예: 실패가 1/1, 1/2, 1/3 연속이면 → (failed, 2019-01-01, 2019-01-03) 한 행.
1단계: 두 로그를 합친다.
UNION ALL로 실패/성공을 period_state, dt 두 컬럼짜리 하나의 스트림으로 만든다.
2단계: gaps-and-islands 핵심 아이디어.
같은 상태 안에서 날짜를 정렬한 뒤 ROW_NUMBER()(1,2,3,...)를 매긴다.
연속된 날짜라면 날짜도 1씩 증가하고 행번호도 1씩 증가한다. 따라서 날짜 − 행번호는 연속 구간 내내 같은 값으로 고정된다. 구간이 끊기면(하루라도 건너뛰면) 이 값이 바뀐다.
failed 상태를 예로 표로 보자. grp = 날짜 − rn(일 단위 뺄셈):
| dt (fail_date) | rn | dt − rn (grp) |
|---|---|---|
| 2019-01-01 | 1 | 2018-12-31 |
| 2019-01-02 | 2 | 2018-12-31 |
| 2019-01-03 | 3 | 2018-12-31 |
| 2019-01-06 | 4 | 2019-01-02 |
| 2019-01-07 | 5 | 2019-01-02 |
grp가 2018-12-31로 동일 → 한 섬(island).grp가 2019-01-02로 바뀜 → 새 섬.3단계: 같은 (period_state, grp)끼리 묶어 최솟값을 start_date, 최댓값을 end_date로 집계한다.
period_state를 그룹 키에 포함하는 이유: 실패 섬과 성공 섬의 grp 값이 우연히 겹칠 수 있으므로 상태별로 분리해야 한다.
INTERVAL rn DAY를 빼면 날짜형 그룹 키가 나온다. 정수 날짜(TO_DAYS(dt) - rn)로 만들어도 결과는 동일하다.ROW_NUMBER() OVER (PARTITION BY ... ORDER BY ...): 그룹별 순번.PARTITION BY period_state를 빠뜨리면 실패/성공이 섞여 순번이 뒤엉킨다.GROUP BY에 grp만 넣고 period_state를 빼면 서로 다른 상태의 섬이 합쳐질 수 있다.2019 한 해)를 필터하지 않으면 범위 밖 데이터가 섞일 수 있으니 확인한다.DATE_SUB(dt, INTERVAL rn DAY) 대신 DATEDIFF(dt, '2000-01-01') - rn(정수)로 그룹 키를 만들어도 된다. 정수 비교가 더 가볍다.링크: https://leetcode.cn/problems/active-users/
학습 포인트: 5일 연속 로그인 판정 — self join 또는 날짜−행번호 그룹핑.
Accounts(id PK, name), Logins(id, login_date). Logins는 중복 로그인 행이 있을 수 있다.
연속 5일(이상) 이상 로그인한 적이 있는 사용자의 id, name을 id 순으로 출력하라. 하루에 여러 번 로그인해도 하루로 센다.
SELECT DISTINCT id, login_date — 핵심 전처리. 한 사용자가 같은 날 여러 번 로그인한 중복을 제거해 "1일 = 1행"으로 만든다. 이걸 빠뜨리면 연속 판정이 깨진다.login_date − ROW_NUMBER()가 같으면 연속된 날짜다.GROUP BY id, grp 후 HAVING COUNT(*) >= 5 — 같은 연속 섬에 날짜가 5개 이상이면 5일 연속 로그인.Accounts와 조인해 이름을 붙이고 DISTINCT로 중복 제거.HAVING COUNT(*) >= N만 바꾸면 된다.DISTINCT 없이 순번을 매기면 같은 날에 rn이 2씩 늘어 grp가 어긋나고 연속 판정이 틀린다.DATEDIFF로 판단할 때 경계(정확히 4일 차이 = 5일 구간)를 헷갈리기 쉽다.INTERVAL 4 DAY가 "당일 포함 5일"을 뜻하는 점에 주의.링크: https://leetcode.cn/problems/sales-analysis-iii/
학습 포인트: "특정 기간에만" 팔린 항목 — MIN/MAX로 판별하는 HAVING.
Product(product_id PK, product_name, unit_price), Sales(seller_id, product_id, buyer_id, sale_date, quantity, price).
오직 2019년 봄(2019-01-01 ~ 2019-03-31) 사이에만 팔린 상품의 product_id, product_name을 구하라. 즉 그 기간 밖에서는 한 번도 안 팔린 상품.
"오직 봄에만 팔렸다"는 곧 모든 판매 날짜가 봄 구간 안에 있다는 뜻이다. 어떤 상품의 판매 날짜 집합에서
MIN(sale_date)이 2019-01-01 이상이고,MAX(sale_date)이 2019-03-31 이하이면,그 사이의 모든 날짜도 자동으로 구간 안에 있다. 따라서 MIN과 MAX만 검사하면 전체가 구간 내인지 판정된다.
WHERE가 아니라 그룹 단위 조건이므로 HAVING에 둔다.WHERE sale_date BETWEEN ...으로 필터하면 틀린다. 봄 밖 판매 행만 제거될 뿐, 봄에도 팔리고 여름에도 팔린 상품이 걸러지지 않아 오답이 된다. 반드시 그룹 전체를 HAVING MIN/MAX로 봐야 한다.HAVING 조건 하나만(예: MAX <= '2019-03-31') 쓰면 하한 검증이 빠진다.HAVING SUM(sale_date NOT BETWEEN '2019-01-01' AND '2019-03-31') = 0처럼 "구간 밖 판매 건수가 0"으로도 표현 가능하다. MIN/MAX 방식이 더 직관적이다.링크: https://leetcode.cn/problems/rearrange-products-table/
학습 포인트: 열→행 unpivot을 UNION ALL로 구현, NULL 제외.
Products(product_id, store1, store2, store3). 각 storeN 컬럼에는 해당 매장에서의 가격이 들어 있고, 그 매장에서 안 팔면 NULL이다.
이 와이드(wide) 형태를 롱(long) 형태 (product_id, store, price)로 펼쳐라. 단, 가격이 NULL인 조합(그 매장에서 안 파는 것)은 출력하지 않는다.
store 컬럼에 매장 이름 문자열 리터럴을, price 컬럼에 해당 매장 가격 컬럼을 넣는다.UNION ALL로 세로로 이어 붙인다 → 열이 행으로 펼쳐진다(unpivot).WHERE storeN IS NOT NULL로 안 파는 매장은 제외한다.UNION(중복 제거)이 아니라 UNION ALL을 쓴다: 각 조각은 서로 다른 매장이라 중복이 없고, 중복 제거 정렬 비용을 아낄 수 있다.NULL 제외를 각 조각의 WHERE에 넣지 않으면 안 파는 매장 행이 섞여 나온다.UNION을 쓰면 (드물지만) 완전히 동일한 (product_id, store, price) 행이 합쳐질 수 있고 불필요한 정렬이 생긴다. 여기선 UNION ALL이 맞다.CROSS JOIN으로 매장 목록을 만들고 CASE로 가격을 고르는 방식도 있으나, 컬럼이 몇 개뿐이면 UNION ALL이 가장 명료하다.링크: https://leetcode.cn/problems/user-purchase-platform/
학습 포인트: 사용자·날짜별 플랫폼 집합(desktop/mobile/both) 판정 후 집계 — 촘촘한 단계 설계.
Spending(user_id, spend_date, platform, amount). platform은 'desktop' 또는 'mobile'. (user_id, spend_date, platform)은 유일하다.
각 spend_date마다, 그날 각 사용자가 어떤 플랫폼에서 샀는지에 따라 사용자를 세 부류로 나눈다.
desktop: 그날 데스크톱에서만 구매mobile: 그날 모바일에서만 구매both: 그날 두 플랫폼 모두에서 구매각 (spend_date, platform) 조합에 대해 **총 지출액(total_amount)**과 **해당 사용자 수(total_users)**를 구하라. 그날 특정 부류에 해당하는 사용자가 한 명도 없어도 0으로 출력해야 한다(3부류 × 각 날짜 모두).
1단계 — 사용자·날짜 단위로 부류 판정 (user_day).
같은 사용자가 같은 날 desktop과 mobile을 둘 다 쓰면 both로 합쳐야 한다. 그래서 (user_id, spend_date)로 그룹핑하고:
COUNT(DISTINCT platform) = 2 → 두 플랫폼 다 썼다 → 'both'MIN(platform)이 그 유일한 값(desktop 또는 mobile)을 준다.SUM(amount)로 그날 그 사용자의 총 지출을 미리 합친다. both인 경우 desktop+mobile 금액이 모두 더해져 both의 금액이 된다.
2·3·4단계 — 출력 뼈대(grid) 생성.
"사용자가 0명인 조합도 0으로 출력"하려면, 실제 데이터로부터 집계만 해서는 안 된다(없는 조합은 행 자체가 안 생기기 때문). 그래서 모든 날짜(dates)와 세 부류(platforms)를 CROSS JOIN해 나와야 할 모든 (spend_date, platform) 조합을 먼저 만든다.
5단계 — LEFT JOIN 후 집계.
뼈대(grid)를 왼쪽에 두고 user_day를 LEFT JOIN한다. 매칭되는 사용자가 없으면 오른쪽이 전부 NULL이 되고:
COUNT(u.user_id) — NULL은 세지 않으므로 0.COALESCE(SUM(u.amount), 0) — 합이 NULL이면 0으로 대체.GROUP BY g.spend_date, g.platform으로 조합별 최종 행을 만든다.
COUNT(DISTINCT platform)로 사용자의 그날 플랫폼 사용 개수를 세어 both/단일을 구분.CROSS JOIN으로 미리 만들고 실제 데이터를 LEFT JOIN하는 것이 정석. 집계 대상이 없어도 0행이 유지된다.COUNT(컬럼)은 NULL을 제외, COALESCE로 NULL 합계를 0 치환.Spending을 바로 집계하면 both가 desktop/mobile로 이중 계산되어 사용자 수·금액이 틀린다.user_day만 GROUP BY하면 사용자가 0인 (날짜, 부류) 행이 아예 안 나와 요구사항(0 출력)을 못 지킨다.COUNT(*)를 쓰면 LEFT JOIN에서 매칭 없는 행도 1로 세어 0이어야 할 곳이 1이 된다. 반드시 COUNT(u.user_id)처럼 오른쪽 테이블 컬럼을 센다.Spending에서 뽑는 대신 별도 달력(calendar) 테이블이 있으면 더 견고하다. 여기선 "데이터에 존재하는 날짜"만 요구하므로 DISTINCT spend_date로 충분하다.SUM(CASE ...))로도 풀 수 있지만, both와 "0 출력"이 얽혀 grid 방식이 가장 안전하다.