AWS에서 로그나 CSV 파일을 S3에 쌓아 두고, 별도 서버 없이 SQL로 바로 분석하고 싶은 경우가 많습니다. 서버를 띄우지 않아도 되는 서버리스 분석 조합이 바로 Glue Data Catalog + Athena입니다. Glue가 S3 파일의 스키마(테이블 정의)를 관리하고, Athena가 그 정의를 읽어서 SQL을 실행하는 구조입니다.
이번 글에서는 S3에 올린 CSV 두 개를 Glue에 테이블로 등록하고, Athena에서 기본 조회 → JOIN → 집계 → 검증까지 직접 실행합니다. 웹 콘솔 클릭 경로 중심으로 설명하고, 실습 중 자주 발생하는 오류와 해결법도 함께 다룹니다.
실습을 시작하기 전에 아래 세 가지를 먼저 고정합니다. 리전, S3 경로, 데이터 형태가 맞지 않으면 이후 모든 단계에서 문제가 생깁니다.
Athena, Glue, S3를 같은 리전에서 사용합니다. 이번 글은 ap-northeast-2(서울) 기준입니다.
⚠️ 세 서비스의 리전이 하나라도 다르면, Athena에서 테이블이 보이지 않거나 쿼리가 실행되지 않습니다. 콘솔 우측 상단의 리전 표시를 반드시 확인합니다.
| 용도 | 경로 |
|---|---|
| 로그 데이터 | s3://<bucket>/samplelog/ |
| 타이틀 매핑 데이터 | s3://<bucket>/titles/ |
| Athena 쿼리 결과 저장 | s3://<bucket>/athena-results/ |
<bucket> 부분은 본인이 만든 버킷 이름으로 바꿉니다. 세 경로 모두 같은 버킷 안에 두면 관리가 편합니다.
이번 실습에서 사용하는 CSV 파일은 두 개입니다.
samplelog.csv — 사용자 로그 테이블
title_id,country,device_type,device_ts
101,KR,mobile,2026-04-01 10:03:22
101,KR,tv,2026-04-01 10:12:10
102,US,web,2026-04-01 11:01:00
103,JP,mobile,2026-04-02 09:30:00
102,KR,mobile,2026-04-02 14:22:11
titles.csv — 타이틀 매핑 테이블
title_id,title
101,드라마A
102,예능B
103,영화C
두 파일 모두 첫 번째 줄이 헤더(컬럼명)입니다. S3에 업로드할 때 각각 samplelog/, titles/ 폴더 안에 넣습니다.
⚠️ 폴더 안에 CSV 파일만 있어야 합니다. 관계없는 파일이 섞여 있으면 Athena가 읽다가 오류를 냅니다.
작업 순서를 먼저 잡고 시작합니다.
| 순서 | 작업 | 도구 |
|---|---|---|
| ① | S3에 CSV 업로드 | S3 콘솔 |
| ② | Athena 결과 저장 위치 설정 | Athena 콘솔 |
| ③ | Glue에서 Database 생성 | Glue 콘솔 |
| ④ | Glue에서 테이블 2개 수동 생성 | Glue 콘솔 |
| ⑤ | Athena에서 테이블 인식 확인 | Athena 쿼리 |
| ⑥ | 기본 조회 (LIMIT, COUNT) | Athena 쿼리 |
| ⑦ | JOIN + 집계 쿼리 | Athena 쿼리 |
| ⑧ | 결과 검증 | Athena 쿼리 |
비유하자면, Glue는 도서관의 카탈로그(목록표)이고 Athena는 그 목록표를 보고 실제 책(S3 데이터)을 찾아 읽는 사서입니다. 카탈로그가 틀리면 사서가 엉뚱한 책을 가져오거나 아예 찾지 못합니다.
Athena에서 쿼리를 실행하려면, 결과를 저장할 S3 경로가 반드시 지정되어 있어야 합니다. 이 설정이 빠지면 쿼리 자체가 실행되지 않습니다.
s3://<bucket>/athena-results/ 입력| 확인 항목 | 기대 결과 |
|---|---|
| Query result location 값 | s3://<bucket>/athena-results/ 가 표시됨 |
| Settings 저장 후 Query editor 복귀 | 오류 메시지 없음 |
⚠️ 이 단계를 건너뛰면 이후 모든 쿼리에서
No output location provided오류가 발생합니다. 처음 한 번만 설정하면 됩니다.
Glue Database는 테이블을 묶어서 관리하는 이름 공간입니다.
media_analytics 입력| 확인 항목 | 기대 결과 |
|---|---|
| Databases 목록 | media_analytics가 표시됨 |
Glue 테이블을 만드는 방법은 크게 두 가지입니다. Crawler를 돌려서 자동 생성하거나, 콘솔에서 수동으로 만들 수 있습니다. 이번 글에서는 수동 생성을 먼저 다룹니다. 수동으로 해 봐야 스키마가 어떻게 구성되는지 직접 확인할 수 있습니다.
아래 값을 순서대로 입력합니다.
| 항목 | 값 |
|---|---|
| Table name | samplelog |
| Database | media_analytics (드롭다운에서 선택) |
| Data store | S3 |
| S3 path | s3://<bucket>/samplelog/ ← 마지막 슬래시 포함 |
| Data format | CSV |
| Delimiter | , (Comma) |
다음 단계에서 컬럼을 추가합니다.
| 컬럼 이름 | 타입 | 설명 |
|---|---|---|
title_id | int | 타이틀 식별자 |
country | string | 국가 코드 |
device_type | string | 디바이스 종류 |
device_ts | string | 이벤트 발생 시각 |
device_ts를 string으로 두는 이유: timestamp 타입으로 설정하면 CSV의 날짜 포맷이 조금이라도 다를 때 파싱 오류가 납니다. string으로 먼저 등록하고, Athena 쿼리에서
DATE_PARSE함수로 변환하는 편이 디버깅이 훨씬 쉽습니다.
마지막으로 Table properties에서 아래 항목을 반드시 설정합니다.
| 속성 | 값 | 이유 |
|---|---|---|
skip.header.line.count | 1 | CSV 첫 줄(헤더)을 데이터로 읽지 않도록 건너뜀 |
⚠️ 이 속성을 빠뜨리면
title_id,country,device_type,device_ts라는 문자열이 첫 번째 데이터 행으로 잡힙니다. 조회할 때 숫자 컬럼에 문자열이 들어 있어서 CAST 오류가 발생합니다.
Create 클릭으로 테이블 생성을 완료합니다.
같은 방식으로 두 번째 테이블을 만듭니다.
| 항목 | 값 |
|---|---|
| Table name | titles |
| Database | media_analytics |
| Data store | S3 |
| S3 path | s3://<bucket>/titles/ |
| Data format | CSV |
| Delimiter | , |
| 컬럼 이름 | 타입 |
|---|---|
title_id | int |
title | string |
Table properties에 skip.header.line.count = 1도 동일하게 설정합니다.
⚠️ title_id 타입은 반드시 samplelog와 동일하게
int로 맞춥니다. 한쪽이int이고 다른 쪽이string이면 JOIN 결과가 0건으로 나옵니다. 이 실수가 가장 흔합니다.
Glue에서 테이블을 만들었으면, Athena에서 제대로 인식되는지부터 확인합니다.
Athena Query editor에서 아래 쿼리를 하나씩 실행합니다.
⚠️ Athena는 한 번에 SQL 1개만 실행할 수 있습니다. 세미콜론으로 구분해서 2개를 붙여 넣으면
Only one sql statement is allowed오류가 발생합니다.
SHOW DATABASES;
결과에 media_analytics가 보여야 합니다.
SHOW TABLES IN media_analytics;
결과에 samplelog, titles 두 개가 보여야 합니다.
DESCRIBE media_analytics.samplelog;
DESCRIBE media_analytics.titles;
| 확인 항목 | 기대 결과 |
|---|---|
| samplelog 컬럼 | title_id(int), country(string), device_type(string), device_ts(string) |
| titles 컬럼 | title_id(int), title(string) |
| title_id 타입 | 양쪽 모두 int |
컬럼 타입이 다르면 Glue 콘솔에서 해당 테이블의 Edit schema로 들어가서 수정한 뒤 다시 확인합니다.
SELECT * FROM media_analytics.samplelog LIMIT 10;
SELECT * FROM media_analytics.titles LIMIT 10;
성공 판정 기준: 두 쿼리 모두 데이터 행이 출력되고, 헤더 행(title_id,country,...)이 데이터로 섞여 있지 않아야 합니다.
SELECT COUNT(*) AS samplelog_cnt FROM media_analytics.samplelog;
SELECT COUNT(*) AS titles_cnt FROM media_analytics.titles;
S3에 올린 CSV 행 수(헤더 제외)와 일치하는지 비교합니다.
데이터 품질을 빠르게 점검하는 쿼리입니다.
-- samplelog 테이블 NULL/빈값 점검
SELECT
SUM(CASE WHEN title_id IS NULL THEN 1 ELSE 0 END) AS null_title_id,
SUM(CASE WHEN country IS NULL OR country = '' THEN 1 ELSE 0 END) AS null_country,
SUM(CASE WHEN device_type IS NULL OR device_type = '' THEN 1 ELSE 0 END) AS null_device_type,
SUM(CASE WHEN device_ts IS NULL OR device_ts = '' THEN 1 ELSE 0 END) AS null_device_ts
FROM media_analytics.samplelog;
성공 판정 기준: 모든 값이 0이면 정상입니다. 0이 아니면 S3 원본 CSV를 열어서 빈 셀이나 누락된 값을 확인합니다.
기본 조회가 정상이면 두 테이블을 조인하고, 집계 쿼리를 실행합니다.
samplelog의 title_id와 titles의 title_id를 기준으로 조인합니다.
-- samplelog + titles 기본 조인
SELECT
s.title_id,
t.title,
s.country,
s.device_type,
s.device_ts
FROM media_analytics.samplelog s
JOIN media_analytics.titles t
ON s.title_id = t.title_id
LIMIT 100;
성공 판정 기준: 결과가 1건 이상 나와야 합니다. 0건이면 title_id 타입 불일치를 의심합니다.
만약 한쪽 title_id가
string이고 다른 쪽이int여서 0건이 나온다면, 아래처럼 CAST로 임시 해결할 수 있습니다. 하지만 근본적으로는 Glue 테이블 정의에서 타입을 통일하는 것이 맞습니다.
-- title_id 타입이 다를 때 임시 해결
SELECT s.title_id, t.title
FROM media_analytics.samplelog s
JOIN media_analytics.titles t
ON CAST(s.title_id AS VARCHAR) = CAST(t.title_id AS VARCHAR)
LIMIT 20;
-- 타이틀별 조회 건수 (내림차순)
SELECT
t.title,
COUNT(*) AS view_cnt
FROM media_analytics.samplelog s
JOIN media_analytics.titles t
ON s.title_id = t.title_id
GROUP BY t.title
ORDER BY view_cnt DESC;
성공 판정 기준: 타이틀별로 숫자가 나오고, 합계가 samplelog 전체 건수와 같거나 작아야 합니다. titles에 없는 title_id는 JOIN에서 빠지기 때문에 합계가 작을 수 있습니다.
-- 국가별 건수
SELECT
country,
COUNT(*) AS cnt
FROM media_analytics.samplelog
GROUP BY country
ORDER BY cnt DESC;
-- 디바이스 유형별 건수
SELECT
device_type,
COUNT(*) AS cnt
FROM media_analytics.samplelog
GROUP BY device_type
ORDER BY cnt DESC;
device_ts 컬럼은 string 타입이므로, 날짜로 변환해야 일자별 집계가 가능합니다. Athena(Presto 엔진)에서는 DATE_PARSE 함수를 사용합니다.
먼저 변환이 정상적으로 되는지 확인합니다.
-- 날짜 변환 테스트
SELECT
device_ts,
DATE_PARSE(device_ts, '%Y-%m-%d %H:%i:%s') AS parsed_ts
FROM media_analytics.samplelog
LIMIT 10;
변환이 정상이면 일자별 집계를 실행합니다.
-- 일자별 건수
SELECT
DATE(DATE_PARSE(device_ts, '%Y-%m-%d %H:%i:%s')) AS dt,
COUNT(*) AS cnt
FROM media_analytics.samplelog
GROUP BY DATE(DATE_PARSE(device_ts, '%Y-%m-%d %H:%i:%s'))
ORDER BY dt;
⚠️
DATE_PARSE의 포맷 문자열은 실제 데이터 형태와 정확히 일치해야 합니다. 예를 들어 데이터가2026/04/01 10:03:22형태인데 포맷을%Y-%m-%d로 쓰면 파싱 오류가 납니다.
특정 날짜 필터링이 필요한 경우, 문자열 함수를 사용하는 방법도 있습니다. device_ts가 string 타입일 때는 substr이나 split_part가 더 간단합니다.
-- 특정 날짜 필터 (방법 1: substr)
SELECT *
FROM media_analytics.samplelog
WHERE substr(device_ts, 1, 10) = '2026-04-01';
-- 특정 날짜 필터 (방법 2: split_part)
SELECT *
FROM media_analytics.samplelog
WHERE split_part(device_ts, ' ', 1) = '2026-04-01';
⚠️
WHERE date(device_ts) = date '2026-04-01'처럼string을date로 직접 캐스팅하면INVALID_CAST_ARGUMENT오류가 발생합니다. device_ts가 string 타입인 경우,DATE_PARSE를 거치거나 문자열 함수로 비교해야 합니다.
분석 쿼리를 돌린 뒤, 결과가 맞는지 확인하는 루틴입니다. 아래 순서대로 점검하면 "쿼리는 돌아가는데 결과가 이상한" 상태를 빠르게 잡아낼 수 있습니다.
| 순서 | 검증 항목 | 쿼리/방법 |
|---|---|---|
| 1 | 샘플 10건 조회 | SELECT * FROM ... LIMIT 10 — 컬럼명, 값 형태 확인 |
| 2 | 전체 건수 | COUNT(*) — S3 원본 행 수와 비교 |
| 3 | NULL/빈값 | 위 4-4 쿼리 — 0이 아니면 원본 확인 |
| 4 | JOIN 매핑 | 기본 JOIN 결과가 1건 이상인지 확인 |
| 5 | 타이틀별 집계 | 합계가 전체 건수 이하인지 확인 |
| 6 | 국가/디바이스 집계 | 합계가 전체 건수와 동일한지 확인 |
| 7 | 일자별 집계 | 날짜 범위가 기대한 기간과 맞는지 확인 |
최종 성공 판정 기준:
LIMIT 10 조회 정상실습 중 자주 발생하는 오류를 표로 정리합니다.
| 증상 | 원인 | 해결 |
|---|---|---|
No output location provided — 쿼리 실행 자체가 안 됨 | Athena 결과 저장 위치 미설정 | Athena Settings → Query result location에 S3 경로 입력 |
Only one sql statement is allowed | SQL 2개를 세미콜론으로 이어 붙여 실행 | 한 번에 1개씩 실행 |
INVALID_CAST_ARGUMENT / TYPE_MISMATCH — 날짜 필터에서 발생 | string 컬럼을 date로 직접 캐스팅 | DATE_PARSE 또는 substr/split_part 사용 |
| JOIN 결과 0건 | 양쪽 테이블의 title_id 타입 불일치 (한쪽 int, 한쪽 string) | Glue 테이블 정의에서 타입 통일. 임시로는 CAST(... AS VARCHAR) |
EXPRESSION_NOT_AGGREGATE — GROUP BY 오류 | SELECT에 있는 비집계 컬럼을 GROUP BY에 안 넣음 | SELECT의 비집계 컬럼을 GROUP BY에 모두 포함 |
| 헤더가 첫 번째 데이터 행으로 잡힘 | Glue 테이블의 skip.header.line.count 미설정 | Table properties에서 skip.header.line.count = 1 추가 |
DATE_PARSE 오류 | 포맷 문자열과 실제 데이터 형태 불일치 | 데이터를 LIMIT 10으로 먼저 확인하고 포맷을 정확히 맞춤 |
| 테이블이 Athena에서 안 보임 | 리전 불일치 (Glue와 Athena가 다른 리전) | S3, Glue, Athena 리전을 모두 동일하게 맞춤 |
| Crawler 실행 후 테이블이 안 생김 | S3 입력 경로에 파일이 없음 | S3 경로에 CSV가 실제로 존재하는지 먼저 확인 |
실습을 마치기 전에 아래 항목을 하나씩 점검합니다.
| # | 확인 항목 | 상태 |
|---|---|---|
| 1 | S3, Glue, Athena의 리전이 모두 동일하다 | ☐ |
| 2 | Athena 결과 저장 위치(athena-results/)가 설정되어 있다 | ☐ |
| 3 | Glue database media_analytics가 생성되어 있다 | ☐ |
| 4 | Glue table samplelog, titles 2개가 생성되어 있다 | ☐ |
| 5 | 양쪽 테이블의 title_id 타입이 동일하다 (int) | ☐ |
| 6 | skip.header.line.count = 1이 양쪽 테이블 모두 설정되어 있다 | ☐ |
| 7 | SELECT * LIMIT 10 조회에서 헤더가 데이터로 섞이지 않는다 | ☐ |
| 8 | COUNT(*) 결과가 S3 원본 행 수와 일치한다 | ☐ |
| 9 | JOIN 결과가 0건이 아니다 | ☐ |
| 10 | GROUP BY 쿼리(국가/디바이스/일자)가 오류 없이 실행된다 | ☐ |
Glue + Athena의 핵심은 복잡한 기능이 아니라 기본 정합성입니다. 리전 통일, S3 경로 확인, 스키마 타입 통일, 결과 저장 위치 설정 — 이 네 가지만 먼저 잡으면 대부분의 문제는 쿼리를 쓰기도 전에 예방됩니다.