<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>rudin_.log</title>
        <link>https://velog.io/</link>
        <description>성장하기 위한 기록</description>
        <lastBuildDate>Fri, 08 May 2026 01:46:46 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>rudin_.log</title>
            <url>https://velog.velcdn.com/images/rudin_/profile/98ac20e7-c040-43db-90ed-a1e9818e55b1/social_profile.png</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. rudin_.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/rudin_" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[[MicrosoftDataSchool] 84일차 - Azure DataWarehouse]]></title>
            <link>https://velog.io/@rudin_/MicrosoftDataSchool-84%EC%9D%BC%EC%B0%A8-Azure-DataWarehouse</link>
            <guid>https://velog.io/@rudin_/MicrosoftDataSchool-84%EC%9D%BC%EC%B0%A8-Azure-DataWarehouse</guid>
            <pubDate>Fri, 08 May 2026 01:46:46 GMT</pubDate>
            <description><![CDATA[<h1 id="azure-data-warehouse">Azure Data Warehouse</h1>
<h2 id="데이터-웨어하우스dw란">데이터 웨어하우스(DW)란</h2>
<p>데이터 웨어하우스(Data Warehouse)는 운영 시스템에 분산된 데이터를 분석 목적에 최적화된 형태로 통합·정제·보관하는 저장소이다.</p>
<p>Bill Inmon은 데이터 웨어하우스를 다음 네 가지 특성으로 정의했다.</p>
<table>
<thead>
<tr>
<th>특성</th>
<th>의미</th>
<th>OLTP와의 차이</th>
</tr>
</thead>
<tbody><tr>
<td>주제 지향(Subject-Oriented)</td>
<td>거래 단위가 아니라 고객, 상품, 이용 같은 분석 주제 중심으로 구성</td>
<td>OLTP는 트랜잭션 중심</td>
</tr>
<tr>
<td>통합(Integrated)</td>
<td>여러 시스템의 코드·단위·정의를 표준화</td>
<td>시스템별 자체 코드 사용</td>
</tr>
<tr>
<td>시간 가변(Time-Variant)</td>
<td>과거 시점 데이터와 이력을 유지</td>
<td>OLTP는 현재값 중심</td>
</tr>
<tr>
<td>비휘발성(Non-Volatile)</td>
<td>적재 후 수정·삭제보다 추가 중심</td>
<td>OLTP는 UPDATE/DELETE 빈번</td>
</tr>
</tbody></table>
<p>OLTP가 “현재 거래를 빠르고 정확하게 처리”하는 시스템이라면,
DW는 “장기간 데이터를 누적해 패턴과 추세를 분석”하는 시스템이다.</p>
<p>따라서:</p>
<ul>
<li>스키마 설계</li>
<li>인덱스 전략</li>
<li>저장 구조</li>
<li>하드웨어 구성</li>
<li>튜닝 방식</li>
</ul>
<p>모두 OLTP와 다르게 접근해야 한다.</p>
<hr>
<h2 id="oltp-vs-olap-워크로드-비교">OLTP vs OLAP 워크로드 비교</h2>
<table>
<thead>
<tr>
<th>관점</th>
<th>OLTP</th>
<th>OLAP / DW</th>
</tr>
</thead>
<tbody><tr>
<td>주 사용자</td>
<td>거래 시스템(POS, 예약, 결제)</td>
<td>분석가, BI, 경영진</td>
</tr>
<tr>
<td>쿼리 특성</td>
<td>단건 조회·갱신</td>
<td>대규모 집계·스캔</td>
</tr>
<tr>
<td>인덱스 전략</td>
<td>B-Tree 중심</td>
<td>Columnstore 중심</td>
</tr>
<tr>
<td>스키마</td>
<td>정규화(3NF)</td>
<td>스타/스노우플레이크</td>
</tr>
<tr>
<td>데이터 신선도</td>
<td>초·밀리초</td>
<td>분·시간·일 단위</td>
</tr>
<tr>
<td>동시성</td>
<td>짧은 트랜잭션 다수</td>
<td>긴 분석 쿼리 소수</td>
</tr>
<tr>
<td>저장 방식</td>
<td>Row Store 중심</td>
<td>Column Store 권장</td>
</tr>
</tbody></table>
<hr>
<h1 id="차원-모델링dimensional-modeling">차원 모델링(Dimensional Modeling)</h1>
<p>차원 모델링은 Ralph Kimball이 정립한 DW 설계 방식이다.</p>
<p>핵심 목표는:</p>
<blockquote>
<p>“사용자가 어떤 측정값을 어떤 관점에서 보고 싶어하는가?”</p>
</blockquote>
<p>를 직관적으로 표현하는 것이다.</p>
<hr>
<h2 id="팩트fact와-디멘션dimension">팩트(Fact)와 디멘션(Dimension)</h2>
<table>
<thead>
<tr>
<th>테이블 종류</th>
<th>역할</th>
<th>따릉이 예시</th>
</tr>
</thead>
<tbody><tr>
<td>팩트(Fact)</td>
<td>측정값 저장. 행 수가 매우 많음</td>
<td>FactRental</td>
</tr>
<tr>
<td>디멘션(Dimension)</td>
<td>분석 관점 제공</td>
<td>DimStation, DimDate</td>
</tr>
<tr>
<td>브릿지/팩트리스 팩트</td>
<td>다대다 관계 표현</td>
<td>본 과정 미사용</td>
</tr>
</tbody></table>
<h3 id="factrental-예시">FactRental 예시</h3>
<ul>
<li>대여 1건 = 1행</li>
<li>이용 시간</li>
<li>이동 거리</li>
<li>탄소 절감량</li>
</ul>
<p>등 숫자형 측정값 중심.</p>
<h3 id="디멘션-예시">디멘션 예시</h3>
<ul>
<li>언제?</li>
<li>어디서?</li>
<li>누가?</li>
<li>어떤 유형?</li>
</ul>
<p>같은 분석 관점을 제공한다.</p>
<hr>
<h2 id="스타-스키마-vs-스노우플레이크">스타 스키마 vs 스노우플레이크</h2>
<h3 id="스타-스키마">스타 스키마</h3>
<p>팩트를 중심에 두고 디멘션이 한 단계로 연결되는 구조.</p>
<pre><code class="language-text">           DimDate
               |
DimUserType - FactRental - DimStation(대여)
               |
          DimStation(반납)
               |
            DimTime</code></pre>
<p>특징:</p>
<ul>
<li>조인 단순</li>
<li>BI 도구 친화적</li>
<li>분석 성능 우수</li>
<li>가장 일반적</li>
</ul>
<hr>
<h3 id="스노우플레이크">스노우플레이크</h3>
<p>디멘션 내부를 다시 정규화한 구조.</p>
<p>예:</p>
<ul>
<li><p>DimStation</p>
<ul>
<li><p>DimDistrict</p>
<ul>
<li>DimCity</li>
</ul>
</li>
</ul>
</li>
</ul>
<p>장점:</p>
<ul>
<li>저장 공간 절약</li>
<li>정규화 수준 높음</li>
</ul>
<p>단점:</p>
<ul>
<li>조인 증가</li>
<li>분석 성능 저하</li>
<li>BI 가독성 저하</li>
</ul>
<p>실무에서는:</p>
<blockquote>
<p>스타 스키마를 기본으로 하고,
디멘션 규모가 매우 클 때만 부분 스노우플레이크를 적용한다.</p>
</blockquote>
<hr>
<h1 id="scdslowly-changing-dimension">SCD(Slowly Changing Dimension)</h1>
<p>디멘션 데이터는 시간이 지나며 변경된다.</p>
<p>예:</p>
<ul>
<li>대여소 이름 변경</li>
<li>자치구 변경</li>
<li>거치대 수 변경</li>
</ul>
<p>이런 변경 이력을 어떻게 관리할지 정의하는 것이 SCD 전략이다.</p>
<hr>
<h2 id="scd-type-비교">SCD Type 비교</h2>
<table>
<thead>
<tr>
<th>타입</th>
<th>동작</th>
<th>특징</th>
<th>본 과정 사용</th>
</tr>
</thead>
<tbody><tr>
<td>Type 0</td>
<td>변경 금지</td>
<td>단순</td>
<td>DimDate</td>
</tr>
<tr>
<td>Type 1</td>
<td>현재값 덮어쓰기</td>
<td>이력 없음</td>
<td>코드 정정</td>
</tr>
<tr>
<td>Type 2</td>
<td>행 추가 + 이력 유지</td>
<td>가장 중요</td>
<td>DimStation</td>
</tr>
<tr>
<td>Type 3</td>
<td>이전값 컬럼 유지</td>
<td>1단계 이력만</td>
<td>미사용</td>
</tr>
<tr>
<td>Type 6</td>
<td>Hybrid</td>
<td>복잡</td>
<td>미사용</td>
</tr>
</tbody></table>
<hr>
<h2 id="scd-type-2">SCD Type 2</h2>
<p>Type 2는 기존 행을 수정하지 않고 새로운 행을 추가한다.</p>
<p>주요 컬럼:</p>
<table>
<thead>
<tr>
<th>컬럼</th>
<th>역할</th>
</tr>
</thead>
<tbody><tr>
<td>EffectiveStart</td>
<td>시작 시점</td>
</tr>
<tr>
<td>EffectiveEnd</td>
<td>종료 시점</td>
</tr>
<tr>
<td>IsCurrent</td>
<td>현재 유효 여부</td>
</tr>
</tbody></table>
<p>예:</p>
<table>
<thead>
<tr>
<th>StationId</th>
<th>RackCount</th>
<th>EffectiveStart</th>
<th>EffectiveEnd</th>
<th>IsCurrent</th>
</tr>
</thead>
<tbody><tr>
<td>ST-001</td>
<td>10</td>
<td>2025-01-01</td>
<td>2025-09-01</td>
<td>0</td>
</tr>
<tr>
<td>ST-001</td>
<td>15</td>
<td>2025-09-01</td>
<td>NULL</td>
<td>1</td>
</tr>
</tbody></table>
<p>이 방식으로:</p>
<ul>
<li>과거 상태 유지</li>
<li>시점 분석 가능</li>
<li>히스토리 추적 가능</li>
</ul>
<p>해진다.</p>
<hr>
<h2 id="lamda-kappa">Lamda Kappa</h2>
<table>
<thead>
<tr>
<th>항목</th>
<th>Lambda</th>
<th>Kappa</th>
</tr>
</thead>
<tbody><tr>
<td>처리 방식</td>
<td>Batch + Stream</td>
<td>Stream Only</td>
</tr>
<tr>
<td>실시간성</td>
<td>좋음</td>
<td>매우 좋음</td>
</tr>
<tr>
<td>정확성</td>
<td>매우 높음</td>
<td>높음</td>
</tr>
<tr>
<td>구조 복잡도</td>
<td>높음</td>
<td>낮음</td>
</tr>
<tr>
<td>개발 난이도</td>
<td>높음</td>
<td>상대적으로 쉬움</td>
</tr>
<tr>
<td>재처리 방식</td>
<td>Batch 재계산</td>
<td>Kafka replay</td>
</tr>
<tr>
<td>대표 기술</td>
<td>Hadoop + Spark</td>
<td>Kafka + Flink</td>
</tr>
</tbody></table>
<h3 id="azure-기준-예시">Azure 기준 예시</h3>
<h4 id="lambda-architecture">Lambda Architecture</h4>
<pre><code>Event Hub
 ├─ Azure Stream Analytics → 실시간 대시보드
 └─ Databricks Batch → 정산/통계</code></pre><p>사용자 구조랑 비슷하게 보면:
KMA/AirKorea
→ Azure Function
→ Event Hub
→ ASA (실시간)
→ PostgreSQL</p>
<ul>
<li>Databricks 배치 분석</li>
</ul>
<h4 id="kappa-architecture">Kappa Architecture</h4>
<pre><code>Kafka/Event Hub
→ Flink/ASA
→ PostgreSQL/Power BI</code></pre><p>배치 없이:</p>
<ul>
<li>스트림만 계속 처리</li>
<li>필요 시 이벤트 재생(replay)</li>
</ul>
<hr>
<h1 id="azure-dw-선택지">Azure DW 선택지</h1>
<p>Azure에서 DW를 구축할 때는 다양한 선택지가 존재한다.</p>
<hr>
<h2 id="azure-sql-database--serverless">Azure SQL Database — Serverless</h2>
<h3 id="특징">특징</h3>
<ul>
<li>자동 일시 중지</li>
<li>사용량 기반 과금</li>
<li>자동 스케일</li>
<li>운영 부담 최소</li>
</ul>
<h3 id="장점">장점</h3>
<ul>
<li>개발/교육 환경 비용 절감</li>
<li>유휴 시간 과금 최소화</li>
<li>PaaS 기반 자동 운영</li>
</ul>
<h3 id="단점">단점</h3>
<ul>
<li>콜드 스타트 발생</li>
<li>vCore 상한 존재</li>
</ul>
<h3 id="본-과정-메인-플랫폼">본 과정 메인 플랫폼</h3>
<hr>
<h2 id="azure-sql-database--provisioned">Azure SQL Database — Provisioned</h2>
<h3 id="특징-1">특징</h3>
<ul>
<li>상시 가동</li>
<li>일정한 응답 성능</li>
<li>Hyperscale 가능</li>
</ul>
<h3 id="장점-1">장점</h3>
<ul>
<li>안정적 응답 시간</li>
<li>대규모 운영 적합</li>
</ul>
<h3 id="단점-1">단점</h3>
<ul>
<li>유휴 시간도 비용 발생</li>
</ul>
<hr>
<h2 id="azure-sql-managed-instance">Azure SQL Managed Instance</h2>
<h3 id="특징-2">특징</h3>
<ul>
<li>SQL Server와 거의 동일</li>
<li>SQL Agent 지원</li>
<li>크로스 DB 쿼리 가능</li>
</ul>
<h3 id="장점-2">장점</h3>
<ul>
<li>기존 SQL Server 이전 용이</li>
<li>높은 호환성</li>
</ul>
<h3 id="단점-2">단점</h3>
<ul>
<li>비용 높음</li>
<li>프로비저닝 느림</li>
</ul>
<hr>
<h2 id="azure-sql-on-vm">Azure SQL on VM</h2>
<h3 id="특징-3">특징</h3>
<ul>
<li>IaaS 기반</li>
<li>OS 직접 관리</li>
<li>SQL Server 전체 기능 사용 가능</li>
</ul>
<h3 id="장점-3">장점</h3>
<ul>
<li>FCI</li>
<li>Replication</li>
<li>Linked Server</li>
<li>CLR</li>
</ul>
<p>등 전체 기능 사용 가능.</p>
<h3 id="단점-3">단점</h3>
<ul>
<li>패치 책임 직접 부담</li>
<li>백업/HA 직접 구성</li>
</ul>
<hr>
<h2 id="microsoft-fabric-warehouse">Microsoft Fabric Warehouse</h2>
<h3 id="특징-4">특징</h3>
<ul>
<li>SaaS 기반</li>
<li>OneLake 통합</li>
<li>Power BI 친화적</li>
</ul>
<h3 id="장점-4">장점</h3>
<ul>
<li>데이터·BI 통합 우수</li>
</ul>
<h3 id="단점-4">단점</h3>
<ul>
<li>Capacity 기반 과금</li>
<li>기능 변화가 빠름</li>
</ul>
<hr>
<h1 id="의사결정-매트릭스">의사결정 매트릭스</h1>
<table>
<thead>
<tr>
<th>조건</th>
<th>추천</th>
</tr>
</thead>
<tbody><tr>
<td>SQL Server 전체 기능 필요</td>
<td>SQL on VM</td>
</tr>
<tr>
<td>온프레미스 거의 그대로 이전</td>
<td>Managed Instance</td>
</tr>
<tr>
<td>비용 최적 + 간헐적 사용</td>
<td>SQL DB Serverless</td>
</tr>
<tr>
<td>일정한 응답 성능 필요</td>
<td>Provisioned</td>
</tr>
<tr>
<td>Power BI 중심 SaaS 환경</td>
<td>Fabric</td>
</tr>
</tbody></table>
<hr>
<h1 id="azure-sql-db-serverless-동작-원리">Azure SQL DB Serverless 동작 원리</h1>
<p>Serverless는 사용한 만큼만 과금되는 컴퓨트 모델이다.</p>
<p>핵심 기능은 다음 세 가지다.</p>
<hr>
<h2 id="자동-일시-중지auto-pause">자동 일시 중지(Auto Pause)</h2>
<p>일정 시간 동안 쿼리나 연결이 없으면:</p>
<ul>
<li>컴퓨트 제거</li>
<li>과금 중단</li>
</ul>
<p>스토리지 비용만 유지된다.</p>
<p>교육/개발 환경에서 매우 유리하다.</p>
<hr>
<h2 id="자동-재개auto-resume">자동 재개(Auto Resume)</h2>
<p>새 연결이 들어오면:</p>
<ul>
<li>자동으로 DB 재개</li>
<li>약 30~60초 콜드 스타트 가능</li>
</ul>
<p>운영 환경에서는:</p>
<ul>
<li>재시도 로직</li>
<li>Keep-alive</li>
<li>워밍 전략</li>
</ul>
<p>등이 필요하다.</p>
<hr>
<h2 id="자동-스케일auto-scale">자동 스케일(Auto Scale)</h2>
<p>최소·최대 vCore 범위를 설정하면:</p>
<ul>
<li>부하에 따라 자동 확장</li>
<li>메모리도 함께 증가</li>
</ul>
<p>한다.</p>
<hr>
<h1 id="storage-→-dw-적재-패턴">Storage → DW 적재 패턴</h1>
<p>DW 적재에서 가장 일반적인 패턴은:</p>
<pre><code class="language-text">CSV → Storage Account → DW</code></pre>
<p>이다.</p>
<p>본 과정에서는 두 가지 표준 패턴을 사용한다.</p>
<hr>
<h2 id="bulk-insert">BULK INSERT</h2>
<p>외부 CSV 파일을 대량 적재하는 전통적 방식.</p>
<h3 id="특징-5">특징</h3>
<ul>
<li>매우 빠름</li>
<li>대량 적재 최적화</li>
<li>staging 적재에 적합</li>
</ul>
<h3 id="흐름">흐름</h3>
<ol>
<li>MASTER KEY 생성</li>
<li>DATABASE SCOPED CREDENTIAL 생성</li>
<li>EXTERNAL DATA SOURCE 생성</li>
<li>BULK INSERT 수행</li>
</ol>
<hr>
<h2 id="openrowsetbulk">OPENROWSET(BULK)</h2>
<p>외부 파일을 가상 테이블처럼 SELECT 하는 방식.</p>
<pre><code class="language-sql">INSERT INTO staging.RentalRaw (rental_id, station_id, started_at, ended_at, duration_min)
SELECT
    JSON_VALUE(c.line, &#39;$.rental_id&#39;),
    CAST(c.station_id AS INT),
    TRY_CONVERT(datetime2, c.started_at),
    TRY_CONVERT(datetime2, c.ended_at),
    c.duration_min
FROM OPENROWSET(
       BULK &#39;2024/2024-01-rental.csv&#39;,
       DATA_SOURCE = &#39;BlobDS&#39;,
       FORMAT = &#39;CSV&#39;,
       FIRSTROW = 2,
       FIELDTERMINATOR = &#39;,&#39;
     ) WITH (
       rental_id     varchar(40),
       station_id    varchar(20),
       started_at    varchar(30),
       ended_at      varchar(30),
       duration_min  int
     ) AS c
WHERE TRY_CONVERT(datetime2, c.started_at) IS NOT NULL;
</code></pre>
<h3 id="특징-6">특징</h3>
<ul>
<li>변환 자유도 높음</li>
<li>INSERT ... SELECT 가능</li>
<li>TRY_CONVERT 활용 가능</li>
</ul>
<h3 id="장점-5">장점</h3>
<ul>
<li>적재 중 정제 가능</li>
<li>필터링 가능</li>
<li>데이터 품질 방어 쉬움</li>
</ul>
<hr>
<h2 id="bulk-insert-vs-openrowset-비교">BULK INSERT vs OPENROWSET 비교</h2>
<table>
<thead>
<tr>
<th>항목</th>
<th>BULK INSERT</th>
<th>OPENROWSET</th>
</tr>
</thead>
<tbody><tr>
<td>속도</td>
<td>매우 빠름</td>
<td>상대적으로 느림</td>
</tr>
<tr>
<td>변환</td>
<td>낮음</td>
<td>높음</td>
</tr>
<tr>
<td>에러 처리</td>
<td>제한적</td>
<td>TRY_CONVERT 가능</td>
</tr>
<tr>
<td>권장 용도</td>
<td>staging 적재</td>
<td>정제·변환 적재</td>
</tr>
</tbody></table>
<hr>
<h2 id="책임-기준-분리">책임 기준 분리</h2>
<table>
<thead>
<tr>
<th>레이어</th>
<th>스키마</th>
<th>책임</th>
<th>예시 객체</th>
</tr>
</thead>
<tbody><tr>
<td>Raw</td>
<td>Storage <code>raw/</code></td>
<td>원본 보관, 변환·삭제 금지</td>
<td>2024/2024-01-rental.csv</td>
</tr>
<tr>
<td>Staging (DB)</td>
<td><code>staging</code></td>
<td>원형 그대로 적재된 1차 테이블, 클렌징·중복 제거 단계</td>
<td>staging.RentalRaw</td>
</tr>
<tr>
<td>Warehouse (DB)</td>
<td><code>dw</code></td>
<td>스타 스키마, 팩트·디멘션 정규 모델</td>
<td>dw.FactRental, dw.DimStation</td>
</tr>
<tr>
<td>Mart (DB)</td>
<td><code>mart</code></td>
<td>분석 사용자용 비정규 집계, 뷰·성능 최우선</td>
<td>mart.vw_HourlyDemand</td>
</tr>
</tbody></table>
<hr>
<h1 id="이벤트-기반-적재-패턴">이벤트 기반 적재 패턴</h1>
<p>배치 적재 이후 단계는 자동화이다.</p>
<p>Azure에서는:</p>
<ul>
<li>Event Grid</li>
<li>Azure Functions</li>
<li>Logic Apps</li>
</ul>
<p>를 조합한다.</p>
<hr>
<h2 id="azure-functions">Azure Functions</h2>
<h3 id="특징-7">특징</h3>
<ul>
<li>코드 기반</li>
<li>Python/C#/JS 지원</li>
<li>유연성 높음</li>
</ul>
<h3 id="적합한-경우">적합한 경우</h3>
<ul>
<li>즉시 처리</li>
<li>복잡한 로직</li>
<li>실시간 이벤트</li>
</ul>
<hr>
<h2 id="azure-logic-apps">Azure Logic Apps</h2>
<h3 id="특징-8">특징</h3>
<ul>
<li>GUI 기반</li>
<li>노코드 워크플로우</li>
<li>다양한 SaaS 연결</li>
</ul>
<h3 id="적합한-경우-1">적합한 경우</h3>
<ul>
<li>스케줄 기반 ETL</li>
<li>알림</li>
<li>오케스트레이션</li>
</ul>
<hr>
<h2 id="event-grid">Event Grid</h2>
<h3 id="역할">역할</h3>
<p>Azure 전체 이벤트 라우팅 백본.</p>
<p>예:</p>
<pre><code class="language-text">Blob 업로드
  ↓
Event Grid
  ↓
Function App
  ↓
Stored Procedure 실행</code></pre>
<hr>
<h1 id="이벤트-기반-적재-구조">이벤트 기반 적재 구조</h1>
<pre><code class="language-text">CSV Upload
    ↓
Blob Storage
    ↓ BlobCreated Event
Event Grid
    ↓
Function App
    ↓
EXEC sp_LoadFactFromBlob
    ↓
Azure SQL Database</code></pre>
<p>이 구조의 핵심은:</p>
<blockquote>
<p>“Storage가 진실의 원본(Source of Truth)”</p>
</blockquote>
<p>이라는 점이다.</p>
<hr>
<h1 id="배치-vs-이벤트-적재-선택-기준">배치 vs 이벤트 적재 선택 기준</h1>
<table>
<thead>
<tr>
<th>상황</th>
<th>권장 방식</th>
</tr>
</thead>
<tbody><tr>
<td>하루 1회 대량 적재</td>
<td>배치</td>
</tr>
<tr>
<td>실시간 데이터 도착</td>
<td>이벤트</td>
</tr>
<tr>
<td>대량 + 실시간 혼합</td>
<td>하이브리드</td>
</tr>
</tbody></table>
<p>실무에서는:</p>
<ul>
<li>야간 대량 적재 = 배치</li>
<li>실시간 보정 = 이벤트</li>
</ul>
<p>조합이 가장 흔하다.</p>
<hr>
<h1 id="전체-아키텍처">전체 아키텍처</h1>
<p>이번 실습의 전체 구조는 다음과 같다.</p>
<pre><code class="language-text">서울시 따릉이 CSV
        ↓
Azure Storage Account
(raw / staging / archive)
        ↓
BULK INSERT / OPENROWSET
        ↓
Azure SQL Database Serverless
(staging / dw / mart)
        ↓
분석 쿼리 / Power BI

추가 자동화:
BlobCreated Event
        ↓
Event Grid
        ↓
Azure Function
        ↓
Stored Procedure 실행
        ↓
DW 자동 적재</code></pre>
<p>핵심은 Storage Account를 원본 데이터 저장소로 두고, Azure SQL Database Serverless를 분석용 DW로 사용하는 것이다.</p>
<hr>
<h1 id="lab-01--azure-sql-database-serverless-생성">Lab 01 — Azure SQL Database Serverless 생성</h1>
<h2 id="리소스-그룹과-sql-server-생성">리소스 그룹과 SQL Server 생성</h2>
<p>먼저 실습에서 사용할 변수들을 정의한다.</p>
<pre><code class="language-bash">RG=rg-dwlab-$USER
LOC=koreacentral
SQL_SRV=sql-dwlab-$USER-$RANDOM
SQL_DB=dw_seoulbike
ADMIN_USER=dwadmin
ADMIN_PASS=&#39;Dw!Lab2026Secure&#39;</code></pre>
<p>리소스 그룹과 SQL Server를 생성한다.</p>
<pre><code class="language-bash">az group create -n $RG -l $LOC

az sql server create \
  --name $SQL_SRV \
  --resource-group $RG \
  --location $LOC \
  --admin-user $ADMIN_USER \
  --admin-password $ADMIN_PASS</code></pre>
<p>현재 접속 IP를 방화벽에 등록한다.</p>
<pre><code class="language-bash">MY_IP=$(curl -s https://api.ipify.org)

az sql server firewall-rule create \
  --resource-group $RG \
  --server $SQL_SRV \
  --name allow-me \
  --start-ip-address $MY_IP \
  --end-ip-address $MY_IP</code></pre>
<hr>
<h2 id="serverless-db-생성">Serverless DB 생성</h2>
<pre><code class="language-bash">az sql db create \
  --resource-group $RG \
  --server $SQL_SRV \
  --name $SQL_DB \
  --edition GeneralPurpose \
  --family Gen5 \
  --compute-model Serverless \
  --min-capacity 0.5 \
  --capacity 2 \
  --auto-pause-delay 60 \
  --backup-storage-redundancy Local \
  --collation Korean_Wansung_CI_AS</code></pre>
<p>주요 옵션은 다음과 같다.</p>
<table>
<thead>
<tr>
<th>옵션</th>
<th>의미</th>
</tr>
</thead>
<tbody><tr>
<td><code>--compute-model Serverless</code></td>
<td>Serverless 계층 사용</td>
</tr>
<tr>
<td><code>--min-capacity 0.5</code></td>
<td>최소 0.5 vCore</td>
</tr>
<tr>
<td><code>--capacity 2</code></td>
<td>최대 2 vCore</td>
</tr>
<tr>
<td><code>--auto-pause-delay 60</code></td>
<td>60분 미사용 시 자동 일시 중지</td>
</tr>
<tr>
<td><code>--backup-storage-redundancy Local</code></td>
<td>교육용 비용 절감</td>
</tr>
</tbody></table>
<p>접속 확인:</p>
<pre><code class="language-bash">sqlcmd -S $SQL_SRV.database.windows.net \
  -d $SQL_DB \
  -U $ADMIN_USER \
  -P &quot;$ADMIN_PASS&quot; \
  -Q &quot;SELECT @@VERSION;&quot;</code></pre>
<hr>
<h1 id="lab-02--serverless-동작-확인">Lab 02 — Serverless 동작 확인</h1>
<h2 id="auto-pause-시간-변경">Auto Pause 시간 변경</h2>
<p>교육 환경에서는 자동 일시 중지를 빠르게 확인하기 위해 15분으로 변경한다.</p>
<pre><code class="language-bash">az sql db update \
  -g $RG \
  -s $SQL_SRV \
  -n $SQL_DB \
  --auto-pause-delay 15</code></pre>
<hr>
<h2 id="리소스-사용량-확인">리소스 사용량 확인</h2>
<p>SQL Database에서 다음 DMV를 조회해 CPU, 메모리, IO 사용률을 확인한다.</p>
<pre><code class="language-sql">SELECT TOP 5
    avg_cpu_percent,
    avg_memory_usage_percent,
    avg_data_io_percent,
    end_time
FROM sys.dm_db_resource_stats
ORDER BY end_time DESC;</code></pre>
<p>확인할 내용은 다음과 같다.</p>
<table>
<thead>
<tr>
<th>항목</th>
<th>확인 내용</th>
</tr>
</thead>
<tbody><tr>
<td>Auto Pause</td>
<td>일정 시간 미사용 시 DB가 Paused 상태가 되는지</td>
</tr>
<tr>
<td>Auto Resume</td>
<td>다시 접속했을 때 자동으로 Online 상태가 되는지</td>
</tr>
<tr>
<td>Cold Start</td>
<td>첫 연결까지 30~120초 정도 지연되는지</td>
</tr>
</tbody></table>
<p>운영 환경에서는 Cold Start가 사용자 경험에 영향을 줄 수 있으므로, 재시도 로직이나 Keep-alive 전략을 고려해야 한다.</p>
<hr>
<h1 id="lab-03--storage-account-생성-및-데이터-업로드">Lab 03 — Storage Account 생성 및 데이터 업로드</h1>
<h2 id="storage-account-생성">Storage Account 생성</h2>
<pre><code class="language-bash">STO_ACC=stodwlab$USER$RANDOM

az storage account create \
  -g $RG \
  -n $STO_ACC \
  -l $LOC \
  --sku Standard_LRS \
  --kind StorageV2 \
  --access-tier Hot \
  --allow-blob-public-access false \
  --min-tls-version TLS1_2</code></pre>
<p>Storage Key를 가져온다.</p>
<pre><code class="language-bash">STO_KEY=$(az storage account keys list \
  -g $RG \
  -n $STO_ACC \
  --query [0].value \
  -o tsv)</code></pre>
<p>컨테이너 3개를 생성한다.</p>
<pre><code class="language-bash">for c in raw staging archive; do
  az storage container create \
    --name $c \
    --account-name $STO_ACC \
    --account-key $STO_KEY
done</code></pre>
<table>
<thead>
<tr>
<th>컨테이너</th>
<th>역할</th>
</tr>
</thead>
<tbody><tr>
<td><code>raw</code></td>
<td>원본 CSV 보관</td>
</tr>
<tr>
<td><code>staging</code></td>
<td>정제 중간 결과</td>
</tr>
<tr>
<td><code>archive</code></td>
<td>적재 완료 파일 보관</td>
</tr>
</tbody></table>
<hr>
<h2 id="따릉이-샘플-데이터-업로드">따릉이 샘플 데이터 업로드</h2>
<p>생성된 샘플 CSV 파일을 <code>raw/seoul_bike/</code> 경로에 업로드한다.</p>
<pre><code class="language-bash">az storage blob upload-batch \
  --account-name &quot;$STO_ACC&quot; \
  --account-key $STO_KEY \
  --destination raw \
  --destination-path seoul_bike/ \
  --source ./seoul_bike_data \
  --pattern &quot;*.csv&quot; \
  --overwrite</code></pre>
<p>업로드 확인:</p>
<pre><code class="language-bash">az storage blob list \
  --account-name &quot;$STO_ACC&quot; \
  --account-key $STO_KEY \
  --container-name raw \
  --prefix &quot;seoul_bike/&quot; \
  --query &quot;[].{name:name, size:properties.contentLength}&quot; \
  -o table</code></pre>
<hr>
<h2 id="sas-토큰-생성">SAS 토큰 생성</h2>
<p>Azure SQL Database에서 Blob을 읽기 위해 SAS 토큰을 생성한다.</p>
<pre><code class="language-bash">EXPIRY=$(date -u -d &quot;+7 days&quot; &#39;+%Y-%m-%dT%H:%MZ&#39;)

SAS=$(az storage container generate-sas \
  --account-name $STO_ACC \
  --name raw \
  --permissions rl \
  --expiry $EXPIRY \
  --https-only \
  --output tsv)

echo $SAS</code></pre>
<p>SAS는 비밀번호와 같은 민감 정보이므로 외부에 노출되지 않도록 관리해야 한다.</p>
<hr>
<h1 id="lab-04--스타-스키마-생성">Lab 04 — 스타 스키마 생성</h1>
<h2 id="스키마-생성">스키마 생성</h2>
<pre><code class="language-sql">CREATE SCHEMA staging AUTHORIZATION dbo;
GO
CREATE SCHEMA dw AUTHORIZATION dbo;
GO
CREATE SCHEMA mart AUTHORIZATION dbo;
GO</code></pre>
<hr>
<h2 id="디멘션-테이블-생성">디멘션 테이블 생성</h2>
<pre><code class="language-sql">CREATE TABLE dw.DimDate (
  DateKey       INT          NOT NULL PRIMARY KEY,
  [Date]        DATE         NOT NULL,
  [Year]        SMALLINT     NOT NULL,
  Quarter       TINYINT      NOT NULL,
  [Month]       TINYINT      NOT NULL,
  MonthName     NVARCHAR(10) NOT NULL,
  [Day]         TINYINT      NOT NULL,
  DayOfWeek     TINYINT      NOT NULL,
  DayName       NVARCHAR(10) NOT NULL,
  IsWeekend     BIT          NOT NULL,
  IsHoliday     BIT          NOT NULL DEFAULT 0
);

CREATE TABLE dw.DimTime (
  TimeKey       INT          NOT NULL PRIMARY KEY,
  [Hour]        TINYINT      NOT NULL,
  [Minute]      TINYINT      NOT NULL,
  TimeBucket    NVARCHAR(10) NOT NULL
);

CREATE TABLE dw.DimUserType (
  UserTypeKey   INT IDENTITY PRIMARY KEY,
  UserTypeCode  VARCHAR(20)  NOT NULL UNIQUE,
  UserTypeName  NVARCHAR(40) NOT NULL
);</code></pre>
<hr>
<h2 id="팩트-테이블-생성">팩트 테이블 생성</h2>
<pre><code class="language-sql">CREATE TABLE dw.FactRental (
  RentalKey       BIGINT IDENTITY,
  RentalId        VARCHAR(40)  NOT NULL,
  StartDateKey    INT          NOT NULL,
  StartTimeKey    INT          NOT NULL,
  EndDateKey      INT          NULL,
  EndTimeKey      INT          NULL,
  StartStationKey BIGINT       NULL,
  EndStationKey   BIGINT       NULL,
  UserTypeKey     INT          NULL,
  DurationMin     INT          NULL,
  DistanceMeter   INT          NULL,
  CarbonGramSaved DECIMAL(10,2) NULL,
  LoadedAt        DATETIME2    NOT NULL DEFAULT SYSUTCDATETIME(),
  SourceFile      VARCHAR(256) NULL,
  CONSTRAINT PK_FactRental PRIMARY KEY NONCLUSTERED (RentalKey)
);

CREATE CLUSTERED COLUMNSTORE INDEX CCI_FactRental
ON dw.FactRental;

CREATE UNIQUE INDEX UX_FactRental_RentalId
ON dw.FactRental(RentalId);</code></pre>
<p><code>FactRental</code>은 대량 집계가 주 목적이므로 Clustered Columnstore Index를 적용한다.</p>
<hr>
<h2 id="dimdate--dimtime-사전-적재">DimDate / DimTime 사전 적재</h2>
<pre><code class="language-sql">WITH d AS (
  SELECT CAST(&#39;2015-01-01&#39; AS DATE) AS dt
  UNION ALL
  SELECT DATEADD(DAY, 1, dt)
  FROM d
  WHERE dt &lt; &#39;2030-12-31&#39;
)
INSERT dw.DimDate (
  DateKey, [Date], [Year], Quarter, [Month], MonthName,
  [Day], DayOfWeek, DayName, IsWeekend
)
SELECT
  CONVERT(INT, FORMAT(dt,&#39;yyyyMMdd&#39;)),
  dt,
  YEAR(dt),
  DATEPART(QUARTER, dt),
  MONTH(dt),
  DATENAME(MONTH, dt),
  DAY(dt),
  DATEPART(WEEKDAY, dt),
  DATENAME(WEEKDAY, dt),
  CASE WHEN DATEPART(WEEKDAY, dt) IN (1,7) THEN 1 ELSE 0 END
FROM d
OPTION (MAXRECURSION 0);</code></pre>
<pre><code class="language-sql">WITH m AS (
  SELECT 0 AS n
  UNION ALL
  SELECT n + 1
  FROM m
  WHERE n &lt; 1439
)
INSERT dw.DimTime (TimeKey, [Hour], [Minute], TimeBucket)
SELECT
  (n / 60) * 100 + (n % 60),
  n / 60,
  n % 60,
  CASE
    WHEN n/60 BETWEEN 0  AND 5  THEN N&#39;심야&#39;
    WHEN n/60 BETWEEN 6  AND 11 THEN N&#39;오전&#39;
    WHEN n/60 BETWEEN 12 AND 17 THEN N&#39;오후&#39;
    WHEN n/60 BETWEEN 18 AND 22 THEN N&#39;저녁&#39;
    ELSE N&#39;심야&#39;
  END
FROM m
OPTION (MAXRECURSION 0);</code></pre>
<p>사용자 유형도 기본 적재한다.</p>
<pre><code class="language-sql">INSERT dw.DimUserType (UserTypeCode, UserTypeName) VALUES
  (&#39;MEMBER&#39;,     N&#39;정기권 회원&#39;),
  (&#39;NONMEMBER&#39;,  N&#39;일일권 비회원&#39;),
  (&#39;UNKNOWN&#39;,    N&#39;미상&#39;);</code></pre>
<p>검증 기준:</p>
<table>
<thead>
<tr>
<th>테이블</th>
<th>기대값</th>
</tr>
</thead>
<tbody><tr>
<td><code>dw.DimDate</code></td>
<td>5,844행</td>
</tr>
<tr>
<td><code>dw.DimTime</code></td>
<td>1,440행</td>
</tr>
<tr>
<td><code>dw.DimUserType</code></td>
<td>3행</td>
</tr>
<tr>
<td><code>dw.FactRental</code></td>
<td>빈 상태</td>
</tr>
</tbody></table>
<hr>
<h1 id="lab-05--bulk-insert로-첫-적재">Lab 05 — BULK INSERT로 첫 적재</h1>
<h2 id="blob-접근-객체-생성">Blob 접근 객체 생성</h2>
<p>SQL Database에서 Blob Storage에 접근하기 위한 3종 객체를 만든다.</p>
<pre><code class="language-sql">IF NOT EXISTS (
  SELECT 1
  FROM sys.symmetric_keys
  WHERE name = &#39;##MS_DatabaseMasterKey##&#39;
)
  CREATE MASTER KEY ENCRYPTION BY PASSWORD = &#39;Lab!MasterKey2026&#39;;

CREATE DATABASE SCOPED CREDENTIAL StorageCred
  WITH IDENTITY = &#39;SHARED ACCESS SIGNATURE&#39;,
       SECRET   = &#39;&lt;SAS_TOKEN&gt;&#39;;

CREATE EXTERNAL DATA SOURCE BlobRaw
  WITH (
    TYPE       = BLOB_STORAGE,
    LOCATION   = &#39;https://&lt;STO_ACC&gt;.blob.core.windows.net/raw&#39;,
    CREDENTIAL = StorageCred
  );</code></pre>
<p><code>&lt;SAS_TOKEN&gt;</code>에는 앞의 <code>?</code>를 제외한 SAS 본문만 넣는다.</p>
<hr>
<h2 id="staging-테이블-생성">Staging 테이블 생성</h2>
<pre><code class="language-sql">DROP TABLE IF EXISTS staging.RentalRaw;

CREATE TABLE staging.RentalRaw (
  RentalId        VARCHAR(20)  NOT NULL,
  BikeId          VARCHAR(20)  NOT NULL,
  StartTime       DATETIME2(0) NOT NULL,
  EndTime         DATETIME2(0) NOT NULL,
  StartStationId  VARCHAR(20)  NOT NULL,
  EndStationId    VARCHAR(20)  NOT NULL,
  DurationMin     INT          NOT NULL,
  DistanceMeter   INT          NOT NULL,
  UserType        VARCHAR(20)  NOT NULL
);</code></pre>
<hr>
<h2 id="csv-파일-bulk-insert">CSV 파일 BULK INSERT</h2>
<pre><code class="language-sql">TRUNCATE TABLE staging.RentalRaw;

DECLARE @i INT = 1, @sql NVARCHAR(MAX);

WHILE @i &lt;= 7
BEGIN
  SET @sql = N&#39;
    BULK INSERT staging.RentalRaw
    FROM &#39;&#39;seoul_bike/rentals_2025090&#39; + CAST(@i AS VARCHAR(1)) + &#39;.csv&#39;&#39;
    WITH (
      DATA_SOURCE     = &#39;&#39;BlobRaw&#39;&#39;,
      FORMAT          = &#39;&#39;CSV&#39;&#39;,
      FIRSTROW        = 2,
      FIELDTERMINATOR = &#39;&#39;,&#39;&#39;,
      ROWTERMINATOR   = &#39;&#39;0x0d0a&#39;&#39;,
      CODEPAGE        = &#39;&#39;65001&#39;&#39;,
      TABLOCK,
      MAXERRORS       = 100
    );&#39;;

  EXEC sp_executesql @sql;
  SET @i += 1;
END</code></pre>
<p>검증:</p>
<pre><code class="language-sql">SELECT COUNT(*) AS staging_rows
FROM staging.RentalRaw;</code></pre>
<p>기대값은 21,600행이다.</p>
<hr>
<h2 id="staging-→-factrental-변환-적재">staging → FactRental 변환 적재</h2>
<pre><code class="language-sql">TRUNCATE TABLE dw.FactRental;

INSERT INTO dw.FactRental (
  RentalId, StartDateKey, StartTimeKey, EndDateKey, EndTimeKey,
  StartStationKey, EndStationKey, UserTypeKey,
  DurationMin, DistanceMeter, CarbonGramSaved,
  LoadedAt, SourceFile
)
SELECT
  s.RentalId,
  CONVERT(INT, CONVERT(VARCHAR(8), s.StartTime, 112)) AS StartDateKey,
  DATEPART(HOUR, s.StartTime) * 60 + DATEPART(MINUTE, s.StartTime) AS StartTimeKey,
  CONVERT(INT, CONVERT(VARCHAR(8), s.EndTime, 112)) AS EndDateKey,
  DATEPART(HOUR, s.EndTime) * 60 + DATEPART(MINUTE, s.EndTime) AS EndTimeKey,
  CAST(NULL AS BIGINT) AS StartStationKey,
  CAST(NULL AS BIGINT) AS EndStationKey,
  COALESCE(ut.UserTypeKey, ut_unk.UserTypeKey) AS UserTypeKey,
  s.DurationMin,
  s.DistanceMeter,
  CAST(s.DistanceMeter * 0.21 AS DECIMAL(10,2)) AS CarbonGramSaved,
  SYSUTCDATETIME() AS LoadedAt,
  &#39;seoul_bike/rentals_&#39; + CONVERT(VARCHAR(8), s.StartTime, 112) + &#39;.csv&#39; AS SourceFile
FROM staging.RentalRaw AS s
LEFT JOIN dw.DimUserType AS ut
  ON ut.UserTypeCode = s.UserType
LEFT JOIN dw.DimUserType AS ut_unk
  ON ut_unk.UserTypeCode = &#39;UNKNOWN&#39;;</code></pre>
<p>여기서는 아직 <code>StartStationKey</code>, <code>EndStationKey</code>를 채우지 않는다.<br>대여소 디멘션은 Lab 06에서 SCD Type 2로 처리한 뒤 백필한다.</p>
<hr>
<h2 id="적재-검증">적재 검증</h2>
<pre><code class="language-sql">SELECT COUNT(*) AS fact_rows
FROM dw.FactRental;</code></pre>
<pre><code class="language-sql">SELECT StartDateKey, COUNT(*) AS c
FROM dw.FactRental
GROUP BY StartDateKey
ORDER BY StartDateKey;</code></pre>
<pre><code class="language-sql">SELECT COUNT(*) AS unmapped_user_type
FROM dw.FactRental
WHERE UserTypeKey IS NULL;</code></pre>
<p>검증 포인트:</p>
<table>
<thead>
<tr>
<th>항목</th>
<th>기대값</th>
</tr>
</thead>
<tbody><tr>
<td>Fact 행수</td>
<td>21,600</td>
</tr>
<tr>
<td>날짜 분포</td>
<td>7일치</td>
</tr>
<tr>
<td>UserTypeKey NULL</td>
<td>0</td>
</tr>
<tr>
<td>StationKey</td>
<td>아직 NULL</td>
</tr>
<tr>
<td>CarbonGramSaved</td>
<td><code>DistanceMeter * 0.21</code></td>
</tr>
</tbody></table>
<hr>
<h1 id="lab-06--scd-type-2-대여소-디멘션-적재">Lab 06 — SCD Type 2 대여소 디멘션 적재</h1>
<h2 id="dimstation-생성">DimStation 생성</h2>
<pre><code class="language-sql">CREATE TABLE dw.DimStation (
  StationKey   BIGINT IDENTITY(1,1) NOT NULL,
  StationId    VARCHAR(20)   NOT NULL,
  StationName  NVARCHAR(100) NOT NULL,
  Gu           NVARCHAR(50)  NOT NULL,
  Lat          DECIMAL(9,5)  NOT NULL,
  Lng          DECIMAL(9,5)  NOT NULL,
  RackCount    INT           NOT NULL,
  RowHash      BINARY(32)    NOT NULL,
  ValidFrom    DATE          NOT NULL,
  ValidTo      DATE          NULL,
  IsCurrent    BIT           NOT NULL,
  LoadedAt     DATETIME2(0)  NOT NULL DEFAULT SYSUTCDATETIME(),
  CONSTRAINT PK_DimStation PRIMARY KEY NONCLUSTERED (StationKey)
);

CREATE UNIQUE INDEX UX_DimStation_BusinessVersion
ON dw.DimStation (StationId, ValidFrom);

CREATE INDEX IX_DimStation_Current
ON dw.DimStation (StationId)
WHERE IsCurrent = 1;</code></pre>
<hr>
<h2 id="rowhash-함수-생성">RowHash 함수 생성</h2>
<pre><code class="language-sql">CREATE OR ALTER FUNCTION dw.fn_StationRowHash(
  @StationName NVARCHAR(100),
  @Gu NVARCHAR(50),
  @Lat DECIMAL(9,5),
  @Lng DECIMAL(9,5),
  @RackCount INT
) RETURNS BINARY(32)
WITH SCHEMABINDING
AS
BEGIN
  RETURN HASHBYTES(&#39;SHA2_256&#39;,
    CONCAT_WS(N&#39;|&#39;,
      @StationName,
      @Gu,
      CONVERT(NVARCHAR(20), @Lat, 1),
      CONVERT(NVARCHAR(20), @Lng, 1),
      CAST(@RackCount AS NVARCHAR(20))
    )
  );
END</code></pre>
<p>RowHash를 사용하면 여러 컬럼을 각각 비교하지 않고 해시값 하나로 변경 여부를 판단할 수 있다.</p>
<hr>
<h2 id="stationraw-적재">StationRaw 적재</h2>
<pre><code class="language-sql">DROP TABLE IF EXISTS staging.StationRaw;

CREATE TABLE staging.StationRaw (
  StationId    VARCHAR(20)   NOT NULL,
  StationName  NVARCHAR(100) NOT NULL,
  Gu           NVARCHAR(50)  NOT NULL,
  Lat          DECIMAL(9,5)  NOT NULL,
  Lng          DECIMAL(9,5)  NOT NULL,
  RackCount    INT           NOT NULL,
  OpenedDate   DATE          NOT NULL
);

TRUNCATE TABLE staging.StationRaw;

BULK INSERT staging.StationRaw
FROM &#39;seoul_bike/stations.csv&#39;
WITH (
  DATA_SOURCE=&#39;BlobRaw&#39;,
  FORMAT=&#39;CSV&#39;,
  FIRSTROW=2,
  FIELDTERMINATOR=&#39;,&#39;,
  ROWTERMINATOR=&#39;0x0d0a&#39;,
  CODEPAGE=&#39;65001&#39;,
  TABLOCK,
  MAXERRORS=0
);</code></pre>
<hr>
<h2 id="최초-적재">최초 적재</h2>
<pre><code class="language-sql">INSERT INTO dw.DimStation (
  StationId, StationName, Gu, Lat, Lng, RackCount,
  RowHash, ValidFrom, ValidTo, IsCurrent
)
SELECT
  s.StationId,
  s.StationName,
  s.Gu,
  s.Lat,
  s.Lng,
  s.RackCount,
  dw.fn_StationRowHash(s.StationName, s.Gu, s.Lat, s.Lng, s.RackCount),
  s.OpenedDate,
  NULL,
  1
FROM staging.StationRaw s;</code></pre>
<hr>
<h2 id="팩트-외래키-백필">팩트 외래키 백필</h2>
<pre><code class="language-sql">UPDATE f
SET f.StartStationKey = s.StationKey
FROM dw.FactRental f
INNER JOIN staging.RentalRaw r
  ON r.RentalId = f.RentalId
INNER JOIN dw.DimStation s
  ON s.StationId = r.StartStationId
 AND CAST(r.StartTime AS DATE) &gt;= s.ValidFrom
 AND (s.ValidTo IS NULL OR CAST(r.StartTime AS DATE) &lt; s.ValidTo)
WHERE f.StartStationKey IS NULL;

UPDATE f
SET f.EndStationKey = s.StationKey
FROM dw.FactRental f
INNER JOIN staging.RentalRaw r
  ON r.RentalId = f.RentalId
INNER JOIN dw.DimStation s
  ON s.StationId = r.EndStationId
 AND CAST(r.EndTime AS DATE) &gt;= s.ValidFrom
 AND (s.ValidTo IS NULL OR CAST(r.EndTime AS DATE) &lt; s.ValidTo)
WHERE f.EndStationKey IS NULL;</code></pre>
<p>검증:</p>
<pre><code class="language-sql">SELECT
  SUM(CASE WHEN StartStationKey IS NULL THEN 1 ELSE 0 END) AS null_start,
  SUM(CASE WHEN EndStationKey IS NULL THEN 1 ELSE 0 END) AS null_end
FROM dw.FactRental;</code></pre>
<p>기대값은 <code>0 / 0</code>이다.</p>
<hr>
<h1 id="lab-07--분석-쿼리">Lab 07 — 분석 쿼리</h1>
<h2 id="시간대별-평균-이용-패턴">시간대별 평균 이용 패턴</h2>
<pre><code class="language-sql">SELECT
  t.TimeBucket,
  d.DayName,
  COUNT(*) AS rentals,
  AVG(f.DurationMin) AS avg_minutes
FROM dw.FactRental f
JOIN dw.DimTime t
  ON t.TimeKey = f.StartTimeKey
JOIN dw.DimDate d
  ON d.DateKey = f.StartDateKey
GROUP BY t.TimeBucket, d.DayName
ORDER BY rentals DESC;</code></pre>
<p>이 쿼리로 시간대와 요일별 이용 패턴을 확인할 수 있다.</p>
<hr>
<h2 id="자치구별-출발도착-비대칭">자치구별 출발/도착 비대칭</h2>
<pre><code class="language-sql">WITH dep AS (
  SELECT s.Gu, COUNT(*) AS departures
  FROM dw.FactRental f
  JOIN dw.DimStation s
    ON s.StationKey = f.StartStationKey
  GROUP BY s.Gu
),
arr AS (
  SELECT s.Gu, COUNT(*) AS arrivals
  FROM dw.FactRental f
  JOIN dw.DimStation s
    ON s.StationKey = f.EndStationKey
  GROUP BY s.Gu
)
SELECT
  COALESCE(d.Gu, a.Gu) AS Gu,
  d.departures,
  a.arrivals,
  a.arrivals - d.departures AS net_flow
FROM dep d
FULL OUTER JOIN arr a
  ON a.Gu = d.Gu
ORDER BY ABS(a.arrivals - d.departures) DESC;</code></pre>
<p><code>net_flow</code>가 크면 해당 자치구에서 자전거 적체 또는 부족이 발생할 가능성이 높다.</p>
<hr>
<h2 id="top-10-od-페어">Top 10 OD 페어</h2>
<pre><code class="language-sql">SELECT TOP 10
  st.StationName AS start_station,
  en.StationName AS end_station,
  COUNT(*) AS trips,
  AVG(f.DurationMin) AS avg_min
FROM dw.FactRental f
JOIN dw.DimStation st
  ON st.StationKey = f.StartStationKey
JOIN dw.DimStation en
  ON en.StationKey = f.EndStationKey
WHERE f.StartStationKey IS NOT NULL
  AND f.EndStationKey IS NOT NULL
  AND f.StartStationKey &lt;&gt; f.EndStationKey
GROUP BY st.StationName, en.StationName
ORDER BY trips DESC;</code></pre>
<hr>
<h2 id="rollup으로-소계-만들기">ROLLUP으로 소계 만들기</h2>
<pre><code class="language-sql">SELECT
  COALESCE(s.Gu, &#39;&lt;&lt;TOTAL_GU&gt;&gt;&#39;) AS Gu,
  COALESCE(CAST(t.Hour AS VARCHAR(8)), &#39;&lt;&lt;TOTAL_HOUR&gt;&gt;&#39;) AS Hour,
  COUNT(*) AS rides
FROM dw.FactRental f
JOIN dw.DimStation s
  ON s.StationKey = f.StartStationKey
JOIN dw.DimTime t
  ON t.TimeKey = f.StartTimeKey
GROUP BY ROLLUP(s.Gu, t.Hour)
ORDER BY GROUPING(s.Gu), s.Gu, GROUPING(t.Hour), t.Hour;</code></pre>
<hr>
<h1 id="lab-08--function-app--event-grid-자동-적재">Lab 08 — Function App + Event Grid 자동 적재</h1>
<h2 id="function-app-생성">Function App 생성</h2>
<pre><code class="language-bash">FUNC_APP=func-dwlab-$USER-$RANDOM
FUNC_LOC=&quot;koreacentral&quot;

az functionapp create \
  -g $RG \
  -n $FUNC_APP \
  --consumption-plan-location $FUNC_LOC \
  --runtime python \
  --runtime-version 3.11 \
  --functions-version 4 \
  --storage-account $STO_ACC \
  --os-type Linux</code></pre>
<p>Managed Identity를 활성화한다.</p>
<pre><code class="language-bash">az functionapp identity assign \
  -g $RG \
  -n $FUNC_APP</code></pre>
<p>Storage 읽기 권한을 부여한다.</p>
<pre><code class="language-bash">FUNC_PRINCIPAL=$(az functionapp identity show \
  -g $RG \
  -n $FUNC_APP \
  --query principalId \
  -o tsv)

STO_ID=$(az storage account show \
  -g $RG \
  -n $STO_ACC \
  --query id \
  -o tsv)

az role assignment create \
  --assignee &quot;$FUNC_PRINCIPAL&quot; \
  --role &#39;Storage Blob Data Reader&#39; \
  --scope &quot;$STO_ID&quot;</code></pre>
<hr>
<h2 id="sql-db에-managed-identity-사용자-등록">SQL DB에 Managed Identity 사용자 등록</h2>
<pre><code class="language-sql">CREATE USER [func-dwlab-...] FROM EXTERNAL PROVIDER;

ALTER ROLE db_datareader ADD MEMBER [func-dwlab-...];
ALTER ROLE db_datawriter ADD MEMBER [func-dwlab-...];

GRANT EXECUTE ON SCHEMA :: staging TO [func-dwlab-...];
GRANT EXECUTE ON SCHEMA :: dw TO [func-dwlab-...];</code></pre>
<p>운영 환경에서는 Function 코드에 SQL 비밀번호를 넣지 않는 것이 중요하다.<br>Managed Identity를 사용하면 Function App 자체의 신원으로 SQL Database에 접근할 수 있다.</p>
<hr>
<h2 id="function-코드-핵심">Function 코드 핵심</h2>
<pre><code class="language-python">import logging, os, struct
import azure.functions as func
from azure.identity import DefaultAzureCredential
import pyodbc

app = func.FunctionApp()

@app.event_grid_trigger(arg_name=&#39;event&#39;)
def blob_loaded(event: func.EventGridEvent):
    data = event.get_json()
    blob_url = data.get(&#39;url&#39;)
    logging.info(f&#39;BlobCreated: {blob_url}&#39;)

    if &#39;/raw/&#39; not in blob_url or not blob_url.endswith(&#39;.csv&#39;):
        logging.info(&#39;Skip non-target blob&#39;)
        return

    rel = blob_url.split(&#39;/raw/&#39;)[-1]

    cred = DefaultAzureCredential()
    token = cred.get_token(
        &#39;https://database.windows.net/.default&#39;
    ).token.encode(&#39;utf-16-le&#39;)

    token_struct = struct.pack(f&#39;=i{len(token)}s&#39;, len(token), token)
    SQL_COPT_SS_ACCESS_TOKEN = 1256

    conn_str = (
        &#39;Driver={ODBC Driver 18 for SQL Server};&#39;
        f&#39;Server=tcp:{os.environ[&quot;SQL_SERVER&quot;]},1433;&#39;
        f&#39;Database={os.environ[&quot;SQL_DB&quot;]};&#39;
        &#39;Encrypt=yes;TrustServerCertificate=no;&#39;
    )

    with pyodbc.connect(
        conn_str,
        attrs_before={SQL_COPT_SS_ACCESS_TOKEN: token_struct}
    ) as cn:
        cn.cursor().execute(
            &#39;EXEC dw.sp_LoadFactFromBlob @blobPath = ?&#39;,
            rel
        ).commit()

    logging.info(&#39;sp_LoadFactFromBlob OK&#39;)</code></pre>
<hr>
<h2 id="stored-procedure-생성">Stored Procedure 생성</h2>
<pre><code class="language-sql">CREATE OR ALTER PROCEDURE dw.sp_LoadFactFromBlob
  @blobPath NVARCHAR(500)
AS
BEGIN
  SET NOCOUNT ON;

  DECLARE @sql NVARCHAR(MAX);

  TRUNCATE TABLE staging.RentalRaw;

  SET @sql = N&#39;BULK INSERT staging.RentalRaw
    FROM &#39;&#39;&#39; + @blobPath + N&#39;&#39;&#39;
    WITH (
      DATA_SOURCE=&#39;&#39;BlobRaw&#39;&#39;,
      FORMAT=&#39;&#39;CSV&#39;&#39;,
      FIRSTROW=2,
      FIELDTERMINATOR=&#39;&#39;,&#39;&#39;,
      ROWTERMINATOR=&#39;&#39;0x0a&#39;&#39;,
      CODEPAGE=&#39;&#39;65001&#39;&#39;,
      MAXERRORS=100
    )&#39;;

  EXEC sp_executesql @sql;

  EXEC dw.sp_TransformAndLoad @sourceFile = @blobPath;
END</code></pre>
<hr>
<h2 id="event-grid-구독-생성">Event Grid 구독 생성</h2>
<pre><code class="language-bash">FUNC_KEY=$(az functionapp keys list \
  -g $RG \
  -n $FUNC_APP \
  --query systemKeys.eventgrid_extension \
  -o tsv)

ENDPOINT=&quot;https://$FUNC_APP.azurewebsites.net/runtime/webhooks/EventGrid?functionName=blob_loaded&amp;code=$FUNC_KEY&quot;

az eventgrid event-subscription create \
  --name sub-blob-to-func \
  --source-resource-id $STO_ID \
  --endpoint-type webhook \
  --endpoint &quot;$ENDPOINT&quot; \
  --included-event-types Microsoft.Storage.BlobCreated \
  --subject-begins-with /blobServices/default/containers/raw/</code></pre>
<p>이제 <code>raw</code> 컨테이너에 CSV가 업로드되면 Event Grid가 Function을 호출하고, Function이 SQL 저장 프로시저를 실행해 적재한다.</p>
<hr>
<h1 id="lab-09--logic-apps-일일-적재-워크플로우">Lab 09 — Logic Apps 일일 적재 워크플로우</h1>
<p>Logic Apps는 GUI 기반으로 ETL 흐름을 구성할 수 있다.</p>
<p>워크플로우 구조는 다음과 같다.</p>
<pre><code class="language-text">Recurrence: 매일 02:00 KST
        ↓
List blobs in raw
        ↓
Filter array
        ↓
For each
        ↓
Execute stored procedure
        ↓
성공: archive 이동
실패: 이메일 알림</code></pre>
<p>Function이 “파일이 올라오자마자 즉시 처리”에 적합하다면, Logic Apps는 “정해진 시간에 여러 작업을 순서대로 실행”하는 데 적합하다.</p>
<hr>
<h1 id="lab-10--운영-pitr-모니터링-권한-정리">Lab 10 — 운영: PITR, 모니터링, 권한, 정리</h1>
<h2 id="pitr-복원">PITR 복원</h2>
<pre><code class="language-bash">RESTORE_TS=$(date -u -d &quot;-5 min&quot; &#39;+%Y-%m-%dT%H:%M:%S&#39;)

az sql db restore \
  -g $RG \
  -s $SQL_SRV \
  -n dw_seoulbike \
  --dest-name dw_seoulbike_pitr \
  --time $RESTORE_TS \
  --edition GeneralPurpose \
  --family Gen5 \
  --capacity 2</code></pre>
<p>복원된 DB 행수 확인:</p>
<pre><code class="language-bash">sqlcmd -S $SQL_SRV.database.windows.net \
  -d dw_seoulbike_pitr \
  -U $ADMIN_USER \
  -P &quot;$ADMIN_PASS&quot; \
  -Q &quot;SELECT COUNT(*) FROM dw.FactRental;&quot;</code></pre>
<hr>
<h2 id="cpu-알림-규칙-생성">CPU 알림 규칙 생성</h2>
<pre><code class="language-bash">az monitor metrics alert create \
  -g $RG \
  -n alert-dw-cpu-high \
  --scopes $(az sql db show -g $RG -s $SQL_SRV -n $SQL_DB --query id -o tsv) \
  --condition &quot;avg cpu_percent &gt; 80&quot; \
  --window-size 5m \
  --evaluation-frequency 1m \
  --severity 2</code></pre>
<hr>
<h2 id="role-기반-권한-분리">ROLE 기반 권한 분리</h2>
<pre><code class="language-sql">CREATE ROLE dw_analyst;
GRANT SELECT ON SCHEMA :: dw TO dw_analyst;
GRANT SELECT ON SCHEMA :: mart TO dw_analyst;
DENY SELECT ON SCHEMA :: staging TO dw_analyst;

CREATE ROLE dw_loader;
GRANT SELECT, INSERT, UPDATE, DELETE ON SCHEMA :: staging TO dw_loader;
GRANT SELECT, INSERT, UPDATE ON SCHEMA :: dw TO dw_loader;
GRANT EXECUTE ON SCHEMA :: dw TO dw_loader;

CREATE ROLE dw_admin;
GRANT CONTROL ON DATABASE :: dw_seoulbike TO dw_admin;</code></pre>
<table>
<thead>
<tr>
<th>Role</th>
<th>역할</th>
</tr>
</thead>
<tbody><tr>
<td><code>dw_analyst</code></td>
<td>분석가 읽기 권한</td>
</tr>
<tr>
<td><code>dw_loader</code></td>
<td>ETL 적재 권한</td>
</tr>
<tr>
<td><code>dw_admin</code></td>
<td>운영 관리자 권한</td>
</tr>
</tbody></table>
<hr>
<h2 id="리소스-정리">리소스 정리</h2>
<p>실습 종료 후에는 리소스 그룹을 삭제해 비용 누적을 막는다.</p>
<pre><code class="language-bash">az sql db delete \
  -g $RG \
  -s $SQL_SRV \
  -n dw_seoulbike_pitr \
  --yes 2&gt;/dev/null

az group delete \
  -n $RG \
  --yes \
  --no-wait</code></pre>
<hr>
<h1 id="전체-실습-흐름-정리">전체 실습 흐름 정리</h1>
<table>
<thead>
<tr>
<th>단계</th>
<th>내용</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>Azure SQL Database Serverless 생성</td>
</tr>
<tr>
<td>2</td>
<td>Serverless auto-pause / auto-resume 검증</td>
</tr>
<tr>
<td>3</td>
<td>Storage Account 생성 및 CSV 업로드</td>
</tr>
<tr>
<td>4</td>
<td>스타 스키마 생성</td>
</tr>
<tr>
<td>5</td>
<td>BULK INSERT로 staging 적재</td>
</tr>
<tr>
<td>6</td>
<td>staging → FactRental 변환 적재</td>
</tr>
<tr>
<td>7</td>
<td>SCD Type 2로 대여소 디멘션 관리</td>
</tr>
<tr>
<td>8</td>
<td>시간대·자치구·OD 분석</td>
</tr>
<tr>
<td>9</td>
<td>Event Grid + Function으로 자동 적재</td>
</tr>
<tr>
<td>10</td>
<td>Logic Apps로 일일 적재 워크플로우 구성</td>
</tr>
<tr>
<td>11</td>
<td>PITR, Monitor, Role, 비용 정리</td>
</tr>
</tbody></table>
<hr>
<h1 id="15-핵심-정리">15. 핵심 정리</h1>
<p>이번 실습의 핵심은 다음과 같다.</p>
<ol>
<li>DW는 OLTP와 목적이 다르기 때문에 스키마와 인덱스 전략도 달라야 한다.</li>
<li>분석 중심 모델링에서는 Fact와 Dimension을 분리한다.</li>
<li>대량 분석용 Fact 테이블에는 Columnstore Index가 적합하다.</li>
<li>Storage Account는 원본 데이터 저장소 역할을 한다.</li>
<li><code>BULK INSERT</code>는 빠른 1차 적재에 적합하다.</li>
<li>SCD Type 2는 대여소처럼 속성이 변하는 디멘션의 이력을 보존하는 데 사용한다.</li>
<li>Event Grid + Function을 사용하면 Blob 업로드 기반 자동 적재가 가능하다.</li>
<li>Logic Apps는 일정 기반 ETL 오케스트레이션에 적합하다.</li>
<li>PITR, Monitor, Role, 비용 정리는 DW 운영에서 반드시 필요하다.</li>
</ol>
]]></description>
        </item>
        <item>
            <title><![CDATA[[MicrosoftDataSchool] 83일차 - Azure SQL에서 Graph Database 활용하기 ]]></title>
            <link>https://velog.io/@rudin_/MicrosoftDataSchool-83%EC%9D%BC%EC%B0%A8-Azure-SQL%EC%97%90%EC%84%9C-Graph-Database-%ED%99%9C%EC%9A%A9%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@rudin_/MicrosoftDataSchool-83%EC%9D%BC%EC%B0%A8-Azure-SQL%EC%97%90%EC%84%9C-Graph-Database-%ED%99%9C%EC%9A%A9%ED%95%98%EA%B8%B0</guid>
            <pubDate>Thu, 07 May 2026 03:35:30 GMT</pubDate>
            <description><![CDATA[<h1 id="1-개요">1. 개요</h1>
<p><img src="https://velog.velcdn.com/images/rudin_/post/eb69803b-92b0-426c-8b1f-2180c38f8639/image.png" alt="">
그래프 쪽은 JOIN을 명시적으로 쓰지 않아도 패턴이 곧 쿼리가 됨</p>
<h2 id="11-sql-server-graph-database란">1.1 SQL Server Graph Database란?</h2>
<ul>
<li>SQL Server 2017부터 도입됨 </li>
<li>노드(Node)와 엣지(Edge)를 사용하여 복잡한 관계형 데이터를 자연스럽게 표현하고 쿼리할 수 있게 해줌</li>
<li>Azure SQL Database와 Azure SQL VM 모두에서 이 기능을 완벽하게 지원</li>
</ul>
<p>관계형 모델로도 같은 데이터를 표현할 수 있지만, &quot;친구의 친구의 친구&quot;처럼 여러 단계를 거쳐가는 질의를 JOIN으로 풀려고 하면 SQL이 금세 복잡해집니다. 그래프 모델은 이런 다단계 탐색을 시각적인 패턴 그대로 쿼리로 표현할 수 있게 해 줍니다. </p>
<h2 id="12-graph-database를-사용하는-이유">1.2 Graph Database를 사용하는 이유</h2>
<ul>
<li>복잡한 관계 표현 — 다대다 관계와 계층 구조를 직관적으로 모델링 </li>
<li>경로 탐색 — 친구의 친구, 추천 시스템 등 연결 기반 쿼리에 최적화 </li>
<li>패턴 매칭 — MATCH 절로 복잡한 관계 패턴을 간단하게 표현 </li>
<li>기존 SQL과 통합 — 관계형 테이블과 그래프 테이블을 함께 사용 가능 </li>
</ul>
<h3 id="💡--언제-그래프-db가-빛을-발하나요">💡  언제 그래프 DB가 빛을 발하나요?</h3>
<ul>
<li>조직도 / 권한 위임 / 분류 체계처럼 깊이가 가변적인 계층 구조 </li>
<li>소셜 그래프 (친구·팔로우·차단 등 같은 노드 타입 사이의 다양한 관계) </li>
<li>추천 엔진 (콜드 스타트 우회: &quot;비슷한 사람이 좋아하는 것&quot;) </li>
<li>사기 탐지·자금 흐름 추적 (의심 노드를 시작점으로 N단계 확산 분석) </li>
<li>지식 그래프·Knowledge Base (엔티티 + 관계 위주 질의) </li>
</ul>
<h2 id="13-graph-database-핵심-개념">1.3 Graph Database 핵심 개념</h2>
<table>
<thead>
<tr>
<th>개념</th>
<th>설명 / SQL Server 구현</th>
</tr>
</thead>
<tbody><tr>
<td>Node (노드)</td>
<td>엔티티(사람, 게시물, 상품 등)를 표현하는 점. <code>CREATE TABLE ... AS NODE</code> 로 생성하며 내부적으로 <code>$node_id</code> 컬럼이 자동 생성됨</td>
</tr>
<tr>
<td>Edge (엣지)</td>
<td>두 노드를 잇는 방향성 있는 선. <code>CREATE TABLE ... AS EDGE</code> 로 생성하며 <code>$edge_id</code>, <code>$from_id</code>, <code>$to_id</code> 컬럼이 자동 생성됨</td>
</tr>
<tr>
<td>$node_id</td>
<td>노드 고유 ID. JSON 형식(<code>{&quot;schema&quot;:&quot;...&quot;, &quot;table&quot;:&quot;...&quot;, &quot;id&quot;:&quot;...&quot;}</code>)이며 시스템이 자동 부여</td>
</tr>
<tr>
<td>$edge_id</td>
<td>엣지 고유 ID. <code>$node_id</code>와 동일한 JSON 형식 사용</td>
</tr>
<tr>
<td>$from_id / $to_id</td>
<td>엣지가 연결하는 시작/끝 노드의 <code>$node_id</code> 값. 엣지 방향 정의</td>
</tr>
<tr>
<td>MATCH</td>
<td>WHERE 절에서 그래프 패턴을 명시하는 키워드. 예: <code>MATCH(A-(e)-&gt;B)</code></td>
</tr>
</tbody></table>
<p><img src="https://velog.velcdn.com/images/rudin_/post/6cc4fb28-13ee-4869-9906-225c44ac535a/image.png" alt=""></p>
<h4 id="node_id는-직접-다루면-안됨">$node_id는 직접 다루면 안됨</h4>
<p>$node_id 값은 &quot;{&quot;schema&quot;:&quot;dbo&quot;,&quot;table&quot;:&quot;Person&quot;,&quot;id&quot;:0}&quot; 같은 JSON 문자열입니다. 
직접 INSERT하거나 비교 키로 외부 시스템에 노출하지 마세요. 대신 PersonId 같은 비즈니스 키로 식별하고, $node_id는 시스템 내부 조인용으로만 씁니다. </p>
<h1 id="2-환경-설정-및-샘플-데이터">2. 환경 설정 및 샘플 데이터</h1>
<h2 id="21-시나리오-소셜-네트워크">2.1 시나리오: 소셜 네트워크</h2>
<p>이 실습에서는 작은 소셜 네트워크를 모델링합니다. 사용자(Person) 5명과 게시물(Post) 3개가 있고, 사용자 간에는 팔로우 관계가, 사용자와 게시물 사이에는 작성/좋아요 관계가 형성됩니다. 앞으로 모든 쿼리는 이 한 장의 그래프를 기준으로 다양한 질문을 던지게 됩니다. 
<img src="https://velog.velcdn.com/images/rudin_/post/bda638aa-49f4-4712-82d8-4e53bc6c2138/image.png" alt=""></p>
<p>나같은경우엔 sql vm 생성 시 이미지 선택을 잘못했는지, SSMS가 설치되어있지 않았다.
별도로 설치한 후, 연결은 </p>
<ul>
<li>서버이름: localhost</li>
<li>인증서 신뢰 체크</li>
</ul>
<p>후 진행하였다.
<img src="https://velog.velcdn.com/images/rudin_/post/4b2133fe-1516-48d3-9759-e141a734d7e5/image.png" alt=""></p>
<h2 id="22-node-테이블-생성">2.2 Node 테이블 생성</h2>
<p>핵심 키워드는 끝부분의 AS NODE 입니다. </p>
<pre><code class="language-sql">-- 사용자 노드 테이블 
CREATE TABLE Person ( 
    PersonId  INT PRIMARY KEY, 
    Name      NVARCHAR(100), 
    Email     NVARCHAR(200),
    JoinDate  DATE DEFAULT GETDATE()
) AS NODE; 
-- 게시물 노드 테이블 
CREATE TABLE Post ( 
    PostId     INT PRIMARY KEY, 
    Title      NVARCHAR(200), 
    Content    NVARCHAR(MAX), 
    CreatedAt  DATETIME2 DEFAULT GETDATE() 
) AS NODE; </code></pre>
<p><img src="https://velog.velcdn.com/images/rudin_/post/4609ea5f-3d6b-455e-913c-37a80e48203f/image.png" alt="">
① AS NODE 키워드 </p>
<p>테이블 정의 마지막에 AS NODE를 붙이는 것이 그래프 노드 선언의 전부입니다. 이 한 줄로 SQL Server는 내부적으로 $node_id라는 보이지 않는 컬럼을 추가합니다. </p>
<p>② PRIMARY KEY는 별개 </p>
<p>PersonId는 우리가 부여하는 비즈니스 식별자, $node_id는 시스템이 부여하는 그래프 식별자입니다. 두 개가 공존하며 역할이 다릅니다. </p>
<p>③ 일반 컬럼은 자유 </p>
<p>Name, Email 등 평소 테이블 만들듯이 컬럼을 자유롭게 추가하면 됩니다. 그래프 테이블도 본질은 일반 테이블입니다. </p>
<p>④ Post 테이블도 동일 패턴 </p>
<p>Person과 똑같이 AS NODE로 끝맺기만 하면 됩니다. 노드 타입이 두 종류 이상이어도 패턴은 같습니다. </p>
<blockquote>
<p>Person, Post 두 테이블이 생성되며, 각 테이블에는 우리가 정의한 컬럼 외에 $node_id가 숨겨진 형태로 추가됩니다. 
sys.tables 카탈로그 뷰에서 is_node = 1로 표시되며, sys.columns에서 graph_type 값을 가진 시스템 컬럼들이 함께 보입니다. 
아직 데이터는 비어 있고, 두 테이블 사이에는 어떤 관계도 없습니다 — 관계는 다음 단계의 Edge 테이블이 담당합니다. </p>
</blockquote>
<h2 id="23-edge-테이블-생성">2.3 Edge 테이블 생성</h2>
<pre><code class="language-sql">-- 팔로우 관계 (Person → Person) 
CREATE TABLE follows ( 
    FollowDate DATE DEFAULT GETDATE() 
) AS EDGE; 

-- 좋아요 관계 (Person → Post) 
CREATE TABLE likes ( 
    LikedAt DATETIME2 DEFAULT GETDATE() 
) AS EDGE; 

-- 작성 관계 (Person → Post) 
CREATE TABLE wrote AS EDGE; </code></pre>
<p>① AS EDGE 키워드 </p>
<p>엣지 테이블이 됨을 선언합니다. 자동으로 $edge_id, $from_id, $to_id 세 개의 컬럼이 생성됩니다. </p>
<p>② 엣지 속성 컬럼 </p>
<p>follows의 FollowDate처럼, 관계 자체에 대한 메타데이터(언제 맺어졌는지, 가중치 등)를 컬럼으로 자유롭게 둘 수 있습니다. </p>
<p>③ 컬럼 없는 엣지도 가능 </p>
<p>wrote 테이블처럼 사용자 정의 컬럼이 하나도 없어도 됩니다. AS EDGE만 있으면 시스템 컬럼만으로 동작합니다. </p>
<p>④ 방향성 </p>
<p>엣지는 항상 $from_id → $to_id 방향을 갖습니다. 양방향 관계를 표현하고 싶다면 동일한 엣지를 반대 방향으로 한 번 더 INSERT 하면 됩니다. </p>
<blockquote>
<p>⚠️  엣지 테이블에는 PRIMARY KEY를 두지 않습니다 
$edge_id가 시스템 PK 역할을 자동으로 하므로 굳이 추가 PK를 둘 필요가 없습니다. 추가 PK를 두면 같은 두 노드 사이에 여러 엣지(예: 같은 사람이 같은 게시물을 시점을 달리해 두 번 좋아요)를 만들 수 없게 되어 오히려 모델링이 어색해집니다. PRIMARY KEY가 없다고 해서 취소 여부를 못 아는 게 아니라, 엣지 테이블에 FollowedAt, UnfollowedAt, IsActive 같은 속성을 넣어서 관계 자체를 상태 데이터로 관리하는게 보통입니다.</p>
</blockquote>
<p>혹은 SCD를 사용한다.</p>
<h3 id="scd">SCD</h3>
<p>데이터 웨어하우스에서 “시간이 지나며 바뀌는 데이터”를 어떻게 관리할지에 대한 패턴
| 구분       | Type 1    | Type 2           |
| -------- | --------- | ---------------- |
| 데이터 변경 시 | 기존 데이터 수정 | 새 행 추가           |
| 과거 데이터   | 사라짐       | 유지               |
| 테이블 크기   | 작음        | 커짐               |
| 구현 난이도   | 쉬움        | 복잡               |
| 분석 용도    | 현재 상태 중심  | 이력 분석 가능         |
| 예시       | 최신 프로필    | 팔로우 이력, 주소 변경 이력 |</p>
<h2 id="24-샘플-데이터-삽입">2.4 샘플 데이터 삽입</h2>
<p>노드 데이터를 먼저 삽입한 후, 엣지 데이터를 삽입합니다. 이 순서는 매우 중요합니다 — 엣지가 참조할 노드가 먼저 존재해야 합니다. </p>
<h3 id="①-노드-데이터">① 노드 데이터</h3>
<pre><code class="language-sql">-- 사용자 노드 삽입 
INSERT INTO Person (PersonId, Name, Email) VALUES 
    (1, N&#39;김원일&#39;, &#39;kim@example.com&#39;), 
    (2, N&#39;이두석&#39;, &#39;lee@example.com&#39;), 
    (3, N&#39;박삼현&#39;, &#39;park@example.com&#39;), 
    (4, N&#39;정사람&#39;, &#39;jung@example.com&#39;), 
    (5, N&#39;오동현&#39;, &#39;oh@example.com&#39;);   

-- 게시물 노드 삽입 
INSERT INTO Post (PostId, Title, Content) VALUES 
    (101, N&#39;Azure SQL 시작하기&#39;, N&#39;Azure SQL의 기본 사용법을 알아봅니다…&#39;), 
    (102, N&#39;Graph DB 활용법&#39;,    N&#39;소셜 네트워크 구현 예제입니다…&#39;), 
    (103, N&#39;성능 최적화 팁&#39;,     N&#39;쿼리 성능을 향상시키는 방법…&#39;); </code></pre>
<h3 id="②-엣지-데이터-follows">② 엣지 데이터 (follows)</h3>
<pre><code class="language-sql">-- 팔로우 관계 삽입 (누가 누구를 팔로우하는지) 
INSERT INTO follows ($from_id, $to_id) VALUES 
    ((SELECT $node_id FROM Person WHERE PersonId = 1), 
     (SELECT $node_id FROM Person WHERE PersonId = 2)),     -- 김원일 → 이두석 

    ((SELECT $node_id FROM Person WHERE PersonId = 1), 
     (SELECT $node_id FROM Person WHERE PersonId = 3)),     -- 김원일 → 박삼현 

    ((SELECT $node_id FROM Person WHERE PersonId = 2), 
     (SELECT $node_id FROM Person WHERE PersonId = 3)),     -- 이두석 → 박삼현 

    ((SELECT $node_id FROM Person WHERE PersonId = 3), 
     (SELECT $node_id FROM Person WHERE PersonId = 4)),     -- 박삼현 → 정사람 

    ((SELECT $node_id FROM Person WHERE PersonId = 4), 
     (SELECT $node_id FROM Person WHERE PersonId = 5));     -- 정사람 → 오동현 </code></pre>
<p>① $from_id, $to_id에 직접 INSERT </p>
<p>엣지를 만들 때는 두 시스템 컬럼 $from_id, $to_id에 값을 채워 넣습니다. 이 값들은 노드의 $node_id (JSON)와 동일한 형태여야 합니다. </p>
<p>② 서브쿼리로 $node_id 조회 </p>
<p>JSON 값을 직접 입력하긴 어렵기 때문에, (SELECT $node_id FROM Person WHERE PersonId = 1) 형태로 비즈니스 키 → $node_id 변환을 매번 거칩니다. 이 패턴이 그래프 INSERT의 표준 관용구입니다. </p>
<p>③ 한 INSERT에 여러 엣지 </p>
<p>VALUES 절에 행을 콤마로 나열하면 한 번의 INSERT로 여러 엣지를 만들 수 있어 트랜잭션 비용이 줄어듭니다. </p>
<p>④ 결과 그래프 </p>
<p>실행이 끝나면 그림 2-1의 follows 5개 엣지가 모두 만들어집니다. 5명의 Person 노드를 잇는 사슬과 분기 구조가 형성됩니다. </p>
<h4 id="edge-table에-중복-안넣는-방법">Edge Table에 중복 안넣는 방법</h4>
<p>팔로우 했다가 취소했다가 팔로하면? 삭제하지 않고 그냥 둔다. 따라서 보통은 중복을 허용한다. 굳이 중복방지를 하고싶다면 조건을 걸면 된다. edge table에 굳이 추가 PK를 두지 않는것도 있다.</p>
<h3 id="③-엣지-데이터-wrote-likes">③ 엣지 데이터 (wrote, likes)</h3>
<pre><code class="language-sql">-- 게시물 작성 관계 
INSERT INTO wrote ($from_id, $to_id) VALUES 
    ((SELECT $node_id FROM Person WHERE PersonId = 1), 
     (SELECT $node_id FROM Post   WHERE PostId   = 101)),  -- 김원일 → Post 101 
    ((SELECT $node_id FROM Person WHERE PersonId = 2), 
     (SELECT $node_id FROM Post   WHERE PostId   = 102)),  -- 이두석 → Post 102 
    ((SELECT $node_id FROM Person WHERE PersonId = 3), 
     (SELECT $node_id FROM Post   WHERE PostId   = 103));  -- 박삼현 → Post 103 

-- 좋아요 관계 
INSERT INTO likes ($from_id, $to_id) VALUES 
    ((SELECT $node_id FROM Person WHERE PersonId = 2), 
     (SELECT $node_id FROM Post   WHERE PostId   = 101)), 
    ((SELECT $node_id FROM Person WHERE PersonId = 3), 
     (SELECT $node_id FROM Post   WHERE PostId   = 101)), 
    ((SELECT $node_id FROM Person WHERE PersonId = 1), 
     (SELECT $node_id FROM Post   WHERE PostId   = 102)); </code></pre>
<h2 id="🎯--실습-과제-1">🎯  실습 과제 1</h2>
<h3 id="과제-1-a">과제 1-A.</h3>
<p>새 사용자 &quot;최여섯&quot;(PersonId=6)을 INSERT한 뒤, 김원일이 최여섯을 팔로우하는 엣지를 추가하세요. </p>
<pre><code class="language-sql">INSERT INTO Person (PersonId, Name) VALUES 
(6,N&#39;최여섯&#39;);

INSERT INTO follows ($from_id, $to_id) VALUES 
    ((SELECT $node_id FROM Person WHERE PersonId = 1), 
     (SELECT $node_id FROM Person WHERE PersonId = 6));     -- 김원일 → 최여섯 </code></pre>
<h3 id="과제-1-b">과제 1-B.</h3>
<p>SELECT * FROM Person; 과 SELECT * FROM follows; 를 각각 실행해서 $node_id, $from_id, $to_id 값이 실제로 어떻게 생겼는지 눈으로 확인해 보세요. </p>
<pre><code class="language-sql">SELECT * FROM Person;
SELECT * FROM follows;</code></pre>
<p><img src="https://velog.velcdn.com/images/rudin_/post/84497c9e-e3ff-486b-9302-9df2f8d330e3/image.png" alt="">
<img src="https://velog.velcdn.com/images/rudin_/post/b4e0bc53-beb2-457c-ab39-a3252b7fc41b/image.png" alt=""></p>
<h3 id="과제-1-c">과제 1-C.</h3>
<p>시스템 카탈로그를 사용해 이 데이터베이스의 모든 노드 테이블과 엣지 테이블을 나열해 보세요. 힌트:  SELECT name, is_node, is_edge FROM sys.tables WHERE is_node = 1 OR is_edge = 1; </p>
<p><img src="https://velog.velcdn.com/images/rudin_/post/c014cdd3-c671-49a1-9f09-4b0816da2769/image.png" alt=""></p>
<h1 id="3-match--그래프-패턴-쿼리">3. MATCH — 그래프 패턴 쿼리</h1>
<h2 id="31-이론">3.1 이론</h2>
<p><img src="https://velog.velcdn.com/images/rudin_/post/b381be65-d60b-4a79-937d-2d7d5659ae43/image.png" alt="">
노드와 엣지를 ASCII 아트처럼 그려서 패턴을 정의하면, SQL Server가 그래프를 따라가며 그 패턴에 맞는 모든 경로를 찾아옵니다. </p>
<p>핵심은 &quot;코드에 그린 그림이 곧 찾고자 하는 패턴&quot;이라는 점입니다. (A)-(e)-&gt;(B) 라고 쓰면 노드 A에서 엣지 e를 타고 노드 B로 가는 모든 쌍을 찾는다는 의미가 됩니다. </p>
<table>
<thead>
<tr>
<th>구문 요소</th>
<th>의미</th>
</tr>
</thead>
<tbody><tr>
<td>(node)</td>
<td>노드. FROM 절에 등장한 노드 테이블의 별칭(alias)을 그대로 사용</td>
</tr>
<tr>
<td>-(edge)-&gt;</td>
<td>정방향 엣지. 왼쪽 노드의 <code>$node_id</code>가 엣지의 <code>$from_id</code>와 같고, 오른쪽 노드의 <code>$node_id</code>가 <code>$to_id</code>와 같은 행 탐색</td>
</tr>
<tr>
<td>&lt;-(edge)-</td>
<td>역방향 엣지. <code>from/to</code>가 반대 방향인 형태</td>
</tr>
<tr>
<td>-(edge)-</td>
<td>방향 무관 탐색 (양방향 시도). SQL Server 2019부터 지원</td>
</tr>
<tr>
<td>MATCH(...)</td>
<td>WHERE 절에서 그래프 패턴 전체를 감싸는 표현식. 일반 조건과 <code>AND</code> 결합 가능</td>
</tr>
</tbody></table>
<h2 id="32-기본-패턴-매칭">3.2 기본 패턴 매칭</h2>
<h3 id="직접-연결된-노드-찾기">직접 연결된 노드 찾기</h3>
<pre><code class="language-sql">SELECT 
    Person1.Name AS Follower, 
    Person2.Name AS Following 
FROM Person AS Person1, follows, Person AS Person2 
WHERE MATCH(Person1-(follows)-&gt;Person2) 
  AND Person1.Name = N&#39;김원일&#39;; </code></pre>
<p><img src="https://velog.velcdn.com/images/rudin_/post/bee32df1-6788-4dbd-8af2-97b2b2427df6/image.png" alt=""></p>
<p>① FROM 절에 노드와 엣지 모두 나열 </p>
<p>같은 Person 테이블이지만 시작 노드와 끝 노드 두 역할로 쓰이므로, 별칭 Person1, Person2로 두 번 나타나게 합니다. 가운데 follows는 엣지 테이블 그 자체입니다. </p>
<p>② WHERE MATCH(...) — 패턴 정의 </p>
<p>MATCH 절은 &quot;Person1에서 follows 엣지를 타고 Person2로 가는 경로&quot;를 의미합니다. SQL Server는 이 표현을 &quot;follows.$from_id = Person1.$node_id AND follows.$to_id = Person2.$node_id&quot;라는 조건으로 내부 변환합니다. </p>
<p>③ AND Person1.Name = N&#39;김원일&#39; — 시작점 고정 </p>
<p>MATCH 패턴 자체에 비교 조건을 끼워 넣지 않습니다. 시작 노드를 좁히고 싶을 때는 일반 WHERE 절처럼 AND로 추가합니다. </p>
<p>④ SELECT — 무엇을 가져올지 결정 </p>
<p>Person1.Name(팔로워), Person2.Name(팔로잉 대상)을 컬럼명으로 반환합니다. 같은 테이블에서 두 행을 동시에 다루는 self-join 같은 효과를 MATCH 한 줄로 깔끔하게 만든 셈입니다. </p>
<h3 id="역방향-탐색">역방향 탐색</h3>
<p>이번엔 반대로, &quot;박삼현을 팔로우하는 사람들&quot;을 조회합니다. 두 가지 동등한 표현이 있습니다. </p>
<pre><code class="language-sql">-- 방법 A : 순방향 패턴 + 끝 노드를 박삼현으로 고정 
SELECT Person1.Name AS Follower 
FROM Person AS Person1, follows, Person AS Person2 
WHERE MATCH(Person1-(follows)-&gt;Person2) 
  AND Person2.Name = N&#39;박삼현&#39;; 

-- 방법 B : 역방향 화살표 사용 
SELECT Person1.Name AS Follower 
FROM Person AS Person1, follows, Person AS Person2 
WHERE MATCH(Person2&lt;-(follows)-Person1) 
  AND Person2.Name = N&#39;박삼현&#39;; </code></pre>
<blockquote>
<p>💡  두 표현은 어떻게 다른가요? 
결과는 동일합니다. SQL Server가 동일한 실행 계획으로 평가하기 때문에 성능 차이도 없습니다. 
가독성 차이만 있습니다. &quot;박삼현 입장에서 자기를 팔로우하는 사람&quot;이라는 관점이 자연스러울 때는 방법 B가 읽기 쉽고, &quot;전체 팔로우 관계 중 끝점이 박삼현&quot;이라는 관점이라면 방법 A가 자연스럽습니다. </p>
</blockquote>
<h2 id="33-다중-홉multi-hop-탐색">3.3 다중 홉(Multi-hop) 탐색</h2>
<p>관계형으로는 N홉마다 JOIN이 N-1번 늘어나지만, 그래프에서는 패턴에 화살표를 더 이어붙이기만 하면 됩니다. 
<img src="https://velog.velcdn.com/images/rudin_/post/ba73fe07-f06d-4a81-83f6-b55671938479/image.png" alt=""></p>
<h3 id="친구의-친구-찾기-2-hop">친구의 친구 찾기 (2-hop)</h3>
<pre><code class="language-sql">SELECT DISTINCT 
    Person1.Name AS Person, 
    Person2.Name AS Friend, 
    Person3.Name AS FriendOfFriend 
FROM 
    Person AS Person1, 
    follows AS f1, 
    Person AS Person2, 
    follows AS f2, 
    Person AS Person3 
WHERE MATCH(Person1-(f1)-&gt;Person2-(f2)-&gt;Person3) 
  AND Person1.Name = N&#39;김원일&#39; 
  AND Person1.PersonId &lt;&gt; Person3.PersonId;   -- 자기 자신 제외 </code></pre>
<p><img src="https://velog.velcdn.com/images/rudin_/post/d2389180-7d6e-43ce-acec-9110026d2535/image.png" alt=""></p>
<p>① FROM 절이 길어지는 이유 </p>
<p>Person1, Person2, Person3 — 같은 Person 테이블의 별칭이 3개. 엣지도 f1, f2 두 개로 별도 별칭을 부여합니다. 한 패턴 안에 같은 테이블이 여러 번 등장할 수 있기 때문에 별칭은 필수입니다. </p>
<p>② MATCH 패턴 — 화살표 잇기 </p>
<p>Person1-(f1)-&gt;Person2-(f2)-&gt;Person3. 두 엣지가 가운데 노드 Person2를 공유하면서 자연스럽게 이어집니다. 그래프의 ASCII 그림이 곧 우리가 찾고자 하는 경로의 모양. </p>
<p>③ DISTINCT가 필요한 이유 </p>
<p>같은 사람이 여러 경로로 도달 가능할 수 있습니다. 예를 들어 X가 다른 두 친구를 통해 동시에 친구의 친구가 되는 경우, DISTINCT 없이는 결과가 중복됩니다. </p>
<p>④ 자기 자신 제외 </p>
<p>Person1.PersonId &lt;&gt; Person3.PersonId. 만약 A→B→A라는 상호 팔로우가 있다면 A 자신이 친구의 친구로 잡혀버립니다. 이 조건으로 그런 경우를 제외합니다. </p>
<blockquote>
<p>⚠️  결과 폭발에 주의 
N홉 탐색은 평균 차수(degree)의 N제곱에 비례하는 경로 수를 만들어낼 수 있습니다. 
평균 팔로우 100명이라면 3홉만 해도 100^3 = 100만 경로 후보가 발생합니다. 
실무에서는 보통 2~3홉으로 제한하고, 시작 노드를 명확하게 좁히는 WHERE 조건을 함께 사용합니다. </p>
</blockquote>
<h3 id="3단계-연결-탐색-3-hop">3단계 연결 탐색 (3-hop)</h3>
<pre><code class="language-sql">SELECT DISTINCT 
    P1.Name AS Start, 
    P2.Name AS Hop1, 
    P3.Name AS Hop2, 
    P4.Name AS Hop3 
FROM 
    Person AS P1, follows AS f1, 
    Person AS P2, follows AS f2, 
    Person AS P3, follows AS f3, 
    Person AS P4 
WHERE MATCH(P1-(f1)-&gt;P2-(f2)-&gt;P3-(f3)-&gt;P4) 
  AND P1.Name = N&#39;김원일&#39;; </code></pre>
<p><img src="https://velog.velcdn.com/images/rudin_/post/a10fe6cd-e7a5-4bf6-9849-69a4b29ecd12/image.png" alt=""></p>
<h1 id="4-복합-패턴-쿼리">4. 복합 패턴 쿼리</h1>
<h2 id="41-여러-엣지-타입-결합">4.1 여러 엣지 타입 결합</h2>
<p>한 쿼리 안에서 서로 다른 종류의 엣지를 자유롭게 섞을 수 있습니다. 예를 들어 &quot;내가 팔로우하는 사람이 작성한 게시물&quot;은 follows + wrote 두 엣지 타입을 거치는 패턴입니다. </p>
<h3 id="내가-팔로우하는-사람이-작성한-게시물">내가 팔로우하는 사람이 작성한 게시물</h3>
<pre><code class="language-sql">SELECT 
    Person1.Name  AS Me, 
    Person2.Name  AS Following, 
    Post.Title    AS PostTitle 
FROM 
    Person AS Person1, 
    follows, 
    Person AS Person2, 
    wrote, 
    Post 
WHERE MATCH(Person1-(follows)-&gt;Person2-(wrote)-&gt;Post) 
  AND Person1.Name = N&#39;김원일&#39;; </code></pre>
<p>① 서로 다른 노드 타입의 등장 </p>
<p>이 쿼리는 Person 노드 두 개와 Post 노드 하나가 한 패턴에 등장합니다. 노드 별칭만 다르게 잡으면 한 그래프 안에 다양한 종류의 노드를 자유롭게 섞을 수 있습니다. </p>
<p>② 두 엣지 타입을 한 패턴에 </p>
<p>follows와 wrote는 의미가 전혀 다른 엣지지만, 가운데 Person2 노드를 공유하면서 자연스럽게 이어집니다. 패턴 표현 그대로 &quot;사람을 따라가서 그 사람이 쓴 글까지&quot; 한 줄로 표현되는 셈. </p>
<p>③ 관계형 모델과 비교 </p>
<p>같은 결과를 관계형으로 풀려면 Person ⨝ follows ⨝ Person ⨝ wrote ⨝ Post의 4단계 JOIN이 필요합니다. MATCH 패턴은 이 모든 JOIN 조건을 한 줄로 압축합니다. </p>
<h2 id="42-조건부-필터링과-집계">4.2 조건부 필터링과 집계</h2>
<h3 id="팔로워-수-계산">팔로워 수 계산</h3>
<pre><code class="language-sql">SELECT 
    Person2.Name, 
    COUNT(*) AS FollowerCount 
FROM Person AS Person1, follows, Person AS Person2 
WHERE MATCH(Person1-(follows)-&gt;Person2) 
GROUP BY Person2.Name, Person2.PersonId 
ORDER BY FollowerCount DESC; </code></pre>
<p>▶  실행 흐름 </p>
<p>1) follows 엣지 전체를 훑어 (시작자, 도착자) 쌍을 모두 만든다. 
2) Person2(도착자) 기준으로 그룹화한다. 
3) 각 그룹의 행 수를 세면 그 사람이 받은 팔로우 수가 된다. </p>
<p>샘플 그래프에서는 박삼현이 2명(김원일, 이두석)으로 1위, 그 외는 모두 1명</p>
<h3 id="인기-게시물-찾기-좋아요-수-기준">인기 게시물 찾기 (좋아요 수 기준)</h3>
<pre><code class="language-sql">SELECT 
    Post.Title, 
    COUNT(*) AS LikeCount 
FROM Person, likes, Post 
WHERE MATCH(Person-(likes)-&gt;Post) 
GROUP BY Post.PostId, Post.Title 
HAVING COUNT(*) &gt;= 2 
ORDER BY LikeCount DESC; </code></pre>
<p>① Person 별칭 생략 가능 </p>
<p>같은 테이블이 한 패턴에 한 번만 등장하면 별칭(AS …)을 생략하고 테이블명을 그대로 별칭처럼 사용할 수 있습니다. 가독성을 위해 명시하는 것이 보통이지만 짧은 쿼리에서는 생략도 흔합니다. </p>
<p>② GROUP BY에 PK 포함 </p>
<p>Title이 같다면 PostId 없이 Title만으로 그룹화하면 잘못 합쳐질 수 있습니다. 비즈니스 키를 GROUP BY에 함께 넣는 습관을 들이세요. </p>
<p>③ HAVING으로 사후 필터 </p>
<p>집계 결과에 대한 조건은 WHERE가 아니라 HAVING입니다. &quot;좋아요 2개 이상인 게시물만&quot;이라는 조건이 여기에 해당. </p>
<h1 id="5-실무-시나리오">5. 실무 시나리오</h1>
<h2 id="51-추천-시스템-구현">5.1 추천 시스템 구현</h2>
<h3 id="친구-추천-공통-친구-기반">친구 추천 (공통 친구 기반)</h3>
<p>소셜 네트워크의 클래식한 추천 알고리즘 중 하나는 &quot;공통 친구가 많은 사람&quot;을 우선 추천하는 방식입니다. 나의 1-hop 친구(공통 친구)를 거쳐서 도달 가능한 2-hop의 사람을 모두 모은 뒤, 같은 사람이 여러 경로로 도달될수록 점수가 높다고 보는 것이 핵심입니다. 
<img src="https://velog.velcdn.com/images/rudin_/post/2dbc362b-2245-4bbb-94ba-c5d68ddd8698/image.png" alt=""></p>
<pre><code class="language-sql">
-- 김원일에게 친구 추천: 공통 친구가 많은 사람 
SELECT 
    Recommended.Name AS RecommendedPerson, 
    COUNT(*)         AS CommonFriends 
FROM 
    Person  AS Me, 
    follows AS f1, 
    Person  AS CommonFriend, 
    follows AS f2, 
    Person  AS Recommended 
WHERE MATCH(Me-(f1)-&gt;CommonFriend-(f2)-&gt;Recommended) 
  AND Me.Name = N&#39;김원일&#39; 
  AND Me.PersonId &lt;&gt; Recommended.PersonId 
  -- 이미 팔로우하는 사람 제외 
  AND NOT EXISTS ( 
        SELECT 1 FROM follows AS existing -- 데이터 존재 여부(TRUE) 를 확인하는 성능 최적화용
        WHERE existing.$from_id = Me.$node_id 
          AND existing.$to_id   = Recommended.$node_id 
  ) 
GROUP BY Recommended.PersonId, Recommended.Name 
ORDER BY CommonFriends DESC; </code></pre>
<p>① Me-(f1)-&gt;CommonFriend-(f2)-&gt;Recommended </p>
<p>2-hop 패턴. 가운데 CommonFriend는 &quot;내가 팔로우하는 사람&quot;이자 &quot;Recommended를 팔로우하는 사람&quot; 두 역할을 동시에 수행합니다. </p>
<p>② COUNT(*)의 의미 </p>
<p>같은 Recommended가 서로 다른 CommonFriend를 통해 여러 번 도달되면, 그만큼 (Me, CommonFriend, Recommended) 행이 여러 개 나옵니다. 이걸 GROUP BY Recommended로 묶어 COUNT하면 곧 &quot;공통 친구의 수&quot;가 됩니다. </p>
<p>③ 자기 자신 제외 </p>
<p>A→B→A 형태의 상호 팔로우가 있을 때 자기가 자기에게 추천되는 것을 막습니다. </p>
<p>④ 이미 팔로우 중인 사람 제외 (NOT EXISTS) </p>
<p>Me.$node_id에서 Recommended.$node_id로 가는 follows 엣지가 이미 존재하면 추천 후보에서 빼야 합니다. NOT EXISTS는 결과 1건만 확인되면 빠르게 끝나기 때문에 NOT IN 보다 효율적이고 NULL 안전합니다. </p>
<p>⑤ 결과 해석 </p>
<p>샘플 그래프에서 김원일에게 추천될 수 있는 후보는 정사람뿐(박삼현 → 정사람 경로). 이두석 → 박삼현 경로는 박삼현이 이미 팔로우 중이라 NOT EXISTS에서 걸러집니다. </p>
<h3 id="select-1">select 1</h3>
<ul>
<li>데이터 값 자체는 필요 없고, 조건을 만족하는 행이 존재하는지만 확인하고 싶을 때</li>
<li>DB 연결 테스트</li>
<li>쿼리 동작 확인용</li>
</ul>
<h3 id="콘텐츠-추천-팔로우하는-사람들이-좋아한-게시물">콘텐츠 추천 (팔로우하는 사람들이 좋아한 게시물)</h3>
<pre><code class="language-sql">SELECT 
    Post.Title, 
    COUNT(DISTINCT Following.PersonId) AS LikedByFollowing 
FROM 
    Person  AS Me, 
    follows, 
    Person  AS Following, 
    likes, 
    Post 
WHERE MATCH(Me-(follows)-&gt;Following-(likes)-&gt;Post) 
  AND Me.Name = N&#39;김원일&#39; 
  -- 내가 이미 좋아요한 게시물 제외 
  AND NOT EXISTS ( 
        SELECT 1 FROM likes AS myLikes 
        WHERE myLikes.$from_id = Me.$node_id 
          AND myLikes.$to_id   = Post.$node_id 
  ) 
GROUP BY Post.PostId, Post.Title 
ORDER BY LikedByFollowing DESC; </code></pre>
<h3 id="💡--count-vs-countdistinct--차이">💡  COUNT(*) vs COUNT(DISTINCT …) 차이</h3>
<p>여기서 COUNT(<em>)를 쓰면 &quot;내 팔로우 친구 한 명이 같은 게시물에 두 번 좋아요한 경우&quot;가 잘못 가중되어 계산됩니다. 
COUNT(DISTINCT Following.PersonId)는 &quot;이 게시물을 좋아한 (서로 다른) 친구 수&quot;라는 정확한 지표가 됩니다. 데이터 모델상 한 사람이 같은 게시물을 두 번 좋아요 할 수 없다면(원천적으로 안된다면) COUNT(</em>)도 무방하지만, 안전한 기본값은 DISTINCT. </p>
<h2 id="52-영향력-분석">5.2 영향력 분석</h2>
<h3 id="인플루언서-찾기">인플루언서 찾기</h3>
<p>&quot;받은 팔로워 수&quot;와 &quot;내 글이 받은 좋아요 수&quot;를 합산해 영향력 점수를 계산합니다. CTE(공통 테이블 표현식)로 두 지표를 따로 구하고 마지막에 LEFT JOIN으로 합치는 패턴입니다. </p>
<pre><code class="language-sql">-- 팔로워 수와 게시물 좋아요 수를 합산한 영향력 점수 
WITH FollowerCounts AS ( 
    SELECT 
        Person2.PersonId, 
        COUNT(*) AS Followers 
    FROM Person AS Person1, follows, Person AS Person2 
    WHERE MATCH(Person1-(follows)-&gt;Person2) 
    GROUP BY Person2.PersonId 
), 
LikeCounts AS ( 
    SELECT 
        Author.PersonId, 
        COUNT(*) AS TotalLikes 
    FROM Person AS Liker, likes, Post, wrote, Person AS Author 
    WHERE MATCH(Liker-(likes)-&gt;Post&lt;-(wrote)-Author) 
    GROUP BY Author.PersonId 
) 
SELECT 
    p.Name, 
    ISNULL(f.Followers, 0)   AS Followers, 
    ISNULL(l.TotalLikes, 0)  AS TotalLikes, 
    ISNULL(f.Followers, 0) + ISNULL(l.TotalLikes, 0) AS InfluenceScore 
FROM Person p 
LEFT JOIN FollowerCounts f ON p.PersonId = f.PersonId 
LEFT JOIN LikeCounts     l ON p.PersonId = l.PersonId 
ORDER BY InfluenceScore DESC; </code></pre>
<p>① CTE 1 — FollowerCounts </p>
<p>4.2의 팔로워 수 쿼리를 그대로 가져와 &quot;PersonId별 팔로워 수&quot;를 임시 테이블처럼 다룹니다. CTE는 메인 쿼리에서 한 번만 사용해도 가독성을 크게 높입니다. </p>
<p>② CTE 2 — LikeCounts와 역방향 패턴 </p>
<p>Liker-(likes)-&gt;Post&lt;-(wrote)-Author. 좋아요한 사람으로부터 게시물로 가고, 그 게시물을 누가 썼는지 역방향으로 가져옵니다. 한 패턴 안에서 &quot;정방향 + 역방향&quot; 혼용이 가능합니다. </p>
<p>③ LEFT JOIN으로 모든 사람 보존 </p>
<p>글을 쓴 적 없거나 팔로워가 0인 사람도 결과에 나타나야 합니다. INNER JOIN으로 묶으면 이런 사람이 사라지므로 LEFT JOIN + ISNULL 패턴이 정석. </p>
<p>④ ISNULL로 NULL을 0으로 </p>
<p>집계 결과가 없는 사람은 Followers/TotalLikes가 NULL입니다. ISNULL(…, 0)로 0 처리해야 더하기 연산이 망가지지 않습니다. </p>
<blockquote>
<p>⚠️  실무 단순화 주의 
&quot;팔로워 수 + 좋아요 수&quot; 단순합은 학습용 예시입니다. 
실무에서는 PageRank, HITS, Eigenvector centrality 같은 지표가 훨씬 견고합니다 — &quot;유명한 사람을 많이 팔로우하는 게 단순히 무명인 사람 100명을 팔로우하는 것보다 점수가 높아야&quot; 하기 때문입니다. 
SQL Graph 자체는 PageRank 내장 함수를 제공하지 않으므로, 본격 분석은 외부 그래프 엔진(Neo4j GDS, Spark GraphFrames 등)이나 Python(networkx)으로 옮겨 수행하는 것이 일반적입니다. </p>
</blockquote>
<h1 id="6-shortest_path--최단-경로-탐색">6. SHORTEST_PATH — 최단 경로 탐색</h1>
<p>SQL Server 2019부터는 두 노드 간의 최단 경로를 자동으로 찾아주는 SHORTEST_PATH 키워드가 추가되었습니다. &quot;6단계 분리 이론&quot;을 직접 검증해 볼 수 있는 강력한 기능입니다. (Azure SQL Database도 동일한 호환성 레벨에서 지원합니다.)</p>
<p><img src="https://velog.velcdn.com/images/rudin_/post/ff3d0356-628c-4a95-b939-859fb9fad4db/image.png" alt=""></p>
<h2 id="61-x명까지의-모든-도달-가능-노드">6.1: &quot;X명까지의 모든 도달 가능 노드&quot;</h2>
<pre><code class="language-sql">-- 김원일에서 출발해 follows 경로상 모든 도달 가능한 사람과 거리 
SELECT 
    LAST_VALUE(Person2.Name) WITHIN GROUP (GRAPH PATH) AS Reachable, 
    STRING_AGG(Person2.Name, &#39; → &#39;) WITHIN GROUP (GRAPH PATH) AS Path, 
    COUNT(Person2.PersonId)  WITHIN GROUP (GRAPH PATH) AS Distance 
FROM 
    Person AS Person1, 
    follows FOR PATH AS f, 
    Person  FOR PATH AS Person2 
WHERE MATCH( SHORTEST_PATH( Person1( -(f)-&gt;Person2 )+ ) ) 
  AND Person1.Name = N&#39;김원일&#39; 
ORDER BY Distance; </code></pre>
<p><img src="https://velog.velcdn.com/images/rudin_/post/2e8fa673-e798-4325-be86-e65acb826387/image.png" alt=""></p>
<p>이 쿼리는 김원일이 follows 경로로 도달 가능한 모든 사람과 그 사람까지의 최단 거리, 그리고 거치는 경로를 한꺼번에 반환합니다. 거리 1(직접 팔로우), 거리 2(친구의 친구), 거리 3 형태로 자연스럽게 묶입니다.</p>
<blockquote>
<p>⚠️SHORTEST_PATH 사용상 주의 
SHORTEST_PATH는 &quot;엣지 가중치&quot;를 고려하지 않는 단순 BFS입니다. 가중치가 있는 최단 경로(다익스트라)가 필요하면 SQL Graph로는 직접 구현할 수 없고, 외부 그래프 엔진을 사용해야 합니다. 
하나의 패턴 안에 SHORTEST_PATH는 한 번만 사용 가능합니다. 
큰 그래프에서는 시작 노드를 매우 좁게 좁히지 않으면 (예: WHERE Person1.Name = …) 폭발적으로 느려질 수 있습니다. 
데이터베이스 호환성 레벨이 140 이상(SQL Server 2019, Azure SQL DB 최신)이어야 합니다. </p>
</blockquote>
<h1 id="7-성능-최적화">7. 성능 최적화</h1>
<h2 id="71-인덱스-전략">7.1 인덱스 전략</h2>
<p>그래프 테이블도 결국 일반 테이블이므로, 일반 SQL Server의 인덱스 전략이 그대로 적용됩니다. 다만 자주 검색되는 시작 노드의 비즈니스 키와, 엣지의 $from_id, $to_id 시스템 컬럼에 인덱스가 잘 걸려 있는지가 핵심입니다. </p>
<pre><code class="language-sql">
-- 노드 테이블의 자주 검색되는 컬럼에 인덱스 
CREATE INDEX IX_Person_Name  ON Person(Name); 
CREATE INDEX IX_Post_Title   ON Post(Title);

-- 엣지 테이블의 시스템 컬럼에 인덱스 (★ 가장 중요) 
CREATE INDEX IX_follows_from ON follows($from_id); 
CREATE INDEX IX_follows_to   ON follows($to_id); 
CREATE INDEX IX_likes_from   ON likes($from_id); 
CREATE INDEX IX_likes_to     ON likes($to_id); 
CREATE INDEX IX_wrote_from   ON wrote($from_id); 
CREATE INDEX IX_wrote_to     ON wrote($to_id); </code></pre>
<p>① 시작점 좁히기용 인덱스 </p>
<p>IX_Person_Name 같은 비즈니스 컬럼 인덱스는 &quot;Name = N&#39;김원일&#39;&quot; 같은 조건이 시작 노드를 빠르게 찾도록 도와줍니다. 시작 노드를 좁히지 못하면 그래프 전체를 훑게 되어 성능 저하의 가장 흔한 원인이 됩니다. </p>
<p>② $from_id, $to_id 인덱스 </p>
<p>그래프 탐색은 본질적으로 &quot;엣지의 한쪽 끝 ID를 기준으로 다른 쪽을 찾는&quot; 연산의 반복입니다. 이 두 컬럼이 인덱스 없이 풀 스캔되면 N홉 쿼리가 N제곱으로 느려집니다. 엣지 테이블을 만들면 거의 자동 반사로 두 인덱스를 함께 만드세요. </p>
<p>③ 복합 인덱스 고려 </p>
<p>특정 조건(예: 특정 기간의 follows만)이 자주 함께 들어간다면 ($from_id, FollowDate) 같은 복합 인덱스가 더 유리할 수 있습니다. 단순 단일 컬럼 인덱스만 무한정 만드는 것이 능사는 아닙니다. </p>
<h2 id="72-모범-사례">7.2 모범 사례</h2>
<h3 id="1-명명-규칙">1. 명명 규칙</h3>
<p>Node는 단수 명사 단수형(Person, Post), Edge는 동사 또는 관계명(follows, likes, wrote). 엣지가 동사형이면 패턴이 자연어처럼 읽힙니다. </p>
<h3 id="2-데이터-무결성">2. 데이터 무결성</h3>
<p>엣지 제약 조건을 적극 활용해 잘못된 노드 타입 사이에 엣지가 만들어지지 않도록. </p>
<h3 id="3-성능-모니터링">3. 성능 모니터링</h3>
<p>실행 계획에서 Edge Scan, Filter 비용이 큰 단계를 찾아내고, 인덱스/통계 갱신을 주기적으로 수행. </p>
<h3 id="4-홉-수-제한">4. 홉 수 제한</h3>
<p>가능한 한 명시적인 N-hop 쿼리(2~3홉)로 작성하고, 무제한 SHORTEST_PATH는 시작 노드가 충분히 좁혀진 경우에만. </p>
<h1 id="8-관계형--그래프-통합">8. 관계형 + 그래프 통합</h1>
<h2 id="81-하이브리드-쿼리">8.1 하이브리드 쿼리</h2>
<p>Graph DB의 가장 큰 장점 중 하나는 기존 관계형 테이블과 자유롭게 결합할 수 있다는 점입니다. 그래프 패턴 + 일반 JOIN을 한 쿼리에 섞을 수 있어, 별도 NoSQL 그래프 DB로 ETL할 필요 없이 같은 데이터베이스 안에서 모든 분석을 할 수 있습니다. </p>
<pre><code class="language-sql">-- 가정: 일반 관계형 테이블 UserActivity가 있다 
CREATE TABLE UserActivity ( 
    ActivityId    INT IDENTITY PRIMARY KEY, 
    PersonId      INT, 
    ActivityType  NVARCHAR(50), 
    ActivityDate  DATETIME2 DEFAULT GETDATE() 
); 

-- 그래프 패턴 + 관계형 JOIN을 한 쿼리에 섞기 
SELECT 
    p.Name, 
    COUNT(DISTINCT f2.$to_id)  AS FollowingCount, 
    COUNT(DISTINCT ua.ActivityId) AS RecentActivities 
FROM Person p 
LEFT JOIN follows f2     ON f2.$from_id = p.$node_id 
LEFT JOIN UserActivity ua 
       ON p.PersonId = ua.PersonId 
      AND ua.ActivityDate &gt;= DATEADD(day, -7, GETDATE()) 
GROUP BY p.PersonId, p.Name; </code></pre>
<p>① $node_id로 직접 JOIN </p>
<p>MATCH 절을 쓰지 않고도, follows 엣지의 $from_id와 Person의 $node_id를 직접 ON 조건으로 묶을 수 있습니다. 그래프 패턴 표현이 어색한 상황에서 유용한 우회로. </p>
<p>② 관계형 테이블과 자연스럽게 결합 </p>
<p>UserActivity는 그래프와 무관한 일반 테이블이지만, PersonId라는 비즈니스 키로 자연스럽게 LEFT JOIN됩니다. 같은 데이터베이스 안에 있다는 것의 큰 이점. </p>
<p>③ COUNT(DISTINCT)의 두 용도 </p>
<p>f2.$to_id를 DISTINCT 카운트하면 &quot;팔로잉 인원&quot;을, ua.ActivityId를 DISTINCT 카운트하면 &quot;활동 건수&quot;를 동시에 한 GROUP BY 안에서 산출할 수 있습니다. 두 측면을 각각 별도 쿼리로 만들고 합치는 수고를 덜어줍니다. </p>
<h2 id="82-그래프-데이터를-json으로-내보내기">8.2 그래프 데이터를 JSON으로 내보내기</h2>
<p>그래프를 외부 시스템(D3.js, 시각화 도구 등)에 전달할 때 흔한 형식은 nodes/edges가 분리된 JSON입니다.</p>
<pre><code class="language-sql">-- 노드를 JSON 배열로 
SELECT 
    PersonId   AS id, 
    Name       AS label, 
    &#39;Person&#39;   AS type 
FROM Person 
FOR JSON PATH, ROOT(&#39;nodes&#39;);  

-- 엣지를 JSON 배열로 
SELECT 
    Person1.PersonId   AS source, 
    Person2.PersonId   AS target, 
    &#39;follows&#39;          AS type 
FROM Person AS Person1, follows, Person AS Person2 
WHERE MATCH(Person1-(follows)-&gt;Person2) 
FOR JSON PATH, ROOT(&#39;edges&#39;); </code></pre>
<blockquote>
<p>💡  FOR JSON PATH의 결과 형태 
{ &quot;nodes&quot;: [ {&quot;id&quot;:1,&quot;label&quot;:&quot;김원일&quot;,&quot;type&quot;:&quot;Person&quot;}, … ] } 형태가 한 줄로 반환됩니다. 
필요하면 두 쿼리 결과를 애플리케이션 단에서 합쳐 { nodes:[…], edges:[…] } 형태로 만든 뒤 D3.js force layout 등으로 렌더링하면 됩니다. 
대용량 그래프라면 FOR JSON 결과가 행당 2GB 한도에 부딪힐 수 있으므로 페이지 단위로 잘라 내보내는 패턴이 안전합니다. </p>
</blockquote>
<h1 id="9-자주-하는-실수--트러블슈팅">9. 자주 하는 실수 / 트러블슈팅</h1>
<h2 id="91-노드엣지-정의-실수">9.1 노드/엣지 정의 실수</h2>
<h3 id="⚠️--실수-1-as-node--as-edge-키워드-누락">⚠️  실수 1: AS NODE / AS EDGE 키워드 누락</h3>
<p>증상: 평범한 테이블이 만들어지지만, 이후 MATCH 쿼리에서 &quot;테이블이 그래프 노드/엣지가 아닙니다&quot; 류 에러가 발생. </p>
<p>원인: CREATE TABLE 마지막의 AS NODE 또는 AS EDGE를 빠뜨림. </p>
<p>해결: 테이블을 DROP한 뒤 다시 생성하거나, 새 그래프 테이블을 만들고 데이터를 옮긴 뒤 교체. (일반 테이블 → 그래프 테이블로의 자동 변환은 지원되지 않음.) </p>
<h3 id="⚠️--실수-2-엣지-테이블에-primary-key-추가">⚠️  실수 2: 엣지 테이블에 PRIMARY KEY 추가</h3>
<p>증상: 같은 두 노드 사이에 여러 엣지를 만들 수 없게 됨. 예) &quot;한 사람이 같은 게시물을 두 번 좋아요&quot;가 안 됨. </p>
<p>원인: $edge_id가 시스템 PK 역할을 하는데, 추가로 사용자 PK를 두면 의도치 않은 유일성 제약이 걸림. </p>
<p>해결: 엣지 테이블에는 일반 PK를 두지 않습니다. 유일성이 정말 필요하면 UNIQUE INDEX로 명시적으로 표현. </p>
<h2 id="92-insert-단계의-실수">9.2 INSERT 단계의 실수</h2>
<h3 id="⚠️--실수-3-node_id를-직접-insert하려-함">⚠️  실수 3: $node_id를 직접 INSERT하려 함</h3>
<p>증상: &quot;$node_id 컬럼에 직접 값을 삽입할 수 없습니다&quot; 류 에러. </p>
<p>원인: $node_id는 시스템이 자동 생성하는 컴퓨티드 컬럼. 사용자가 값을 넣을 수 없습니다. </p>
<p>해결: INSERT INTO Person (PersonId, Name, …) VALUES … 형태로, 시스템 컬럼은 빼고 비즈니스 컬럼만 명시. </p>
<h3 id="⚠️--실수-4-엣지-insert-시-null-from_id--to_id">⚠️  실수 4: 엣지 INSERT 시 NULL $from_id / $to_id</h3>
<p>증상: 엣지가 만들어졌지만 MATCH 쿼리에서 잡히지 않음. </p>
<p>원인: 서브쿼리 (SELECT $node_id FROM Person WHERE PersonId = 999)가 매칭 행이 없어 NULL을 반환했고, NULL인 채로 INSERT됨. </p>
<p>해결: 서브쿼리 작성 시 비즈니스 키가 실제 존재하는지 먼저 확인. 또는 INSERT 전에 EXISTS 체크를 추가하거나, 트랜잭션 + RAISERROR 패턴으로 안전망 구성.</p>
<h2 id="93-match-절-작성-실수">9.3 MATCH 절 작성 실수</h2>
<h3 id="⚠️--실수-5-match를-select-절에-적음">⚠️  실수 5: MATCH를 SELECT 절에 적음</h3>
<p>증상: &quot;MATCH 절은 WHERE 절에서만 사용할 수 있습니다&quot; 에러. </p>
<p>원인: 익숙한 SQL 사고방식으로 SELECT나 FROM 위치에 MATCH를 두려고 함. </p>
<p>해결: MATCH는 반드시 WHERE 절 안에 위치. 일반 비교 조건은 AND로 자유롭게 결합. </p>
<h3 id="⚠️--실수-6-from-절에-같은-테이블-별칭-누락">⚠️  실수 6: FROM 절에 같은 테이블 별칭 누락</h3>
<p>증상: &quot;Person이 두 번 사용되었지만 별칭이 없습니다&quot; 류 에러. </p>
<p>원인: 한 패턴에 같은 노드 타입이 여러 번 등장할 때 별칭(AS Person1, AS Person2)을 부여하지 않음. </p>
<p>해결: 같은 테이블이 한 패턴에 두 번 이상 나오면 반드시 다른 별칭을 부여. </p>
<h3 id="⚠️--실수-7-match-패턴-안에-비교-연산자">⚠️  실수 7: MATCH 패턴 안에 비교 연산자</h3>
<p>증상: 컴파일 에러. </p>
<p>원인: MATCH(Person1.Name = N&#39;김원일&#39;-(follows)-&gt;Person2) 처럼 패턴 안에 일반 비교를 넣으려 함. </p>
<p>해결: MATCH 안에는 그래프 패턴만. 일반 비교는 AND로 분리. </p>
<h2 id="94-shortest_path-관련-실수">9.4 SHORTEST_PATH 관련 실수</h2>
<h3 id="⚠️--실수-8-for-path-키워드-누락">⚠️  실수 8: FOR PATH 키워드 누락</h3>
<p>증상: &quot;SHORTEST_PATH 함수의 인수에는 FOR PATH 별칭이 필요합니다&quot; 류 에러. </p>
<p>원인: 가변 길이 패턴 안의 노드/엣지 변수에 FOR PATH 표시가 빠짐. </p>
<p>해결: follows FOR PATH AS f, Person FOR PATH AS Person2 형태로 모든 반복 패턴 변수에 FOR PATH 추가. </p>
<h3 id="⚠️--실수-9-호환성-레벨이-낮음">⚠️  실수 9: 호환성 레벨이 낮음</h3>
<p>증상: SHORTEST_PATH 키워드 자체를 인식하지 못함. </p>
<p>원인: 데이터베이스 호환성 레벨 &lt; 140. </p>
<p>해결: ALTER DATABASE [DBName] SET COMPATIBILITY_LEVEL = 140; (또는 더 높음). Azure SQL DB에서는 보통 자동으로 최신 레벨이지만, 마이그레이션된 DB는 확인 필요. </p>
<h2 id="95-성능-관련-실수">9.5 성능 관련 실수</h2>
<h3 id="⚠️--실수-10-시작-노드를-좁히지-않은-n-hop-쿼리">⚠️  실수 10: 시작 노드를 좁히지 않은 N-hop 쿼리</h3>
<p>증상: 데이터가 조금만 커져도 쿼리가 수십 초 ~ 수 분. </p>
<p>원인: WHERE 절에 시작 노드를 좁히는 조건이 없어, 모든 노드 쌍에 대해 N-hop 패턴을 평가함. </p>
<p>해결: AND Person1.Name = …, Person1.PersonId = … 같은 시작 노드 식별 조건을 반드시 함께 사용. 인덱스(7.1)도 함께 점검. </p>
<h3 id="⚠️--실수-11-from_id--to_id-인덱스-부재">⚠️  실수 11: $from_id / $to_id 인덱스 부재</h3>
<p>증상: 작은 그래프에서는 빠르지만 데이터 증가에 따라 N제곱으로 느려짐. </p>
<p>원인: 엣지의 $from_id, $to_id에 인덱스가 없어, 매 홉마다 풀 스캔. </p>
<p>해결: 7.1의 모든 엣지 테이블에 ($from_id), ($to_id) 인덱스를 만들고, 통계를 최신 상태로 유지. </p>
<h2 id="96-진단용-sql-모음">9.6 진단용 SQL 모음</h2>
<pre><code class="language-sql">-- (1) 모든 그래프 테이블 목록 
SELECT name, is_node, is_edge 
FROM sys.tables 
WHERE is_node = 1 OR is_edge = 1; 

-- (2) 그래프 테이블의 시스템 컬럼 확인 
SELECT t.name AS TableName, c.name AS ColumnName, c.graph_type_desc 
FROM sys.tables t 
JOIN sys.columns c ON t.object_id = c.object_id 
WHERE (t.is_node = 1 OR t.is_edge = 1) AND c.graph_type IS NOT NULL 
ORDER BY t.name, c.column_id;  

-- (3) 엣지 제약 조건 목록 
SELECT OBJECT_NAME(parent_object_id) AS EdgeTable, name AS ConstraintName 
FROM sys.edge_constraints; 

-- (4) 호환성 레벨 확인 
SELECT name, compatibility_level 
FROM sys.databases 
WHERE name = DB_NAME(); </code></pre>
<hr>

<h1 id="neo4j와-azure-기반-금융-사기-탐지-실습">Neo4j와 Azure 기반 금융 사기 탐지 실습</h1>
<h2 id="1장-그래프-데이터베이스-이론">1장. 그래프 데이터베이스 이론</h2>
<h3 id="14-lpg-vs-rdf--두-가지-그래프-모델">1.4 LPG vs RDF — 두 가지 그래프 모델</h3>
<p> LPG(Labeled Property Graph) 는 노드와 엣지에 ‘라벨(타입)’을 붙이고 ‘속성(key=value)’을 자유롭게 달 수 있는 모델로, Neo4j가 대표적입니다. RDF(Resource Description Framework) 는 W3C 표준으로 모든 데이터를 ‘주어-서술어-목적어(triple)’로 표현하며, 시맨틱 웹과 지식 그래프 분야에서 강세입니다.
실무에서는 표현력과 학습 곡선의 균형이 좋은 LPG가 더 널리 채택되고 있습니다.</p>
<h3 id="15-그래프-db를-써야-할-세-가지-신호">1.5 그래프 DB를 써야 할 ‘세 가지 신호’</h3>
<p>모든 데이터를 그래프에 담으려는 것은 망치를 든 사람에게 모든 것이 못으로 보이는 함정입니다. 다음 중 둘 이상이 해당될 때 그래프 DB가 적합합니다.</p>
<ul>
<li>관계의 깊이가 3-hop 이상. SNS의 친구 추천, 사기 탐지의 우회 거래 등.</li>
<li>관계 자체가 분석 대상. ‘무엇이 연결되어 있는가’가 ‘무엇이 있는가’보다 중요한 도메인. 지식 그래프, 추천 시스템, 사회망 분석.</li>
<li>스키마가 자주 변하거나 다양한 관계 타입이 등장. 새로운 관계 타입을 추가하려고 매번 ALTER TABLE을 하지 않아도 됨.<blockquote>
<p>단순 트랜잭션 처리(OLTP), 회계장부형 데이터, 전통적 보고서 생성, 컬럼 단위 집계 분석 등은 RDB나 데이터 웨어하우스가 훨씬 효율적입니다. 그래프 DB를 강제로 도입하면 오히려 복잡도만 늘어납니다.</p>
</blockquote>
</li>
</ul>
<h3 id="16-주요-제품-비교">1.6 주요 제품 비교</h3>
<table>
<thead>
<tr>
<th>제품</th>
<th>모델</th>
<th>특징</th>
<th>운영 형태</th>
</tr>
</thead>
<tbody><tr>
<td>Neo4j</td>
<td>LPG</td>
<td>Cypher 표준, 가장 큰 생태계</td>
<td>셀프호스팅 / AuraDB</td>
</tr>
<tr>
<td>Amazon Neptune</td>
<td>LPG + RDF</td>
<td>AWS 통합, Gremlin/SPARQL 지원</td>
<td>AWS 매니지드</td>
</tr>
<tr>
<td>Azure Cosmos DB (Gremlin API)</td>
<td>LPG</td>
<td>Azure 통합, 글로벌 분산</td>
<td>Azure 매니지드</td>
</tr>
<tr>
<td>TigerGraph</td>
<td>LPG</td>
<td>분산 처리 강점, GSQL</td>
<td>엔터프라이즈</td>
</tr>
<tr>
<td>ArangoDB</td>
<td>다중모델</td>
<td>그래프 + 문서 + KV 동시 지원</td>
<td>오픈소스</td>
</tr>
</tbody></table>
<h2 id="2장-neo4j-핵심-개념">2장. Neo4j 핵심 개념</h2>
<h3 id="21-neo4j-아키텍처-한눈에">2.1 Neo4j 아키텍처 한눈에</h3>
<p>Neo4j는 JVM 위에서 동작하는 단일 프로세스 데이터베이스입니다. 클라이언트는 두 가지 프로토콜로 접속합니다. HTTP(7474)는 웹 브라우저 기반 Neo4j Browser용이고, Bolt(7687)은 애플리케이션 드라이버용 바이너리 프로토콜입니다.
스토리지는 ‘기록 파일(record store)’ 구조로, 각 노드와 관계는 고정 크기 레코드로 저장되어 ID 기반 직접 접근(O(1))이 가능합니다. 이것이 N-hop 트래버설이 빠른 핵심 비결입니다 — JOIN 비용 없이 인접 관계의 메모리 주소를 바로 따라갑니다(‘index-free adjacency’).</p>
<h3 id="22-lpg-데이터-모델">2.2 LPG 데이터 모델</h3>
<pre><code class="language-markdown">| 구성요소 | 설명 | 예시 |
|----------|------|------|
| Node | 개체, 라벨로 타입 구분 | (:Account) |
| Relationship | 노드 사이의 방향성 있는 관계 | [:TRANSFER] |
| Property | 노드/관계에 붙는 key=value 데이터 | {amount: 89500000} |
| Label | 노드의 분류 태그 (다중 부착 가능) | :Account:HighRisk |</code></pre>
<h3 id="23-cypher-쿼리-언어">2.3 Cypher 쿼리 언어</h3>
<p>Cypher는 Neo4j가 만든 그래프 질의 언어로, 2018년 ISO/IEC GQL 표준의 기반이 되었습니다. 핵심 아이디어는 ‘ASCII 아트로 패턴을 그린다’ 입니다.</p>
<pre><code>// &#39;계좌 A가 계좌 B에게 송금했다&#39;를 그림으로
MATCH (a:Account)-[:TRANSFER]-&gt;(b:Account)
RETURN a, b
LIMIT 5;</code></pre><table>
<thead>
<tr>
<th>절</th>
<th>역할</th>
<th>SQL 대응</th>
</tr>
</thead>
<tbody><tr>
<td>MATCH</td>
<td>패턴 매칭 (데이터 찾기)</td>
<td>FROM + JOIN + WHERE</td>
</tr>
<tr>
<td>WHERE</td>
<td>조건 필터</td>
<td>WHERE</td>
</tr>
<tr>
<td>RETURN</td>
<td>결과 반환</td>
<td>SELECT</td>
</tr>
<tr>
<td>CREATE</td>
<td>노드/관계 생성</td>
<td>INSERT</td>
</tr>
<tr>
<td>MERGE</td>
<td>있으면 매칭, 없으면 생성</td>
<td>UPSERT</td>
</tr>
<tr>
<td>WITH</td>
<td>중간 결과 파이프라인</td>
<td>서브쿼리 + AS</td>
</tr>
</tbody></table>
<h3 id="24-인덱스와-제약조건">2.4 인덱스와 제약조건</h3>
<p>인덱스 없는 그래프 DB는 인덱스 없는 RDB보다도 더 빨리 느려집니다. 첫 노드를 어떻게 ‘찾을 것인가(seek)’가 트래버설의 시작점이기 때문입니다. Neo4j 5.x에서는 다음 두 가지를 거의 항상 만들어 둡니다.</p>
<pre><code>// 고유성 제약 (자동으로 인덱스 생성됨)
CREATE CONSTRAINT account_id IF NOT EXISTS
  FOR (a:Account) REQUIRE a.accountId IS UNIQUE;

// 일반 인덱스 (자주 조회하는 속성)
CREATE INDEX account_country IF NOT EXISTS
  FOR (a:Account) ON (a.country);</code></pre><h3 id="25-gds--graph-data-science-라이브러리">2.5 GDS — Graph Data Science 라이브러리</h3>
<p>Neo4j의 진가는 단순 패턴 매칭을 넘어 그래프 알고리즘까지 한 자리에서 실행할 수 있다는 점입니다. GDS 라이브러리는 다음과 같은 알고리즘을 한 줄로 호출할 수 있게 해 줍니다.</p>
<table>
<thead>
<tr>
<th>분류</th>
<th>알고리즘</th>
<th>활용 예</th>
</tr>
</thead>
<tbody><tr>
<td>중심성</td>
<td>PageRank, Betweenness</td>
<td>허브 계좌 탐지, 영향력 분석</td>
</tr>
<tr>
<td>커뮤니티</td>
<td>Louvain, Label Propagation, WCC</td>
<td>공모 집단, 클러스터링</td>
</tr>
<tr>
<td>경로</td>
<td>Shortest Path, A*</td>
<td>최단 자금 흐름</td>
</tr>
<tr>
<td>유사도</td>
<td>Node Similarity, Jaccard</td>
<td>추천 시스템</td>
</tr>
<tr>
<td>임베딩</td>
<td>FastRP, GraphSAGE, Node2Vec</td>
<td>ML 피처 생성</td>
</tr>
</tbody></table>
<blockquote>
<p>📌  GDS의 동작 원리
GDS는 디스크의 그래프를 그대로 쓰지 않고, 분석 대상 부분을 ‘메모리에 투영(project)’한 뒤 알고리즘을 돌립니다. 큰 그래프에서도 빠른 이유이자, 분석이 끝나면 명시적으로 drop하지 않으면 메모리에 남는 이유이기도 합니다.</p>
</blockquote>
<h2 id="3장-azure-환경-구축">3장. Azure 환경 구축</h2>
<p>NSG로 본인 IP만 허용 → 7474, 7687 포트 한정 개방
<img src="https://velog.velcdn.com/images/rudin_/post/b740cd48-7ad9-4319-b10a-cfd29a20c0e7/image.png" alt=""></p>
<p>이후 vm 내에서 neo4j 설치</p>
<ul>
<li>APT 업데이트 + Java 21 설치 — Neo4j 5.x는 OpenJDK 21을 요구합니다.</li>
<li>Neo4j 공식 GPG 키 + APT 저장소 등록 — debian.neo4j.com을 신뢰 저장소로 추가.</li>
<li>apt install neo4j — 5.26.x 안정 버전 설치.</li>
<li>외부 접속 허용 — 기본은 127.0.0.1만 listen하므로 0.0.0.0으로 변경. 보안은 NSG가 담당.</li>
<li>서비스 시작 + 검증 — systemctl로 부팅 시 자동 시작 등록.</li>
</ul>
<p><img src="https://velog.velcdn.com/images/rudin_/post/1ca64735-7345-4d44-bc2e-3344f7b99a8f/image.png" alt=""></p>
<blockquote>
<p>💡  메모리 튜닝의 황금률
Neo4j 성능의 80%는 메모리 설정이 좌우합니다. 일반적으로 &#39;heap size 2~4GB&#39; + &#39;나머지 메모리는 page cache&#39;에 할당합니다. 본 실습은 8GB VM 기준 heap 2GB + page cache 1GB로 설정합니다</p>
</blockquote>
<h2 id="4장-openpay-시나리오">4장. OpenPay 시나리오</h2>
<h3 id="41-도메인-모델링">4.1 도메인 모델링</h3>
<table>
<thead>
<tr>
<th>구성</th>
<th>분류</th>
<th>노드/관계</th>
</tr>
</thead>
<tbody><tr>
<td>계좌</td>
<td>노드</td>
<td>(:Account {accountId, name, country, createdAt})</td>
</tr>
<tr>
<td>디바이스</td>
<td>노드</td>
<td>(:Device {deviceId, type, fingerprint})</td>
</tr>
<tr>
<td>IP 주소</td>
<td>노드</td>
<td>(:IPAddress {ipId, address, country, isProxy})</td>
</tr>
<tr>
<td>계좌가 디바이스를 소유</td>
<td>관계</td>
<td>(:Account)-[:OWNS]-&gt;(:Device)</td>
</tr>
<tr>
<td>계좌가 IP에서 접속</td>
<td>관계</td>
<td>(:Account)-[:USED]-&gt;(:IPAddress)</td>
</tr>
<tr>
<td>계좌가 계좌에 송금</td>
<td>관계</td>
<td>(:Account)-[:TRANSFER {amount, timestamp, channel}]-&gt;(:Account)</td>
</tr>
</tbody></table>
<blockquote>
<p>💡  모델링 원칙
‘속성으로 표현할 수 있는 것은 노드로 만들지 마라.’ 처음 입문하면 거래 채널(MOBILE/WEB/ATM)도 노드로 만들고 싶어지지만, 이는 트래버설을 무겁게 만들 뿐입니다. 채널은 TRANSFER 관계의 속성으로 충분합니다. 반면 ‘디바이스’는 여러 계좌가 ‘공유’할 가능성이 핵심이므로 반드시 노드여야 합니다.</p>
</blockquote>
<h3 id="42-데이터-적재">4.2 데이터 적재</h3>
<p>부속 코드 02-data-generation/generate_data.py는 결정론적 시드(SEED=20260506)로 다음 데이터를 생성</p>
<p><img src="https://velog.velcdn.com/images/rudin_/post/cecf0529-1e2c-46d6-a772-de1df458c639/image.png" alt=""></p>
<pre><code>// CSV 파일을 한 줄씩 읽어 노드 생성
LOAD CSV WITH HEADERS FROM &#39;file:///accounts.csv&#39; AS row
CREATE (a:Account {
  accountId: row.accountId,
  name:      row.name,
  country:   row.country,
  createdAt: datetime(row.createdAt)
});

// 관계는 양 끝 노드를 MATCH로 찾고 CREATE
LOAD CSV WITH HEADERS FROM &#39;file:///transactions.csv&#39; AS row
CALL {
  WITH row
  MATCH (src:Account {accountId: row.fromAccount})
  MATCH (dst:Account {accountId: row.toAccount})
  CREATE (src)-[t:TRANSFER {
    txId:      row.txId,
    amount:    toFloat(row.amount),
    timestamp: datetime(row.timestamp),
    channel:   row.channel
  }]-&gt;(dst)
} IN TRANSACTIONS OF 1000 ROWS;
</code></pre><blockquote>
<p>⚠  Neo4j 5.x의 변화
Neo4j 4.x에서 쓰던 &#39;USING PERIODIC COMMIT&#39;은 5.x에서 deprecated되었습니다. 대신 &#39;CALL { ... } IN TRANSACTIONS OF N ROWS&#39; 패턴을 사용해야 큰 CSV도 메모리 부족 없이 적재됩니다.</p>
</blockquote>
<pre><code>MATCH (a:Account)   RETURN &#39;Account&#39;    AS label, count(a) AS cnt
UNION ALL
MATCH ()-[r:TRANSFER]-&gt;() RETURN &#39;TRANSFER&#39; AS label, count(r) AS cnt;</code></pre><p><img src="https://velog.velcdn.com/images/rudin_/post/dfcb329e-5b3c-4b53-92bc-9d1694f7b918/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/aa98f4fa-48c0-466c-931b-f5333583476b/image.png" alt=""></p>
<h2 id="5장-cypher-사기-탐지-실습">5장. Cypher 사기 탐지 실습</h2>
<h3 id="51-org-5-허브-계좌--단순-집계로-시작">5.1 [ORG-5] 허브 계좌 — 단순 집계로 시작</h3>
<p>가장 쉬운 패턴은 ‘비정상적으로 많이 받는 계좌’입니다. RDB의 GROUP BY와 본질적으로 같습니다.</p>
<pre><code>MATCH (sender:Account)-[t:TRANSFER]-&gt;(receiver:Account)
RETURN receiver.accountId AS account,
       count(t)           AS in_count,
       sum(t.amount)      AS total_received
ORDER BY in_count DESC
LIMIT 10;
</code></pre><p><img src="https://velog.velcdn.com/images/rudin_/post/e001f05a-c18a-487f-9539-357401642cca/image.png" alt=""></p>
<h3 id="52-org-3-smurfing--where-조건으로-패턴-좁히기">5.2 [ORG-3] SMURFING — WHERE 조건으로 패턴 좁히기</h3>
<p>‘100만원 미만 거래를 같은 수신자에게 10회 이상’ — CTR(고액현금거래보고) 기준선을 회피하려는 분할 송금 패턴입니다. WHERE로 금액 임계값을 걸고 count()로 횟수를 셉니다.</p>
<pre><code>MATCH (s:Account)-[t:TRANSFER]-&gt;(r:Account)
WHERE t.amount &lt; 1000000
WITH s, r, count(t) AS n_tx, sum(t.amount) AS total
WHERE n_tx &gt;= 10
RETURN s.accountId AS sender,
       r.accountId AS receiver,
       n_tx        AS tx_count,
       round(total) AS total_amount
ORDER BY n_tx DESC;
</code></pre><p><img src="https://velog.velcdn.com/images/rudin_/post/6a6507dd-eff1-42c0-b508-1a990b24fecd/image.png" alt=""></p>
<h3 id="53-org-2-디바이스-공모--양방향-패턴">5.3 [ORG-2] 디바이스 공모 — 양방향 패턴</h3>
<p>‘한 디바이스를 여러 계좌가 공유한다’는 SQL로 표현하면 GROUP BY device + HAVING count(*) &gt;= N입니다. Cypher에서는 패턴이 거의 그림 그대로 — (a)-[:OWNS]-&gt;(d)&lt;-[:OWNS]-(b) 가 ‘서로 다른 두 계좌가 같은 디바이스를 가리킨다’는 뜻입니다.</p>
<pre><code>MATCH (a:Account)-[:OWNS]-&gt;(d:Device)
WITH d, collect(DISTINCT a.accountId) AS accounts
WHERE size(accounts) &gt;= 8
RETURN d.deviceId AS device,
       size(accounts) AS n_accounts,
       accounts
ORDER BY n_accounts DESC;
</code></pre><p><img src="https://velog.velcdn.com/images/rudin_/post/f303205c-65ac-4ad2-af17-f7da7f4932cf/image.png" alt=""></p>
<blockquote>
<p>💡  노이즈는 학습 자료다
결과에 의도 외 계좌가 섞여 있는 것은 버그가 아닙니다. 현실 데이터에는 ‘우연히’ 같은 디바이스를 쓴 가족, 한 디바이스를 두 명이 공유하는 부부 등이 늘 섞여 있습니다. 이런 노이즈와 진짜 사기를 구분하는 능력 자체가 분석가의 몫입니다.</p>
</blockquote>
<h3 id="54-org-4-해외-ip-공모--다중-조건-결합">5.4 [ORG-4] 해외 IP 공모 — 다중 조건 결합</h3>
<p>디바이스 공모와 같은 패턴이지만 IP가 대상이고, 추가로 ‘국가가 한국이 아님’이라는 조건이 붙습니다.</p>
<pre><code>MATCH (a:Account)-[:USED]-&gt;(ip:IPAddress)
WITH ip, collect(DISTINCT a.accountId) AS accounts
WHERE size(accounts) &gt;= 10
  AND ip.country &lt;&gt; &#39;KR&#39;
RETURN ip.ipId, ip.address, ip.country, ip.isProxy,
       size(accounts) AS n_accounts, accounts
ORDER BY n_accounts DESC;
</code></pre><p><img src="https://velog.velcdn.com/images/rudin_/post/d6c785d7-4739-428c-8dbf-f9e60bbb696f/image.png" alt=""></p>
<h3 id="55-org-1-자금세탁-순환--그래프-db의-강점">5.5 [ORG-1] 자금세탁 순환 — 그래프 DB의 강점</h3>
<p>여기까지의 패턴은 RDB도 어떻게든 풀 수 있습니다. 하지만 ‘N개 계좌를 거쳐 자금이 출발지로 돌아오는 순환’은 RDB로는 사실상 불가능합니다. 자기 조인 4번을 짜야 하고, 각 단계마다 시간 순서까지 비교해야 하기 때문입니다.
Cypher에서는 단 한 줄. 시작 노드와 끝 노드를 같은 변수 a로 묶기만 하면 됩니다.</p>
<pre><code>MATCH path = (a:Account)-[t1:TRANSFER]-&gt;(b:Account)
                        -[t2:TRANSFER]-&gt;(c:Account)
                        -[t3:TRANSFER]-&gt;(d:Account)
                        -[t4:TRANSFER]-&gt;(a)
WHERE a &lt;&gt; b AND b &lt;&gt; c AND c &lt;&gt; d AND d &lt;&gt; a
  AND t1.timestamp &lt; t2.timestamp
  AND t2.timestamp &lt; t3.timestamp
  AND t3.timestamp &lt; t4.timestamp
  AND t1.amount &gt; 10000000
RETURN a.accountId, b.accountId, c.accountId, d.accountId,
       round(t1.amount) AS amt1,
       round(t4.amount) AS amt4,
       duration.between(t1.timestamp, t4.timestamp).hours AS hours;</code></pre><p><img src="https://velog.velcdn.com/images/rudin_/post/3d2c33bb-470f-4e49-8a70-487d76fbf624/image.png" alt=""></p>
<p>의심도 점수 매기기
탐지를 넘어 우선순위를 매기는 것이 분석가의 다음 일입니다. 다음 두 신호가 강할수록 의심도가 높습니다.
(1) 거래 간격이 짧을수록 — 자동화된 자금세탁일 가능성, (2) 금액이 비슷할수록 — 수수료 정도만 빠지는 패턴.</p>
<h2 id="6장-gds-알고리즘-응용">6장. GDS 알고리즘 응용</h2>
<h3 id="gds-설치">gds 설치</h3>
<pre><code>cd /var/lib/neo4j/plugins/
sudo wget https://github.com/neo4j/graph-data-science/releases/download/2.13.2/neo4j-graph-data-science-2.13.2.jar -O neo4j-graph-data-science.jar

sudo systemctl restart neo4j</code></pre><p><img src="https://velog.velcdn.com/images/rudin_/post/a3bf66f7-7cf8-4908-8f52-91fbb7bb81cf/image.png" alt=""></p>
<h3 id="61-그래프-프로젝션--gds의-첫-단계">6.1 그래프 프로젝션 — GDS의 첫 단계</h3>
<p>GDS는 디스크 그래프가 아닌 ‘메모리 투영본’에서 알고리즘을 돌립니다. 분석할 부분만 골라 메모리에 올리는 작업이 프로젝션입니다.</p>
<pre><code>CALL gds.graph.project(
  &#39;fraud-graph&#39;,
  &#39;Account&#39;,
  {
    TRANSFER: {
      orientation: &#39;NATURAL&#39;,
      properties: &#39;amount&#39;
    }
  }
);
</code></pre><p><img src="https://velog.velcdn.com/images/rudin_/post/4d415a3d-f5d5-49a4-abd8-daaaf5af7c81/image.png" alt=""></p>
<h3 id="62-pagerank--허브-식별의-정교한-버전">6.2 PageRank — 허브 식별의 정교한 버전</h3>
<p>PageRank는 본래 1998년 구글 검색이 등장하면서 ‘웹페이지 중요도’를 매기기 위해 만들어졌습니다. 핵심 직관은 ‘중요한 페이지로부터 링크받은 페이지는 중요하다’ 입니다. 이 직관을 거래 그래프에 적용하면, 단순 ‘수신 건수’를 넘어 ‘중요한 계좌로부터 송금받은 계좌’가 더 높은 점수를 받습니다.</p>
<pre><code>CALL gds.pageRank.stream(&#39;fraud-graph&#39;, {
  maxIterations: 20,
  dampingFactor: 0.85,
  relationshipWeightProperty: &#39;amount&#39;
})
YIELD nodeId, score
WITH gds.util.asNode(nodeId) AS account, score
RETURN account.accountId, round(score * 100) / 100 AS pagerank
ORDER BY pagerank DESC LIMIT 10;
</code></pre><p><img src="https://velog.velcdn.com/images/rudin_/post/d93a857b-30ea-4b28-9c1d-56aead31e7bc/image.png" alt=""></p>
<h3 id="63-weakly-connected-components--공모-집단">6.3 Weakly Connected Components — 공모 집단</h3>
<p>WCC는 ‘방향 무시하고 한 덩어리로 연결된 노드들’을 찾는 알고리즘입니다. 디바이스/IP 공유 그래프에서 연결된 컴포넌트는 곧 ‘같은 자원을 통해 연결된 계좌 집단’ — 즉 잠재적 공모 그룹입니다.</p>
<pre><code>CALL gds.graph.project.cypher(
  &#39;shared-resource-graph&#39;,
  &#39;MATCH (a:Account) RETURN id(a) AS id&#39;,
  &#39;
   MATCH (a1:Account)-[:OWNS]-&gt;(:Device)&lt;-[:OWNS]-(a2:Account)
   WHERE id(a1) &lt; id(a2)
   RETURN id(a1) AS source, id(a2) AS target
   UNION
   MATCH (a1:Account)-[:USED]-&gt;(:IPAddress)&lt;-[:USED]-(a2:Account)
   WHERE id(a1) &lt; id(a2)
   RETURN id(a1) AS source, id(a2) AS target
  &#39;
);

CALL gds.wcc.stream(&#39;shared-resource-graph&#39;)
YIELD nodeId, componentId
WITH componentId, collect(gds.util.asNode(nodeId).accountId) AS members
WHERE size(members) &gt;= 6
RETURN componentId, size(members) AS size, members
ORDER BY size DESC;
</code></pre><p><img src="https://velog.velcdn.com/images/rudin_/post/ced81c7e-d953-4dc5-b100-d6cd90e26385/image.png" alt=""></p>
<h3 id="64-louvain--거래-흐름-기반-커뮤니티">6.4 Louvain — 거래 흐름 기반 커뮤니티</h3>
<p>Louvain은 모듈러리티 최적화 기반 커뮤니티 탐지 알고리즘입니다. ‘안에서는 빽빽하게 연결되어 있고, 밖으로는 듬성듬성 연결된’ 노드 집단을 찾습니다. 거래 흐름에 적용하면 ‘끼리끼리 거래하는 그룹’이 드러납니다.</p>
<pre><code>CALL gds.louvain.stream(&#39;fraud-graph&#39;, {
  relationshipWeightProperty: &#39;amount&#39;
})
YIELD nodeId, communityId
WITH communityId, collect(gds.util.asNode(nodeId).accountId) AS members
WHERE size(members) &gt;= 3 AND size(members) &lt;=30
RETURN communityId, size(members) AS size, members[0..10] AS sample
ORDER BY size DESC LIMIT 10;
</code></pre><p><img src="https://velog.velcdn.com/images/rudin_/post/5d265df3-accd-4754-8de0-0311793c19fc/image.png" alt=""></p>
<blockquote>
<p>📌  알고리즘의 한계 이해하기
Louvain은 거래 흐름을 보지 디바이스/IP는 보지 않습니다. 즉 ORG-2/ORG-4(자원 공모)는 Louvain만으로는 잡히지 않습니다. </p>
</blockquote>
<h3 id="65-메모리-정리">6.5 메모리 정리</h3>
<pre><code>CALL gds.graph.drop(&#39;fraud-graph&#39;) YIELD graphName RETURN graphName;
CALL gds.graph.drop(&#39;shared-resource-graph&#39;) YIELD graphName RETURN graphName;
</code></pre>]]></description>
        </item>
        <item>
            <title><![CDATA[[MicrosoftDataSchool] 82일차 - Azure SQL Index]]></title>
            <link>https://velog.io/@rudin_/MicrosoftDataSchool-82%EC%9D%BC%EC%B0%A8-Azure-SQL-Index</link>
            <guid>https://velog.io/@rudin_/MicrosoftDataSchool-82%EC%9D%BC%EC%B0%A8-Azure-SQL-Index</guid>
            <pubDate>Thu, 07 May 2026 00:39:19 GMT</pubDate>
            <description><![CDATA[<h1 id="sql-server-성능-튜닝-실습-정리---인덱스-실행-계획-blocking-query-store">SQL Server 성능 튜닝 실습 정리 - 인덱스, 실행 계획, Blocking, Query Store</h1>
<p>SQL Server 성능 튜닝을 제대로 이해하려면 가장 먼저 알아야 하는 것이 바로 <strong>인덱스(Index)</strong> 이다.</p>
<p>이번 실습에서도:</p>
<ul>
<li>Fragmentation</li>
<li>Key Lookup</li>
<li>Execution Plan</li>
<li>Query Store</li>
<li>Blocking</li>
</ul>
<p>같은 다양한 성능 이슈를 다뤘지만, 결국 대부분의 문제는 인덱스와 연결되어 있었다.</p>
<p>따라서 본격적인 실습 내용을 보기 전에, 먼저 SQL Server 인덱스 개념을 정리하고 시작한다.</p>
<hr>
<h1 id="인덱스index란">인덱스(Index)란?</h1>
<p>인덱스는 데이터를 빠르게 찾기 위한 자료구조이다.</p>
<p>책에서 원하는 내용을 찾을 때:</p>
<ul>
<li>책 전체를 처음부터 끝까지 읽지 않고</li>
<li>맨 뒤의 색인(Index)을 먼저 보는 것과 비슷하다.</li>
</ul>
<p>SQL Server에서도 인덱스가 없다면 원하는 데이터를 찾기 위해 테이블 전체를 읽어야 한다.</p>
<p>이를 <strong>Table Scan</strong>이라고 한다.</p>
<p>데이터 양이 적을 때는 큰 문제가 없지만, 데이터가 수백만 건 이상으로 증가하면 성능 차이가 매우 커진다.</p>
<hr>
<h1 id="인덱스가-필요한-이유">인덱스가 필요한 이유</h1>
<h2 id="1-검색-속도-향상">1. 검색 속도 향상</h2>
<p>인덱스는 원하는 데이터 위치를 빠르게 찾을 수 있게 해준다.</p>
<p>예를 들어:</p>
<pre><code class="language-sql">SELECT *
FROM Users
WHERE Email = &#39;test@test.com&#39;</code></pre>
<p>같은 쿼리에서 Email 컬럼에 인덱스가 존재하면 SQL Server는 전체 데이터를 읽지 않고 필요한 데이터만 바로 찾을 수 있다.</p>
<hr>
<h2 id="2-디스크-io-감소">2. 디스크 I/O 감소</h2>
<p>인덱스가 없으면 SQL Server는 테이블 전체를 읽는다.</p>
<p>즉:</p>
<ul>
<li>더 많은 페이지 읽기</li>
<li>더 많은 디스크 접근</li>
<li>더 많은 메모리 사용</li>
</ul>
<p>이 발생한다.</p>
<p>인덱스를 사용하면 필요한 데이터만 읽기 때문에 I/O가 감소한다.</p>
<hr>
<h2 id="3-대규모-시스템-필수-요소">3. 대규모 시스템 필수 요소</h2>
<p>실제 운영 환경에서는:</p>
<ul>
<li>수많은 사용자</li>
<li>동시 요청</li>
<li>대량 데이터</li>
</ul>
<p>를 처리해야 한다.</p>
<p>인덱스 없이 운영하면:</p>
<ul>
<li>응답 속도 저하</li>
<li>CPU 사용량 증가</li>
<li>서버 부하 증가</li>
</ul>
<p>가 발생한다.</p>
<p>따라서 인덱스는 단순 최적화가 아니라 필수 요소에 가깝다.</p>
<hr>
<h1 id="sql-server의-인덱스-구조">SQL Server의 인덱스 구조</h1>
<p>SQL Server는 대부분 B-Tree(B+Tree) 구조를 사용한다.</p>
<p>구조는 다음과 같다.</p>
<table>
<thead>
<tr>
<th>구조</th>
<th>역할</th>
</tr>
</thead>
<tbody><tr>
<td>Root Node</td>
<td>검색 시작점</td>
</tr>
<tr>
<td>Intermediate Node</td>
<td>중간 탐색</td>
</tr>
<tr>
<td>Leaf Node</td>
<td>실제 데이터 또는 데이터 위치 저장</td>
</tr>
</tbody></table>
<hr>
<h1 id="b-tree-구조-동작-방식">B-Tree 구조 동작 방식</h1>
<p>예를 들어 다음과 같은 데이터가 있다고 가정한다.</p>
<pre><code class="language-text">1, 5, 10, 20, 50, 100</code></pre>
<p>SQL Server는 이 값을 트리 형태로 정렬하여 저장한다.</p>
<p>검색 시에는:</p>
<ol>
<li>Root Node 탐색</li>
<li>중간 노드 이동</li>
<li>Leaf Node 도달</li>
</ol>
<p>과정을 거친다.</p>
<p>즉:</p>
<blockquote>
<p>전체 데이터를 순차 탐색하지 않아도 된다.</p>
</blockquote>
<p>이것이 인덱스가 빠른 이유이다.</p>
<hr>
<h1 id="clustered-index">Clustered Index</h1>
<p>Clustered Index는 실제 데이터 자체가 정렬된다.</p>
<p>즉:</p>
<ul>
<li>데이터 저장 순서</li>
<li>인덱스 순서</li>
</ul>
<p>가 동일하다.</p>
<hr>
<h1 id="특징">특징</h1>
<table>
<thead>
<tr>
<th>특징</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>테이블당 1개만 가능</td>
<td>실제 데이터 순서는 하나만 존재 가능</td>
</tr>
<tr>
<td>범위 검색에 강함</td>
<td>BETWEEN, ORDER BY 최적화</td>
</tr>
<tr>
<td>Primary Key에 자주 사용</td>
<td>기본 키 생성 시 자동 생성되는 경우 많음</td>
</tr>
</tbody></table>
<hr>
<h1 id="clustered-index-예시">Clustered Index 예시</h1>
<pre><code class="language-sql">CREATE CLUSTERED INDEX IX_Users_Id
ON Users(UserId);</code></pre>
<p>이 경우 Users 테이블 데이터 자체가 UserId 기준으로 정렬된다.</p>
<hr>
<h1 id="nonclustered-index">Nonclustered Index</h1>
<p>Nonclustered Index는 실제 데이터와 별도 구조로 존재한다.</p>
<p>인덱스에는:</p>
<ul>
<li>키 값</li>
<li>데이터 위치 포인터</li>
</ul>
<p>만 저장된다.</p>
<hr>
<h1 id="구조-특징">구조 특징</h1>
<table>
<thead>
<tr>
<th>구조</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>별도 인덱스 구조 존재</td>
<td>데이터와 분리</td>
</tr>
<tr>
<td>Leaf Node</td>
<td>실제 데이터 위치 저장</td>
</tr>
<tr>
<td>여러 개 생성 가능</td>
<td>최대 999개</td>
</tr>
</tbody></table>
<hr>
<h1 id="nonclustered-index-예시">Nonclustered Index 예시</h1>
<pre><code class="language-sql">CREATE NONCLUSTERED INDEX IX_Users_Email
ON Users(Email);</code></pre>
<p>이 경우 Email 기반 검색이 빨라진다.</p>
<hr>
<h1 id="index-seek-vs-table-scan">Index Seek vs Table Scan</h1>
<p>실행 계획에서 가장 중요하게 보는 것 중 하나가:</p>
<ul>
<li>Index Seek</li>
<li>Table Scan</li>
</ul>
<p>이다.</p>
<hr>
<h1 id="table-scan">Table Scan</h1>
<p>Table Scan은 테이블 전체를 읽는다.</p>
<p>즉:</p>
<pre><code class="language-text">1행부터 끝까지 전부 읽음</code></pre>
<p>데이터가 많을수록 매우 느려진다.</p>
<hr>
<h1 id="index-seek">Index Seek</h1>
<p>Index Seek는 필요한 데이터만 찾는다.</p>
<p>즉:</p>
<pre><code class="language-text">원하는 위치만 바로 접근</code></pre>
<p>따라서 훨씬 빠르다.</p>
<hr>
<h1 id="실행-계획에서-확인-가능">실행 계획에서 확인 가능</h1>
<p>실행 계획(Execution Plan)에서:</p>
<ul>
<li>Table Scan 발생 여부</li>
<li>Index Seek 사용 여부</li>
</ul>
<p>를 확인할 수 있다.</p>
<p>튜닝에서 가장 기본적으로 보는 부분이다.</p>
<hr>
<h1 id="covering-index">Covering Index</h1>
<p>Covering Index는 쿼리에 필요한 모든 컬럼이 인덱스에 포함된 경우를 말한다.</p>
<p>즉:</p>
<ul>
<li>추가 테이블 접근 없이</li>
<li>인덱스만 읽어서</li>
<li>결과 반환 가능</li>
</ul>
<p>하다.</p>
<hr>
<h1 id="include-사용">INCLUDE 사용</h1>
<pre><code class="language-sql">CREATE INDEX IX_Orders_CustomerId
ON Orders(CustomerId)
INCLUDE (OrderDate, TotalAmount);</code></pre>
<hr>
<h1 id="장점">장점</h1>
<table>
<thead>
<tr>
<th>장점</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>Key Lookup 제거</td>
<td>추가 테이블 접근 감소</td>
</tr>
<tr>
<td>Logical Read 감소</td>
<td>성능 향상</td>
</tr>
<tr>
<td>실행 계획 단순화</td>
<td>I/O 절약</td>
</tr>
</tbody></table>
<hr>
<h1 id="key-lookup">Key Lookup</h1>
<p>Key Lookup은 SQL Server가:</p>
<ol>
<li>인덱스로 위치 찾고</li>
<li>원본 데이터 다시 접근</li>
</ol>
<p>하는 작업이다.</p>
<p>즉:</p>
<pre><code class="language-text">인덱스만으로 필요한 컬럼이 부족함</code></pre>
<p>을 의미한다.</p>
<hr>
<h1 id="왜-문제인가">왜 문제인가?</h1>
<p>Key Lookup은:</p>
<ul>
<li>랜덤 I/O 증가</li>
<li>페이지 접근 증가</li>
<li>성능 저하</li>
</ul>
<p>를 유발한다.</p>
<p>특히 결과 건수가 많을수록 심각해진다.</p>
<hr>
<h1 id="fragmentation조각화">Fragmentation(조각화)</h1>
<p>데이터가 지속적으로 변경되면 인덱스 페이지 순서가 깨진다.</p>
<p>이를 Fragmentation이라고 한다.</p>
<hr>
<h1 id="조각화-발생-원인">조각화 발생 원인</h1>
<table>
<thead>
<tr>
<th>작업</th>
<th>영향</th>
</tr>
</thead>
<tbody><tr>
<td>INSERT</td>
<td>페이지 분할 발생</td>
</tr>
<tr>
<td>UPDATE</td>
<td>데이터 이동 발생</td>
</tr>
<tr>
<td>DELETE</td>
<td>빈 공간 증가</td>
</tr>
</tbody></table>
<hr>
<h1 id="조각화-문제점">조각화 문제점</h1>
<table>
<thead>
<tr>
<th>문제</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>디스크 I/O 증가</td>
<td>페이지 순서 깨짐</td>
</tr>
<tr>
<td>Logical Read 증가</td>
<td>더 많은 페이지 읽기</td>
</tr>
<tr>
<td>성능 저하</td>
<td>응답 속도 감소</td>
</tr>
</tbody></table>
<hr>
<h1 id="fragmentation-확인">Fragmentation 확인</h1>
<p>SQL Server에서는 다음 DMV로 확인 가능하다.</p>
<pre><code class="language-sql">sys.dm_db_index_physical_stats</code></pre>
<hr>
<h1 id="reorganize-vs-rebuild">REORGANIZE vs REBUILD</h1>
<p>조각화 해결 방법은 크게 2가지이다.</p>
<table>
<thead>
<tr>
<th>구분</th>
<th>REORGANIZE</th>
<th>REBUILD</th>
</tr>
</thead>
<tbody><tr>
<td>방식</td>
<td>페이지 재정렬</td>
<td>인덱스 새 생성</td>
</tr>
<tr>
<td>부하</td>
<td>낮음</td>
<td>높음</td>
</tr>
<tr>
<td>통계 갱신</td>
<td>X</td>
<td>O</td>
</tr>
<tr>
<td>추천 구간</td>
<td>10~30%</td>
<td>30% 이상</td>
</tr>
</tbody></table>
<hr>
<h1 id="실행-계획execution-plan">실행 계획(Execution Plan)</h1>
<p>SQL Server는 쿼리를 실행하기 전에:</p>
<blockquote>
<p>“어떻게 실행하는 것이 가장 효율적인가”</p>
</blockquote>
<p>를 계산한다.</p>
<p>이 결과가 실행 계획이다.</p>
<hr>
<h1 id="실행-계획에서-보는-주요-요소">실행 계획에서 보는 주요 요소</h1>
<table>
<thead>
<tr>
<th>요소</th>
<th>의미</th>
</tr>
</thead>
<tbody><tr>
<td>Index Seek</td>
<td>효율적 검색</td>
</tr>
<tr>
<td>Index Scan</td>
<td>인덱스 전체 탐색</td>
</tr>
<tr>
<td>Table Scan</td>
<td>테이블 전체 탐색</td>
</tr>
<tr>
<td>Key Lookup</td>
<td>원본 데이터 재접근</td>
</tr>
<tr>
<td>Hash Match</td>
<td>해시 기반 조인</td>
</tr>
<tr>
<td>Sort</td>
<td>정렬 작업</td>
</tr>
</tbody></table>
<hr>
<h1 id="query-optimizer">Query Optimizer</h1>
<p>실행 계획은 Query Optimizer가 생성한다.</p>
<p>Optimizer는:</p>
<ul>
<li>인덱스</li>
<li>통계 정보</li>
<li>데이터 분포</li>
<li>비용 계산</li>
</ul>
<p>을 기반으로 가장 효율적이라고 판단한 플랜을 선택한다.</p>
<p>하지만 항상 완벽하지는 않다.</p>
<p>따라서:</p>
<ul>
<li>잘못된 실행 계획</li>
<li>비효율 플랜</li>
<li>Parameter Sniffing</li>
</ul>
<p>같은 문제도 발생한다.</p>
<hr>
<h1 id="query-store">Query Store</h1>
<p>SQL Server의 Query Store는:</p>
<ul>
<li>실행 계획 저장</li>
<li>느린 쿼리 분석</li>
<li>성능 Regression 추적</li>
</ul>
<p>기능을 제공한다.</p>
<p>특히:</p>
<ul>
<li>이전보다 느려진 쿼리</li>
<li>플랜 변경</li>
<li>강제 플랜 적용(Force Plan)</li>
</ul>
<p>분석에 매우 유용하다.</p>
<hr>
<h1 id="blocking">Blocking</h1>
<p>Blocking은 트랜잭션이 서로 Lock을 기다리는 현상이다.</p>
<p>예를 들어:</p>
<ol>
<li>세션 A가 UPDATE 수행</li>
<li>COMMIT 안 함</li>
<li>세션 B가 같은 데이터 읽기 시도</li>
</ol>
<p>하면 세션 B는 대기 상태가 된다.</p>
<hr>
<h1 id="snapshot-isolation">Snapshot Isolation</h1>
<p>Blocking 완화를 위해 SQL Server는 Snapshot 기반 격리 수준을 제공한다.</p>
<p>대표적으로:</p>
<pre><code class="language-sql">READ_COMMITTED_SNAPSHOT</code></pre>
<p>이 있다.</p>
<hr>
<h1 id="핵심-개념">핵심 개념</h1>
<p>기존 Read Committed:</p>
<ul>
<li>Lock 기다림</li>
</ul>
<p>Snapshot 기반:</p>
<ul>
<li>이전 버전(Row Version) 읽음</li>
</ul>
<p>즉:</p>
<blockquote>
<p>Reader와 Writer 충돌 감소</p>
</blockquote>
<p>효과가 있다.</p>
<hr>
<h1 id="이번-실습에서-중요했던-핵심-포인트">이번 실습에서 중요했던 핵심 포인트</h1>
<p>이번 실습의 핵심은 단순 SQL 문법이 아니었다.</p>
<p>진짜 중요한 것은:</p>
<ul>
<li>왜 실행 계획이 그렇게 나오는가</li>
<li>왜 인덱스가 필요한가</li>
<li>왜 Key Lookup이 성능 병목이 되는가</li>
<li>왜 Fragmentation이 Logical Read를 증가시키는가</li>
<li>왜 Blocking이 발생하는가</li>
</ul>
<h2 id="를-sql-server-내부-동작-관점에서-이해하는-것이었다">를 SQL Server 내부 동작 관점에서 이해하는 것이었다.</h2>
<h1 id="lab07---fragmentation-실습">Lab07 - Fragmentation 실습</h1>
<p>이번 실습에서는 조각화를 의도적으로 발생시켰다.</p>
<h2 id="1-조각화-상태-확인">1. 조각화 상태 확인</h2>
<pre><code class="language-sql">USE AdventureWorks2017
GO

SELECT i.name Index_Name
, avg_fragmentation_in_percent
, db_name(database_id)
, i.object_id
, i.index_id
, index_type_desc
FROM sys.dm_db_index_physical_stats(
    db_id(&#39;AdventureWorks2017&#39;),
    object_id(&#39;person.address&#39;),
    NULL,
    NULL,
    &#39;DETAILED&#39;
) ps
INNER JOIN sys.indexes i
ON ps.object_id = i.object_id
AND ps.index_id = i.index_id
WHERE avg_fragmentation_in_percent &gt; 50</code></pre>
<p>처음에는 조각화가 거의 없기 때문에 결과가 나오지 않는다.</p>
<hr>
<h2 id="2-데이터-대량-insert">2. 데이터 대량 INSERT</h2>
<pre><code class="language-sql">USE AdventureWorks2017
GO

INSERT INTO [Person].[Address]
(
    [AddressLine1],
    [AddressLine2],
    [City],
    [StateProvinceID],
    [PostalCode],
    [SpatialLocation],
    [rowguid],
    [ModifiedDate]
)
SELECT
    AddressLine1,
    AddressLine2,
    &#39;Amsterdam&#39;,
    StateProvinceID,
    PostalCode,
    SpatialLocation,
    newid(),
    getdate()
FROM Person.Address;
GO</code></pre>
<p>이 작업으로 Person.Address 테이블 크기가 증가하며 페이지 분할(Page Split)이 발생한다.</p>
<p>결과적으로 인덱스 조각화율이 크게 증가한다.</p>
<hr>
<h2 id="3-논리적-읽기logical-read-측정">3. 논리적 읽기(Logical Read) 측정</h2>
<pre><code class="language-sql">SET STATISTICS IO,TIME ON
GO

USE AdventureWorks2017
GO

SELECT DISTINCT (StateProvinceID)
,count(StateProvinceID) AS CustomerCount
FROM person.Address
GROUP BY StateProvinceID
ORDER BY count(StateProvinceID) DESC;
GO</code></pre>
<p>메시지 탭에서 Logical Read 값을 확인할 수 있다.</p>
<p>조각화가 심해질수록 더 많은 페이지를 읽게 된다.</p>
<hr>
<h1 id="인덱스-rebuild">인덱스 REBUILD</h1>
<p>조각화 문제 해결을 위해 REBUILD를 수행한다.</p>
<pre><code class="language-sql">USE AdventureWorks2017
GO

ALTER INDEX [IX_Address_StateProvinceID]
ON [Person].[Address]
REBUILD PARTITION = ALL</code></pre>
<p>REBUILD는 인덱스를 새로 만드는 작업이다.</p>
<p>결과:</p>
<ul>
<li>Fragmentation 감소</li>
<li>Logical Read 감소</li>
<li>쿼리 성능 향상</li>
</ul>
<hr>
<h1 id="reorganize-vs-rebuild-1">REORGANIZE vs REBUILD</h1>
<table>
<thead>
<tr>
<th>구분</th>
<th>REORGANIZE</th>
<th>REBUILD</th>
</tr>
</thead>
<tbody><tr>
<td>방식</td>
<td>페이지 재정렬</td>
<td>인덱스 새 생성</td>
</tr>
<tr>
<td>부하</td>
<td>낮음</td>
<td>높음</td>
</tr>
<tr>
<td>통계 갱신</td>
<td>X</td>
<td>O</td>
</tr>
<tr>
<td>권장 구간</td>
<td>10~30%</td>
<td>30% 이상</td>
</tr>
</tbody></table>
<hr>
<h1 id="실행-계획execution-plan-1">실행 계획(Execution Plan)</h1>
<p>SQL Server는 쿼리를 실행하기 전에 “어떻게 실행할지” 계획을 만든다.</p>
<p>이것이 Execution Plan이다.</p>
<p>실행 계획을 통해:</p>
<ul>
<li>Index Seek</li>
<li>Table Scan</li>
<li>Key Lookup</li>
<li>Hash Match</li>
</ul>
<p>등이 어떻게 발생하는지 확인할 수 있다.</p>
<hr>
<h1 id="lab09---암시적-형-변환implicit-conversion">Lab09 - 암시적 형 변환(Implicit Conversion)</h1>
<p>다음 쿼리를 실행했다.</p>
<pre><code class="language-sql">SELECT BusinessEntityID,
       NationalIDNumber,
       LoginID,
       HireDate,
       JobTitle
FROM HumanResources.Employee
WHERE NationalIDNumber = 14417807;</code></pre>
<p>실행 계획에서 경고가 발생했다.</p>
<p>원인은 Implicit Conversion이다.</p>
<hr>
<h1 id="왜-발생했는가">왜 발생했는가?</h1>
<p>NationalIDNumber 컬럼 타입은:</p>
<pre><code class="language-sql">nvarchar(15)</code></pre>
<p>이다.</p>
<p>하지만 비교값은 숫자(INT)이다.</p>
<p>따라서 SQL Server가 내부적으로:</p>
<pre><code class="language-sql">CONVERT_IMPLICIT(...)</code></pre>
<p>를 수행한다.</p>
<hr>
<h1 id="문제점">문제점</h1>
<p>Implicit Conversion은 다음 문제를 만든다.</p>
<table>
<thead>
<tr>
<th>문제</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>CPU 증가</td>
<td>형 변환 연산 발생</td>
</tr>
<tr>
<td>인덱스 비효율</td>
<td>Seek 최적화 방해</td>
</tr>
<tr>
<td>실행 계획 악화</td>
<td>Optimizer가 비효율 플랜 선택</td>
</tr>
</tbody></table>
<hr>
<h1 id="해결-방법-1---쿼리-수정">해결 방법 1 - 쿼리 수정</h1>
<pre><code class="language-sql">WHERE NationalIDNumber = &#39;14417807&#39;;</code></pre>
<p>문자열로 맞춰주면 된다.</p>
<p>이렇게 하면:</p>
<ul>
<li>경고 제거</li>
<li>실행 계획 개선</li>
<li>인덱스 활용 가능</li>
</ul>
<p>해진다.</p>
<hr>
<h1 id="해결-방법-2---컬럼-타입-변경">해결 방법 2 - 컬럼 타입 변경</h1>
<pre><code class="language-sql">ALTER TABLE [HumanResources].[Employee]
ALTER COLUMN [NationalIDNumber] INT NOT NULL;</code></pre>
<p>하지만 여기서 중요한 문제가 발생한다.</p>
<hr>
<h1 id="인덱스-문제">인덱스 문제</h1>
<p>NationalIDNumber는 기존 인덱스에서 사용 중이었다.</p>
<p>따라서:</p>
<ol>
<li>인덱스 DROP</li>
<li>ALTER COLUMN</li>
<li>인덱스 재생성</li>
</ol>
<p>순서가 필요하다.</p>
<p>즉 운영 환경에서는 다운타임 문제가 발생할 수 있다.</p>
<hr>
<h1 id="key-lookup-문제">Key Lookup 문제</h1>
<p>Lab10에서는 Key Lookup 문제를 분석했다.</p>
<p>실행 계획에서:</p>
<ul>
<li>Index Seek</li>
<li>Key Lookup(cost 99%)</li>
</ul>
<p>이 발생했다.</p>
<hr>
<h1 id="왜-key-lookup이-발생하는가">왜 Key Lookup이 발생하는가?</h1>
<p>현재 인덱스는:</p>
<pre><code class="language-sql">(ProductID)</code></pre>
<p>만 포함한다.</p>
<p>하지만 SELECT에서 필요한 컬럼은 더 많다.</p>
<p>따라서 SQL Server는:</p>
<ol>
<li>인덱스로 위치 찾기</li>
<li>원본 데이터 다시 접근</li>
</ol>
<p>을 수행한다.</p>
<p>이 추가 접근이 Key Lookup이다.</p>
<hr>
<h1 id="covering-index-1">Covering Index</h1>
<p>해결 방법은 Covering Index이다.</p>
<pre><code class="language-sql">CREATE NONCLUSTERED INDEX
[IX_SalesOrderDetail_ProductID]

ON [Sales].[SalesOrderDetail]
([ProductID],[ModifiedDate])

INCLUDE (
    [CarrierTrackingNumber],
    [OrderQty],
    [UnitPrice]
)

WITH (DROP_EXISTING = on);</code></pre>
<p>핵심:</p>
<ul>
<li>필요한 컬럼을 INCLUDE로 포함</li>
<li>인덱스만 읽어서 쿼리 해결 가능</li>
</ul>
<p>결과:</p>
<ul>
<li>Key Lookup 제거</li>
<li>Logical Read 감소</li>
<li>성능 향상</li>
</ul>
<hr>
<h1 id="query-store-1">Query Store</h1>
<p>Query Store는 SQL Server의 성능 분석 기능이다.</p>
<p>활성화:</p>
<pre><code class="language-sql">ALTER DATABASE [AdventureWorks2017]
SET QUERY_STORE = ON;</code></pre>
<hr>
<h1 id="query-store로-할-수-있는-것">Query Store로 할 수 있는 것</h1>
<ul>
<li>느린 쿼리 찾기</li>
<li>실행 계획 비교</li>
<li>성능 Regression 추적</li>
<li>좋은 실행 계획 강제 적용</li>
</ul>
<hr>
<h1 id="force-plan">Force Plan</h1>
<p>실행 계획 중 더 좋은 플랜을 강제로 사용 가능하다.</p>
<p>실습에서는:</p>
<ul>
<li>빠른 Plan</li>
<li>느린 Plan</li>
</ul>
<p>두 개를 비교 후:</p>
<pre><code class="language-text">Force Plan</code></pre>
<p>기능으로 좋은 플랜을 강제 적용했다.</p>
<hr>
<h1 id="blocking-1">Blocking</h1>
<p>Blocking은 트랜잭션이 서로 잠금을 기다리는 현상이다.</p>
<hr>
<h1 id="blocking-실습">Blocking 실습</h1>
<h2 id="세션-1">세션 1</h2>
<pre><code class="language-sql">BEGIN TRANSACTION

UPDATE Person.Person
SET LastName = LastName;</code></pre>
<p>트랜잭션 종료 안 함.</p>
<hr>
<h2 id="세션-2">세션 2</h2>
<pre><code class="language-sql">SELECT TOP (1000)
    [LastName],
    [FirstName],
    [Title]
FROM Person.Person
WHERE FirstName = &#39;David&#39;</code></pre>
<p>결과:</p>
<ul>
<li>무한 대기</li>
<li>Blocking 발생</li>
</ul>
<hr>
<h1 id="왜-발생했는가-1">왜 발생했는가?</h1>
<p>첫 번째 세션이 Lock을 유지 중이기 때문이다.</p>
<p>두 번째 세션은 같은 데이터 접근 시도 중이라 대기 상태가 된다.</p>
<hr>
<h1 id="extended-events">Extended Events</h1>
<p>Blocking 추적을 위해 Extended Events를 사용했다.</p>
<pre><code class="language-sql">blocked_process_report</code></pre>
<p>를 통해:</p>
<ul>
<li>누가 막는지</li>
<li>어떤 쿼리가 원인인지</li>
<li>어떤 세션인지</li>
</ul>
<p>확인 가능하다.</p>
<hr>
<h1 id="read_committed_snapshot">READ_COMMITTED_SNAPSHOT</h1>
<p>Blocking 완화를 위해 Snapshot Isolation 기반 설정을 적용했다.</p>
<pre><code class="language-sql">ALTER DATABASE AdventureWorks2017
SET READ_COMMITTED_SNAPSHOT ON
WITH ROLLBACK IMMEDIATE;</code></pre>
<hr>
<h1 id="핵심-개념-1">핵심 개념</h1>
<p>기존 Read Committed:</p>
<ul>
<li>수정 중 데이터 접근 시 대기</li>
</ul>
<p>READ_COMMITTED_SNAPSHOT:</p>
<ul>
<li>이전 버전(Row Version) 읽음</li>
<li>Lock 기다리지 않음</li>
</ul>
<p>즉:</p>
<blockquote>
<p>Reader와 Writer 충돌 감소</p>
</blockquote>
<hr>
<h1 id="최종-정리">최종 정리</h1>
<p>이번 실습에서는 단순 SQL 작성이 아니라 실제 운영 환경 수준의 SQL Server 성능 튜닝 과정을 경험했다.</p>
<p>특히 다음 내용을 실제로 확인할 수 있었다.</p>
<ul>
<li>인덱스 구조와 동작 방식</li>
<li>Fragmentation이 성능에 미치는 영향</li>
<li>실행 계획 분석</li>
<li>Implicit Conversion 문제</li>
<li>Key Lookup 제거 방법</li>
<li>Covering Index 설계</li>
<li>Query Store 기반 분석</li>
<li>Blocking 및 Snapshot Isolation</li>
</ul>
<p>결국 SQL 성능 튜닝의 핵심은:</p>
<blockquote>
<p>“왜 SQL Server가 그런 실행 계획을 선택했는가”</p>
</blockquote>
<p>를 이해하는 것이라는 점을 확인할 수 있었다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[MicrosoftDataSchool] 81일차 - SQL Server VM → Azure SQL Database 마이그레이션, 데이터베이스 조각화]]></title>
            <link>https://velog.io/@rudin_/MicrosoftDataSchool-81%EC%9D%BC%EC%B0%A8-SQL-Server-VM-Azure-SQL-Database-%EB%A7%88%EC%9D%B4%EA%B7%B8%EB%A0%88%EC%9D%B4%EC%85%98</link>
            <guid>https://velog.io/@rudin_/MicrosoftDataSchool-81%EC%9D%BC%EC%B0%A8-SQL-Server-VM-Azure-SQL-Database-%EB%A7%88%EC%9D%B4%EA%B7%B8%EB%A0%88%EC%9D%B4%EC%85%98</guid>
            <pubDate>Mon, 04 May 2026 01:02:51 GMT</pubDate>
            <description><![CDATA[<h1 id="carmarket-중고차-mvp--iaas에서-paas로">CarMarket 중고차 MVP — IaaS에서 PaaS로</h1>
<h2 id="아키텍처-개요">아키텍처 개요</h2>
<p><img src="https://velog.velcdn.com/images/rudin_/post/91467739-63a8-40c1-951a-5d6f78679851/image.png" alt=""></p>
<pre><code class="language-shell">#!/usr/bin/env bash
# ============================================================
# 01-setup-vm-sqlserver.sh
# Azure VM 생성 → SQL Server 2025 설치 → 외부 SSMS 접근 → 시딩
#
# 로컬 PC (Mac/Linux/WSL) 에서 실행
#
# 사용법:
#   bash 01-setup-vm-sqlserver.sh
#
# 환경변수 사전 설정 가능:
#   export RG=rg-carmarket-lab LOC=koreacentral SA_PASSWORD=&#39;YourP@ssw0rd!&#39;
#   bash 01-setup-vm-sqlserver.sh
# ============================================================

set -euo pipefail

# =============================================================
# 기본값
# =============================================================
RG=&quot;${RG:-rg-carmarket-lab}&quot;
LOC=&quot;${LOC:-koreacentral}&quot;
VM=&quot;${VM:-vm-carmarket-$(date +%m%d)}&quot;
VM_SIZE=&quot;${VM_SIZE:-Standard_B2s}&quot;
USER_NAME=&quot;${USER_NAME:-azureuser}&quot;
SSH_KEY=&quot;${SSH_KEY:-$HOME/.ssh/id_rsa.pub}&quot;
SA_PASSWORD=&quot;${SA_PASSWORD:-}&quot;
REPO_URL=&quot;https://github.com/jhjwlee/sqlvm_usedcar.git&quot;

# 색상
G=&#39;\033[0;32m&#39;; Y=&#39;\033[0;33m&#39;; R=&#39;\033[0;31m&#39;; B=&#39;\033[0;34m&#39;; NC=&#39;\033[0m&#39;
banner() { echo &quot;&quot;; echo -e &quot;${B}═══════════════════════════════════════════════════${NC}&quot;; echo -e &quot;${B}  $1${NC}&quot;; echo -e &quot;${B}═══════════════════════════════════════════════════${NC}&quot;; }
step()   { echo &quot;&quot;; echo -e &quot;${G}▶ [$1/$TOTAL_STEPS]${NC} $2&quot;; }
ok()     { echo -e &quot;${G}  ✓${NC} $1&quot;; }
warn()   { echo -e &quot;${Y}  ⚠${NC} $1&quot;; }
fail()   { echo -e &quot;${R}  ✗${NC} $1&quot;; }
abort()  { echo -e &quot;${R}❌ $1${NC}&quot;; exit 1; }

TOTAL_STEPS=8
LOG_FILE=&quot;/tmp/carmarket-setup-$(date +%Y%m%d-%H%M%S).log&quot;
exec &gt; &gt;(tee -a &quot;$LOG_FILE&quot;) 2&gt;&amp;1

banner &quot;CarMarket Lab — VM + SQL Server + SSMS 접근 자동 설정&quot;
echo &quot;  리소스 그룹: $RG&quot;
echo &quot;  위치:       $LOC&quot;
echo &quot;  VM 이름:    $VM&quot;
echo &quot;  VM 크기:    $VM_SIZE&quot;
echo &quot;  로그 파일:  $LOG_FILE&quot;

# =============================================================
# Step 1: 사전 점검
# =============================================================
step 1 &quot;사전 점검 (Azure CLI · SSH 키 · 로그인)&quot;

command -v az &gt;/dev/null 2&gt;&amp;1 || abort &quot;Azure CLI 미설치. https://aka.ms/azcli&quot;
ok &quot;Azure CLI: $(az version --query &#39;\&quot;azure-cli\&quot;&#39; -o tsv 2&gt;/dev/null || echo &#39;unknown&#39;)&quot;

if ! az account show &gt;/dev/null 2&gt;&amp;1; then
  warn &quot;Azure 로그인 필요&quot;
  az login
fi
ok &quot;구독: $(az account show --query name -o tsv)&quot;

if [ ! -f &quot;$SSH_KEY&quot; ]; then
  warn &quot;SSH 키 없음 → 자동 생성&quot;
  ssh-keygen -t rsa -b 4096 -N &quot;&quot; -f &quot;${SSH_KEY%.pub}&quot; -q
fi
ok &quot;SSH 키: $SSH_KEY&quot;

# =============================================================
# Step 2: SA 비밀번호 입력
# =============================================================
step 2 &quot;SA 비밀번호 설정&quot;

if [ -z &quot;$SA_PASSWORD&quot; ]; then
  echo &quot;  SQL Server SA 비밀번호를 입력하세요.&quot;
  echo &quot;  요구사항: 8자+ / 대·소문자·숫자·특수문자 중 3종 이상&quot;
  echo &quot;  예) CarMarket@2026&quot;
  while true; do
    read -s -p &quot;  SA Password: &quot; SA_PASSWORD; echo &quot;&quot;
    read -s -p &quot;  Confirm:     &quot; SA_CONFIRM;  echo &quot;&quot;
    if [ &quot;$SA_PASSWORD&quot; = &quot;$SA_CONFIRM&quot; ] &amp;&amp; [ ${#SA_PASSWORD} -ge 8 ]; then
      break
    fi
    echo -e &quot;${R}  비밀번호 불일치 또는 8자 미만. 재입력.${NC}&quot;
  done
fi
ok &quot;SA 비밀번호 설정 완료 (${#SA_PASSWORD}자)&quot;

# 비용 안내
echo &quot;&quot;
echo &quot;  예상 비용: VM(B2s) ≈ \$0.5/일&quot;
echo &quot;  예상 시간: 약 10~15분&quot;
read -p &quot;  진행? (y/N): &quot; ok_proceed
[[ &quot;$ok_proceed&quot; =~ ^[Yy]$ ]] || abort &quot;취소됨&quot;

# =============================================================
# Step 3: Resource Group + VM 생성
# =============================================================
step 3 &quot;Azure 리소스 생성 (RG + VM)&quot;

if az group show -n &quot;$RG&quot; &gt;/dev/null 2&gt;&amp;1; then
  ok &quot;RG &#39;$RG&#39; 이미 존재 (재사용)&quot;
else
  az group create -n &quot;$RG&quot; -l &quot;$LOC&quot; --output none
  ok &quot;RG &#39;$RG&#39; 생성&quot;
fi

if az vm show -g &quot;$RG&quot; -n &quot;$VM&quot; &gt;/dev/null 2&gt;&amp;1; then
  warn &quot;VM &#39;$VM&#39; 이미 존재 → 재사용&quot;
else
  echo &quot;  → VM 생성 중 (3~5분)...&quot;
  az vm create \
    --resource-group &quot;$RG&quot; \
    --name &quot;$VM&quot; \
    --image Ubuntu2404 \
    --size &quot;$VM_SIZE&quot; \
    --admin-username &quot;$USER_NAME&quot; \
    --ssh-key-values &quot;$SSH_KEY&quot; \
    --public-ip-sku Standard \
    --storage-sku Premium_LRS \
    --os-disk-size-gb 32 \
    --output none
  ok &quot;VM &#39;$VM&#39; 생성 완료&quot;
fi

PUBIP=$(az vm show -d -g &quot;$RG&quot; -n &quot;$VM&quot; --query publicIps -o tsv)
ok &quot;Public IP: $PUBIP&quot;

# =============================================================
# Step 4: NSG 포트 오픈 (22, 1433, 5000)
# =============================================================
step 4 &quot;NSG 포트 오픈 — SSH(22) + SQL(1433) + Flask(5000)&quot;

# NSG 이름 자동 탐색
NSG_NAME=$(az network nsg list -g &quot;$RG&quot; --query &quot;[0].name&quot; -o tsv 2&gt;/dev/null || echo &quot;${VM}NSG&quot;)

open_port() {
  local PORT=$1 PRIORITY=$2 NAME=$3
  if az network nsg rule show -g &quot;$RG&quot; --nsg-name &quot;$NSG_NAME&quot; -n &quot;$NAME&quot; &gt;/dev/null 2&gt;&amp;1; then
    ok &quot;$NAME ($PORT) 이미 존재&quot;
  else
    az vm open-port -g &quot;$RG&quot; -n &quot;$VM&quot; --port &quot;$PORT&quot; --priority &quot;$PRIORITY&quot; --output none 2&gt;/dev/null || true
    ok &quot;$NAME ($PORT) 오픈&quot;
  fi
}

open_port 1433 1010 &quot;allow_sql_1433&quot;
open_port 5000 1020 &quot;allow_flask_5000&quot;
ok &quot;NSG 규칙 적용 완료&quot;

# =============================================================
# Step 5: SSH 대기 + 접속
# =============================================================
step 5 &quot;SSH 연결 대기&quot;

echo &quot;  → SSH 준비 대기 (최대 90초)...&quot;
for i in $(seq 1 45); do
  if ssh -o StrictHostKeyChecking=no -o ConnectTimeout=3 -o BatchMode=yes \
     &quot;$USER_NAME@$PUBIP&quot; &quot;echo ready&quot; &gt;/dev/null 2&gt;&amp;1; then
    ok &quot;SSH 연결 가능&quot;
    break
  fi
  sleep 2
  [ $i -eq 45 ] &amp;&amp; abort &quot;SSH 연결 90초 타임아웃&quot;
done

# =============================================================
# Step 6: VM 내부 — SQL Server 설치 (0.0.0.0 바인딩)
# =============================================================
step 6 &quot;VM 내부: SQL Server 2025 설치 + 0.0.0.0 바인딩&quot;

ssh -o StrictHostKeyChecking=accept-new &quot;$USER_NAME@$PUBIP&quot; bash -s &quot;$SA_PASSWORD&quot; &lt;&lt; &#39;REMOTE_SCRIPT&#39;
#!/usr/bin/env bash
set -euo pipefail

SA_PASSWORD=&quot;$1&quot;
G=&#39;\033[0;32m&#39;; NC=&#39;\033[0m&#39;
ok() { echo -e &quot;${G}  ✓${NC} $1&quot;; }

echo &quot;=== [VM] 패키지 업데이트 ===&quot;
sudo apt update -qq
sudo apt install -y -qq curl wget gnupg2 software-properties-common \
  apt-transport-https ca-certificates lsb-release git unzip jq &gt; /dev/null
ok &quot;기본 패키지&quot;

# Swap (RAM &lt; 6GB)
RAM_GB=$(free -g | awk &#39;NR==2{print $2}&#39;)
if [ &quot;$RAM_GB&quot; -lt 6 ] &amp;&amp; ! swapon --show | grep -q swapfile; then
  sudo fallocate -l 2G /swapfile &amp;&amp; sudo chmod 600 /swapfile
  sudo mkswap /swapfile -q &amp;&amp; sudo swapon /swapfile
  grep -q &quot;/swapfile&quot; /etc/fstab || echo &#39;/swapfile none swap sw 0 0&#39; | sudo tee -a /etc/fstab &gt; /dev/null
  ok &quot;swap 2GB 활성화&quot;
fi

echo &quot;=== [VM] Microsoft GPG 키 + 저장소 ===&quot;
sudo rm -f /etc/apt/sources.list.d/mssql-server-2022.list /etc/apt/sources.list.d/mssql-server-preview.list
if [ ! -f /usr/share/keyrings/microsoft-prod.gpg ]; then
  curl -fsSL https://packages.microsoft.com/keys/microsoft.asc | \
    sudo gpg --dearmor -o /usr/share/keyrings/microsoft-prod.gpg
fi
curl -fsSL https://packages.microsoft.com/config/ubuntu/24.04/mssql-server-2025.list | \
  sudo tee /etc/apt/sources.list.d/mssql-server-2025.list &gt; /dev/null
curl -fsSL https://packages.microsoft.com/config/ubuntu/24.04/prod.list | \
  sudo tee /etc/apt/sources.list.d/mssql-release.list &gt; /dev/null
sudo apt update -qq
ok &quot;저장소 등록&quot;

echo &quot;=== [VM] SQL Server 설치 ===&quot;
if ! dpkg -l | grep -q &quot;^ii.*mssql-server &quot;; then
  sudo apt install -y -qq mssql-server &gt; /dev/null
  ok &quot;mssql-server 패키지 설치&quot;
else
  ok &quot;mssql-server 이미 설치됨&quot;
fi

if ! sudo systemctl is-active --quiet mssql-server; then
  sudo MSSQL_PID=Developer ACCEPT_EULA=Y MSSQL_SA_PASSWORD=&quot;$SA_PASSWORD&quot; \
    /opt/mssql/bin/mssql-conf -n setup &gt; /dev/null
  ok &quot;SQL Server setup (Developer Edition)&quot;
fi

# ★ 핵심: 0.0.0.0 바인딩 (외부 SSMS 접근 허용)
sudo /opt/mssql/bin/mssql-conf set network.ipaddress 0.0.0.0 &gt; /dev/null
sudo systemctl restart mssql-server

echo &quot;=== [VM] SQL Server 시작 대기 ===&quot;
for i in $(seq 1 30); do
  if sudo ss -tlnp 2&gt;/dev/null | grep -q &quot;:1433&quot;; then
    ok &quot;0.0.0.0:1433 listen 확인&quot;
    break
  fi
  sleep 2
  [ $i -eq 30 ] &amp;&amp; { echo &quot;❌ 60초 내 시작 안됨&quot;; exit 1; }
done

echo &quot;=== [VM] mssql-tools18 + ODBC ===&quot;
sudo ACCEPT_EULA=Y apt install -y -qq mssql-tools18 unixodbc-dev msodbcsql18 &gt; /dev/null
grep -q &quot;mssql-tools18/bin&quot; &quot;$HOME/.bashrc&quot; || \
  echo &#39;export PATH=&quot;$PATH:/opt/mssql-tools18/bin&quot;&#39; &gt;&gt; &quot;$HOME/.bashrc&quot;
export PATH=&quot;$PATH:/opt/mssql-tools18/bin&quot;
ok &quot;mssql-tools18 + ODBC Driver 18&quot;

# 연결 검증
if /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P &quot;$SA_PASSWORD&quot; -C -Q &quot;SELECT 1&quot; -h -1 -W 2&gt;/dev/null | grep -q &quot;^1$&quot;; then
  ok &quot;sqlcmd 로컬 연결 성공&quot;
else
  echo &quot;❌ sqlcmd 연결 실패&quot;; exit 1
fi

echo &quot;=== [VM] 완료 ===&quot;
REMOTE_SCRIPT

ok &quot;SQL Server 2025 설치 + 0.0.0.0 바인딩 완료&quot;

# =============================================================
# Step 7: DB 스키마 + 시드 데이터
# =============================================================
step 7 &quot;DB 스키마 + 시드 데이터 적용&quot;

ssh &quot;$USER_NAME@$PUBIP&quot; bash -s &quot;$SA_PASSWORD&quot; &quot;$REPO_URL&quot; &lt;&lt; &#39;SEED_SCRIPT&#39;
#!/usr/bin/env bash
set -euo pipefail
SA_PASSWORD=&quot;$1&quot;
REPO_URL=&quot;$2&quot;
INSTALL_DIR=&quot;$HOME/sqlvm_usedcar&quot;
export PATH=&quot;$PATH:/opt/mssql-tools18/bin&quot;

G=&#39;\033[0;32m&#39;; NC=&#39;\033[0m&#39;
ok() { echo -e &quot;${G}  ✓${NC} $1&quot;; }

# Repo clone
if [ -d &quot;$INSTALL_DIR/.git&quot; ]; then
  cd &quot;$INSTALL_DIR&quot; &amp;&amp; git pull --rebase --quiet
else
  [ -d &quot;$INSTALL_DIR&quot; ] &amp;&amp; mv &quot;$INSTALL_DIR&quot; &quot;${INSTALL_DIR}.bak.$(date +%s)&quot;
  git clone -q &quot;$REPO_URL&quot; &quot;$INSTALL_DIR&quot;
  cd &quot;$INSTALL_DIR&quot;
fi
ok &quot;Repo clone: $(git rev-parse --short HEAD)&quot;

# Schema
sqlcmd -S localhost -U sa -P &quot;$SA_PASSWORD&quot; -C -i sql/schema.sql &gt; /dev/null
ok &quot;schema.sql 적용 (Users, Cars, Inquiries + 3 indexes)&quot;

# Seed
sqlcmd -S localhost -U sa -P &quot;$SA_PASSWORD&quot; -C -i sql/seed.sql &gt; /dev/null

# 검증
SEED_COUNT=$(sqlcmd -S localhost -U sa -P &quot;$SA_PASSWORD&quot; -C -d CarMarket \
  -Q &quot;SELECT COUNT(*) FROM Cars&quot; -h -1 -W 2&gt;/dev/null | head -1 | tr -d &#39; \r&#39;)
if [ &quot;$SEED_COUNT&quot; = &quot;5&quot; ]; then
  ok &quot;seed.sql 적용 (Users 5건, Cars 5건)&quot;
else
  echo &quot;⚠ Cars 행 수: $SEED_COUNT (예상 5)&quot;
fi
SEED_SCRIPT

ok &quot;CarMarket DB 시딩 완료&quot;

# =============================================================
# Step 8: Flask 앱 + systemd + 검증
# =============================================================
step 8 &quot;Flask 앱 배포 + 헬스체크&quot;

ssh &quot;$USER_NAME@$PUBIP&quot; bash -s &quot;$SA_PASSWORD&quot; &lt;&lt; &#39;APP_SCRIPT&#39;
#!/usr/bin/env bash
set -euo pipefail
SA_PASSWORD=&quot;$1&quot;
INSTALL_DIR=&quot;$HOME/sqlvm_usedcar&quot;

G=&#39;\033[0;32m&#39;; NC=&#39;\033[0m&#39;
ok() { echo -e &quot;${G}  ✓${NC} $1&quot;; }

sudo apt install -y -qq python3 python3-pip python3-venv python3-dev &gt; /dev/null

cd &quot;$INSTALL_DIR/app&quot;
[ ! -f venv/bin/activate ] &amp;&amp; python3 -m venv venv
source venv/bin/activate
pip install --quiet --upgrade pip
pip install --quiet -r requirements.txt
deactivate

cat &gt; .env &lt;&lt;EOF
SA_PASSWORD=$SA_PASSWORD
DB_SERVER=localhost
DB_NAME=CarMarket
FLASK_PORT=5000
EOF
chmod 600 .env
ok &quot;.env 생성&quot;

cd &quot;$INSTALL_DIR&quot;
sudo cp systemd/carmarket.service /etc/systemd/system/carmarket.service
sudo sed -i &quot;s|/home/azureuser|$HOME|g&quot; /etc/systemd/system/carmarket.service
sudo sed -i &quot;s|User=azureuser|User=$(whoami)|&quot; /etc/systemd/system/carmarket.service
sudo systemctl daemon-reload
sudo systemctl enable carmarket --quiet
sudo systemctl restart carmarket
sleep 3

if sudo systemctl is-active --quiet carmarket; then
  ok &quot;carmarket.service 실행 중&quot;
else
  echo &quot;❌ Flask 서비스 실행 실패&quot;
  sudo journalctl -u carmarket -n 20 --no-pager
  exit 1
fi

HEALTH=$(curl -s --max-time 5 http://localhost:5000/health || echo &#39;{}&#39;)
if echo &quot;$HEALTH&quot; | grep -q &#39;&quot;status&quot;:&quot;ok&quot;&#39;; then
  ok &quot;Health OK: $HEALTH&quot;
fi
APP_SCRIPT

ok &quot;Flask 앱 배포 완료&quot;

# =============================================================
# 외부 검증
# =============================================================
echo &quot;&quot;
sleep 3
echo &quot;  → 외부 헬스체크...&quot;
if curl -fsS --max-time 10 &quot;http://$PUBIP:5000/health&quot; 2&gt;/dev/null | grep -q &#39;&quot;status&quot;:&quot;ok&quot;&#39;; then
  ok &quot;외부 Flask 접근 확인: http://$PUBIP:5000/&quot;
else
  warn &quot;Flask 외부 접근 실패 — NSG/서비스 확인 필요&quot;
fi

# =============================================================
# 완료 안내
# =============================================================
banner &quot;설치 완료!&quot;

# 환경변수 파일 저장
cat &gt; &quot;$HOME/.carmarket-env&quot; &lt;&lt;EOF
export RG=$RG
export LOC=$LOC
export VM=$VM
export PUBIP=$PUBIP
export USER_NAME=$USER_NAME
export SA_PASSWORD=&#39;$SA_PASSWORD&#39;
EOF
chmod 600 &quot;$HOME/.carmarket-env&quot;

cat &lt;&lt;EOF

┌─────────────────────────────────────────────────────────────┐
│  SQL Server 2025 (Developer)  — 외부 SSMS 접근 가능        │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  SSMS 연결 정보:                                            │
│    서버:    $PUBIP,1433                                     │
│    인증:    SQL Server 인증                                  │
│    로그인:  sa                                              │
│    암호:    (설정한 SA_PASSWORD)                             │
│    ★ 연결 속성 → &quot;서버 인증서 신뢰&quot; 체크                     │
│                                                             │
│  웹 앱: http://$PUBIP:5000/                                 │
│  SSH:   ssh $USER_NAME@$PUBIP                               │
│                                                             │
│  비용 차단:  az vm deallocate -g $RG -n $VM                 │
│  완전 삭제:  az group delete -n $RG --yes --no-wait         │
│                                                             │
│  환경변수:   source ~/.carmarket-env                         │
└─────────────────────────────────────────────────────────────┘

EOF
</code></pre>
<ol>
<li>Azure CLI 로그인 확인 + SSH 키 검증</li>
<li>Resource Group + Ubuntu 24.04 VM (B2s) 생성</li>
<li>NSG에서 22(SSH) + 1433(SQL) + 5000(Flask) 포트 오픈</li>
<li>SQL Server 2025 Developer Edition 설치</li>
<li>0.0.0.0 바인딩 (외부 SSMS 접근 허용)</li>
<li>CarMarket DB 스키마 생성 + 시드 데이터 5건</li>
<li>Flask 앱 배포 + systemd 서비스 등록</li>
</ol>
<h2 id="ssms-연결">SSMS 연결</h2>
<p><img src="https://velog.velcdn.com/images/rudin_/post/76ad8d8a-bf61-4487-a0ad-9db37c663a62/image.png" alt="">
또는 VSCode에서 SQL Server(mssql) extension을 통해 접속 가능</p>
<h3 id="데이터-확인">데이터 확인</h3>
<pre><code>USE CarMarket;
GO
-- 테이블 목록 확인
SELECT TABLE_NAME, TABLE_TYPE
FROM INFORMATION_SCHEMA.TABLES
WHERE TABLE_TYPE = &#39;BASE TABLE&#39;;
-- 차량 매물 확인
SELECT c.Brand, c.Model, c.Year,
FORMAT(c.Price, &#39;N0&#39;) AS Price,
FORMAT(c.Mileage, &#39;N0&#39;) AS Mileage,
u.Name AS Seller
FROM Cars c
JOIN Users u ON c.SellerId = u.UserId
ORDER BY c.Price DESC;
-- 인덱스 확인
SELECT i.name AS IndexName,
t.name AS TableName,
COL_NAME(ic.object_id, ic.column_id) AS ColumnName
FROM sys.indexes i
JOIN sys.tables t ON i.object_id = t.object_id
JOIN sys.index_columns ic ON i.object_id = ic.object_id AND i.index_id = ic.index_id
WHERE i.name LIKE &#39;IX_%&#39;;</code></pre><p><img src="https://velog.velcdn.com/images/rudin_/post/329935fb-ae94-4c2c-bdf2-71ce814921d9/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/07470af1-2433-47ca-ad72-9540bd030577/image.png" alt=""></p>
<p>이 실습에서는 학습 편의를 위해 1433을 외부에 직접 오픈합니다. 프로덕션 환경에서는 절대 하지 마세요. 실무에서는 다음 방법을 사용합니다:</p>
<ul>
<li>SSH 터널: <code>ssh -L 1433:localhost:1433 azureuser@&lt;PUBIP&gt;</code> 후 SSMS에서 localhost 접속</li>
<li>VPN Gateway 또는 Azure Bastion</li>
<li>Private Endpoint</li>
</ul>
<h2 id="azure-sql-database-마이그레이션">Azure SQL Database 마이그레이션</h2>
<pre><code class="language-shell">#!/usr/bin/env bash
# ============================================================
# 02-migrate-to-azure-sql.sh
# VM SQL Server → Azure SQL Database 마이그레이션 (Azure DMS)
#
# 사전 조건:
#   - 01-setup-vm-sqlserver.sh 완료
#   - source ~/.carmarket-env (환경변수 로드)
#
# 사용법:
#   source ~/.carmarket-env
#   bash 02-migrate-to-azure-sql.sh
# ============================================================

set -euo pipefail

# =============================================================
# 환경변수 확인 + 기본값
# =============================================================
RG=&quot;${RG:-rg-carmarket-lab}&quot;
LOC=&quot;${LOC:-koreacentral}&quot;
VM=&quot;${VM:-}&quot;
PUBIP=&quot;${PUBIP:-}&quot;
USER_NAME=&quot;${USER_NAME:-azureuser}&quot;
SA_PASSWORD=&quot;${SA_PASSWORD:-}&quot;

# Azure SQL 관련 변수
SQL_SERVER_NAME=&quot;${SQL_SERVER_NAME:-sql-carmarket-$(date +%m%d)-$RANDOM}&quot;
SQL_DB_NAME=&quot;${SQL_DB_NAME:-CarMarket}&quot;
SQL_ADMIN=&quot;${SQL_ADMIN:-sqladmin}&quot;
SQL_ADMIN_PASSWORD=&quot;${SQL_ADMIN_PASSWORD:-}&quot;
DMS_NAME=&quot;${DMS_NAME:-dms-carmarket-$(date +%m%d)}&quot;

# 색상
G=&#39;\033[0;32m&#39;; Y=&#39;\033[0;33m&#39;; R=&#39;\033[0;31m&#39;; B=&#39;\033[0;34m&#39;; NC=&#39;\033[0m&#39;
banner() { echo &quot;&quot;; echo -e &quot;${B}═══════════════════════════════════════════════════${NC}&quot;; echo -e &quot;${B}  $1${NC}&quot;; echo -e &quot;${B}═══════════════════════════════════════════════════${NC}&quot;; }
step()   { echo &quot;&quot;; echo -e &quot;${G}▶ [$1/$TOTAL_STEPS]${NC} $2&quot;; }
ok()     { echo -e &quot;${G}  ✓${NC} $1&quot;; }
warn()   { echo -e &quot;${Y}  ⚠${NC} $1&quot;; }
abort()  { echo -e &quot;${R}❌ $1${NC}&quot;; exit 1; }

TOTAL_STEPS=7
LOG_FILE=&quot;/tmp/carmarket-migrate-$(date +%Y%m%d-%H%M%S).log&quot;
exec &gt; &gt;(tee -a &quot;$LOG_FILE&quot;) 2&gt;&amp;1

banner &quot;CarMarket Lab — VM → Azure SQL Database 마이그레이션&quot;

# =============================================================
# Step 1: 사전 점검
# =============================================================
step 1 &quot;사전 점검&quot;

command -v az &gt;/dev/null 2&gt;&amp;1 || abort &quot;Azure CLI 미설치&quot;
az account show &gt;/dev/null 2&gt;&amp;1 || { warn &quot;로그인 필요&quot;; az login; }
ok &quot;Azure CLI 로그인 확인&quot;

# PUBIP가 비어있으면 VM에서 가져오기
if [ -z &quot;$PUBIP&quot; ] &amp;&amp; [ -n &quot;$VM&quot; ]; then
  PUBIP=$(az vm show -d -g &quot;$RG&quot; -n &quot;$VM&quot; --query publicIps -o tsv 2&gt;/dev/null || echo &quot;&quot;)
fi
[ -z &quot;$PUBIP&quot; ] &amp;&amp; abort &quot;PUBIP를 확인할 수 없습니다. source ~/.carmarket-env 후 재시도&quot;
ok &quot;소스 VM: $PUBIP&quot;

# SA 비밀번호 확인
if [ -z &quot;$SA_PASSWORD&quot; ]; then
  read -s -p &quot;  소스 VM SA 비밀번호: &quot; SA_PASSWORD; echo &quot;&quot;
fi
ok &quot;소스 SA 비밀번호 확인&quot;

# Azure SQL 관리자 비밀번호
if [ -z &quot;$SQL_ADMIN_PASSWORD&quot; ]; then
  echo &quot;  Azure SQL Database 관리자 비밀번호를 입력하세요.&quot;
  echo &quot;  (소스와 같은 비밀번호 사용 가능)&quot;
  while true; do
    read -s -p &quot;  SQL Admin Password: &quot; SQL_ADMIN_PASSWORD; echo &quot;&quot;
    read -s -p &quot;  Confirm:            &quot; CONFIRM; echo &quot;&quot;
    [ &quot;$SQL_ADMIN_PASSWORD&quot; = &quot;$CONFIRM&quot; ] &amp;&amp; [ ${#SQL_ADMIN_PASSWORD} -ge 8 ] &amp;&amp; break
    echo -e &quot;${R}  불일치 또는 8자 미만${NC}&quot;
  done
fi
ok &quot;Azure SQL 관리자 비밀번호 설정&quot;

echo &quot;&quot;
echo &quot;  Azure SQL Server: $SQL_SERVER_NAME.database.windows.net&quot;
echo &quot;  Database:         $SQL_DB_NAME&quot;
echo &quot;  Admin:            $SQL_ADMIN&quot;
echo &quot;  예상 비용: DTU 기반 S0 ≈ \$0.49/일&quot;
read -p &quot;  진행? (y/N): &quot; ok_proceed
[[ &quot;$ok_proceed&quot; =~ ^[Yy]$ ]] || abort &quot;취소됨&quot;

# =============================================================
# Step 2: Azure SQL Server + Database 생성
# =============================================================
step 2 &quot;Azure SQL Server + Database 생성&quot;

# SQL Server (논리 서버)
if az sql server show -g &quot;$RG&quot; -n &quot;$SQL_SERVER_NAME&quot; &gt;/dev/null 2&gt;&amp;1; then
  ok &quot;SQL Server &#39;$SQL_SERVER_NAME&#39; 이미 존재&quot;
else
  echo &quot;  → 논리 서버 생성 중...&quot;
  az sql server create \
    --resource-group &quot;$RG&quot; \
    --name &quot;$SQL_SERVER_NAME&quot; \
    --location &quot;$LOC&quot; \
    --admin-user &quot;$SQL_ADMIN&quot; \
    --admin-password &quot;$SQL_ADMIN_PASSWORD&quot; \
    --output none
  ok &quot;SQL Server &#39;$SQL_SERVER_NAME&#39; 생성&quot;
fi

# 방화벽: VM Public IP 허용
echo &quot;  → 방화벽 규칙 추가...&quot;
az sql server firewall-rule create \
  --resource-group &quot;$RG&quot; \
  --server &quot;$SQL_SERVER_NAME&quot; \
  --name &quot;AllowSourceVM&quot; \
  --start-ip-address &quot;$PUBIP&quot; \
  --end-ip-address &quot;$PUBIP&quot; \
  --output none 2&gt;/dev/null || true

# 방화벽: 내 로컬 IP 허용
MY_IP=$(curl -s https://api.ipify.org 2&gt;/dev/null || echo &quot;&quot;)
if [ -n &quot;$MY_IP&quot; ]; then
  az sql server firewall-rule create \
    --resource-group &quot;$RG&quot; \
    --server &quot;$SQL_SERVER_NAME&quot; \
    --name &quot;AllowMyIP&quot; \
    --start-ip-address &quot;$MY_IP&quot; \
    --end-ip-address &quot;$MY_IP&quot; \
    --output none 2&gt;/dev/null || true
  ok &quot;방화벽: VM($PUBIP) + 로컬($MY_IP) 허용&quot;
else
  ok &quot;방화벽: VM($PUBIP) 허용&quot;
fi

# Azure 서비스 접근 허용
az sql server firewall-rule create \
  --resource-group &quot;$RG&quot; \
  --server &quot;$SQL_SERVER_NAME&quot; \
  --name &quot;AllowAzureServices&quot; \
  --start-ip-address 0.0.0.0 \
  --end-ip-address 0.0.0.0 \
  --output none 2&gt;/dev/null || true
ok &quot;Azure 서비스 접근 허용&quot;

# Database 생성 (S0 = 10 DTU, 실습에 충분)
if az sql db show -g &quot;$RG&quot; -s &quot;$SQL_SERVER_NAME&quot; -n &quot;$SQL_DB_NAME&quot; &gt;/dev/null 2&gt;&amp;1; then
  ok &quot;Database &#39;$SQL_DB_NAME&#39; 이미 존재&quot;
else
  echo &quot;  → Database 생성 중 (1~2분)...&quot;
  az sql db create \
    --resource-group &quot;$RG&quot; \
    --server &quot;$SQL_SERVER_NAME&quot; \
    --name &quot;$SQL_DB_NAME&quot; \
    --service-objective S0 \
    --output none
  ok &quot;Database &#39;$SQL_DB_NAME&#39; 생성 (S0 / 10 DTU)&quot;
fi

SQL_FQDN=&quot;${SQL_SERVER_NAME}.database.windows.net&quot;
ok &quot;Azure SQL: $SQL_FQDN / $SQL_DB_NAME&quot;

# =============================================================
# Step 3: 소스 VM에서 bacpac 내보내기 준비
# =============================================================
step 3 &quot;소스 DB에서 스키마·데이터 SQL 스크립트 생성&quot;

# DMS 대신 sqlcmd를 통한 직접 마이그레이션 (소규모 DB에 적합)
# 대규모에서는 DMS를 사용하지만, 이 실습은 교육용이므로 두 방식 모두 제공

echo &quot;  → VM에서 스키마 + 데이터 추출...&quot;
ssh &quot;$USER_NAME@$PUBIP&quot; bash -s &quot;$SA_PASSWORD&quot; &lt;&lt; &#39;EXPORT_SCRIPT&#39;
#!/usr/bin/env bash
set -euo pipefail
SA_PASSWORD=&quot;$1&quot;
export PATH=&quot;$PATH:/opt/mssql-tools18/bin&quot;
EXPORT_DIR=&quot;$HOME/migration_export&quot;
mkdir -p &quot;$EXPORT_DIR&quot;

G=&#39;\033[0;32m&#39;; NC=&#39;\033[0m&#39;
ok() { echo -e &quot;${G}  ✓${NC} $1&quot;; }

# Azure SQL 호환 스키마 생성 (IDENTITY 유지, Azure SQL 미지원 구문 제거)
cat &gt; &quot;$EXPORT_DIR/schema-azure.sql&quot; &lt;&lt; &#39;AZSCHEMA&#39;
-- Azure SQL Database용 스키마 (CarMarket)
-- Azure SQL은 CREATE DATABASE를 별도로 실행하므로 DB 생성 구문 제외

-- 기존 테이블 정리 (멱등성)
IF OBJECT_ID(&#39;Inquiries&#39;, &#39;U&#39;) IS NOT NULL DROP TABLE Inquiries;
IF OBJECT_ID(&#39;Cars&#39;,      &#39;U&#39;) IS NOT NULL DROP TABLE Cars;
IF OBJECT_ID(&#39;Users&#39;,     &#39;U&#39;) IS NOT NULL DROP TABLE Users;
GO

CREATE TABLE Users (
    UserId    INT IDENTITY(1,1) PRIMARY KEY,
    Name      NVARCHAR(100)  NOT NULL,
    Email     NVARCHAR(200)  NOT NULL UNIQUE,
    Phone     NVARCHAR(20),
    UserType  NVARCHAR(10)   NOT NULL DEFAULT &#39;both&#39;
              CHECK (UserType IN (&#39;seller&#39;, &#39;buyer&#39;, &#39;both&#39;)),
    CreatedAt DATETIME2      DEFAULT SYSUTCDATETIME()
);
GO

CREATE TABLE Cars (
    CarId       INT IDENTITY(1,1) PRIMARY KEY,
    SellerId    INT            NOT NULL FOREIGN KEY REFERENCES Users(UserId),
    Brand       NVARCHAR(50)   NOT NULL,
    Model       NVARCHAR(100)  NOT NULL,
    Year        INT            NOT NULL,
    Price       DECIMAL(12, 0) NOT NULL,
    Mileage     INT            NOT NULL,
    FuelType    NVARCHAR(20),
    Description NVARCHAR(MAX),
    Status      NVARCHAR(20)   NOT NULL DEFAULT &#39;available&#39;
                CHECK (Status IN (&#39;available&#39;, &#39;reserved&#39;, &#39;sold&#39;)),
    CreatedAt   DATETIME2      DEFAULT SYSUTCDATETIME()
);
GO

CREATE TABLE Inquiries (
    InquiryId INT IDENTITY(1,1) PRIMARY KEY,
    CarId     INT            NOT NULL FOREIGN KEY REFERENCES Cars(CarId),
    BuyerId   INT            NOT NULL FOREIGN KEY REFERENCES Users(UserId),
    Message   NVARCHAR(1000) NOT NULL,
    CreatedAt DATETIME2      DEFAULT SYSUTCDATETIME()
);
GO

CREATE INDEX IX_Cars_Brand     ON Cars(Brand);
CREATE INDEX IX_Cars_Status    ON Cars(Status);
CREATE INDEX IX_Cars_CreatedAt ON Cars(CreatedAt DESC);
GO
AZSCHEMA
ok &quot;Azure SQL 호환 스키마 생성&quot;

# 데이터 추출 (INSERT 문으로)
sqlcmd -S localhost -U sa -P &quot;$SA_PASSWORD&quot; -C -d CarMarket -h -1 -W -Q &quot;
SET NOCOUNT ON;

-- Users
SELECT &#39;SET IDENTITY_INSERT Users ON;&#39;
UNION ALL
SELECT &#39;INSERT INTO Users (UserId, Name, Email, Phone, UserType) VALUES (&#39;
  + CAST(UserId AS NVARCHAR) + &#39;, N&#39;&#39;&#39; + REPLACE(Name, &#39;&#39;&#39;&#39;, &#39;&#39;&#39;&#39;&#39;&#39;) + &#39;&#39;&#39;, &#39;&#39;&#39;
  + Email + &#39;&#39;&#39;, &#39;&#39;&#39; + ISNULL(Phone, &#39;&#39;) + &#39;&#39;&#39;, &#39;&#39;&#39; + UserType + &#39;&#39;&#39;);&#39;
FROM Users
UNION ALL
SELECT &#39;SET IDENTITY_INSERT Users OFF;&#39;
UNION ALL
SELECT &#39;&#39;
UNION ALL
-- Cars
SELECT &#39;SET IDENTITY_INSERT Cars ON;&#39;
UNION ALL
SELECT &#39;INSERT INTO Cars (CarId, SellerId, Brand, Model, Year, Price, Mileage, FuelType, Description, Status) VALUES (&#39;
  + CAST(CarId AS NVARCHAR) + &#39;, &#39; + CAST(SellerId AS NVARCHAR) + &#39;, N&#39;&#39;&#39;
  + REPLACE(Brand, &#39;&#39;&#39;&#39;, &#39;&#39;&#39;&#39;&#39;&#39;) + &#39;&#39;&#39;, N&#39;&#39;&#39; + REPLACE(Model, &#39;&#39;&#39;&#39;, &#39;&#39;&#39;&#39;&#39;&#39;) + &#39;&#39;&#39;, &#39;
  + CAST(Year AS NVARCHAR) + &#39;, &#39; + CAST(Price AS NVARCHAR) + &#39;, &#39;
  + CAST(Mileage AS NVARCHAR) + &#39;, N&#39;&#39;&#39; + ISNULL(FuelType, &#39;&#39;) + &#39;&#39;&#39;, N&#39;&#39;&#39;
  + ISNULL(REPLACE(Description, &#39;&#39;&#39;&#39;, &#39;&#39;&#39;&#39;&#39;&#39;), &#39;&#39;) + &#39;&#39;&#39;, &#39;&#39;&#39; + Status + &#39;&#39;&#39;);&#39;
FROM Cars
UNION ALL
SELECT &#39;SET IDENTITY_INSERT Cars OFF;&#39;
UNION ALL
SELECT &#39;GO&#39;;
&quot; &gt; &quot;$EXPORT_DIR/seed-azure.sql&quot; 2&gt;/dev/null

# 빈 줄/공백 정리
sed -i &#39;/^$/d&#39; &quot;$EXPORT_DIR/seed-azure.sql&quot;
ok &quot;데이터 INSERT 스크립트 생성&quot;

# 행 수 검증
USER_CNT=$(sqlcmd -S localhost -U sa -P &quot;$SA_PASSWORD&quot; -C -d CarMarket \
  -Q &quot;SET NOCOUNT ON; SELECT COUNT(*) FROM Users&quot; -h -1 -W | head -1 | tr -d &#39; \r&#39;)
CAR_CNT=$(sqlcmd -S localhost -U sa -P &quot;$SA_PASSWORD&quot; -C -d CarMarket \
  -Q &quot;SET NOCOUNT ON; SELECT COUNT(*) FROM Cars&quot; -h -1 -W | head -1 | tr -d &#39; \r&#39;)
ok &quot;소스 DB: Users=${USER_CNT}건, Cars=${CAR_CNT}건&quot;

echo &quot;$USER_CNT $CAR_CNT&quot; &gt; &quot;$EXPORT_DIR/source_counts.txt&quot;
EXPORT_SCRIPT

ok &quot;마이그레이션 데이터 준비 완료&quot;

# =============================================================
# Step 4: Azure SQL에 스키마 적용
# =============================================================
step 4 &quot;Azure SQL Database에 스키마 적용&quot;

# VM에서 Azure SQL로 직접 sqlcmd 실행
ssh &quot;$USER_NAME@$PUBIP&quot; bash -s &quot;$SQL_FQDN&quot; &quot;$SQL_ADMIN&quot; &quot;$SQL_ADMIN_PASSWORD&quot; &quot;$SQL_DB_NAME&quot; &lt;&lt; &#39;APPLY_SCHEMA&#39;
#!/usr/bin/env bash
set -euo pipefail
SQL_FQDN=&quot;$1&quot;; SQL_ADMIN=&quot;$2&quot;; SQL_ADMIN_PASSWORD=&quot;$3&quot;; SQL_DB_NAME=&quot;$4&quot;
export PATH=&quot;$PATH:/opt/mssql-tools18/bin&quot;
EXPORT_DIR=&quot;$HOME/migration_export&quot;

G=&#39;\033[0;32m&#39;; NC=&#39;\033[0m&#39;
ok() { echo -e &quot;${G}  ✓${NC} $1&quot;; }

echo &quot;  → Azure SQL에 스키마 적용...&quot;
sqlcmd -S &quot;$SQL_FQDN&quot; -U &quot;$SQL_ADMIN&quot; -P &quot;$SQL_ADMIN_PASSWORD&quot; -d &quot;$SQL_DB_NAME&quot; \
  -i &quot;$EXPORT_DIR/schema-azure.sql&quot; &gt; /dev/null
ok &quot;스키마 적용 완료&quot;

echo &quot;  → Azure SQL에 시드 데이터 적용...&quot;
sqlcmd -S &quot;$SQL_FQDN&quot; -U &quot;$SQL_ADMIN&quot; -P &quot;$SQL_ADMIN_PASSWORD&quot; -d &quot;$SQL_DB_NAME&quot; \
  -i &quot;$EXPORT_DIR/seed-azure.sql&quot; &gt; /dev/null 2&gt;&amp;1 || true
ok &quot;시드 데이터 적용&quot;
APPLY_SCHEMA

ok &quot;Azure SQL 스키마 + 시드 적용 완료&quot;

# =============================================================
# Step 5: Azure DMS를 통한 온라인 마이그레이션 (선택)
# =============================================================
step 5 &quot;Azure DMS 리소스 생성 (추가 마이그레이션 도구)&quot;

echo &quot;  ℹ️  소규모 DB는 Step 4의 직접 sqlcmd 방식으로 충분합니다.&quot;
echo &quot;  ℹ️  대규모·프로덕션에서는 Azure DMS를 사용합니다.&quot;
echo &quot;&quot;

read -p &quot;  DMS 리소스도 생성하시겠습니까? (y/N): &quot; create_dms
if [[ &quot;$create_dms&quot; =~ ^[Yy]$ ]]; then
  # DMS 확장 설치
  az extension add --name dms 2&gt;/dev/null || true

  echo &quot;  → DMS 인스턴스 생성 중 (5~10분)...&quot;
  az dms create \
    --resource-group &quot;$RG&quot; \
    --name &quot;$DMS_NAME&quot; \
    --location &quot;$LOC&quot; \
    --sku-name Standard_1vCores \
    --output none 2&gt;/dev/null || warn &quot;DMS 생성 실패 (수동 생성 필요할 수 있음)&quot;

  ok &quot;DMS &#39;$DMS_NAME&#39; 생성&quot;
  echo &quot;&quot;
  echo &quot;  DMS는 Azure Portal에서 마이그레이션 프로젝트를 생성하여 사용합니다.&quot;
  echo &quot;  Portal → Database Migration Service → 새 마이그레이션 프로젝트&quot;
  echo &quot;    소스: SQL Server ($PUBIP:1433)&quot;
  echo &quot;    대상: Azure SQL Database ($SQL_FQDN)&quot;
else
  ok &quot;DMS 생성 건너뜀 (sqlcmd 직접 방식 사용)&quot;
fi

# =============================================================
# Step 6: 마이그레이션 검증
# =============================================================
step 6 &quot;마이그레이션 검증&quot;

echo &quot;  → Azure SQL 데이터 검증...&quot;
VERIFY_RESULT=$(ssh &quot;$USER_NAME@$PUBIP&quot; bash -s &quot;$SQL_FQDN&quot; &quot;$SQL_ADMIN&quot; &quot;$SQL_ADMIN_PASSWORD&quot; &quot;$SQL_DB_NAME&quot; &lt;&lt; &#39;VERIFY&#39;
export PATH=&quot;$PATH:/opt/mssql-tools18/bin&quot;
SQL_FQDN=&quot;$1&quot;; SQL_ADMIN=&quot;$2&quot;; SQL_ADMIN_PASSWORD=&quot;$3&quot;; SQL_DB_NAME=&quot;$4&quot;

# 테이블 수
TABLE_COUNT=$(sqlcmd -S &quot;$SQL_FQDN&quot; -U &quot;$SQL_ADMIN&quot; -P &quot;$SQL_ADMIN_PASSWORD&quot; -d &quot;$SQL_DB_NAME&quot; \
  -Q &quot;SET NOCOUNT ON; SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_TYPE=&#39;BASE TABLE&#39;&quot; \
  -h -1 -W 2&gt;/dev/null | head -1 | tr -d &#39; \r&#39;)

# Users 수
USER_COUNT=$(sqlcmd -S &quot;$SQL_FQDN&quot; -U &quot;$SQL_ADMIN&quot; -P &quot;$SQL_ADMIN_PASSWORD&quot; -d &quot;$SQL_DB_NAME&quot; \
  -Q &quot;SET NOCOUNT ON; SELECT COUNT(*) FROM Users&quot; -h -1 -W 2&gt;/dev/null | head -1 | tr -d &#39; \r&#39;)

# Cars 수
CAR_COUNT=$(sqlcmd -S &quot;$SQL_FQDN&quot; -U &quot;$SQL_ADMIN&quot; -P &quot;$SQL_ADMIN_PASSWORD&quot; -d &quot;$SQL_DB_NAME&quot; \
  -Q &quot;SET NOCOUNT ON; SELECT COUNT(*) FROM Cars&quot; -h -1 -W 2&gt;/dev/null | head -1 | tr -d &#39; \r&#39;)

# 인덱스 수
IDX_COUNT=$(sqlcmd -S &quot;$SQL_FQDN&quot; -U &quot;$SQL_ADMIN&quot; -P &quot;$SQL_ADMIN_PASSWORD&quot; -d &quot;$SQL_DB_NAME&quot; \
  -Q &quot;SET NOCOUNT ON; SELECT COUNT(*) FROM sys.indexes WHERE name LIKE &#39;IX_%&#39;&quot; \
  -h -1 -W 2&gt;/dev/null | head -1 | tr -d &#39; \r&#39;)

echo &quot;$TABLE_COUNT $USER_COUNT $CAR_COUNT $IDX_COUNT&quot;
VERIFY
)

read T_CNT U_CNT C_CNT I_CNT &lt;&lt;&lt; &quot;$VERIFY_RESULT&quot;
ok &quot;Azure SQL 테이블: ${T_CNT}개&quot;
ok &quot;Azure SQL Users:  ${U_CNT}건&quot;
ok &quot;Azure SQL Cars:   ${C_CNT}건&quot;
ok &quot;Azure SQL 인덱스: ${I_CNT}개&quot;

# 소스와 비교
SOURCE_COUNTS=$(ssh &quot;$USER_NAME@$PUBIP&quot; &quot;cat ~/migration_export/source_counts.txt&quot; 2&gt;/dev/null || echo &quot;5 5&quot;)
read S_U S_C &lt;&lt;&lt; &quot;$SOURCE_COUNTS&quot;

if [ &quot;$U_CNT&quot; = &quot;$S_U&quot; ] &amp;&amp; [ &quot;$C_CNT&quot; = &quot;$S_C&quot; ]; then
  ok &quot;✅ 소스 ↔ 대상 데이터 일치 (Users: $S_U, Cars: $S_C)&quot;
else
  warn &quot;데이터 불일치: 소스(U:$S_U, C:$S_C) ↔ 대상(U:$U_CNT, C:$C_CNT)&quot;
fi

# =============================================================
# Step 7: 연결 정보 저장
# =============================================================
step 7 &quot;연결 정보 저장&quot;

# 환경변수 파일 업데이트
cat &gt;&gt; &quot;$HOME/.carmarket-env&quot; &lt;&lt;EOF

# Azure SQL Database
export SQL_SERVER_NAME=$SQL_SERVER_NAME
export SQL_FQDN=$SQL_FQDN
export SQL_DB_NAME=$SQL_DB_NAME
export SQL_ADMIN=$SQL_ADMIN
export SQL_ADMIN_PASSWORD=&#39;$SQL_ADMIN_PASSWORD&#39;
EOF
chmod 600 &quot;$HOME/.carmarket-env&quot;
ok &quot;~/.carmarket-env 업데이트&quot;

# =============================================================
# 완료 안내
# =============================================================
banner &quot;마이그레이션 완료!&quot;

cat &lt;&lt;EOF

┌─────────────────────────────────────────────────────────────┐
│  Azure SQL Database 마이그레이션 결과                        │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  소스 (VM SQL Server):                                      │
│    $PUBIP:1433 / sa                                         │
│                                                             │
│  대상 (Azure SQL Database):                                 │
│    서버: $SQL_FQDN                                          │
│    DB:   $SQL_DB_NAME                                       │
│    관리자: $SQL_ADMIN                                       │
│                                                             │
│  SSMS 연결 (Azure SQL):                                     │
│    서버: $SQL_FQDN                                          │
│    인증: SQL Server 인증                                     │
│    로그인: $SQL_ADMIN                                       │
│    암호: (설정한 SQL_ADMIN_PASSWORD)                         │
│                                                             │
│  검증: 테이블 ${T_CNT}개, Users ${U_CNT}건, Cars ${C_CNT}건   │
│                                                             │
│  다음 단계:                                                  │
│    bash 03-switch-app-to-azure-sql.sh                       │
│    (Flask 앱을 Azure SQL로 전환)                             │
│                                                             │
│  환경변수: source ~/.carmarket-env                           │
└─────────────────────────────────────────────────────────────┘

EOF
</code></pre>
<ol>
<li>Azure SQL Server (논리 서버) + Database (S0) 생성</li>
<li>방화벽 규칙 설정 (VM IP + 로컬 IP + Azure 서비스)</li>
<li>VM SQL Server에서 스키마·데이터를 SQL 스크립트로 추출</li>
<li>Azure SQL Database에 스키마·시드 데이터 적용</li>
<li>Azure DMS 리소스 생성 (선택)</li>
<li>소스 ↔ 대상 데이터 일치 검증</li>
</ol>
<p><img src="https://velog.velcdn.com/images/rudin_/post/845fbfee-b531-41b5-b94a-aea4ece017b6/image.png" alt=""></p>
<h3 id="검증">검증</h3>
<p>마이그레이션 검증 (SSMS에서)</p>
<pre><code>-- 테이블 구조 비교
SELECT TABLE_NAME,
(SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS c
WHERE c.TABLE_NAME = t.TABLE_NAME) AS ColumnCount
FROM INFORMATION_SCHEMA.TABLES t
WHERE TABLE_TYPE = &#39;BASE TABLE&#39;
ORDER BY TABLE_NAME;
-- 행 수 비교 (소스와 동일해야 함)
SELECT &#39;Users&#39; AS TableName, COUNT(*) AS [RowCount] FROM Users
UNION ALL
SELECT &#39;Cars&#39;, COUNT(*) FROM Cars
UNION ALL
SELECT &#39;Inquiries&#39;, COUNT(*) FROM Inquiries;
-- 데이터 내용 확인
SELECT c.Brand, c.Model, c.Year,
FORMAT(c.Price, &#39;N0&#39;) AS Price,
u.Name AS Seller
FROM Cars c
JOIN Users u ON c.SellerId = u.UserId
ORDER BY c.Price DESC;
-- Azure SQL 특유 정보 확인
SELECT
@@VERSION AS SQLVersion,
DB_NAME() AS DatabaseName,
DATABASEPROPERTYEX(DB_NAME(), &#39;Edition&#39;) AS Edition,
DATABASEPROPERTYEX(DB_NAME(), &#39;ServiceObjective&#39;) AS ServiceTier;</code></pre><p><img src="https://velog.velcdn.com/images/rudin_/post/09f7b91e-8207-4f51-9311-374e1946bad4/image.png" alt=""></p>
<h3 id="vm-sql-server-vs-azure-sql-database-비교">VM SQL Server vs Azure SQL Database 비교</h3>
<p><img src="https://velog.velcdn.com/images/rudin_/post/5db8dae0-7c4b-4e01-a5ab-9cb851a42496/image.png" alt=""></p>
<h2 id="flask-앱-연결-전환">Flask 앱 연결 전환</h2>
<pre><code class="language-shell">#!/usr/bin/env bash
# ============================================================
# 03-switch-app-to-azure-sql.sh
# Flask 앱 연결 대상을 VM SQL Server → Azure SQL Database로 전환
#
# 사전 조건:
#   - 01, 02 스크립트 완료
#   - source ~/.carmarket-env
#
# 사용법:
#   source ~/.carmarket-env
#   bash 03-switch-app-to-azure-sql.sh
# ============================================================

set -euo pipefail

# 환경변수
RG=&quot;${RG:-rg-carmarket-lab}&quot;
VM=&quot;${VM:-}&quot;
PUBIP=&quot;${PUBIP:-}&quot;
USER_NAME=&quot;${USER_NAME:-azureuser}&quot;
SQL_FQDN=&quot;${SQL_FQDN:-}&quot;
SQL_DB_NAME=&quot;${SQL_DB_NAME:-CarMarket}&quot;
SQL_ADMIN=&quot;${SQL_ADMIN:-sqladmin}&quot;
SQL_ADMIN_PASSWORD=&quot;${SQL_ADMIN_PASSWORD:-}&quot;

# 색상
G=&#39;\033[0;32m&#39;; Y=&#39;\033[0;33m&#39;; R=&#39;\033[0;31m&#39;; B=&#39;\033[0;34m&#39;; NC=&#39;\033[0m&#39;
banner() { echo &quot;&quot;; echo -e &quot;${B}═══════════════════════════════════════════════════${NC}&quot;; echo -e &quot;${B}  $1${NC}&quot;; echo -e &quot;${B}═══════════════════════════════════════════════════${NC}&quot;; }
ok()    { echo -e &quot;${G}  ✓${NC} $1&quot;; }
warn()  { echo -e &quot;${Y}  ⚠${NC} $1&quot;; }
abort() { echo -e &quot;${R}❌ $1${NC}&quot;; exit 1; }

banner &quot;Flask 앱 → Azure SQL Database 전환&quot;

# 검증
[ -z &quot;$SQL_FQDN&quot; ] &amp;&amp; abort &quot;SQL_FQDN 환경변수 없음. source ~/.carmarket-env&quot;
[ -z &quot;$PUBIP&quot; ] &amp;&amp; abort &quot;PUBIP 환경변수 없음. source ~/.carmarket-env&quot;
[ -z &quot;$SQL_ADMIN_PASSWORD&quot; ] &amp;&amp; { read -s -p &quot;Azure SQL Admin 비밀번호: &quot; SQL_ADMIN_PASSWORD; echo &quot;&quot;; }

echo &quot;&quot;
echo &quot;  현재:  localhost (VM SQL Server)&quot;
echo &quot;  전환:  $SQL_FQDN (Azure SQL Database)&quot;
echo &quot;&quot;
read -p &quot;  Flask 앱 연결 대상을 Azure SQL로 전환? (y/N): &quot; ok_proceed
[[ &quot;$ok_proceed&quot; =~ ^[Yy]$ ]] || abort &quot;취소됨&quot;

# =============================================================
# VM에서 .env 수정 + 재시작
# =============================================================
echo &quot;&quot;
echo &quot;  → .env 백업 + 수정...&quot;

ssh &quot;$USER_NAME@$PUBIP&quot; bash -s &quot;$SQL_FQDN&quot; &quot;$SQL_DB_NAME&quot; &quot;$SQL_ADMIN&quot; &quot;$SQL_ADMIN_PASSWORD&quot; &lt;&lt; &#39;SWITCH&#39;
#!/usr/bin/env bash
set -euo pipefail
SQL_FQDN=&quot;$1&quot;; SQL_DB_NAME=&quot;$2&quot;; SQL_ADMIN=&quot;$3&quot;; SQL_ADMIN_PASSWORD=&quot;$4&quot;
APP_DIR=&quot;$HOME/sqlvm_usedcar/app&quot;

G=&#39;\033[0;32m&#39;; NC=&#39;\033[0m&#39;
ok() { echo -e &quot;${G}  ✓${NC} $1&quot;; }

# 백업
cp &quot;$APP_DIR/.env&quot; &quot;$APP_DIR/.env.vm-backup&quot;
ok &quot;기존 .env 백업 → .env.vm-backup&quot;

# Azure SQL 용 .env 생성
cat &gt; &quot;$APP_DIR/.env&quot; &lt;&lt;EOF
# Azure SQL Database 연결
SA_PASSWORD=$SQL_ADMIN_PASSWORD
DB_SERVER=$SQL_FQDN
DB_NAME=$SQL_DB_NAME
DB_USER=$SQL_ADMIN
FLASK_PORT=5000
EOF
chmod 600 &quot;$APP_DIR/.env&quot;
ok &quot;.env 업데이트 → $SQL_FQDN&quot;

# app.py에서 DB_USER 환경변수 지원하도록 패치 (필요시)
if ! grep -q &#39;DB_USER&#39; &quot;$APP_DIR/app.py&quot;; then
  # DB_USER = &quot;sa&quot; → 환경변수에서 읽도록 변경
  sed -i &#39;s/DB_USER = &quot;sa&quot;/DB_USER = os.environ.get(&quot;DB_USER&quot;, &quot;sa&quot;)/&#39; &quot;$APP_DIR/app.py&quot;
  ok &quot;app.py: DB_USER 환경변수 지원 패치&quot;
fi

# 재시작
sudo systemctl restart carmarket
sleep 3

if sudo systemctl is-active --quiet carmarket; then
  ok &quot;carmarket.service 재시작 완료&quot;
else
  echo &quot;❌ 서비스 재시작 실패&quot;
  sudo journalctl -u carmarket -n 20 --no-pager
  exit 1
fi

# 헬스체크
HEALTH=$(curl -s --max-time 10 http://localhost:5000/health || echo &#39;{}&#39;)
if echo &quot;$HEALTH&quot; | grep -q &#39;&quot;status&quot;:&quot;ok&quot;&#39;; then
  ok &quot;Health OK (Azure SQL 연결): $HEALTH&quot;
else
  echo &quot;⚠ Health 실패: $HEALTH&quot;
  echo &quot;  → VM SQL Server로 롤백하려면:&quot;
  echo &quot;     cp $APP_DIR/.env.vm-backup $APP_DIR/.env&quot;
  echo &quot;     sudo systemctl restart carmarket&quot;
fi
SWITCH

# 외부 검증
echo &quot;&quot;
echo &quot;  → 외부 헬스체크...&quot;
sleep 2
HEALTH=$(curl -s --max-time 10 &quot;http://$PUBIP:5000/health&quot; || echo &#39;{}&#39;)
if echo &quot;$HEALTH&quot; | grep -q &#39;&quot;status&quot;:&quot;ok&quot;&#39;; then
  ok &quot;외부에서 Azure SQL 통해 앱 정상 작동 확인&quot;
else
  warn &quot;외부 헬스체크 실패: $HEALTH&quot;
fi

# API 검증
echo &quot;  → API 테스트 (차량 목록)...&quot;
CARS=$(curl -s --max-time 10 &quot;http://$PUBIP:5000/api/cars&quot; || echo &#39;[]&#39;)
CAR_COUNT=$(echo &quot;$CARS&quot; | python3 -c &quot;import sys,json; print(len(json.load(sys.stdin)))&quot; 2&gt;/dev/null || echo &quot;0&quot;)
ok &quot;API 응답: Cars ${CAR_COUNT}건&quot;

banner &quot;전환 완료!&quot;

cat &lt;&lt;EOF

┌─────────────────────────────────────────────────────────────┐
│  Flask 앱이 Azure SQL Database를 사용 중입니다               │
│                                                             │
│  웹 앱:  http://$PUBIP:5000/                                │
│  DB:     $SQL_FQDN / $SQL_DB_NAME                           │
│                                                             │
│  롤백 방법 (VM SQL Server로 복귀):                           │
│    ssh $USER_NAME@$PUBIP                                    │
│    cp ~/sqlvm_usedcar/app/.env.vm-backup ~/sqlvm_usedcar/app/.env │
│    sudo systemctl restart carmarket                         │
└─────────────────────────────────────────────────────────────┘

EOF
</code></pre>
<ol>
<li>기존 .env 백업 ( .env.vm-backup )</li>
<li>DB 연결 대상을 Azure SQL Database로 변경</li>
<li>app.py 에 DB_USER 환경변수 지원 패치</li>
<li>carmarket 서비스 재시작</li>
<li>헬스체크 + API 테스트</li>
</ol>
<p>이후 ssh로 vm 접속</p>
<pre><code>cd ~/sqlvm_usedcar/app
# 1. 현재 DB_USER 확인
grep &#39;DB_USER&#39; app.py
# 2. app.py 수정 (DB_USER를 환경변수에서 읽도록)
sed -i &#39;s/DB_USER = &quot;sa&quot;/DB_USER = os.environ.get(&quot;DB_USER&quot;, &quot;sa&quot;)/&#39; app.py
# 3. .env에 DB_USER가 있는지 확인
cat .env
# 4. DB_USER가 없으면 추가
grep -q &#39;DB_USER&#39; .env || echo &#39;DB_USER=sqladmin&#39; &gt;&gt; .env
# 5. 서비스 재시작
sudo systemctl restart carmarket
# 6. 확인
curl -s http://localhost:5000/health</code></pre><h3 id="전환-후-검증">전환 후 검증</h3>
<pre><code># 헬스체크 — db: connected 확인
curl http://$PUBIP:5000/health
# 차량 목록 — Azure SQL에서 조회
curl http://$PUBIP:5000/api/cars
# 매물 등록 테스트 — Azure SQL에 INSERT
curl -X POST http://$PUBIP:5000/api/cars \
-H &quot;Content-Type: application/json&quot; \
-d &#39;{&quot;seller_id&quot;:1,&quot;brand&quot;:&quot;기아&quot;,&quot;model&quot;:&quot;카니발&quot;,&quot;year&quot;:2023,&quot;price&quot;:38000000,&quot;mileage&quot;:10000}&#39;</code></pre><p><img src="https://velog.velcdn.com/images/rudin_/post/830ba0fb-224b-453b-9918-4e21d57e3c19/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/7acafcab-7e58-407b-9329-a38d22ac23ba/image.png" alt=""></p>
<h2 id="롤백-vm-sql-server로-복귀">롤백 (VM SQL Server로 복귀)</h2>
<pre><code>ssh azureuser@$PUBIP
cp ~/sqlvm_usedcar/app/.env.vm-backup ~/sqlvm_usedcar/app/.env
sudo systemctl restart carmarket</code></pre><h2 id="리소스-정리">리소스 정리</h2>
<pre><code>source ~/.carmarket-env
# 방법 1: VM만 중지 (데이터 유지, 비용 중단)
bash scripts/99-cleanup.sh
# 방법 2: 전체 삭제 (되돌릴 수 없음)
bash scripts/99-cleanup.sh --delete</code></pre><hr>

<h1 id="sql-server-on-azure-virtual-machines">SQL Server on Azure Virtual Machines</h1>
<p><img src="https://velog.velcdn.com/images/rudin_/post/cf9c4921-3576-4bf9-a083-50fee5993f36/image.png" alt="">
<img src="https://velog.velcdn.com/images/rudin_/post/b1b352b3-c936-47d0-9d45-832f62239912/image.png" alt="">
<img src="https://velog.velcdn.com/images/rudin_/post/ef486cf5-a3b2-44d0-bba5-8b49e0d19aeb/image.png" alt="">
<img src="https://velog.velcdn.com/images/rudin_/post/7ec1b03c-4dc8-43dc-9006-7596bd46bdb0/image.png" alt=""></p>
<h2 id="cidr">CIDR</h2>
<p>사이더
CIDR(Classless Inter-Domain Routing, 클래스 없는 도메인 간 라우팅)은 1993년 도입된 IP 주소 할당 및 라우팅 효율화 방식입니다. 고정된 클래스 기반 체계(A, B, C)를 대체하여 IP 주소 낭비를 줄이고, 접두어(Prefix)를 사용하여 유연하게 네트워크 영역을 나누어 라우팅 테이블 크기를 줄인다.</p>
<h1 id="sql-server-인덱스-조각화-문제-감지-및-수정-실습">SQL Server 인덱스 조각화 문제 감지 및 수정 실습</h1>
<h2 id="실습-개요">실습 개요</h2>
<p>이 실습은 SQL Server에서 <strong>인덱스 조각화(Index Fragmentation)</strong> 문제를 감지하고, 조각난 인덱스를 다시 작성하여 쿼리 성능 변화를 확인하는 과정이다.</p>
<p>AdventureWorks 데이터베이스를 복원한 뒤, <code>Person.Address</code> 테이블에 데이터를 추가하여 인덱스 조각화를 인위적으로 발생시킨다. 이후 DMV를 사용해 조각화 수준을 확인하고, <code>ALTER INDEX ... REBUILD</code>로 인덱스를 다시 작성한다. 마지막으로 <code>SET STATISTICS IO, TIME ON</code>을 사용해 논리적 읽기 수가 줄어드는지 비교한다.</p>
<hr>
<h2 id="실습-배경">실습 배경</h2>
<p>AdventureWorks는 10년 넘게 자전거와 자전거 부품을 소비자와 유통업체에 직접 판매해 온 회사이다. 최근 고객 요청을 처리하는 데 사용되는 제품의 성능 저하가 발견되었다.</p>
<p>데이터베이스 관리자는 SQL 도구를 사용하여 성능 문제를 식별하고, 발견된 문제를 해결할 수 있는 실행 가능한 솔루션을 제공해야 한다.</p>
<p>이 실습에서는 다음을 수행한다.</p>
<ul>
<li>AdventureWorks2017 데이터베이스 복원</li>
<li>인덱스 조각화 상태 확인</li>
<li>대량 데이터 삽입으로 조각화 유발</li>
<li>조각화된 인덱스 확인</li>
<li>논리적 읽기 수 측정</li>
<li>인덱스 다시 작성</li>
<li>조각화 감소 및 논리적 읽기 감소 확인</li>
</ul>
<hr>
<h2 id="참고-ssms에서-라인-번호-표시하기">참고: SSMS에서 라인 번호 표시하기</h2>
<p>T-SQL 코드를 복사하여 실행할 때 디버깅을 쉽게 하기 위해 SSMS 편집기에 라인 번호를 표시할 수 있다.</p>
<p>설정 경로:</p>
<pre><code class="language-text">Tools → Options → Text Editor → Transact-SQL → General → Line numbers 체크</code></pre>
<hr>
<h1 id="데이터베이스-복원">데이터베이스 복원</h1>
<h2 id="1-adventureworks2017-백업-파일-다운로드">1. AdventureWorks2017 백업 파일 다운로드</h2>
<p>랩 가상 머신에서 아래 경로의 데이터베이스 백업 파일을 다운로드한다.</p>
<pre><code class="language-text">https://github.com/MicrosoftLearning/dp-300-database-administrator/blob/master/Instructions/Templates/AdventureWorks2017.bak</code></pre>
<p>다운로드한 파일은 아래 폴더에 저장한다.</p>
<pre><code class="language-text">C:\LabFiles\Monitor and optimize</code></pre>
<p>해당 폴더가 없다면 직접 생성한다.</p>
<hr>
<h2 id="2-ssms-실행">2. SSMS 실행</h2>
<p>Windows 시작 버튼을 선택하고 <code>SSMS</code>를 입력한다.</p>
<p>목록에서 <strong>Microsoft SQL Server Management Studio 18</strong>을 선택한다.</p>
<hr>
<h2 id="3-sql-server-연결">3. SQL Server 연결</h2>
<p>SSMS가 열리면 <strong>Connect to Server</strong> 대화 상자가 표시된다.</p>
<p>기본 인스턴스 이름이 미리 채워져 있으면 그대로 <strong>Connect</strong>를 선택한다.</p>
<p>서버가 보이지 않는 경우에는 다음을 선택해 서버를 찾을 수 있다.</p>
<pre><code class="language-text">&lt;Browse for more&gt;</code></pre>
<hr>
<h2 id="4-new-query-선택">4. New Query 선택</h2>
<p>Object Explorer에서 <strong>Databases</strong> 폴더를 선택한 뒤, 상단의 <strong>New Query</strong>를 선택한다.</p>
<hr>
<h2 id="5-데이터베이스-복원-쿼리-실행">5. 데이터베이스 복원 쿼리 실행</h2>
<p>New Query 창에 아래 T-SQL을 복사하여 붙여넣고 실행한다.</p>
<pre><code class="language-sql">RESTORE DATABASE AdventureWorks2017
FROM DISK = &#39;C:\LabFiles\Monitor and optimize\AdventureWorks2017.bak&#39;
WITH RECOVERY,
 MOVE &#39;AdventureWorks2017&#39;
 TO &#39;C:\LabFiles\Monitor and optimize\AdventureWorks2017.mdf&#39;,
 MOVE &#39;AdventureWorks2017_log&#39;
 TO &#39;C:\LabFiles\Monitor and optimize\AdventureWorks2017_log.ldf&#39;;</code></pre>
<blockquote>
<p>백업 파일 이름과 경로는 실제 다운로드한 파일 위치와 일치해야 한다. 경로가 다르면 복원 명령이 실패한다.</p>
</blockquote>
<hr>
<h2 id="6-복원-성공-확인">6. 복원 성공 확인</h2>
<p>복원이 완료되면 메시지 창에 성공 메시지가 표시된다.</p>
<p>예시:</p>
<pre><code class="language-text">RESTORE DATABASE successfully processed ... pages ...</code></pre>
<hr>
<h1 id="인덱스-조각화-조사">인덱스 조각화 조사</h1>
<h2 id="1-현재-조각화-상태-확인">1. 현재 조각화 상태 확인</h2>
<p>New Query를 선택한 뒤 아래 T-SQL 코드를 실행한다.</p>
<pre><code class="language-sql">USE AdventureWorks2017
GO
SELECT i.name Index_Name
, avg_fragmentation_in_percent
, db_name(database_id)
, i.object_id
, i.index_id
, index_type_desc
FROM
sys.dm_db_index_physical_stats(db_id(&#39;AdventureWorks2017&#39;),object_id(&#39;person.address&#39;),NULL,NULL,&#39;DETAILED&#39;) ps
INNER JOIN sys.indexes i ON ps.object_id = i.object_id 
AND ps.index_id = i.index_id
WHERE avg_fragmentation_in_percent &gt; 50
-- find indexes where fragmentation is greater than 50%</code></pre>
<p>이 쿼리는 <code>Person.Address</code> 테이블에서 조각화가 50%를 초과하는 인덱스를 조회한다.</p>
<p>처음 실행하면 반환되는 결과가 없다. 즉, 현재는 50%를 초과하는 조각화된 인덱스가 없는 상태이다.</p>
<hr>
<h2 id="2-데이터-삽입으로-조각화-유발">2. 데이터 삽입으로 조각화 유발</h2>
<p>다음 T-SQL을 실행하여 <code>Person.Address</code> 테이블에 많은 수의 새 레코드를 삽입한다.</p>
<pre><code class="language-sql">USE AdventureWorks2017
GO

INSERT INTO [Person].[Address]
 ([AddressLine1]
 ,[AddressLine2]
 ,[City]
 ,[StateProvinceID]
 ,[PostalCode]
 ,[SpatialLocation]
 ,[rowguid]
 ,[ModifiedDate])

SELECT AddressLine1,
 AddressLine2, 
 &#39;Amsterdam&#39;,
 StateProvinceID, 
 PostalCode, 
 SpatialLocation, 
 newid(), 
 getdate()
FROM Person.Address;
GO</code></pre>
<p>이 쿼리는 기존 <code>Person.Address</code> 데이터를 다시 읽어 같은 테이블에 추가 삽입한다.</p>
<p>특히 <code>City</code> 값을 <code>&#39;Amsterdam&#39;</code>으로 고정하여 삽입한다. 결과적으로 행 개수가 약 2배로 늘어나고, <code>Person.Address</code> 테이블과 관련 인덱스의 조각화 수준이 증가한다.</p>
<hr>
<h2 id="3-조각화-상태-다시-확인">3. 조각화 상태 다시 확인</h2>
<p>처음 실행했던 조각화 확인 쿼리를 다시 실행한다.</p>
<pre><code class="language-sql">USE AdventureWorks2017
GO
SELECT i.name Index_Name
, avg_fragmentation_in_percent
, db_name(database_id)
, i.object_id
, i.index_id
, index_type_desc
FROM
sys.dm_db_index_physical_stats(db_id(&#39;AdventureWorks2017&#39;),object_id(&#39;person.address&#39;),NULL,NULL,&#39;DETAILED&#39;) ps
INNER JOIN sys.indexes i ON ps.object_id = i.object_id 
AND ps.index_id = i.index_id
WHERE avg_fragmentation_in_percent &gt; 50
-- find indexes where fragmentation is greater than 50%</code></pre>
<p>이제 고도로 조각난 인덱스 4개를 확인할 수 있다.</p>
<p>예시 결과에서는 다음과 같은 인덱스들이 50% 이상의 조각화를 보인다.</p>
<table>
<thead>
<tr>
<th>Index_Name</th>
<th align="right">avg_fragmentation_in_percent</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>AK_Address_rowguid</td>
<td align="right">약 98%</td>
<td>rowguid 관련 인덱스</td>
</tr>
<tr>
<td>IX_Address_AddressLine1_AddressLine2_City_StateProvinceID_PostalCode</td>
<td align="right">약 98%</td>
<td>주소 검색 관련 인덱스</td>
</tr>
<tr>
<td>IX_Address_AddressLine1_AddressLine2_City_StateProvinceID_PostalCode</td>
<td align="right">약 90%</td>
<td>주소 검색 관련 인덱스</td>
</tr>
<tr>
<td>IX_Address_StateProvinceID</td>
<td align="right">약 80~81%</td>
<td>StateProvinceID 관련 인덱스</td>
</tr>
</tbody></table>
<hr>
<h1 id="논리적-읽기-수-측정">논리적 읽기 수 측정</h1>
<h2 id="1-statistics-io-time-활성화-후-쿼리-실행">1. STATISTICS IO, TIME 활성화 후 쿼리 실행</h2>
<p>다음 쿼리를 실행한다.</p>
<pre><code class="language-sql">SET STATISTICS IO,TIME ON
GO

USE AdventureWorks2017
GO

SELECT DISTINCT (StateProvinceID)
 ,count(StateProvinceID) AS CustomerCount
FROM person.Address
GROUP BY StateProvinceID
ORDER BY count(StateProvinceID) DESC;
GO</code></pre>
<p>이 쿼리는 <code>Person.Address</code> 테이블에서 <code>StateProvinceID</code>별 건수를 집계하고, 건수가 많은 순서대로 정렬한다.</p>
<hr>
<h2 id="2-messages-탭에서-logical-reads-확인">2. Messages 탭에서 logical reads 확인</h2>
<p>SQL Server Management Studio의 결과 창에서 <strong>Messages</strong> 탭을 클릭한다.</p>
<p>여기에서 쿼리에 의해 수행된 논리적 읽기 수를 확인한다.</p>
<p>실습 자료 기준으로 조각화된 상태에서의 논리적 읽기 수는 다음과 같다.</p>
<pre><code class="language-text">logical reads = 94</code></pre>
<p>논리적 읽기(logical reads)는 SQL Server가 버퍼 캐시에서 읽은 데이터 페이지 수를 의미한다.</p>
<p>조각화가 심하면 쿼리가 필요한 데이터를 찾기 위해 더 많은 페이지를 읽게 되고, 이로 인해 성능 저하가 발생할 수 있다.</p>
<hr>
<h1 id="조각난-인덱스-다시-작성">조각난 인덱스 다시 작성</h1>
<h2 id="1-ix_address_stateprovinceid-인덱스-rebuild">1. IX_Address_StateProvinceID 인덱스 REBUILD</h2>
<p>다음 T-SQL을 실행하여 <code>IX_Address_StateProvinceID</code> 인덱스를 다시 작성한다.</p>
<pre><code class="language-sql">USE AdventureWorks2017
GO
ALTER INDEX [IX_Address_StateProvinceID] ON [Person].[Address] REBUILD PARTITION = ALL
WITH (PAD_INDEX = OFF, 
 STATISTICS_NORECOMPUTE = OFF, 
 SORT_IN_TEMPDB = OFF, 
 IGNORE_DUP_KEY = OFF, 
 ONLINE = OFF, 
 ALLOW_ROW_LOCKS = ON, 
 ALLOW_PAGE_LOCKS = ON)</code></pre>
<p><code>ALTER INDEX ... REBUILD</code>는 인덱스를 새로 다시 만드는 작업이다.</p>
<p>이를 통해 인덱스 페이지가 정리되고, 논리적 순서와 물리적 순서가 더 잘 맞춰지며, 페이지 내부의 빈 공간도 정리된다.</p>
<hr>
<h2 id="2-인덱스-조각화-감소-확인">2. 인덱스 조각화 감소 확인</h2>
<p>아래 쿼리를 실행하여 <code>IX_Address_StateProvinceID</code> 인덱스의 조각화가 더 이상 50%를 초과하지 않는지 확인한다.</p>
<pre><code class="language-sql">USE AdventureWorks2017
GO

SELECT DISTINCT i.name Index_Name
 , avg_fragmentation_in_percent
 , db_name(database_id)
 , i.object_id
 , i.index_id
 , index_type_desc
FROM
sys.dm_db_index_physical_stats(db_id(&#39;AdventureWorks2017&#39;),object_id(&#39;person.address&#39;),NULL,NULL,&#39;DETAILED&#39;) ps
 INNER JOIN sys.indexes i ON (ps.object_id = i.object_id AND ps.index_id = i.index_id)
WHERE i.name = &#39;IX_Address_StateProvinceID&#39;</code></pre>
<p>결과를 비교하면 <code>IX_Address_StateProvinceID</code> 인덱스의 조각화가 약 81%에서 0%로 감소한 것을 확인할 수 있다.</p>
<table>
<thead>
<tr>
<th>항목</th>
<th align="right">REBUILD 전</th>
<th align="right">REBUILD 후</th>
</tr>
</thead>
<tbody><tr>
<td>IX_Address_StateProvinceID 조각화율</td>
<td align="right">약 81%</td>
<td align="right">0%</td>
</tr>
</tbody></table>
<hr>
<h1 id="인덱스-재작성-후-논리적-읽기-비교">인덱스 재작성 후 논리적 읽기 비교</h1>
<h2 id="1-동일한-select-쿼리-재실행">1. 동일한 SELECT 쿼리 재실행</h2>
<p>이전 섹션에서 실행했던 집계 쿼리를 다시 실행한다.</p>
<pre><code class="language-sql">SET STATISTICS IO,TIME ON
GO

USE AdventureWorks2017
GO

SELECT DISTINCT (StateProvinceID)
 ,count(StateProvinceID) AS CustomerCount
FROM person.Address
GROUP BY StateProvinceID
ORDER BY count(StateProvinceID) DESC;

GO</code></pre>
<hr>
<h2 id="2-messages-탭에서-logical-reads-재확인">2. Messages 탭에서 logical reads 재확인</h2>
<p>인덱스를 다시 작성했기 때문에 이전보다 효율적으로 데이터를 읽을 수 있다.</p>
<p>실습 자료 기준으로 인덱스 재작성 후 논리적 읽기는 다음과 같이 감소한다.</p>
<pre><code class="language-text">logical reads = 70</code></pre>
<table>
<thead>
<tr>
<th>상태</th>
<th align="right">logical reads</th>
</tr>
</thead>
<tbody><tr>
<td>인덱스 REBUILD 전</td>
<td align="right">94</td>
</tr>
<tr>
<td>인덱스 REBUILD 후</td>
<td align="right">70</td>
</tr>
</tbody></table>
<p>즉, 인덱스 유지 관리가 쿼리 성능에 영향을 줄 수 있다는 것을 확인할 수 있다.</p>
<hr>
<h1 id="실습-결과-정리">실습 결과 정리</h1>
<p>이번 실습에서는 인덱스를 다시 작성하고 논리적 읽기를 분석하여 쿼리 성능을 높이는 방법을 확인했다.</p>
<hr>
<h2 id="1-인덱스-조각화의-이해-및-영향-확인">1. 인덱스 조각화의 이해 및 영향 확인</h2>
<p>인덱스 조각화(Index Fragmentation)는 데이터베이스에서 데이터가 삽입, 업데이트, 삭제되는 과정에서 발생한다.</p>
<p>인덱스의 논리적 순서와 실제 디스크상의 물리적 순서가 달라지거나, 데이터 페이지 내부에 빈 공간이 생기는 현상을 말한다.</p>
<p>이 실습에서는 대량의 데이터를 삽입하여 인덱스 조각화를 인위적으로 발생시켰다. 이를 통해 조각화가 실제로 어떻게 발생하는지 간접적으로 경험할 수 있었다.</p>
<p>가장 중요한 점은 조각화가 심해지면 SQL Server가 데이터를 읽을 때 더 많은 페이지를 읽어야 한다는 것이다.</p>
<p>그래서 이 실습에서는 <code>SET STATISTICS IO ON</code>을 사용해 논리적 읽기(Logical Reads) 횟수를 측정했다. 조각화된 인덱스를 사용하는 쿼리는 불필요하게 많은 페이지를 읽게 되어 성능 저하를 유발할 수 있다.</p>
<hr>
<h2 id="2-조각화-진단-방법-학습">2. 조각화 진단 방법 학습</h2>
<p>조각화 상태는 <code>sys.dm_db_index_physical_stats</code> 동적 관리 뷰(DMV)를 사용해 확인한다.</p>
<p>이 DMV를 통해 특정 테이블이나 특정 인덱스의 조각화 수준을 퍼센트로 확인할 수 있다.</p>
<p>DBA가 시스템 상태를 진단할 때 사용하는 핵심 도구 중 하나이다.</p>
<hr>
<h2 id="3-조각화-해결-방법-학습-및-효과-검증">3. 조각화 해결 방법 학습 및 효과 검증</h2>
<p>심하게 조각화된 인덱스는 <code>ALTER INDEX REBUILD</code> 명령어를 사용하여 다시 작성할 수 있다.</p>
<p>인덱스 재구축은 인덱스 페이지를 새로 만들고, 물리적 순서를 논리적 순서에 가깝게 정리하며, 페이지 내부의 빈 공간을 제거한다.</p>
<p>참고로 <code>ALTER INDEX REORGANIZE</code>는 온라인으로 조각화를 일부 정리하는 다른 방법이다.</p>
<p>실습에서는 인덱스를 재구축한 후 동일한 쿼리를 다시 실행했다. 그 결과 논리적 읽기 수가 감소하는 것을 확인했다.</p>
<p>이는 조각화 해결이 실제 쿼리 성능 향상으로 이어질 수 있음을 보여준다.</p>
<hr>
<h2 id="4-데이터베이스-유지-관리의-중요성">4. 데이터베이스 유지 관리의 중요성</h2>
<p>인덱스 조각화는 시간이 지나면서 자연스럽게 발생한다.</p>
<p>따라서 데이터베이스 성능을 최적으로 유지하려면 정기적인 인덱스 유지 관리가 필요하다.</p>
<p>DBA는 주기적으로 조각화 수준을 모니터링하고, 필요에 따라 인덱스를 재구성하거나 다시 작성해야 한다.</p>
<hr>
<h1 id="핵심-요약">핵심 요약</h1>
<table>
<thead>
<tr>
<th>구분</th>
<th>내용</th>
</tr>
</thead>
<tbody><tr>
<td>문제</td>
<td>인덱스 조각화로 인해 불필요한 페이지 읽기 증가</td>
</tr>
<tr>
<td>진단 도구</td>
<td><code>sys.dm_db_index_physical_stats</code></td>
</tr>
<tr>
<td>측정 지표</td>
<td><code>avg_fragmentation_in_percent</code>, <code>logical reads</code></td>
</tr>
<tr>
<td>조각화 유발</td>
<td><code>Person.Address</code>에 대량 INSERT</td>
</tr>
<tr>
<td>해결 방법</td>
<td><code>ALTER INDEX ... REBUILD</code></td>
</tr>
<tr>
<td>효과</td>
<td>조각화율 약 81% → 0%, logical reads 94 → 70</td>
</tr>
<tr>
<td>의미</td>
<td>인덱스 유지 관리가 쿼리 성능에 영향을 미침</td>
</tr>
</tbody></table>
<hr>
<h1 id="사용한-주요-t-sql-모음">사용한 주요 T-SQL 모음</h1>
<h2 id="데이터베이스-복원-1">데이터베이스 복원</h2>
<pre><code class="language-sql">RESTORE DATABASE AdventureWorks2017
FROM DISK = &#39;C:\LabFiles\Monitor and optimize\AdventureWorks2017.bak&#39;
WITH RECOVERY,
 MOVE &#39;AdventureWorks2017&#39;
 TO &#39;C:\LabFiles\Monitor and optimize\AdventureWorks2017.mdf&#39;,
 MOVE &#39;AdventureWorks2017_log&#39;
 TO &#39;C:\LabFiles\Monitor and optimize\AdventureWorks2017_log.ldf&#39;;</code></pre>
<h2 id="조각화-확인">조각화 확인</h2>
<pre><code class="language-sql">USE AdventureWorks2017
GO
SELECT i.name Index_Name
, avg_fragmentation_in_percent
, db_name(database_id)
, i.object_id
, i.index_id
, index_type_desc
FROM
sys.dm_db_index_physical_stats(db_id(&#39;AdventureWorks2017&#39;),object_id(&#39;person.address&#39;),NULL,NULL,&#39;DETAILED&#39;) ps
INNER JOIN sys.indexes i ON ps.object_id = i.object_id 
AND ps.index_id = i.index_id
WHERE avg_fragmentation_in_percent &gt; 50
-- find indexes where fragmentation is greater than 50%</code></pre>
<h2 id="데이터-삽입으로-조각화-유발">데이터 삽입으로 조각화 유발</h2>
<pre><code class="language-sql">USE AdventureWorks2017
GO

INSERT INTO [Person].[Address]
 ([AddressLine1]
 ,[AddressLine2]
 ,[City]
 ,[StateProvinceID]
 ,[PostalCode]
 ,[SpatialLocation]
 ,[rowguid]
 ,[ModifiedDate])

SELECT AddressLine1,
 AddressLine2, 
 &#39;Amsterdam&#39;,
 StateProvinceID, 
 PostalCode, 
 SpatialLocation, 
 newid(), 
 getdate()
FROM Person.Address;
GO</code></pre>
<h2 id="논리적-읽기-측정">논리적 읽기 측정</h2>
<pre><code class="language-sql">SET STATISTICS IO,TIME ON
GO

USE AdventureWorks2017
GO

SELECT DISTINCT (StateProvinceID)
 ,count(StateProvinceID) AS CustomerCount
FROM person.Address
GROUP BY StateProvinceID
ORDER BY count(StateProvinceID) DESC;
GO</code></pre>
<h2 id="인덱스-다시-작성">인덱스 다시 작성</h2>
<pre><code class="language-sql">USE AdventureWorks2017
GO
ALTER INDEX [IX_Address_StateProvinceID] ON [Person].[Address] REBUILD PARTITION = ALL
WITH (PAD_INDEX = OFF, 
 STATISTICS_NORECOMPUTE = OFF, 
 SORT_IN_TEMPDB = OFF, 
 IGNORE_DUP_KEY = OFF, 
 ONLINE = OFF, 
 ALLOW_ROW_LOCKS = ON, 
 ALLOW_PAGE_LOCKS = ON)</code></pre>
<h2 id="특정-인덱스-조각화-확인">특정 인덱스 조각화 확인</h2>
<pre><code class="language-sql">USE AdventureWorks2017
GO

SELECT DISTINCT i.name Index_Name
 , avg_fragmentation_in_percent
 , db_name(database_id)
 , i.object_id
 , i.index_id
 , index_type_desc
FROM
sys.dm_db_index_physical_stats(db_id(&#39;AdventureWorks2017&#39;),object_id(&#39;person.address&#39;),NULL,NULL,&#39;DETAILED&#39;) ps
 INNER JOIN sys.indexes i ON (ps.object_id = i.object_id AND ps.index_id = i.index_id)
WHERE i.name = &#39;IX_Address_StateProvinceID&#39;</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[[MicrosoftDataSchool] 80일차 - Azure VM, SQL Server 실습]]></title>
            <link>https://velog.io/@rudin_/MicrosoftDataSchool-80%EC%9D%BC%EC%B0%A8-Azure-VM-SQL-Server-%EC%8B%A4%EC%8A%B5</link>
            <guid>https://velog.io/@rudin_/MicrosoftDataSchool-80%EC%9D%BC%EC%B0%A8-Azure-VM-SQL-Server-%EC%8B%A4%EC%8A%B5</guid>
            <pubDate>Thu, 30 Apr 2026 03:42:02 GMT</pubDate>
            <description><![CDATA[<h1 id="중고차-커뮤니티-mvp-실습">중고차 커뮤니티 MVP 실습</h1>
<h1 id="vm-생성">VM 생성</h1>
<p><img src="https://velog.velcdn.com/images/rudin_/post/ed41a451-6a54-4783-88f3-263a7bd49b86/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/749f92bc-2e02-4310-9543-64da13747f92/image.png" alt=""></p>
<h2 id="디스크">디스크</h2>
<p><img src="https://velog.velcdn.com/images/rudin_/post/ca1eb900-4d0c-4d2e-98ff-e30d3f91dcdb/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/b0d56c9d-f7bd-4c47-bf76-cdc3cf5023e3/image.png" alt=""></p>
<p>여기서는 안쓰지만, 디스크를 사용하여 애플리케이션 등 저장이 가능하다.</p>
<h2 id="자동-종료">자동 종료</h2>
<p>관리탭
<img src="https://velog.velcdn.com/images/rudin_/post/c38a00b4-55c2-4037-afdf-7e21bead1ca3/image.png" alt=""></p>
<p>이후 검토 + 만들기 하고 <code>.pem</code> 확장자의 ssh 키를 다운받는다.</p>
<h1 id="vm-연결">VM 연결</h1>
<p><img src="https://velog.velcdn.com/images/rudin_/post/1788c521-16c6-40ed-ade7-e3f38772e080/image.png" alt="">
<img src="https://velog.velcdn.com/images/rudin_/post/9c8f232b-deb2-4fdc-9915-cb2db6e32cd3/image.png" alt="">
<img src="https://velog.velcdn.com/images/rudin_/post/09dd1cb1-0e09-4c11-a9cc-69017d4fb1c9/image.png" alt="">
SSH 명령 탭의 경로 입력하기</p>
<h2 id="powershell-연결">powershell 연결</h2>
<p><img src="https://velog.velcdn.com/images/rudin_/post/080512b5-ca02-409f-80dc-6a5c6f511481/image.png" alt=""></p>
<pre><code>ssh -i &quot;키경로&quot; azureuser@ip주소</code></pre><p><img src="https://velog.velcdn.com/images/rudin_/post/502c2974-6535-47e3-8b4b-2707e433cb16/image.png" alt=""></p>
<h2 id="os-정보-확인">OS 정보 확인</h2>
<p><img src="https://velog.velcdn.com/images/rudin_/post/483c2695-79e3-46f1-9f27-974641088a3b/image.png" alt=""></p>
<h2 id="패키지-업데이트">패키지 업데이트</h2>
<pre><code>sudo apt update
sudo apt upgrade -y
sudo apt install -y curl wget gnupg2 software-properties-common apt-transport-https ca-certificates</code></pre><h2 id="swap-파일-생성sql-server-안정성">Swap 파일 생성(SQL Server 안정성)</h2>
<p>B2s의 4GB RAM은 SQL Server + Python + OS 동시 구동에 빠듯합니다. 2GB swap을 추가하여 OOM Killer 발동을 예방합니다.</p>
<pre><code>sudo fallocate -l 2G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo &#39;/swapfile none swap sw 0 0&#39; | sudo tee -a /etc/fstab
free -h</code></pre><p>free -h 출력의 Swap 행에 2.0Gi 표시되면 OK.
<img src="https://velog.velcdn.com/images/rudin_/post/f244ac78-ce6e-463e-b368-56f63b76d414/image.png" alt="">
Swap(스왑) 파일은 물리적 메모리(RAM)가 부족할 때 디스크(HDD/SSD)의 일부를 메모리처럼 사용하는 가상 메모리 공간입니다.RAM이 가득 찼을 때 스왑 공간이 없으면 리눅스 커널은 OOM(Out of Memory) Killer를 동작시켜 중요 프로세스를 강제로 종료합니다. 스왑 파일은 이러한 갑작스러운 시스템 멈춤이나 응용 프로그램 종료를 막아줍니다. </p>
<h2 id="시간대-설정">시간대 설정</h2>
<p><img src="https://velog.velcdn.com/images/rudin_/post/3ad0a020-5226-43ab-833c-a247018e3cab/image.png" alt=""></p>
<h1 id="sql-server2025-설치">SQL Server2025 설치</h1>
<h2 id="microsoft-공식-저장소-등록">Microsoft 공식 저장소 등록</h2>
<pre><code># Microsoft GPG 키 등록 (Ubuntu 24.04 권장 방식)
curl -fsSL https://packages.microsoft.com/keys/microsoft.asc | \
  sudo gpg --dearmor -o /usr/share/keyrings/microsoft-prod.gpg

# SQL Server 2025 저장소 등록 (Ubuntu 24.04 공식)
curl -fsSL https://packages.microsoft.com/config/ubuntu/24.04/mssql-server-2025.list | \
  sudo tee /etc/apt/sources.list.d/mssql-server-2025.list

sudo apt update
</code></pre><h2 id="sql-server-패키지-설치">SQL Server 패키지 설치</h2>
<pre><code>sudo apt install -y mssql-server</code></pre><h2 id="mssql-conf-setup--edition-·-비밀번호-설정">mssql-conf setup — Edition · 비밀번호 설정</h2>
<pre><code>sudo /opt/mssql/bin/mssql-conf setup</code></pre><p><img src="https://velog.velcdn.com/images/rudin_/post/fdb57430-8ff7-47fd-98e2-962872d81f36/image.png" alt="">
<img src="https://velog.velcdn.com/images/rudin_/post/fe4bd180-676b-4b6d-92fd-7af71b8c6a84/image.png" alt=""></p>
<h2 id="서비스-상태-확인">서비스 상태 확인</h2>
<pre><code>systemctl status mssql-server --no-pager</code></pre><p>Active: active (running) 표시되면 OK.
LISTEN 0.0.0.0:1433 으로 바인딩 — 다음 단계에서 localhost-only로 변경합니다.
<img src="https://velog.velcdn.com/images/rudin_/post/56a87108-c686-44ae-8e13-9347a21e4cc6/image.png" alt=""></p>
<h2 id="sql-server를-localhost-전용으로-바인딩-보안-핵심">SQL Server를 localhost 전용으로 바인딩 (보안 핵심)</h2>
<p>SQL Server를 외부에 노출하면 Brute force 공격의 1순위 대상이 됩니다. NSG뿐 아니라 SQL Server 자체에서도 localhost만 바인딩하도록 이중 차단합니다.</p>
<pre><code># /var/opt/mssql/mssql.conf 에 IP 바인딩 설정 추가
sudo /opt/mssql/bin/mssql-conf set network.ipaddress 127.0.0.1
sudo systemctl restart mssql-server
sudo ss -tlnp | grep 1433</code></pre><p>ss 출력이 127.0.0.1:1433 으로만 표시되면 OK. 0.0.0.0:1433 또는 *:1433 이면 실패.</p>
<p><img src="https://velog.velcdn.com/images/rudin_/post/9e32016a-6d22-4d27-af1f-3bc92ece5adc/image.png" alt=""></p>
<h3 id="설정-제거-방법">설정 제거 방법</h3>
<pre><code>sudo /opt/mssql/bin/mssql-conf unset network.ipaddress
sudo systemctl restart mssql-server</code></pre><p>0.0.0.0:1433 나오면 외부 접속 가능 상태
<img src="https://velog.velcdn.com/images/rudin_/post/d73fcc13-404a-4909-84ff-02cbde83f082/image.png" alt=""></p>
<h1 id="mssql-tools-odbc-driver-python">mssql-tools, ODBC Driver, Python</h1>
<pre><code># prod.list 저장소 등록 (Ubuntu 24.04용)
curl -fsSL https://packages.microsoft.com/config/ubuntu/24.04/prod.list | \
  sudo tee /etc/apt/sources.list.d/mssql-release.list

sudo apt update
sudo ACCEPT_EULA=Y apt install -y mssql-tools18 unixodbc-dev msodbcsql18

# PATH에 sqlcmd 추가
echo &#39;export PATH=&quot;$PATH:/opt/mssql-tools18/bin&quot;&#39; &gt;&gt; ~/.bashrc
source ~/.bashrc</code></pre><h2 id="sqlcmd-접속">sqlcmd 접속</h2>
<pre><code>sqlcmd -S localhost -U sa -P &#39;&lt;SA_비밀번호&gt;&#39; -C -Q &quot;SELECT @@VERSION&quot;</code></pre><p>Microsoft SQL Server 2025 (RTM-CU...) 또는 17.x 버전 정보 출력되면 OK.
<img src="https://velog.velcdn.com/images/rudin_/post/76d44ce1-4e0e-4fcd-ab64-160690d8ceab/image.png" alt=""></p>
<ul>
<li>-C 옵션: TrustServerCertificate (자체 서명 인증서 신뢰). 실습용.</li>
<li>비밀번호에 특수문자가 있을 때는 작은따옴표 &#39; &#39; 로 감쌀 것.</li>
<li>명령 히스토리에 비밀번호가 남는 것을 피하려면 -P 생략 후 프롬프트 입력 권장.</li>
</ul>
<h2 id="python3--가상환경--필수-패키지-설치">Python3 + 가상환경 + 필수 패키지 설치</h2>
<pre><code>sudo apt install -y python3 python3-pip python3-venv

# 작업 디렉토리
mkdir -p ~/carmarket &amp;&amp; cd ~/carmarket

# 가상환경 생성
python3 -m venv venv
source venv/bin/activate

# 패키지 설치
pip install --upgrade pip
pip install flask pyodbc python-dotenv gunicorn</code></pre><p>pip list | grep -E &#39;Flask|pyodbc|python-dotenv&#39; 로 3개 패키지 출력 확인.
<img src="https://velog.velcdn.com/images/rudin_/post/5a54a2b8-3847-4a2a-9176-7948892fef87/image.png" alt=""></p>
<h3 id="pyodbc-동작-검증">pyodbc 동작 검증</h3>
<pre><code>python3 -c &quot;import pyodbc; print(pyodbc.drivers())&quot;</code></pre><p>출력에 [&#39;ODBC Driver 18 for SQL Server&#39;] 가 포함되면 OK.
<img src="https://velog.velcdn.com/images/rudin_/post/3ecab966-28a0-4151-83b3-475e3a976f94/image.png" alt=""></p>
<h1 id="데이터베이스-스키마-시드-데이터">데이터베이스, 스키마, 시드 데이터</h1>
<p>CarMarket 데이터베이스를 만들고 Users, Cars, Inquiries 3-table 모델을 구축, 시드 데이터를 적재</p>
<h2 id="스키마-sql-파일-작성">스키마 SQL 파일 작성</h2>
<pre><code>cd ~/carmarket
nano schema.sql
</code></pre><p>schema.sql</p>
<pre><code>CREATE DATABASE CarMarket;
GO
USE CarMarket;
GO

CREATE TABLE Users (
    UserId    INT IDENTITY(1,1) PRIMARY KEY,
    Name      NVARCHAR(100) NOT NULL,
    Email     NVARCHAR(200) NOT NULL UNIQUE,
    Phone     NVARCHAR(20),
    UserType  NVARCHAR(10) NOT NULL DEFAULT &#39;both&#39;
              CHECK (UserType IN (&#39;seller&#39;,&#39;buyer&#39;,&#39;both&#39;)),
    CreatedAt DATETIME2 DEFAULT SYSUTCDATETIME()
);

CREATE TABLE Cars (
    CarId       INT IDENTITY(1,1) PRIMARY KEY,
    SellerId    INT NOT NULL FOREIGN KEY REFERENCES Users(UserId),
    Brand       NVARCHAR(50) NOT NULL,
    Model       NVARCHAR(100) NOT NULL,
    Year        INT NOT NULL,
    Price       DECIMAL(12,0) NOT NULL,
    Mileage     INT NOT NULL,
    FuelType    NVARCHAR(20),
    Description NVARCHAR(MAX),
    Status      NVARCHAR(20) NOT NULL DEFAULT &#39;available&#39;
                CHECK (Status IN (&#39;available&#39;,&#39;reserved&#39;,&#39;sold&#39;)),
    CreatedAt   DATETIME2 DEFAULT SYSUTCDATETIME()
);

CREATE TABLE Inquiries (
    InquiryId INT IDENTITY(1,1) PRIMARY KEY,
    CarId     INT NOT NULL FOREIGN KEY REFERENCES Cars(CarId),
    BuyerId   INT NOT NULL FOREIGN KEY REFERENCES Users(UserId),
    Message   NVARCHAR(1000) NOT NULL,
    CreatedAt DATETIME2 DEFAULT SYSUTCDATETIME()
);

CREATE INDEX IX_Cars_Brand     ON Cars(Brand);
CREATE INDEX IX_Cars_Status    ON Cars(Status);
CREATE INDEX IX_Cars_CreatedAt ON Cars(CreatedAt DESC);
GO</code></pre><h3 id="시드-데이터-작성">시드 데이터 작성</h3>
<pre><code>nano seed.sql</code></pre><pre><code>USE CarMarket;
GO

INSERT INTO Users (Name, Email, Phone, UserType) VALUES
(N&#39;김판매&#39;, &#39;seller1@test.com&#39;, &#39;010-1111-1111&#39;, &#39;seller&#39;),
(N&#39;이판매&#39;, &#39;seller2@test.com&#39;, &#39;010-2222-2222&#39;, &#39;seller&#39;),
(N&#39;박판매&#39;, &#39;seller3@test.com&#39;, &#39;010-3333-3333&#39;, &#39;seller&#39;),
(N&#39;최구매&#39;, &#39;buyer1@test.com&#39;,  &#39;010-4444-4444&#39;, &#39;buyer&#39;),
(N&#39;정구매&#39;, &#39;buyer2@test.com&#39;,  &#39;010-5555-5555&#39;, &#39;buyer&#39;);

INSERT INTO Cars (SellerId, Brand, Model, Year, Price, Mileage, FuelType, Description) VALUES
(1, N&#39;현대&#39;,   N&#39;쏘나타 DN8&#39;,     2021, 18500000, 45000, N&#39;가솔린&#39;, N&#39;무사고, 1인 소유, 정기점검 완료&#39;),
(1, N&#39;기아&#39;,   N&#39;K5 3세대&#39;,        2020, 16000000, 62000, N&#39;가솔린&#39;, N&#39;썬루프, 어라운드뷰 옵션&#39;),
(2, N&#39;BMW&#39;,   N&#39;520d (G30)&#39;,      2019, 28000000, 78000, N&#39;디젤&#39;,   N&#39;풀옵션, 가죽시트, 무사고&#39;),
(2, N&#39;벤츠&#39;,   N&#39;E300 (W213)&#39;,     2020, 38000000, 55000, N&#39;가솔린&#39;, N&#39;AMG 패키지, 1인 소유&#39;),
(3, N&#39;제네시스&#39;, N&#39;G80 (RG3)&#39;,      2022, 45000000, 28000, N&#39;가솔린&#39;, N&#39;신차급, 출고 1년&#39;);
GO
</code></pre><h3 id="sql-파일-실행">sql 파일 실행</h3>
<pre><code>read -s -p &quot;SA Password: &quot; SA_PWD
export SA_PWD

sqlcmd -S localhost -U sa -P &quot;$SA_PWD&quot; -C -i schema.sql
sqlcmd -S localhost -U sa -P &quot;$SA_PWD&quot; -C -i seed.sql</code></pre><p><img src="https://velog.velcdn.com/images/rudin_/post/556ccf0e-eda9-4210-b164-97547dc14742/image.png" alt=""></p>
<h3 id="데이터-검증">데이터 검증</h3>
<pre><code>sqlcmd -S localhost -U sa -P &quot;$SA_PWD&quot; -C -d CarMarket -Q \
  &quot;SELECT c.Brand, c.Model, c.Year, c.Price, u.Name AS Seller
   FROM Cars c JOIN Users u ON c.SellerId = u.UserId
   ORDER BY c.Price DESC&quot;</code></pre><h1 id="flask-백엔드-구현rest-api">Flask 백엔드 구현(REST API)</h1>
<p>Python Flask로 5개의 REST 엔드포인트(/health, GET·POST /cars, POST /inquiries, GET /users)를 구현하고 SQL Server와 연결</p>
<h2 id="env파일로-비밀번호-분리">.env파일로 비밀번호 분리</h2>
<pre><code>nano .env

SA_PASSWORD=YourStrongP@ssw0rd
DB_SERVER=localhost
DB_NAME=CarMarket
FLASK_PORT=5000

# 권한 600 — 본인만 읽기·쓰기
chmod 600 .env
ls -la .env</code></pre><h2 id="apppy">app.py</h2>
<pre><code>nano app.py</code></pre><pre><code>import os
from contextlib import contextmanager
import pyodbc
from flask import Flask, request, jsonify, render_template_string
from dotenv import load_dotenv

load_dotenv()

app = Flask(__name__)

DB_SERVER   = os.environ.get(&quot;DB_SERVER&quot;, &quot;localhost&quot;)
DB_NAME     = os.environ.get(&quot;DB_NAME&quot;, &quot;CarMarket&quot;)
DB_USER     = &quot;sa&quot;
DB_PASSWORD = os.environ.get(&quot;SA_PASSWORD&quot;)
FLASK_PORT  = int(os.environ.get(&quot;FLASK_PORT&quot;, 5000))

CONN_STR = (
    &quot;DRIVER={ODBC Driver 18 for SQL Server};&quot;
    f&quot;SERVER={DB_SERVER};DATABASE={DB_NAME};&quot;
    f&quot;UID={DB_USER};PWD={DB_PASSWORD};&quot;
    &quot;TrustServerCertificate=yes;Encrypt=yes;&quot;
)

@contextmanager
def db():
    conn = pyodbc.connect(CONN_STR, autocommit=False)
    try:
        yield conn
        conn.commit()
    except Exception:
        conn.rollback()
        raise
    finally:
        conn.close()

# ====== Health check ======
@app.route(&quot;/health&quot;)
def health():
    try:
        with db() as conn:
            cur = conn.cursor()
            cur.execute(&quot;SELECT 1&quot;)
            cur.fetchone()
        return jsonify({&quot;status&quot;: &quot;ok&quot;, &quot;db&quot;: &quot;connected&quot;}), 200
    except Exception as e:
        return jsonify({&quot;status&quot;: &quot;error&quot;, &quot;db&quot;: str(e)}), 500

# ====== Users ======
@app.route(&quot;/api/users&quot;, methods=[&quot;GET&quot;])
def list_users():
    with db() as conn:
        cur = conn.cursor()
        cur.execute(&quot;SELECT UserId, Name, Email, Phone, UserType FROM Users ORDER BY UserId&quot;)
        rows = cur.fetchall()
    return jsonify([
        {&quot;id&quot;: r[0], &quot;name&quot;: r[1], &quot;email&quot;: r[2], &quot;phone&quot;: r[3], &quot;type&quot;: r[4]}
        for r in rows
    ])

# ====== Cars: 목록 + 검색 ======
@app.route(&quot;/api/cars&quot;, methods=[&quot;GET&quot;])
def list_cars():
    brand = request.args.get(&quot;brand&quot;)
    max_price = request.args.get(&quot;max_price&quot;, type=int)

    sql = &quot;&quot;&quot;
        SELECT c.CarId, u.Name, c.Brand, c.Model, c.Year, c.Price,
               c.Mileage, c.FuelType, c.Description, c.Status, c.CreatedAt
          FROM Cars c
          JOIN Users u ON c.SellerId = u.UserId
         WHERE c.Status = &#39;available&#39;
    &quot;&quot;&quot;
    params = []
    if brand:
        sql += &quot; AND c.Brand = ?&quot;
        params.append(brand)
    if max_price:
        sql += &quot; AND c.Price &lt;= ?&quot;
        params.append(max_price)
    sql += &quot; ORDER BY c.CreatedAt DESC&quot;

    with db() as conn:
        cur = conn.cursor()
        cur.execute(sql, params)
        rows = cur.fetchall()

    return jsonify([
        {
            &quot;id&quot;: r[0], &quot;seller&quot;: r[1], &quot;brand&quot;: r[2], &quot;model&quot;: r[3],
            &quot;year&quot;: r[4], &quot;price&quot;: int(r[5]), &quot;mileage&quot;: r[6],
            &quot;fuel&quot;: r[7], &quot;desc&quot;: r[8], &quot;status&quot;: r[9],
            &quot;created_at&quot;: r[10].isoformat() if r[10] else None
        } for r in rows
    ])

# ====== Cars: 등록 ======
@app.route(&quot;/api/cars&quot;, methods=[&quot;POST&quot;])
def create_car():
    data = request.get_json(silent=True) or {}
    required = [&quot;seller_id&quot;, &quot;brand&quot;, &quot;model&quot;, &quot;year&quot;, &quot;price&quot;, &quot;mileage&quot;]
    missing = [k for k in required if k not in data]
    if missing:
        return jsonify({&quot;error&quot;: f&quot;missing fields: {missing}&quot;}), 400

    with db() as conn:
        cur = conn.cursor()
        cur.execute(&quot;&quot;&quot;
            INSERT INTO Cars (SellerId, Brand, Model, Year, Price, Mileage, FuelType, Description)
            OUTPUT INSERTED.CarId
            VALUES (?, ?, ?, ?, ?, ?, ?, ?)
        &quot;&quot;&quot;, data[&quot;seller_id&quot;], data[&quot;brand&quot;], data[&quot;model&quot;], int(data[&quot;year&quot;]),
             int(data[&quot;price&quot;]), int(data[&quot;mileage&quot;]),
             data.get(&quot;fuel&quot;, &quot;&quot;), data.get(&quot;desc&quot;, &quot;&quot;))
        new_id = cur.fetchone()[0]
    return jsonify({&quot;car_id&quot;: new_id}), 201

# ====== Inquiries ======
@app.route(&quot;/api/inquiries&quot;, methods=[&quot;POST&quot;])
def create_inquiry():
    data = request.get_json(silent=True) or {}
    for k in (&quot;car_id&quot;, &quot;buyer_id&quot;, &quot;message&quot;):
        if k not in data:
            return jsonify({&quot;error&quot;: f&quot;missing {k}&quot;}), 400

    with db() as conn:
        cur = conn.cursor()
        cur.execute(&quot;&quot;&quot;
            INSERT INTO Inquiries (CarId, BuyerId, Message)
            OUTPUT INSERTED.InquiryId
            VALUES (?, ?, ?)
        &quot;&quot;&quot;, int(data[&quot;car_id&quot;]), int(data[&quot;buyer_id&quot;]), data[&quot;message&quot;])
        new_id = cur.fetchone()[0]
    return jsonify({&quot;inquiry_id&quot;: new_id}), 201

# ====== UI: 단일 페이지 (Step 8에서 추가) ======
INDEX_HTML = &quot;&quot;  # Step 8에서 채움

@app.route(&quot;/&quot;)
def index():
    return render_template_string(INDEX_HTML)

if __name__ == &quot;__main__&quot;:
    app.run(host=&quot;0.0.0.0&quot;, port=FLASK_PORT, debug=False)</code></pre><h3 id="로컬-테스트">로컬 테스트</h3>
<pre><code>source venv/bin/activate
python app.py</code></pre><pre><code># 다른 터미널 또는 같은 세션에서:
curl -s http://localhost:5000/health | python3 -m json.tool</code></pre><p><img src="https://velog.velcdn.com/images/rudin_/post/289a2e91-6422-4375-bed6-a5b35e28ca0d/image.png" alt=""></p>
<h3 id="api-동작-테스트">API 동작 테스트</h3>
<pre><code>curl -s http://localhost:5000/api/cars | python3 -m json.tool</code></pre><p><img src="https://velog.velcdn.com/images/rudin_/post/5959faa4-5047-4e9e-9875-005b692ec204/image.png" alt=""></p>
<pre><code># 브랜드 필터
curl -s &quot;http://localhost:5000/api/cars?brand=BMW&quot; | python3 -m json.tool

# 차량 등록
curl -s -X POST http://localhost:5000/api/cars \
  -H &quot;Content-Type: application/json&quot; \
  -d &#39;{&quot;seller_id&quot;:1,&quot;brand&quot;:&quot;기아&quot;,&quot;model&quot;:&quot;카니발&quot;,&quot;year&quot;:2022,&quot;price&quot;:35000000,&quot;mileage&quot;:15000,&quot;fuel&quot;:&quot;디젤&quot;,&quot;desc&quot;:&quot;하이리무진 풀옵&quot;}&#39; \
  | python3 -m json.tool

# 문의 등록
curl -s -X POST http://localhost:5000/api/inquiries \
  -H &quot;Content-Type: application/json&quot; \
  -d &#39;{&quot;car_id&quot;:1,&quot;buyer_id&quot;:4,&quot;message&quot;:&quot;실차 확인 가능한가요?&quot;}&#39; \
  | python3 -m json.tool
</code></pre><h3 id="백그라운드-프로세스-종료">백그라운드 프로세스 종료</h3>
<pre><code>ps aux | grep &quot;[p]ython app.py&quot;
kill &lt;PID&gt;</code></pre><p><img src="https://velog.velcdn.com/images/rudin_/post/5f236cff-c5ab-4bc9-9c07-106bc558697d/image.png" alt=""></p>
<h1 id="프론트엔드">프론트엔드</h1>
<p>Bootstrap 5 CDN으로 차량 목록·등록·문의 UI를 구현. 단일 HTML 문자열을 Flask render_template_string으로 제공</p>
<p>app.py의 INDEX_HTML = &quot;&quot; 라인을 아래 내용으로 교체</p>
<pre><code>INDEX_HTML = &quot;&quot;&quot;
&lt;!doctype html&gt;
&lt;html lang=&quot;ko&quot;&gt;
&lt;head&gt;
  &lt;meta charset=&quot;utf-8&quot;&gt;
  &lt;meta name=&quot;viewport&quot; content=&quot;width=device-width,initial-scale=1&quot;&gt;
  &lt;title&gt;중고차 마켓 MVP&lt;/title&gt;
  &lt;link href=&quot;https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css&quot; rel=&quot;stylesheet&quot;&gt;
  &lt;style&gt;
    body { background:#f4f8fa; }
    .navbar { background:#21295C !important; }
    .card-car { transition: transform .15s; }
    .card-car:hover { transform: translateY(-3px); box-shadow:0 6px 20px rgba(0,0,0,.08);}
    .price { color:#065A82; font-weight:700; }
    .badge-status { font-size: .75rem; }
  &lt;/style&gt;
&lt;/head&gt;
&lt;body&gt;
&lt;nav class=&quot;navbar navbar-dark px-4&quot;&gt;
  &lt;span class=&quot;navbar-brand mb-0 h4&quot;&gt;🚗 중고차 마켓 MVP&lt;/span&gt;
  &lt;div&gt;
    &lt;button class=&quot;btn btn-light btn-sm me-2&quot; onclick=&quot;showRegister()&quot;&gt;+ 매물 등록&lt;/button&gt;
    &lt;span id=&quot;health&quot; class=&quot;badge bg-secondary&quot;&gt;checking…&lt;/span&gt;
  &lt;/div&gt;
&lt;/nav&gt;

&lt;div class=&quot;container my-4&quot;&gt;
  &lt;div class=&quot;row g-2 mb-3 align-items-end&quot;&gt;
    &lt;div class=&quot;col-md-3&quot;&gt;
      &lt;label class=&quot;form-label small&quot;&gt;브랜드&lt;/label&gt;
      &lt;select id=&quot;filterBrand&quot; class=&quot;form-select form-select-sm&quot;&gt;
        &lt;option value=&quot;&quot;&gt;전체&lt;/option&gt;
        &lt;option&gt;현대&lt;/option&gt;&lt;option&gt;기아&lt;/option&gt;&lt;option&gt;제네시스&lt;/option&gt;
        &lt;option&gt;BMW&lt;/option&gt;&lt;option&gt;벤츠&lt;/option&gt;
      &lt;/select&gt;
    &lt;/div&gt;
    &lt;div class=&quot;col-md-3&quot;&gt;
      &lt;label class=&quot;form-label small&quot;&gt;최대 가격(원)&lt;/label&gt;
      &lt;input id=&quot;filterPrice&quot; type=&quot;number&quot; class=&quot;form-control form-control-sm&quot; placeholder=&quot;예: 30000000&quot;&gt;
    &lt;/div&gt;
    &lt;div class=&quot;col-md-2&quot;&gt;
      &lt;button class=&quot;btn btn-primary btn-sm w-100&quot; onclick=&quot;loadCars()&quot;&gt;검색&lt;/button&gt;
    &lt;/div&gt;
    &lt;div class=&quot;col-md-4 text-end&quot;&gt;
      &lt;small class=&quot;text-muted&quot; id=&quot;count&quot;&gt;&lt;/small&gt;
    &lt;/div&gt;
  &lt;/div&gt;

  &lt;div id=&quot;cars&quot; class=&quot;row g-3&quot;&gt;&lt;/div&gt;
&lt;/div&gt;

&lt;!-- 등록 모달 --&gt;
&lt;div class=&quot;modal fade&quot; id=&quot;regModal&quot; tabindex=&quot;-1&quot;&gt;
  &lt;div class=&quot;modal-dialog&quot;&gt;
    &lt;div class=&quot;modal-content&quot;&gt;
      &lt;div class=&quot;modal-header bg-primary text-white&quot;&gt;&lt;h5 class=&quot;modal-title&quot;&gt;매물 등록&lt;/h5&gt;&lt;/div&gt;
      &lt;div class=&quot;modal-body&quot;&gt;
        &lt;div class=&quot;row g-2&quot;&gt;
          &lt;div class=&quot;col-6&quot;&gt;&lt;label class=&quot;form-label&quot;&gt;판매자&lt;/label&gt;
            &lt;select id=&quot;r_seller&quot; class=&quot;form-select&quot;&gt;&lt;/select&gt;&lt;/div&gt;
          &lt;div class=&quot;col-6&quot;&gt;&lt;label class=&quot;form-label&quot;&gt;브랜드&lt;/label&gt;
            &lt;input id=&quot;r_brand&quot; class=&quot;form-control&quot; placeholder=&quot;현대&quot;&gt;&lt;/div&gt;
          &lt;div class=&quot;col-6&quot;&gt;&lt;label class=&quot;form-label&quot;&gt;모델&lt;/label&gt;
            &lt;input id=&quot;r_model&quot; class=&quot;form-control&quot;&gt;&lt;/div&gt;
          &lt;div class=&quot;col-3&quot;&gt;&lt;label class=&quot;form-label&quot;&gt;연식&lt;/label&gt;
            &lt;input id=&quot;r_year&quot; type=&quot;number&quot; class=&quot;form-control&quot; value=&quot;2022&quot;&gt;&lt;/div&gt;
          &lt;div class=&quot;col-3&quot;&gt;&lt;label class=&quot;form-label&quot;&gt;연료&lt;/label&gt;
            &lt;input id=&quot;r_fuel&quot; class=&quot;form-control&quot; placeholder=&quot;가솔린&quot;&gt;&lt;/div&gt;
          &lt;div class=&quot;col-6&quot;&gt;&lt;label class=&quot;form-label&quot;&gt;가격(원)&lt;/label&gt;
            &lt;input id=&quot;r_price&quot; type=&quot;number&quot; class=&quot;form-control&quot;&gt;&lt;/div&gt;
          &lt;div class=&quot;col-6&quot;&gt;&lt;label class=&quot;form-label&quot;&gt;주행(km)&lt;/label&gt;
            &lt;input id=&quot;r_mile&quot; type=&quot;number&quot; class=&quot;form-control&quot;&gt;&lt;/div&gt;
          &lt;div class=&quot;col-12&quot;&gt;&lt;label class=&quot;form-label&quot;&gt;설명&lt;/label&gt;
            &lt;textarea id=&quot;r_desc&quot; rows=&quot;2&quot; class=&quot;form-control&quot;&gt;&lt;/textarea&gt;&lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;
      &lt;div class=&quot;modal-footer&quot;&gt;
        &lt;button class=&quot;btn btn-secondary&quot; data-bs-dismiss=&quot;modal&quot;&gt;취소&lt;/button&gt;
        &lt;button class=&quot;btn btn-primary&quot; onclick=&quot;submitCar()&quot;&gt;등록&lt;/button&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;!-- 문의 모달 --&gt;
&lt;div class=&quot;modal fade&quot; id=&quot;inqModal&quot; tabindex=&quot;-1&quot;&gt;
  &lt;div class=&quot;modal-dialog&quot;&gt;
    &lt;div class=&quot;modal-content&quot;&gt;
      &lt;div class=&quot;modal-header bg-info text-white&quot;&gt;&lt;h5 class=&quot;modal-title&quot;&gt;문의하기&lt;/h5&gt;&lt;/div&gt;
      &lt;div class=&quot;modal-body&quot;&gt;
        &lt;p class=&quot;text-muted small mb-2&quot;&gt;차량 ID: &lt;span id=&quot;inq_car_id&quot;&gt;&lt;/span&gt;&lt;/p&gt;
        &lt;label class=&quot;form-label&quot;&gt;구매자&lt;/label&gt;
        &lt;select id=&quot;inq_buyer&quot; class=&quot;form-select mb-2&quot;&gt;&lt;/select&gt;
        &lt;label class=&quot;form-label&quot;&gt;메시지&lt;/label&gt;
        &lt;textarea id=&quot;inq_msg&quot; class=&quot;form-control&quot; rows=&quot;3&quot;&gt;&lt;/textarea&gt;
      &lt;/div&gt;
      &lt;div class=&quot;modal-footer&quot;&gt;
        &lt;button class=&quot;btn btn-secondary&quot; data-bs-dismiss=&quot;modal&quot;&gt;취소&lt;/button&gt;
        &lt;button class=&quot;btn btn-info text-white&quot; onclick=&quot;submitInquiry()&quot;&gt;전송&lt;/button&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;script src=&quot;https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js&quot;&gt;&lt;/script&gt;
&lt;script&gt;
let users = [];

async function checkHealth() {
  try {
    const r = await fetch(&#39;/health&#39;);
    const j = await r.json();
    document.getElementById(&#39;health&#39;).className = &#39;badge &#39; + (j.status===&#39;ok&#39; ? &#39;bg-success&#39; : &#39;bg-danger&#39;);
    document.getElementById(&#39;health&#39;).textContent = &#39;DB &#39; + j.status;
  } catch (e) {
    document.getElementById(&#39;health&#39;).className = &#39;badge bg-danger&#39;;
    document.getElementById(&#39;health&#39;).textContent = &#39;DB error&#39;;
  }
}

async function loadUsers() {
  const r = await fetch(&#39;/api/users&#39;);
  users = await r.json();
  const sellerSel = document.getElementById(&#39;r_seller&#39;);
  const buyerSel  = document.getElementById(&#39;inq_buyer&#39;);
  sellerSel.innerHTML = users.filter(u =&gt; u.type !== &#39;buyer&#39;)
    .map(u =&gt; &#39;&lt;option value=&quot;&#39; + u.id + &#39;&quot;&gt;&#39; + u.name + &#39; (&#39; + u.email + &#39;)&lt;/option&gt;&#39;).join(&#39;&#39;);
  buyerSel.innerHTML  = users.filter(u =&gt; u.type !== &#39;seller&#39;)
    .map(u =&gt; &#39;&lt;option value=&quot;&#39; + u.id + &#39;&quot;&gt;&#39; + u.name + &#39;&lt;/option&gt;&#39;).join(&#39;&#39;);
}

async function loadCars() {
  const brand = document.getElementById(&#39;filterBrand&#39;).value;
  const price = document.getElementById(&#39;filterPrice&#39;).value;
  const params = new URLSearchParams();
  if (brand) params.append(&#39;brand&#39;, brand);
  if (price) params.append(&#39;max_price&#39;, price);
  const r = await fetch(&#39;/api/cars?&#39; + params);
  const cars = await r.json();
  document.getElementById(&#39;count&#39;).textContent = &#39;검색 결과: &#39; + cars.length + &#39;건&#39;;
  document.getElementById(&#39;cars&#39;).innerHTML = cars.map(function(c) {
    return &#39;&#39; +
      &#39;&lt;div class=&quot;col-md-6 col-lg-4&quot;&gt;&#39; +
        &#39;&lt;div class=&quot;card card-car h-100&quot;&gt;&lt;div class=&quot;card-body&quot;&gt;&#39; +
          &#39;&lt;div class=&quot;d-flex justify-content-between&quot;&gt;&#39; +
            &#39;&lt;h5 class=&quot;card-title mb-0&quot;&gt;&#39; + c.brand + &#39; &#39; + c.model + &#39;&lt;/h5&gt;&#39; +
            &#39;&lt;span class=&quot;badge bg-success badge-status&quot;&gt;&#39; + c.status + &#39;&lt;/span&gt;&#39; +
          &#39;&lt;/div&gt;&#39; +
          &#39;&lt;p class=&quot;text-muted small mt-1&quot;&gt;&#39; + c.year + &#39;년식 · &#39; + c.mileage.toLocaleString() + &#39;km · &#39; + (c.fuel||&#39;-&#39;) + &#39;&lt;/p&gt;&#39; +
          &#39;&lt;p class=&quot;price h5 mb-2&quot;&gt;&#39; + c.price.toLocaleString() + &#39; 원&lt;/p&gt;&#39; +
          &#39;&lt;p class=&quot;small mb-2&quot;&gt;&#39; + (c.desc||&#39;&#39;) + &#39;&lt;/p&gt;&#39; +
          &#39;&lt;p class=&quot;small text-muted mb-2&quot;&gt;판매자: &#39; + c.seller + &#39;&lt;/p&gt;&#39; +
          &#39;&lt;button class=&quot;btn btn-sm btn-outline-info&quot; onclick=&quot;openInquiry(&#39; + c.id + &#39;)&quot;&gt;문의하기&lt;/button&gt;&#39; +
        &#39;&lt;/div&gt;&lt;/div&gt;&#39; +
      &#39;&lt;/div&gt;&#39;;
  }).join(&#39;&#39;);
}

function showRegister() { new bootstrap.Modal(document.getElementById(&#39;regModal&#39;)).show(); }

async function submitCar() {
  const body = {
    seller_id: parseInt(document.getElementById(&#39;r_seller&#39;).value),
    brand:   document.getElementById(&#39;r_brand&#39;).value.trim(),
    model:   document.getElementById(&#39;r_model&#39;).value.trim(),
    year:    parseInt(document.getElementById(&#39;r_year&#39;).value),
    price:   parseInt(document.getElementById(&#39;r_price&#39;).value),
    mileage: parseInt(document.getElementById(&#39;r_mile&#39;).value),
    fuel:    document.getElementById(&#39;r_fuel&#39;).value.trim(),
    desc:    document.getElementById(&#39;r_desc&#39;).value.trim(),
  };
  const r = await fetch(&#39;/api/cars&#39;, {
    method: &#39;POST&#39;,
    headers: {&#39;Content-Type&#39;: &#39;application/json&#39;},
    body: JSON.stringify(body)
  });
  if (r.ok) {
    alert(&#39;등록 완료!&#39;);
    bootstrap.Modal.getInstance(document.getElementById(&#39;regModal&#39;)).hide();
    loadCars();
  } else {
    alert(&#39;실패: &#39; + (await r.text()));
  }
}

function openInquiry(carId) {
  document.getElementById(&#39;inq_car_id&#39;).textContent = carId;
  document.getElementById(&#39;inq_msg&#39;).value = &#39;&#39;;
  new bootstrap.Modal(document.getElementById(&#39;inqModal&#39;)).show();
}

async function submitInquiry() {
  const body = {
    car_id:   parseInt(document.getElementById(&#39;inq_car_id&#39;).textContent),
    buyer_id: parseInt(document.getElementById(&#39;inq_buyer&#39;).value),
    message:  document.getElementById(&#39;inq_msg&#39;).value.trim(),
  };
  if (!body.message) { alert(&#39;메시지를 입력하세요&#39;); return; }
  const r = await fetch(&#39;/api/inquiries&#39;, {
    method: &#39;POST&#39;,
    headers: {&#39;Content-Type&#39;: &#39;application/json&#39;},
    body: JSON.stringify(body)
  });
  if (r.ok) {
    alert(&#39;문의 전송 완료!&#39;);
    bootstrap.Modal.getInstance(document.getElementById(&#39;inqModal&#39;)).hide();
  } else {
    alert(&#39;실패: &#39; + (await r.text()));
  }
}

checkHealth();
loadUsers();
loadCars();
&lt;/script&gt;
&lt;/body&gt;
&lt;/html&gt;
&quot;&quot;&quot;
</code></pre><h2 id="로컬-테스트-1">로컬 테스트</h2>
<pre><code>source venv/bin/activate
python app.py &amp;

curl -s http://localhost:5000/ | head -20</code></pre><p><img src="https://velog.velcdn.com/images/rudin_/post/aad4fdeb-0faf-4eda-9f51-074f9034a100/image.png" alt=""></p>
<h1 id="외부-접근-설정nsg--systemd-서비스화">외부 접근 설정(NSG) + systemd 서비스화</h1>
<p>NSG에 5000 포트를 열어 외부 브라우저에서 접근 가능하게 하고, Flask 앱을 systemd 서비스로 등록해 SSH 종료 후에도 동작하게 설정</p>
<h2 id="nsg-규칙-추가---flask-5000-포트">NSG 규칙 추가 - Flask 5000 포트</h2>
<p>로컬 개발 머신(VM이 아닌, 로컬 PC)에서 실행</p>
<pre><code># 5000 포트 외부 노출
az vm open-port \
  --resource-group $RG \
  --name $VM \
  --port 5000 \
  --priority 1010

# 현재 NSG 규칙 확인
az network nsg rule list \
  --resource-group $RG \
  --nsg-name ${VM}NSG \
  --output table</code></pre><p><img src="https://velog.velcdn.com/images/rudin_/post/e0ea58b7-9db6-46e7-9fa1-11653ce32fd1/image.png" alt=""></p>
<ul>
<li>1433 포트는 절대 열지 마세요. SQL Server는 Step 4에서 127.0.0.1 바인딩했지만 NSG도 이중 차단입니다.</li>
<li>우선순위(priority) 1010은 SSH 1000과 충돌하지 않도록 100 이상 차이를 둡니다.</li>
<li>끝나면 5000도 다시 닫는것 필수</li>
</ul>
<h2 id="외부-차단-검증">외부 차단 검증</h2>
<pre><code># 외부에서 SQL Server 접속 시도 — 반드시 timeout 발생해야 정상
sqlcmd -S $PUBIP,1433 -U sa -P &#39;dummy&#39; -C -l 5
# 출력 예: Login timeout expired ... (10 ~ 60초 후)

# 또는 nc(netcat) / Test-NetConnection
# Linux/Mac:
nc -zv $PUBIP 1433
# 결과: &quot;Connection refused&quot; 또는 &quot;timed out&quot; → OK

# Windows PowerShell:
Test-NetConnection -ComputerName $env:PUBIP -Port 1433
# TcpTestSucceeded : False → OK</code></pre><p>1433 포트가 외부에서 timeout 또는 refused — 정상. 만약 connect 성공하면 즉시 NSG와 SQL Server bind 설정 재점검.</p>
<h2 id="flask앱-systemd-서비스-등록">Flask앱 systemd 서비스 등록</h2>
<pre><code>sudo nano /etc/systemd/system/carmarket.service

[Unit]
Description=CarMarket Flask App
After=network.target mssql-server.service
Requires=mssql-server.service

[Service]
Type=simple
User=azureuser
WorkingDirectory=/home/azureuser/carmarket
EnvironmentFile=/home/azureuser/carmarket/.env
ExecStart=/home/azureuser/carmarket/venv/bin/gunicorn \
  --bind 0.0.0.0:5000 \
  --workers 2 \
  --access-logfile - \
  app:app
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target

# 서비스 활성화 및 시작
sudo systemctl daemon-reload
sudo systemctl enable carmarket
sudo systemctl start carmarket
sudo systemctl status carmarket --no-pager
</code></pre><p><img src="https://velog.velcdn.com/images/rudin_/post/91bd6627-ba0b-449c-a5e6-4091b1ab104a/image.png" alt=""></p>
<p>Active: active (running) 표시되면 OK. 실패 시 journalctl -u carmarket -n 50 으로 로그 확인.</p>
<h2 id="외부-브라우저로-접속">외부 브라우저로 접속</h2>
<p>로컬 PC 브라우저에서 <code>http://&lt;PUBIP&gt;:5000/</code> 로 접근
(azure portal의 vm 리소스에 기본 NIC 공용 IP 사용)
<img src="https://velog.velcdn.com/images/rudin_/post/84a61638-c1c3-4a9c-b539-5634097dab0d/image.png" alt=""></p>
<h1 id="정리">정리</h1>
<p>리소스 삭제하거나, vm의 인바운드 포트 규칙의 5000 삭제</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[MicrosoftDataSchool] 79일차 - DB 역사]]></title>
            <link>https://velog.io/@rudin_/MicrosoftDataSchool-79%EC%9D%BC%EC%B0%A8</link>
            <guid>https://velog.io/@rudin_/MicrosoftDataSchool-79%EC%9D%BC%EC%B0%A8</guid>
            <pubDate>Wed, 29 Apr 2026 01:09:14 GMT</pubDate>
            <description><![CDATA[<p>supabase, postgresql, mysql, mariadb, firebase 등으로 시작하고 후에 확장해서 로드밸런서 붙이거나 하는 것
MVP: Minimum Viable Product</p>
<p>초기에 죄다 azure function(서버리스)로 처리하면 의존성이 증가하여 유지보수가 힘들다 → NOSQL로도 만들수 있지만, RDBMS를 함께 써야 확장성이 좋다.</p>
<p>MySQL을 쓰더라도 온프레미스로 vm(월 100$)에 설치해서 직접 관리하냐 vs 돈 좀 내고 Paas 서비스(Azure Database for MySQL 월 200$) 쓸거냐</p>
<hr>

<h1 id="azure-database-engineer-bootcamp-정리">Azure Database Engineer Bootcamp 정리</h1>
<h1 id="지형도--환경-셋업">지형도 &amp; 환경 셋업</h1>
<p>Azure 데이터 서비스의 전체 그림을 이해하고, 실습 환경을 직접 구축하는 과정이다.
예상 비용은 <strong>1인당 약 $0.5</strong>로 안내되어 있다.</p>
<hr>
<h1 id="학습-목표">학습 목표</h1>
<p>이번 과정의 학습 목표는 다음과 같다.</p>
<table>
<thead>
<tr>
<th>학습 목표</th>
</tr>
</thead>
<tbody><tr>
<td>DB 진화 과정(계층형 → RDBMS → NoSQL → NewSQL) 설명 가능</td>
</tr>
<tr>
<td>CAP / BASE 차이를 실무 사례로 비교 가능</td>
</tr>
<tr>
<td>Azure 데이터 서비스 전체 지형도 + 의사결정 트리 활용</td>
</tr>
<tr>
<td>구독 · 리소스 그룹 · VNet · NSG 직접 생성</td>
</tr>
<tr>
<td>Budget Alert로 비용 사전 통제</td>
</tr>
<tr>
<td>리소스 정리 스크립트 작성 및 실행</td>
</tr>
</tbody></table>
<hr>
<h1 id="선수-지식-확인">선수 지식 확인</h1>
<table>
<thead>
<tr>
<th>스킬</th>
<th>수준</th>
<th>구분</th>
</tr>
</thead>
<tbody><tr>
<td>SQL</td>
<td>SELECT / JOIN 기본 쿼리 작성 · 조인 이해</td>
<td>필수</td>
</tr>
<tr>
<td>Linux CLI</td>
<td><code>ls</code>, <code>cd</code>, <code>ssh</code>, <code>vi</code> 기본 조작</td>
<td>필수</td>
</tr>
<tr>
<td>Azure Portal</td>
<td>리소스 검색 · 생성 화면 탐색</td>
<td>필수</td>
</tr>
<tr>
<td>네트워킹 기초</td>
<td>IP, 서브넷, 방화벽 개념</td>
<td>권장</td>
</tr>
<tr>
<td>Git 기초</td>
<td>clone, commit 수준</td>
<td>권장</td>
</tr>
</tbody></table>
<hr>
<h1 id="db-역사와-이론">DB 역사와 이론</h1>
<h2 id="왜-db-역사를-배우는가">왜 DB 역사를 배우는가?</h2>
<p>DB 역사를 배우는 이유는 현재 기술의 장단점이 과거 문제 해결 과정에서 탄생했기 때문이다.</p>
<ul>
<li>현재 기술의 장단점은 과거 문제 해결 과정에서 탄생</li>
<li>NoSQL은 RDBMS를 “대체”하는 것이 아님</li>
<li>워크로드에 맞는 DB 선택 = DBA 핵심 역량</li>
<li>Azure에 데이터 서비스가 10가지 넘는 이유를 이해하기 위함</li>
</ul>
<hr>
<h2 id="계층형-db-→-관계형-db">계층형 DB → 관계형 DB</h2>
<h3 id="1960s-계층형-db">1960s 계층형 DB</h3>
<table>
<thead>
<tr>
<th>항목</th>
<th>내용</th>
</tr>
</thead>
<tbody><tr>
<td>대표 시스템</td>
<td>IBM IMS (1966)</td>
</tr>
<tr>
<td>구조</td>
<td>트리 구조: 부모 → 자식</td>
</tr>
<tr>
<td>장점</td>
<td>빠른 읽기</td>
</tr>
<tr>
<td>한계</td>
<td>유연성 부족</td>
</tr>
</tbody></table>
<h3 id="1970s-네트워크-db">1970s 네트워크 DB</h3>
<table>
<thead>
<tr>
<th>항목</th>
<th>내용</th>
</tr>
</thead>
<tbody><tr>
<td>대표 모델</td>
<td>CODASYL</td>
</tr>
<tr>
<td>특징</td>
<td>다대다 관계 지원</td>
</tr>
</tbody></table>
<h3 id="197080s-관계형-db">1970~80s 관계형 DB</h3>
<table>
<thead>
<tr>
<th>항목</th>
<th>내용</th>
</tr>
</thead>
<tbody><tr>
<td>핵심 인물</td>
<td>E.F. Codd (1970)</td>
</tr>
<tr>
<td>핵심 개념</td>
<td>데이터를 테이블로 추상화</td>
</tr>
<tr>
<td>쿼리 언어</td>
<td>SQL: 선언적 쿼리 언어</td>
</tr>
<tr>
<td>대표 DB</td>
<td>Oracle (1979), SQL Server (1989), PostgreSQL (1996)</td>
</tr>
</tbody></table>
<hr>
<h2 id="계층형·네트워크-db의-한계">계층형·네트워크 DB의 한계</h2>
<p>RDBMS가 혁명적이었던 이유는 기존 계층형·네트워크 DB의 한계를 해결했기 때문이다.</p>
<ul>
<li>트리/그래프 구조에서는 데이터 접근 경로를 프로그래머가 직접 코딩해야 했다.</li>
<li>스키마 변경 시 애플리케이션 전체 수정이 필요했다.</li>
<li>데이터 독립성이 부족했다.</li>
<li>물리적 저장 구조와 논리적 구조가 결합되어 있었다.</li>
<li>다대다 관계 표현이 복잡했다.</li>
<li>이러한 문제들이 Codd의 관계 모델 탄생 배경이 되었다.</li>
</ul>
<hr>
<h2 id="sql의-탄생과-표준화">SQL의 탄생과 표준화</h2>
<h3 id="sql의-진화">SQL의 진화</h3>
<table>
<thead>
<tr>
<th>연도</th>
<th>내용</th>
</tr>
</thead>
<tbody><tr>
<td>1970</td>
<td>Codd 관계 모델 논문</td>
</tr>
<tr>
<td>1974</td>
<td>SEQUEL (IBM)</td>
</tr>
<tr>
<td>1979</td>
<td>Oracle V2, 최초 상용</td>
</tr>
<tr>
<td>1986</td>
<td>SQL-86, ANSI 표준</td>
</tr>
<tr>
<td>1992</td>
<td>SQL-92</td>
</tr>
<tr>
<td>1999</td>
<td>SQL:1999, CTE / 윈도우 함수</td>
</tr>
<tr>
<td>2016</td>
<td>SQL:2016, JSON</td>
</tr>
</tbody></table>
<hr>
<h2 id="sql의-선언적-혁신">SQL의 선언적 혁신</h2>
<p>SQL은 “어떻게 가져올지”가 아니라 “무엇을 가져올지”를 기술한다.</p>
<table>
<thead>
<tr>
<th>방식</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>절차적</td>
<td>HOW를 기술, 어떻게 데이터를 가져올지 작성</td>
</tr>
<tr>
<td>선언적</td>
<td>WHAT을 기술, 무엇을 가져올지 작성</td>
</tr>
</tbody></table>
<p>예시:</p>
<pre><code class="language-sql">SELECT name FROM users
WHERE age &gt; 30;</code></pre>
<p>이 경우 사용자는 원하는 결과만 선언하고, 실제 최적 경로는 DB 엔진이 결정한다.</p>
<hr>
<h2 id="rdbms-30년-지배">RDBMS 30년 지배</h2>
<p>1980년대부터 2000년대까지 RDBMS는 데이터베이스 시장을 지배했다.</p>
<ul>
<li>1980~2000: Oracle, SQL Server, MySQL, PostgreSQL 시장 장악</li>
<li>ACID 트랜잭션: 금융 · ERP · 재고 시스템의 기반</li>
<li>SQL 표준화: 어떤 RDBMS든 비슷한 쿼리 사용 가능</li>
<li>정규화 이론: 중복 제거, 무결성 보장<ul>
<li>예)중복되는 부분을 테이블 분리 후 ID로 관리</li>
</ul>
</li>
<li>그러나 2000년대 웹 스케일 문제가 RDBMS의 한계를 드러냄</li>
</ul>
<hr>
<h1 id="nosql">NoSQL</h1>
<p>Not Only SQL</p>
<h2 id="nosql-4가지-데이터-모델">NoSQL 4가지 데이터 모델</h2>
<table>
<thead>
<tr>
<th>모델</th>
<th>대표 기술</th>
<th>특징</th>
<th>사용 예</th>
</tr>
</thead>
<tbody><tr>
<td>Key-Value</td>
<td>Redis, DynamoDB</td>
<td>최고 성능</td>
<td>캐시, 세션 관리</td>
</tr>
<tr>
<td>Document</td>
<td>MongoDB, Cosmos DB</td>
<td>JSON / BSON, 유연한 스키마</td>
<td>문서형 데이터</td>
</tr>
<tr>
<td>Column-Family</td>
<td>Cassandra, HBase</td>
<td>대규모 쓰기</td>
<td>시계열, 로그</td>
</tr>
<tr>
<td>Graph</td>
<td>Neo4j, Gremlin</td>
<td>관계 탐색</td>
<td>소셜, 추천</td>
</tr>
</tbody></table>
<hr>
<h2 id="nosql이-등장한-배경">NoSQL이 등장한 배경</h2>
<p>2000년대 Google과 Amazon이 NoSQL의 길을 열었다.</p>
<ul>
<li><p>2004: Google BigTable 논문 → Column-Family 모델에 영감</p>
</li>
<li><p>2007: Amazon Dynamo 논문 → Key-Value + Eventual Consistency</p>
</li>
<li><p>2009: MongoDB, Cassandra, Redis 등 폭발적 등장</p>
</li>
<li><p>핵심 동인: 웹 스케일</p>
<ul>
<li>수십억 사용자</li>
<li>페타바이트 데이터</li>
</ul>
</li>
<li><p>RDBMS의 수직 확장 한계</p>
<ul>
<li>단일 서버 성능에 의존</li>
</ul>
</li>
<li><p>수평 확장 필요</p>
<ul>
<li>Scale-Out</li>
<li>분산 시스템</li>
<li>CAP 트레이드오프 발생</li>
</ul>
</li>
</ul>
<p>실시간으로 기록해야하는 부분(그날 게임점수 신기록) 이런건 NoSQL로 처리하는게 이득</p>
<hr>
<h1 id="데이터베이스의-종류와-역사">데이터베이스의 종류와 역사</h1>
<h2 id="데이터베이스란">데이터베이스란?</h2>
<p>데이터베이스는 구조화된 정보나 데이터의 조직화된 모음이다. 일반적으로 컴퓨터 시스템에 전자적으로 저장된다.</p>
<p>DBMS(Database Management System)는 사용자와 애플리케이션이 데이터베이스와 상호 작용할 수 있게 해주는 소프트웨어이다.</p>
<h3 id="주요-역할">주요 역할</h3>
<table>
<thead>
<tr>
<th>역할</th>
</tr>
</thead>
<tbody><tr>
<td>데이터의 일관성 유지</td>
</tr>
<tr>
<td>보안 및 접근 제어 관리</td>
</tr>
<tr>
<td>중복 데이터 제거 및 저장 공간 최적화</td>
</tr>
<tr>
<td>다중 사용자 접근 제어</td>
</tr>
</tbody></table>
<hr>
<h2 id="데이터와-정보의-차이">데이터와 정보의 차이</h2>
<h3 id="데이터data">데이터(Data)</h3>
<p>데이터는 가공되지 않은 원시 사실이나 관찰 결과를 의미한다. 그 자체로는 특별한 의미가 없는 단순한 숫자, 문자, 이미지 등의 모음이다.</p>
<table>
<thead>
<tr>
<th>구분</th>
<th>내용</th>
</tr>
</thead>
<tbody><tr>
<td>예시</td>
<td><code>36.5</code>, <code>&quot;홍길동&quot;</code>, <code>2023-05-15</code></td>
</tr>
<tr>
<td>특징</td>
<td>컴퓨터가 처리할 수 있는 형태로 표현된 사실</td>
</tr>
<tr>
<td>상태</td>
<td>해석되지 않은 원시 상태</td>
</tr>
</tbody></table>
<h3 id="정보information">정보(Information)</h3>
<p>정보는 데이터를 가공·처리하여 의미를 부여한 결과물이다. 특정 목적을 위해 데이터를 해석하고 조직화한 형태이다.</p>
<table>
<thead>
<tr>
<th>구분</th>
<th>내용</th>
</tr>
</thead>
<tbody><tr>
<td>예시</td>
<td>“체온은 정상(36.5℃)입니다”</td>
</tr>
<tr>
<td>특징</td>
<td>의사결정에 활용 가능한 가치 있는 결과물</td>
</tr>
<tr>
<td>상태</td>
<td>맥락과 관계성이 부여된 상태</td>
</tr>
</tbody></table>
<hr>
<h2 id="데이터베이스의-역사-개요">데이터베이스의 역사 개요</h2>
<p>데이터베이스 기술은 1960년대부터 시작되어 컴퓨팅 기술의 발전과 함께 계속 진화해왔다.</p>
<p>초기에는 단순한 파일 시스템으로 데이터를 관리했지만, 시간이 지나면서 계층형 DB, 네트워크형 DB, 관계형 DB, 객체지향 DB, NoSQL, NewSQL, 클라우드 DB까지 발전했다.</p>
<p>이러한 변화는 다음 요인에 의해 가속화되었다.</p>
<table>
<thead>
<tr>
<th>발전 요인</th>
</tr>
</thead>
<tbody><tr>
<td>하드웨어 성능 향상</td>
</tr>
<tr>
<td>네트워크 기술 발전</td>
</tr>
<tr>
<td>비즈니스 요구사항 변화</td>
</tr>
</tbody></table>
<hr>
<h1 id="파일-시스템-시대">파일 시스템 시대</h1>
<h2 id="파일-시스템-시대-1세대">파일 시스템 시대 (1세대)</h2>
<p>파일 시스템은 초기 데이터 관리 방식이다. 데이터를 데이터베이스가 아니라 파일 단위로 저장하고, 애플리케이션이 직접 파일을 읽고 쓰는 방식이었다.</p>
<h3 id="파일-시스템의-특징">파일 시스템의 특징</h3>
<table>
<thead>
<tr>
<th>특징</th>
</tr>
</thead>
<tbody><tr>
<td>데이터를 단순 텍스트 파일이나 바이너리 파일로 저장</td>
</tr>
<tr>
<td>각 애플리케이션마다 독립적인 파일 구조 사용</td>
</tr>
<tr>
<td>메인프레임 컴퓨터에서 주로 사용</td>
</tr>
</tbody></table>
<h3 id="파일-시스템의-한계">파일 시스템의 한계</h3>
<table>
<thead>
<tr>
<th>한계</th>
</tr>
</thead>
<tbody><tr>
<td>데이터 중복 발생 불가피</td>
</tr>
<tr>
<td>일관성 유지 어려움</td>
</tr>
<tr>
<td>복잡한 쿼리나 검색 기능 부재</td>
</tr>
<tr>
<td>데이터 보안 취약</td>
</tr>
<tr>
<td>동시 접근 제어 불가능</td>
</tr>
</tbody></table>
<p>이 시기에는 프로그래머가 직접 파일 관리 로직을 구현해야 했다. 따라서 데이터 접근과 관리에 많은 시간과 자원이 소모되었다.</p>
<hr>
<h1 id="계층형-데이터베이스">계층형 데이터베이스</h1>
<h2 id="계층형-데이터베이스-2세대">계층형 데이터베이스 (2세대)</h2>
<p>계층형 데이터베이스는 데이터를 트리 구조로 구성한다. 부모-자식 관계를 중심으로 데이터를 표현하며, 하향식으로 데이터를 탐색한다.</p>
<h3 id="주요-특징">주요 특징</h3>
<table>
<thead>
<tr>
<th>특징</th>
</tr>
</thead>
<tbody><tr>
<td>트리 구조로 데이터 구성</td>
</tr>
<tr>
<td>부모-자식 관계(1:N) 표현</td>
</tr>
<tr>
<td>하향식 데이터 탐색 방식</td>
</tr>
<tr>
<td>IBM IMS(Information Management System, 1966년) 등장</td>
</tr>
</tbody></table>
<h3 id="한계점">한계점</h3>
<table>
<thead>
<tr>
<th>한계</th>
</tr>
</thead>
<tbody><tr>
<td>복잡한 관계 표현 어려움</td>
</tr>
<tr>
<td>데이터 접근 경로 사전 정의 필요</td>
</tr>
<tr>
<td>구조 변경 시 전체 시스템에 영향</td>
</tr>
</tbody></table>
<p>계층형 데이터베이스는 파일 시스템의 한계를 극복하고 구조화된 데이터 관리를 가능하게 했지만, 복잡한 다대다 관계를 표현하기에는 제한적이었다.</p>
<hr>
<h1 id="네트워크형-데이터베이스">네트워크형 데이터베이스</h1>
<h2 id="네트워크형-데이터베이스-1">네트워크형 데이터베이스</h2>
<p>네트워크형 데이터베이스는 계층형 모델보다 복잡한 관계를 표현하기 위해 등장했다. 그래프 구조를 사용하며 레코드 간 다대다 관계를 표현할 수 있다.</p>
<table>
<thead>
<tr>
<th>요소</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>그래프 구조</td>
<td>레코드 간 다대다(N:M) 관계 표현 가능</td>
</tr>
<tr>
<td>CODASYL</td>
<td>1970년대 네트워크 DB 표준화 모델 개발</td>
</tr>
<tr>
<td>포인터 시스템</td>
<td>레코드 간 직접 연결 포인터 사용</td>
</tr>
<tr>
<td>유연한 쿼리</td>
<td>계층형보다 향상된 데이터 검색 기능</td>
</tr>
</tbody></table>
<p>네트워크형 데이터베이스는 계층형 모델의 제한을 극복했지만, 여전히 데이터 구조와 쿼리가 복잡하다는 한계가 있었다.</p>
<hr>
<h1 id="관계형-데이터베이스-도입">관계형 데이터베이스 도입</h1>
<h2 id="ef-codd의-혁신">E.F. Codd의 혁신</h2>
<p>1970년 IBM 연구원 에드거 F. 코드(E.F. Codd)는 <strong>“A Relational Model of Data for Large Shared Data Banks”</strong>라는 논문을 발표했다. 이 논문은 관계형 데이터베이스의 이론적 기반을 마련했다.</p>
<h3 id="주요-혁신점">주요 혁신점</h3>
<table>
<thead>
<tr>
<th>혁신점</th>
</tr>
</thead>
<tbody><tr>
<td>테이블(릴레이션) 구조 도입</td>
</tr>
<tr>
<td>수학적 집합 이론 기반</td>
</tr>
<tr>
<td>데이터와 물리적 저장 구조 분리</td>
</tr>
<tr>
<td>선언적 쿼리 언어 개념 제시</td>
</tr>
</tbody></table>
<p>코드의 관계형 모델은 이전 데이터베이스 패러다임의 복잡성을 극복하고, 직관적이고 유연한 데이터 구조를 제공했다. 이 개념은 현대 데이터베이스 발전의 토대가 되었다.</p>
<hr>
<h2 id="관계형-dbms의-발전-3세대">관계형 DBMS의 발전 (3세대)</h2>
<table>
<thead>
<tr>
<th>시기</th>
<th>내용</th>
</tr>
</thead>
<tbody><tr>
<td>1974</td>
<td>IBM System R 프로젝트에서 SQL(Structured Query Language) 개발</td>
</tr>
<tr>
<td>1979</td>
<td>Oracle V2 출시, 최초의 상업용 RDBMS</td>
</tr>
<tr>
<td>1980년대</td>
<td>IBM DB2, Informix, Sybase 등장</td>
</tr>
<tr>
<td>1989</td>
<td>ANSI 및 ISO에서 SQL 표준 제정</td>
</tr>
<tr>
<td>1990년대</td>
<td>Microsoft SQL Server, MySQL 등장으로 RDBMS 대중화</td>
</tr>
</tbody></table>
<p>관계형 데이터베이스는 표준화된 SQL의 등장과 함께 산업 표준으로 자리 잡았다. 비즈니스 애플리케이션과 공공 시스템에서 널리 채택되었고, 이 시기에 데이터 무결성, 트랜잭션 처리, 백업/복구 등의 핵심 기능이 발전했다.</p>
<hr>
<h1 id="객체지향-dbms">객체지향 DBMS</h1>
<h2 id="객체지향-dbms-4세대">객체지향 DBMS (4세대)</h2>
<p>1980년대 후반 객체지향 프로그래밍이 인기를 얻으면서 복잡한 데이터 구조를 더 잘 표현할 수 있는 데이터베이스의 필요성이 커졌다.</p>
<h3 id="객체지향-db의-등장-배경">객체지향 DB의 등장 배경</h3>
<table>
<thead>
<tr>
<th>내용</th>
</tr>
</thead>
<tbody><tr>
<td>객체지향 프로그래밍의 확산</td>
</tr>
<tr>
<td>복잡한 데이터 구조 표현 필요</td>
</tr>
<tr>
<td>객체와 데이터베이스 간 표현 차이 해소 필요</td>
</tr>
</tbody></table>
<h3 id="주요-특징-1">주요 특징</h3>
<table>
<thead>
<tr>
<th>특징</th>
</tr>
</thead>
<tbody><tr>
<td>객체와 클래스 개념을 데이터베이스에 적용</td>
</tr>
<tr>
<td>상속, 다형성, 캡슐화 지원</td>
</tr>
<tr>
<td>복잡한 데이터 타입과 관계 표현 가능</td>
</tr>
<tr>
<td>객체 참조를 통한 관계 표현</td>
</tr>
</tbody></table>
<h3 id="대표-시스템">대표 시스템</h3>
<table>
<thead>
<tr>
<th>대표 객체지향 DBMS</th>
</tr>
</thead>
<tbody><tr>
<td>GemStone</td>
</tr>
<tr>
<td>ObjectStore</td>
</tr>
<tr>
<td>Versant</td>
</tr>
</tbody></table>
<p>객체지향 DBMS는 복잡한 데이터 표현에 강점이 있었지만, 관계형 데이터베이스의 강력한 점유율과 표준화된 SQL의 부재로 인해 주류 시장에서는 제한적인 성공을 거두었다.</p>
<hr>
<h1 id="객체-관계형-dbms">객체-관계형 DBMS</h1>
<h2 id="객체-관계형-dbms-5세대">객체-관계형 DBMS (5세대)</h2>
<p>객체-관계형 DBMS는 관계형 데이터베이스의 테이블 구조와 객체지향 데이터베이스의 유연성을 결합한 하이브리드 모델이다.</p>
<h3 id="하이브리드-접근법">하이브리드 접근법</h3>
<p>관계형 데이터베이스의 테이블 구조와 객체지향 데이터베이스의 유연성을 결합한다.</p>
<h3 id="확장된-데이터-타입">확장된 데이터 타입</h3>
<p>복잡한 사용자 정의 데이터 타입, 배열, XML, JSON 등 다양한 형식의 데이터를 직접 저장하고 처리할 수 있다.</p>
<h3 id="sql-확장">SQL 확장</h3>
<p>객체 조작을 위한 확장된 SQL 구문을 제공하여 복잡한 데이터 구조에 대한 쿼리를 쉽게 한다.</p>
<h3 id="대표-시스템-1">대표 시스템</h3>
<table>
<thead>
<tr>
<th>대표 객체-관계형 DBMS</th>
</tr>
</thead>
<tbody><tr>
<td>PostgreSQL</td>
</tr>
<tr>
<td>Oracle Database</td>
</tr>
<tr>
<td>IBM DB2</td>
</tr>
<tr>
<td>Microsoft SQL Server</td>
</tr>
</tbody></table>
<p>현대적인 관계형 데이터베이스 대부분은 객체-관계형 기능을 통합하고 있다.</p>
<hr>
<h1 id="dbms-역사-총정리">DBMS 역사 총정리</h1>
<table>
<thead>
<tr>
<th>세대</th>
<th>유형</th>
<th>시기</th>
<th>주요 특징</th>
<th>대표 시스템</th>
</tr>
</thead>
<tbody><tr>
<td>1세대</td>
<td>파일 시스템</td>
<td>1960년대 초</td>
<td>단순 파일 기반 데이터 저장</td>
<td>ISAM, VSAM</td>
</tr>
<tr>
<td>2세대</td>
<td>계층형</td>
<td>1960년대 중반</td>
<td>트리 구조, 부모-자식 관계</td>
<td>IBM IMS</td>
</tr>
<tr>
<td>3세대</td>
<td>네트워크형</td>
<td>1970년대 초</td>
<td>그래프 구조, 복잡한 관계</td>
<td>IDMS, CODASYL</td>
</tr>
<tr>
<td>4세대</td>
<td>관계형</td>
<td>1970~80년대</td>
<td>테이블 구조, SQL</td>
<td>Oracle, DB2, SQL Server</td>
</tr>
<tr>
<td>5세대</td>
<td>객체지향 / 객체관계형</td>
<td>1990년대</td>
<td>객체 모델, 복잡한 데이터 처리</td>
<td>PostgreSQL, ObjectStore</td>
</tr>
</tbody></table>
<p>데이터베이스 시스템은 단순 파일 저장에서 시작해 복잡한 데이터 관계와 구조를 표현할 수 있는 형태로 발전했다. 각 세대는 이전 세대의 한계를 극복하고 새로운 요구사항을 충족하기 위해 등장했다.</p>
<hr>
<h1 id="대표적-관계형-dbms">대표적 관계형 DBMS</h1>
<h2 id="oracle-database">Oracle Database</h2>
<p>1979년 출시된 엔터프라이즈급 RDBMS이다. 대규모 트랜잭션 처리와 안정성에 강점이 있다.</p>
<table>
<thead>
<tr>
<th>항목</th>
<th>내용</th>
</tr>
</thead>
<tbody><tr>
<td>출시</td>
<td>1979년</td>
</tr>
<tr>
<td>특징</td>
<td>대규모 트랜잭션 처리, 안정성</td>
</tr>
<tr>
<td>주요 사용처</td>
<td>금융, 통신, 제조 등 대형 기업</td>
</tr>
</tbody></table>
<h2 id="mysql">MySQL</h2>
<p>1995년 출시된 오픈소스 RDBMS이다. 웹 애플리케이션과의 뛰어난 호환성과 속도가 특징이다.</p>
<table>
<thead>
<tr>
<th>항목</th>
<th>내용</th>
</tr>
</thead>
<tbody><tr>
<td>출시</td>
<td>1995년</td>
</tr>
<tr>
<td>특징</td>
<td>오픈소스, 웹 애플리케이션 친화적, 빠른 속도</td>
</tr>
<tr>
<td>주요 사용처</td>
<td>WordPress, Facebook 등 웹 서비스 기반</td>
</tr>
</tbody></table>
<h2 id="microsoft-sql-server">Microsoft SQL Server</h2>
<p>1989년 출시된 Microsoft의 RDBMS이다. Windows 환경과의 통합성이 뛰어나다.</p>
<table>
<thead>
<tr>
<th>항목</th>
<th>내용</th>
</tr>
</thead>
<tbody><tr>
<td>출시</td>
<td>1989년</td>
</tr>
<tr>
<td>특징</td>
<td>Windows 환경과 뛰어난 통합성</td>
</tr>
<tr>
<td>주요 사용처</td>
<td>중소기업부터 대기업까지 다양한 비즈니스</td>
</tr>
</tbody></table>
<h2 id="postgresql">PostgreSQL</h2>
<p>1996년 출시된 고급 오픈소스 RDBMS이다. 확장성과 표준 준수에 강점이 있다.</p>
<table>
<thead>
<tr>
<th>항목</th>
<th>내용</th>
</tr>
</thead>
<tbody><tr>
<td>출시</td>
<td>1996년</td>
</tr>
<tr>
<td>특징</td>
<td>확장성, SQL 표준 준수, 복잡한 쿼리 처리</td>
</tr>
<tr>
<td>주요 사용처</td>
<td>대규모 데이터베이스, 복잡한 분석 시스템</td>
</tr>
</tbody></table>
<hr>
<h1 id="mysql과-postgresql-비교">MySQL과 PostgreSQL 비교</h1>
<h2 id="mysql의-특징">MySQL의 특징</h2>
<table>
<thead>
<tr>
<th>특징</th>
</tr>
</thead>
<tbody><tr>
<td>읽기 작업에 최적화된 성능</td>
</tr>
<tr>
<td>단순한 설치와 구성</td>
</tr>
<tr>
<td>웹 애플리케이션과의 호환성</td>
</tr>
<tr>
<td>다양한 스토리지 엔진 지원</td>
</tr>
<tr>
<td>빠른 처리 속도 중심</td>
</tr>
</tbody></table>
<p>MySQL은 주로 콘텐츠 관리 시스템, 블로그, 웹 애플리케이션에 적합하다.</p>
<h2 id="postgresql의-특징">PostgreSQL의 특징</h2>
<table>
<thead>
<tr>
<th>특징</th>
</tr>
</thead>
<tbody><tr>
<td>SQL 표준 준수 중시</td>
</tr>
<tr>
<td>복잡한 쿼리 최적화</td>
</tr>
<tr>
<td>고급 데이터 타입과 함수 지원</td>
</tr>
<tr>
<td>지리 정보 시스템(GIS) 기능</td>
</tr>
<tr>
<td>동시성과 안정성 중심</td>
</tr>
</tbody></table>
<p>PostgreSQL은 금융 시스템, 과학 연구, 복잡한 데이터 분석에 적합하다.</p>
<hr>
<h1 id="오픈소스-db의-부상">오픈소스 DB의 부상</h1>
<table>
<thead>
<tr>
<th>시기</th>
<th>내용</th>
</tr>
</thead>
<tbody><tr>
<td>1995년</td>
<td>MySQL 출시. Michael Widenius와 David Axmark이 개발. 무료 관계형 DB로 웹 개발에 혁신을 가져옴</td>
</tr>
<tr>
<td>1996년</td>
<td>PostgreSQL 출시. 버클리 대학 POSTGRES 프로젝트에서 발전한 고급 오픈소스 RDBMS</td>
</tr>
<tr>
<td>2000년대 초</td>
<td>닷컴 붐과 저비용 웹 솔루션 수요 증가로 MySQL 등 오픈소스 DB 채택 급증</td>
</tr>
<tr>
<td>2008년</td>
<td>Sun Microsystems가 MySQL을 10억 달러에 인수. 오픈소스 DB의 상업적 가치 입증</td>
</tr>
<tr>
<td>2010년 이후</td>
<td>오픈소스 DB가 엔터프라이즈 영역에서도 주류화. MariaDB, MongoDB 등 다양한 솔루션 등장</td>
</tr>
</tbody></table>
<hr>
<h1 id="엔터프라이즈-dbms">엔터프라이즈 DBMS</h1>
<h2 id="oracle-database-1">Oracle Database</h2>
<p>글로벌 금융 기관과 대기업에서 많이 사용되는 엔터프라이즈 DBMS이다.</p>
<table>
<thead>
<tr>
<th>주요 특징</th>
</tr>
</thead>
<tbody><tr>
<td>고성능 트랜잭션 처리(OLTP) 지원</td>
</tr>
<tr>
<td>실시간 애플리케이션 클러스터(RAC)</td>
</tr>
<tr>
<td>고급 보안 및 암호화 기능</td>
</tr>
<tr>
<td>다양한 데이터 유형 지원: 공간 데이터, 멀티미디어 등</td>
</tr>
</tbody></table>
<h2 id="ibm-db2">IBM Db2</h2>
<p>대형 메인프레임 환경에서 강점을 가진 엔터프라이즈 데이터베이스이다.</p>
<table>
<thead>
<tr>
<th>주요 특징</th>
</tr>
</thead>
<tbody><tr>
<td>고성능 분석 처리</td>
</tr>
<tr>
<td>AI 기반 쿼리 최적화</td>
</tr>
<tr>
<td>하이브리드 트랜잭션/분석 처리(HTAP)</td>
</tr>
<tr>
<td>메인프레임 환경과의 통합 지원</td>
</tr>
</tbody></table>
<h2 id="microsoft-sql-server-1">Microsoft SQL Server</h2>
<p>Windows 환경에서 강력한 통합 기능을 제공하는 엔터프라이즈 솔루션이다.</p>
<table>
<thead>
<tr>
<th>주요 특징</th>
</tr>
</thead>
<tbody><tr>
<td>비즈니스 인텔리전스 도구 통합</td>
</tr>
<tr>
<td>인메모리 OLTP 엔진</td>
</tr>
<tr>
<td>Microsoft 생태계와 높은 통합성</td>
</tr>
<tr>
<td>고급 보안 및 감사 기능</td>
</tr>
</tbody></table>
<hr>
<h1 id="nosql의-등장">NoSQL의 등장</h1>
<h2 id="nosql의-의미">NoSQL의 의미</h2>
<p>NoSQL은 <strong>Not Only SQL</strong>을 의미한다. 전통적인 관계형 데이터베이스의 한계를 넘어서기 위해 등장한 새로운 데이터베이스 패러다임이다.</p>
<h2 id="등장-배경">등장 배경</h2>
<table>
<thead>
<tr>
<th>배경</th>
</tr>
</thead>
<tbody><tr>
<td>웹 2.0과 소셜 미디어의 폭발적 성장</td>
</tr>
<tr>
<td>빅데이터와 실시간 분석 필요성</td>
</tr>
<tr>
<td>수평적 확장성(Scale-out) 요구</td>
</tr>
<tr>
<td>유연한 스키마와 다양한 데이터 형식 필요</td>
</tr>
</tbody></table>
<p>2000년대 후반 Google의 BigTable과 Amazon의 Dynamo 논문이 발표되면서 NoSQL 움직임이 본격화되었다. 이후 MongoDB, Cassandra, Redis 등 다양한 NoSQL 데이터베이스가 등장하며 새로운 데이터 저장 패러다임을 형성했다.</p>
<hr>
<h1 id="nosql-배경">NoSQL 배경</h1>
<table>
<thead>
<tr>
<th>수치</th>
<th>의미</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>2.5EB</td>
<td>일일 생성 데이터</td>
<td>2020년 기준 전 세계에서 하루에 생성되는 데이터 양. 이 중 80% 이상이 비정형 데이터</td>
</tr>
<tr>
<td>1B+</td>
<td>소셜 미디어 사용자</td>
<td>소셜 미디어 플랫폼 사용자는 매일 수십억 건의 상호작용 데이터를 생성</td>
</tr>
<tr>
<td>40%</td>
<td>연간 데이터 증가율</td>
<td>기업 데이터는 연평균 40% 이상 증가</td>
</tr>
<tr>
<td>1000x</td>
<td>처리 속도 향상</td>
<td>일부 NoSQL 시스템은 특정 워크로드에서 관계형 DB보다 최대 1000배 빠른 처리 속도 제공</td>
</tr>
</tbody></table>
<hr>
<h1 id="nosql-주요-유형-개관">NoSQL 주요 유형 개관</h1>
<table>
<thead>
<tr>
<th>유형</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>Key-Value</td>
<td>단순한 키와 값의 쌍으로 데이터 저장. 고속 읽기/쓰기에 최적화</td>
</tr>
<tr>
<td>Document</td>
<td>JSON/BSON 형식의 문서로 데이터 저장. 유연한 스키마</td>
</tr>
<tr>
<td>Column</td>
<td>컬럼 패밀리 단위로 데이터 저장. 대규모 분석에 적합</td>
</tr>
<tr>
<td>Graph</td>
<td>노드와 관계로 데이터 저장. 복잡한 연결 분석에 최적화</td>
</tr>
</tbody></table>
<p>각 NoSQL 유형은 특정 사용 사례와 데이터 모델에 최적화되어 있다. 관계형 데이터베이스로 해결하기 어려운 특정 문제를 효율적으로 해결할 수 있다. 오늘날 많은 기업은 다양한 워크로드를 위해 여러 유형의 데이터베이스를 함께 사용하는 멀티 모델 접근 방식을 채택하고 있다.</p>
<hr>
<h1 id="key-value-db">Key-Value DB</h1>
<h2 id="작동-원리">작동 원리</h2>
<p>Key-Value DB는 단순한 키(key)와 값(value)의 쌍으로 데이터를 저장하는 가장 기본적인 NoSQL 데이터베이스이다.</p>
<h2 id="주요-특징-2">주요 특징</h2>
<table>
<thead>
<tr>
<th>특징</th>
</tr>
</thead>
<tbody><tr>
<td>초고속 읽기/쓰기 성능</td>
</tr>
<tr>
<td>수평적 확장성(Scale-out) 용이</td>
</tr>
<tr>
<td>단순한 API: GET, PUT, DELETE</td>
</tr>
<tr>
<td>스키마 제약 없음</td>
</tr>
</tbody></table>
<h2 id="대표적-시스템">대표적 시스템</h2>
<table>
<thead>
<tr>
<th>시스템</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>Redis</td>
<td>인메모리 기반, 다양한 데이터 구조 지원</td>
</tr>
<tr>
<td>Amazon DynamoDB</td>
<td>완전 관리형 서비스</td>
</tr>
<tr>
<td>Riak</td>
<td>고가용성, 분산 아키텍처</td>
</tr>
<tr>
<td>Memcached</td>
<td>분산 캐싱 시스템</td>
</tr>
</tbody></table>
<h2 id="주요-활용-분야">주요 활용 분야</h2>
<table>
<thead>
<tr>
<th>활용 분야</th>
</tr>
</thead>
<tbody><tr>
<td>세션 관리 및 사용자 프로필</td>
</tr>
<tr>
<td>실시간 추천 엔진</td>
</tr>
<tr>
<td>쇼핑 카트 및 캐싱 시스템</td>
</tr>
<tr>
<td>IoT 데이터 저장</td>
</tr>
</tbody></table>
<hr>
<h1 id="document-db">Document DB</h1>
<h2 id="작동-원리-1">작동 원리</h2>
<p>Document DB는 JSON, BSON 또는 XML과 같은 반구조화된 형식의 문서로 데이터를 저장한다. 각 문서는 자체적으로 완결된 정보를 포함하며, 다양한 필드와 중첩 구조를 가질 수 있다.</p>
<h3 id="예시-문서">예시 문서</h3>
<pre><code class="language-json">{
  &quot;id&quot;: &quot;user123&quot;,
  &quot;name&quot;: &quot;홍길동&quot;,
  &quot;email&quot;: &quot;hong@example.com&quot;,
  &quot;orders&quot;: [
    { &quot;id&quot;: &quot;ord1&quot;, &quot;date&quot;: &quot;2023-01-15&quot; },
    { &quot;id&quot;: &quot;ord2&quot;, &quot;date&quot;: &quot;2023-02-20&quot; }
  ]
}</code></pre>
<h2 id="주요-특징-3">주요 특징</h2>
<table>
<thead>
<tr>
<th>특징</th>
</tr>
</thead>
<tbody><tr>
<td>유연한 스키마: 문서마다 다른 구조 가능</td>
</tr>
<tr>
<td>복잡한 데이터 구조의 자연스러운 표현</td>
</tr>
<tr>
<td>개발자 친화적인 데이터 모델</td>
</tr>
<tr>
<td>수평적 확장성 및 복제 지원</td>
</tr>
<tr>
<td>강력한 쿼리 기능: 인덱싱, 집계 등</td>
</tr>
</tbody></table>
<h2 id="대표-시스템-및-활용">대표 시스템 및 활용</h2>
<table>
<thead>
<tr>
<th>대표 Document DB</th>
</tr>
</thead>
<tbody><tr>
<td>MongoDB</td>
</tr>
<tr>
<td>Couchbase</td>
</tr>
<tr>
<td>Firebase Firestore</td>
</tr>
<tr>
<td>Amazon DocumentDB</td>
</tr>
</tbody></table>
<p>주요 활용 분야는 웹 애플리케이션, 콘텐츠 관리 시스템, 카탈로그, 사용자 프로필 관리 등이다.</p>
<hr>
<h1 id="컬럼형-데이터베이스">컬럼형 데이터베이스</h1>
<h2 id="작동-원리-2">작동 원리</h2>
<p>컬럼형 데이터베이스는 데이터를 행이 아닌 컬럼 단위로 저장한다. 관련 컬럼들은 <strong>컬럼 패밀리</strong>로 그룹화된다. 분석 쿼리에서 필요한 컬럼만 효율적으로 읽을 수 있다는 장점이 있다.</p>
<h2 id="주요-특징-4">주요 특징</h2>
<table>
<thead>
<tr>
<th>특징</th>
</tr>
</thead>
<tbody><tr>
<td>대규모 데이터 분석에 최적화</td>
</tr>
<tr>
<td>고도의 확장성: 페타바이트 규모</td>
</tr>
<tr>
<td>희소 매트릭스 효율적 처리</td>
</tr>
<tr>
<td>데이터 압축률이 높음</td>
</tr>
<tr>
<td>분산 아키텍처 기본 지원</td>
</tr>
</tbody></table>
<h2 id="대표-시스템-2">대표 시스템</h2>
<table>
<thead>
<tr>
<th>시스템</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>Apache Cassandra</td>
<td>높은 가용성, 선형적 확장성</td>
</tr>
<tr>
<td>HBase</td>
<td>Hadoop 에코시스템 기반</td>
</tr>
<tr>
<td>Google Bigtable</td>
<td>구글의 대규모 데이터 처리 시스템</td>
</tr>
<tr>
<td>ScyllaDB</td>
<td>고성능 Cassandra 호환 시스템</td>
</tr>
</tbody></table>
<h2 id="주요-활용-분야-1">주요 활용 분야</h2>
<table>
<thead>
<tr>
<th>활용 분야</th>
</tr>
</thead>
<tbody><tr>
<td>시계열 데이터</td>
</tr>
<tr>
<td>센서 데이터</td>
</tr>
<tr>
<td>로그 분석</td>
</tr>
<tr>
<td>대규모 분석 시스템</td>
</tr>
</tbody></table>
<hr>
<h1 id="그래프-db">그래프 DB</h1>
<p>그래프 DB는 실제 세계의 연결 구조를 노드와 관계로 모델링한다.</p>
<table>
<thead>
<tr>
<th>요소</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>노드와 관계</td>
<td>실제 세계의 연결 구조를 노드(개체)와 엣지(관계)로 자연스럽게 모델링</td>
</tr>
<tr>
<td>관계 탐색</td>
<td>복잡한 관계를 효율적으로 탐색하는 쿼리 성능 우수</td>
</tr>
<tr>
<td>속성 그래프</td>
<td>노드와 관계 모두에 속성을 부여할 수 있는 유연한 모델</td>
</tr>
<tr>
<td>그래프 쿼리</td>
<td>Cypher, Gremlin 등 그래프 전용 쿼리 언어 지원</td>
</tr>
</tbody></table>
<h2 id="대표-시스템-3">대표 시스템</h2>
<table>
<thead>
<tr>
<th>대표 그래프 DB</th>
</tr>
</thead>
<tbody><tr>
<td>Neo4j</td>
</tr>
<tr>
<td>Amazon Neptune</td>
</tr>
<tr>
<td>JanusGraph</td>
</tr>
<tr>
<td>ArangoDB</td>
</tr>
</tbody></table>
<h2 id="주요-활용-분야-2">주요 활용 분야</h2>
<table>
<thead>
<tr>
<th>활용 분야</th>
</tr>
</thead>
<tbody><tr>
<td>소셜 네트워크 분석</td>
</tr>
<tr>
<td>추천 엔진</td>
</tr>
<tr>
<td>사기 탐지</td>
</tr>
<tr>
<td>지식 그래프</td>
</tr>
<tr>
<td>네트워크 및 IT 운영 분석</td>
</tr>
</tbody></table>
<hr>
<h1 id="newsql의-출현">NewSQL의 출현</h1>
<p>NewSQL은 전통적인 관계형 데이터베이스의 강점과 NoSQL의 확장성을 결합한 데이터베이스이다.</p>
<table>
<thead>
<tr>
<th>핵심 요소</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>관계형 모델</td>
<td>SQL 및 ACID 트랜잭션 지원</td>
</tr>
<tr>
<td>수평적 확장성</td>
<td>NoSQL 수준의 분산 아키텍처</td>
</tr>
<tr>
<td>고성능 처리</td>
<td>트랜잭션과 분석 워크로드 동시 최적화</td>
</tr>
</tbody></table>
<h2 id="주요-특징-5">주요 특징</h2>
<table>
<thead>
<tr>
<th>특징</th>
</tr>
</thead>
<tbody><tr>
<td>분산 SQL 쿼리 처리</td>
</tr>
<tr>
<td>자동 샤딩(Sharding) 지원</td>
</tr>
<tr>
<td>실시간 분석 기능</td>
</tr>
<tr>
<td>클라우드 네이티브 설계</td>
</tr>
</tbody></table>
<h2 id="대표-시스템-4">대표 시스템</h2>
<table>
<thead>
<tr>
<th>시스템</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>Google Spanner</td>
<td>글로벌 분산 트랜잭션</td>
</tr>
<tr>
<td>CockroachDB</td>
<td>Spanner에서 영감을 받은 오픈소스 DB</td>
</tr>
<tr>
<td>VoltDB</td>
<td>인메모리 트랜잭션 처리</td>
</tr>
<tr>
<td>TiDB</td>
<td>MySQL 호환 분산 데이터베이스</td>
</tr>
</tbody></table>
<p>NewSQL은 미션 크리티컬한 트랜잭션 처리와 대규모 데이터 처리를 동시에 요구하는 현대적 애플리케이션에 적합하다.</p>
<hr>
<h1 id="인메모리-dbms">인메모리 DBMS</h1>
<h2 id="기본-개념">기본 개념</h2>
<p>인메모리 DBMS는 주로 디스크가 아닌 메인 메모리(RAM)에 데이터를 저장하고 처리하는 데이터베이스 시스템이다. 디스크 I/O 병목을 제거하여 매우 빠른 처리 속도를 제공한다.</p>
<h2 id="주요-특징-6">주요 특징</h2>
<table>
<thead>
<tr>
<th>특징</th>
</tr>
</thead>
<tbody><tr>
<td>초고속 데이터 접근: 디스크 대비 수십~수백 배</td>
</tr>
<tr>
<td>실시간 데이터 처리 및 분석</td>
</tr>
<tr>
<td>복잡한 쿼리의 빠른 실행</td>
</tr>
<tr>
<td>낮은 지연 시간(latency)</td>
</tr>
</tbody></table>
<h2 id="대표-시스템-5">대표 시스템</h2>
<table>
<thead>
<tr>
<th>시스템</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>Redis</td>
<td>오픈소스 인메모리 키-값 저장소</td>
</tr>
<tr>
<td>SAP HANA</td>
<td>기업용 인메모리 분석 플랫폼</td>
</tr>
<tr>
<td>MemSQL(SingleStore)</td>
<td>SQL 기반 분산 인메모리 DB</td>
</tr>
<tr>
<td>VoltDB</td>
<td>고성능 트랜잭션 처리</td>
</tr>
</tbody></table>
<h2 id="주요-활용-분야-3">주요 활용 분야</h2>
<table>
<thead>
<tr>
<th>활용 분야</th>
</tr>
</thead>
<tbody><tr>
<td>실시간 분석</td>
</tr>
<tr>
<td>금융 거래</td>
</tr>
<tr>
<td>게임</td>
</tr>
<tr>
<td>IoT</td>
</tr>
<tr>
<td>실시간 대시보드</td>
</tr>
</tbody></table>
<hr>
<h1 id="데이터베이스의-활용-변화">데이터베이스의 활용 변화</h1>
<p>데이터베이스의 활용 영역은 단순 트랜잭션 처리에서 실시간 분석, AI 기반 의사결정, 엣지 컴퓨팅까지 확장되고 있다.</p>
<table>
<thead>
<tr>
<th>활용 영역</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>웹 애플리케이션</td>
<td>초기 인터넷과 웹사이트를 위한 단순 데이터 저장</td>
</tr>
<tr>
<td>소셜 미디어</td>
<td>대규모 사용자 관계와 상호작용 데이터 처리</td>
</tr>
<tr>
<td>빅데이터</td>
<td>페타바이트 규모의 구조화/비구조화 데이터 분석</td>
</tr>
<tr>
<td>IoT 센서 데이터</td>
<td>수백만 디바이스의 실시간 데이터 수집과 처리</td>
</tr>
<tr>
<td>AI 및 머신러닝</td>
<td>모델 훈련 및 추론을 위한 대규모 데이터 저장소</td>
</tr>
</tbody></table>
<p>현대 애플리케이션은 다양한 데이터 유형과 워크로드를 처리할 수 있는 다기능 데이터 플랫폼을 요구한다.</p>
<hr>
<h1 id="데이터베이스와-클라우드">데이터베이스와 클라우드</h1>
<h2 id="클라우드-전환의-배경">클라우드 전환의 배경</h2>
<p>기업들은 자체 데이터센터에서 데이터베이스를 운영·유지보수하는 대신, 클라우드 제공업체가 관리하는 서비스형 데이터베이스(DBaaS)로 전환하고 있다.</p>
<h2 id="주요-이점">주요 이점</h2>
<table>
<thead>
<tr>
<th>이점</th>
</tr>
</thead>
<tbody><tr>
<td>초기 투자 비용(CapEx) 감소</td>
</tr>
<tr>
<td>자동화된 확장성 및 고가용성</td>
</tr>
<tr>
<td>관리 부담 및 운영 비용 감소</td>
</tr>
<tr>
<td>신속한 배포 및 테스트 환경 구성</td>
</tr>
<tr>
<td>최신 기술로의 지속적 업그레이드</td>
</tr>
</tbody></table>
<h2 id="주요-클라우드-제공업체">주요 클라우드 제공업체</h2>
<table>
<thead>
<tr>
<th>클라우드 제공업체</th>
</tr>
</thead>
<tbody><tr>
<td>Amazon Web Services(AWS)</td>
</tr>
<tr>
<td>Microsoft Azure</td>
</tr>
<tr>
<td>Google Cloud Platform(GCP)</td>
</tr>
<tr>
<td>IBM Cloud</td>
</tr>
<tr>
<td>Oracle Cloud</td>
</tr>
</tbody></table>
<p>2023년 기준 새로 배포되는 데이터베이스의 75% 이상이 클라우드 환경에서 구축되고 있으며, 이 비율은 계속 증가할 전망이라고 설명한다.</p>
<hr>
<h1 id="클라우드-dbms-개요">클라우드 DBMS 개요</h1>
<h2 id="관리형-서비스">관리형 서비스</h2>
<p>패치, 백업, 확장, 고가용성 설정 등 데이터베이스 관리 작업을 클라우드 제공업체가 자동으로 처리한다.</p>
<h2 id="탄력적-확장성">탄력적 확장성</h2>
<p>필요에 따라 컴퓨팅 및 스토리지 리소스를 자동으로 확장하거나 축소하여 비용을 최적화할 수 있다.</p>
<h2 id="서버리스-옵션">서버리스 옵션</h2>
<p>사용한 만큼만 비용을 지불하는 서버리스 데이터베이스 옵션을 통해 인프라 관리 부담을 제거한다.</p>
<h2 id="멀티-모델-지원">멀티 모델 지원</h2>
<p>하나의 서비스에서 관계형, 문서, 그래프 등 다양한 데이터 모델을 지원하여 애플리케이션 개발을 단순화한다.</p>
<h2 id="글로벌-분산">글로벌 분산</h2>
<p>전 세계 데이터 센터에 데이터를 자동으로 복제하여 지연 시간을 줄이고 데이터 주권 준수를 지원한다.</p>
<h2 id="서비스-통합">서비스 통합</h2>
<p>분석, 머신러닝, IoT 등 다른 클라우드 서비스와 통합하여 데이터 활용 가치를 높인다.</p>
<hr>
<h1 id="aws의-주요-db-서비스">AWS의 주요 DB 서비스</h1>
<h2 id="amazon-rds">Amazon RDS</h2>
<p>관리형 관계형 데이터베이스 서비스이다. MySQL, PostgreSQL, Oracle, SQL Server, MariaDB 등 다양한 엔진을 지원한다.</p>
<table>
<thead>
<tr>
<th>주요 기능</th>
</tr>
</thead>
<tbody><tr>
<td>자동 백업 및 패치 적용</td>
</tr>
<tr>
<td>다중 AZ 배포를 통한 고가용성</td>
</tr>
<tr>
<td>읽기 전용 복제본 지원</td>
</tr>
</tbody></table>
<h2 id="amazon-dynamodb">Amazon DynamoDB</h2>
<p>완전 관리형 NoSQL 데이터베이스 서비스이다. 무제한 확장성과 밀리초 단위 성능을 제공한다.</p>
<table>
<thead>
<tr>
<th>주요 기능</th>
</tr>
</thead>
<tbody><tr>
<td>서버리스 아키텍처</td>
</tr>
<tr>
<td>자동 다중 리전 복제</td>
</tr>
<tr>
<td>온디맨드 용량 모드</td>
</tr>
</tbody></table>
<h2 id="amazon-aurora">Amazon Aurora</h2>
<p>MySQL 및 PostgreSQL과 호환되는 클라우드 네이티브 관계형 데이터베이스이다. 기존 엔진보다 최대 5배 빠른 성능을 제공한다고 설명한다.</p>
<table>
<thead>
<tr>
<th>주요 기능</th>
</tr>
</thead>
<tbody><tr>
<td>분산 스토리지 아키텍처</td>
</tr>
<tr>
<td>자동 복구 기능</td>
</tr>
<tr>
<td>글로벌 데이터베이스 지원</td>
</tr>
</tbody></table>
<h2 id="기타-aws-db-서비스">기타 AWS DB 서비스</h2>
<table>
<thead>
<tr>
<th>서비스</th>
<th>용도</th>
</tr>
</thead>
<tbody><tr>
<td>Amazon Redshift</td>
<td>데이터 웨어하우스</td>
</tr>
<tr>
<td>ElastiCache</td>
<td>인메모리 캐싱</td>
</tr>
<tr>
<td>Neptune</td>
<td>그래프 DB</td>
</tr>
<tr>
<td>DocumentDB</td>
<td>MongoDB 호환</td>
</tr>
<tr>
<td>Timestream</td>
<td>시계열 DB</td>
</tr>
</tbody></table>
<hr>
<h1 id="google-cloud-db-서비스">Google Cloud DB 서비스</h1>
<h2 id="cloud-spanner">Cloud Spanner</h2>
<p>글로벌 분산 트랜잭션을 지원하는 수평적 확장 관계형 데이터베이스이다. 강력한 일관성과 99.999% 가용성을 제공한다.</p>
<table>
<thead>
<tr>
<th>주요 기능</th>
</tr>
</thead>
<tbody><tr>
<td>글로벌 트랜잭션 일관성</td>
</tr>
<tr>
<td>자동 샤딩 및 복제</td>
</tr>
<tr>
<td>SQL 인터페이스</td>
</tr>
</tbody></table>
<h2 id="cloud-bigtable">Cloud Bigtable</h2>
<p>대규모 분석 및 운영 워크로드를 위한 완전 관리형 NoSQL 데이터베이스 서비스이다.</p>
<table>
<thead>
<tr>
<th>주요 기능</th>
</tr>
</thead>
<tbody><tr>
<td>선형적 확장성</td>
</tr>
<tr>
<td>HBase API 호환성</td>
</tr>
<tr>
<td>빅데이터 워크로드 최적화</td>
</tr>
</tbody></table>
<h2 id="cloud-sql">Cloud SQL</h2>
<p>MySQL, PostgreSQL, SQL Server를 위한 완전 관리형 관계형 데이터베이스 서비스이다.</p>
<table>
<thead>
<tr>
<th>주요 기능</th>
</tr>
</thead>
<tbody><tr>
<td>자동 백업 및 복제</td>
</tr>
<tr>
<td>고가용성 구성</td>
</tr>
<tr>
<td>암호화 및 VPC 지원</td>
</tr>
</tbody></table>
<h2 id="bigquery">BigQuery</h2>
<p>서버리스 엔터프라이즈 데이터 웨어하우스이다. 페타바이트 규모의 데이터를 실시간으로 분석할 수 있다.</p>
<table>
<thead>
<tr>
<th>주요 기능</th>
</tr>
</thead>
<tbody><tr>
<td>SQL 기반 분석</td>
</tr>
<tr>
<td>머신러닝 통합</td>
</tr>
<tr>
<td>실시간 스트리밍 분석</td>
</tr>
</tbody></table>
<h2 id="기타-google-cloud-db-서비스">기타 Google Cloud DB 서비스</h2>
<table>
<thead>
<tr>
<th>서비스</th>
<th>용도</th>
</tr>
</thead>
<tbody><tr>
<td>Firestore</td>
<td>문서형 DB</td>
</tr>
<tr>
<td>Memorystore</td>
<td>인메모리 DB</td>
</tr>
<tr>
<td>Firebase Realtime Database</td>
<td>실시간 동기화 DB</td>
</tr>
</tbody></table>
<hr>
<h1 id="microsoft-azure-db-서비스">Microsoft Azure DB 서비스</h1>
<h2 id="azure-sql-database">Azure SQL Database</h2>
<p>Microsoft SQL Server 기반의 완전 관리형 관계형 데이터베이스 서비스이다.</p>
<table>
<thead>
<tr>
<th>주요 기능</th>
</tr>
</thead>
<tbody><tr>
<td>지능형 성능 최적화</td>
</tr>
<tr>
<td>자동 확장 및 백업</td>
</tr>
<tr>
<td>고급 보안 기능</td>
</tr>
<tr>
<td>서버리스 컴퓨팅 옵션</td>
</tr>
</tbody></table>
<h2 id="azure-cosmos-db">Azure Cosmos DB</h2>
<p>글로벌 분산 멀티 모델 데이터베이스 서비스이다. 다양한 데이터 모델과 API를 지원한다.</p>
<table>
<thead>
<tr>
<th>주요 기능</th>
</tr>
</thead>
<tbody><tr>
<td>SQL, MongoDB, Cassandra, Gremlin, Table API 지원</td>
</tr>
<tr>
<td>글로벌 분산 및 다중 지역 쓰기</td>
</tr>
<tr>
<td>밀리초 단위 응답 시간 SLA</td>
</tr>
<tr>
<td>자동 인덱싱 및 확장</td>
</tr>
</tbody></table>
<h2 id="azure-database-for-mysqlpostgresql">Azure Database for MySQL/PostgreSQL</h2>
<p>오픈소스 데이터베이스를 위한 완전 관리형 서비스이다.</p>
<table>
<thead>
<tr>
<th>주요 기능</th>
</tr>
</thead>
<tbody><tr>
<td>자동 패치 및 백업</td>
</tr>
<tr>
<td>고가용성 구성</td>
</tr>
<tr>
<td>확장 가능한 스토리지</td>
</tr>
<tr>
<td>고급 보안 기능</td>
</tr>
</tbody></table>
<h2 id="기타-azure-db-서비스">기타 Azure DB 서비스</h2>
<table>
<thead>
<tr>
<th>서비스</th>
<th>용도</th>
</tr>
</thead>
<tbody><tr>
<td>Azure Synapse Analytics</td>
<td>데이터 웨어하우스</td>
</tr>
<tr>
<td>Azure Cache for Redis</td>
<td>인메모리 DB</td>
</tr>
<tr>
<td>Azure Database for MariaDB</td>
<td>관리형 MariaDB</td>
</tr>
</tbody></table>
<hr>
<h1 id="클라우드-dbms-특징">클라우드 DBMS 특징</h1>
<h2 id="무중단-백업-및-복구">무중단 백업 및 복구</h2>
<p>자동화된 백업 시스템과 시점 복구(Point-in-Time Recovery)를 통해 데이터 손실 위험을 최소화한다. 백업 작업이 성능에 영향을 미치지 않으며 재해 복구 계획도 쉽게 구현할 수 있다.</p>
<h2 id="자동-확장-및-성능-최적화">자동 확장 및 성능 최적화</h2>
<p>워크로드 증가에 따라 리소스를 자동 확장하고, AI 기반 성능 모니터링과 최적화 도구로 데이터베이스 성능을 지속적으로 개선한다.</p>
<p>제공 기능:</p>
<table>
<thead>
<tr>
<th>기능</th>
</tr>
</thead>
<tbody><tr>
<td>쿼리 분석</td>
</tr>
<tr>
<td>인덱스 추천</td>
</tr>
<tr>
<td>자동 튜닝</td>
</tr>
</tbody></table>
<h2 id="향상된-보안-및-규정-준수">향상된 보안 및 규정 준수</h2>
<p>저장 및 전송 중 암호화, 세밀한 접근 제어, 위협 감지 등 다양한 보안 기능이 기본 제공된다.</p>
<p>지원하는 규정 준수 예시:</p>
<table>
<thead>
<tr>
<th>규정</th>
</tr>
</thead>
<tbody><tr>
<td>GDPR</td>
</tr>
<tr>
<td>HIPAA</td>
</tr>
<tr>
<td>PCI DSS</td>
</tr>
</tbody></table>
<h2 id="고가용성-및-재해-복구">고가용성 및 재해 복구</h2>
<p>다중 가용 영역 및 지역 복제, 자동 장애 조치(failover), 상시 가동 아키텍처 등을 통해 최대 99.999%의 가용성을 보장한다. 자연재해나 지역 장애에도 서비스 연속성을 유지할 수 있다.</p>
<hr>
<h1 id="온프레미스-db-vs-클라우드-db">온프레미스 DB vs 클라우드 DB</h1>
<h2 id="온프레미스-데이터베이스">온프레미스 데이터베이스</h2>
<h3 id="장점">장점</h3>
<table>
<thead>
<tr>
<th>장점</th>
</tr>
</thead>
<tbody><tr>
<td>데이터에 대한 완전한 제어권</td>
</tr>
<tr>
<td>네트워크 지연 시간 최소화</td>
</tr>
<tr>
<td>라이선스 기반 일회성 비용 구조</td>
</tr>
<tr>
<td>클라우드 의존성 없음</td>
</tr>
</tbody></table>
<h3 id="단점">단점</h3>
<table>
<thead>
<tr>
<th>단점</th>
</tr>
</thead>
<tbody><tr>
<td>높은 초기 투자 비용</td>
</tr>
<tr>
<td>확장성 제한 및 복잡성</td>
</tr>
<tr>
<td>인력 및 유지보수 부담</td>
</tr>
<tr>
<td>재해 복구 구현 어려움</td>
</tr>
</tbody></table>
<h2 id="클라우드-데이터베이스">클라우드 데이터베이스</h2>
<h3 id="장점-1">장점</h3>
<table>
<thead>
<tr>
<th>장점</th>
</tr>
</thead>
<tbody><tr>
<td>빠른 배포 및 확장성</td>
</tr>
<tr>
<td>사용량 기반 비용 구조</td>
</tr>
<tr>
<td>관리 부담 최소화</td>
</tr>
<tr>
<td>내장된 고가용성 및 재해 복구</td>
</tr>
</tbody></table>
<h3 id="단점-1">단점</h3>
<table>
<thead>
<tr>
<th>단점</th>
</tr>
</thead>
<tbody><tr>
<td>데이터 주권 및 규제 이슈</td>
</tr>
<tr>
<td>네트워크 의존성 및 지연 가능성</td>
</tr>
<tr>
<td>장기적인 비용 증가 가능성</td>
</tr>
<tr>
<td>벤더 종속성(Lock-in) 위험</td>
</tr>
</tbody></table>
<p>많은 기업은 두 접근 방식의 장점을 결합한 하이브리드 방식을 채택하고 있다. 워크로드 특성과 비즈니스 요구사항에 따라 최적의 배포 모델을 선택한다.</p>
<hr>
<h1 id="멀티-클라우드·하이브리드-db">멀티 클라우드·하이브리드 DB</h1>
<h2 id="멀티-클라우드-전략">멀티 클라우드 전략</h2>
<p>멀티 클라우드는 여러 클라우드 제공업체의 데이터베이스 서비스를 동시에 활용하는 접근 방식이다.</p>
<h3 id="장점-2">장점</h3>
<table>
<thead>
<tr>
<th>장점</th>
</tr>
</thead>
<tbody><tr>
<td>벤더 종속성 감소</td>
</tr>
<tr>
<td>각 제공업체의 강점 활용</td>
</tr>
<tr>
<td>지역별 최적 서비스 선택</td>
</tr>
<tr>
<td>협상력 및 위험 분산</td>
</tr>
</tbody></table>
<h3 id="단점-2">단점</h3>
<table>
<thead>
<tr>
<th>단점</th>
</tr>
</thead>
<tbody><tr>
<td>일관된 관리의 복잡성 증가</td>
</tr>
<tr>
<td>데이터 동기화 복잡성 증가</td>
</tr>
</tbody></table>
<h2 id="하이브리드-데이터베이스-환경">하이브리드 데이터베이스 환경</h2>
<p>하이브리드 데이터베이스 환경은 온프레미스와 클라우드 데이터베이스를 함께 운영하는 방식이다.</p>
<h3 id="장점-3">장점</h3>
<table>
<thead>
<tr>
<th>장점</th>
</tr>
</thead>
<tbody><tr>
<td>민감한 데이터는 온프레미스에 보관</td>
</tr>
<tr>
<td>탄력적 워크로드는 클라우드로 이동</td>
</tr>
<tr>
<td>점진적 클라우드 마이그레이션</td>
</tr>
<tr>
<td>기존 투자 활용과 혁신 균형</td>
</tr>
</tbody></table>
<h3 id="주요-과제">주요 과제</h3>
<table>
<thead>
<tr>
<th>과제</th>
</tr>
</thead>
<tbody><tr>
<td>데이터 일관성 유지</td>
</tr>
<tr>
<td>복잡한 네트워크 구성</td>
</tr>
</tbody></table>
<p>최근에는 Kubernetes 기반 데이터베이스 운영과 같은 컨테이너화된 접근 방식이 등장하여 환경 간 이식성을 높이고 있다. 데이터베이스 가상화 및 추상화 레이어를 통해 복잡성을 관리하는 솔루션도 발전하고 있다.</p>
<hr>
<h1 id="dbms-발전의-핵심-트렌드">DBMS 발전의 핵심 트렌드</h1>
<h2 id="오픈소스-확산">오픈소스 확산</h2>
<p>엔터프라이즈 영역에서도 오픈소스 데이터베이스 채택이 급증하고 있다.</p>
<table>
<thead>
<tr>
<th>주요 흐름</th>
</tr>
</thead>
<tbody><tr>
<td>PostgreSQL의 기업용 워크로드 확대</td>
</tr>
<tr>
<td>MongoDB, Redis 등 NoSQL 솔루션의 성숙</td>
</tr>
<tr>
<td>클라우드 제공업체의 오픈소스 호환 서비스</td>
</tr>
<tr>
<td>개발자 커뮤니티 중심 혁신 가속화</td>
</tr>
</tbody></table>
<h2 id="클라우드-전환-가속">클라우드 전환 가속</h2>
<p>기업들이 자체 데이터센터에서 클라우드 환경으로 데이터베이스를 이전하는 추세가 가속화되고 있다.</p>
<table>
<thead>
<tr>
<th>주요 흐름</th>
</tr>
</thead>
<tbody><tr>
<td>DBaaS(Database as a Service) 모델 확산</td>
</tr>
<tr>
<td>서버리스 데이터베이스 도입 증가</td>
</tr>
<tr>
<td>멀티 클라우드 및 하이브리드 전략 채택</td>
</tr>
<tr>
<td>마이그레이션 도구 및 서비스 발전</td>
</tr>
</tbody></table>
<h2 id="다양한-데이터-포맷-지원">다양한 데이터 포맷 지원</h2>
<p>정형 데이터 외에도 다양한 비정형/반정형 데이터를 처리할 수 있는 능력이 중요해지고 있다.</p>
<table>
<thead>
<tr>
<th>주요 흐름</th>
</tr>
</thead>
<tbody><tr>
<td>JSON, XML, 지리공간 데이터 네이티브 지원</td>
</tr>
<tr>
<td>텍스트, 이미지, 오디오 분석 기능 통합</td>
</tr>
<tr>
<td>그래프 데이터 및 관계 분석 강화</td>
</tr>
<tr>
<td>멀티 모델 데이터베이스 증가</td>
</tr>
</tbody></table>
<hr>
<h1 id="데이터베이스-선택-기준">데이터베이스 선택 기준</h1>
<h2 id="용도-파악">용도 파악</h2>
<p>트랜잭션 처리(OLTP), 분석(OLAP), 혼합(HTAP) 중 어떤 워크로드인지 파악해야 한다.</p>
<h2 id="데이터-특성">데이터 특성</h2>
<p>정형 데이터인지 비정형 데이터인지, 데이터 크기와 성장률은 어느 정도인지, 관계 복잡성은 어떤지 고려해야 한다.</p>
<h2 id="확장성-요구">확장성 요구</h2>
<p>예상 사용자 수, 트래픽 패턴, 수직/수평 확장 필요성을 고려해야 한다.</p>
<h2 id="일관성-vs-가용성-요구사항">일관성 vs 가용성 요구사항</h2>
<p>CAP 이론에 따라 강한 일관성(CP)이 중요한지, 가용성과 파티션 허용(AP)이 중요한지 고려해야 한다.</p>
<p>예를 들어 금융 거래는 강한 일관성이 필요하지만, 소셜 미디어는 일시적 불일치를 허용할 수 있다.</p>
<h2 id="쿼리-패턴-및-성능-요구사항">쿼리 패턴 및 성능 요구사항</h2>
<p>복잡한 조인이 필요한지, 단순 키-값 검색이 중심인지, 실시간 응답이 필요한지에 따라 적합한 DB가 달라진다.</p>
<p>명확히 정의해야 할 성능 지표:</p>
<table>
<thead>
<tr>
<th>성능 지표</th>
</tr>
</thead>
<tbody><tr>
<td>지연 시간</td>
</tr>
<tr>
<td>처리량</td>
</tr>
<tr>
<td>동시 사용자 수</td>
</tr>
</tbody></table>
<h2 id="기술-생태계-및-개발자-역량">기술 생태계 및 개발자 역량</h2>
<p>팀의 기존 기술 스택과 호환되는지, 개발자가 얼마나 익숙한지, 커뮤니티 지원과 도구 생태계가 충분한지 고려해야 한다.</p>
<p>좋은 데이터베이스라도 팀이 효과적으로 활용할 수 없다면 가치가 제한된다.</p>
<hr>
<h1 id="주요-dbms-비교-표">주요 DBMS 비교 표</h1>
<table>
<thead>
<tr>
<th>유형</th>
<th>대표 시스템</th>
<th>강점</th>
<th>약점</th>
<th>주요 사용 사례</th>
</tr>
</thead>
<tbody><tr>
<td>관계형</td>
<td>Oracle, MySQL, PostgreSQL</td>
<td>트랜잭션 처리, 데이터 일관성, SQL 표준</td>
<td>수평적 확장성 제한, 스키마 변경 어려움</td>
<td>금융, ERP, CRM 시스템</td>
</tr>
<tr>
<td>문서형</td>
<td>MongoDB, Couchbase</td>
<td>유연한 스키마, 개발 생산성, JSON 지원</td>
<td>복잡한 조인, 트랜잭션 처리 제한</td>
<td>콘텐츠 관리, 모바일 앱, 카탈로그</td>
</tr>
<tr>
<td>키-값</td>
<td>Redis, DynamoDB</td>
<td>초고속 응답, 단순성, 확장성</td>
<td>복잡한 쿼리 제한, 데이터 관계 표현 어려움</td>
<td>캐싱, 세션 관리, 실시간 분석</td>
</tr>
<tr>
<td>컬럼형</td>
<td>Cassandra, HBase</td>
<td>대규모 쓰기/분석, 수평적 확장성</td>
<td>실시간 읽기 성능, 복잡한 구성</td>
<td>IoT 데이터, 로그 분석, 시계열 데이터</td>
</tr>
<tr>
<td>그래프</td>
<td>Neo4j, Neptune</td>
<td>관계 탐색, 연결 데이터 분석</td>
<td>대규모 확장성, 학습 곡선</td>
<td>소셜 네트워크, 추천 엔진, 사기 탐지</td>
</tr>
<tr>
<td>인메모리</td>
<td>Redis, SAP HANA</td>
<td>초고속 성능, 실시간 처리</td>
<td>비용, 메모리 제한, 지속성 관리</td>
<td>실시간 분석, 캐싱, 게임 리더보드</td>
</tr>
<tr>
<td>NewSQL</td>
<td>Google Spanner, CockroachDB</td>
<td>확장성 + SQL + 트랜잭션</td>
<td>성숙도, 복잡성, 비용</td>
<td>글로벌 금융 시스템, 고확장성 앱</td>
</tr>
</tbody></table>
<p>각 데이터베이스 유형은 고유한 강점과 약점을 가지고 있으며, 특정 워크로드와 사용 사례에 최적화되어 있다.</p>
<p>많은 현대 애플리케이션은 다양한 유형의 데이터베이스를 함께 사용하는 <strong>폴리글랏 퍼시스턴스(Polyglot Persistence)</strong> 접근 방식을 채택하고 있다.</p>
<hr>
<h1 id="미래의-데이터베이스-전망">미래의 데이터베이스 전망</h1>
<h2 id="ai-기반-자율-데이터베이스">AI 기반 자율 데이터베이스</h2>
<p>인공지능이 자동으로 데이터베이스를 튜닝, 최적화, 관리하는 시스템이 확산되고 있다.</p>
<p>자동화되는 영역:</p>
<table>
<thead>
<tr>
<th>영역</th>
</tr>
</thead>
<tbody><tr>
<td>쿼리 최적화</td>
</tr>
<tr>
<td>인덱스 생성</td>
</tr>
<tr>
<td>리소스 할당</td>
</tr>
<tr>
<td>보안 위협 감지</td>
</tr>
</tbody></table>
<p>DBA의 역할도 단순 운영보다 전략적 방향으로 진화하고 있다.</p>
<h2 id="서버리스-데이터베이스의-확산">서버리스 데이터베이스의 확산</h2>
<p>인프라 관리 없이 필요한 만큼만 사용하고 비용을 지불하는 서버리스 DB가 주류화될 전망이다. 개발자는 데이터베이스 운영보다 비즈니스 로직과 애플리케이션 개발에 집중할 수 있다.</p>
<h2 id="엣지-컴퓨팅과-분산-데이터베이스">엣지 컴퓨팅과 분산 데이터베이스</h2>
<p>IoT 장치와 5G 네트워크 확산으로 데이터 생성 지점에 가까운 엣지 위치에서 데이터를 처리하는 분산 데이터베이스 시스템이 중요해질 것이다.</p>
<p>핵심 과제는 중앙 클라우드와 엣지 노드 간의 효율적인 데이터 동기화이다.</p>
<h2 id="데이터베이스와-ai의-통합">데이터베이스와 AI의 통합</h2>
<p>데이터베이스 시스템 내에서 직접 머신러닝 모델을 실행하고 학습하는 기능이 강화될 것이다.</p>
<p>데이터 이동 없이 데이터베이스 내부에서 분석과 예측을 수행하는 <strong>AI in DB</strong> 개념이 발전할 것이다.</p>
<h2 id="블록체인-기반-분산-데이터베이스">블록체인 기반 분산 데이터베이스</h2>
<p>높은 투명성과 변조 방지가 필요한 애플리케이션을 위한 블록체인 기반 데이터베이스 시스템이 발전할 것이다.</p>
<p>중요성이 증가할 산업:</p>
<table>
<thead>
<tr>
<th>산업</th>
</tr>
</thead>
<tbody><tr>
<td>공급망</td>
</tr>
<tr>
<td>금융</td>
</tr>
<tr>
<td>의료</td>
</tr>
</tbody></table>
<hr>
<h1 id="결론-데이터베이스의-진화-방향">결론: 데이터베이스의 진화 방향</h1>
<h2 id="다종-복합-db-환경-도래">다종 복합 DB 환경 도래</h2>
<p>단일 데이터베이스 시스템으로 모든 요구사항을 충족하는 시대는 지나고 있다. 현대적인 데이터 아키텍처는 다양한 유형의 데이터베이스를 목적에 맞게 조합하는 방향으로 진화하고 있다.</p>
<table>
<thead>
<tr>
<th>변화</th>
</tr>
</thead>
<tbody><tr>
<td>워크로드별 최적화된 데이터 저장소 활용</td>
</tr>
<tr>
<td>마이크로서비스와 연계된 분산 데이터 관리</td>
</tr>
<tr>
<td>데이터 통합 및 거버넌스의 중요성 증가</td>
</tr>
</tbody></table>
<h2 id="데이터-활용-방식의-변화">데이터 활용 방식의 변화</h2>
<p>데이터베이스는 단순한 저장소를 넘어 비즈니스 가치 창출의 핵심 도구로 진화하고 있다.</p>
<table>
<thead>
<tr>
<th>변화</th>
</tr>
</thead>
<tbody><tr>
<td>실시간 인사이트 생성과 의사결정 지원</td>
</tr>
<tr>
<td>AI/ML과 결합한 예측 분석 기능 강화</td>
</tr>
<tr>
<td>데이터 중심 조직으로의 변화 가속화</td>
</tr>
<tr>
<td>데이터 민주화와 셀프 서비스 분석 확산</td>
</tr>
</tbody></table>
<p>데이터베이스 기술은 60년이 넘는 역사 동안 계속 진화해왔다. 앞으로도 클라우드, AI, IoT 등의 기술과 융합하며 발전할 것이다.</p>
<p>이러한 변화에 맞춰 데이터 전략을 수립하고 적응하는 조직이 디지털 시대의 경쟁에서 우위를 점할 수 있다.</p>
<hr>
<h1 id="참고-자료">참고 자료</h1>
<h2 id="dbms-선택-실전-사례">DBMS 선택 실전 사례</h2>
<p>다양한 산업 분야에서 비즈니스 요구사항에 따라 데이터베이스를 선택한 실제 사례를 살펴볼 수 있다.</p>
<table>
<thead>
<tr>
<th>사례</th>
</tr>
</thead>
<tbody><tr>
<td>전자상거래 플랫폼의 멀티 모델 DB 전략</td>
</tr>
<tr>
<td>금융 기관의 하이브리드 클라우드 구현</td>
</tr>
<tr>
<td>의료 기관의 데이터 보안 및 규정 준수 접근법</td>
</tr>
</tbody></table>
<hr>
<h1 id="newsql과-vector-db">NewSQL과 Vector DB</h1>
<h2 id="newsql">NewSQL</h2>
<p>NewSQL은 ACID와 수평 확장을 동시에 목표로 한다.</p>
<table>
<thead>
<tr>
<th>항목</th>
<th>내용</th>
</tr>
</thead>
<tbody><tr>
<td>핵심</td>
<td>ACID + 수평 확장</td>
</tr>
<tr>
<td>대표 기술</td>
<td>CockroachDB, Google Spanner, TiDB</td>
</tr>
</tbody></table>
<hr>
<h2 id="vector-db">Vector DB</h2>
<p>Vector DB는 AI/ML 임베딩 검색에 사용된다.</p>
<table>
<thead>
<tr>
<th>항목</th>
<th>내용</th>
</tr>
</thead>
<tbody><tr>
<td>핵심</td>
<td>AI/ML 임베딩 검색</td>
</tr>
<tr>
<td>대표 기술</td>
<td>Pinecone, Weaviate, Azure AI Search</td>
</tr>
<tr>
<td>주요 활용</td>
<td>RAG 패턴 핵심</td>
</tr>
</tbody></table>
<hr>
<h1 id="acid-트랜잭션">ACID 트랜잭션</h1>
<p>ACID는 관계형 DB의 핵심 보장이다.</p>
<table>
<thead>
<tr>
<th>요소</th>
<th>의미</th>
</tr>
</thead>
<tbody><tr>
<td>Atomicity</td>
<td>전부 성공 또는 전부 실패</td>
</tr>
<tr>
<td>Consistency</td>
<td>전후 무결성 유지</td>
</tr>
<tr>
<td>Isolation</td>
<td>동시 트랜잭션 비간섭</td>
</tr>
<tr>
<td>Durability</td>
<td>커밋 후 장애에도 보존</td>
</tr>
</tbody></table>
<hr>
<h1 id="cap-정리">CAP 정리</h1>
<p>CAP는 분산 시스템에서 중요한 세 가지 특성이다.</p>
<table>
<thead>
<tr>
<th>요소</th>
<th>의미</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>C</td>
<td>Consistency</td>
<td>모든 노드 동일 데이터, 강한 일관성</td>
</tr>
<tr>
<td>A</td>
<td>Availability</td>
<td>모든 요청에 응답, 다운타임 없음</td>
</tr>
<tr>
<td>P</td>
<td>Partition Tolerance</td>
<td>네트워크 분할 시에도 동작, 분산 시스템 필수</td>
</tr>
</tbody></table>
<p>세가지를 모두 동시에 만족할 순 없다.(동시에 두개까지만 가능)</p>
<hr>
<h2 id="cap-실무-적용--서비스별-선택">CAP 실무 적용 — 서비스별 선택</h2>
<table>
<thead>
<tr>
<th>서비스</th>
<th>CAP 선택</th>
<th>일관성</th>
<th>가용성</th>
</tr>
</thead>
<tbody><tr>
<td>SQL Server</td>
<td>CP</td>
<td>강한 일관성</td>
<td>장애 시 다운타임</td>
</tr>
<tr>
<td>Cosmos DB (Strong)</td>
<td>CP</td>
<td>강한 일관성</td>
<td>쓰기 지연</td>
</tr>
<tr>
<td>Cosmos DB (Session)</td>
<td>AP (실질)</td>
<td>세션 내 일관</td>
<td>항상 응답</td>
</tr>
<tr>
<td>Cassandra</td>
<td>AP</td>
<td>최종 일관성</td>
<td>항상 응답</td>
</tr>
<tr>
<td>MongoDB (기본)</td>
<td>CP</td>
<td>강한 일관성</td>
<td>Primary 장애 시 선출 대기</td>
</tr>
</tbody></table>
<hr>
<h1 id="base-특성">BASE 특성</h1>
<p>BASE는 NoSQL에서 많이 사용하는 유연한 일관성 모델이다.</p>
<table>
<thead>
<tr>
<th>요소</th>
<th>의미</th>
</tr>
</thead>
<tbody><tr>
<td>Basically Available</td>
<td>항상 응답, 오래된 데이터라도 응답</td>
</tr>
<tr>
<td>Soft State</td>
<td>시스템 상태가 시간에 따라 변함</td>
</tr>
<tr>
<td>Eventually Consistent</td>
<td>충분한 시간이 지나면 일관성에 도달</td>
</tr>
</tbody></table>
<hr>
<h2 id="acid-vs-base-비교">ACID vs BASE 비교</h2>
<table>
<thead>
<tr>
<th>구분</th>
<th>ACID (RDBMS)</th>
<th>BASE (NoSQL)</th>
</tr>
</thead>
<tbody><tr>
<td>일관성</td>
<td>강한 일관성 (Strong)</td>
<td>최종 일관성 (Eventual)</td>
</tr>
<tr>
<td>확장</td>
<td>수직 확장 (Scale-Up)</td>
<td>수평 확장 (Scale-Out)</td>
</tr>
<tr>
<td>스키마</td>
<td>고정 (DDL)</td>
<td>유연 (Schema-less)</td>
</tr>
<tr>
<td>트랜잭션</td>
<td>복잡한 조인 최적</td>
<td>대량 읽기/쓰기 최적</td>
</tr>
<tr>
<td>사용 사례</td>
<td>금융 · ERP · 재고</td>
<td>소셜 · IoT · 실시간 분석</td>
</tr>
</tbody></table>
<hr>
<h1 id="db-진화-요약">DB 진화 요약</h1>
<p>60년의 DB 진화를 한 장으로 요약하면 다음과 같다.</p>
<table>
<thead>
<tr>
<th>시대</th>
<th>변화</th>
<th>핵심 의미</th>
</tr>
</thead>
<tbody><tr>
<td>1960s → 1970s</td>
<td>계층형 → 관계형</td>
<td>데이터 독립성 혁명</td>
</tr>
<tr>
<td>2000s</td>
<td>NoSQL</td>
<td>스케일과 유연성 혁명</td>
</tr>
<tr>
<td>2010s</td>
<td>NewSQL</td>
<td>ACID + Scale-Out 결합 시도</td>
</tr>
<tr>
<td>2020s</td>
<td>Vector DB</td>
<td>AI/ML 시대의 검색 인프라</td>
</tr>
</tbody></table>
<p>핵심 메시지는 다음과 같다.</p>
<blockquote>
<p>정답은 없다. 워크로드에 맞는 선택이 최선이다.</p>
</blockquote>
<p>이 원칙이 이후 전체 과정의 판단 기준이 된다.</p>
<hr>
<h1 id="azure-데이터-서비스-지형도">Azure 데이터 서비스 지형도</h1>
<h2 id="azure-데이터-서비스-전체-맵">Azure 데이터 서비스 전체 맵</h2>
<p>Azure에서 선택할 수 있는 데이터 서비스는 크게 네 영역으로 나눌 수 있다.</p>
<table>
<thead>
<tr>
<th>분류</th>
<th>서비스</th>
<th>특징</th>
</tr>
</thead>
<tbody><tr>
<td>IaaS — VM 기반</td>
<td>SQL Server on VM, PG/MySQL on VM</td>
<td>100% 제어</td>
</tr>
<tr>
<td>PaaS — 관계형</td>
<td>SQL Database, SQL MI, Flexible Server</td>
<td>관리형 관계형 DB</td>
</tr>
<tr>
<td>PaaS — NoSQL</td>
<td>Cosmos DB</td>
<td>5 API, 글로벌 분산</td>
</tr>
<tr>
<td>분석 · 시계열</td>
<td>Synapse, ADX, Fabric</td>
<td>분석 및 시계열 처리</td>
</tr>
</tbody></table>
<hr>
<h2 id="sql-vm-vs-sql-db-vs-sql-mi">SQL VM vs SQL DB vs SQL MI</h2>
<table>
<thead>
<tr>
<th>구분</th>
<th>SQL VM</th>
<th>SQL DB</th>
<th>SQL MI</th>
</tr>
</thead>
<tbody><tr>
<td>관리</td>
<td>OS 직접 관리</td>
<td>완전 관리</td>
<td>거의 완전 관리</td>
</tr>
<tr>
<td>호환성</td>
<td>100%</td>
<td>일부 제한</td>
<td>99%</td>
</tr>
<tr>
<td>HA</td>
<td>AG 직접 구성</td>
<td>내장 자동</td>
<td>내장 자동</td>
</tr>
<tr>
<td>비용</td>
<td>VM + 라이선스</td>
<td>DTU / vCore</td>
<td>vCore</td>
</tr>
<tr>
<td>프로비저닝</td>
<td>수 분</td>
<td>수 분</td>
<td>약 4시간</td>
</tr>
<tr>
<td>부트캠프</td>
<td>Day 2-3</td>
<td>Day 4-5</td>
<td>이론만 (D-2)</td>
</tr>
</tbody></table>
<hr>
<h1 id="cosmos-db-개요">Cosmos DB 개요</h1>
<p>Cosmos DB는 글로벌 분산 NoSQL 서비스이다.</p>
<ul>
<li>글로벌 분산 NoSQL</li>
<li>60개 이상 Azure 리전 지원</li>
<li>10ms 미만 읽기/쓰기</li>
<li>5가지 일관성 수준</li>
<li>5가지 API</li>
<li>상세 실습은 Day 6에서 진행 예정</li>
</ul>
<hr>
<h1 id="oss-paas--분석-서비스">OSS PaaS + 분석 서비스</h1>
<table>
<thead>
<tr>
<th>서비스</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>PG Flexible Server</td>
<td>Day 5 마이그레이션 타깃</td>
</tr>
<tr>
<td>MySQL Flexible Server</td>
<td>MySQL 관리형 서비스</td>
</tr>
<tr>
<td>Synapse Analytics</td>
<td>데이터 웨어하우스</td>
</tr>
<tr>
<td>ADX</td>
<td>시계열 분석, Day 7 실습, Free Cluster</td>
</tr>
<tr>
<td>Microsoft Fabric</td>
<td>통합 분석 플랫폼</td>
</tr>
</tbody></table>
<hr>
<h1 id="의사결정-트리--5개-질문">의사결정 트리 — 5개 질문</h1>
<p>Azure 데이터 서비스를 선택할 때 다음 질문을 기준으로 판단한다.</p>
<table>
<thead>
<tr>
<th>질문</th>
<th>선택</th>
</tr>
</thead>
<tbody><tr>
<td>SQL Server 100% 필요?</td>
<td>SQL VM</td>
</tr>
<tr>
<td>완전 관리형 + SQL 호환?</td>
<td>SQL MI (이론)</td>
</tr>
<tr>
<td>관계형 + 비용 최적?</td>
<td>SQL DB Serverless</td>
</tr>
<tr>
<td>PG/MySQL 워크로드?</td>
<td>Flexible Server</td>
</tr>
<tr>
<td>글로벌 분산 + 다중 모델?</td>
<td>Cosmos DB</td>
</tr>
</tbody></table>
<hr>
<h1 id="워크샵-시나리오별-서비스-선택">워크샵: 시나리오별 서비스 선택</h1>
<table>
<thead>
<tr>
<th>시나리오</th>
<th>선택</th>
</tr>
</thead>
<tbody><tr>
<td>온프레미스 SQL 2016 이관</td>
<td>MI가 이상적이지만 프로비저닝 4시간 → SQL DB 검토</td>
</tr>
<tr>
<td>글로벌 게임 프로필</td>
<td>Cosmos DB NoSQL API</td>
</tr>
<tr>
<td>IoT 센서 실시간</td>
<td>ADX + KQL</td>
</tr>
</tbody></table>
<hr>
<h1 id="환경-셋업-실습">환경 셋업 실습</h1>
<p>Azure 구독부터 Budget Alert까지 직접 구축한다.</p>
<hr>
<h2 id="워크샵-가이드-의사결정-트리-실습">워크샵 가이드: 의사결정 트리 실습</h2>
<p>목표는 3개 시나리오에 대해 2~3인 조별 토론 후 서비스 선택 결과를 발표하는 것이다.</p>
<table>
<thead>
<tr>
<th>순서</th>
<th>내용</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>강사가 시나리오 3개를 화면에 표시</td>
</tr>
<tr>
<td>2</td>
<td>2~3인 조 구성</td>
</tr>
<tr>
<td>3</td>
<td>조별 5분 토론: 각 시나리오에 적합한 Azure 서비스 선택</td>
</tr>
<tr>
<td>4</td>
<td>각 조 1분 발표: 선택한 서비스와 이유</td>
</tr>
<tr>
<td>5</td>
<td>강사가 정답과 추가 고려사항 설명</td>
</tr>
<tr>
<td>6</td>
<td>의사결정 트리와 비교하며 피드백</td>
</tr>
</tbody></table>
<hr>
<h1 id="실습-1-구독-·-리소스-그룹-생성">실습 1: 구독 · 리소스 그룹 생성</h1>
<p>부트캠프 전용 리소스 그룹을 생성한다.</p>
<pre><code class="language-bash"># Cloud Shell: Portal 상단 &gt;_ 아이콘

# 구독 확인
az account show --output table

# 리소스 그룹 생성
az group create \
--name rg-bootcamp-day1 \
--location koreacentral \
--tags &quot;project=bootcamp&quot; &quot;owner=&lt;이름&gt;&quot;

# 확인
az group show --name rg-bootcamp-day1 -o table</code></pre>
<hr>
<h1 id="실습-2-vnet-·-nsg">실습 2: VNet · NSG</h1>
<p>부트캠프 전 과정에서 사용할 VNet과 NSG를 생성한다.</p>
<pre><code class="language-bash"># VNet 생성
az network vnet create \
-g rg-bootcamp-day1 \
--name vnet-bootcamp \
--address-prefix 10.0.0.0/16 \
--subnet-name snet-default \
--subnet-prefix 10.0.1.0/24

# NSG 생성 + SSH 규칙
az network nsg create \
-g rg-bootcamp-day1 -n nsg-bootcamp

az network nsg rule create \
-g rg-bootcamp-day1 --nsg-name nsg-bootcamp \
-n AllowSSH --priority 1000 \
--destination-port-ranges 22 \
--access Allow --protocol Tcp --direction Inbound</code></pre>
<hr>
<h1 id="vnet과-nsg-기본-개념">VNet과 NSG 기본 개념</h1>
<h2 id="vnet-가상-네트워크">VNet (가상 네트워크)</h2>
<p>VNet은 Azure 내 프라이빗 네트워크이다.</p>
<table>
<thead>
<tr>
<th>항목</th>
<th>내용</th>
</tr>
</thead>
<tbody><tr>
<td>주소 공간</td>
<td>10.0.0.0/16</td>
</tr>
<tr>
<td>서브넷</td>
<td>10.0.1.0/24</td>
</tr>
<tr>
<td>역할</td>
<td>VM, DB, 서비스가 통신하는 기반</td>
</tr>
<tr>
<td>확장</td>
<td>온프레미스 VPN 연결 가능</td>
</tr>
<tr>
<td>부트캠프 사용</td>
<td><code>vnet-bootcamp</code> (Day 1~9)</td>
</tr>
</tbody></table>
<hr>
<h2 id="nsg-네트워크-보안-그룹">NSG (네트워크 보안 그룹)</h2>
<p>NSG는 방화벽 규칙 집합이다.</p>
<table>
<thead>
<tr>
<th>항목</th>
<th>내용</th>
</tr>
</thead>
<tbody><tr>
<td>역할</td>
<td>인바운드 / 아웃바운드 제어</td>
</tr>
<tr>
<td>기본 정책</td>
<td>모든 인바운드 차단</td>
</tr>
<tr>
<td>AllowSSH (22)</td>
<td>Day 2 VM용</td>
</tr>
<tr>
<td>AllowRDP (3389)</td>
<td>Day 3 SQL VM</td>
</tr>
<tr>
<td>원칙</td>
<td>최소 필요 포트만 개방</td>
</tr>
</tbody></table>
<hr>
<h1 id="실습-3-cloud-shell-환경">실습 3: Cloud Shell 환경</h1>
<p>목표는 CLI 환경을 확인하고 기본 명령을 연습하는 것이다.</p>
<table>
<thead>
<tr>
<th>순서</th>
<th>내용</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>Portal → Cloud Shell (<code>&gt;_</code>) → Bash</td>
</tr>
<tr>
<td>2</td>
<td><code>az version</code>으로 CLI 버전 확인</td>
</tr>
<tr>
<td>3</td>
<td><code>az account show</code>로 구독 확인</td>
</tr>
<tr>
<td>4</td>
<td><code>az group list</code>로 리소스 그룹 목록 확인</td>
</tr>
<tr>
<td>5</td>
<td><code>az resource list</code>로 리소스 확인</td>
</tr>
<tr>
<td>6</td>
<td>로컬 CLI 설치는 선택사항</td>
</tr>
</tbody></table>
<hr>
<h1 id="실습-4-budget-alert-설정">실습 4: Budget Alert 설정</h1>
<p>목표는 비용 한도 초과를 사전에 감지하는 것이다.</p>
<table>
<thead>
<tr>
<th>순서</th>
<th>내용</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>Portal → Cost Management → Budgets</td>
</tr>
<tr>
<td>2</td>
<td><code>+ Add</code> → <code>bootcamp-budget</code></td>
</tr>
<tr>
<td>3</td>
<td>Amount: ₩30,000 ~ ₩50,000</td>
</tr>
<tr>
<td>4</td>
<td>Alert: 80%, 100% 이메일</td>
</tr>
<tr>
<td>5</td>
<td>Action Group: 본인 이메일</td>
</tr>
<tr>
<td>6</td>
<td>Create</td>
</tr>
</tbody></table>
<hr>
<h1 id="리소스-정리-스크립트">리소스 정리 스크립트</h1>
<p>Day 1은 VM이 없으므로 모든 리소스를 유지한다.</p>
<p>유지 리소스는 다음과 같다.</p>
<table>
<thead>
<tr>
<th>유지 리소스</th>
</tr>
</thead>
<tbody><tr>
<td><code>rg-bootcamp-day1</code></td>
</tr>
<tr>
<td><code>vnet-bootcamp</code></td>
</tr>
<tr>
<td><code>nsg-bootcamp</code></td>
</tr>
<tr>
<td><code>bootcamp-budget</code></td>
</tr>
</tbody></table>
<p>확인 명령:</p>
<pre><code class="language-bash"># Day 1은 VM 없음 — 모두 유지

# 유지 리소스:
# rg-bootcamp-day1
# vnet-bootcamp
# nsg-bootcamp
# bootcamp-budget

# 확인만:
az resource list -g rg-bootcamp-day1 -o table

# Cost Analysis에서 비용 확인: $0.5 이하</code></pre>
<hr>
<h1 id="day-1-리소스-정리--체크리스트">Day 1 리소스 정리 + 체크리스트</h1>
<pre><code class="language-bash"># Day 1 체크리스트
az resource list -g rg-bootcamp-day1 -o table</code></pre>
<p>정리 체크리스트는 다음과 같다.</p>
<table>
<thead>
<tr>
<th>체크리스트</th>
</tr>
</thead>
<tbody><tr>
<td>VNet / NSG / Budget 유지 확인</td>
</tr>
<tr>
<td>Cost Analysis: $0.5 이하</td>
</tr>
<tr>
<td>불필요 리소스 없음</td>
</tr>
</tbody></table>
<p>Day 1 예상 비용: <code>$0.5</code>
한도: <code>$10/일</code></p>
<hr>
<h1 id="비용-관리--9일-전체-전략">비용 관리 — 9일 전체 전략</h1>
<p>이 습관이 9일간의 비용을 결정한다.</p>
<table>
<thead>
<tr>
<th>항목</th>
<th>내용</th>
</tr>
</thead>
<tbody><tr>
<td>일일 1인당 한도</td>
<td>$10</td>
</tr>
<tr>
<td>대부분 사용 비용</td>
<td>$0.5 ~ $1.0</td>
</tr>
<tr>
<td>9일 총 예상</td>
<td>$10.8</td>
</tr>
<tr>
<td>한도 대비</td>
<td>6~8배 버퍼</td>
</tr>
<tr>
<td>Budget Alert</td>
<td>80%, 100% 도달 시 이메일</td>
</tr>
<tr>
<td>Cost Analysis</td>
<td>매일 실습 종료 시 직접 확인</td>
</tr>
<tr>
<td>가장 중요한 절약 수단</td>
<td>VM deallocate</td>
</tr>
<tr>
<td>주의</td>
<td>Stop ≠ Deallocate, Stop은 계속 과금됨</td>
</tr>
</tbody></table>
<hr>
<h1 id="qa">Q&amp;A</h1>
<table>
<thead>
<tr>
<th>질문</th>
<th>답변</th>
</tr>
</thead>
<tbody><tr>
<td>SQL MI를 왜 실습 안 하나?</td>
<td>프로비저닝 4시간이 걸리므로 이론 + 매트릭스로 대체</td>
</tr>
<tr>
<td>Vector DB는 Azure에서?</td>
<td>AI Search 벡터 검색, Cosmos MongoDB vCore</td>
</tr>
<tr>
<td>Budget Alert 안 와요</td>
<td>최대 24시간 지연 가능, Cost Analysis 직접 확인</td>
</tr>
<tr>
<td>Free Trial로 9일 가능?</td>
<td>$200 크레딧, 예상 $10.8로 충분</td>
</tr>
</tbody></table>
<hr>
<h1 id="정리">정리</h1>
<table>
<thead>
<tr>
<th>모듈</th>
<th>내용</th>
</tr>
</thead>
<tbody><tr>
<td>DB 역사와 이론</td>
<td>계층형 → RDBMS → NoSQL → NewSQL</td>
</tr>
<tr>
<td>DB 이론</td>
<td>ACID vs BASE, CAP</td>
</tr>
<tr>
<td>Azure 서비스 지형도</td>
<td>SQL VM / DB / MI, Cosmos DB</td>
</tr>
<tr>
<td>의사결정</td>
<td>의사결정 트리 워크샵</td>
</tr>
<tr>
<td>환경 셋업 실습</td>
<td>구독 · RG · VNet · NSG</td>
</tr>
<tr>
<td>비용 관리</td>
<td>Budget Alert 설정</td>
</tr>
<tr>
<td>다음 과정</td>
<td>OSS DB on VM: PostgreSQL · MySQL · MongoDB</td>
</tr>
</tbody></table>
<hr>
<h1 id="핵심-요약">핵심 요약</h1>
<p>이번 과정은 단순히 Azure 리소스를 만드는 실습이 아니라,
데이터베이스가 왜 지금과 같은 형태로 발전했는지 이해하고,
워크로드에 따라 적절한 Azure 데이터 서비스를 선택하기 위한 기준을 세우는 과정이다.</p>
<p>핵심은 다음과 같다.</p>
<ul>
<li>RDBMS는 데이터 독립성과 ACID를 기반으로 발전했다.</li>
<li>NoSQL은 웹 스케일과 수평 확장 문제를 해결하기 위해 등장했다.</li>
<li>NewSQL은 ACID와 Scale-Out을 동시에 추구한다.</li>
<li>Vector DB는 AI/ML 시대의 검색 인프라로 중요해졌다.</li>
<li>Azure에서는 SQL VM, SQL DB, SQL MI, Cosmos DB, Flexible Server, ADX, Fabric 등 다양한 선택지가 있다.</li>
<li>정답은 하나가 아니라, 워크로드에 맞는 선택이 최선이다.</li>
<li>실습 환경은 RG, VNet, NSG, Budget Alert를 기준으로 구성한다.</li>
<li>비용 관리는 매일 Cost Analysis 확인과 VM deallocate가 핵심이다.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[MicrosoftDataSchool] 78일차 - Azure SQL Database 데이터 복구 실습]]></title>
            <link>https://velog.io/@rudin_/MicrosoftDataSchool-78%EC%9D%BC%EC%B0%A8-Azure-SQL-Database</link>
            <guid>https://velog.io/@rudin_/MicrosoftDataSchool-78%EC%9D%BC%EC%B0%A8-Azure-SQL-Database</guid>
            <pubDate>Tue, 28 Apr 2026 00:54:20 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>Azure의 대표 컨셉은 Microsoft Entra ID이다(AAD)</p>
</blockquote>
<h1 id="azure-sql-database-실습">Azure SQL Database 실습</h1>
<table>
<thead>
<tr>
<th>항목</th>
<th>Azure SQL Database</th>
<th>Managed Instance</th>
</tr>
</thead>
<tbody><tr>
<td>단위</td>
<td>DB</td>
<td>인스턴스</td>
</tr>
<tr>
<td>파일 시스템</td>
<td>없음</td>
<td>있음</td>
</tr>
<tr>
<td>SQL Server 엔진</td>
<td>추상화됨</td>
<td>거의 그대로</td>
</tr>
<tr>
<td>OS 접근</td>
<td>불가</td>
<td>일부 가능</td>
</tr>
<tr>
<td>복원 방식</td>
<td>.bacpac</td>
<td>.bak</td>
</tr>
<tr>
<td>물리 구조 접근</td>
<td>불가</td>
<td>가능</td>
</tr>
<tr>
<td>보안 구조</td>
<td>다중 테넌트</td>
<td>격리된 환경</td>
</tr>
</tbody></table>
<h2 id="lab-1--lab-환경-구축">Lab 1 – Lab 환경 구축</h2>
<p><img src="https://velog.velcdn.com/images/rudin_/post/e3c547de-c810-4ec6-98fe-0ef86f7decea/image.png" alt="">
Bastion은 가상 네트워크 생성시 선택 가능</p>
<hr>
<h2 id="portal에서-azure-sql-찾기">Portal에서 Azure SQL 찾기</h2>
<p>Azure Portal 상단 검색창에서 <strong>Azure SQL</strong>을 검색하면
Azure SQL과 관련된 서비스 목록이 표시된다. </p>
<p>이 화면에서는 다음과 같은 리소스를 확인할 수 있다.</p>
<ul>
<li>Azure SQL Database</li>
<li>Azure SQL Managed Instance</li>
<li>SQL Server on Azure VM</li>
</ul>
<hr>
<h2 id="azure-sql-선택을-위한-질문">Azure SQL 선택을 위한 질문</h2>
<p>Azure SQL 선택 화면에서는 사용자의 요구사항을 기반으로
적절한 서비스를 추천하기 위해 질문을 제공한다. </p>
<p>이 질문들은 다음과 같은 기준을 포함한다.</p>
<ul>
<li>어떤 유형의 워크로드인가</li>
<li>기존 시스템을 마이그레이션하는가</li>
<li>새로운 애플리케이션인가</li>
</ul>
<hr>
<h2 id="azure-sql-선택-의사-결정-트리">Azure SQL 선택 의사 결정 트리</h2>
<p><img src="https://velog.velcdn.com/images/rudin_/post/83e45e98-33eb-422d-b554-cee19d14bf9f/image.png" alt=""></p>
<hr>
<h2 id="azure로-database-마이그레이션">Azure로 Database 마이그레이션</h2>
<p>Azure에서는 데이터베이스 마이그레이션을 쉽게 하기 위한 도구를 제공한다. </p>
<p>마이그레이션 시작 화면에서 다음 옵션을 확인할 수 있다.</p>
<ul>
<li>Azure Database Migration Service</li>
<li>Azure Arc enabled SQL Server</li>
<li>Azure Migrate</li>
</ul>
<hr>
<h2 id="데이터베이스를-azure로-이전하기-위한-방법">데이터베이스를 Azure로 이전하기 위한 방법</h2>
<p>Azure에서 제공하는 마이그레이션 방법은 다음과 같다.</p>
<ul>
<li><p>다양한 DB → Azure 이전
→ Azure Database Migration Service</p>
</li>
<li><p>SQL Server → Azure 이전
→ Azure Arc enabled SQL Server</p>
</li>
<li><p>앱 + DB 전체 이전
→ Azure Migrate </p>
</li>
</ul>
<hr>
<h1 id="azure-sql-database-만들기">Azure SQL Database 만들기</h1>
<p><a href="https://learn.microsoft.com/ko-kr/azure/private-link/tutorial-private-endpoint-sql-portal">https://learn.microsoft.com/ko-kr/azure/private-link/tutorial-private-endpoint-sql-portal</a></p>
<table>
<thead>
<tr>
<th>항목</th>
<th>Azure SQL Database</th>
<th>Managed Instance</th>
</tr>
</thead>
<tbody><tr>
<td>단위</td>
<td>DB</td>
<td>인스턴스</td>
</tr>
<tr>
<td>파일 시스템</td>
<td>없음</td>
<td>있음</td>
</tr>
<tr>
<td>SQL Server 엔진</td>
<td>추상화됨</td>
<td>거의 그대로</td>
</tr>
<tr>
<td>OS 접근</td>
<td>불가</td>
<td>일부 가능</td>
</tr>
<tr>
<td>복원 방식</td>
<td>.bacpac</td>
<td>.bak</td>
</tr>
<tr>
<td>물리 구조 접근</td>
<td>불가</td>
<td>가능</td>
</tr>
<tr>
<td>보안 구조</td>
<td>다중 테넌트</td>
<td>격리된 환경</td>
</tr>
</tbody></table>
<h2 id="워크로드-환경-및-백업-스토리지-설정">워크로드 환경 및 백업 스토리지 설정</h2>
<p>다음과 같이 설정한다. </p>
<ul>
<li>SQL Elastic Pool: 사용 안 함</li>
<li>워크로드 환경: 개발</li>
<li>백업 스토리지: 로컬 중복</li>
</ul>
<hr>
<h2 id="컴퓨팅-요소-설정">컴퓨팅 요소 설정</h2>
<p>컴퓨팅 계층 및 하드웨어를 설정한다. </p>
<ul>
<li>서비스 계층: 범용</li>
<li>컴퓨팅 계층: 프로비전됨</li>
<li>하드웨어: Gen5</li>
</ul>
<p>👉 이후 Serverless와 비교 목적</p>
<hr>
<h2 id="네트워크-설정">네트워크 설정</h2>
<p>데이터베이스 접근을 위한 네트워크 설정을 수행한다. </p>
<ul>
<li>연결 방법: 퍼블릭 엔드포인트</li>
<li>방화벽: 현재 IP 추가</li>
<li>연결 정책: 기본값</li>
<li>TLS: 1.2</li>
</ul>
<hr>
<h2 id="보안-설정">보안 설정</h2>
<p>기본 제공 옵션을 그대로 유지한다. </p>
<hr>
<h2 id="추가-설정">추가 설정</h2>
<p>기존 데이터 사용에서
👉 샘플 선택 (AdventureWorks) </p>
<hr>
<h2 id="생성">생성</h2>
<p>“만들기”를 선택하면 배포가 진행된다. </p>
<ul>
<li>소요 시간: 약 1~2분</li>
</ul>
<hr>
<h1 id="db-확인-및-쿼리-실행">DB 확인 및 쿼리 실행</h1>
<hr>
<h2 id="db-확인">DB 확인</h2>
<p>쿼리 편집기를 통해 생성된 DB를 확인한다. </p>
<ul>
<li>SQL Server 인증 방식 사용</li>
</ul>
<hr>
<h2 id="쿼리-실행">쿼리 실행</h2>
<pre><code class="language-sql">SELECT TOP 20 pc.Name as CategoryName, p.name as ProductName
FROM SalesLT.ProductCategory pc
JOIN SalesLT.Product p
ON pc.productcategoryid = p.productcategoryid;</code></pre>
<p>쿼리 실행 결과를 통해 데이터 확인 가능 </p>
<hr>
<h1 id="ssms-설치">SSMS 설치</h1>
<p>SSMS 설치 가이드를 통해 설치 진행 
연결시 Microsoft Entra 인증으로 생성했다면 Microsoft Entra 암호 입력 후 
인증서 신뢰를 체크하면 연결됨</p>
<hr>
<h1 id="데이터베이스-복구">데이터베이스 복구</h1>
<hr>
<h2 id="삭제-유형별-비교">삭제 유형별 비교</h2>
<table>
<thead>
<tr>
<th>선택</th>
<th>결과</th>
<th>복구 가능성</th>
</tr>
</thead>
<tbody><tr>
<td>데이터베이스만 삭제</td>
<td>서버 유지, DB 삭제</td>
<td>일정 기간 복구 가능</td>
</tr>
<tr>
<td>서버 삭제</td>
<td>서버 + DB 삭제</td>
<td>서버 복구 불가, DB만 일부 복구</td>
</tr>
<tr>
<td>둘 다 삭제</td>
<td>전부 삭제</td>
<td>DB만 복구 가능</td>
</tr>
</tbody></table>
<hr>
<h1 id="방화벽-확인">방화벽 확인</h1>
<p>Azure SQL Database에서
현재 IP 확인 후 방화벽 등록 </p>
<hr>
<h1 id="virtual-network-생성">Virtual Network 생성</h1>
<p><img src="https://velog.velcdn.com/images/rudin_/post/af3d9cda-2cc6-4055-84b7-f526deaacacc/image.png" alt=""></p>
<h2 id="bastion">Bastion</h2>
<p><img src="https://velog.velcdn.com/images/rudin_/post/4ec11b48-f874-4717-aa9e-70dc04f467f5/image.png" alt=""></p>
<h2 id="virtual-machine">virtual machine</h2>
<p><img src="https://velog.velcdn.com/images/rudin_/post/500d8a2f-cfec-4236-abee-206c8f2b6cc5/image.png" alt="">
bastion을 통해야하므로 공용 인바운드 포트 X</p>
<h2 id="sql-db-프라이빗-엔드포인트">sql db 프라이빗 엔드포인트</h2>
<p><img src="https://velog.velcdn.com/images/rudin_/post/c6e9f36e-0e24-4f47-87c7-1e04bd068539/image.png" alt="">
<img src="https://velog.velcdn.com/images/rudin_/post/8c25015b-b0e3-4646-8af2-6189b8a90c55/image.png" alt="">
<img src="https://velog.velcdn.com/images/rudin_/post/38d91dc6-587f-4be9-996e-10b3871af406/image.png" alt=""></p>
<h2 id="연결">연결</h2>
<p><code>vm</code>-<code>bastion으로 연결</code>-<code>ssh 로컬 파일로 접속(azureuser)</code>
<img src="https://velog.velcdn.com/images/rudin_/post/b015d153-cf09-4972-b3ce-985cdd7cd0fb/image.png" alt="">
<img src="https://velog.velcdn.com/images/rudin_/post/ec64a779-5ca1-4c38-8080-5f25ed435fdb/image.png" alt="">
<img src="https://velog.velcdn.com/images/rudin_/post/359d41c7-9a78-4208-af5f-c2bb3112d6d7/image.png" alt=""></p>
<h2 id="sql-server-도구-설치">SQL Server 도구 설치</h2>
<p><a href="https://learn.microsoft.com/ko-kr/sql/linux/sql-server-linux-setup-tools?view=sql-server-ver17&amp;tabs=redhat-install%2Codbc-ubuntu-1804">https://learn.microsoft.com/ko-kr/sql/linux/sql-server-linux-setup-tools?view=sql-server-ver17&amp;tabs=redhat-install%2Codbc-ubuntu-1804</a></p>
<pre><code>sudo su
curl -sSL -O https://packages.microsoft.com/config/ubuntu/24.04/packages-microsoft-prod.deb
sudo dpkg -i packages-microsoft-prod.deb
exit

sudo apt-get update
sudo apt-get install mssql-tools18 unixodbc-dev

sudo apt-get update
sudo apt-get install mssql-tools18

echo &#39;export PATH=&quot;$PATH:/opt/mssql-tools18/bin&quot;&#39; &gt;&gt; ~/.bash_profile
source ~/.bash_profile

echo &#39;export PATH=&quot;$PATH:/opt/mssql-tools18/bin&quot;&#39; &gt;&gt; ~/.bashrc
source ~/.bashrc</code></pre><hr>
<h1 id="managed-instance-생성">Managed Instance 생성</h1>
<p><img src="https://velog.velcdn.com/images/rudin_/post/fc610b04-86a3-4bfe-af7b-b80c0dea4a5f/image.png" alt="">
<img src="https://velog.velcdn.com/images/rudin_/post/a78c08e8-a581-43cf-b2f5-6cc633eed946/image.png" alt="">
<img src="https://velog.velcdn.com/images/rudin_/post/eedf7d16-1268-42d4-b045-9a0d862a4328/image.png" alt=""></p>
<hr>
<h2 id="설정">설정</h2>
<ul>
<li>Public endpoint 사용</li>
<li>포트 3342 오픈</li>
<li>NSG에서 IP 허용 </li>
</ul>
<hr>
<h2 id="ssms-연결">SSMS 연결</h2>
<ul>
<li>Endpoint 복사</li>
<li>Server name 입력</li>
<li>Connect</li>
</ul>
<p><img src="https://velog.velcdn.com/images/rudin_/post/2cd098ba-0596-4be1-8a1e-3a8a16c73823/image.png" alt=""></p>
<hr>
<h1 id="lab-2--데이터-복원-및-관리">Lab 2 – 데이터 복원 및 관리</h1>
<hr>
<h2 id="데이터-적재">데이터 적재</h2>
<p>AdventureWorks2022.bak 다운로드 후
Blob Storage에 업로드 </p>
<hr>
<h2 id="blob-storage-구성">Blob Storage 구성</h2>
<ul>
<li>Storage Account 생성</li>
<li>Container 생성</li>
<li>파일 업로드</li>
</ul>
<hr>
<h2 id="sas-생성">SAS 생성</h2>
<p>SAS는 다음 역할 수행 </p>
<ul>
<li>제한된 시간 동안 접근 허용</li>
<li>권한 제어 가능</li>
<li>URL 기반 인증</li>
</ul>
<hr>
<h2 id="managed-instance에서-복원">Managed Instance에서 복원</h2>
<ul>
<li>SSMS → Restore Database</li>
<li>Blob URL + SAS 사용
<img src="https://velog.velcdn.com/images/rudin_/post/62f36555-3525-4504-8da0-0ded7c9954d7/image.png" alt="">
<img src="https://velog.velcdn.com/images/rudin_/post/05391056-3ba1-4229-9dba-b5637f914284/image.png" alt="">
<img src="https://velog.velcdn.com/images/rudin_/post/7bae32ac-be65-460c-aa3d-32ffdc0888fa/image.png" alt="">
<img src="https://velog.velcdn.com/images/rudin_/post/c306532b-60f7-474d-a0e1-8de4c6bd87df/image.png" alt="">
<img src="https://velog.velcdn.com/images/rudin_/post/0fa45de3-dec4-44a7-82a2-95d3799e70de/image.png" alt="">
<img src="https://velog.velcdn.com/images/rudin_/post/31993acf-136e-4969-9cf5-a149166f2cab/image.png" alt="">
<img src="https://velog.velcdn.com/images/rudin_/post/5a9e8e86-310a-4f69-ae9b-98af013e824a/image.png" alt="">
<img src="https://velog.velcdn.com/images/rudin_/post/7759a09b-02ee-4151-92b4-442f6ebc09a5/image.png" alt="">
<img src="https://velog.velcdn.com/images/rudin_/post/2451a3bb-f81b-4f0b-910e-b0f865b669f7/image.png" alt=""></li>
</ul>
<hr>
<h1 id="sql-database-vs-managed-instance">SQL Database vs Managed Instance</h1>
<hr>
<h2 id="구조-차이">구조 차이</h2>
<table>
<thead>
<tr>
<th>항목</th>
<th>SQL Database</th>
<th>Managed Instance</th>
</tr>
</thead>
<tbody><tr>
<td>구조</td>
<td>논리 DB</td>
<td>인스턴스</td>
</tr>
<tr>
<td>파일 시스템</td>
<td>없음</td>
<td>있음</td>
</tr>
<tr>
<td>복원 방식</td>
<td>.bacpac</td>
<td>.bak</td>
</tr>
</tbody></table>
<p>MI는 Lift &amp; Shift에 적합</p>
<hr>
<h1 id="bacpac-vs-bak-비교">.bacpac vs .bak 비교</h1>
<p><code>.bak</code>은 <strong>완벽한 시점 복원용 백업/복구 파일</strong>이고, <code>.bacpac</code>은 <strong>클라우드 이전 및 버전 업/다운그레이드용 마이그레이션 파일</strong>이다. </p>
<table>
<thead>
<tr>
<th>구분</th>
<th><code>.bak</code></th>
<th><code>.bacpac</code></th>
</tr>
</thead>
<tbody><tr>
<td>성격</td>
<td>물리적 백업</td>
<td>논리적 아카이브</td>
</tr>
<tr>
<td>목적</td>
<td>특정 시점의 DB를 그대로 복원</td>
<td>DB 구조와 데이터를 다른 환경으로 이전</td>
</tr>
<tr>
<td>포함 내용</td>
<td>데이터 파일 구조, 트랜잭션 로그 상태 등</td>
<td>테이블 구조(스키마) + 데이터</td>
</tr>
<tr>
<td>일관성</td>
<td>트랜잭션 로그 포함으로 특정 시점 일관성 보장</td>
<td>데이터와 구조를 추출해서 이동</td>
</tr>
<tr>
<td>속도</td>
<td>빠름</td>
<td>상대적으로 느릴 수 있음</td>
</tr>
<tr>
<td>용량</td>
<td>큼</td>
<td>작음</td>
</tr>
<tr>
<td>대용량 DB</td>
<td>적합</td>
<td>대용량에는 비효율적일 수 있음</td>
</tr>
<tr>
<td>버전 호환성</td>
<td>낮음. 상위 버전에서 만든 <code>.bak</code>은 하위 버전 복원 불가</td>
<td>높음. 하위 버전으로도 이동 가능</td>
</tr>
<tr>
<td>Azure SQL Database</td>
<td>직접 가져오기 불가</td>
<td>가져오기 가능</td>
</tr>
<tr>
<td>Azure SQL Managed Instance</td>
<td>복원 가능</td>
<td>가져오기 가능하지만 <code>.bak</code>이 더 적합</td>
</tr>
</tbody></table>
<hr>
<h2 id="bak의-장점"><code>.bak</code>의 장점</h2>
<h3 id="1-완벽한-데이터-일관성">1. 완벽한 데이터 일관성</h3>
<p><code>.bak</code> 파일은 특정 시점의 데이터베이스를 그대로 저장한 <strong>Point-in-time 스냅샷</strong>이다.
백업 도중 데이터가 변경되더라도 트랜잭션 로그를 함께 포함하기 때문에 데이터 무결성이 보장된다. </p>
<p>즉, 운영 중인 DB를 백업하더라도
“백업 시점 기준으로 데이터가 깨지지 않은 상태”를 유지할 수 있다.</p>
<hr>
<h3 id="2-압도적인-속도와-성능">2. 압도적인 속도와 성능</h3>
<p><code>.bak</code>은 데이터베이스의 물리적 페이지 단위 블록을 통째로 백업한다.
그래서 대용량 데이터베이스에서는 <code>.bacpac</code>보다 백업/복원 속도가 훨씬 유리하다. </p>
<p>특히 수백 GB~TB 단위 DB에서는 <code>.bak</code> 방식이 사실상 필수에 가깝다.</p>
<hr>
<h3 id="3-시스템-부하가-비교적-작음">3. 시스템 부하가 비교적 작음</h3>
<p><code>.bak</code> 백업은 SQL Server 엔진의 기본 백업 기능을 사용한다.
그래서 운영 중인 서버에서 백업을 수행해도 비교적 안정적으로 동작하고, 시스템 부하도 상대적으로 적다. </p>
<hr>
<h2 id="bacpac의-장점"><code>.bacpac</code>의 장점</h2>
<h3 id="1-버전-호환성과-이식성">1. 버전 호환성과 이식성</h3>
<p><code>.bacpac</code>은 데이터베이스의 물리 구조를 그대로 복사하는 것이 아니라,
테이블 구조와 데이터를 논리적으로 추출한 파일이다.</p>
<p>그래서 SQL Server 버전에 덜 종속된다.
예를 들어 SQL Server 2022에서 만든 <code>.bak</code>은 SQL Server 2019로 복원할 수 없지만, <code>.bacpac</code>은 구조와 데이터를 추출한 형태이기 때문에 하위 버전으로 이동할 수 있다. </p>
<hr>
<h3 id="2-azure-sql-database-이전에-적합">2. Azure SQL Database 이전에 적합</h3>
<p>Azure SQL Database는 사용자에게 파일 시스템 접근 권한을 제공하지 않는다.
그래서 SQL Server의 물리 백업 파일인 <code>.bak</code>을 직접 복원할 수 없다.</p>
<p>반면 <code>.bacpac</code>은 스키마와 데이터만 담은 논리적 아카이브이기 때문에 Azure SQL Database로 가져올 수 있다. </p>
<p>즉,</p>
<ul>
<li><strong>Azure SQL Managed Instance</strong> → <code>.bak</code> 복원 가능</li>
<li><strong>Azure SQL Database</strong> → <code>.bacpac</code> 가져오기 사용</li>
</ul>
<hr>
<h3 id="3-파일-크기가-작음">3. 파일 크기가 작음</h3>
<p><code>.bacpac</code>은 트랜잭션 로그나 여유 디스크 공간을 포함하지 않는다.
순수하게 구조와 데이터만 추출해서 압축하기 때문에 <code>.bak</code>보다 파일 크기가 훨씬 작아질 수 있다.</p>
<p>자료 예시에서는 <strong>4.3GB <code>.bak</code> 파일이 197MB <code>.bacpac</code> 파일로 줄어들 수 있음</strong>을 설명한다. </p>
<hr>
<h2 id="정리">정리</h2>
<table>
<thead>
<tr>
<th>상황</th>
<th>적합한 파일</th>
</tr>
</thead>
<tbody><tr>
<td>SQL Server 전체를 특정 시점으로 복구</td>
<td><code>.bak</code></td>
</tr>
<tr>
<td>대용량 DB 백업/복원</td>
<td><code>.bak</code></td>
</tr>
<tr>
<td>Managed Instance로 복원</td>
<td><code>.bak</code></td>
</tr>
<tr>
<td>Azure SQL Database로 이전</td>
<td><code>.bacpac</code></td>
</tr>
<tr>
<td>버전 차이가 있는 환경으로 이동</td>
<td><code>.bacpac</code></td>
</tr>
<tr>
<td>파일 크기를 줄여 이관</td>
<td><code>.bacpac</code></td>
</tr>
</tbody></table>
<p>한 줄로 정리하면,
<strong><code>.bak</code>은 “복구용”, <code>.bacpac</code>은 “이전용”에 가깝다.</strong></p>
<hr>
<h1 id="database-watcher">Database Watcher</h1>
<p><img src="https://velog.velcdn.com/images/rudin_/post/a0b64c4e-3a6b-4708-b632-e7cd78574cb5/image.png" alt=""></p>
<hr>
<h1 id="클라우드-비교">클라우드 비교</h1>
<table>
<thead>
<tr>
<th>서비스 분야</th>
<th>AWS (Amazon Web Services)</th>
<th>Azure (Microsoft Azure)</th>
<th>GCP (Google Cloud Platform)</th>
</tr>
</thead>
<tbody><tr>
<td>컴퓨팅 (가상 서버)</td>
<td>EC2 (Elastic Compute Cloud)<br>가장 다양한 인스턴스 타입 제공</td>
<td>Azure Compute<br>Windows 서버와의 호환성 우수</td>
<td>Compute Engine<br>빠른 부팅 속도와 자동 할인 기능</td>
</tr>
<tr>
<td>컨테이너 (컨테이너 관리)</td>
<td>ECS / EKS<br>Kubernetes 및 자체 서비스 지원</td>
<td>Azure Kubernetes Service (AKS)<br>Kubernetes 관리 및 통합 강점</td>
<td>Google Kubernetes Engine (GKE)<br>Kubernetes 원조, 안정적 관리</td>
</tr>
<tr>
<td>스토리지 (객체 스토리지)</td>
<td>S3<br>클라우드 스토리지 사실상 표준</td>
<td>Blob Storage<br>온프레미스 연동 용이</td>
<td>Cloud Storage<br>다양한 클래스, 비용 효율</td>
</tr>
<tr>
<td>데이터베이스 (관계형 DB)</td>
<td>RDS<br>MySQL, PostgreSQL 등 다양한 엔진 지원</td>
<td>Azure SQL Database<br>SQL Server와 완벽 호환</td>
<td>Cloud SQL<br>간편한 관리와 성능</td>
</tr>
<tr>
<td>서버리스 (코드 실행)</td>
<td>Lambda<br>다양한 언어 및 서비스 연동</td>
<td>Azure Functions<br>.NET 환경과 자연스러운 통합</td>
<td>Cloud Functions<br>사용량 기반 단순 과금</td>
</tr>
<tr>
<td>빅데이터 (분석, DW)</td>
<td>Redshift / EMR<br>DW + 빅데이터 플랫폼</td>
<td>Synapse Analytics<br>데이터 통합 및 분석 강점</td>
<td>BigQuery<br>압도적 속도와 확장성</td>
</tr>
<tr>
<td>AI/ML (머신러닝 플랫폼)</td>
<td>SageMaker<br>모델 개발~배포 End-to-End</td>
<td>Azure Machine Learning<br>MS AI 기술과 통합</td>
<td>Vertex AI<br>TensorFlow 기반 최신 AI 기술</td>
</tr>
</tbody></table>
]]></description>
        </item>
        <item>
            <title><![CDATA[[MicrosoftDataSchool] 77일차 - Azure SQL Database 이론]]></title>
            <link>https://velog.io/@rudin_/MicrosoftDataSchool-77%EC%9D%BC%EC%B0%A8-Azure-SQL-Database</link>
            <guid>https://velog.io/@rudin_/MicrosoftDataSchool-77%EC%9D%BC%EC%B0%A8-Azure-SQL-Database</guid>
            <pubDate>Mon, 27 Apr 2026 02:13:45 GMT</pubDate>
            <description><![CDATA[<h1 id="azure-sql-database-정리">Azure SQL Database 정리</h1>
<h2 id="azure-sql-개요">Azure SQL 개요</h2>
<p>Azure SQL은 Azure 클라우드에서 SQL Server 데이터베이스 엔진을 사용하는 관리형, 보안 및 인텔리전트 제품군이다. SQL Server 엔진 기반이기 때문에 기존 애플리케이션을 비교적 쉽게 마이그레이션할 수 있고, 익숙한 도구와 언어, 리소스를 계속 사용할 수 있다.</p>
<p>Azure SQL 제품군은 크게 세 가지로 나뉜다.</p>
<table>
<thead>
<tr>
<th>제품</th>
<th>설명</th>
<th>적합한 상황</th>
</tr>
</thead>
<tbody><tr>
<td>Azure SQL Database</td>
<td>서버리스 컴퓨팅을 포함하는 인텔리전트 관리형 데이터베이스 서비스</td>
<td>클라우드에서 새로운 앱을 구축하는 경우</td>
</tr>
<tr>
<td>Azure SQL Managed Instance</td>
<td>SQL Server 데이터베이스 엔진과 거의 100% 동일한 기능을 제공하는 완전 관리형 인스턴스</td>
<td>기존 SQL Server 애플리케이션을 대규모로 현대화하거나 마이그레이션하는 경우</td>
</tr>
<tr>
<td>Azure VM 위의 SQL Server</td>
<td>SQL Server 워크로드를 Azure VM으로 리프트 앤 시프트하며 SQL Server 호환성과 OS 수준 액세스를 유지</td>
<td>OS 수준 제어와 완전한 호환성이 필요한 경우</td>
</tr>
</tbody></table>
<p>리프트 앤 시프트는 주로 IaaS 환경에서 많이 한다.</p>
<hr>
<h2 id="azure-sql-포트폴리오-비교">Azure SQL 포트폴리오 비교</h2>
<p>Azure SQL은 SQL Server 엔진을 기반으로 구축된 통합 SQL 포트폴리오이다. 서비스 선택은 관리 책임, 호환성, 제어 수준에 따라 달라진다.</p>
<table>
<thead>
<tr>
<th>구분</th>
<th>Azure Virtual Machines 위의 SQL Server</th>
<th>Azure SQL Managed Instance</th>
<th>Azure SQL Database</th>
</tr>
</thead>
<tbody><tr>
<td>서비스 유형</td>
<td>IaaS</td>
<td>PaaS</td>
<td>PaaS</td>
</tr>
<tr>
<td>가장 적합한 앱</td>
<td>리호스팅 및 OS 수준 액세스/제어가 필요한 앱</td>
<td>기존 앱 현대화</td>
<td>클라우드 신규 앱 구축</td>
</tr>
<tr>
<td>주요 특징</td>
<td>자동화된 관리 기능 및 OS 수준 액세스</td>
<td>SQL Server와의 높은 호환성, 기본 VNet 지원</td>
<td>사전 프로비저닝 또는 서버리스 컴퓨팅, 하이퍼스케일 스토리지</td>
</tr>
<tr>
<td>제어 수준</td>
<td>가장 높음</td>
<td>중간</td>
<td>가장 낮음</td>
</tr>
<tr>
<td>관리 부담</td>
<td>가장 큼</td>
<td>중간</td>
<td>가장 작음</td>
</tr>
</tbody></table>
<p>리호스팅은 IT 시스템 마이그레이션에서 대표적으로 사용되는 방식으로, 기존 시스템을 큰 변경 없이 다른 환경으로 이전하는 방법이다.(센터 이전도 가능)</p>
<p>신기능은 Azure Virtual Machines 외의 2종류 정도에만 잘 들어간다.</p>
<hr>
<h2 id="versionless-database-엔진과-호환성">Versionless Database 엔진과 호환성</h2>
<p>Azure VM 위의 SQL은 선택한 특정 SQL Server 버전에 묶여 있다. 반면 Azure SQL Database와 Azure SQL Managed Instance는 PaaS 특성상 특정 버전에 종속되지 않는다. 특히 Always-up-to-date 업데이트 정책을 사용하면 최신 클라우드 기능을 빠르게 반영할 수 있다.</p>
<table>
<thead>
<tr>
<th>구분</th>
<th>SQL Server 2025 업데이트 정책</th>
<th>Always-up-to-date 업데이트 정책</th>
</tr>
</thead>
<tbody><tr>
<td>특징</td>
<td>SQL Server 2025 버전과의 호환성 유지</td>
<td>최신 클라우드 기능 즉시 반영</td>
</tr>
<tr>
<td>장점</td>
<td>온프레미스 SQL 2025로 복원 및 링크 가능</td>
<td>최신 엔진 성능 및 보안 업데이트 자동 적용</td>
</tr>
<tr>
<td>제한</td>
<td>최신 클라우드 전용 엔진 기능 사용 불가</td>
<td>이전 버전 정책으로 복구 불가, 하향 불가</td>
</tr>
</tbody></table>
<p>핵심은 SQL Server, Azure SQL Database, Managed Instance가 하나의 공통 코드베이스를 기반으로 최신 엔진 기능을 제공한다는 점이다. SQL Server 버전은 몇 년 단위로 출시되지만, PaaS 서비스는 지속적으로 엔진이 업데이트된다. 이 구조 덕분에 OS 및 SQL Server 패치 부담이 줄어든다.</p>
<hr>
<h2 id="azure-sql-서비스-비교">Azure SQL 서비스 비교</h2>
<p>Azure SQL 서비스는 Bare Metal부터 PaaS까지 다양한 형태로 SQL Server를 사용할 수 있게 한다.
(Bare Metal은 가상화 없이 사용하는 것)
Private Cloud는 자체적으로 구축한 클라우드 환경</p>
<table>
<thead>
<tr>
<th>구분</th>
<th>Azure SQL Database</th>
<th>Azure SQL Managed Instance</th>
<th>Azure VM의 SQL Server</th>
</tr>
</thead>
<tbody><tr>
<td>지원 기능</td>
<td>대부분의 SQL DB 기능 지원, 일부 제약</td>
<td>거의 모든 온프레미스 인스턴스 수준</td>
<td>모든 온프레미스 기능 지원</td>
</tr>
<tr>
<td>가용성/확장성</td>
<td>99.995% SLA, 단일/풀링 지원</td>
<td>99.99% SLA, 단일/풀링 가능</td>
<td>99.99% SLA, VM 크기/구성에 따름</td>
</tr>
<tr>
<td>유지 관리/패치</td>
<td>자동 패치·백업</td>
<td>자동 패치·백업</td>
<td>직접 관리 필요, 일부 자동 기능 지원</td>
</tr>
<tr>
<td>네트워크 접근</td>
<td>Azure Private Link 등 지원</td>
<td>ExpressRoute, VPN Gateway 등 지원</td>
<td>Azure Virtual Network 내에 배치</td>
</tr>
<tr>
<td>최대 저장 공간</td>
<td>128TB</td>
<td>16TB</td>
<td>256TB 이상, 스토리지 추가 가능</td>
</tr>
<tr>
<td>마이그레이션 용이성</td>
<td>일부 SQL Server 기능 제한적 호환</td>
<td>더 높은 호환성, 마이그레이션 쉬움</td>
<td>온프레미스와 동일, 완벽 호환</td>
</tr>
<tr>
<td>운영 책임</td>
<td>대부분 Azure에서 관리</td>
<td>일부 Azure와 공동 책임 모델</td>
<td>사용자 직접 관리</td>
</tr>
<tr>
<td>온프레미스 연계</td>
<td>기본 제공, 제한적</td>
<td>네트워크/도메인 연계 지원</td>
<td>도메인/애플리케이션 등 완벽 연동</td>
</tr>
<tr>
<td>사용 사례</td>
<td>SaaS 앱, 단일/풀링 DB</td>
<td>리프트 앤 시프트, 복잡한 SQL 앱</td>
<td>레거시 이전, 사용자화 앱, 고도의 통제 필요</td>
</tr>
</tbody></table>
<hr>
<h2 id="azure-sql-결정-트리">Azure SQL 결정 트리</h2>
<p>Azure SQL 결정 트리는 신규 앱인지, 기존 DB 마이그레이션인지, OS 접근이 필요한지, SQL Server 호환성이 어느 정도 필요한지에 따라 서비스를 선택하도록 돕는다.</p>
<ul>
<li>신규 클라우드 앱이면 Azure SQL Database가 우선 고려된다.</li>
<li>기존 SQL Server 앱을 마이그레이션하고 호환성이 중요하면 Managed Instance가 적합하다.</li>
<li>OS 수준 접근, 특정 SQL Server 기능, 완전한 제어가 필요하면 SQL Server on Azure VM이 적합하다.</li>
<li>대규모 확장, 서버리스, 하이퍼스케일 요구가 있으면 Azure SQL Database의 Hyperscale 또는 Serverless 옵션을 고려한다.</li>
</ul>
<hr>
<h2 id="azure-sql-db-내부-구조-control-ring-vs-data-ring">Azure SQL DB 내부 구조: Control Ring vs Data Ring</h2>
<p>Azure SQL은 단일 서버가 아니라 Control Ring과 Data Ring으로 나뉜 분산 시스템이다. Control Ring은 라우팅을 담당하고, Data Ring은 실제 연산을 수행한다. 백엔드 노드에 장애가 발생해도 Control Ring이 정상 노드로 트래픽을 우회하여 연결 단절을 최소화한다.
<img src="https://velog.velcdn.com/images/rudin_/post/c792eff0-db64-46eb-bba8-2039babc5cc8/image.png" alt=""></p>
<table>
<thead>
<tr>
<th>구성 요소</th>
<th>역할</th>
</tr>
</thead>
<tbody><tr>
<td>클라이언트 App</td>
<td>포트 1433으로 쿼리 요청</td>
</tr>
<tr>
<td>Control Ring / Gateway Layer</td>
<td>TDS 프로토콜의 앞문 역할, 인증 처리, 방화벽 검사, DB 위치 메타데이터 확인</td>
</tr>
<tr>
<td>Data Ring / Database Compute Layer</td>
<td>SQL Server 프로세스가 동작하는 컨테이너/VM 풀, 쿼리 파싱·컴파일·실행</td>
</tr>
<tr>
<td>Storage Layer</td>
<td>Azure Blob Storage 기반 데이터 및 로그 파일 저장</td>
</tr>
</tbody></table>
<hr>
<h2 id="연결-토폴로지-proxy-vs-redirect">연결 토폴로지: Proxy vs Redirect</h2>
<p>Azure SQL은 성능과 네트워크 보안 요구에 따라 Proxy와 Redirect 방식의 연결 토폴로지를 제공한다.</p>
<table>
<thead>
<tr>
<th>구분</th>
<th>Proxy 모드</th>
<th>Redirect 모드</th>
</tr>
</thead>
<tbody><tr>
<td>작동 방식</td>
<td>모든 통신이 Gateway를 경유하여 Data Node로 전달</td>
<td>최초 연결 시 Gateway에 노드 위치를 질의한 뒤, 이후 Data Node에 직접 연결</td>
</tr>
<tr>
<td>포트 요구사항</td>
<td>아웃바운드 TCP 1433만 개방</td>
<td>TCP 1433 + 11000~11999 범위 개방 필요</td>
</tr>
<tr>
<td>장점</td>
<td>보안 설정이 단순함</td>
<td>지연 시간 최소화, 처리량 극대화</td>
</tr>
<tr>
<td>사용 환경</td>
<td>인터넷을 통한 외부 연결 시 기본값</td>
<td>Azure 내부망, VNet, VM 연결 시 기본값 또는 성능 권장 방식</td>
</tr>
</tbody></table>
<hr>
<h2 id="azure-sql-주요-기능">Azure SQL 주요 기능</h2>
<p>Azure SQL은 안전하고 안정적인 운영을 위해 다양한 기능을 제공한다.</p>
<table>
<thead>
<tr>
<th>기능</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>Business continuity</td>
<td>비즈니스 연속성 보장</td>
</tr>
<tr>
<td>High Availability</td>
<td>고가용성 구성</td>
</tr>
<tr>
<td>Automated Backups</td>
<td>자동 백업</td>
</tr>
<tr>
<td>Geo-replication</td>
<td>지리적 복제</td>
</tr>
<tr>
<td>Scalability</td>
<td>확장성</td>
</tr>
<tr>
<td>Automated patching</td>
<td>자동 패치</td>
</tr>
<tr>
<td>Security</td>
<td>보안</td>
</tr>
<tr>
<td>Automatic tuning</td>
<td>자동 튜닝</td>
</tr>
<tr>
<td>Built-in monitoring and intelligence</td>
<td>내장 모니터링 및 지능형 분석</td>
</tr>
<tr>
<td>Migrating to Azure</td>
<td>Azure 마이그레이션 지원</td>
</tr>
</tbody></table>
<hr>
<h1 id="구매-모델-및-스케일링-전략">구매 모델 및 스케일링 전략</h1>
<h2 id="azure-sql-배포-옵션">Azure SQL 배포 옵션</h2>
<p>Azure SQL은 다양한 워크로드 요구 사항에 맞게 여러 배포 옵션을 제공한다.</p>
<table>
<thead>
<tr>
<th>배포 옵션</th>
<th>설명</th>
<th>대표 형태</th>
</tr>
</thead>
<tbody><tr>
<td>SQL virtual machines</td>
<td>OS 수준 접근이 필요한 마이그레이션 및 애플리케이션에 적합</td>
<td>SQL virtual machine</td>
</tr>
<tr>
<td>Managed instances</td>
<td>리프트 앤 시프트 마이그레이션에 적합</td>
<td>Single instance, Instance pool</td>
</tr>
<tr>
<td>Databases</td>
<td>최신 클라우드 애플리케이션에 적합</td>
<td>Single database, Elastic pool</td>
</tr>
</tbody></table>
<p>SQL virtual machines는 SQL Server와 OS를 직접 접근하고 관리할 수 있다. Managed Instance는 SQL Server surface area 대부분을 지원하면서도 완전 관리형 서비스이다. Database는 Hyperscale, Serverless, Elastic Pool 등을 통해 최신 앱에 적합한 운영 방식을 제공한다.
Elastic이라는 단어가 들어갔다면 확장성에 집중한 모델임을 알 수 있다.</p>
<hr>
<h2 id="dtu-vs-vcore-모델">DTU vs vCore 모델</h2>
<p>Azure SQL Database는 DTU 기반 구매 모델과 vCore 기반 구매 모델을 제공한다. vCore 기반 모델이 권장된다.</p>
<table>
<thead>
<tr>
<th>구매 모델</th>
<th>설명</th>
<th>적합한 대상</th>
</tr>
</thead>
<tbody><tr>
<td>DTU 기반</td>
<td>컴퓨팅, 스토리지, IO 리소스를 번들로 묶은 측정값 기반. 단일 DB는 DTU, 탄력적 풀은 eDTU로 표시</td>
<td>간단하고 미리 구성된 리소스 옵션을 원하는 고객</td>
</tr>
<tr>
<td>vCore 기반</td>
<td>컴퓨팅과 스토리지 리소스를 독립적으로 선택 가능. Azure 하이브리드 혜택으로 비용 절감 가능</td>
<td>유연성, 제어, 투명성을 중요시하는 고객</td>
</tr>
</tbody></table>
<p>DTU는 단순하고 미리 구성된 리소스 옵션을 제공하는 반면, vCore는 CPU와 스토리지 등 리소스를 더 투명하게 선택할 수 있다.</p>
<hr>
<h2 id="provisioned-vs-serverless-설계-및-auto-pause">Provisioned vs Serverless 설계 및 Auto-pause</h2>
<p>워크로드 패턴에 따라 고정 리소스 방식과 자동 스케일링 방식을 선택할 수 있다.</p>
<table>
<thead>
<tr>
<th>구분</th>
<th>Provisioned</th>
<th>Serverless</th>
</tr>
</thead>
<tbody><tr>
<td>동작 방식</td>
<td>24시간 내내 지정된 vCore와 메모리 항상 할당</td>
<td>최소~최대 vCore 범위 안에서 수요에 따라 자동 스케일링</td>
</tr>
<tr>
<td>과금</td>
<td>시간당 고정 과금</td>
<td>초 단위 과금</td>
</tr>
<tr>
<td>장점</td>
<td>성능이 일정하고 쿼리 응답 지연이 없음</td>
<td>사용하지 않을 때 비용 절감 가능</td>
</tr>
<tr>
<td>Auto-pause</td>
<td>없음</td>
<td>지정 시간 동안 쿼리가 없으면 DB 일시 중지, 스토리지 비용만 청구</td>
</tr>
<tr>
<td>Auto-resume</td>
<td>없음</td>
<td>새 연결 발생 시 자동 재개, 첫 연결 시 1~2초 지연 가능</td>
</tr>
<tr>
<td>적용 대상</td>
<td>트래픽이 꾸준하거나 리소스 사용량을 예측 가능한 Production 워크로드</td>
<td>간헐적·예측 불가능한 워크로드, 야간 트래픽 없는 시스템, Dev/Test 환경</td>
</tr>
</tbody></table>
<hr>
<h2 id="scalability">Scalability</h2>
<p>Azure SQL 서비스 유형별 확장 방식과 특징은 다르다.</p>
<table>
<thead>
<tr>
<th>서비스 유형</th>
<th>확장 방식</th>
<th>주요 특징 및 옵션</th>
<th>유의할 점</th>
</tr>
</thead>
<tbody><tr>
<td>Azure SQL Database (PaaS)</td>
<td>포털에서 CPU/메모리/스토리지 슬라이더로 즉시 상향/하향 조정, Elastic Pool/서버리스 등 리소스 풀링·자동 확장</td>
<td>다운타임 없이 수분 내 리소스 증감, Elastic Pool로 여러 DB 간 자원 공유, 서버리스 부하 기반 자동 확장/축소, Premium·Hyperscale·DTU/vCore 옵션</td>
<td>자동 확장/축소는 서버리스 전용, Elastic/Hyperscale은 별도 과금 및 일부 제한</td>
</tr>
<tr>
<td>Azure SQL Managed Instance</td>
<td>인스턴스 단위 CPU/메모리/스토리지 증감, Instance Pool로 여러 인스턴스 자원 풀링</td>
<td>Portal/CLI에서 인스턴스 리사이즈, Instance Pool 활용 가능</td>
<td>자동화 옵션 없음, 수동 확장, 증설 시 엔진 재구동 등 단기 중단 가능</td>
</tr>
<tr>
<td>SQL Server on Azure VM (IaaS)</td>
<td>VM 크기 변경, 디스크 추가/변경, AG 등으로 Scale-Out</td>
<td>Azure VM 관리화면에서 VM 스펙 교체, 스토리지 추가, AG로 수평 분산 가능</td>
<td>VM 리사이즈나 스토리지 증설 시 OS/DB 재시작 필요, 직접 관리 필요</td>
</tr>
</tbody></table>
<hr>
<h2 id="elastic-database-풀">Elastic Database 풀</h2>
<p>Elastic Database Pool은 여러 데이터베이스 또는 인스턴스 간에 리소스를 공유하고 비용을 최적화하는 기능이다.</p>
<table>
<thead>
<tr>
<th>구분</th>
<th>SQL Database Elastic Pool</th>
<th>SQL Managed Instance Pool</th>
</tr>
</thead>
<tbody><tr>
<td>개념</td>
<td>하나의 프로비저닝된 SQL Database 리소스 세트 내에서 여러 DB 호스트</td>
<td>여러 Managed Instance를 호스트하고 리소스를 공유</td>
</tr>
<tr>
<td>장점</td>
<td>여러 DB 성능을 하나의 간소화된 방식으로 관리·모니터링</td>
<td>컴퓨팅 리소스를 사전 프로비저닝하여 배포 시간 단축, 더 작은 MI 구성 가능</td>
</tr>
<tr>
<td>적합 사례</td>
<td>SaaS 애플리케이션 또는 공급자</td>
<td>대규모 Managed Instance 마이그레이션 및 통합</td>
</tr>
<tr>
<td>상태</td>
<td>일반적으로 사용</td>
<td>공개 미리 보기 상태로 언급됨</td>
</tr>
</tbody></table>
<p>Paychex 사례에서는 여러 고객의 시간 및 결제 관리를 개별 DB로 운영하면서도 비용 절감을 위해 SQL Database Elastic Pool을 선택했다.</p>
<hr>
<h2 id="하이퍼스케일-모델-지역-중복-가용성">하이퍼스케일 모델: 지역 중복 가용성</h2>
<p>하이퍼스케일 모델은 기존 로컬/공유 스토리지 모델과 달리 컴퓨트와 스토리지 계층을 완전히 분리한다. 이로 인해 대용량 데이터, 빠른 확장성, 효율적 장애 복구를 제공한다.</p>
<table>
<thead>
<tr>
<th>구성 요소</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>컴퓨팅 노드</td>
<td>읽기/쓰기 노드와 읽기 전용 노드 등으로 구성 가능</td>
</tr>
<tr>
<td>페이지 서버</td>
<td>데이터 페이지를 관리하는 분산 스토리지 계층</td>
</tr>
<tr>
<td>로그 서비스</td>
<td>로그 처리를 담당하는 고가용성 구성 요소</td>
</tr>
<tr>
<td>영구 스토리지</td>
<td>Azure Storage 기반, 네이티브 고가용성 및 중복 기능 제공</td>
</tr>
<tr>
<td>Azure Service Fabric</td>
<td>구성 요소 상태를 제어하고 장애 시 정상 노드로 장애 조치 수행</td>
</tr>
</tbody></table>
<p>하이퍼스케일 모델의 특징은 다음과 같다.</p>
<ul>
<li>컴퓨트와 스토리지가 완전히 분리된다.</li>
<li>여러 컴퓨트와 여러 스토리지 구성 요소가 독립적으로 확장된다.</li>
<li>노드 장애 시 다른 컴퓨트 노드에서 서비스를 재개할 수 있다.</li>
<li>수십 TB급 대용량 데이터베이스에 적합하다.</li>
<li>새 복제본 또는 스냅샷을 빠르게 생성할 수 있다.</li>
</ul>
<hr>
<h2 id="하이퍼스케일-모델-영역간-중복-가용성">하이퍼스케일 모델: 영역간 중복 가용성</h2>
<p>영역 중복성을 선택하면 하이퍼스케일 계층 전체에 대해 가용성 영역 간 복제가 적용되어 영역 수준 복원력이 보장된다.</p>
<ul>
<li>애플리케이션 로직 변경 없이 엔터프라이즈급 내결함성 구성 가능</li>
<li>한 가용성 영역 장애 시 실시간 자동 failover 지원</li>
<li>데이터 손실 없는 고가용성 보장</li>
<li>서비스 중단 없이 패치 및 업그레이드 가능</li>
<li>대용량 확장성과 빠른 복구 제공</li>
</ul>
<hr>
<h1 id="네트워크-보안">네트워크 보안</h1>
<h2 id="방화벽-규칙-서버-수준-vs-db-수준">방화벽 규칙: 서버 수준 vs DB 수준</h2>
<p>Azure SQL 방화벽은 서버 수준과 데이터베이스 수준 두 겹으로 동작한다. 최소 권한 원칙에 따라 특정 DB에만 접근을 허용하는 데이터베이스 수준 방화벽 규칙이 권장된다.</p>
<table>
<thead>
<tr>
<th>구분</th>
<th>서버 수준 방화벽</th>
<th>데이터베이스 수준 방화벽</th>
</tr>
</thead>
<tbody><tr>
<td>적용 범위</td>
<td>논리적 SQL 서버에 속한 모든 DB 접근 허용</td>
<td>규칙이 생성된 특정 단일 DB에만 접근 허용</td>
</tr>
<tr>
<td>설정 방법</td>
<td>Azure Portal, PowerShell, Azure CLI, REST API</td>
<td>포털 설정 불가, T-SQL <code>sp_set_database_firewall_rule</code> 사용</td>
</tr>
<tr>
<td>보안성</td>
<td>범위가 넓어 상대적으로 낮음</td>
<td>특정 DB만 허용하므로 높음</td>
</tr>
<tr>
<td>주의사항</td>
<td>“Azure 서비스 및 리소스에서 이 서버에 액세스하도록 허용” 옵션은 전 세계 Azure 서비스 IP를 허용하므로 위험</td>
<td>규칙 관리가 T-SQL 기반</td>
</tr>
</tbody></table>
<p>규칙 평가 순서는 데이터베이스 수준 규칙이 먼저이며, 매칭되면 해당 DB만 접속된다. 데이터베이스 수준 규칙이 없으면 서버 수준 규칙을 확인하고, 둘 다 없으면 접속이 차단된다.</p>
<hr>
<h2 id="vnet-service-endpoint-vs-private-link">VNet Service Endpoint vs Private Link</h2>
<p>PaaS 데이터베이스를 퍼블릭 인터넷에서 격리하고 회사 내부망과 연동하는 방식은 서비스에 따라 다르다.
온프레미스라면 VNet Injection이 적합(다만 전용인 VNet Injection이 더 비쌈)</p>
<p>각각 공용, 전용
| 구분 | Azure SQL Database: Private Link | SQL Managed Instance: VNet Injection |
|---|---|---|
| 방식 | 프라이빗 엔드포인트 사용 | 가상 네트워크 주입 방식 |
| 구조 | 기존 VNet 변경 불필요, 개별 DB 단위 사설 IP 매핑 | MI 전용 서브넷 필수 |
| 인프라 | 외부 PaaS 인프라 유지 | 내 VNet 안에 서버를 통째로 넣는 구조 |
| 장점 | 개별 DB 단위로 안전하게 사설 접근 가능 | 완벽한 양방향 통신, 온프레미스 VPN 연동, 물리적 격리에 가까운 수준 |</p>
<hr>
<h1 id="데이터베이스-보안-및-관리">데이터베이스 보안 및 관리</h1>
<h2 id="데이터-암호화-및-접근-제어">데이터 암호화 및 접근 제어</h2>
<p>Azure SQL은 TDE, Always Encrypted, RLS, DDM 등 다계층 데이터 보호를 제공한다.</p>
<table>
<thead>
<tr>
<th>구분</th>
<th>기능</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>물리적 암호화</td>
<td>TDE</td>
<td>디스크에 저장되는 데이터와 백업 파일을 실시간 암호화. 기본값 ON. 디스크 탈취 시에도 데이터 보호</td>
</tr>
<tr>
<td>물리적 암호화</td>
<td>Always Encrypted</td>
<td>클라이언트 애플리케이션에서 데이터를 암호화한 뒤 DB 엔진으로 전송. DB 메모리에서도 암호화 상태 유지</td>
</tr>
<tr>
<td>논리적 필터링</td>
<td>RLS</td>
<td>로그인 사용자 권한에 따라 보이는 행을 필터링. 예: 서울 지점 직원은 서울 지점 매출만 조회</td>
</tr>
<tr>
<td>논리적 필터링</td>
<td>DDM</td>
<td>주민등록번호, 신용카드 번호 등 민감한 열을 마스킹해 반환. 실제 데이터는 변경되지 않음</td>
</tr>
</tbody></table>
<hr>
<h2 id="microsoft-entra-id-및-비밀번호-없는-연결">Microsoft Entra ID 및 비밀번호 없는 연결</h2>
<p>Azure SQL은 SQL Server 인증 대신 Microsoft Entra ID 기반 토큰 인증을 지원한다. 이를 통해 소스코드에서 비밀번호를 제거할 수 있다.</p>
<table>
<thead>
<tr>
<th>항목</th>
<th>내용</th>
</tr>
</thead>
<tbody><tr>
<td>중앙 집중식 통제</td>
<td>퇴사자 발생 시 Entra ID에서 계정만 비활성화하면 DB 접근 차단</td>
</tr>
<tr>
<td>MFA 지원</td>
<td>로그인 시 스마트폰 앱 승인 등 추가 인증 강제 가능</td>
</tr>
<tr>
<td>온프레미스 AD 연동</td>
<td>기존 사내 Active Directory와 동기화하여 SSO 가능</td>
</tr>
<tr>
<td>Managed Identity</td>
<td>Azure 리소스에 고유 ID를 부여하고, Connection String에 <code>Authentication=Active Directory Managed Identity</code>를 사용</td>
</tr>
<tr>
<td>Entra-only authentication</td>
<td>SQL sa 계정 로그인을 원천 차단하고 토큰 기반 접근만 허용</td>
</tr>
</tbody></table>
<hr>
<h2 id="azure-sql-ledger">Azure SQL Ledger</h2>
<p>Azure SQL Ledger는 블록체인의 SHA-256 해시 기술을 관계형 데이터베이스 엔진에 탑재하여 데이터 위변조를 탐지할 수 있도록 하는 기능이다.</p>
<table>
<thead>
<tr>
<th>구분</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>작동 원리</td>
<td>INSERT/UPDATE/DELETE 발생 시 트랜잭션 내용을 SHA-256 해시로 암호화하여 블록 생성</td>
</tr>
<tr>
<td>해시 체인</td>
<td>이전 트랜잭션 해시를 다음 트랜잭션이 참조하여 중간 데이터 조작 시 전체 해시값이 깨짐</td>
</tr>
<tr>
<td>Updatable Ledger</td>
<td>일반 테이블처럼 UPDATE/DELETE 가능. 변경 이력은 History Table에 영구 보존</td>
</tr>
<tr>
<td>Append-Only Ledger</td>
<td>INSERT만 가능. UPDATE/DELETE는 엔진 레벨에서 거부</td>
</tr>
<tr>
<td>사용 사례</td>
<td>SIEM, 보안 감사 로그, 금융 거래, 결제 내역, 외부 감사용 데이터 무결성 증명</td>
</tr>
</tbody></table>
<hr>
<h1 id="성능-모니터링-및-최적화">성능 모니터링 및 최적화</h1>
<h2 id="built-in-monitoring-and-intelligence">Built-in monitoring and intelligence</h2>
<p>Azure SQL은 여러 도구를 통해 모니터링과 성능 분석을 제공한다.</p>
<table>
<thead>
<tr>
<th>기능/도구</th>
<th>설명</th>
<th>Azure SQL Database</th>
<th>Managed Instance</th>
<th>SQL on Azure VM</th>
</tr>
</thead>
<tbody><tr>
<td>Azure Monitor</td>
<td>CPU, 메모리, 저장소, 연결 등 리소스 실시간 관찰, 알림 및 진단 로그 집계</td>
<td>지원</td>
<td>지원</td>
<td>지원</td>
</tr>
<tr>
<td>Database Watcher</td>
<td>DB 성능, 건강 상태, 트랜잭션 등 심층 모니터링 및 대시보드</td>
<td>지원</td>
<td>지원</td>
<td>미지원</td>
</tr>
<tr>
<td>Query Performance Insights</td>
<td>상위 리소스 소모/비효율 쿼리 현황 시각화, 실행 성능 분석</td>
<td>지원</td>
<td>일부 지원</td>
<td>SSMS에서 지원</td>
</tr>
<tr>
<td>Intelligent Insights</td>
<td>AI 기반 장애/성능 저하 원인 자동 감지 및 해결 가이드 제공</td>
<td>지원</td>
<td>지원</td>
<td>미지원</td>
</tr>
<tr>
<td>Alert &amp; 대시보드</td>
<td>포털 기반 임계치 알림, 상태/로그 대시보드 제공</td>
<td>지원</td>
<td>지원</td>
<td>Portal/Log Analytics 지원</td>
</tr>
<tr>
<td>Deep Query Analytics</td>
<td>Query Store 등으로 쿼리 실행 이력, 실행 계획, 상세 워크로드 분석</td>
<td>지원</td>
<td>지원</td>
<td>직접 또는 외부 도구 활용</td>
</tr>
<tr>
<td>Best Practice 검사</td>
<td>보안, 아키텍처, 성능 등 운영 모범 실천 기준 자동 점검 및 경고</td>
<td>지원</td>
<td>지원</td>
<td>IaaS Agent 필요</td>
</tr>
</tbody></table>
<hr>
<h2 id="대기-통계-wait-stats-분석-방법">대기 통계 Wait Stats 분석 방법</h2>
<p>Wait Stats는 쿼리가 실행되는 동안 어떤 자원을 기다리느라 시간이 소요되었는지 알려주는 성능 트러블슈팅 핵심 지표이다.</p>
<table>
<thead>
<tr>
<th>항목</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>Wait Stats 의미</td>
<td>SQL 엔진이 필요한 자원을 기다린 시간과 이유를 기록한 데이터</td>
</tr>
<tr>
<td>분석 가치</td>
<td>“쿼리가 느리다”를 “디스크 읽기를 기다리느라 70% 시간을 썼다”처럼 구체화 가능</td>
</tr>
<tr>
<td>DMV</td>
<td><code>sys.dm_db_wait_stats</code>를 통해 DB 레벨 누적 대기 통계 확인</td>
</tr>
<tr>
<td>Query Store</td>
<td>특정 쿼리별, 시간대별 대기 통계를 과거 이력까지 추적</td>
</tr>
</tbody></table>
<table>
<thead>
<tr>
<th>주요 Wait Type</th>
<th>의미</th>
<th>해결 방향</th>
</tr>
</thead>
<tbody><tr>
<td>PAGEIOLATCH_*</td>
<td>디스크에서 데이터 페이지를 메모리로 읽어오기를 기다림</td>
<td>인덱스 튜닝, 메모리 증설</td>
</tr>
<tr>
<td>LCK_M_*</td>
<td>다른 쿼리가 테이블/행 잠금을 잡고 있어 해제를 기다림</td>
<td>트랜잭션 최적화</td>
</tr>
<tr>
<td>CXPACKET</td>
<td>병렬 쿼리 처리 중 스레드 간 속도 차이로 인한 대기</td>
<td>MAXDOP 설정 조정</td>
</tr>
</tbody></table>
<hr>
<h2 id="automatic-tuning-및-지능형-인사이트">Automatic tuning 및 지능형 인사이트</h2>
<p>PaaS 서비스인 Azure SQL Database와 Managed Instance는 AI 기반 자동/추천 성능 최적화 기능을 제공한다. VM 기반 SQL은 운영자가 직접 튜닝해야 한다.</p>
<table>
<thead>
<tr>
<th>서비스 유형</th>
<th>지원 여부</th>
<th>주요 기능/특징</th>
</tr>
</thead>
<tbody><tr>
<td>Azure SQL Database (PaaS)</td>
<td>기본 제공, 자동/수동 설정</td>
<td>자동 인덱스 관리, 인덱스 자동 생성/삭제, 실행 계획 비효율 발견 시 자동 롤백, 지속적 성능 분석, 튜닝 이력 제공</td>
</tr>
<tr>
<td>Azure SQL Managed Instance</td>
<td>기본 제공, 동일</td>
<td>Azure SQL Database와 동일</td>
</tr>
<tr>
<td>SQL Server on Azure VM (IaaS)</td>
<td>미지원, 직접 관리</td>
<td>수동 튜닝 필수, 자동 제안/적용 기능 없음</td>
</tr>
</tbody></table>
<hr>
<h1 id="고가용성-아키텍처-및-재해-복구">고가용성 아키텍처 및 재해 복구</h1>
<h2 id="high-availability">High Availability</h2>
<p>Azure SQL의 고가용성 내부 구현은 서비스 계층에 따라 다르다.</p>
<table>
<thead>
<tr>
<th>특성</th>
<th>General Purpose 계층</th>
<th>Business Critical 계층</th>
<th>Hyperscale 계층</th>
</tr>
</thead>
<tbody><tr>
<td>적용 서비스</td>
<td>Azure SQL Database &amp; Managed Instance</td>
<td>Azure SQL Database &amp; Managed Instance</td>
<td>Azure SQL Database 전용</td>
</tr>
<tr>
<td>HA 설계 원칙</td>
<td>컴퓨팅/스토리지 분리</td>
<td>Always On 가용성 그룹</td>
<td>분산 함수 모델, 컴퓨팅·스토리지·로그 분리</td>
</tr>
<tr>
<td>아키텍처 구성</td>
<td>스테이트리스 컴퓨팅 노드 클러스터, Azure Premium Storage, 3중 복제 스토리지</td>
<td>1개 주 복제본(RW), 3개 보조 복제본(RO), 모든 복제본 로컬 SSD 사용</td>
<td>1개 주 복제본(RW), 0~4개 HA 보조 복제본(RO), 분산 페이지 서버, 고가용성 로그 서비스</td>
</tr>
<tr>
<td>데이터 복제 방식</td>
<td>스토리지 계층에서 3중 복제(LRS/ZRS)</td>
<td>동기식 복제</td>
<td>로그 서비스 및 페이지 서버를 통한 비동기 복제</td>
</tr>
<tr>
<td>장애 조치 메커니즘</td>
<td>컴퓨팅 노드 장애 시 다른 정상 노드로 연결 자동 전환</td>
<td>주 복제본 장애 시 보조 복제본 중 하나로 자동 승격</td>
<td>주 컴퓨팅 복제본 장애 시 HA 복제본 중 하나로 초고속 승격</td>
</tr>
<tr>
<td>RTO</td>
<td>수십 초</td>
<td>일반적으로 10초 이내</td>
<td>수 초</td>
</tr>
<tr>
<td>RPO</td>
<td>0, 커밋된 데이터 손실 없음</td>
<td>0, 데이터 손실 없음</td>
<td>0, 데이터 손실 없음</td>
</tr>
<tr>
<td>읽기 스케일 아웃</td>
<td>제한적</td>
<td>보조 복제본을 통한 읽기 스케일 아웃 가능</td>
<td>HA 보조 복제본을 통한 읽기 스케일 아웃 가능</td>
</tr>
<tr>
<td>스토리지 유형</td>
<td>원격 Azure Premium Storage</td>
<td>로컬 SSD</td>
<td>분산 페이지 서버 기반 관리형 스토리지</td>
</tr>
<tr>
<td>주요 장점</td>
<td>비용 효율성, 컴퓨팅/스토리지 독립 확장성</td>
<td>높은 성능, 낮은 RTO/RPO, 미션 크리티컬 워크로드 적합</td>
<td>극대화된 확장성, 초고속 복구, 대규모 워크로드 적합</td>
</tr>
</tbody></table>
<hr>
<h2 id="availability-architectural-models">Availability Architectural Models</h2>
<p>Azure SQL Database와 SQL Managed Instance는 각각 고유한 고가용성 아키텍처 모델을 제공한다.</p>
<table>
<thead>
<tr>
<th>서비스</th>
<th>고가용성 아키텍처 모델</th>
</tr>
</thead>
<tbody><tr>
<td>Azure SQL Database</td>
<td>General Purpose: 원격/로컬 저장소 분리, Business Critical: Always On/로컬 스토리지/복제, Hyperscale: 분산 스토리지·컴퓨트 계층 구조</td>
</tr>
<tr>
<td>SQL Managed Instance</td>
<td>General Purpose: Standard Availability, Business Critical: Always On 기반 고가용성 클러스터</td>
</tr>
</tbody></table>
<hr>
<h2 id="azure-sql-database-가용성-모델">Azure SQL Database 가용성 모델</h2>
<table>
<thead>
<tr>
<th>서비스 티어</th>
<th>고가용성 모드</th>
<th>지역 중복 가용성</th>
<th>영역간 중복 가용성</th>
</tr>
</thead>
<tbody><tr>
<td>General Purpose (vCore)</td>
<td>원격 스토리지</td>
<td>예</td>
<td>예</td>
</tr>
<tr>
<td>Business Critical (vCore)</td>
<td>로컬 스토리지</td>
<td>예</td>
<td>예</td>
</tr>
<tr>
<td>Hyperscale (vCore)</td>
<td>하이퍼스케일</td>
<td>예</td>
<td>예</td>
</tr>
<tr>
<td>Basic (DTU)</td>
<td>원격 스토리지</td>
<td>예</td>
<td>아니오</td>
</tr>
<tr>
<td>Standard (DTU)</td>
<td>원격 스토리지</td>
<td>예</td>
<td>아니오</td>
</tr>
<tr>
<td>Premium (DTU)</td>
<td>로컬 스토리지</td>
<td>예</td>
<td>예</td>
</tr>
</tbody></table>
<hr>
<h2 id="azure-sql-managed-instance-가용성-모델">Azure SQL Managed Instance 가용성 모델</h2>
<table>
<thead>
<tr>
<th>서비스 티어</th>
<th>고가용성 모드</th>
<th>지역 중복 가용성</th>
<th>영역간 중복 가용성</th>
</tr>
</thead>
<tbody><tr>
<td>General Purpose (vCore)</td>
<td>원격 스토리지</td>
<td>예</td>
<td>예</td>
</tr>
<tr>
<td>Next-gen General Purpose (vCore) - preview</td>
<td>원격 스토리지</td>
<td>예</td>
<td>예</td>
</tr>
<tr>
<td>Business Critical (vCore)</td>
<td>로컬 스토리지</td>
<td>예</td>
<td>예</td>
</tr>
</tbody></table>
<hr>
<h2 id="원격-스토리지-모델-지역-중복-가용성">원격 스토리지 모델: 지역 중복 가용성</h2>
<p>DTU 기반 Basic/Standard 계층과 vCore 기반 General Purpose 계층은 Remote Storage 가용성 모델을 사용한다. 컴퓨팅 레이어와 저장소 계층이 분리되어 있다.</p>
<p>Stateful한건 늘리거나 하지 않고, Stateless에 computing 가능한 부분을 두어 늘릴 수 있게 함.
예를 들자면, 홈쇼핑 데이터베이스라면 주 기능에 관한건 Stateful한 부분에, 그리고 접속이나 계산 등 computing하고 가변적으로 늘려야 하는 부분은 Stateless 사용</p>
<table>
<thead>
<tr>
<th>계층</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>Stateless compute layer</td>
<td>데이터베이스 엔진 프로세스를 실행하는 컴퓨팅 계층. 장애 시 다른 노드로 전환 가능</td>
</tr>
<tr>
<td>Stateful data layer</td>
<td>Azure Blob/Premium Storage에 데이터 파일과 로그 파일 저장. 원격 스토리지 기반 복제 제공</td>
</tr>
</tbody></table>
<hr>
<h2 id="로컬-스토리지-모델-지역-중복-가용성">로컬 스토리지 모델: 지역 중복 가용성</h2>
<p>DTU Premium 계층과 vCore Business Critical 계층은 컴퓨팅 리소스와 로컬 SSD 스토리지를 단일 노드에 통합하는 로컬 스토리지 모델을 사용한다.</p>
<ul>
<li>각 컴퓨트 노드에는 데이터베이스 엔진과 로컬 SSD가 결합되어 있다.</li>
<li>주요 데이터 파일과 로그 파일이 각 노드의 로컬 SSD에 저장된다.</li>
<li>Always On Availability Group을 통해 노드 간 동기화 복제를 수행한다.</li>
<li>로컬 SSD 직접 접근으로 IO 지연이 낮고 성능이 높다.</li>
<li>장애 발생 시 동기화된 Secondary 노드가 Primary로 승격된다.</li>
</ul>
<p>로컬이 더 비싸고 빠름</p>
<hr>
<h2 id="원격-스토리지-모델-영역간-중복-가용성">원격 스토리지 모델: 영역간 중복 가용성</h2>
<p>Zone-redundant 옵션을 사용하면 하나의 Region 내 서로 다른 가용성 영역에 컴퓨트 노드가 분산 배치된다.</p>
<table>
<thead>
<tr>
<th>구분</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>Remote storage model</td>
<td>컴퓨트와 스토리지가 분리. 컴퓨트 노드는 stateless, 영구 데이터는 Azure Premium Storage 등에 저장</td>
</tr>
<tr>
<td>Zone redundant availability</td>
<td>여러 Azure Zone에 Control Ring, 노드, 스토리지 계층이 존재. 데이터와 로그는 ZRS 등 zone-redundant storage에 보관</td>
</tr>
<tr>
<td>장애 대응</td>
<td>Azure Traffic Manager를 통해 정상 Zone 노드로 자동 우회</td>
</tr>
</tbody></table>
<hr>
<h2 id="로컬-스토리지-모델-영역간-중복-가용성">로컬 스토리지 모델: 영역간 중복 가용성</h2>
<p>Premium 또는 Business Critical 계층에서 영역 중복을 사용하면 복제본이 동일 지역의 여러 가용성 영역에 배치된다.</p>
<ul>
<li>SPOF 제거를 위해 Control Ring이 여러 영역에 걸쳐 복제된다.</li>
<li>게이트웨이 링 라우팅은 Azure Traffic Manager가 제어한다.</li>
<li>기존 복제본을 다양한 가용성 영역에 배치하므로 추가 비용 없이 사용할 수 있다.</li>
<li>데이터센터 중단 같은 큰 장애에도 탄력적으로 복구 가능하다.</li>
<li>기존 Premium/Business Critical DB 또는 Elastic Pool을 영역 중복 구성으로 변환할 수 있다.</li>
</ul>
<hr>
<h2 id="automated-backups">Automated Backups</h2>
<p>Azure SQL 서비스별 자동 백업 방식은 다르다.</p>
<table>
<thead>
<tr>
<th>유형</th>
<th>자동 백업 기본 제공</th>
<th>백업 종류/주기</th>
<th>보존 기간</th>
<th>백업 저장 위치</th>
<th>추가 설정/특징</th>
</tr>
</thead>
<tbody><tr>
<td>Azure SQL Database</td>
<td>O, 자동</td>
<td>주 1회 전체, 12시간마다 증분, 약 10분마다 로그</td>
<td>7~35일 기본, 최대 10년 LTR 옵션</td>
<td>RA-GRS</td>
<td>PITR, LTR 지원</td>
</tr>
<tr>
<td>SQL Managed Instance</td>
<td>O, 자동</td>
<td>전체, 차등, 로그</td>
<td>7~35일 기본, 최대 10년 LTR</td>
<td>RA-GRS</td>
<td>PaaS 서비스의 통합 자동 백업 관리</td>
</tr>
<tr>
<td>SQL on Azure VM</td>
<td>X, 별도 설정 필요</td>
<td>사용자 지정</td>
<td>사용자 지정</td>
<td>Azure Storage, Recovery Services Vault, 외부 저장소</td>
<td>SQL IaaS Agent, Azure Backup 등 설정 필요. 관리자가 주기·보존·저장소 직접 결정</td>
</tr>
</tbody></table>
<p>LTR: Long Term Retention</p>
<hr>
<h2 id="automated-patching">Automated Patching</h2>
<p>Azure SQL Database와 Azure SQL Managed Instance는 PaaS 서비스이므로 OS와 SQL 데이터베이스 엔진의 최신 보안 업데이트 및 성능 개선 패치가 자동 적용된다.</p>
<table>
<thead>
<tr>
<th>구분</th>
<th>내용</th>
</tr>
</thead>
<tbody><tr>
<td>Azure SQL Database / Managed Instance</td>
<td>OS 및 SQL 엔진 패치 자동 적용, 관리자가 별도 패치하지 않아도 최신 버전과 보안 수준 유지</td>
</tr>
<tr>
<td>Managed Instance 유지 관리 기간</td>
<td>자동 패치가 적용될 주간 시간대를 선택해 단기 가용성 영향을 제어 가능</td>
</tr>
<tr>
<td>Managed Instance 업데이트 정책</td>
<td>Always Up-to-date 정책 또는 지연 정책 선택 가능</td>
</tr>
<tr>
<td>SQL Server on Azure VM</td>
<td>SQL IaaS Agent Extension 등록 및 별도 설정 필요. Windows Update, 보안 패치, SQL Patch 스케줄을 관리자가 직접 구성 가능</td>
</tr>
</tbody></table>
<hr>
<h2 id="business-continuity-참고">Business Continuity 참고</h2>
<p>Business Continuity는 기업의 핵심 자산, 서비스, 수익에 대한 위협을 식별하고, 주요 비즈니스 기능이 재난이나 장애 상황에서도 계속 운영될 수 있도록 하는 전략이다.</p>
<p>Azure SQL Database에서는 내장된 고가용성, 지역 중복, 장애 복구 기능을 통해 서비스 지속성을 보장한다. SPOF는 시스템, 네트워크, 소프트웨어 등 특정 지점 장애가 전체 시스템 중단으로 이어지는 단일 실패 지점을 의미한다.</p>
<hr>
<h2 id="azure-site-recovery-참고">Azure Site Recovery 참고</h2>
<p>Azure Site Recovery는 Microsoft Azure에서 제공하는 재해 복구 서비스이다. 주요 IT 중단 발생 시에도 비즈니스 애플리케이션과 워크로드를 계속 실행하여 Business Continuity를 보장하도록 돕는다.</p>
<hr>
<h2 id="geo-replication">Geo-replication</h2>
<p>Geo-replication은 Azure SQL Database, Managed Instance, SQL Server on Azure VM에 따라 지원 방식이 다르다.</p>
<table>
<thead>
<tr>
<th>서비스 계층</th>
<th>주요 Geo-replication 옵션</th>
<th>복제 방향/방식</th>
<th>페일오버/관리 특징</th>
<th>지원 및 제한 사항</th>
</tr>
</thead>
<tbody><tr>
<td>Azure SQL Database (PaaS)</td>
<td>Active Geo-replication: 최대 4개 세컨더리 지원, Failover groups: 그룹 단위 페일오버/엔드포인트 자동 전환</td>
<td>단일 DB, Elastic Pool, 그룹 단위. 단방향/읽기 전용 세컨더리</td>
<td>자동 복제, 수동/자동 페일오버 선택. Failover group으로 일괄 관리, 연결 엔드포인트 자동 생성</td>
<td>PaaS라 설정/운영 간편. 서버리스, Hyperscale 등은 제한적 지원</td>
</tr>
<tr>
<td>Azure SQL Managed Instance</td>
<td>Failover groups: 전체 인스턴스 단위 DR, Geo-replication 일부</td>
<td>인스턴스 전체/그룹 단위, 읽기/쓰기 세컨더리</td>
<td>Failover group 자동/수동, 엔드포인트 리디렉션, 정책에 따른 자동 복구</td>
<td>Managed Instance 전용. 일부 설정/기능 변동 가능</td>
</tr>
<tr>
<td>SQL Server on Azure VM (IaaS)</td>
<td>Always On Availability Groups, Distributed Availability Group</td>
<td>VM 간 멀티 리전, 양방향 AG, 복수 세컨더리</td>
<td>쿼럼/AG 정책에 따른 페일오버. 복수 세컨더리로 로드 분산 가능. 수동/자동 다양</td>
<td>IaaS 직접 구성. Windows Failover Cluster 필요, 네트워크/쿼럼 설정 직접 관리, 비용/관리 책임</td>
</tr>
</tbody></table>
<hr>
<h1 id="migration--innovation">Migration &amp; Innovation</h1>
<h2 id="migrating-to-azure">Migrating to Azure</h2>
<p>Azure는 온프레미스 SQL Server, 다른 클라우드, 타 DB 등에서 Azure SQL 제품으로 안전하게 이전하기 위한 절차와 도구를 제공한다.</p>
<table>
<thead>
<tr>
<th>도구/방식</th>
<th>대상 플랫폼</th>
<th>주요 방식/설명</th>
<th>지원 대상/특이사항</th>
</tr>
</thead>
<tbody><tr>
<td>Azure Database Migration Service (DMS)</td>
<td>온프레미스 SQL Server, 타 클라우드 DB, Oracle, MySQL 등</td>
<td>온라인/오프라인 마이그레이션, 다운타임 최소화, 스키마+데이터+보안+연결 전환 지원</td>
<td>Azure SQL Database, Managed Instance, SQL Server on VM</td>
</tr>
<tr>
<td>Dacpac / BACPAC</td>
<td>모든 SQL Server/DB</td>
<td>스키마 및 데이터 옵션을 dacpac/bacpac 파일로 추출 후 신규 DB에 업로드 및 Import</td>
<td>소형 또는 부분 데이터 이전에 적합</td>
</tr>
<tr>
<td>Log Replay Service</td>
<td>온프레미스 SQL → Managed Instance</td>
<td>트랜잭션 로그 전송, 실시간 또는 순차적 로그 누적 적용</td>
<td>Managed Instance 전용</td>
</tr>
<tr>
<td>Managed Instance Link</td>
<td>온프레미스/VM SQL Server → Managed Instance</td>
<td>Always On AG 기술 활용, 실시간 데이터 싱크</td>
<td>Hybrid DR, 긴밀한 실시간 연동에 적합</td>
</tr>
<tr>
<td>Native backup/restore</td>
<td>온프레미스/VM SQL Server → Managed Instance</td>
<td>백업 파일을 Azure Storage로 업로드</td>
<td>대용량, 장기 보관, 이관, 복구에 적합</td>
</tr>
<tr>
<td>Distributed Availability Groups</td>
<td>온프레미스 ↔ Azure VM 상 SQL Server</td>
<td>AG 기반 장애 복구, 저지연 클러스터, VM 간 이중화 복제, 직접적 고가용성 구성</td>
<td>IaaS VM 기반 SQL 전용</td>
</tr>
<tr>
<td>Azure Migrate/Arc</td>
<td>대규모, 복합 자원, 하이브리드 등 다양한 환경</td>
<td>전체 인프라/워크로드 평가, 추천, 예측, DB 포함 전체 인프라 및 서비스 분석·상품 제안</td>
<td>대형·엔터프라이즈·하이브리드 환경에 적합</td>
</tr>
</tbody></table>
<hr>
<h2 id="클라우드-마이그레이션-비즈니스-드라이버-및-전략">클라우드 마이그레이션: 비즈니스 드라이버 및 전략</h2>
<p>성공적인 클라우드 마이그레이션은 기술 이동만이 아니라 명확한 비즈니스 이유를 기반으로 전략을 선택하는 과정이다.</p>
<table>
<thead>
<tr>
<th>단계</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>비즈니스 목표 정의</td>
<td>클라우드 채택으로 달성하려는 상위 수준 성과 정의. 예: AI 도입, 민첩성 향상, 비용 절감, 혁신 가속화</td>
</tr>
<tr>
<td>격차 식별</td>
<td>현재 상태와 목표 수준 간 차이 분석. 성능, 확장성, 규정 준수, 아키텍처 제한 식별</td>
</tr>
<tr>
<td>비즈니스 드라이버 결정</td>
<td>파악된 격차를 메워야 하는 구체적이고 실행 가능한 이유 확정. 최적의 8R 전략 선택 기준</td>
</tr>
</tbody></table>
<table>
<thead>
<tr>
<th>비즈니스 드라이버</th>
<th>마이그레이션 전략</th>
</tr>
</thead>
<tbody><tr>
<td>중복되거나 가치가 낮은 워크로드를 용도 폐기</td>
<td>Retire</td>
</tr>
<tr>
<td>비즈니스 중단 최소화, 가까운 시일 내 현대화 계획 없음</td>
<td>Rehost</td>
</tr>
<tr>
<td>관리 부담을 줄이고 신뢰성을 높이기 위해 PaaS 솔루션과 최소한의 코드 수정 필요</td>
<td>Replatform</td>
</tr>
<tr>
<td>기술 부채를 줄이거나 클라우드 최적화를 위해 코드 수정 필요</td>
<td>Refactor</td>
</tr>
<tr>
<td>클라우드 네이티브 기능 활용을 위해 아키텍처 변경 필요</td>
<td>Rearchitect</td>
</tr>
<tr>
<td>운영 단순화를 위해 SaaS/AI 솔루션 필요</td>
<td>Replace</td>
</tr>
<tr>
<td>요구사항 충족을 위해 새로운 클라우드 네이티브 솔루션 필요</td>
<td>Rebuild</td>
</tr>
<tr>
<td>안정성이 필요하고 변경 사항이 없어야 함</td>
<td>Retain</td>
</tr>
</tbody></table>
<hr>
<h2 id="azure-cloud-migration-strategy-8r">Azure Cloud Migration Strategy: 8R</h2>
<table>
<thead>
<tr>
<th>전략</th>
<th>Business driver</th>
<th>주요 지표</th>
</tr>
</thead>
<tbody><tr>
<td>Retire</td>
<td>중복되거나 가치가 낮은 워크로드를 용도 폐기</td>
<td>현재 또는 미래 비즈니스 가치 제한, 마이그레이션/현대화 비용이 이점보다 큼</td>
</tr>
<tr>
<td>Rehost</td>
<td>비즈니스 중단 최소화, 현대화 계획 없음</td>
<td>워크로드 안정적, Azure 호환, 마이그레이션 리스크 낮음, 단기 클라우드 도입 목표, 현대화 급하지 않음, 자본 지출 절감, 데이터센터 공간 확보, Azure 경험 부족</td>
</tr>
<tr>
<td>Replatform</td>
<td>관리 부담 감소, 신뢰성 향상, PaaS와 최소 코드 수정 필요</td>
<td>안정성과 재해 복구 단순화, OS 및 라이선스 관리 부담 감소, 적절한 투자로 전환 시간 단축, 애플리케이션 컨테이너화</td>
</tr>
<tr>
<td>Refactor</td>
<td>기술 부채 감소 또는 클라우드 최적화를 위한 코드 수정</td>
<td>유지보수 비용 감소, 기술 부채 감소, Azure SDK 사용, 코드 성능 개선, 코드 비용 최적화, 클라우드 디자인 패턴 적용, 모니터링용 코드 계측 적용</td>
</tr>
<tr>
<td>Rearchitect</td>
<td>클라우드 네이티브 기능 활용을 위한 아키텍처 변경</td>
<td>애플리케이션 모듈화/서비스 분해 필요, 구성 요소별 확장 요구 다름, 미래 혁신 지원 필요, 기술 스택 혼재</td>
</tr>
<tr>
<td>Replace</td>
<td>운영 단순화를 위해 SaaS/AI 솔루션 필요</td>
<td>운영 단순화, 내부 개발 리소스를 다른 곳에 활용, 커스터마이징 필요성 적음</td>
</tr>
<tr>
<td>Rebuild</td>
<td>새로운 클라우드 네이티브 솔루션 필요</td>
<td>레거시 시스템이 낡거나 유연하지 않음, 더 빠른 개발/출시 필요, 운영 비용 절감, 최신 프레임워크와 도구 필요</td>
</tr>
<tr>
<td>Retain</td>
<td>안정성이 필요하고 변경 사항이 없어야 함</td>
<td>워크로드 안정적, 규정 준수, 비즈니스 요구 충족, 단기 이동 동인 없음, ROI 낮음</td>
</tr>
</tbody></table>
<p>참고로 Gartner의 5R 이후 AWS에서는 Repurchase를 추가한 6R 및 7R이 많이 활용되며, Azure에서는 8R로 분류해 설명한다.</p>
<hr>
<h2 id="aws-cloud-migration-strategy-참고">AWS Cloud Migration Strategy 참고</h2>
<p>AWS 7R은 다음과 같이 시각적으로 정리된다.</p>
<table>
<thead>
<tr>
<th>그룹</th>
<th>전략</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>Sustain</td>
<td>Rehost</td>
<td>Lift and shift, Amazon EC2에 호스트</td>
</tr>
<tr>
<td>Sustain</td>
<td>Relocate</td>
<td>Hypervisor-level lift and shift, 인프라를 클라우드로 이동</td>
</tr>
<tr>
<td>Optimize</td>
<td>Replatform</td>
<td>Lift and reshape, 일부 클라우드 기능 활용</td>
</tr>
<tr>
<td>Optimize</td>
<td>Repurchase</td>
<td>Drop and shop, 일반적으로 SaaS 제품으로 전환</td>
</tr>
<tr>
<td>Grow</td>
<td>Refactor</td>
<td>Re-architect, Amazon Aurora나 DynamoDB 등 목적 기반 DB 활용</td>
</tr>
<tr>
<td>별도 판단</td>
<td>Retain</td>
<td>유지</td>
</tr>
<tr>
<td>별도 판단</td>
<td>Retire</td>
<td>폐기</td>
</tr>
</tbody></table>
<hr>
<h1 id="최신-기능">최신 기능</h1>
<h2 id="vector-데이터-지원-및-rag-아키텍처">Vector 데이터 지원 및 RAG 아키텍처</h2>
<p>Azure SQL Database는 VECTOR 데이터 형식과 VECTOR_DISTANCE 함수를 지원하여 별도 벡터 DB 없이 기존 관계형 데이터와 함께 RAG 아키텍처를 구현할 수 있다.</p>
<table>
<thead>
<tr>
<th>구분</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>VECTOR 데이터 타입</td>
<td>OpenAI 등 AI 모델이 생성한 다차원 임베딩 배열을 테이블 컬럼에 직접 저장 가능</td>
</tr>
<tr>
<td>VECTOR_DISTANCE 함수</td>
<td>코사인 유사도 등을 이용해 사용자 질문과 의미가 비슷한 데이터를 SQL 쿼리로 검색</td>
</tr>
<tr>
<td>기존 문제</td>
<td>RDBMS 데이터와 벡터 DB가 분리되어 데이터 동기화 및 조인 분석이 어려움</td>
</tr>
<tr>
<td>해결 방식</td>
<td>Azure SQL 하나에서 일반 데이터 필터링과 의미론적 벡터 검색을 동시에 수행. RLS 정책도 유지 가능</td>
</tr>
</tbody></table>
<hr>
<h2 id="json-native-support">JSON Native Support</h2>
<p>기존에는 JSON을 NVARCHAR 문자열 컬럼에 담아 처리했지만, JSON 타입으로 처리하면서 성능 개선이 가능해졌다.</p>
<table>
<thead>
<tr>
<th>항목</th>
<th>개선 내용</th>
</tr>
</thead>
<tbody><tr>
<td>Total Storage Footprint</td>
<td>약 82% 감소</td>
</tr>
<tr>
<td>Data I/O</td>
<td>약 80% 감소</td>
</tr>
<tr>
<td>Query Execution</td>
<td>약 2.5~4배 빠름</td>
</tr>
<tr>
<td>Throughput</td>
<td>약 20~40배 증가</td>
</tr>
<tr>
<td>CPU Usage</td>
<td>약 27% 감소</td>
</tr>
<tr>
<td>Logical Reads</td>
<td>쿼리 실행당 약 80% 감소</td>
</tr>
<tr>
<td>예시 저장 공간</td>
<td>사용량이 5.94GB 수준에서 1.06GB 수준으로 감소한 비교 화면 제시</td>
</tr>
</tbody></table>
<hr>
<h2 id="fabric-mirrored-databases-연동">Fabric Mirrored Databases 연동</h2>
<p>Azure SQL은 Microsoft Fabric의 Mirrored Database와 연결된다. 포털에서 미러링을 켜면 Azure SQL 데이터가 Fabric OneLake에 실시간에 가깝게 Delta 포맷으로 복제된다.</p>
<table>
<thead>
<tr>
<th>특징</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>Zero-ETL</td>
<td>복잡한 파이프라인 개발 없이 버튼 클릭만으로 연동</td>
</tr>
<tr>
<td>Near Real-time</td>
<td>Insert, Update, Delete 변경분을 실시간에 가깝게 증분 복제</td>
</tr>
<tr>
<td>Delta Parquet</td>
<td>분석에 최적화된 개방형 포맷으로 자동 변환 저장</td>
</tr>
<tr>
<td>성능 격리</td>
<td>원본 운영 DB 성능 저하 없이 OLAP 분석 가능</td>
</tr>
</tbody></table>
<hr>
<h1 id="사례">사례</h1>
<h2 id="azure-virtual-machines의-sql-server-사례-allscripts">Azure Virtual Machines의 SQL Server 사례: Allscripts</h2>
<p>Allscripts는 의료 서비스 소프트웨어 제조업체이다. 애플리케이션을 안전하고 안정적으로 호스트하기 위해 Azure로 빠르게 이동하려 했고, Azure Site Recovery를 사용해 약 1,000개의 VM에서 실행 중이던 애플리케이션 수십 개를 3주 만에 Azure로 마이그레이션했다.</p>
<table>
<thead>
<tr>
<th>구분</th>
<th>내용</th>
</tr>
</thead>
<tbody><tr>
<td>고객 과제</td>
<td>애플리케이션을 자주 변환하고 안정적으로 호스트해야 함</td>
</tr>
<tr>
<td>선택 서비스</td>
<td>Azure Virtual Machines의 SQL Server</td>
</tr>
<tr>
<td>주요 도구</td>
<td>Azure Site Recovery</td>
</tr>
<tr>
<td>결과</td>
<td>약 1,000개 VM 기반 애플리케이션을 빠르게 Azure로 이동</td>
</tr>
</tbody></table>
<hr>
<h2 id="azure-sql-managed-instance-사례-komatsu">Azure SQL Managed Instance 사례: Komatsu</h2>
<p>Komatsu는 건설용 중장비 제조 회사로, 여러 메인프레임 애플리케이션의 다양한 데이터를 통합적으로 파악하고 오버헤드를 줄이고자 했다. SQL Server 기능 호환성이 중요했기 때문에 Azure SQL Managed Instance를 선택했다.</p>
<table>
<thead>
<tr>
<th>구분</th>
<th>내용</th>
</tr>
</thead>
<tbody><tr>
<td>고객 과제</td>
<td>여러 메인프레임 애플리케이션 통합, 관리 오버헤드 절감</td>
</tr>
<tr>
<td>선택 서비스</td>
<td>Azure SQL Managed Instance</td>
</tr>
<tr>
<td>이전 데이터</td>
<td>약 1.5TB</td>
</tr>
<tr>
<td>주요 혜택</td>
<td>자동 패치, 버전 업데이트, 자동 백업, 고가용성, 관리 오버헤드 절감</td>
</tr>
<tr>
<td>결과</td>
<td>약 49% 비용 절감, 약 25~30% 성능 향상</td>
</tr>
</tbody></table>
<hr>
<h2 id="azure-sql-database-사례-accuweather">Azure SQL Database 사례: AccuWeather</h2>
<p>AccuWeather는 날씨 분석 및 예측 기업으로, 빅데이터, 머신러닝, AI 기능을 활용하기 위해 Azure를 선택했다. 데이터베이스 관리보다 모델과 애플리케이션 구축에 집중하고자 SQL Database를 Azure Data Factory, Azure Machine Learning 등과 함께 사용했다.</p>
<table>
<thead>
<tr>
<th>구분</th>
<th>내용</th>
</tr>
</thead>
<tbody><tr>
<td>고객 과제</td>
<td>기상 분석 및 예측 기능을 강화하고 빅데이터/AI 기능 활용 필요</td>
</tr>
<tr>
<td>선택 서비스</td>
<td>Azure SQL Database</td>
</tr>
<tr>
<td>연계 서비스</td>
<td>Azure Data Factory, Azure Machine Learning</td>
</tr>
<tr>
<td>목적</td>
<td>매출 및 고객 예측을 위한 내부 애플리케이션 빠른 배포</td>
</tr>
<tr>
<td>주요 장점</td>
<td>관리 부담 감소, 확장성, 최신 클라우드 서비스와의 연계</td>
</tr>
</tbody></table>
<hr>
<h1 id="전체-정리">전체 정리</h1>
<p>Azure SQL은 SQL Server 엔진을 기반으로 한 Azure의 통합 데이터베이스 제품군이다. Azure SQL Database, Azure SQL Managed Instance, SQL Server on Azure VM은 각각 관리 수준, 호환성, 제어 범위가 다르며, 워크로드 특성에 따라 선택해야 한다.</p>
<p>PaaS 기반의 Azure SQL Database와 Managed Instance는 자동 패치, 자동 백업, 고가용성, 보안, 성능 모니터링, 자동 튜닝을 제공한다. 특히 Serverless, Elastic Pool, Hyperscale, Private Link, Entra ID 인증, Ledger, Vector, Fabric Mirroring 등 최신 기능을 통해 운영 부담을 줄이면서 확장성과 보안성을 강화할 수 있다.</p>
<p>마이그레이션 관점에서는 DMS, BACPAC, Log Replay Service, Managed Instance Link, Native backup/restore, Azure Migrate/Arc 등 다양한 도구를 제공한다. 클라우드 이전 전략은 Retire, Rehost, Replatform, Refactor, Rearchitect, Replace, Rebuild, Retain의 8R 관점에서 비즈니스 목표와 워크로드 특성에 맞게 선택해야 한다.</p>
<h2 id="한-줄-요약">한 줄 요약</h2>
<p>Azure SQL은 SQL Server 기반 워크로드를 Azure에서 운영하기 위한 통합 데이터베이스 플랫폼이며, 서비스 유형별로 관리 책임과 호환성, 확장성, 보안 기능이 다르므로 워크로드 특성에 맞는 선택이 중요하다.</p>
<hr>

<h1 id="실습">실습</h1>
<h2 id="azure-sql-생성">Azure SQL 생성</h2>
<p><img src="https://velog.velcdn.com/images/rudin_/post/a5dd552b-b5bd-4417-8d31-2646873bf966/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/4332b036-0a9a-4157-b73b-27513bc7ab80/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/46a8cf2a-c811-46f4-8992-aef1736cc76e/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/c556d7af-16e2-481c-a861-f1de339c802c/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/e3c396c6-ba87-45a1-bf9b-60eac2cc7003/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/1aacb894-3cf3-47c9-8945-5203ae1e41c8/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/ebef80d2-3751-4607-86c4-75a5f5a07986/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/fda10f43-4124-4305-b9b5-c1ae7e48226d/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/31bc14b9-dea0-40b6-87a5-2fb20f5c35d2/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[MicrosoftDataSchool] 75일차 - Azure DevOps]]></title>
            <link>https://velog.io/@rudin_/MicrosoftDataSchool-75%EC%9D%BC%EC%B0%A8</link>
            <guid>https://velog.io/@rudin_/MicrosoftDataSchool-75%EC%9D%BC%EC%B0%A8</guid>
            <pubDate>Thu, 23 Apr 2026 08:51:59 GMT</pubDate>
            <description><![CDATA[<h1 id="azure-devops">Azure DevOps</h1>
<p>소프트웨어 개발 프로젝트의 계획, 개발, 테스트 및 배포 전 과정을 지원하는 통합 플랫폼</p>
<table>
<thead>
<tr>
<th>주요서비스명</th>
<th>역할 및 기능</th>
<th>개발 단계</th>
</tr>
</thead>
<tbody><tr>
<td>Azure Boards</td>
<td>애자일 계획, 작업 추적, 백로그 관리, 칸반 보드 및 스크럼 지원</td>
<td>계획 &amp; 추적</td>
</tr>
<tr>
<td>Azure Repos</td>
<td>Git 기반의 무제한 프라이빗 저장소 제공, 코드 버전 관리 및 협업</td>
<td>코드 관리</td>
</tr>
<tr>
<td>Azure Pipelines</td>
<td>CI/CD 파이프라인 자동화, 코드 커밋 시 자동 빌드, 테스트, 배포 실행</td>
<td>빌드 &amp; 배포</td>
</tr>
<tr>
<td>Azure Test Plans</td>
<td>수동 및 탐색적 테스트 도구, 테스트 케이스 관리 및 실행 결과 추적</td>
<td>테스트 관리</td>
</tr>
<tr>
<td>Azure Artifacts</td>
<td>Maven, npm, NuGet 등 패키지 종속성 저장 및 공유 피드 관리</td>
<td>패키지 관리</td>
</tr>
</tbody></table>
<p><img src="https://velog.velcdn.com/images/rudin_/post/067a901c-470f-4acf-acf7-2308f3cb33f1/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/4cb563a0-1743-4fe3-931a-efee965fb16e/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/20f34848-d45d-4781-b6db-cb1ccba94057/image.png" alt=""></p>
<h1 id="graphql-기반-api-배포">GraphQL 기반 API 배포</h1>
<p>Fabric에서 레이크하우스 생성
<img src="https://velog.velcdn.com/images/rudin_/post/5a00d4af-4364-4c1f-846a-ee5612c9923e/image.png" alt="">
graphQL 생성
<img src="https://velog.velcdn.com/images/rudin_/post/7e245c89-d3a6-49b7-94c5-917a2fec0ebc/image.png" alt="">
<img src="https://velog.velcdn.com/images/rudin_/post/ea348329-09e5-4d57-bb3f-d03ed10b690d/image.png" alt="">
데이터 업로드 후 graphQL 연결
<img src="https://velog.velcdn.com/images/rudin_/post/e011b76c-d9dc-46ad-bece-8bab01fef528/image.png" alt="">
<img src="https://velog.velcdn.com/images/rudin_/post/8314f411-0d03-41ba-a9a2-c96edc0ac723/image.png" alt="">
<img src="https://velog.velcdn.com/images/rudin_/post/d4706f92-92f7-4ea0-aa0c-4b1a32d52390/image.png" alt="">
<img src="https://velog.velcdn.com/images/rudin_/post/77c3ddb1-d2c5-4870-a823-d17a0fd54a13/image.png" alt="">
<img src="https://velog.velcdn.com/images/rudin_/post/a00d84e8-669d-45bb-8b8e-0d901d0b5396/image.png" alt="">
<img src="https://velog.velcdn.com/images/rudin_/post/a1fdbf86-7e9a-4118-8688-11818909e105/image.png" alt="">
엔드포인트 복사로 확인
<img src="https://velog.velcdn.com/images/rudin_/post/99d7d999-f656-490a-b0fe-1663e953f700/image.png" alt="">
<img src="https://velog.velcdn.com/images/rudin_/post/983d7859-cc71-47d2-902f-8ce39baee757/image.png" alt="">
network를 확인해서 http request 내역 확인 가능
<code>엔드포인트</code>, <code>메소드</code>, <code>헤더</code>, <code>바디</code></p>
<h2 id="postman으로-확인">Postman으로 확인</h2>
<p>Authorization Token은 개발자도구로 확인해야하는데, safari를 사용한다면 환경설정에서 웹 개발자 도구 보기를 선택해야한다.
<img src="https://velog.velcdn.com/images/rudin_/post/98497710-80d6-4d99-abbb-4b41dbee3f5e/image.png" alt="">
<code>opt</code>+<code>cmd</code>+<code>i</code>를 누르고, 개발자도구탭에서 네트워크를 선택한다
<img src="https://velog.velcdn.com/images/rudin_/post/f28d55a6-0d81-4b70-a920-31a543c470b0/image.png" alt="">
graphql을 찾고, 우클릭해서 curl로 복사 선택
<img src="https://velog.velcdn.com/images/rudin_/post/bdac6ac8-840f-4aec-aeea-8a3d9529ac0e/image.png" alt="">
이후 postman에서 import로 추가해준다.
<img src="https://velog.velcdn.com/images/rudin_/post/bdf51464-2f6c-43b1-9adc-ff9b384cb959/image.png" alt="">
자동으로 authorization token이 들어가고
<img src="https://velog.velcdn.com/images/rudin_/post/f2b6aaf8-984f-4ed4-849f-4dc0156cbaf2/image.png" alt="">
정상적으로 200 OK 가 떨어진다.
<img src="https://velog.velcdn.com/images/rudin_/post/a7742a73-c7fc-45e4-9242-ec3bbfcedc31/image.png" alt="">
참고로 체크는 GraphQL로 해야하는데, 로그인 안하면 지원 안하는듯. 로그인하니 뜬다.
<img src="https://velog.velcdn.com/images/rudin_/post/95aebe9a-83fc-45ea-ae8f-4297d31ef167/image.png" alt=""></p>
<h2 id="가상환경-생성">가상환경 생성</h2>
<pre><code># 1. &#39;fabric-lab&#39;이라는 이름의 새로운 폴더(디렉토리)를 생성합니다.
mkdir fabric-lab

# 2. 생성한 &#39;fabric-lab&#39; 폴더 안으로 이동합니다.
cd fabric-lab

# 3. &#39;fabric&#39;이라는 이름의 콘다 가상 환경을 생성합니다.
#    -c conda-forge: 패키지를 가져올 채널(저장소) 지정
#    nodejs=24: 최신 버전인 Node.js 24 버전을 함께 설치합니다.
conda create -n fabric -c conda-forge nodejs=24

# 4. 방금 만든 &#39;fabric3&#39; 가상 환경을 활성화합니다. 
# (주의: 위에서 &#39;fabric&#39;으로 만드셨다면 &#39;conda activate fabric&#39;이 맞습니다.)
conda activate fabric3

# 5. 서버 코드를 담을 &#39;server&#39; 폴더를 생성합니다.
mkdir server

# 6. &#39;server&#39; 폴더 안으로 이동합니다.
cd server

# 7. Node.js 프로젝트를 초기화합니다. (-y는 모든 설정을 기본값으로 자동 승인함)
# 이 명령어를 치면 package.json 파일이 생성됩니다.
npm init -y

# 8. 서버 운영에 필요한 핵심 라이브러리들을 설치합니다.
# express: 웹 서버 프레임워크
# cors: 교차 출처 리소스 공유(보안 정책) 해결용
# @azure/identity: Azure 서비스 인증용 (패브릭 연결 시 필요)
# node-fetch@2: API 요청을 보내기 위한 라이브러리 (버전 2)
npm install express cors @azure/identity node-fetch@2
</code></pre><p><img src="https://velog.velcdn.com/images/rudin_/post/277a3b61-ac90-4c02-a8f1-8f8413d457ce/image.png" alt=""></p>
<h2 id="샘플-코드-실행">샘플 코드 실행</h2>
<p>fabric-lab/server 에서 실행</p>
<pre><code>npm init -y
npm install express cors @azure/identity node-fetch@2</code></pre><p><img src="https://velog.velcdn.com/images/rudin_/post/9f24797e-ab40-4265-ac84-f6808ce1efe7/image.png" alt=""></p>
<p>fabric의 graphQL에서 코드 생성(js)
<img src="https://velog.velcdn.com/images/rudin_/post/56951edd-54c2-4aab-94ec-b23c18644afe/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/0c0ff989-3ac1-46e6-adf4-2b42eb47a421/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/f4b24ff0-e1dc-4f10-9c91-2ac1f62227cb/image.png" alt="">
commonjs 를 module로 바꿔줘야한다
<img src="https://velog.velcdn.com/images/rudin_/post/c4db75f6-eac7-4e81-aebb-b80c543712fc/image.png" alt=""></p>
<p>샘플 코드 저장 후 node {파일명}.js로 실행하면 브라우저 인증 후 데이터 조회 가능 (graphql.js 의 경로가 package.json과 동일한 경로여야 함)
이 경우 authentication을 브라우저에서 열리면서 해야한다.
<img src="https://velog.velcdn.com/images/rudin_/post/c4dfdd02-5b82-4039-ae03-065818f35af0/image.png" alt=""></p>
<p>클라이언트 secret을 통해서 인증하는방식으로 해야 앱 개발 가능(매번 브라우저 인증은 불가능)</p>
<h2 id="앱등록-후-액세스-관리">앱등록 후 액세스 관리</h2>
<p>Entra Id 화면- 관리- 앱등록에서 Fabric 전체를 관리할 어플리케이션 등록 가능
등록 후 ClientId와 Secretkey 발급 가능
이후 fabric에서 액세스관리에서 기여자로 추가
<code>tenant ID</code> <code>client ID</code> <code>client Secret</code> 필요</p>
<h2 id="클라이언트로-인증-처리">클라이언트로 인증 처리</h2>
<pre><code class="language-js">import { ClientSecretCredential, InteractiveBrowserCredential } from &quot;@azure/identity&quot;;

// Acquire a token
// DO NOT USE IN PRODUCTION.
// Below code to acquire token is for development purpose only to test the GraphQL endpoint
// For production, always register an application in a Microsoft Entra ID tenant and use the appropriate client_id and scopes
// https://learn.microsoft.com/en-us/fabric/data-engineering/connect-apps-api-graphql#create-a-microsoft-entra-app

const TENANT_ID = &quot;&quot;;
const CLIENT_ID = &quot;&quot;;
const CLIENT_SECRET = &quot;&quot;;

let app = new ClientSecretCredential(TENANT_ID, CLIENT_ID, CLIENT_SECRET);
let tokenPromise = app.getToken(&#39;https://analysis.windows.net/powerbi/api/.default&#39;);
let accessToken = await tokenPromise;

const endpoint = &#39;&#39;;
const query = `
query {
  namhae_travels(first: 10) {
     items {
        no
        name
        address
     }
  }
}
`;

const variables = 
  {

  }
  ;

const headers = {
    &#39;Content-Type&#39;: &#39;application/json&#39;,
    &#39;Authorization&#39;: `Bearer ${accessToken.token}`
};

async function fetchData()     {
    try {
        const response = await fetch(endpoint, {
            method: &#39;POST&#39;,
            headers: headers,
            body: JSON.stringify({ query, variables }),
        });

        const result = await response.json();
        console.log(JSON.stringify(result));
    } catch (error) {
        console.log(&#39;Error fetching data:&#39;, error);
    }
}

fetchData();
</code></pre>
<h2 id="devops에-레포지토리-업로드">DevOps에 레포지토리 업로드</h2>
<p><img src="https://velog.velcdn.com/images/rudin_/post/22658406-906b-4f32-be2a-b8823ee08def/image.png" alt="">
devops의 repos-files의 링크를 local에서 add remote에 사용</p>
<p>vscode상에서는 차례대로 링크복붙-<code>origin</code>입력-git credentials 복붙(password)</p>
<p><img src="https://velog.velcdn.com/images/rudin_/post/5eb9521e-8093-4cc0-bb41-4844761921c0/image.png" alt=""></p>
<h2 id="ci-파이프라인">CI 파이프라인</h2>
<p>Settings-Agent Pool-Default
<img src="https://velog.velcdn.com/images/rudin_/post/ab9e4695-b06f-4fdf-a8ae-1aeacff5ee61/image.png" alt="">
<img src="https://velog.velcdn.com/images/rudin_/post/dfe5e44f-af78-447d-a9eb-6458446f4370/image.png" alt="">
<img src="https://velog.velcdn.com/images/rudin_/post/28c1546e-47a9-4e04-a2d9-53884a1ef6f6/image.png" alt=""></p>
<p>이후 mac에서는 엄청나게 비밀번호 입력을 많이 해야한다고 하셔서 안내해주신대로 비밀번호 입력을 한 번만 하도록 설정했다.(권한변경)</p>
<pre><code>sudo xattr -rd com.apple.quarantine &lt;agent파일 위치&gt;</code></pre><p>이후</p>
<pre><code>./config.sh</code></pre><p>하는 도중에 해당 폴더가 documents 하위에 있어 루트권한으로도 접근이 막히는 문제가 발생했다.
따라서 agent폴더 자체를 루트디렉토리로 옮겨서 해결했다.</p>
<p>PAT 토큰 발급은 우상단 user settings로 하면 된다.
<img src="https://velog.velcdn.com/images/rudin_/post/cdeb638c-0728-4f2e-9d4b-0600e95a362b/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/rudin_/post/a25f6801-74c9-4b6c-8bce-063587adf9b1/image.png" alt="">
<img src="https://velog.velcdn.com/images/rudin_/post/b7aad394-119a-41bf-8ccd-c9f457a699dc/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/rudin_/post/3b51b561-2254-41c8-8a86-c2f0b46f5f2b/image.png" alt="">
<img src="https://velog.velcdn.com/images/rudin_/post/eaabd040-57f5-4f02-b24c-ca952a6d6463/image.png" alt="">
<img src="https://velog.velcdn.com/images/rudin_/post/ddd2257b-ab65-4d95-8095-2e1134b75ace/image.png" alt=""></p>
<pre><code class="language-yaml">trigger:
  paths:
    include:
      - server/*

pool:
  name: &#39;Default&#39;

steps:
  - checkout: self
    clean: true       # 기존에 남은 찌꺼기 파일들을 깨끗이 지우고 시작
    fetchDepth: 1     # 최신 커밋 1개만 빠르게 가져옴
    displayName: &#39;Get Sources (Manual)&#39;
  - task: NodeTool@0
    inputs:
      versionSpec: &#39;24.x&#39;
    displayName: &#39;Install Node.js&#39;

  - script: |
      cd server
      npm install
      # NestJS나 TypeScript를 사용한다면 build 스크립트가 필수입니다.
      # 일반 Express라면 생략 가능하지만, 관례상 포함하는 경우가 많습니다.
      npm run build --if-present
    displayName: &#39;Install and Build&#39;

  - task: ArchiveFiles@2
    inputs:
      # 중요: &#39;server&#39; 폴더 전체를 압축하되,
      # 보통 node_modules를 포함해야 App Service에서 바로 실행됩니다.
      rootFolderOrFile: &#39;server&#39;
      includeRootFolder: false
      archiveType: &#39;zip&#39;
      archiveFile: &#39;$(Build.ArtifactStagingDirectory)/$(Build.BuildId).zip&#39;
    displayName: &#39;Archive files&#39;

  - task: PublishBuildArtifacts@1
    inputs:
      PathtoPublish: &#39;$(Build.ArtifactStagingDirectory)&#39;
      ArtifactName: &#39;drop&#39;
    displayName: &#39;Publish Artifact&#39;
</code></pre>
<p><img src="https://velog.velcdn.com/images/rudin_/post/5aecf973-5b18-4a5a-9f48-dc32e4ea44b7/image.png" alt="">
<img src="https://velog.velcdn.com/images/rudin_/post/b7ac5ae2-3657-4681-a570-3fcbc2d1ea01/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/9fdf6355-bd6f-436b-890e-d7463de3e6a5/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/rudin_/post/338222f6-483e-4507-822e-3849ef162049/image.png" alt=""></p>
<pre><code class="language-python">import { ClientSecretCredential } from &quot;@azure/identity&quot;;
import express from &#39;express&#39;;
import cors from &#39;cors&#39;;
import fetch from &#39;node-fetch&#39;;

const TENANT_ID = &quot;&quot;;
const CLIENT_ID = &quot;&quot;;
const CLIENT_SECRET = &quot;&quot;;

let credential = new ClientSecretCredential(TENANT_ID, CLIENT_ID, CLIENT_SECRET);
let tokenPromise = credential.getToken(&#39;https://analysis.windows.net/powerbi/api/.default&#39;);
let accessToken = await tokenPromise;

const app = express();
app.use(cors()); //이부분 괄호 안쳤다가 접속 안됨
app.use(express.json());

const config = {

}

app.get(&#39;/&#39;, (req, res) =&gt; {
    res.json({
        status: &quot;OK&quot;,
        message: &quot;조회에 성공하였습니다.&quot;,
        token: accessToken
    })
});

app.listen(3000, () =&gt; {
    console.log(&quot;Server is running on port 3000&quot;);
});</code></pre>
<p><img src="https://velog.velcdn.com/images/rudin_/post/169afd7e-4d97-4e58-9943-4d0189d06e20/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/99411786-5b53-441e-8232-030d2901f553/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[MicrosoftDataSchool] 74일차 - Fabric T-SQL, Data Warehouse, Direct Lake, KQL]]></title>
            <link>https://velog.io/@rudin_/MicrosoftDataSchool-74%EC%9D%BC%EC%B0%A8-Fabric-T-SQL-Data-Warehouse-Direct-Lake-KQL</link>
            <guid>https://velog.io/@rudin_/MicrosoftDataSchool-74%EC%9D%BC%EC%B0%A8-Fabric-T-SQL-Data-Warehouse-Direct-Lake-KQL</guid>
            <pubDate>Wed, 22 Apr 2026 08:35:09 GMT</pubDate>
            <description><![CDATA[<h1 id="microsoft-fabric-data-warehouse를-활용한-고성능-t-sql-분석-및-데이터-통합">Microsoft Fabric Data Warehouse를 활용한 고성능 T-SQL 분석 및 데이터 통합</h1>
<h2 id="sql-endpoint-vs-dedicated-warehouse">SQL Endpoint vs Dedicated Warehouse</h2>
<p>Microsoft Fabric는 전통적인 Data Warehouse 외에도 Lakehouse에 정제된 데이터를 두고 SQL Endpoint로 읽는 방법도 제공</p>
<table>
<thead>
<tr>
<th>특징</th>
<th>SQL Endpoint (Lakehouse)</th>
<th>Dedicated Warehouse</th>
<th>선택 기준</th>
</tr>
</thead>
<tbody><tr>
<td>용도</td>
<td>읽기 전용 분석 (Silver/Gold)</td>
<td>읽기/쓰기 모두 가능</td>
<td>데이터를 쓰냐? → DW, 읽기만? → Endpoint</td>
</tr>
<tr>
<td>데이터 위치</td>
<td>Lakehouse의 Delta Table 직접 액세스</td>
<td>독립적 저장소</td>
<td>이미 Lakehouse 있다? → Endpoint</td>
</tr>
<tr>
<td>성능</td>
<td>V-Order 최적화 활용</td>
<td>전용 리소스</td>
<td>대용량 DW 작업 → DW, 분석 → Endpoint</td>
</tr>
<tr>
<td>비용</td>
<td>저렴 (공유 리소스)</td>
<td>높음 (전용 리소스)</td>
<td>예산 조건 확인</td>
</tr>
<tr>
<td>Power BI 연결</td>
<td>Direct Lake 최적</td>
<td>Direct Query / Import</td>
<td>Direct Lake 필요 → Endpoint</td>
</tr>
<tr>
<td>T-SQL 지원</td>
<td>완전 지원</td>
<td>완전 지원</td>
<td>문법 동일</td>
</tr>
</tbody></table>
<h2 id="spark-sql-vs-t-sql">Spark SQL vs T-SQL</h2>
<table>
<thead>
<tr>
<th>기능 영역</th>
<th>Spark SQL (Databricks/Fabric Notebook)</th>
<th>T-SQL (Fabric Warehouse/Endpoint)</th>
</tr>
</thead>
<tbody><tr>
<td>주 목적</td>
<td>대용량 데이터 처리 및 ETL 변환</td>
<td>정형 데이터 조회, 보고, 비즈니스 로직 구현</td>
</tr>
<tr>
<td>로직 구현</td>
<td>UDF (Python/Scala 결합)</td>
<td>Stored Procedure (SQL 전용)</td>
</tr>
<tr>
<td>트랜잭션</td>
<td>파일 기반 (ACID), 암시적</td>
<td>세션 기반, 명시적 (BEGIN TRAN)</td>
</tr>
<tr>
<td>제어 흐름</td>
<td>외부 코드 (Python/Scala)에 의존</td>
<td>SQL 자체 지원 (IF, WHILE, 변수)</td>
</tr>
<tr>
<td>사용자층</td>
<td>데이터 엔지니어, 데이터 사이언티스트</td>
<td>데이터 분석가, BI 개발자, DBA</td>
</tr>
<tr>
<td>문법 특징</td>
<td>LIMIT, current_date(), approx_distinct()</td>
<td>TOP, GETDATE(), COUNT(DISTINCT)</td>
</tr>
</tbody></table>
<p><em>T-SQL</em>: Microsoft SQL Server와 Azure SQL Database에서 사용하는 표준 SQL 언어의 확장판으로서 데이터 정의, 데이터 조작, 데이터 제어 뿐만 아니라, 절차적 프로그래밍 기능을 추가하여 복잡한 비즈니스 로직을 서버 측에서 구현</p>
<h3 id="t-sql을-쓰는-이유">T-SQL을 쓰는 이유</h3>
<ul>
<li><strong>Universal Connectivity</strong>: Excel, 3rd Party BI도구와 높은 호환성</li>
<li><strong>Standard Governance</strong>: GRATN/DENY 기반의 명확한 오브젝트 레벨 권한 관리</li>
<li><strong>Logic Encapsulation</strong>: Stored Procedure를 통해 비즈니스 로직을 DB 내부에 안전하게 격리</li>
</ul>
<h3 id="cross-database-query">Cross-Database Query</h3>
<p><img src="https://velog.velcdn.com/images/rudin_/post/5f04340e-6cdb-46d5-adaf-948c22aa506c/image.png" alt=""></p>
<p>다른 Database, 다른 Lakehouse에 있는 테이블을 JOIN 하기 위해서 데이터를 이동할 필요가 없음</p>
<h3 id="power-bi-direct-lake-mode">Power BI Direct Lake Mode</h3>
<ul>
<li>Import는 메모리 한계가 있고 Direct Query는 느림</li>
<li>이럴때 Direct Lake를 사용하면 빠른 속도에 대용량 파일도 로드 가능</li>
</ul>
<table>
<thead>
<tr>
<th>Feature</th>
<th>Import</th>
<th>Direct Query</th>
<th>Direct Lake</th>
</tr>
</thead>
<tbody><tr>
<td>Speed</td>
<td>Very Fast</td>
<td>Slow</td>
<td>Very Fast</td>
</tr>
<tr>
<td>Data Copy</td>
<td>Yes (Duplication)</td>
<td>No</td>
<td>No (Zero Copy)</td>
</tr>
<tr>
<td>Freshness</td>
<td>Schedule Refresh</td>
<td>Real-time</td>
<td>Real-time</td>
</tr>
<tr>
<td>Limit</td>
<td>Memory Limit</td>
<td>DB Load</td>
<td>Large Scale Support</td>
</tr>
<tr>
<td>### Semantic Models</td>
<td></td>
<td></td>
<td></td>
</tr>
<tr>
<td>Direct Lake모드가 제공하는 빠른 속도 덕분에 기본 모델 생성을 해서 BI 리포트 생성을 좀 더 편하게 해주는 default semantic model 기능이 제공되었으나, 자동 생성의 부작용으로 필요 없는 모델들이 생기고 워크스페이스가 복잡해짐에 따라서 해당 기능은 중단되었고, 대신 사용자가 명시적으로 생성하도록 변경</td>
<td></td>
<td></td>
<td></td>
</tr>
</tbody></table>
<hr>

<h1 id="실습-microsoft-fabric에서-data-warehouse-쿼리">실습: Microsoft Fabric에서 Data Warehouse 쿼리</h1>
<p><img src="https://velog.velcdn.com/images/rudin_/post/40432b5e-abcf-464e-a68c-a5da4e9ce9f3/image.png" alt=""></p>
<h2 id="data-warehouse-쿼리">Data Warehouse 쿼리</h2>
<p>SQL 쿼리 편집기는 IntelliSense, 코드 완성, 구문 강조 표시, 클라이언트 측 구문 분석(parsing) 및 유효성 검사를 지원합니다. Data Definition Language (DDL), Data Manipulation Language (DML) 및 Data Control Language (DCL) 문을 실행할 수 있다.</p>
<pre><code class="language-sql">SELECT
    D.MonthName,
    COUNT(*) AS TotalTrips,
    SUM(T.TotalAmount) AS TotalRevenue
FROM dbo.Trip AS T
JOIN dbo.[Date] AS D
    ON T.[DateID]=D.[DateID]
GROUP BY D.MonthName;</code></pre>
<p><img src="https://velog.velcdn.com/images/rudin_/post/6c1ed0fe-2621-4821-800d-eaad7380af08/image.png" alt=""></p>
<p>이 쿼리를 통해 각 월별 전체 이동 횟수와 총 수익을 확인할 수 있다.</p>
<hr>
<p>다음으로 요일별 평균 이동 시간과 평균 이동 거리를 분석한다.</p>
<pre><code class="language-sql">SELECT
    D.DayName,
    AVG(T.TripDurationSeconds) AS AvgDuration,
    AVG(T.TripDistanceMiles) AS AvgDistance
FROM dbo.Trip AS T
JOIN dbo.[Date] AS D
    ON T.[DateID]=D.[DateID]
GROUP BY D.DayName;</code></pre>
<p><img src="https://velog.velcdn.com/images/rudin_/post/39c048a9-15ef-42da-9fe4-74c0d90a0258/image.png" alt=""></p>
<p>이를 통해 요일별 이동 패턴을 파악할 수 있다.</p>
<hr>
<p>또한, 이동이 많이 발생한 도시를 확인하기 위해
도시별 이동 수 상위 10개를 조회한다.</p>
<pre><code class="language-sql">SELECT TOP 10
    G.City,
    COUNT(*) AS TotalTrips
FROM dbo.Trip AS T
JOIN dbo.Geography AS G
    ON T.DropoffGeographyID=G.GeographyID
GROUP BY G.City
ORDER BY TotalTrips DESC;</code></pre>
<p><img src="https://velog.velcdn.com/images/rudin_/post/a3c2bfad-8786-4f20-aac5-5fba16617849/image.png" alt=""></p>
<p>이 쿼리는 특정 도시에서의 이동량이 얼마나 집중되는지 확인하는 데 유용하다.</p>
<hr>
<h2 id="데이터-일관성-확인">데이터 일관성 확인</h2>
<p>분석 결과의 신뢰성을 확보하기 위해 데이터의 일관성을 확인한다.</p>
<p>먼저 비정상적으로 긴 이동 시간이 존재하는지 확인한다.
(24시간 = 86400초 기준)</p>
<pre><code class="language-sql">-- Check for trips with unusually long duration
SELECT COUNT(*) FROM dbo.Trip WHERE TripDurationSeconds &gt; 86400; -- 24 hours</code></pre>
<p><img src="https://velog.velcdn.com/images/rudin_/post/20cf4f90-f478-4390-8720-58a4c81c09e3/image.png" alt=""></p>
<p>이 값이 존재한다면 데이터 오류 가능성을 의심할 수 있다.</p>
<hr>
<p>다음으로 음수 이동 시간이 존재하는지 확인한다.</p>
<pre><code class="language-sql">-- Check for trips with negative trip duration
SELECT COUNT(*) FROM dbo.Trip WHERE TripDurationSeconds &lt; 0;</code></pre>
<p><img src="https://velog.velcdn.com/images/rudin_/post/40f90eda-eeb1-43eb-93f5-5d9c31c059b7/image.png" alt=""></p>
<p>음수 이동 시간은 명백한 데이터 오류이므로 반드시 제거해야 한다.
확인된 데이터 오류를 제거하여 데이터 품질을 개선한다.</p>
<pre><code class="language-sql">-- Remove trips with negative trip duration
DELETE FROM dbo.Trip WHERE TripDurationSeconds &lt; 0;</code></pre>
<p>이 과정을 통해 분석에 사용되는 데이터의 정확도를 높일 수 있다.</p>
<hr>
<h2 id="뷰로-저장">뷰로 저장</h2>
<p>이제 자주 사용하는 분석 쿼리를 View로 저장하여 재사용할 수 있도록 한다.</p>
<p>먼저 기본 집계 쿼리를 작성한다.</p>
<pre><code class="language-sql">SELECT
    D.DayName,
    AVG(T.TripDurationSeconds) AS AvgDuration,
    AVG(T.TripDistanceMiles) AS AvgDistance
FROM dbo.Trip AS T
JOIN dbo.[Date] AS D
    ON T.[DateID]=D.[DateID]
GROUP BY D.DayName;</code></pre>
<p><img src="https://velog.velcdn.com/images/rudin_/post/b975a479-747f-434d-9cb9-403c246d3462/image.png" alt=""></p>
<hr>
<p>이후 특정 조건(예: 1월 데이터)으로 필터링한다.</p>
<pre><code class="language-sql">SELECT
    D.DayName,
    AVG(T.TripDurationSeconds) AS AvgDuration,
    AVG(T.TripDistanceMiles) AS AvgDistance
FROM dbo.Trip AS T
JOIN dbo.[Date] AS D
    ON T.[DateID]=D.[DateID]
WHERE D.Month = 1
GROUP BY D.DayName</code></pre>
<p><img src="https://velog.velcdn.com/images/rudin_/post/c27c2100-fbb6-4eb4-9a8d-d22f026ee065/image.png" alt=""></p>
<hr>
<p>해당 쿼리를 선택한 후 <strong>Save as view</strong> 기능을 사용하여
<code>vw_JanTrip</code>이라는 이름으로 저장한다.
<img src="https://velog.velcdn.com/images/rudin_/post/0de3519e-0975-49aa-a04d-27510145eb08/image.png" alt=""></p>
<p>이렇게 생성된 View는 이후 반복적인 분석이나 BI 도구에서 재사용할 수 있다.</p>
<hr>

<h1 id="t-sql을-사용하여-data-warehouse에-데이터-로드">T-SQL을 사용하여 Data Warehouse에 데이터 로드</h1>
<h2 id="lakehouse-생성-및-데이터-준비">Lakehouse 생성 및 데이터 준비</h2>
<p>먼저 Microsoft Fabric에서 Workspace를 생성한 뒤, Lakehouse를 생성한다.</p>
<p>이후 제공된 <code>sales.csv</code> 파일을 Lakehouse의 Files 영역에 업로드한다.
업로드한 파일을 기반으로 <strong>테이블을 생성(Create table)</strong> 하여 <code>staging_sales</code> 테이블을 만든다.</p>
<p>이 테이블은 이후 Data Warehouse로 데이터를 적재하기 위한 <strong>Staging 영역</strong> 역할을 한다.</p>
<hr>
<h2 id="data-warehouse-생성">Data Warehouse 생성</h2>
<p>다음으로 Data Warehouse를 생성한다.</p>
<p>이 Warehouse는 Lakehouse의 데이터를 기반으로
분석용 Fact/Dimension 테이블을 구성하는 공간이다.</p>
<p>Warehouse에서 안하고 Lakehouse에서 하면 create table 할때 denied되니 명심하자</p>
<hr>
<h2 id="fact-및-dimension-테이블-생성">Fact 및 Dimension 테이블 생성</h2>
<p>Warehouse에서 SQL Query를 열고,
Fact 테이블과 Dimension 테이블을 생성한다.</p>
<pre><code class="language-sql">CREATE SCHEMA Sales;
GO

CREATE TABLE Sales.Fact_Sales
(
    SalesOrderNumber NVARCHAR(20) NOT NULL,
    SalesOrderLineNumber INT NOT NULL,
    OrderDate DATE NOT NULL,
    CustomerKey INT NOT NULL,
    ItemKey INT NOT NULL,
    Quantity INT,
    UnitPrice FLOAT,
    Tax FLOAT
);
GO

CREATE TABLE Sales.Dim_Customer
(
    CustomerKey INT IDENTITY(1,1) NOT NULL,
    CustomerName NVARCHAR(100),
    Email NVARCHAR(100)
);
GO

CREATE TABLE Sales.Dim_Item
(
    ItemKey INT IDENTITY(1,1) NOT NULL,
    ItemName NVARCHAR(100)
);
GO</code></pre>
<ul>
<li><code>Fact_Sales</code> → 판매 데이터 저장 (Fact Table)</li>
<li><code>Dim_Customer</code>, <code>Dim_Item</code> → 차원 테이블</li>
</ul>
<hr>
<h2 id="staging-데이터-연결-view-생성">Staging 데이터 연결 (View 생성)</h2>
<p>Lakehouse의 <code>staging_sales</code> 테이블을 Warehouse에서 참조하기 위해 View를 생성한다.</p>
<pre><code class="language-sql">CREATE VIEW Sales.Staging_Sales
AS
SELECT *
FROM staging_sales;</code></pre>
<p>이 View를 통해 Lakehouse 데이터를 Warehouse에서 직접 조회할 수 있다.</p>
<hr>
<h2 id="데이터-로드를-위한-저장-프로시저-생성">데이터 로드를 위한 저장 프로시저 생성</h2>
<p>Staging 데이터를 Fact/Dimension 테이블로 적재하기 위해
저장 프로시저를 생성한다.</p>
<pre><code class="language-sql">CREATE PROCEDURE Sales.LoadDataFromStaging (@OrderYear INT)
AS
BEGIN

-- Load customers
INSERT INTO Sales.Dim_Customer (CustomerName, Email)
SELECT DISTINCT CustomerName, Email
FROM Sales.Staging_Sales
WHERE YEAR(OrderDate) = @OrderYear
AND CustomerName NOT IN (SELECT CustomerName FROM Sales.Dim_Customer);

-- Load items
INSERT INTO Sales.Dim_Item (ItemName)
SELECT DISTINCT Item
FROM Sales.Staging_Sales
WHERE YEAR(OrderDate) = @OrderYear
AND Item NOT IN (SELECT ItemName FROM Sales.Dim_Item);

-- Load fact table
INSERT INTO Sales.Fact_Sales
SELECT
    s.SalesOrderNumber,
    s.SalesOrderLineNumber,
    s.OrderDate,
    c.CustomerKey,
    i.ItemKey,
    s.Quantity,
    s.UnitPrice,
    s.Tax
FROM Sales.Staging_Sales s
JOIN Sales.Dim_Customer c
    ON s.CustomerName = c.CustomerName
JOIN Sales.Dim_Item i
    ON s.Item = i.ItemName
WHERE YEAR(s.OrderDate) = @OrderYear;

END;</code></pre>
<p>이 프로시저는 다음 작업을 수행한다.</p>
<ul>
<li>고객 데이터 적재</li>
<li>상품 데이터 적재</li>
<li>Fact 테이블 적재</li>
</ul>
<hr>
<h2 id="데이터-로드-실행">데이터 로드 실행</h2>
<p>2021년 데이터를 Warehouse로 로드한다.</p>
<pre><code class="language-sql">EXEC Sales.LoadDataFromStaging 2021;</code></pre>
<hr>
<h2 id="데이터-분석">데이터 분석</h2>
<p>데이터가 정상적으로 로드되었는지 확인하기 위해
분석 쿼리를 실행한다.</p>
<hr>
<h3 id="고객별-총-판매액">고객별 총 판매액</h3>
<pre><code class="language-sql">SELECT
    c.CustomerName,
    SUM(f.Quantity * (f.UnitPrice + f.Tax)) AS TotalSales
FROM Sales.Fact_Sales f
JOIN Sales.Dim_Customer c
    ON f.CustomerKey = c.CustomerKey
GROUP BY c.CustomerName
ORDER BY TotalSales DESC;</code></pre>
<p><img src="https://velog.velcdn.com/images/rudin_/post/83feb154-6d02-4a16-a390-f6576c25b04e/image.png" alt=""></p>
<hr>
<h3 id="상품별-총-판매액">상품별 총 판매액</h3>
<pre><code class="language-sql">SELECT
    i.ItemName,
    SUM(f.Quantity * (f.UnitPrice + f.Tax)) AS TotalSales
FROM Sales.Fact_Sales f
JOIN Sales.Dim_Item i
    ON f.ItemKey = i.ItemKey
GROUP BY i.ItemName
ORDER BY TotalSales DESC;</code></pre>
<p><img src="https://velog.velcdn.com/images/rudin_/post/edaf37eb-8f48-4b8b-91df-b3648b099d7a/image.png" alt=""></p>
<hr>
<h3 id="카테고리별-상위-고객-분석">카테고리별 상위 고객 분석</h3>
<pre><code class="language-sql">WITH SalesCTE AS
(
    SELECT
        c.CustomerName,
        i.ItemName,
        SUM(f.Quantity * (f.UnitPrice + f.Tax)) AS TotalSales,
        CASE
            WHEN i.ItemName LIKE &#39;%Bike%&#39; THEN &#39;Bike&#39;
            ELSE &#39;Other&#39;
        END AS Category
    FROM Sales.Fact_Sales f
    JOIN Sales.Dim_Customer c
        ON f.CustomerKey = c.CustomerKey
    JOIN Sales.Dim_Item i
        ON f.ItemKey = i.ItemKey
    GROUP BY c.CustomerName, i.ItemName
)
SELECT *
FROM
(
    SELECT *,
           ROW_NUMBER() OVER (PARTITION BY Category ORDER BY TotalSales DESC) AS rn
    FROM SalesCTE
) t
WHERE rn &lt;= 5;</code></pre>
<p><img src="https://velog.velcdn.com/images/rudin_/post/95b81ff4-3360-4557-a203-518e7cbf05c2/image.png" alt=""></p>
<hr>

<h1 id="자습서-direct-lake-의미-체계-모델-및-power-bi-보고서-만들기">자습서: Direct Lake 의미 체계 모델 및 Power BI 보고서 만들기</h1>
<h2 id="데이터-가져오기">데이터 가져오기</h2>
<p>Warehouse에서 데이터 가져오기 선택
샘플 데이터의 Retail~ 선택
<img src="https://velog.velcdn.com/images/rudin_/post/c99a56cc-b97f-42a3-bb5e-2a2562ac1cbc/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/afd4f202-29bf-43fc-80bc-ce681290f23d/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/4c45c54f-9132-49b0-bbf6-39fcf09ca23f/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/17b0a7f2-0913-419d-8d68-2bacd14e3574/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/bc38a62e-aa20-4c64-86d4-c566301ee9c8/image.png" alt=""></p>
<h2 id="의미체계-모델-만들기">의미체계 모델 만들기</h2>
<p><img src="https://velog.velcdn.com/images/rudin_/post/db4323e2-98e7-42c0-b4a8-9419cb899080/image.png" alt="">
만들어진 의미체계 모델은 작업 영역에서 확인 가능
<img src="https://velog.velcdn.com/images/rudin_/post/547cc0ba-f8b5-4160-b0e6-2c2c77acd109/image.png" alt=""></p>
<h3 id="관계-관리">관계 관리</h3>
<p><img src="https://velog.velcdn.com/images/rudin_/post/2ceb6a43-035b-491d-aadd-49ded7513565/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/9eaf0e5f-5141-4361-9519-8127637b111f/image.png" alt=""></p>
<h2 id="보고서-만들기">보고서 만들기</h2>
<p><img src="https://velog.velcdn.com/images/rudin_/post/a1a742b7-2e87-43f1-9e6d-560cc4974d7a/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/df5d28bd-30a4-4542-acac-0550ec3e0b44/image.png" alt=""></p>
<hr>

<h1 id="fabric을-활용한-실시간-분석">Fabric을 활용한 실시간 분석</h1>
<h2 id="security-architecture--hierarchy">Security Architecture &amp; Hierarchy</h2>
<p><img src="https://velog.velcdn.com/images/rudin_/post/db8a2a04-41ca-41a3-8829-ceda804b93bc/image.png" alt="">
<img src="https://velog.velcdn.com/images/rudin_/post/336481d3-fe26-437f-906d-ea2869684d14/image.png" alt=""></p>
<h2 id="workspace-roles--permissions">Workspace Roles &amp; Permissions</h2>
<p>단위입니다. 팀 리더, 데이터 엔지니어, 분석가, 보고서 사용자 등
역할에 따라 Admin, Member, Contributor, Viewer 중 하나를 부여하여 필요한 권한만 제공
더 세밀한 제어가 필요하면 &#39;Share&#39;로 특정 아이템만 공유</p>
<p><img src="https://velog.velcdn.com/images/rudin_/post/c24e0f51-9e8f-4928-9560-f0141ab38a97/image.png" alt=""></p>
<h2 id="onelake-security">OneLake Security</h2>
<p>데이터 자체(테이블, 폴더)에 대한 접근을 제어.</p>
<p><img src="https://velog.velcdn.com/images/rudin_/post/bc0744f8-cedf-427b-8ca1-bd585ce66933/image.png" alt=""></p>
<h2 id="row-level-securityrls">Row-Level Security(RLS)</h2>
<p>사용자의 권한(User Context)에 따라 테이블의 특정 행(Row)만 조회되도록 필터링하는 보안 기능
<img src="https://velog.velcdn.com/images/rudin_/post/f970aead-2c2c-4e34-8dfa-d49f38090fc1/image.png" alt=""></p>
<h2 id="implementing-rls-with-t-sql">Implementing RLS with T-SQL</h2>
<p><img src="https://velog.velcdn.com/images/rudin_/post/9b2f1c68-59d0-4aae-99fc-7b9a64e175d1/image.png" alt=""></p>
<h3 id="1-보안-함수-생성">1. 보안 함수 생성</h3>
<p>USER_NAME() 함수를 사용하여 현재 접속한 사용자 식별</p>
<h3 id="2-보안-정책-적용">2. 보안 정책 적용</h3>
<p>위에서 만든 함수를 실제 테이블에 결합
STATE = ON 으로 설정하는 즉시 모든 쿼리에 필터가 적용</p>
<h2 id="sql-permissions-grant-deny--column-level-security">SQL Permissions: GRANT, Deny &amp; Column-Level Security</h2>
<p><img src="https://velog.velcdn.com/images/rudin_/post/9e648418-6c0a-4f6b-9436-9a60fcdd5641/image.png" alt=""></p>
<h2 id="governance-purview-lineage--endorsement">Governance: Purview, Lineage &amp; Endorsement</h2>
<h3 id="purview-hub-데이터-보호">Purview Hub (데이터 보호)</h3>
<p>• MIP Label: 데이터에 &#39;기밀(Confidential)’ tag를 붙이면, 엑셀로 다운로드해도 암호화가 유지
• 인사이트: &quot;우리 회사에 민감 정보가 얼마나 있지?&quot;를 대시보드로 표시</p>
<h3 id="data-lineage-데이터-족보">Data Lineage (데이터 족보)</h3>
<p>• 자동 시각화: 원본 데이터가 어떤 파이프라인을 거쳐 어떤 리포트가 되었는지 자동 생성
• 영향도 분석: &quot;이 테이블 고치면 어떤 리포트가 깨질까?“ 미리 알 수 있음</p>
<h3 id="endorsement-신뢰-마크">Endorsement (신뢰 마크)</h3>
<p>• Certified (인증됨): IT 부서가 &quot;이건 믿고 써도 됨&quot;이라고 보증한 데이터.
• Promoted (홍보됨): 팀 리더가 &quot;우리 팀 데이터 공유할게&quot;라고 내놓은 데이터.</p>
<h2 id="real-time-intelligence-kql-database--eventstream">Real-Time Intelligence: KQL Database &amp; Eventstream</h2>
<p><img src="https://velog.velcdn.com/images/rudin_/post/edd59c7b-8e42-452e-825f-264f5d1ad883/image.png" alt=""></p>
<h3 id="kql-database-저장소">KQL Database (저장소)</h3>
<p>• 고성능 로그 분석: 기존 SQL DB는 로그 쌓이면 느려지지만, KQL DB는 페타바이트급 로그도 순식간에 검색
• 비정형 데이터: JSON, 텍스트 로그 등 구조가 일정하지 않은 데이터도 그대로 넣고 바로 쿼리</p>
<h3 id="eventstream-연결-통로">Eventstream (연결 통로)</h3>
<p>• No-Code 연결: IoT 센서, 앱 로그, Kafka 등을 코딩 없이 클릭만으로 연결
• 실시간 처리: 데이터가 들어오는 즉시 필터링하거나 변환해서 KQL DB나 Lakehouse로 전송</p>
<h2 id="kql-kusto-query-language">KQL (Kusto Query Language)</h2>
<p><img src="https://velog.velcdn.com/images/rudin_/post/39f22a68-9fe5-498a-9d6a-89f6b1de61d5/image.png" alt=""></p>
<p>데이터를 탐색하고 패턴을 발견하고, 변칙과 이상값을 식별하고, 통계 모델링을 만드는 등의 작업을 수행할 수 있는 강력한 도구로서 Microsoft에서 만들었으며 Azure Data Explorer, Azure Monitor, Microsoft Fabric 등에 사용
대소문자를 구분함</p>
<h2 id="fabric-activator-formely-reflex">Fabric Activator (formely Reflex)</h2>
<p>데이터 소스에서 특정 패턴이나 조건이 감지될 때 자동으로 작업을 실행하는
코드 없는 저지연 이벤트 감지 엔진</p>
<ul>
<li>1초 미만의 대기 시간으로 데이터 원본을 지속적으로 모니터링</li>
<li>임계값이 충족되거나 특정 패턴이 검색되면 작업 (예: 전자메일 또는 Teams 알림 보내기, Power Automate 흐름 시작, 타사 시스템 통합 등)을 시작</li>
<li>데이터가 지속적으로 흐르는 반응형 이벤트 기반 아키텍처에 적합하며, 이벤트 데이터의 상태 저장 평가에 따라 거의 실시간으로 결정</li>
</ul>
<hr>

<h1 id="실습-microsoft-fabric-데이터-웨어하우스-보안-설정">실습: Microsoft Fabric 데이터 웨어하우스 보안 설정</h1>
<h2 id="테이블의-column에-동적-데이터-마스킹-규칙-적용">테이블의 Column에 동적 데이터 마스킹 규칙 적용</h2>
<p>동적 데이터 마스킹 규칙은 테이블 수준의 개별 Column에 적용되므로 모든 Query가 마스킹의 영향을 받습니다. 기밀 데이터를 볼 명시적인 권한이 없는 사용자는 Query 결과에서 마스킹된 값을 보게 되며, 데이터를 볼 명시적인 권한이 있는 사용자는 마스킹되지 않은 데이터를 봅니다. 마스크에는 기본(default), 이메일(email), 무작위(random), 사용자 지정 문자열(custom string)의 네 가지 유형이 있습니다. </p>
<ol>
<li>Warehouse에서 T-SQL 타일을 선택하고, 다음 T-SQL 문을 사용하여 테이블을 생성하고 데이터를 삽입하고 조회합니다.<pre><code class="language-python">CREATE TABLE dbo.Customers
(   
 CustomerID INT NOT NULL,   
 FirstName varchar(50) MASKED WITH (FUNCTION = &#39;partial(1,&quot;XXXXXXX&quot;,0)&#39;) NULL,     
 LastName varchar(50) NOT NULL,     
 Phone varchar(20) MASKED WITH (FUNCTION = &#39;default()&#39;) NULL,     
 Email varchar(50) MASKED WITH (FUNCTION = &#39;email()&#39;) NULL   
);
</code></pre>
</li>
</ol>
<p>INSERT dbo.Customers (CustomerID, FirstName, LastName, Phone, Email) VALUES
(29485,&#39;Catherine&#39;,&#39;Abel&#39;,&#39;555-555-5555&#39;,&#39;catherine0@adventure-works.com&#39;),
(29486,&#39;Kim&#39;,&#39;Abercrombie&#39;,&#39;444-444-4444&#39;,&#39;kim2@adventure-works.com&#39;),
(29489,&#39;Frances&#39;,&#39;Adams&#39;,&#39;333-333-3333&#39;,&#39;frances0@adventure-works.com&#39;);</p>
<p>SELECT * FROM dbo.Customers;</p>
<pre><code>마스크 해제된 데이터를 볼 수 없는 사용자가 테이블을 Query할 때, FirstName Column은 문자열의 첫 글자와 XXXXXXX를 표시하고 마지막 문자는 표시하지 않습니다. Phone Column은 xxxx를 표시합니다. Email Column은 이메일 주소의 첫 글자 다음에 XXX@XXX.com을 표시합니다. 이 접근 방식은 민감한 데이터가 기밀을 유지하도록 보장하면서도 제한된 사용자가 테이블을 Query할 수 있도록 합니다.
![](https://velog.velcdn.com/images/rudin_/post/8f5fd4e8-123a-4eda-adc9-78cd9d61ca4d/image.png)

2. ▷ 실행 버튼을 사용하여 SQL 스크립트를 실행합니다. 이 스크립트는 Data Warehouse의 dbo Schema에 Customers라는 새 테이블을 생성합니다.
3. 그런 다음, 탐색기 창에서 Schemas &gt; dbo &gt; Tables를 확장하고 Customers 테이블이 생성되었는지 확인합니다. Workspace 생성자로서 마스크 해제된 데이터를 볼 수 있는 Workspace Admin 역할의 멤버이므로, SELECT 문은 마스크 해제된 데이터를 반환합니다.

## 행 수준 보안(Row-level security) 적용
```python
CREATE TABLE dbo.Sales  
(  
    OrderID INT,  
    SalesRep VARCHAR(60),  
    Product VARCHAR(10),  
    Quantity INT  
);

--Populate the table with 6 rows of data, showing 3 orders for each test user. 
INSERT dbo.Sales (OrderID, SalesRep, Product, Quantity) VALUES
(1, &#39;&lt;username1&gt;@&lt;your_domain&gt;.com&#39;, &#39;Valve&#39;, 5),   
(2, &#39;&lt;username1&gt;@&lt;your_domain&gt;.com&#39;, &#39;Wheel&#39;, 2),   
(3, &#39;&lt;username1&gt;@&lt;your_domain&gt;.com&#39;, &#39;Valve&#39;, 4),  
(4, &#39;&lt;username2&gt;@&lt;your_domain&gt;.com&#39;, &#39;Bracket&#39;, 2),   
(5, &#39;&lt;username2&gt;@&lt;your_domain&gt;.com&#39;, &#39;Wheel&#39;, 5),   
(6, &#39;&lt;username2&gt;@&lt;your_domain&gt;.com&#39;, &#39;Seat&#39;, 5);  

SELECT * FROM dbo.Sales;  </code></pre><p><img src="https://velog.velcdn.com/images/rudin_/post/0c34671a-6014-4aa7-858f-2325d3ea5a81/image.png" alt=""></p>
<p>새 Schema, Function으로 정의된 보안 Predicate, 그리고 보안 정책을 생성</p>
<pre><code class="language-python">--Create a separate schema to hold the row-level security objects (the predicate function and the security policy)
CREATE SCHEMA rls;
GO

/*Create the security predicate defined as an inline table-valued function.
A predicate evaluates to true (1) or false (0). This security predicate returns 1,
meaning a row is accessible, when a row in the SalesRep column is the same as the user
executing the query.*/   
--Create a function to evaluate who is querying the table
CREATE FUNCTION rls.fn_securitypredicate(@SalesRep AS VARCHAR(60)) 
    RETURNS TABLE  
WITH SCHEMABINDING  
AS  
    RETURN SELECT 1 AS fn_securitypredicate_result   
WHERE @SalesRep = USER_NAME();
GO   
/*Create a security policy to invoke and enforce the function each time a query is run on the Sales table.
The security policy has a filter predicate that silently filters the rows available to 
read operations (SELECT, UPDATE, and DELETE). */
CREATE SECURITY POLICY SalesFilter  
ADD FILTER PREDICATE rls.fn_securitypredicate(SalesRep)   
ON dbo.Sales  
WITH (STATE = ON);
GO</code></pre>
<p><img src="https://velog.velcdn.com/images/rudin_/post/8cbe9155-ca96-4c66-becc-6c9d2760eaf3/image.png" alt=""></p>
<h2 id="열-수준-보안column-level-security-구현">열 수준 보안(Column-level security) 구현</h2>
<p>열 수준 보안은 어떤 사용자가 테이블의 특정 Column에 접근할 수 있는지 지정할 수 있도록 합니다. 이는 Column 목록과 Column을 읽을 수 있거나 없는 사용자 또는 역할을 지정하여 테이블에 GRANT 또는 DENY 문을 발행함으로써 구현됩니다. 접근 관리를 간소화하기 위해 개별 사용자 대신 역할에 권한을 할당합니다. 이 실습에서는 테이블을 생성하고, 테이블의 Column 하위 집합에 접근 권한을 부여하며, 제한된 Column이 본인 외의 사용자에게는 보이지 않는지 테스트합니다.</p>
<pre><code class="language-python">CREATE TABLE dbo.Orders
(   
    OrderID INT,   
    CustomerID INT,  
    CreditCard VARCHAR(20)      
);   
INSERT dbo.Orders (OrderID, CustomerID, CreditCard) VALUES
(1234, 5678, &#39;111111111111111&#39;),
(2341, 6785, &#39;222222222222222&#39;),
(3412, 7856, &#39;333333333333333&#39;);   
SELECT * FROM dbo.Orders;</code></pre>
<pre><code class="language-python">DENY SELECT ON dbo.Orders (CreditCard) TO [&lt;username1&gt;@&lt;your_domain&gt;.com];</code></pre>
<p><img src="https://velog.velcdn.com/images/rudin_/post/1f650da1-8964-47e1-9959-22725df7f124/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/11f31fe4-8809-4993-b801-8df3e7f00ff8/image.png" alt=""></p>
<h2 id="t-sql을-사용하여-sql-세분화된-권한-구성">T-SQL을 사용하여 SQL 세분화된 권한 구성</h2>
<p>Fabric은 Workspace 수준 및 항목 수준에서 데이터 접근을 제어할 수 있는 권한 모델을 가지고 있습니다. Fabric Warehouse의 보안 개체(securables)를 사용자들이 무엇을 할 수 있는지 더 세밀하게 제어해야 할 때, 표준 SQL 데이터 제어 언어(DCL) 명령어인 GRANT, DENY, REVOKE를 사용할 수 있습니다. 이 실습에서는 객체(objects)를 생성하고, GRANT 및 DENY를 사용하여 객체를 보호한 다음, Query를 실행하여 세분화된 권한 적용의 효과를 확인합니다.</p>
<pre><code class="language-python">CREATE PROCEDURE dbo.sp_PrintMessage
AS
PRINT &#39;Hello World.&#39;;
GO   
CREATE TABLE dbo.Parts
(
    PartID INT,
    PartName VARCHAR(25)
);

INSERT dbo.Parts (PartID, PartName) VALUES
(1234, &#39;Wheel&#39;),
(5678, &#39;Seat&#39;);
 GO

/*Execute the stored procedure and select from the table and note the results you get
as a member of the Workspace Admin role. Look for output from the stored procedure on 
the &#39;Messages&#39; tab.*/
EXEC dbo.sp_PrintMessage;
GO   
SELECT * FROM dbo.Parts</code></pre>
<p>다음으로, Workspace Viewer 역할의 멤버인 사용자에게 테이블에 대한 DENY SELECT 권한을 부여하고, 동일한 사용자에게 프로시저에 대한 GRANT EXECUTE 권한을 부여합니다. <code>&lt;username1&gt;@&lt;your_domain&gt;.com</code>을 Workspace에 Viewer 권한을 가진 사용자의 사용자 이름으로 대체합니다.</p>
<pre><code class="language-python">DENY SELECT on dbo.Parts to [&lt;username1&gt;@&lt;your_domain&gt;.com];

GRANT EXECUTE on dbo.sp_PrintMessage to [&lt;username1&gt;@&lt;your_domain&gt;.com];</code></pre>
<hr>

<h1 id="microsoft-fabric에서-eventstream을-사용하여-실시간-데이터-수집">Microsoft Fabric에서 Eventstream을 사용하여 실시간 데이터 수집</h1>
<p>Eventstream은 Microsoft Fabric의 기능으로, 실시간 이벤트를 캡처, 변환 및 다양한 대상으로 라우팅합니다. Eventstream에 이벤트 데이터 원본, 대상 및 변환을 추가할 수 있습니다.</p>
<h2 id="eventhouse-만들기">Eventhouse 만들기</h2>
<p>작업 영역에서 + 새 항목을 선택
<img src="https://velog.velcdn.com/images/rudin_/post/f7d5416f-03df-4cbd-bdb5-b6a37bce0450/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/8b33f9b1-9327-4180-85d6-4d218bee1186/image.png" alt=""></p>
<h2 id="eventstream-만들기">Eventstream 만들기</h2>
<p><img src="https://velog.velcdn.com/images/rudin_/post/440ddbce-a20a-4aca-85d6-10ddab18f098/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/3f3d6188-150e-487f-a4ce-a7e9c56200b2/image.png" alt="">
샘플 데이터 사용
<img src="https://velog.velcdn.com/images/rudin_/post/5f14496d-07bd-4253-b911-c79d81b029e2/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/bc326108-10fb-4824-a3e0-1f98ea0b096e/image.png" alt=""></p>
<h2 id="원본-추가">원본 추가</h2>
<p><img src="https://velog.velcdn.com/images/rudin_/post/6debace3-8c1d-405a-88c2-3e4445731e18/image.png" alt=""></p>
<h2 id="대상-추가">대상 추가</h2>
<p><img src="https://velog.velcdn.com/images/rudin_/post/e3f4d900-0595-419c-a1e8-ccd554f38790/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/f89172f7-94c6-4f8f-9167-86b108f49244/image.png" alt="">
<em>watermark delay</em>: 데이터가 늦게 도착해도 윈도우(결과 집계)를 닫지 않고 얼마나 더 기다려줄 것인가</p>
<h2 id="캡처된-데이터-쿼리">캡처된 데이터 쿼리</h2>
<p><img src="https://velog.velcdn.com/images/rudin_/post/05e14776-fdce-45fe-9489-20b911db048a/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/1386f129-e79f-4d64-b91a-7bd8c9462fa9/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/e82f63a5-762c-4206-853a-49f9e893b70c/image.png" alt=""></p>
<h2 id="이벤트-데이터-변환">이벤트 데이터 변환</h2>
<p><img src="https://velog.velcdn.com/images/rudin_/post/74809ba5-e704-486c-bdf2-9a62f6fa5271/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/37a6072e-f7b2-4eb4-8891-3be7ecccb88c/image.png" alt="">
<img src="https://velog.velcdn.com/images/rudin_/post/0d9dc182-49e6-45c8-85b7-0e5bf13a7ba8/image.png" alt="">
<img src="https://velog.velcdn.com/images/rudin_/post/ff3ab149-aa82-4420-b57a-3be1ab1331ed/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/cfd16e66-2240-475f-8618-5399afd4538b/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/035f0287-bcc1-44fd-be9b-9ca5621c16a7/image.png" alt=""></p>
<h2 id="변환된-데이터-쿼리">변환된 데이터 쿼리</h2>
<p><img src="https://velog.velcdn.com/images/rudin_/post/ac238a0f-2f18-4567-9680-62baeb016af8/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/a18a5691-fcdc-4263-b5b1-62c84ada1c5f/image.png" alt=""></p>
<hr>

<h1 id="microsoft-fabric-eventhouse에서-데이터-작업">Microsoft Fabric Eventhouse에서 데이터 작업</h1>
<h2 id="kql을-사용하여-데이터-쿼리">KQL을 사용하여 데이터 쿼리</h2>
<pre><code class="language-python">// Use &#39;project&#39; and &#39;take&#39; to view a sample number of records in the table and check the data.
Bikestream
| project Street, No_Bikes
| take 10</code></pre>
<p><img src="https://velog.velcdn.com/images/rudin_/post/822b95f4-1f0f-4443-975a-4e86a6a3c7dc/image.png" alt=""></p>
<pre><code class="language-python">Bikestream
| project Street, [&quot;Number of Empty Docks&quot;] = No_Empty_Docks
| take 10</code></pre>
<p><img src="https://velog.velcdn.com/images/rudin_/post/05d83584-a628-4ebd-b4c6-108f4f9aecda/image.png" alt=""></p>
<pre><code class="language-python">
Bikestream
| summarize [&quot;Total Number of Bikes&quot;] = sum(No_Bikes)</code></pre>
<p><img src="https://velog.velcdn.com/images/rudin_/post/f4c6ac7b-33b9-4b61-8b25-a34c882494f5/image.png" alt=""></p>
<pre><code class="language-python">Bikestream
| summarize [&quot;Total Number of Bikes&quot;] = sum(No_Bikes) by Neighbourhood
| project Neighbourhood, [&quot;Total Number of Bikes&quot;]</code></pre>
<p><img src="https://velog.velcdn.com/images/rudin_/post/023968bf-3641-4cea-ac1e-446af65bf39a/image.png" alt=""></p>
<pre><code class="language-python">Bikestream
| summarize [&quot;Total Number of Bikes&quot;] = sum(No_Bikes) by Neighbourhood
| project Neighbourhood = case(isempty(Neighbourhood) or isnull(Neighbourhood), &quot;Unidentified&quot;, Neighbourhood), [&quot;Total Number of Bikes&quot;]</code></pre>
<p><img src="https://velog.velcdn.com/images/rudin_/post/89a7a4fe-c75f-473e-a3b2-7ad94634e142/image.png" alt=""></p>
<pre><code class="language-python">Bikestream
| summarize [&quot;Total Number of Bikes&quot;] = sum(No_Bikes) by Neighbourhood
| project Neighbourhood = case(isempty(Neighbourhood) or isnull(Neighbourhood), &quot;Unidentified&quot;, Neighbourhood), [&quot;Total Number of Bikes&quot;]
| sort by Neighbourhood asc</code></pre>
<p><img src="https://velog.velcdn.com/images/rudin_/post/54a2f601-5482-4805-8a5b-7145e451fb9a/image.png" alt=""></p>
<pre><code class="language-python">Bikestream
| summarize [&quot;Total Number of Bikes&quot;] = sum(No_Bikes) by Neighbourhood
| project Neighbourhood = case(isempty(Neighbourhood) or isnull(Neighbourhood), &quot;Unidentified&quot;, Neighbourhood), [&quot;Total Number of Bikes&quot;]
| order by Neighbourhood asc</code></pre>
<p><img src="https://velog.velcdn.com/images/rudin_/post/4e02af2a-51a6-4055-9215-36c9f7eeb4b5/image.png" alt=""></p>
<pre><code class="language-python">Bikestream
| where Neighbourhood == &quot;Chelsea&quot;
| summarize [&quot;Total Number of Bikes&quot;] = sum(No_Bikes) by Neighbourhood
| project Neighbourhood = case(isempty(Neighbourhood) or isnull(Neighbourhood), &quot;Unidentified&quot;, Neighbourhood), [&quot;Total Number of Bikes&quot;]
| sort by Neighbourhood asc</code></pre>
<p><img src="https://velog.velcdn.com/images/rudin_/post/867cf1d8-0261-417e-bf89-41a0e090877c/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[MicrosoftDataSchool] 73일차 - Fabric에서 Apache Spark 사용하기, 델타 테이블 사용, 레이크하우스에서 메달리온 아키텍처 생성, Data Wrangler]]></title>
            <link>https://velog.io/@rudin_/MicrosoftDataSchool-73%EC%9D%BC%EC%B0%A8-Fabric%EC%97%90%EC%84%9C-Apache-Spark-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0-%EB%8D%B8%ED%83%80-%ED%85%8C%EC%9D%B4%EB%B8%94-%EC%82%AC%EC%9A%A9-%EB%A0%88%EC%9D%B4%ED%81%AC%ED%95%98%EC%9A%B0%EC%8A%A4%EC%97%90%EC%84%9C-%EB%A9%94%EB%8B%AC%EB%A6%AC%EC%98%A8-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98-%EC%83%9D%EC%84%B1-Data-Wrangler</link>
            <guid>https://velog.io/@rudin_/MicrosoftDataSchool-73%EC%9D%BC%EC%B0%A8-Fabric%EC%97%90%EC%84%9C-Apache-Spark-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0-%EB%8D%B8%ED%83%80-%ED%85%8C%EC%9D%B4%EB%B8%94-%EC%82%AC%EC%9A%A9-%EB%A0%88%EC%9D%B4%ED%81%AC%ED%95%98%EC%9A%B0%EC%8A%A4%EC%97%90%EC%84%9C-%EB%A9%94%EB%8B%AC%EB%A6%AC%EC%98%A8-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98-%EC%83%9D%EC%84%B1-Data-Wrangler</guid>
            <pubDate>Tue, 21 Apr 2026 08:41:01 GMT</pubDate>
            <description><![CDATA[<h1 id="microsoft-fabric-apache-spark-데이터-분석">Microsoft Fabric: Apache Spark 데이터 분석</h1>
<p>이 가이드는 Fabric Lakehouse로 데이터를 수집하고, <strong>PySpark</strong>와 <strong>Spark SQL</strong>을 사용하여 데이터를 읽고, 분석하고, 시각화하는 전체 과정을 다룹니다.</p>
<h2 id="1-환경-설정-및-데이터-준비">1. 환경 설정 및 데이터 준비</h2>
<h3 id="작업-영역-및-lakehouse-생성">작업 영역 및 Lakehouse 생성</h3>
<ol>
<li><strong>작업 영역 만들기</strong>: <a href="https://app.fabric.microsoft.com/home?experience=fabric-developer">Fabric 홈 페이지</a>에서 <strong>Workspaces</strong>를 선택하고, <strong>Advanced</strong> 섹션에서 Fabric Capacity 라이선스 모드를 선택하여 새 작업 영역을 만듭니다.</li>
<li><strong>Lakehouse 생성</strong>: <strong>Create</strong> 메뉴에서 <strong>Lakehouse</strong>를 선택하고 이름을 지정하여 생성합니다.</li>
<li><strong>데이터 업로드</strong>:<ul>
<li><a href="https://github.com/MicrosoftLearning/dp-data/raw/main/orders.zip">orders.zip</a> 파일을 다운로드하고 압축을 풉니다.</li>
<li>Lakehouse Explorer의 <strong>Files</strong> 폴더에서 <strong>Upload &gt; Upload folder</strong>를 선택하여 <code>orders</code> 폴더(2019.csv, 2020.csv, 2021.csv 포함)를 업로드합니다.</li>
</ul>
</li>
</ol>
<h3 id="notebook-생성">Notebook 생성</h3>
<ul>
<li><strong>Create</strong> 페이지에서 <strong>Notebook</strong>을 생성합니다.</li>
</ul>
<h2 id="2-데이터-로드-및-스키마-정의">2. 데이터 로드 및 스키마 정의</h2>
<h3 id="기본-데이터-로드-csv">기본 데이터 로드 (CSV)</h3>
<p><code>2019.csv</code> 파일을 로드하는 가장 기본적인 코드입니다.</p>
<pre><code class="language-python"># 2019.csv 파일 로드 (헤더 포함)
df = spark.read.format(&quot;csv&quot;).option(&quot;header&quot;,&quot;true&quot;).load(&quot;Files/orders/2019.csv&quot;)
display(df)</code></pre>
<h3 id="스키마-정의-및-모든-파일-로드">스키마 정의 및 모든 파일 로드</h3>
<p>데이터 형식을 명시적으로 지정하고, 와일드카드(<code>*</code>)를 사용하여 <code>orders</code> 폴더 내의 모든 CSV 파일을 로드합니다.</p>
<pre><code class="language-python">from pyspark.sql.types import *

# 스키마 정의
orderSchema = StructType([
    StructField(&quot;SalesOrderNumber&quot;, StringType()),
    StructField(&quot;SalesOrderLineNumber&quot;, IntegerType()),
    StructField(&quot;OrderDate&quot;, DateType()),
    StructField(&quot;CustomerName&quot;, StringType()),
    StructField(&quot;Email&quot;, StringType()),
    StructField(&quot;Item&quot;, StringType()),
    StructField(&quot;Quantity&quot;, IntegerType()),
    StructField(&quot;UnitPrice&quot;, FloatType()),
    StructField(&quot;Tax&quot;, FloatType())
])

# 모든 연도의 CSV 파일 로드
df = spark.read.format(&quot;csv&quot;).schema(orderSchema).load(&quot;Files/orders/*.csv&quot;)
display(df)</code></pre>
<h2 id="3-데이터-탐색-및-집계">3. 데이터 탐색 및 집계</h2>
<h3 id="데이터-필터링">데이터 필터링</h3>
<p>특정 열을 선택하거나 조건을 적용하여 데이터를 필터링합니다.</p>
<pre><code class="language-python"># 특정 제품을 구매한 고유 고객 리스트 추출
customers = df.select(&quot;CustomerName&quot;, &quot;Email&quot;).where(df[&#39;Item&#39;]==&#39;Road-250 Red, 52&#39;)
print(f&quot;Total records: {customers.count()}&quot;)
print(f&quot;Distinct customers: {customers.distinct().count()}&quot;)
display(customers.distinct())</code></pre>
<h3 id="데이터-집계-및-그룹화">데이터 집계 및 그룹화</h3>
<p>제품별 수량 합계 및 연도별 주문 수를 계산합니다.</p>
<pre><code class="language-python"># 제품별 주문 수량 합계
productSales = df.select(&quot;Item&quot;, &quot;Quantity&quot;).groupBy(&quot;Item&quot;).sum()
display(productSales)

# 연도별 판매 주문 수 계산
from pyspark.sql.functions import *
yearlySales = df.select(year(col(&quot;OrderDate&quot;)).alias(&quot;Year&quot;)).groupBy(&quot;Year&quot;).count().orderBy(&quot;Year&quot;)
display(yearlySales)</code></pre>
<h2 id="4-데이터-변환-및-저장">4. 데이터 변환 및 저장</h2>
<h3 id="데이터프레임-변환-열-추가-및-재정렬">데이터프레임 변환 (열 추가 및 재정렬)</h3>
<p>연/월 추출, 이름 분리 등 복합적인 변환을 수행합니다.</p>
<pre><code class="language-python">from pyspark.sql.functions import *

# 연, 월 열 추가 및 FirstName, LastName 분리
transformed_df = df.withColumn(&quot;Year&quot;, year(col(&quot;OrderDate&quot;))).withColumn(&quot;Month&quot;, month(col(&quot;OrderDate&quot;)))
transformed_df = transformed_df.withColumn(&quot;FirstName&quot;, split(col(&quot;CustomerName&quot;), &quot; &quot;).getItem(0)).withColumn(&quot;LastName&quot;, split(col(&quot;CustomerName&quot;), &quot; &quot;).getItem(1))

# 열 필터링 및 순서 재정렬
transformed_df = transformed_df[&quot;SalesOrderNumber&quot;, &quot;SalesOrderLineNumber&quot;, &quot;OrderDate&quot;, &quot;Year&quot;, &quot;Month&quot;, &quot;FirstName&quot;, &quot;LastName&quot;, &quot;Email&quot;, &quot;Item&quot;, &quot;Quantity&quot;, &quot;UnitPrice&quot;, &quot;Tax&quot;]

# 결과 확인
display(transformed_df.limit(5))</code></pre>
<p><img src="https://velog.velcdn.com/images/rudin_/post/de03c04f-8ae5-42b8-a843-b0f3f9331a1d/image.png" alt=""></p>
<h3 id="변환된-데이터-저장-parquet">변환된 데이터 저장 (Parquet)</h3>
<p>데이터를 Parquet 형식으로 저장하고 다시 로드합니다.</p>
<pre><code class="language-python"># Parquet 형식으로 저장
transformed_df.write.mode(&quot;overwrite&quot;).parquet(&#39;Files/transformed_data/orders&#39;)
print(&quot;Transformed data saved!&quot;)

# 저장된 Parquet 파일 로드
orders_df = spark.read.format(&quot;parquet&quot;).load(&quot;Files/transformed_data/orders&quot;)
display(orders_df)</code></pre>
<p><img src="https://velog.velcdn.com/images/rudin_/post/8524aad7-5a6d-4f8c-8d3b-5c8936e9f8f2/image.png" alt=""></p>
<h3 id="데이터-분할partitioning-저장">데이터 분할(Partitioning) 저장</h3>
<p>성능 향상을 위해 연도 및 월별로 데이터를 분할하여 저장합니다.</p>
<pre><code class="language-python"># Year 및 Month별로 분할 저장
orders_df.write.partitionBy(&quot;Year&quot;,&quot;Month&quot;).mode(&quot;overwrite&quot;).parquet(&quot;Files/partitioned_data&quot;)
print(&quot;Transformed data saved!&quot;)

# 특정 파티션(2021년 전체) 데이터만 로드
orders_2021_df = spark.read.format(&quot;parquet&quot;).load(&quot;Files/partitioned_data/Year=2021/Month=*&quot;)
display(orders_2021_df)</code></pre>
<p><img src="https://velog.velcdn.com/images/rudin_/post/3ce51505-1eb8-45ae-8453-b997cb0aa8b0/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/0aef51be-5538-44d0-a9d3-7d10856c5e86/image.png" alt=""></p>
<h2 id="5-테이블-및-sql-작업">5. 테이블 및 SQL 작업</h2>
<h3 id="delta-테이블-생성-및-쿼리">Delta 테이블 생성 및 쿼리</h3>
<p>관계를 정의하고 SQL로 쿼리할 수 있도록 Delta 테이블로 저장합니다.</p>
<pre><code class="language-python"># Delta 테이블로 저장
df.write.format(&quot;delta&quot;).saveAsTable(&quot;salesorders&quot;)

# 테이블 정보 확인
spark.sql(&quot;DESCRIBE EXTENDED salesorders&quot;).show(truncate=False)</code></pre>
<p>salesorders 테이블의 ..메뉴에서 Load data&gt; Spakr를 선택하여 새 코드 셀 추가
<img src="https://velog.velcdn.com/images/rudin_/post/fb901710-170a-4ba6-819b-464cabcc7d89/image.png" alt=""></p>
<pre><code class="language-python"># PySpark 내에서 SQL 쿼리 실행
df = spark.sql(&quot;SELECT * FROM day1_lakehouse2.salesorders LIMIT 1000&quot;)
display(df)</code></pre>
<p><img src="https://velog.velcdn.com/images/rudin_/post/6dd3ec7d-381a-4775-a4a3-dc0eea8fa312/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/75ba55ae-cac5-4c40-aa26-2330a3a756ac/image.png" alt=""></p>
<h3 id="sql-매직-커맨드-사용">SQL 매직 커맨드 사용</h3>
<p>셀 상단에 <code>%%sql</code>을 사용하여 직접 SQL 문을 실행합니다.</p>
<pre><code class="language-sql">%%sql
SELECT YEAR(OrderDate) AS OrderYear, 
       SUM((UnitPrice * Quantity) + Tax) AS GrossRevenue
FROM salesorders
GROUP BY YEAR(OrderDate)
ORDER BY OrderYear;</code></pre>
<p><img src="https://velog.velcdn.com/images/rudin_/post/0c7bd3dd-4414-45a4-baeb-42ea6218c8fd/image.png" alt=""></p>
<h2 id="6-데이터-시각화">6. 데이터 시각화</h2>
<h3 id="spark로-데이터-시각화">Spark로 데이터 시각화</h3>
<pre><code class="language-python">%%sql
SELECT * FROM salesorders</code></pre>
<p><img src="https://velog.velcdn.com/images/rudin_/post/05353232-f4a5-49a4-b8dc-b68c7da4451b/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/4e781ccb-c944-4d0f-8f1f-6097b8507899/image.png" alt=""></p>
<h3 id="matplotlib를-사용한-시각화">Matplotlib를 사용한 시각화</h3>
<p>Spark 데이터프레임을 Pandas로 변환하여 막대형 차트를 생성합니다.</p>
<pre><code class="language-python">import pandas as pd
from matplotlib import pyplot as plt

# SQL 쿼리 결과를 Spark DF로 가져오기
sqlQuery = &quot;SELECT CAST(YEAR(OrderDate) AS CHAR(4)) AS OrderYear, \
            SUM((UnitPrice * Quantity) + Tax) AS GrossRevenue, \
            COUNT(DISTINCT SalesOrderNumber) AS YearlyCounts \
            FROM salesorders \
            GROUP BY CAST(YEAR(OrderDate) AS CHAR(4)) \
            ORDER BY OrderYear&quot;
df_spark = spark.sql(sqlQuery)

# Matplotlib를 위해 Pandas DF로 변환
df_sales = df_spark.toPandas()

# 차트 사용자 지정 및 출력
plt.clf()
fig = plt.figure(figsize=(8,3))
plt.bar(x=df_sales[&#39;OrderYear&#39;], height=df_sales[&#39;GrossRevenue&#39;], color=&#39;orange&#39;)
plt.title(&#39;Revenue by Year&#39;)
plt.xlabel(&#39;Year&#39;)
plt.ylabel(&#39;Revenue&#39;)
plt.grid(color=&#39;#95a5a6&#39;, linestyle=&#39;--&#39;, linewidth=2, axis=&#39;y&#39;, alpha=0.7)
plt.xticks(rotation=45)
plt.show()

# 서브플롯 생성 (막대 차트 + 파이 차트)
plt.clf()
fig, ax = plt.subplots(1, 2, figsize = (10,4))
ax.bar(x=df_sales[&#39;OrderYear&#39;], height=df_sales[&#39;GrossRevenue&#39;], color=&#39;orange&#39;)
ax.set_title(&#39;Revenue by Year&#39;)
ax.pie(df_sales[&#39;YearlyCounts&#39;])
ax.set_title(&#39;Orders per Year&#39;)
ax.legend(df_sales[&#39;OrderYear&#39;])
fig.suptitle(&#39;Sales Data&#39;)
plt.show()</code></pre>
<p><img src="https://velog.velcdn.com/images/rudin_/post/264904b7-3539-4b39-a697-7da4c6a50f79/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/42418d39-0c1f-40c4-b883-b4fd4e491300/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/9a302f34-7c52-4945-9684-f2b485f19f5f/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/c1484db0-5a2b-4a34-954d-639562ddf694/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/3b99098f-698b-417f-acf2-f5fb9a4bf724/image.png" alt=""></p>
<h3 id="seaborn을-사용한-시각화">Seaborn을 사용한 시각화</h3>
<p>더 간결한 코드로 세련된 테마의 차트를 생성합니다.</p>
<pre><code class="language-python">import seaborn as sns

# 막대 차트 (Whitegrid 테마)
plt.clf()
sns.set_theme(style=&quot;whitegrid&quot;)
ax = sns.barplot(x=&quot;OrderYear&quot;, y=&quot;GrossRevenue&quot;, data=df_sales)
plt.show()

# 선형 차트
plt.clf()
ax = sns.lineplot(x=&quot;OrderYear&quot;, y=&quot;GrossRevenue&quot;, data=df_sales)
plt.show()</code></pre>
<p><img src="https://velog.velcdn.com/images/rudin_/post/79ad7064-cb27-40b2-a102-0306eec3bef8/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/f0953121-1377-4c24-b365-678008e67611/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/4eb4e9a4-7610-44e6-ae5f-7cec81e00387/image.png" alt=""></p>
<h2 id="7-리소스-정리">7. 리소스 정리</h2>
<p>실습이 종료되면 세션을 중단하고 작업 영역을 삭제합니다.</p>
<ol>
<li>Notebook 상단에서 <strong>Stop session</strong>을 클릭합니다.</li>
<li><strong>Workspace settings</strong>에서 <strong>Remove this workspace</strong>를 선택하여 삭제합니다.</li>
</ol>
<hr>

<h2 id="스트리밍-데이터에-delta-table-사용">스트리밍 데이터에 Delta Table 사용</h2>
<p>Delta Lake는 스트리밍 데이터를 지원합니다. Delta Table은 Spark Structured Streaming API를 사용하여 생성된 데이터 스트림의 Sink 또는 Source가 될 수 있습니다. 이 예시에서는 시뮬레이션된 IoT(Internet of Things) 시나리오에서 일부 스트리밍 데이터의 Sink로 Delta Table을 사용합니다.</p>
<pre><code class="language-python"> from notebookutils import mssparkutils
 from pyspark.sql.types import *
 from pyspark.sql.functions import *

 # Create a folder
 inputPath = &#39;Files/data/&#39;
 mssparkutils.fs.mkdirs(inputPath)

 # Create a stream that reads data from the folder, using a JSON schema
 jsonSchema = StructType([
 StructField(&quot;device&quot;, StringType(), False),
 StructField(&quot;status&quot;, StringType(), False)
 ])
 iotstream = spark.readStream.schema(jsonSchema).option(&quot;maxFilesPerTrigger&quot;, 1).json(inputPath)

 # Write some event data to the folder
 device_data = &#39;&#39;&#39;{&quot;device&quot;:&quot;Dev1&quot;,&quot;status&quot;:&quot;ok&quot;}
 {&quot;device&quot;:&quot;Dev1&quot;,&quot;status&quot;:&quot;ok&quot;}
 {&quot;device&quot;:&quot;Dev1&quot;,&quot;status&quot;:&quot;ok&quot;}
 {&quot;device&quot;:&quot;Dev2&quot;,&quot;status&quot;:&quot;error&quot;}
 {&quot;device&quot;:&quot;Dev1&quot;,&quot;status&quot;:&quot;ok&quot;}
 {&quot;device&quot;:&quot;Dev1&quot;,&quot;status&quot;:&quot;error&quot;}
 {&quot;device&quot;:&quot;Dev2&quot;,&quot;status&quot;:&quot;ok&quot;}
 {&quot;device&quot;:&quot;Dev2&quot;,&quot;status&quot;:&quot;error&quot;}
 {&quot;device&quot;:&quot;Dev1&quot;,&quot;status&quot;:&quot;ok&quot;}&#39;&#39;&#39;

 mssparkutils.fs.put(inputPath + &quot;data.txt&quot;, device_data, True)

 print(&quot;Source stream created...&quot;)</code></pre>
<p>방금 실행한 코드는 가상의 IoT 디바이스에서 읽은 데이터를 나타내는, 일부 데이터가 저장된 폴더를 기반으로 스트리밍 데이터 Source를 생성했습니다.</p>
<pre><code class="language-python"># Write the stream to a delta table
delta_stream_table_path = &#39;Tables/iotdevicedata&#39;
checkpointpath = &#39;Files/delta/checkpoint&#39;
deltastream = iotstream.writeStream.format(&quot;delta&quot;).option(&quot;checkpointLocation&quot;, checkpointpath).start(delta_stream_table_path)
print(&quot;Streaming to delta sink...&quot;)</code></pre>
<p>이 코드는 스트리밍 디바이스 데이터를 Delta 형식으로 iotdevicedata라는 폴더에 씁니다. Tables 폴더에 있는 폴더 위치의 경로 때문에 해당 폴더에 Table이 자동으로 생성됩니다.</p>
<pre><code class="language-python">%%sql
SELECT * FROM IotDeviceData;</code></pre>
<pre><code class="language-python"># Add more data to the source stream
more_data = &#39;&#39;&#39;{&quot;device&quot;:&quot;Dev1&quot;,&quot;status&quot;:&quot;ok&quot;}
{&quot;device&quot;:&quot;Dev1&quot;,&quot;status&quot;:&quot;ok&quot;}
{&quot;device&quot;:&quot;Dev1&quot;,&quot;status&quot;:&quot;ok&quot;}
{&quot;device&quot;:&quot;Dev1&quot;,&quot;status&quot;:&quot;ok&quot;}
{&quot;device&quot;:&quot;Dev1&quot;,&quot;status&quot;:&quot;error&quot;}
{&quot;device&quot;:&quot;Dev2&quot;,&quot;status&quot;:&quot;error&quot;}
{&quot;device&quot;:&quot;Dev1&quot;,&quot;status&quot;:&quot;ok&quot;}&#39;&#39;&#39;

mssparkutils.fs.put(inputPath + &quot;more-data.txt&quot;, more_data, True)</code></pre>
<pre><code class="language-python">%%sql
SELECT * FROM IotDeviceData;</code></pre>
<pre><code class="language-python">deltastream.stop()</code></pre>
<p><img src="https://velog.velcdn.com/images/rudin_/post/4a2fdbfe-6849-4149-a28e-841f9d362c3a/image.png" alt="">
코드에서 <code>Files/data</code> 안에 있는 데이터들로 하도록 설정해서, 기존의 sales 데이터까지 읽어들여 NULL값이 입력되는 문제가 있었다. sales 데이터를 삭제하고 진행하면 정상적으로 진행된다.</p>
<p>또한 동일한 데이터(동일한 셀)을 넣는걸 반복하더라도 중복으로 적재되지 않는다.</p>
<hr>


<h1 id="pyspark-기반-메달리온-아키텍처-구축과-data-wrangler를-활용한-효율적-데이터-전처리">PySpark 기반 메달리온 아키텍처 구축과 Data Wrangler를 활용한 효율적 데이터 전처리</h1>
<h2 id="starter-pool">Starter Pool</h2>
<p>Fabric에서는 Live Pool(Warm) 방식으로 클러스터를 운영 → 대기 시간 없이 거의 바로 세션을 시작 가능</p>
<ul>
<li>항상 켜져 있음</li>
<li>5~10초안에 세션 시작</li>
<li>대기 비용 X</li>
</ul>
<h2 id="onelake--notebook">OneLake &amp; Notebook</h2>
<ul>
<li>Fabric Notebook에서는 Lakehouse Explorer(OneLake에 저장된 테이블 및 파일 탐색)가 내장</li>
<li>데이터 탐색을 위한 별도 탭 이동 없이 Notebook 내에서 데이터 탐색, 코드 작성, 시각화를 진행</li>
</ul>
<table>
<thead>
<tr>
<th>구분</th>
<th>Azure Databricks</th>
<th>Microsoft Fabric</th>
</tr>
</thead>
<tbody><tr>
<td>외부 데이터 접근 방식</td>
<td>mount 필요 (Storage 연결 설정)</td>
<td>바로 접근 가능 (Lakehouse 기반)</td>
</tr>
<tr>
<td>설정 과정</td>
<td>App Registration → RBAC 권한 → Key Vault → Secret Scope → mount 코드 작성</td>
<td>별도 설정 없음</td>
</tr>
<tr>
<td>보안 처리</td>
<td>Key Vault + Secret 관리 필요</td>
<td>플랫폼에서 자동 관리</td>
</tr>
<tr>
<td>사용 편의성</td>
<td>초기 설정 복잡, 매번 mount 필요</td>
<td>매우 간단 (즉시 사용)</td>
</tr>
<tr>
<td>코드 예시</td>
<td>dbutils.fs.mount(...) 설정 필요</td>
<td>spark.read.csv(&quot;Files/...&quot;) 바로 사용</td>
</tr>
<tr>
<td>플랫폼 특성</td>
<td>IaaS/PaaS 기반 구성형 환경</td>
<td>완전 관리형 SaaS 통합 플랫폼</td>
</tr>
<tr>
<td>Lakehouse 연동</td>
<td>직접 연결 및 설정 필요</td>
<td>기본 내장 및 긴밀한 통합</td>
</tr>
</tbody></table>
<h2 id="spark-on-databricks-vs-spark-on-fabric">Spark on Databricks vs Spark on Fabric</h2>
<table>
<thead>
<tr>
<th>비교 항목</th>
<th>Spark on Databricks</th>
<th>Spark on Fabric</th>
</tr>
</thead>
<tbody><tr>
<td>클러스터 시작</td>
<td>Cold Start (3~5분 소요)<br>직접 생성 및 관리 필요</td>
<td>Live Start (5~10초 소요)<br>Starter Pool 자동 할당</td>
</tr>
<tr>
<td>저장소 연결</td>
<td>Mount 방식 (dbutils.fs.mount)<br>별도 권한 / Key Vault 설정 필수</td>
<td>Direct Access (Files/, Tables/)<br>OneLake 자동 통합, 설정 불필요</td>
</tr>
<tr>
<td>파일 최적화</td>
<td>Z-Order<br>사용자가 수동으로 실행 (OPTIMIZE)</td>
<td>V-Order Write 시 자동 적용 (기본값)</td>
</tr>
<tr>
<td>BI 연동</td>
<td>Power BI Import / DirectQuery<br>데이터 이동 또는 성능 제약 있음</td>
<td>Direct Lake<br>데이터 이동 없이 실시간급 조회</td>
</tr>
<tr>
<td>비용 모델</td>
<td>VM + DBU (이중 과금 구조)<br>복잡한 비용 예측</td>
<td>Capacity 단위 (통합 과금)<br>단일 Capacity로 모든 워크로드 사용</td>
</tr>
</tbody></table>
<br>

<table>
<thead>
<tr>
<th>구분</th>
<th>Databricks</th>
<th>Microsoft Fabric</th>
</tr>
</thead>
<tbody><tr>
<td>파일 경로</td>
<td>dbfs:/mnt/my_mount/data.csv</td>
<td>Files/data.csv (Relative)<br>Tables/my_table<br>abfss://... (Full Path)</td>
</tr>
<tr>
<td>파일 읽기</td>
<td>spark.read.csv(&quot;dbfs:/mnt/...&quot;)</td>
<td>spark.read.csv(&quot;Files/data/raw.csv&quot;)</td>
</tr>
<tr>
<td>테이블 저장</td>
<td>df.write.saveAsTable(&quot;hive_metastore...&quot;)</td>
<td>df.write.format(&quot;delta&quot;).save(&quot;Tables/sales&quot;)</td>
</tr>
<tr>
<td>파일 목록 조회</td>
<td>dbutils.fs.ls(&quot;/mnt/...&quot;)</td>
<td>mssparkutils.fs.ls(&quot;Files/...&quot;)</td>
</tr>
<tr>
<td>데이터 레이크</td>
<td>별도 구성 필요</td>
<td>OneLake 기본 통합</td>
</tr>
<tr>
<td>Delta Lake</td>
<td>필요 시 설치</td>
<td>기본 탑재 (설치 불필요)</td>
</tr>
</tbody></table>
<h2 id="medallion-architecture">Medallion Architecture</h2>
<table>
<thead>
<tr>
<th>구분</th>
<th>Bronze Layer (Raw Zone)</th>
<th>Silver Layer (Trusted Zone)</th>
<th>Gold Layer (Curated Zone)</th>
</tr>
</thead>
<tbody><tr>
<td>목표</td>
<td>원본 데이터 그대로 저장 (History 보존)</td>
<td>깨끗하고 신뢰할 수 있는 데이터 (분석 준비 완료)</td>
<td>비즈니스 리포팅 및 AI 모델링용 데이터</td>
</tr>
<tr>
<td>주요 작업</td>
<td>read (csv, json, parquet)<br>메타데이터 컬럼 추가 (ingestion_date, source_system)</td>
<td>NULL 처리 (dropna / fillna)<br>중복 제거 (dropDuplicates)<br>타입 변환 (cast)<br>스키마 강제</td>
<td>집계 (groupBy, sum, avg)<br>조인 (Fact + Dimension, Star Schema)<br>파생 변수 생성 (withColumn)</td>
</tr>
</tbody></table>
<h2 id="data-quality-startegy">Data Quality Startegy</h2>
<p><img src="https://velog.velcdn.com/images/rudin_/post/c2d8318d-2bcd-467c-9e8c-6d4073769138/image.png" alt=""></p>
<h3 id="level-3-advanced-validation-고급">Level 3: Advanced Validation (고급)</h3>
<ul>
<li>What: 복잡한 비즈니스 규칙 검증</li>
<li>How: Great Expectations (GX) 라이브러리 활용 (Fabric Notebook에 설치 가능)</li>
<li>Note: Fabric은 Python 라이브러리를 자유롭게 지원하므로, 기존에 쓰던 GX 같은 도구를 그대로 사용 가능</li>
</ul>
<h3 id="level-2-constraint-checks-중급">Level 2: Constraint Checks (중급)</h3>
<ul>
<li>What: 값의 유효성 검사 (NULL 불가, 양수만 허용 등)</li>
<li>How: Delta Lake의 CHECK 제약 조건 사용</li>
<li>Code:<code>ALTER TABLE sales ADD CONSTRAINT valid_amount
CHECK (amount &gt; 0);</code></li>
</ul>
<h3 id="level-1-schema-enforcement-기본">Level 1: Schema Enforcement (기본)</h3>
<ul>
<li>What: 데이터 타입과 컬럼명 강제</li>
<li>How: Delta Table의 Schema Enforcement 기능 활용</li>
<li>Code: <code>df.write.option(&quot;mergeSchema&quot;, &quot;false&quot;).save(...)</code>
(스키마 변경 시 에러 발생시켜 보호)</li>
</ul>
<h2 id="delta-lake-in-fabric">Delta Lake in Fabric</h2>
<h4 id="native-integration">Native Integration</h4>
<ul>
<li>Spark, SQL Engine, Power BI 모두 Delta Lake를 기본 포맷으로 인식</li>
<li>복잡한 변환 과정이 없음<ul>
<li>Databricks에서는 Parquet도 써야 하는데, Fabric은 Delta만 쓰면 됨<h4 id="v-order-optimization">V-Order Optimization</h4>
</li>
</ul>
</li>
<li>Spark로 저장하는 순간 자동 최적화</li>
<li>열 순서 재정렬, 압축 레벨 조정</li>
<li>별도의 Z-Ordering 설정 불필요<h4 id="one-copy-principle">One Copy Principle</h4>
</li>
<li>Power BI가 Direct Lake 방식으로 직접 읽음 (복제 없음)</li>
<li>Bronze/Silver/Gold 데이터를 바로BI에 연결 가능</li>
<li>저장 공간 절감 + 최신성 보장<h2 id="data-wrangler">Data Wrangler</h2>
전처리 과정을 visualization해서 보여줌</li>
<li>예비 데이터 분석을 위한 몰입형 인터페이스를 제공하는 Notebook 기반 도구</li>
<li>격자 형태의 데이터 표시, 동적 요약 통계, 기본 제공 시각화 및 일반적인 데이터 정리 작업에 대한 라이브러리를 결합</li>
</ul>
<hr>

<h1 id="실습-microsoft-fabric-레이크하우스에-메달리온-아키텍처-구축하기">실습: Microsoft Fabric 레이크하우스에 메달리온 아키텍처 구축하기</h1>
<p>맞다. 전에 내가 <strong>페이지에 있는 코드 셀 전부를 다 안 넣었다</strong>.
이번엔 페이지 원문을 다시 확인해서, <strong>실습 흐름에 나온 코드 셀들을 순서대로 빠짐없이 재구성</strong>해서 준다. 이 실습은 Silver용 Notebook, SQL 쿼리 2개, Gold용 Notebook으로 구성된다. ([YSSuperS2000][1])</p>
<p>아래 내용은 <strong>Velog에 올리기 좋게 정리한 완전판</strong>이다.</p>
<hr>
<h1 id="microsoft-fabric-medallion-lakehouse-실습-정리">Microsoft Fabric Medallion Lakehouse 실습 정리</h1>
<h2 id="개요">개요</h2>
<p>이번 실습에서는 Microsoft Fabric Lakehouse에서 <strong>Bronze → Silver → Gold</strong> 구조의 메달리온 아키텍처를 구축한다.
실습 흐름은 다음과 같다.</p>
<ul>
<li>Bronze 폴더에 원본 CSV 업로드</li>
<li>Notebook으로 Silver Delta 테이블 생성 및 적재</li>
<li>SQL endpoint로 Silver 데이터 탐색</li>
<li>Notebook으로 Gold 차원/팩트 테이블 생성</li>
<li>필요 시 Semantic Model 생성 ([YSSuperS2000][1])</li>
</ul>
<hr>
<h2 id="1-bronze-layer">1. Bronze Layer</h2>
<p>Lakehouse의 <code>Files/bronze/</code> 경로에 아래 3개 파일을 업로드한다. ([YSSuperS2000][1])</p>
<ul>
<li><code>2019.csv</code></li>
<li><code>2020.csv</code></li>
<li><code>2021.csv</code></li>
</ul>
<hr>
<h2 id="2-silver-layer-notebook">2. Silver Layer Notebook</h2>
<h3 id="2-1-원본-csv-로드--스키마-정의">2-1. 원본 CSV 로드 + 스키마 정의</h3>
<pre><code class="language-python">from pyspark.sql.types import *

orderSchema = StructType([
    StructField(&quot;SalesOrderNumber&quot;, StringType()),
    StructField(&quot;SalesOrderLineNumber&quot;, IntegerType()),
    StructField(&quot;OrderDate&quot;, DateType()),
    StructField(&quot;CustomerName&quot;, StringType()),
    StructField(&quot;Email&quot;, StringType()),
    StructField(&quot;Item&quot;, StringType()),
    StructField(&quot;Quantity&quot;, IntegerType()),
    StructField(&quot;UnitPrice&quot;, FloatType()),
    StructField(&quot;Tax&quot;, FloatType())
])

df = spark.read.format(&quot;csv&quot;).option(&quot;header&quot;, &quot;false&quot;).schema(orderSchema).load(&quot;Files/bronze/*.csv&quot;)

display(df.head(10))</code></pre>
<h3 id="2-2-컬럼-추가-및-데이터-정제">2-2. 컬럼 추가 및 데이터 정제</h3>
<p>원본 파일명, 플래그 여부, 생성/수정 시각을 추가하고, <code>CustomerName</code>이 비어 있거나 null이면 <code>&quot;Unknown&quot;</code>으로 치환한다. ([YSSuperS2000][1])</p>
<pre><code class="language-python">from pyspark.sql.functions import when, lit, col, current_timestamp, input_file_name

df = df.withColumn(&quot;FileName&quot;, input_file_name()) \
    .withColumn(&quot;IsFlagged&quot;, when(col(&quot;OrderDate&quot;) &lt; &#39;2019-08-01&#39;, True).otherwise(False)) \
    .withColumn(&quot;CreatedTS&quot;, current_timestamp()) \
    .withColumn(&quot;ModifiedTS&quot;, current_timestamp())

df = df.withColumn(
    &quot;CustomerName&quot;,
    when((col(&quot;CustomerName&quot;).isNull() | (col(&quot;CustomerName&quot;) == &quot;&quot;)), lit(&quot;Unknown&quot;))
    .otherwise(col(&quot;CustomerName&quot;))
)</code></pre>
<p><img src="https://velog.velcdn.com/images/rudin_/post/10dd0b29-3cd0-4c09-824c-200c6de5f0d5/image.png" alt=""></p>
<h3 id="2-3-salessales_silver-테이블-생성">2-3. <code>sales.sales_silver</code> 테이블 생성</h3>
<p>실습 페이지에서는 Delta Lake 형식으로 Silver 테이블 스키마를 먼저 정의한다. 
만약 스키마를 사용하지 않는다고 lakehouse 생성 시 설정한 경우 <code>sales.sales_silver</code> 가 아닌, <code>sales_silver</code>로 테이블을 설정해서 dbo에 저장하면 된다.</p>
<pre><code class="language-python">from pyspark.sql.types import *
from delta.tables import *

DeltaTable.createIfNotExists(spark) \
    .tableName(&quot;sales.sales_silver&quot;) \
    .addColumn(&quot;SalesOrderNumber&quot;, StringType()) \
    .addColumn(&quot;SalesOrderLineNumber&quot;, IntegerType()) \
    .addColumn(&quot;OrderDate&quot;, DateType()) \
    .addColumn(&quot;CustomerName&quot;, StringType()) \
    .addColumn(&quot;Email&quot;, StringType()) \
    .addColumn(&quot;Item&quot;, StringType()) \
    .addColumn(&quot;Quantity&quot;, IntegerType()) \
    .addColumn(&quot;UnitPrice&quot;, FloatType()) \
    .addColumn(&quot;Tax&quot;, FloatType()) \
    .addColumn(&quot;FileName&quot;, StringType()) \
    .addColumn(&quot;IsFlagged&quot;, BooleanType()) \
    .addColumn(&quot;CreatedTS&quot;, DateType()) \
    .addColumn(&quot;ModifiedTS&quot;, DateType()) \
    .execute()</code></pre>
<h3 id="2-4-silver-테이블-upsert">2-4. Silver 테이블 Upsert</h3>
<p><code>SalesOrderNumber</code>, <code>OrderDate</code>, <code>CustomerName</code>, <code>Item</code> 기준으로 merge를 수행하고, 일치하지 않으면 insert한다. 실습 페이지의 <code>whenMatchedUpdate</code>는 비어 있는 형태로 제시되어 있다. ([YSSuperS2000][1])</p>
<pre><code class="language-python">from delta.tables import *

deltaTable = DeltaTable.forPath(spark, &#39;Tables/sales_silver&#39;)

dfUpdates = df

deltaTable.alias(&#39;silver&#39;) \
  .merge(
    dfUpdates.alias(&#39;updates&#39;),
    &#39;silver.SalesOrderNumber = updates.SalesOrderNumber and silver.OrderDate = updates.OrderDate and silver.CustomerName = updates.CustomerName and silver.Item = updates.Item&#39;
  ) \
  .whenMatchedUpdate(set=
    {
    }
  ) \
  .whenNotMatchedInsert(values=
    {
      &quot;SalesOrderNumber&quot;: &quot;updates.SalesOrderNumber&quot;,
      &quot;SalesOrderLineNumber&quot;: &quot;updates.SalesOrderLineNumber&quot;,
      &quot;OrderDate&quot;: &quot;updates.OrderDate&quot;,
      &quot;CustomerName&quot;: &quot;updates.CustomerName&quot;,
      &quot;Email&quot;: &quot;updates.Email&quot;,
      &quot;Item&quot;: &quot;updates.Item&quot;,
      &quot;Quantity&quot;: &quot;updates.Quantity&quot;,
      &quot;UnitPrice&quot;: &quot;updates.UnitPrice&quot;,
      &quot;Tax&quot;: &quot;updates.Tax&quot;,
      &quot;FileName&quot;: &quot;updates.FileName&quot;,
      &quot;IsFlagged&quot;: &quot;updates.IsFlagged&quot;,
      &quot;CreatedTS&quot;: &quot;updates.CreatedTS&quot;,
      &quot;ModifiedTS&quot;: &quot;updates.ModifiedTS&quot;
    }
  ) \
  .execute()</code></pre>
<hr>
<h2 id="3-sql-endpoint에서-silver-데이터-탐색">3. SQL Endpoint에서 Silver 데이터 탐색</h2>
<p><img src="https://velog.velcdn.com/images/rudin_/post/c30834fc-6e6b-4355-9e06-7d1f3020173a/image.png" alt=""></p>
<h3 id="3-1-연도별-총매출">3-1. 연도별 총매출</h3>
<pre><code class="language-sql">SELECT YEAR(OrderDate) AS Year
, CAST (SUM(Quantity * (UnitPrice + Tax)) AS DECIMAL(12, 2)) AS TotalSales
FROM sales_silver
GROUP BY YEAR(OrderDate)
ORDER BY YEAR(OrderDate)</code></pre>
<h3 id="3-2-구매-수량-상위-고객-10명">3-2. 구매 수량 상위 고객 10명</h3>
<pre><code class="language-sql">SELECT TOP 10 CustomerName, SUM(Quantity) AS TotalQuantity
FROM sales_silver
GROUP BY CustomerName
ORDER BY TotalQuantity DESC</code></pre>
<hr>
<h2 id="4-gold-layer-notebook">4. Gold Layer Notebook</h2>
<p>Notebook 이름: <code>Transform data for Gold</code>
이 Notebook에서는 <code>sales_silver</code>를 기반으로 날짜 차원, 고객 차원, 제품 차원, 판매 팩트 테이블을 만든다. ([YSSuperS2000][1])</p>
<h3 id="4-1-silver-데이터-로드">4-1. Silver 데이터 로드</h3>
<pre><code class="language-python">df = spark.read.table(&quot;Sales.sales_silver&quot;)</code></pre>
<hr>
<h3 id="4-2-날짜-차원-테이블-생성">4-2. 날짜 차원 테이블 생성</h3>
<h3 id="4-2-1-salesdimdate_gold-테이블-생성">4-2-1. <code>sales.dimdate_gold</code> 테이블 생성</h3>
<pre><code class="language-python">from pyspark.sql.types import *
from delta.tables import *

DeltaTable.createIfNotExists(spark) \
    .tableName(&quot;sales.dimdate_gold&quot;) \
    .addColumn(&quot;OrderDate&quot;, DateType()) \
    .addColumn(&quot;Day&quot;, IntegerType()) \
    .addColumn(&quot;Month&quot;, IntegerType()) \
    .addColumn(&quot;Year&quot;, IntegerType()) \
    .addColumn(&quot;mmmyyyy&quot;, StringType()) \
    .addColumn(&quot;yyyymm&quot;, StringType()) \
    .execute()</code></pre>
<h3 id="4-2-2-날짜-차원-dataframe-생성">4-2-2. 날짜 차원 DataFrame 생성</h3>
<pre><code class="language-python">from pyspark.sql.functions import col, dayofmonth, month, year, date_format

dfdimDate_gold = df.dropDuplicates([&quot;OrderDate&quot;]).select(
        col(&quot;OrderDate&quot;),
        dayofmonth(&quot;OrderDate&quot;).alias(&quot;Day&quot;),
        month(&quot;OrderDate&quot;).alias(&quot;Month&quot;),
        year(&quot;OrderDate&quot;).alias(&quot;Year&quot;),
        date_format(col(&quot;OrderDate&quot;), &quot;MMM-yyyy&quot;).alias(&quot;mmmyyyy&quot;),
        date_format(col(&quot;OrderDate&quot;), &quot;yyyyMM&quot;).alias(&quot;yyyymm&quot;),
    ).orderBy(&quot;OrderDate&quot;)

display(dfdimDate_gold.head(10))</code></pre>
<h3 id="4-2-3-날짜-차원-upsert">4-2-3. 날짜 차원 Upsert</h3>
<pre><code class="language-python">from delta.tables import *

deltaTable = DeltaTable.forPath(spark, &#39;Tables/dimdate_gold&#39;)

dfUpdates = dfdimDate_gold

deltaTable.alias(&#39;gold&#39;) \
  .merge(
    dfUpdates.alias(&#39;updates&#39;),
    &#39;gold.OrderDate = updates.OrderDate&#39;
  ) \
  .whenMatchedUpdate(set=
    {
    }
  ) \
  .whenNotMatchedInsert(values=
    {
      &quot;OrderDate&quot;: &quot;updates.OrderDate&quot;,
      &quot;Day&quot;: &quot;updates.Day&quot;,
      &quot;Month&quot;: &quot;updates.Month&quot;,
      &quot;Year&quot;: &quot;updates.Year&quot;,
      &quot;mmmyyyy&quot;: &quot;updates.mmmyyyy&quot;,
      &quot;yyyymm&quot;: &quot;updates.yyyymm&quot;
    }
  ) \
  .execute()</code></pre>
<hr>
<h3 id="4-3-고객-차원-테이블-생성">4-3. 고객 차원 테이블 생성</h3>
<h3 id="4-3-1-salesdimcustomer_gold-테이블-생성">4-3-1. <code>sales.dimcustomer_gold</code> 테이블 생성</h3>
<pre><code class="language-python">from pyspark.sql.types import *
from delta.tables import *

DeltaTable.createIfNotExists(spark) \
    .tableName(&quot;sales.dimcustomer_gold&quot;) \
    .addColumn(&quot;CustomerName&quot;, StringType()) \
    .addColumn(&quot;Email&quot;, StringType()) \
    .addColumn(&quot;First&quot;, StringType()) \
    .addColumn(&quot;Last&quot;, StringType()) \
    .addColumn(&quot;CustomerID&quot;, LongType()) \
    .execute()</code></pre>
<h3 id="4-3-2-고객-차원용-silver-dataframe-생성">4-3-2. 고객 차원용 Silver DataFrame 생성</h3>
<pre><code class="language-python">from pyspark.sql.functions import col, split

dfdimCustomer_silver = df.dropDuplicates([&quot;CustomerName&quot;, &quot;Email&quot;]).select(
    col(&quot;CustomerName&quot;),
    col(&quot;Email&quot;)
).withColumn(
    &quot;First&quot;, split(col(&quot;CustomerName&quot;), &quot; &quot;).getItem(0)
).withColumn(
    &quot;Last&quot;, split(col(&quot;CustomerName&quot;), &quot; &quot;).getItem(1)
)

display(dfdimCustomer_silver.head(10))</code></pre>
<h3 id="4-3-3-customerid-생성">4-3-3. CustomerID 생성</h3>
<p>기존 <code>dimCustomer_gold</code>와 비교해서 신규 고객만 남긴 뒤 <code>monotonically_increasing_id()</code>로 ID를 만든다. ([YSSuperS2000][1])</p>
<pre><code class="language-python">from pyspark.sql.functions import monotonically_increasing_id, col, when, coalesce, max, lit

dfdimCustomer_temp = spark.read.table(&quot;Sales.dimCustomer_gold&quot;)
MAXCustomerID = dfdimCustomer_temp.select(
    coalesce(max(col(&quot;CustomerID&quot;)), lit(0)).alias(&quot;MAXCustomerID&quot;)
).first()[0]

dfdimCustomer_gold = dfdimCustomer_silver.join(
    dfdimCustomer_temp,
    (dfdimCustomer_silver.CustomerName == dfdimCustomer_temp.CustomerName) &amp;
    (dfdimCustomer_silver.Email == dfdimCustomer_temp.Email),
    &quot;left_anti&quot;
)

dfdimCustomer_gold = dfdimCustomer_gold.withColumn(
    &quot;CustomerID&quot;,
    monotonically_increasing_id() + MAXCustomerID + 1
)

display(dfdimCustomer_gold.head(10))</code></pre>
<h3 id="4-3-4-고객-차원-upsert">4-3-4. 고객 차원 Upsert</h3>
<pre><code class="language-python">from delta.tables import *

deltaTable = DeltaTable.forPath(spark, &#39;Tables/dimcustomer_gold&#39;)

dfUpdates = dfdimCustomer_gold

deltaTable.alias(&#39;gold&#39;) \
  .merge(
    dfUpdates.alias(&#39;updates&#39;),
    &#39;gold.CustomerName = updates.CustomerName AND gold.Email = updates.Email&#39;
  ) \
  .whenMatchedUpdate(set=
    {
    }
  ) \
  .whenNotMatchedInsert(values=
    {
      &quot;CustomerName&quot;: &quot;updates.CustomerName&quot;,
      &quot;Email&quot;: &quot;updates.Email&quot;,
      &quot;First&quot;: &quot;updates.First&quot;,
      &quot;Last&quot;: &quot;updates.Last&quot;,
      &quot;CustomerID&quot;: &quot;updates.CustomerID&quot;
    }
  ) \
  .execute()</code></pre>
<hr>
<h3 id="4-4-제품-차원-테이블-생성">4-4. 제품 차원 테이블 생성</h3>
<h3 id="4-4-1-salesdimproduct_gold-테이블-생성">4-4-1. <code>sales.dimproduct_gold</code> 테이블 생성</h3>
<pre><code class="language-python">from pyspark.sql.types import *
from delta.tables import *

DeltaTable.createIfNotExists(spark) \
    .tableName(&quot;sales.dimproduct_gold&quot;) \
    .addColumn(&quot;ItemName&quot;, StringType()) \
    .addColumn(&quot;ItemID&quot;, LongType()) \
    .addColumn(&quot;ItemInfo&quot;, StringType()) \
    .execute()</code></pre>
<h3 id="4-4-2-제품-차원용-silver-dataframe-생성">4-4-2. 제품 차원용 Silver DataFrame 생성</h3>
<p><code>Item</code> 컬럼을 <code>ItemName</code>, <code>ItemInfo</code>로 분리한다. ([YSSuperS2000][1])</p>
<pre><code class="language-python">from pyspark.sql.functions import col, split, lit, when

dfdimProduct_silver = df.dropDuplicates([&quot;Item&quot;]).select(col(&quot;Item&quot;)) \
    .withColumn(&quot;ItemName&quot;, split(col(&quot;Item&quot;), &quot;, &quot;).getItem(0)) \
    .withColumn(
        &quot;ItemInfo&quot;,
        when(
            (split(col(&quot;Item&quot;), &quot;, &quot;).getItem(1).isNull() |
             (split(col(&quot;Item&quot;), &quot;, &quot;).getItem(1) == &quot;&quot;)),
            lit(&quot;&quot;)
        ).otherwise(split(col(&quot;Item&quot;), &quot;, &quot;).getItem(1))
    )

display(dfdimProduct_silver.head(10))</code></pre>
<h3 id="4-4-3-itemid-생성">4-4-3. ItemID 생성</h3>
<pre><code class="language-python">from pyspark.sql.functions import monotonically_increasing_id, col, lit, max, coalesce

dfdimProduct_temp = spark.read.table(&quot;Sales.dimProduct_gold&quot;)

MAXProductID = dfdimProduct_temp.select(
    coalesce(max(col(&quot;ItemID&quot;)), lit(0)).alias(&quot;MAXItemID&quot;)
).first()[0]

dfdimProduct_gold = dfdimProduct_silver.join(
    dfdimProduct_temp,
    (dfdimProduct_silver.ItemName == dfdimProduct_temp.ItemName) &amp;
    (dfdimProduct_silver.ItemInfo == dfdimProduct_temp.ItemInfo),
    &quot;left_anti&quot;
)

dfdimProduct_gold = dfdimProduct_gold.withColumn(
    &quot;ItemID&quot;,
    monotonically_increasing_id() + MAXProductID + 1
)

display(dfdimProduct_gold.head(10))</code></pre>
<h3 id="4-4-4-제품-차원-upsert">4-4-4. 제품 차원 Upsert</h3>
<pre><code class="language-python">from delta.tables import *

deltaTable = DeltaTable.forPath(spark, &#39;Tables/dimproduct_gold&#39;)

dfUpdates = dfdimProduct_gold

deltaTable.alias(&#39;gold&#39;) \
  .merge(
    dfUpdates.alias(&#39;updates&#39;),
    &#39;gold.ItemName = updates.ItemName AND gold.ItemInfo = updates.ItemInfo&#39;
  ) \
  .whenMatchedUpdate(set=
    {
    }
  ) \
  .whenNotMatchedInsert(values=
    {
      &quot;ItemName&quot;: &quot;updates.ItemName&quot;,
      &quot;ItemInfo&quot;: &quot;updates.ItemInfo&quot;,
      &quot;ItemID&quot;: &quot;updates.ItemID&quot;
    }
  ) \
  .execute()</code></pre>
<hr>
<h3 id="4-5-판매-팩트-테이블-생성">4-5. 판매 팩트 테이블 생성</h3>
<h3 id="4-5-1-salesfactsales_gold-테이블-생성">4-5-1. <code>sales.factsales_gold</code> 테이블 생성</h3>
<pre><code class="language-python">from pyspark.sql.types import *
from delta.tables import *

DeltaTable.createIfNotExists(spark) \
    .tableName(&quot;sales.factsales_gold&quot;) \
    .addColumn(&quot;CustomerID&quot;, LongType()) \
    .addColumn(&quot;ItemID&quot;, LongType()) \
    .addColumn(&quot;OrderDate&quot;, DateType()) \
    .addColumn(&quot;Quantity&quot;, IntegerType()) \
    .addColumn(&quot;UnitPrice&quot;, FloatType()) \
    .addColumn(&quot;Tax&quot;, FloatType()) \
    .execute()</code></pre>
<h3 id="4-5-2-팩트-dataframe-생성">4-5-2. 팩트 DataFrame 생성</h3>
<p>고객 차원, 제품 차원과 조인해서 <code>CustomerID</code>, <code>ItemID</code>를 붙인다. ([YSSuperS2000][1])</p>
<pre><code class="language-python">from pyspark.sql.functions import col, split, lit, when

dfdimCustomer_temp = spark.read.table(&quot;Sales.dimCustomer_gold&quot;)
dfdimProduct_temp = spark.read.table(&quot;Sales.dimProduct_gold&quot;)

df = df.withColumn(&quot;ItemName&quot;, split(col(&quot;Item&quot;), &quot;, &quot;).getItem(0)) \
    .withColumn(
        &quot;ItemInfo&quot;,
        when(
            (split(col(&quot;Item&quot;), &quot;, &quot;).getItem(1).isNull() |
             (split(col(&quot;Item&quot;), &quot;, &quot;).getItem(1) == &quot;&quot;)),
            lit(&quot;&quot;)
        ).otherwise(split(col(&quot;Item&quot;), &quot;, &quot;).getItem(1))
    )

dffactSales_gold = df.alias(&quot;df1&quot;) \
    .join(
        dfdimCustomer_temp.alias(&quot;df2&quot;),
        (df.CustomerName == dfdimCustomer_temp.CustomerName) &amp;
        (df.Email == dfdimCustomer_temp.Email),
        &quot;left&quot;
    ) \
    .join(
        dfdimProduct_temp.alias(&quot;df3&quot;),
        (df.ItemName == dfdimProduct_temp.ItemName) &amp;
        (df.ItemInfo == dfdimProduct_temp.ItemInfo),
        &quot;left&quot;
    ) \
    .select(
        col(&quot;df2.CustomerID&quot;),
        col(&quot;df3.ItemID&quot;),
        col(&quot;df1.OrderDate&quot;),
        col(&quot;df1.Quantity&quot;),
        col(&quot;df1.UnitPrice&quot;),
        col(&quot;df1.Tax&quot;)
    ) \
    .orderBy(col(&quot;df1.OrderDate&quot;), col(&quot;df2.CustomerID&quot;), col(&quot;df3.ItemID&quot;))

display(dffactSales_gold.head(10))</code></pre>
<h3 id="4-5-3-팩트-테이블-upsert">4-5-3. 팩트 테이블 Upsert</h3>
<pre><code class="language-python">from delta.tables import *

deltaTable = DeltaTable.forPath(spark, &#39;Tables/factsales_gold&#39;)

dfUpdates = dffactSales_gold

deltaTable.alias(&#39;gold&#39;) \
  .merge(
    dfUpdates.alias(&#39;updates&#39;),
    &#39;gold.OrderDate = updates.OrderDate AND gold.CustomerID = updates.CustomerID AND gold.ItemID = updates.ItemID&#39;
  ) \
  .whenMatchedUpdate(set=
    {
    }
  ) \
  .whenNotMatchedInsert(values=
    {
      &quot;CustomerID&quot;: &quot;updates.CustomerID&quot;,
      &quot;ItemID&quot;: &quot;updates.ItemID&quot;,
      &quot;OrderDate&quot;: &quot;updates.OrderDate&quot;,
      &quot;Quantity&quot;: &quot;updates.Quantity&quot;,
      &quot;UnitPrice&quot;: &quot;updates.UnitPrice&quot;,
      &quot;Tax&quot;: &quot;updates.Tax&quot;
    }
  ) \
  .execute()</code></pre>
<p><img src="https://velog.velcdn.com/images/rudin_/post/4c911958-fe89-4fc9-8783-792230e60b0e/image.png" alt=""></p>
<hr>

<h1 id="microsoft-fabric에서-data-wrangler를-사용하여-데이터-전처리">Microsoft Fabric에서 Data Wrangler를 사용하여 데이터 전처리</h1>
<h2 id="dataframe에-데이터-로드">Dataframe에 데이터 로드</h2>
<pre><code class="language-python"># Azure storage access info for open dataset diabetes
blob_account_name = &quot;azureopendatastorage&quot;
blob_container_name = &quot;ojsales-simulatedcontainer&quot;
blob_relative_path = &quot;oj_sales_data&quot;
blob_sas_token = r&quot;&quot; # Blank since container is Anonymous access

# Set Spark config to access  blob storage
wasbs_path = f&quot;wasbs://%s@%s.blob.core.windows.net/%s&quot; % (blob_container_name, blob_account_name, blob_relative_path)
spark.conf.set(&quot;fs.azure.sas.%s.%s.blob.core.windows.net&quot; % (blob_container_name, blob_account_name), blob_sas_token)
print(&quot;Remote blob path: &quot; + wasbs_path)

# Spark reads csv
df = spark.read.csv(wasbs_path, header=True)</code></pre>
<pre><code class="language-python">import pandas as pd

df = df.toPandas()
df = df.sample(n=500, random_state=1)

df[&#39;WeekStarting&#39;] = pd.to_datetime(df[&#39;WeekStarting&#39;])
df[&#39;Quantity&#39;] = df[&#39;Quantity&#39;].astype(&#39;int&#39;)
df[&#39;Advert&#39;] = df[&#39;Advert&#39;].astype(&#39;int&#39;)
df[&#39;Price&#39;] = df[&#39;Price&#39;].astype(&#39;float&#39;)
df[&#39;Revenue&#39;] = df[&#39;Revenue&#39;].astype(&#39;float&#39;)

df = df.reset_index(drop=True)
df.head(4)</code></pre>
<p><img src="https://velog.velcdn.com/images/rudin_/post/e84ca673-6e3b-4252-bb79-d39ae283fa69/image.png" alt=""></p>
<h2 id="텍스트-데이터-형식-지정">텍스트 데이터 형식 지정</h2>
<p><img src="https://velog.velcdn.com/images/rudin_/post/5a0361c2-70c3-4215-a3da-3d4b77021853/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/702634dd-d831-4036-9d64-4a5cbbc535a5/image.png" alt=""></p>
<p>1 Data Wrangler 대시보드에서 그리드의 Brand Feature를 선택합니다.
<img src="https://velog.velcdn.com/images/rudin_/post/5f7e090d-81f3-46c4-a19f-473258f0b58b/image.png" alt=""></p>
<p>2 Operations 패널로 이동하여 찾기 및 바꾸기를 확장한 다음, 찾기 및 바꾸기를 선택합니다.
3 찾기 및 바꾸기 패널에서 다음 속성을 변경합니다.</p>
<pre><code>이전 값: “.”
새 값: “” (공백 문자)
작업 결과가 디스플레이 그리드에 자동으로 미리 보기됩니다.</code></pre><p><img src="https://velog.velcdn.com/images/rudin_/post/f012c3d5-d7bf-4b0a-b42a-c6aae26386b6/image.png" alt=""></p>
<p>4 적용을 선택합니다.
5 Operations 패널로 돌아가서 형식을 확장합니다.
6 첫 글자 대문자로 변환을 선택합니다. 모든 단어 대문자로 변환 토글을 켜고, 적용을 선택합니다.
<img src="https://velog.velcdn.com/images/rudin_/post/2feb8125-5959-48c9-9597-7a5e4861af11/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/e278f5af-a541-41e0-8f9d-bbb97f36a4e7/image.png" alt=""></p>
<p>7 Notebook에 코드 추가를 선택합니다. 또한, 코드를 복사하여 변환된 데이터 세트를 CSV 파일로 저장할 수도 있습니다.
8 Data Wrangler에서 생성된 코드는 원래 dataframe을 덮어쓰지 않으므로, 10행과 11행을 <code>df = clean_data(df)</code> 코드로 바꿉니다. 최종 코드 블록은 다음과 같아야 합니다.</p>
<pre><code class="language-python">def clean_data(df):
    # Replace all instances of &quot;.&quot; with &quot; &quot; in column: &#39;Brand&#39;
    df[&#39;Brand&#39;] = df[&#39;Brand&#39;].str.replace(&quot;.&quot;, &quot; &quot;, case=False, regex=False)
    # Capitalize the first character in column: &#39;Brand&#39;
    df[&#39;Brand&#39;] = df[&#39;Brand&#39;].str.title()
    return df

df = clean_data(df)</code></pre>
<p>9 코드 셀을 실행하고 Brand 변수를 확인합니다.</p>
<pre><code class="language-python">df[&#39;Brand&#39;].unique()</code></pre>
<h3 id="원-핫-인코딩-변환-적용">원-핫 인코딩 변환 적용</h3>
<p>원-핫 인코딩: 범주형 데이터를 머신러닝 모델이 이해할 수 있도록, 고유한 값에 해당하는 위치에만 1을 부여하고 나머지는 0으로 채워 수치화하는 방식</p>
<p>1 상단 메뉴에서 df dataframe에 대해 Data Wrangler를 시작합니다.
2 그리드의 Brand Feature를 선택합니다.
3 Operations 패널에서 수식을 확장한 다음, 원-핫 인코딩을 선택합니다.
<img src="https://velog.velcdn.com/images/rudin_/post/9773d051-081b-4009-92d3-b940ce521e90/image.png" alt=""></p>
<p>4 원-핫 인코딩 패널에서 적용을 선택합니다.
<img src="https://velog.velcdn.com/images/rudin_/post/56d075d7-c957-4d5e-a9d9-932d5b67cd8a/image.png" alt=""></p>
<p>5 코드를 생성하지 않고 Data Wrangler를 종료합니다.</p>
<h3 id="정렬-및-필터링-작업">정렬 및 필터링 작업</h3>
<p>1 df dataframe에 대해 Data Wrangler를 시작합니다.
2 Operations 패널로 돌아가서 정렬 및 필터링을 확장합니다.
3 필터를 선택합니다.
<img src="https://velog.velcdn.com/images/rudin_/post/fa24074d-87e0-4957-81e0-fdbdc219e5c0/image.png" alt=""></p>
<p>4 필터 패널에서 다음 조건을 추가합니다.</p>
<pre><code>대상 열: Store
작업: 같음
값: 1227
동작: 일치하는 행 유지</code></pre><p><img src="https://velog.velcdn.com/images/rudin_/post/1a5680d0-29cf-4733-9e4a-2b9c737f50bc/image.png" alt=""></p>
<p>5 적용을 선택하고 Data Wrangler 디스플레이 그리드의 변경 사항을 확인합니다.
6 Revenue Feature를 선택한 다음, 요약 사이드 패널의 세부 정보를 검토합니다.
7 Operations 패널로 돌아가서 정렬 및 필터링을 확장합니다.
8 값 정렬을 선택합니다.
9 값 정렬 패널에서 다음 속성을 선택합니다.</p>
<pre><code>열 이름: Price
정렬 순서: 내림차순</code></pre><p><img src="https://velog.velcdn.com/images/rudin_/post/0cd70d41-724f-4930-8212-77d76298b43f/image.png" alt=""></p>
<p>10 적용을 선택합니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[MicrosoftDataSchool] 72일차 - Fabric 시작하기]]></title>
            <link>https://velog.io/@rudin_/MicrosoftDataSchool-72%EC%9D%BC%EC%B0%A8-Fabric-%EC%8B%9C%EC%9E%91%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@rudin_/MicrosoftDataSchool-72%EC%9D%BC%EC%B0%A8-Fabric-%EC%8B%9C%EC%9E%91%ED%95%98%EA%B8%B0</guid>
            <pubDate>Mon, 20 Apr 2026 08:37:13 GMT</pubDate>
            <description><![CDATA[<h1 id="fabric-시작하기">Fabric 시작하기</h1>
<p>Databricks와 같은 서비스를 포함하는 더 큰 서비스</p>
<h2 id="실습-준비">실습 준비</h2>
<h3 id="평가판-활성화">평가판 활성화</h3>
<p><img src="https://velog.velcdn.com/images/rudin_/post/2071edbc-c09d-4235-8f09-1a797fb74306/image.png" alt=""></p>
<h3 id="작업-영역-생성">작업 영역 생성</h3>
<p>패브릭 평가판을 선택한다.
<img src="https://velog.velcdn.com/images/rudin_/post/32ddf9dc-ea82-44bc-ada3-f7a706ad0359/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/4651e54f-b170-440c-bb6a-5fd5d8db2ac6/image.png" alt=""></p>
<hr>

<h1 id="onelake-기반의-데이터-수집-정제-파이프라인과-웨어하우스-분석-기초">OneLake 기반의 데이터 수집, 정제 파이프라인과 웨어하우스 분석 기초</h1>
<h2 id="fabric의-필요성">Fabric의 필요성</h2>
<p><img src="https://velog.velcdn.com/images/rudin_/post/6ee49b82-720f-423a-8b43-ee1d724e20a3/image.png" alt=""></p>
<ul>
<li>도구의 파편화에서 비용 추적이 어려우며, 관리 복잡도가 증가함</li>
<li>데이터 사일로 현상 발생(팀 간 데이터 공유 어려움)</li>
<li>각 플랫폼별 학습곡선 존재
→ 통합 솔루션의 등장 필요</li>
</ul>
<h2 id="azure-service-fabric">Azure Service Fabric</h2>
<p>마이크로서비스와 컨테이너를 패키징, 배포, 관리하는 분산 시스템 플랫폼</p>
<h3 id="fabric에-속한-서비스">Fabric에 속한 서비스</h3>
<table>
<thead>
<tr>
<th>Experience</th>
<th>담당자</th>
<th>핵심기능</th>
</tr>
</thead>
<tbody><tr>
<td>Data Factory</td>
<td>데이터 엔지니어</td>
<td>데이터 수집, Mirroring (Zero-ETL), Shortcuts(연결)</td>
</tr>
<tr>
<td>Data Engineering</td>
<td>데이터 엔지니어</td>
<td>Spark 기반 변환, Lakehouse 구조, Medallion Architecture</td>
</tr>
<tr>
<td>Data Science</td>
<td>데이터 과학자</td>
<td>ML 모델 개발, MLflow 통합</td>
</tr>
<tr>
<td>Data Warehousing</td>
<td>데이터 분석가</td>
<td>T-SQL 분석, DirectLake(실시간), 엔터프라이즈 DW</td>
</tr>
<tr>
<td>Real-Time Intelligence</td>
<td>실시간 분석가</td>
<td>KQL 스트림 분석, Data Activator (알림)</td>
</tr>
<tr>
<td>Power BI</td>
<td>BI 개발자</td>
<td>시각화 &amp; 대시보드</td>
</tr>
</tbody></table>
<h3 id="onelake의-계층-구조">OneLake의 계층 구조</h3>
<pre><code>Tenant → Domain → Workspace → Item</code></pre><h3 id="lakehouse-vs-warehouse">Lakehouse vs Warehouse</h3>
<p>Fabric 은 둘 다 지원</p>
<table>
<thead>
<tr>
<th>구분</th>
<th>Data Warehouse</th>
<th>Lakehouse</th>
</tr>
</thead>
<tbody><tr>
<td>데이터 형태</td>
<td>정형 데이터만 (정제된 형식)</td>
<td>모든 형식 (정형 + 반정형 + 비정형)</td>
</tr>
<tr>
<td>스키마</td>
<td>엄격함 (Schema-on-Write, Star Schema)</td>
<td>유연함 (Schema-on-Read)</td>
</tr>
<tr>
<td>데이터 흐름</td>
<td>Lakehouse → 변환 → 저장</td>
<td>수집 → 저장 (원본 그대로)</td>
</tr>
<tr>
<td>계층 구조</td>
<td>Gold (최종, 분석용)</td>
<td>Bronze(원본) → Silver(정제) → Gold</td>
</tr>
<tr>
<td>주요 사용자</td>
<td>비즈니스 분석가 (BI 팀)</td>
<td>데이터 엔지니어</td>
</tr>
<tr>
<td>처리 방식</td>
<td>ETL 중심</td>
<td>ELT 및 스트리밍 포함</td>
</tr>
<tr>
<td>활용 영역</td>
<td>BI, 리포트 중심</td>
<td>BI + 데이터 사이언스 + ML + 실시간 분석</td>
</tr>
</tbody></table>
<h3 id="microsoft-fabric-의사-결정-가이드">Microsoft Fabric 의사 결정 가이드</h3>
<p><img src="https://velog.velcdn.com/images/rudin_/post/baf3eebb-6476-4a1d-8047-d20c5419aa76/image.png" alt=""></p>
<h3 id="데이터-이동을-위한-전략적-선택-가이드">데이터 이동을 위한 전략적 선택 가이드</h3>
<p>Fabric에서는 가능한 Shortcut이나 Mirroring을 쓰고, 어쩔 수 없을 때만 Copy하도록 권장</p>
<table>
<thead>
<tr>
<th>비교 항목</th>
<th>1. Mirroring</th>
<th>2. Copy Data Pipeline</th>
<th>3. Shortcut</th>
</tr>
</thead>
<tbody><tr>
<td>별명</td>
<td>거울 (실시간)</td>
<td>트럭 (배치)</td>
<td>지름길 (연결)</td>
</tr>
<tr>
<td>데이터 이동</td>
<td>자동 복제 (Zero-ETL)</td>
<td>물리적 복사</td>
<td>이동 없음</td>
</tr>
<tr>
<td>복잡도</td>
<td>매우 쉬움</td>
<td>중간 (설정 필요)</td>
<td>매우 쉬움</td>
</tr>
<tr>
<td>비용</td>
<td>무료 (컴퓨팅만)</td>
<td>유료 (처리량 비례)</td>
<td>무료</td>
</tr>
<tr>
<td>대상</td>
<td>Azure SQL, Snowflake</td>
<td>On-prem DB, File</td>
<td>S3, ADLS Gen2</td>
</tr>
</tbody></table>
<h3 id="shortcut의-동작방식">Shortcut의 동작방식</h3>
<p><img src="https://velog.velcdn.com/images/rudin_/post/3d4858fc-9a41-4ada-bbf0-90dfef80a814/image.png" alt=""></p>
<p>Spark나 SQL은 OneLake를 바라보지만 실제 I/O는 원본 스토리지에서 직접 발생
Egress비용(Cloud 간 이동)은 발생할 수 있지만 Fabric 내부 저장 비용은 0원</p>
<hr>

<h1 id="실습">실습</h1>
<h2 id="lakehouse-생성-및-파일-업로드">Lakehouse 생성 및 파일 업로드</h2>
<blockquote>
<p><a href="https://microsoftlearning.github.io/mslearn-fabric/Instructions/Labs/01-lakehouse.html">https://microsoftlearning.github.io/mslearn-fabric/Instructions/Labs/01-lakehouse.html</a></p>
</blockquote>
<ul>
<li>Fabric의 저장소인 OneLake와 Lakehouse를 직접 만들어보기</li>
</ul>
<h3 id="생성">생성</h3>
<p>좌상단 <code>+새항목</code> 버튼 클릭
<img src="https://velog.velcdn.com/images/rudin_/post/3bc03457-2efb-4475-9bca-031e5dcc003c/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/7787c87e-2537-40d9-a916-eb3d6728761b/image.png" alt=""></p>
<ul>
<li>레이크하우스 스키마: 스키마 별로 저장 가능. X시 기본 데이터테이블에 저장</li>
</ul>
<h3 id="파일-업로드">파일 업로드</h3>
<p><code>데이터 가져오기</code> - <code>파일 업로드</code>
<img src="https://velog.velcdn.com/images/rudin_/post/d25cb3a1-0339-4740-b8d5-63212513c63e/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/9fff8e05-24af-4e1e-be5b-575795d4b04e/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/df3a10ad-d102-417c-bc4a-086d5fb6f305/image.png" alt=""></p>
<h3 id="테이블에-로드">테이블에 로드</h3>
<p><img src="https://velog.velcdn.com/images/rudin_/post/a1478b8d-1d4f-4b9f-b193-808b5a70f414/image.png" alt=""></p>
<p>새 테이블 만들기를 시도했으나, 과정의 이전 기수들의 리소스 해제가 안되어있어 Too many Requests 오류가 발생했다.
<img src="https://velog.velcdn.com/images/rudin_/post/e97ac8b0-1b9e-4129-b23a-c63c57e0434a/image.png" alt="">
삭제해주신 후 정상 진행이 되었다.
무료 평가판에서는 테넌트 내의 50명이 정원인 듯 했다.</p>
<h3 id="쿼리-전송">쿼리 전송</h3>
<p><img src="https://velog.velcdn.com/images/rudin_/post/4f3231d0-14d8-4051-b7af-a548d1f72686/image.png" alt="">
SQL 분석 엔드포인트로 이동
<img src="https://velog.velcdn.com/images/rudin_/post/9c25722a-e750-4a92-9bbd-0fc63b8aa666/image.png" alt="">
<code>새 SQL 쿼리</code> 선택
<img src="https://velog.velcdn.com/images/rudin_/post/a1a7f7b7-a022-4f66-977c-31d0f2c93d66/image.png" alt=""></p>
<h3 id="visual-query">Visual Query</h3>
<p><img src="https://velog.velcdn.com/images/rudin_/post/e73e35f6-c260-4c41-b6cc-2f2d991daf82/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/e0daf835-4ae7-40f8-a383-b4cb24d343ef/image.png" alt="">
테이블을 드래그 앤 드랍해서 시작
<img src="https://velog.velcdn.com/images/rudin_/post/2ca2be77-bb30-4c6b-b960-0a7995cfcaac/image.png" alt="">
SQL 보기 선택으로 SQL 코드 확인 가능
<img src="https://velog.velcdn.com/images/rudin_/post/cedc69f7-dd1a-4a96-81a0-b2df488a1ef2/image.png" alt="">
뷰로 저장도 가능
<img src="https://velog.velcdn.com/images/rudin_/post/7b4f7664-2f4b-46df-bb48-be69df387084/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/c300f9c8-9ac7-49a3-b7bd-58b1c820ed68/image.png" alt=""></p>
<h2 id="파이프라인으로-데이터-가져오기http">파이프라인으로 데이터 가져오기(HTTP)</h2>
<p><a href="https://microsoftlearning.github.io/mslearn-fabric/Instructions/Labs/04-ingest-pipeline.html">https://microsoftlearning.github.io/mslearn-fabric/Instructions/Labs/04-ingest-pipeline.html</a></p>
<h3 id="파이프라인-생성">파이프라인 생성</h3>
<p>작업영역-좌상단 새항목-파이프라인 선택
<img src="https://velog.velcdn.com/images/rudin_/post/d778fba6-e62d-41fc-b1e7-6df1b294aafc/image.png" alt=""></p>
<p>데이터 복사 도우미 선택
<img src="https://velog.velcdn.com/images/rudin_/post/54f0cdba-94a4-447a-b69a-6291c56bca2e/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/714d984e-442a-41a6-b30b-79225a3794fa/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/50416c4f-fc4d-4e13-8e7b-a33be117f10c/image.png" alt="">
기존 레이크하우스 선택
<img src="https://velog.velcdn.com/images/rudin_/post/49eaf42d-c06a-43b0-b151-19cbc2d22d92/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/7a5605b2-a4f0-4215-985b-265efa394197/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/a2d0ca85-a278-4411-b8b1-76ce7c980361/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/501181be-8492-4b03-8a89-46e1b5dc3292/image.png" alt=""></p>
<p>연결에 파이프라인을 추가해야하는데, 안 뜨는 현상이 발생했다.
그래서 일단 작업영역으로 나와 직접 1회 파이프라인을 실행했다.
<img src="https://velog.velcdn.com/images/rudin_/post/6b5d2d33-7200-44e9-88f3-302124ad7981/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/74bb9107-83cb-4437-8541-ba2344572b14/image.png" alt="">
이렇게 하면 <code>복사작업</code> 권한이 생긴다..고했으나 생기지 않았다.
우상단 점 3개 - 설정 - 연결 및 게이트웨이 관리
<img src="https://velog.velcdn.com/images/rudin_/post/f3fae97e-5672-4c47-abe1-f3a5a09acd27/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/36ae3e9a-6518-4759-9262-a138eebe6396/image.png" alt="">
안뜬다면 자격증명을 새로 만들어야 한다.
<img src="https://velog.velcdn.com/images/rudin_/post/4188fd43-05dc-4518-9ba6-77ced1e400a8/image.png" alt="">
좌상단 신규 후 클라우드 선택
<img src="https://velog.velcdn.com/images/rudin_/post/f39c8ef6-470d-4a3d-b7de-093ae65fc90d/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/3471e31d-a6a3-4c35-9557-787917e84da1/image.png" alt="">
추가하면 이제 표시된다.
<img src="https://velog.velcdn.com/images/rudin_/post/a1ed02e2-12b4-4af1-a71f-a22848818d3b/image.png" alt=""></p>
<h3 id="노트북-생성">노트북 생성</h3>
<p><img src="https://velog.velcdn.com/images/rudin_/post/65ce1d51-52d6-420d-90a5-00a4e804fdc7/image.png" alt="">
<img src="https://velog.velcdn.com/images/rudin_/post/49671708-ff96-417b-9870-6397eafecabf/image.png" alt=""></p>
<p>여기서도 표준 세션을 할당할 때 403 에러가 발생했다. 다른 분이 세션을 끊어주셔야만 쓸 수 있었다. 문제가 많다.
<img src="https://velog.velcdn.com/images/rudin_/post/3aad6b16-23ac-4941-919d-c4e1c5d80e8e/image.png" alt="">
또한 세션을 받더라도, Storage Blob Data Contributor 권한이 없어 에러가 발생했다.</p>
<p><img src="https://velog.velcdn.com/images/rudin_/post/ca2d7d91-234f-4a46-a0d2-20a56ebda258/image.png" alt="">
작업 영역 설정에서 
<img src="https://velog.velcdn.com/images/rudin_/post/d2fee995-95f3-46ce-a9a8-8a8aa10a8771/image.png" alt="">
작업 영역 유형 설정이 가능하다.
여기서 원격 컴퓨팅 리소스를 설정할 수 있다.
일단은 강의에선 5개의 컴퓨팅 리소스로 사람들을 분할하여 해결하고자 했다.</p>
<p>그럼에도 400 에러가 발생했다.
<img src="https://velog.velcdn.com/images/rudin_/post/51acf7a4-df09-4943-bfaa-35335b4fbd1c/image.png" alt="">
알고보니 내가 데이터 항목을 추가하지 않아서였다.</p>
<p>이후에도 강사님께서 추가 컴퓨팅 리소스를 마련해주심에도 세션이 시작되지 않아 노트북을 아예 새로 생성했더니 세션 설정이 되었다.
미묘하게 처리 확인이 힘든 에러가 많은듯하다.</p>
<p><img src="https://velog.velcdn.com/images/rudin_/post/1299542b-7701-428a-8d0f-685892fdff0b/image.png" alt=""></p>
<p>참고로 변수처럼 테이블 이름을 바꿀 수 있게 하기 위해서는 toggle parameter cell 을 선택해줘야 한다.
<img src="https://velog.velcdn.com/images/rudin_/post/02bc8cd9-4de9-408d-8b5b-c61216ceac02/image.png" alt=""></p>
<h3 id="파이프라인에-노트북-추가">파이프라인에 노트북 추가</h3>
<p><img src="https://velog.velcdn.com/images/rudin_/post/1703b8e6-2fec-42d1-b86b-cbd6414b3d4a/image.png" alt="">
우상단 노트북 선택으로 추가
<img src="https://velog.velcdn.com/images/rudin_/post/a6c3106d-056a-4e7d-885f-6ad9261b8d8b/image.png" alt="">
이후 <code>기본 매개변수 추가</code></p>
<p><img src="https://velog.velcdn.com/images/rudin_/post/7599ea99-7c1b-42d0-a07d-90c51ce777c3/image.png" alt="">
데이터 삭제를 추가하여 기존에 만들어둔 csv를 삭제한다.
<img src="https://velog.velcdn.com/images/rudin_/post/49558f4f-b8a1-4973-bdde-1a6f2b6f11f7/image.png" alt="">
<img src="https://velog.velcdn.com/images/rudin_/post/b092566d-2a4a-48fb-8f75-2996c666dda5/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/rudin_/post/7dacc77b-d9ea-4ea8-9077-d73433b79856/image.png" alt=""></p>
<p>기존의 sales2가 아닌, new_sales2로 테이블이 생성됨을 확인 가능하다
<img src="https://velog.velcdn.com/images/rudin_/post/d85de149-d90a-4f0a-ae49-18fa7a438a04/image.png" alt=""></p>
<hr>

<h2 id="데이터-웨어하우스에서-데이터-분석">데이터 웨어하우스에서 데이터 분석</h2>
<p><a href="https://microsoftlearning.github.io/mslearn-fabric/Instructions/Labs/06-data-warehouse.html">https://microsoftlearning.github.io/mslearn-fabric/Instructions/Labs/06-data-warehouse.html</a></p>
<p><img src="https://velog.velcdn.com/images/rudin_/post/f72f1c7c-667b-4211-b3d9-87bf52cce762/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/c0494440-3bea-4d09-98bd-64898991047c/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/dd5bfa43-d1e2-4a6c-a605-8237ba9c3124/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/0a4fc535-17bc-4612-bb81-ac45046f1662/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/a828cb19-fd27-419c-b6ca-30080ef925f3/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/25d8608b-a29c-4a80-9594-92a6902660bc/image.png" alt="">
이후 <a href="https://raw.githubusercontent.com/MicrosoftLearning/dp-data/main/create-dw.txt">https://raw.githubusercontent.com/MicrosoftLearning/dp-data/main/create-dw.txt</a> 의 쿼리로 데이터 생성</p>
<ul>
<li>DimCustomer</li>
<li>DimDate</li>
<li>DimProduct</li>
<li>FactSalesOrder</li>
</ul>
<h3 id="쿼리">쿼리</h3>
<p><img src="https://velog.velcdn.com/images/rudin_/post/743f8b97-3466-4bad-8739-6ef6b2a58c0f/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/c5ddc6ff-1ee1-4475-bee3-fa61cf31b3c1/image.png" alt=""></p>
<h3 id="뷰-생성">뷰 생성</h3>
<p><img src="https://velog.velcdn.com/images/rudin_/post/9b5a57e3-940f-4496-8210-f3c9deca504b/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/c75c38a3-c12f-48a3-bc0e-593843968d7b/image.png" alt=""></p>
<h3 id="visual-query-1">Visual Query</h3>
<p><img src="https://velog.velcdn.com/images/rudin_/post/8c1bc6c2-dd87-4d8b-8eca-e1880d0aaebb/image.png" alt=""></p>
<hr>

<h2 id="fabric에서-apache-spark를-사용하여-데이터-분석">Fabric에서 Apache Spark를 사용하여 데이터 분석</h2>
<p><a href="https://microsoftlearning.github.io/mslearn-fabric/Instructions/Labs/02-analyze-spark.html">https://microsoftlearning.github.io/mslearn-fabric/Instructions/Labs/02-analyze-spark.html</a></p>
<h3 id="데이터-읽어오기">데이터 읽어오기</h3>
<p><img src="https://velog.velcdn.com/images/rudin_/post/1d1e286c-dd2a-46d0-90a1-68d5f6875306/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/070a559d-3689-416f-ba36-b6727eba7b96/image.png" alt=""></p>
<p>와일드카드를 이용하여 전체 데이터를 가져올 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/rudin_/post/1aa76132-b560-48b0-9563-7920481cc4e9/image.png" alt=""></p>
<h3 id="dataframe에서-데이터-탐색">DataFrame에서 데이터 탐색</h3>
<p><img src="https://velog.velcdn.com/images/rudin_/post/51a199bb-dec3-4c22-a933-a3714a332d93/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/9085d33b-37a6-4b4f-b2cf-6f5c53021121/image.png" alt="">
<img src="https://velog.velcdn.com/images/rudin_/post/d24f88df-899d-4f76-bc4e-5f36a32abd90/image.png" alt=""></p>
<h3 id="dataframe에서-데이터-집계-및-그룹화">DataFrame에서 데이터 집계 및 그룹화</h3>
<p><img src="https://velog.velcdn.com/images/rudin_/post/6d0fa693-6624-4757-ad02-168a97d7b4ab/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/fa609591-d2ca-4173-88d6-cfb2916474b8/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[MicrosoftDataschool] 71일차 - 오늘의밥..]]></title>
            <link>https://velog.io/@rudin_/MicrosoftDataschool-71%EC%9D%BC%EC%B0%A8-%EC%98%A4%EB%8A%98%EC%9D%98%EB%B0%A5</link>
            <guid>https://velog.io/@rudin_/MicrosoftDataschool-71%EC%9D%BC%EC%B0%A8-%EC%98%A4%EB%8A%98%EC%9D%98%EB%B0%A5</guid>
            <pubDate>Fri, 17 Apr 2026 08:17:41 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/rudin_/post/9093d055-f210-474c-826c-3a765b8be14b/image.jpeg" alt=""></p>
<p>그럭저럭 맛있었다
저번보다는 반찬이 호불호 없는편</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[MicrosoftDataSchool] 61~71일차 - 2차 팀 프로젝트 <AutoDetect>: Azure Databricks를 활용한 소스코드 취약점 자동탐지 및 분석 솔루션]]></title>
            <link>https://velog.io/@rudin_/MicrosoftDataSchool-61%EC%9D%BC%EC%B0%A8-2%EC%B0%A8-%ED%8C%80-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-AutoDetect-Azure-Databricks%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%9C-%EC%86%8C%EC%8A%A4%EC%BD%94%EB%93%9C-%EC%B7%A8%EC%95%BD%EC%A0%90-%EC%9E%90%EB%8F%99%ED%83%90%EC%A7%80-%EB%B0%8F-%EB%B6%84%EC%84%9D-%EC%86%94%EB%A3%A8%EC%85%98</link>
            <guid>https://velog.io/@rudin_/MicrosoftDataSchool-61%EC%9D%BC%EC%B0%A8-2%EC%B0%A8-%ED%8C%80-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-AutoDetect-Azure-Databricks%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%9C-%EC%86%8C%EC%8A%A4%EC%BD%94%EB%93%9C-%EC%B7%A8%EC%95%BD%EC%A0%90-%EC%9E%90%EB%8F%99%ED%83%90%EC%A7%80-%EB%B0%8F-%EB%B6%84%EC%84%9D-%EC%86%94%EB%A3%A8%EC%85%98</guid>
            <pubDate>Fri, 17 Apr 2026 08:04:20 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/rudin_/post/456b6d98-2b07-4882-a801-164ce2917c96/image.png" alt=""></p>
<blockquote>
<p><a href="https://github.com/data-school-2nd-5th/AutoDetect">Github 링크</a></p>
</blockquote>
<h1 id="cwe-공식-문서에-기반한-js-코드-취약점-탐지-vs-extension">CWE 공식 문서에 기반한 js 코드 취약점 탐지 VS Extension</h1>
<h2 id="1-제안-배경">1. 제안 배경</h2>
<ul>
<li>LLM 및 Vibe Coding을 활용한 코드 단위 개발자 계층의 증대로 <strong>공격 벡터(Attack Vector) 증가</strong></li>
<li>개발 단계에서 발견되지 않은 취약점은 테스트 및 배포과정에서 더 큰 수정 비용과 운영 리스크로 직결</li>
<li>보안 지식이 부족한 전공자 혹은 비전공자 개발자를 위한 <strong>저비용 소스코드 취약점 분석 지침 가이드</strong> 필요</li>
<li>시중 상용 취약점 탐지 및 보수 솔루션의 한계<ul>
<li>개발자가 코딩 작업 중 즉시 활용 가능해야 함</li>
<li>탐지 자체가 아닌 유지 보수에 도움이 되는 설명 필요</li>
<li>공식 보안 기준에 따른 취약점 정보에 대한 설명 필요</li>
</ul>
</li>
</ul>
<h3 id="본-프로젝트의-제안-방향">본 프로젝트의 제안 방향</h3>
<ul>
<li>Hallucination 방지를 위한 비생성형 AI 기반 취약점 탐지와 RAG+LLM 기술을 활용한 설명 기능의 결합</li>
<li>AI 모델 신뢰성 확보를 위한 모델 의사결정 투명성 확보</li>
<li>Azure Cloud Resource 기반으로 확장성 및 범용성 확보</li>
</ul>
<h2 id="2-추진-필요성">2. 추진 필요성</h2>
<ul>
<li>취약점 발견 시점이 늦어짐에 따라 수정 범위 및 비용 증가로 인한 조기 발견 필요</li>
<li>보안 내재화(Security By Design): 보안의 사후 대응이 아닌 소프트웨어 설계 단계에서의 보안 고려<ul>
<li>소프트웨어 개발자 중심의 보안 내재화 필요</li>
</ul>
</li>
<li>설명 가능한 분석 체계<ul>
<li>단순 “취약점 탐지”에 그치지 않는 “모델의 의사결정 투명성”을 보장하는 XAI 기술 도입</li>
<li>실제 현장에서 필요한 정보는 “취약점이 있음”이 아닌 “코드의 어떤 부분이 취약점인지”가 필요</li>
<li>또한, 취약점 분석 시 Exploit 위험도 우선순위에 따른 수정 절차 필요</li>
</ul>
</li>
</ul>
<h2 id="3-제안-개요">3. 제안 개요</h2>
<h3 id="1-목적">(1) 목적</h3>
<ul>
<li>소스코드 내 보안 취약점을 자동으로 탐지</li>
<li>해당 소스코드에서 발견된 취약점에 대한 MITRE 공식 문서 기반의 설명 제공</li>
</ul>
<h3 id="2-구성">(2) 구성</h3>
<ul>
<li>1단계: 비생성형 ML 모델을 활용한 취약점 탐지</li>
<li>2단계: 발견된 취약점 설명 및 대응 가이드</li>
<li>3단계: 개발 환경 연계 (VSCode Extension)</li>
</ul>
<h2 id="4-기대-효과">4. 기대 효과</h2>
<h3 id="1-보안-리스크-사전-예방">(1) 보안 리스크 사전 예방</h3>
<ul>
<li>개발 초기 단계에서 취약점 식별 가능</li>
<li>CI/CD 절차의 이전 단계에서 발생할 수 있는 보안 대책 강구 가능</li>
<li>조직 차원의 리스크 관리 강화 효과 기대</li>
</ul>
<h3 id="2-수정-비용-절감">(2) 수정 비용 절감</h3>
<ul>
<li>취약점의 조기 발견을 통한 분석·수정·재검증 비용의 절감 가능</li>
<li>보안에 대한 사전 지식이 부족한 사람에게도 공식 문서 기반의 취약점 정보를 제공해 탐색 시간 단축</li>
</ul>
<h3 id="3-개발-생산성-증대">(3) 개발 생산성 증대</h3>
<ul>
<li>현재 개발 중인 코드에 적합한 CVE, CWE에 대한 자료를 탐색하지 않아도 취약점에 대한 지표 제공</li>
<li>코드 리뷰 및 보안 검토 효율 향상</li>
<li>반복 발생 취약점에 대한 대응 표준화 가능</li>
</ul>
<h3 id="4-보안-내재화를-통한-시스템-안전성-향상">(4) 보안 내재화를 통한 시스템 안전성 향상</h3>
<ul>
<li>시스템의 보안 수준을 특정 인력에게 편중시키지 않도록 유도</li>
<li>개발 조직 전체의 보안 인식 제고</li>
<li>장기적인 시스템 보안 유지 기반 마련</li>
</ul>
<h2 id="5-차별성-및-도입-타당성">5. 차별성 및 도입 타당성</h2>
<h3 id="1-시장-조사">(1) 시장 조사</h3>
<p><img src="https://velog.velcdn.com/images/rudin_/post/30b5a022-cc44-4d1b-a0a2-53489d770a24/image.png" alt=""></p>
<h3 id="2-차별점">(2) 차별점</h3>
<ul>
<li>XAI 기반 탐지 근거 시각화</li>
<li>Azure-based End to End Framework</li>
<li>공식 취약점 문서 기반 RAG 시스템 구축</li>
<li>경량 모델 + 실시간 IDE UX를 통한 개발 도중 즉시 피드백 가능</li>
<li>사용자 피드백 반영을 통한 데이터 재학습 및 정책 최적화까지 확장할 수 있음</li>
</ul>
<h2 id="6-리스크-및-대응-방향">6. 리스크 및 대응 방향</h2>
<h3 id="1-예상-리스크">(1) 예상 리스크</h3>
<ul>
<li>초기 단계에서 모든 언어 및 모든 취약점 유형을 포괄하기 어려움</li>
<li>데이터셋의 범위와 분포의 한계 존재</li>
<li>탐지 모델과 설명 모델의 적용 범위 차이 존재</li>
</ul>
<h3 id="2-대응-방향">(2) 대응 방향</h3>
<ul>
<li>초기는 Vibe Coding에서 특히 많이 사용되는 언어(JavaScript)와 우선순위 높은 취약점으로 한정</li>
<li>취약점 범위 : <a href="https://owasp.org/Top10/2025/A01_2025-Broken_Access_Control/">https://owasp.org/Top10/2025/A01_2025-Broken_Access_Control/</a></li>
<li>한정된 범위 내의 적정선의 성능 확보 후 점진적으로 확대</li>
<li>생성형 AI 단독 판단이 아닌 공식 문서 기반 결합으로 생성형 기반 타 솔루션과 다른 신뢰도 보완</li>
<li>최종 판단은 개발자 및 보안 담당자 검토로 진행 (보조 기구로서의 의의)</li>
<li>사용자 피드백 및 최신 취약점 문서 반영을 통한 지속적인 개선</li>
</ul>
<h3 id="기본-관점">기본 관점</h3>
<ul>
<li>본 솔루션은 완전 자동화 및 판단 도구가 아님</li>
<li>개발자와 보안 담당자의 판단을 빠르고 일관되게 지원하는 보조 지표로서의 의의를 가짐</li>
</ul>
<h2 id="8-차후-발전-방향">8. 차후 발전 방향</h2>
<ul>
<li>취약점 탐지 모델 정확도 향상</li>
<li>취약점 탐지 모델의 Handling Scope 확장<ul>
<li>다언어 지원</li>
<li>학습 데이터베이스 수집 및 구축을 통한 최신 취약점 반영</li>
</ul>
</li>
<li>RAG를 활용한 LLM Hallucination 최소화 및 취약점 데이터베이스 기반 수정 방안 제안 기능 추가</li>
<li>서비스 별 반복 발생 취약점 분석 대시보드 제공</li>
<li>최신 OWASP 상위 10개의 취약점에 기반한 우선순위 자동 정렬화</li>
</ul>
<h2 id="9-결론">9. 결론</h2>
<ul>
<li>개발 과정에서 보안리스크를 조기에 식별 가능</li>
<li>장기적인 보안 사고 대응 역량 강화를 위해 필요한 보안 지원 도구 및 체계의 구축</li>
<li>핵심 구성<ul>
<li>경량화된 비생성형 AI 모델을 활용한 취약점 탐지</li>
<li>설명 가능한 인공지능(XAI) 기술 도입을 통한 모델 투명성 확보</li>
<li>공식 취약점 문서 기반 분석 체계 수립</li>
<li>사용률이 높은 IDE인 VSCode Extension을 통한 UX</li>
</ul>
</li>
<li>기대 효과<ul>
<li>시스템 보안 내재화</li>
<li>개발 생산성 향상</li>
<li>운영 리스크 감소</li>
</ul>
</li>
<li>종합<ul>
<li>개발 보안 수준 향상, 업무 효율 개선을 동시에 기대할 수 있는 전략적 솔루션</li>
</ul>
</li>
</ul>
<hr>

<h2 id="시스템-아키텍처">시스템 아키텍처</h2>
<p><img src="https://velog.velcdn.com/images/rudin_/post/e9610f29-9432-4f0d-87a6-2fec29206708/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/c4ac2b01-c80d-482b-ae2e-092e2125cc5e/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/36cb952d-afe3-47aa-bfad-32878216c0cc/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/b4e69171-ef16-463b-b450-e9ad1a17a768/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/d42492c8-aae6-4097-ba23-68ee5b1ce9fb/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/36c0c4fc-96d8-41dc-a2b9-39b95bb541f4/image.png" alt=""></p>
<hr>

<h2 id="시연-영상">시연 영상</h2>
<p><a href="https://canva.link/txux14cq2bj3wzf">시연 영상</a></p>
<hr>

<h2 id="트러블슈팅">트러블슈팅</h2>
<h3 id="azure-function-시작-실패-modulenotfounderror-requests-2026-04-06">Azure Function 시작 실패 (ModuleNotFoundError: requests) (2026-04-06)</h3>
<p>Problem: 배포 후 Function Host 기동 실패
Cause: FlexConsumption 배포 경로에서 remote build 미적용으로 런타임 의존성 미설치
Solution: GitHub Actions에 enable-oryx-build=true, remote-build=true 적용 + runtime/databricks 의존성 분리</p>
<h3 id="databricks-run-now-400-실패-2026-04-06">Databricks run-now 400 실패 (2026-04-06)</h3>
<p>Problem: Function에서 Databricks job 제출 자체 실패
Cause: Job이 job_parameters 계약인데 코드가 notebook_params 사용
Solution: 호출 payload를 job_parameters로 전환</p>
<h3 id="databricks-태스크-실패-source_xml_path-is-required-2026-04-0607">Databricks 태스크 실패 (source_xml_path is required) (2026-04-06~07)</h3>
<p>Problem: Function 응답은 성공인데 Databricks task는 내부 실패
Cause: Notebook widget/파라미터 바인딩 누락, base_parameters 미정의
Solution: notebook wrapper에서 파라미터 바인딩 고정 + 전달 경로 정규화</p>
<h3 id="sas-url-읽기-실패-binaryfile-진입-오류-2026-04-07">SAS URL 읽기 실패 (binaryFile 진입 오류) (2026-04-07)</h3>
<p>Problem: HTTP(S) SAS URL인데 Spark binaryFile 경로로 처리되어 AnalysisException
Cause: 경로 문자열 공백/quote 등으로 분기 미스
Solution: source_xml_path 정규화 + URL scheme 판별 강화 + HTTP(S)는 직접 읽기 강제</p>
<h3 id="delta-적재-0건간헐-실패-2026-04-0708">Delta 적재 0건/간헐 실패 (2026-04-07~08)</h3>
<p>Problem: 실행 완료처럼 보이나 테이블 row count가 0 또는 No module named &#39;azure&#39; 재발
Cause: XML namespace 미처리 + package import side-effect가 azure SDK 의존성 유입
Solution: parser namespace 처리/빈 결과 가드 추가 + service/shared lazy import 구조로 전환</p>
<h3 id="ucexternal-location-생성-불가-2026-04-06">UC/External Location 생성 불가 (2026-04-06)</h3>
<p>Problem: Catalog에서 External data 메뉴 미노출 및 External Location 생성 실패
Cause: Account 권한·metastore 할당·HNS·CREATE EXTERNAL LOCATION 권한 부족
Solution: HNS 활성 스토리지로 전환 + Access Connector/Metastore 권한 보강</p>
<h3 id="gcs→adf-파라미터-공백으로-파이프라인-오동작-2026-04-09">GCS→ADF 파라미터 공백으로 파이프라인 오동작 (2026-04-09)</h3>
<p>Problem: bucket_name/object_path가 빈 값으로 전달되어 If Condition/경로 처리 실패
Cause: Form 전송(application/x-www-form-urlencoded) 또는 {&quot;parameters&quot;:{...}} 중첩 payload 사용
Solution: application/json + 최상위 키(bucket_name, object_path, object_generation)로 고정</p>
<h3 id="eventarccloud-function-배포-권한-오류-2026-04-09">Eventarc/Cloud Function 배포 권한 오류 (2026-04-09)</h3>
<p>Problem: Eventarc 관련 permission denied, SA not found로 배포 차단
Cause: 이벤트 수신/Invoker/PubSub 퍼블리셔 권한 및 트리거 SA 설정 불일치
Solution: 역할 재부여 + 전용 SA 재지정 + 버킷 리전 맞춘 재배포</p>
<h3 id="databricks-path_not_found-파일명경로-변환-이슈-2026-04-09">Databricks PATH_NOT_FOUND (파일명/경로 변환 이슈) (2026-04-09)</h3>
<p>Problem: ADLS 저장 경로와 notebook 읽기 경로 불일치
Cause: ADF Dataset/Copy에서 디렉토리·파일명 매핑 분리 미흡
Solution: p_dir/p_file 분리 매핑으로 raw/&lt;원본디렉토리&gt;/&lt;원본파일명&gt; 규칙 고정</p>
<h3 id="ml-대시보드-null이상치-문제-2026-04-14">ML 대시보드 NULL/이상치 문제 (2026-04-14)</h3>
<p>Problem: training_data_count, f1/r2/rmse 등 지표가 NULL 또는 비정상값
Cause: metric key 매핑 협소, source 탐색 제한, Best Run/Trend 집계식 취약
Solution: params/tags/run_metrics_history fallback 확장 + key 탐지 규칙 강화 + Best Run 정렬식(R2 DESC, RMSE ASC) 보정</p>
<h3 id="운영-장애-감지-공백-2026-04-13">운영 장애 감지 공백 (2026-04-13)</h3>
<p>Problem: 실패 발생 시 통합 알림 체계 부재
Cause: 플랫폼별 실패 이벤트가 분산되어 즉시 감지 어려움
Solution: Logic App Webhook 기반 공통 알림 훅 표준화 + 테스트 추가 + GCP/ADF/Databricks 운영 가이드 정리</p>
<hr>

<h2 id="내가-담당한-부분">내가 담당한 부분</h2>
<ul>
<li>Azure Databricks Account 관리 및 대시보드 구현</li>
<li>Azure Factory 구성, GCP Bucket, Cloud Function 생성 및 연결</li>
<li>Azure Function-Blob(ADLS)-AAC-ADB 데이터 파이프라인 구현</li>
<li>Logic App으로 데이터 파이프라인 실패 시 이메일 알림기능 구현</li>
</ul>
<h2 id="추가로-배운-부분azure-비용적-관점">추가로 배운 부분(Azure 비용적 관점)</h2>
<ul>
<li>databricks 컴퓨팅 생성하면 azure 파생 그룹이 발생 → 클라우드 관리자에게 요청하여 해당 그룹으로 포함 요청하여 가시성을 얻어야 토탈 요금 확인 가능(기존 리소스그룹 요금 + databricks전용 리소스그룹 요금)</li>
<li>작업 시작 전 항상 git pull --rebase origin main, 푸시 직전 다시 git fetch 후 필요 시 git pull --rebase origin main, main에서는 merge commit 안 만들고 rebase/fast-forward만 사용하여 브랜치 히스토리를 깔끔하게</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[MicrosoftDataSchool] 60일차 - AzureDataFactory 필터, Until, Join]]></title>
            <link>https://velog.io/@rudin_/MicrosoftDataSchool-60%EC%9D%BC%EC%B0%A8</link>
            <guid>https://velog.io/@rudin_/MicrosoftDataSchool-60%EC%9D%BC%EC%B0%A8</guid>
            <pubDate>Wed, 01 Apr 2026 03:42:21 GMT</pubDate>
            <description><![CDATA[<h1 id="azure-data-factory-필터-활동-filter-activity">[Azure Data Factory] 필터 활동 (Filter Activity)</h1>
<h2 id="1-개요">1. 개요</h2>
<p>필터 활동은 필터 변환과 달리, 파이프라인 내에서 배열(Array) 데이터(예: Lookup, Get Metadata, Web Activity 등에서 반환된 값)를 조건에 따라 필터링하는 활동(Activity)입니다.</p>
<ul>
<li><strong>사용 위치</strong>: 파이프라인(컨트롤 플로우)에 배치하여 실행</li>
<li><strong>적용 대상</strong>: 일반적으로 JSON 배열, 객체 배열 등 구조화된 리스트 데이터</li>
<li><strong>주요 예시</strong>:<ul>
<li>Get Metadata로 파일 목록을 받아온 후, 특정 확장자만 필터링</li>
<li>Lookup으로 여러 레코드의 배열을 가져온 후, 특정 조건에 맞는 레코드만 추출</li>
</ul>
</li>
</ul>
<h3 id="필터-활동-vs-필터-변환-비교"><strong>필터 활동 vs 필터 변환 비교</strong></h3>
<table>
<thead>
<tr>
<th align="left">구분</th>
<th align="left">필터 활동</th>
<th align="left">필터 변환</th>
</tr>
</thead>
<tbody><tr>
<td align="left"><strong>적용 위치</strong></td>
<td align="left">파이프라인(Activity)</td>
<td align="left">매핑 데이터 플로우(Transformation)</td>
</tr>
<tr>
<td align="left"><strong>적용 대상</strong></td>
<td align="left">배열 데이터(Array, JSON 등)</td>
<td align="left">테이블 데이터(행/컬럼 기반)</td>
</tr>
<tr>
<td align="left"><strong>조건 작성 방식</strong></td>
<td align="left">파이프라인 식(표현식)</td>
<td align="left">데이터 플로우 내 식(식 편집기)</td>
</tr>
<tr>
<td align="left"><strong>용도</strong></td>
<td align="left">리스트, 메타데이터 등 구조화된 배열</td>
<td align="left">레코드(행) 기반 데이터 처리</td>
</tr>
<tr>
<td align="left"><strong>주요 사용 예시</strong></td>
<td align="left">파일/테이블/객체 리스트 조건 분기</td>
<td align="left">데이터 필터링(컬럼 조건에 따라 행 추출)</td>
</tr>
</tbody></table>
<hr>
<h2 id="2-실습-준비">2. 실습 준비</h2>
<p><img src="https://velog.velcdn.com/images/rudin_/post/2a89bd78-6625-4e08-9c5e-84ca7141f973/image.png" alt=""></p>
<h3 id="2-1-입력-컨테이너-및-파일-준비"><strong>2-1. 입력 컨테이너 및 파일 준비</strong></h3>
<ul>
<li><strong>Storage Account</strong>: <code>a000storagedemo</code></li>
<li><strong>Container</strong>: <code>baseball-hitter</code></li>
<li><strong>준비 파일 목록</strong>:<ul>
<li><code>2000_2001_hitter.csv</code></li>
<li><code>2000_2001_hitter.xlsx</code></li>
<li><code>2002_2013_hitter.csv</code></li>
<li><code>2002_2013_hitter.xlsx</code></li>
<li><code>2014_hitter.csv</code></li>
<li><code>2014_hitter.xlsx</code></li>
</ul>
</li>
</ul>
<h3 id="2-2-링크드-서비스-확인"><strong>2-2. 링크드 서비스 확인</strong></h3>
<ul>
<li><strong>이름</strong>: <code>BlobStorage1</code></li>
<li><strong>형식</strong>: Azure Blob Storage</li>
<li><strong>통합 런타임</strong>: <code>AutoResolveIntegrationRuntime</code></li>
</ul>
<hr>
<h2 id="3-메인-파이프라인-구성-filtercsvfiles_pl">3. 메인 파이프라인 구성 (FilterCsvFiles_PL)</h2>
<h3 id="3-1-get-metadata-활동-get-metadata---list-files"><strong>3-1. Get Metadata 활동 (<code>Get Metadata - List Files</code>)</strong></h3>
<ul>
<li><strong>데이터 세트</strong>: <code>baseballinput_DS</code> (baseball-hitter 컨테이너 연결)</li>
<li><strong>필드 목록</strong>:<ul>
<li><code>exists</code></li>
<li><code>childItems</code></li>
</ul>
</li>
<li><strong>실행 결과(출력 예시)</strong>:<pre><code class="language-json">{
    &quot;exists&quot;: true,
    &quot;itemName&quot;: &quot;baseball-hitter&quot;,
    &quot;itemType&quot;: &quot;Folder&quot;,
    &quot;childItems&quot;: [
        { &quot;name&quot;: &quot;2000_2001_hitter.csv&quot;, &quot;type&quot;: &quot;File&quot; },
        { &quot;name&quot;: &quot;2000_2001_hitter.xlsx&quot;, &quot;type&quot;: &quot;File&quot; },
        ...
    ]
}</code></pre>
</li>
</ul>
<h3 id="3-2-if-condition-활동-if-exist"><strong>3-2. If Condition 활동 (<code>If Exist</code>)</strong></h3>
<ul>
<li><strong>식(Expression)</strong>:<pre><code>@activity(&#39;Get Metadata - List Files&#39;).output.exists</code></pre></li>
<li><strong>True 작업</strong>: 내부에 필터 및 후속 활동 배치</li>
</ul>
<h3 id="중요-설계-변경-사유"><strong>[중요] 설계 변경 사유</strong></h3>
<ul>
<li><strong>제약 사항</strong>: 중첩된 ForEach 작업은 지원되지 않으며, <strong>ForEach 작업을 If Condition activity 범위 내에서 사용할 수 없습니다.</strong></li>
<li><strong>해결 방법</strong>: If Condition 내에서 <strong>Execute Pipeline</strong> 활동을 사용하여 자식 파이프라인을 호출하는 방식으로 구성합니다.</li>
</ul>
<hr>
<h2 id="4-자식-파이프라인-구성-filterforeachcopy_pl">4. 자식 파이프라인 구성 (FilterForEachCopy_PL)</h2>
<p><img src="https://velog.velcdn.com/images/rudin_/post/c63075a3-f8fa-4265-8fb5-a15095caee41/image.png" alt=""></p>
<h3 id="4-1-매개변수-설정"><strong>4-1. 매개변수 설정</strong></h3>
<ul>
<li><strong>이름</strong>: <code>fileListToProcess</code></li>
<li><strong>형식</strong>: <code>Array</code></li>
</ul>
<h3 id="4-2-필터-활동-filter-csv"><strong>4-2. 필터 활동 (<code>Filter CSV</code>)</strong></h3>
<ul>
<li><strong>항목(Items)</strong>:<pre><code>@pipeline().parameters.fileListToProcess</code></pre></li>
<li><strong>조건(Condition)</strong>:<pre><code>@and(
    equals(item().type, &#39;File&#39;),
    endswith(item().name, &#39;.csv&#39;)
)</code></pre></li>
</ul>
<h3 id="4-3-foreach-활동-foreach-files"><strong>4-3. ForEach 활동 (<code>ForEach Files</code>)</strong></h3>
<ul>
<li><strong>항목(Items)</strong>:<pre><code>@activity(&#39;Filter CSV&#39;).output.value</code></pre></li>
</ul>
<p><img src="https://velog.velcdn.com/images/rudin_/post/e854dc1b-364f-4455-9c74-28537a4dc906/image.png" alt=""></p>
<h3 id="4-4-복사-활동-copy-files"><strong>4-4. 복사 활동 (<code>Copy Files</code>)</strong></h3>
<ul>
<li><strong>원본 데이터 세트</strong>: <code>baseballCopyInput_DS</code><ul>
<li>매개변수 <code>fileName</code> 사용: <code>@dataset().fileName</code></li>
<li>값 매핑: <code>@item().name</code></li>
</ul>
</li>
<li><strong>싱크 데이터 세트</strong>: <code>baseballCopyOutput_DS</code><ul>
<li>경로: <code>baseball-hitter/csv</code></li>
<li>매개변수 <code>fileName</code> 사용: <code>@dataset().fileName</code></li>
<li>값 매핑: <code>@item().name</code></li>
</ul>
</li>
</ul>
<hr>
<h2 id="5-메인-파이프라인-수정-및-실행">5. 메인 파이프라인 수정 및 실행,</h2>
<h3 id="5-1-execute-pipeline-활동-추가"><strong>5-1. Execute Pipeline 활동 추가</strong></h3>
<ul>
<li>If Condition의 <strong>True</strong> 섹션에 <code>Execute Pipeline</code> 활동을 추가합니다.</li>
<li><strong>호출된 파이프라인</strong>: <code>FilterForEachCopy_PL</code></li>
<li><strong>매개변수(<code>fileListToProcess</code>) 전달</strong>:<pre><code>@activity(&#39;Get Metadata - List Files&#39;).output.childItems</code></pre></li>
</ul>
<h3 id="5-2-실행-결과-확인"><strong>5-2. 실행 결과 확인</strong></h3>
<ol>
<li><strong>Get Metadata</strong>: 성공 (6개 항목 조회)</li>
<li><strong>If Exist</strong>: 성공 (True 분기)</li>
<li><strong>Execute Pipeline</strong>: 성공 (자식 파이프라인 호출)<ul>
<li>입력 데이터 확인: <code>.csv</code>와 <code>.xlsx</code> 파일이 모두 포함된 배열 전달</li>
</ul>
</li>
<li><strong>자식 파이프라인 내 Filter</strong>: 성공 (6개 중 <code>.csv</code> 파일 3개만 필터링)</li>
</ol>
<h3 id="5-3-최종-스토리지-확인"><strong>5-3. 최종 스토리지 확인</strong>,</h3>
<ul>
<li><code>baseball-hitter/csv</code> 폴더 내에 필터링된 파일들이 정상 복사되었는지 확인합니다:<ul>
<li><code>2000_2001_hitter.csv</code></li>
<li><code>2002_2013_hitter.csv</code></li>
<li><code>2014_hitter.csv</code></li>
</ul>
</li>
</ul>
<hr>
<p><strong>마무리</strong>: Filter 활동을 통해 파이프라인 흐름 제어 단계에서 배열 데이터를 정교하게 제어할 수 있으며, If Condition과 결합 시 자식 파이프라인 호출 방식을 활용해야 함을 유의하시기 바랍니다.</p>
<hr>
<hr>
<h1 id="azure-data-factory-파생열-및-조건부-분할-변환-derived-column--conditional-split">[Azure Data Factory] 파생열 및 조건부 분할 변환 (Derived Column &amp; Conditional Split)</h1>
<h2 id="1-개요-1">1. 개요</h2>
<h3 id="파생열-변환-derived-column-transformation"><strong>파생열 변환 (Derived Column Transformation)</strong></h3>
<p>파생열 변환은 기존 데이터 컬럼을 가공하거나 새로운 컬럼을 추가할 때 사용하는 변환 단계입니다. 입력된 데이터의 컬럼 값을 수식/표현식으로 가공하여 파생 컬럼을 생성하거나 기존 컬럼 값을 대체할 수 있습니다.</p>
<ul>
<li><strong>구성 요소</strong>:<ul>
<li><strong>파생 컬럼(Derived Column)</strong>: 새로운 컬럼 추가 또는 기존 컬럼 값 대체</li>
<li><strong>표현식(Expression)</strong>: 문자열, 수치, 날짜, 논리 연산 등 다양한 함수/연산자 지원</li>
<li><strong>미리보기(Data Preview)</strong>: 변환 결과를 즉시 확인할 수 있는 기능</li>
</ul>
</li>
<li><strong>장점</strong>:<ul>
<li>기존 문자열 컬럼에서 특정 패턴 추출하여 파생 정보 제공</li>
<li>데이터 전처리 및 가공의 자동화</li>
<li>별도의 데이터 소스 수정 없이 컬럼 가공 가능</li>
<li>다양한 비즈니스 로직 반영 가능</li>
</ul>
</li>
<li><strong>사용 시나리오</strong>:<ul>
<li>성적 데이터에서 점수에 따라 &quot;합격/불합격&quot; 여부 컬럼 추가</li>
<li>주문 데이터에서 단가 × 수량으로 총액(Total) 컬럼 추가</li>
<li>날짜 데이터를 가공하여 연도, 월 등 새로운 컬럼 추출</li>
</ul>
</li>
</ul>
<h3 id="조건부-분할-변환-conditional-split-transformation"><strong>조건부 분할 변환 (Conditional Split Transformation)</strong></h3>
<p>조건부 분할 변환은 입력 데이터 행(Row)을 지정한 조건(수식/표현식)에 따라 여러 그룹(분기)으로 나누어 주는 변환 단계입니다.</p>
<ul>
<li><strong>구성 요소</strong>:<ul>
<li><strong>분기 조건(Condition)</strong>: 분리 기준이 되는 조건을 작성하며, 하나의 데이터 행에 대해 첫 번째로 참이 되는 조건에 따라 분기함</li>
<li><strong>기본 분기(Default Output)</strong>: 모든 조건을 만족하지 않을 때 데이터를 분리할 기본 분기 지정</li>
<li><strong>미리보기(Data Preview)</strong>: 분할 결과를 즉시 확인할 수 있는 기능</li>
</ul>
</li>
<li><strong>장점</strong>:<ul>
<li>조건별로 데이터 흐름을 분리하여 후처리 용이</li>
<li>복잡한 분기 로직을 시각적으로 설계 가능</li>
<li>다양한 조건별 분석 및 후속 처리 지원</li>
</ul>
</li>
<li><strong>사용 시나리오</strong>:<ul>
<li>점수 90점 이상/80점 이상/기타 등급별로 데이터 분할</li>
<li>주문 상태(배송완료/배송중/취소 등)에 따라 데이터 분할</li>
<li>거래 금액이 임계값 이상/미만인 고객 분리</li>
</ul>
</li>
</ul>
<hr>
<h2 id="2-실습-준비-1">2. 실습 준비</h2>
<h3 id="2-1-실습-데이터-employeecsv"><strong>2-1. 실습 데이터 (<code>employee.csv</code>)</strong></h3>
<pre><code class="language-text">ID, Name, Salary, Address, Location, Email
1, 김철수, 2750000, 서울 강남구, Korea, chulsu.kim@example1.com
2, 이영희,, Irvine CA, US, younghee.lee@example3.com
3, 박민준, 3820000, 인천 연수구, Korea, minjun.park@example1.com
4, 최지영, 4500000, 부산 해운대구, Korea, jiyoung.choi@example2.com
5, 정윤화, 2810000, 대구 수성구, Korea, yoonhwa.chung@example2.com
6, 강서준, 3540000,, China, seojun.kang@example2.com
7, 윤아영,, 제주 제주시, Korea, ayoung.yoon@example3.com</code></pre>
<h3 id="2-2-컨테이너-및-데이터-세트-준비"><strong>2-2. 컨테이너 및 데이터 세트 준비</strong></h3>
<ol>
<li><strong>Storage</strong>: <code>a000storagedemo2</code> 내 <code>employee</code> 컨테이너 생성 및 <code>employee.csv</code> 업로드.</li>
<li><strong>데이터 흐름 디버그 켜기</strong>: Small 컴퓨팅, 1시간 TTL 설정.</li>
<li><strong>소스 데이터 세트 (<code>sourceCsv_DS</code>)</strong>: <code>employee.csv</code> 연결, 첫 번째 행을 머리글로 설정.</li>
</ol>
<hr>
<h2 id="3-실습-1-데이터-정제-및-파생열-생성">3. [실습 1] 데이터 정제 및 파생열 생성</h2>
<h3 id="3-1-주소-누락값-처리-missingvalueaddress"><strong>3-1. 주소 누락값 처리 (<code>missingValueAddress</code>)</strong></h3>
<ul>
<li><strong>열</strong>: <code>Address</code></li>
<li><strong>식</strong>:<pre><code>iif(isNull(Address), &#39;unknown&#39;, Address)</code></pre></li>
</ul>
<h3 id="3-2-급여-누락값-처리-missingvaluesalary"><strong>3-2. 급여 누락값 처리 (<code>missingValueSalary</code>)</strong></h3>
<ul>
<li><strong>열</strong>: <code>Salary</code></li>
<li><strong>식</strong>:<pre><code>iif(isNull(Salary), &#39;2500000&#39;, Salary)</code></pre></li>
</ul>
<h3 id="3-3-국가-컬럼-대문자-변환-upperlocationtocountry"><strong>3-3. 국가 컬럼 대문자 변환 (<code>upperLocationToCountry</code>)</strong></h3>
<ul>
<li><strong>열</strong>: <code>Country</code> (새로 만들기)</li>
<li><strong>식</strong>:<pre><code>upper(Location)</code></pre></li>
</ul>
<h3 id="3-4-싱크-설정-및-파이프라인-실행"><strong>3-4. 싱크 설정 및 파이프라인 실행</strong></h3>
<ul>
<li><strong>싱크(<code>sink1</code>)</strong>: <code>employee_processed.csv</code>로 단일 파일 출력.</li>
<li><strong>파이프라인</strong>: <code>employee1_PL</code> 생성 후 데이터 흐름 실행.</li>
<li><strong>결과</strong>: <code>employee_processed.csv</code> 생성 완료.</li>
</ul>
<hr>
<h2 id="4-실습-2-복합-파생열-및-조건부-분할-advanced">4. [실습 2] 복합 파생열 및 조건부 분할 (Advanced)</h2>
<p>기존 파이프라인에 추가 실습 구성을 연결합니다.</p>
<h3 id="4-1-급여-등급-컬럼-생성-addsalarygrade"><strong>4-1. 급여 등급 컬럼 생성 (<code>addSalaryGrade</code>)</strong></h3>
<ul>
<li><strong>열</strong>: <code>SalaryGrade</code></li>
<li><strong>식</strong>:<pre><code>iif(toInteger(Salary) &lt;= 3000000, &#39;Low&#39;,
iif(toInteger(Salary) &lt;= 4000000, &#39;Mid&#39;, &#39;High&#39;))</code></pre></li>
</ul>
<h3 id="4-2-이메일-도메인-추출-addemaildomain"><strong>4-2. 이메일 도메인 추출 (<code>addEmailDomain</code>)</strong></h3>
<ul>
<li><strong>열</strong>: <code>EmailDomain</code></li>
<li><strong>식</strong>:<pre><code>split(Email,&#39;@&#39;)</code></pre></li>
</ul>
<h3 id="4-3-조건부-분할-splitbylocation"><strong>4-3. 조건부 분할 (<code>SplitByLocation</code>)</strong></h3>
<ul>
<li><strong>스트림 1 (<code>headquarters</code>)</strong>:<ul>
<li>조건: <code>Location == &#39;Korea&#39;</code></li>
</ul>
</li>
<li><strong>스트림 2 (<code>Branch</code>)</strong>:<ul>
<li>조건: (기본 분기 - 조건을 충족하지 않는 행)</li>
</ul>
</li>
</ul>
<h3 id="4-4-다중-싱크-설정"><strong>4-4. 다중 싱크 설정</strong></h3>
<ol>
<li><strong>본사 싱크 (<code>headquaterSink</code>)</strong>:<ul>
<li>파일 이름: <code>headquarters-employee.csv</code></li>
<li>내용: 한국(Korea) 근무자 데이터 5건.</li>
</ul>
</li>
<li><strong>지사 싱크 (<code>branchSink</code>)</strong>:<ul>
<li>파일 이름: <code>branch-employee.csv</code></li>
<li>내용: 해외(US, China) 근무자 데이터 2건.</li>
</ul>
</li>
</ol>
<hr>
<h2 id="5-최종-결과-확인">5. 최종 결과 확인</h2>
<p><strong>headquarters-employee.csv 예시</strong>:</p>
<pre><code class="language-text">ID, Name, Salary, Address, Location, Email, Country, SalaryGrade, EmailDomain
1, 김철수, 2750000, 서울 강남구, Korea, chulsu.kim@example1.com, KOREA, Low, example1.com
3, 박민준, 3820000, 인천 연수구, Korea, minjun.park@example1.com, KOREA, Mid, example1.com
4, 최지영, 4500000, 부산 해운대구, Korea, jiyoung.choi@example2.com, KOREA, High, example2.com
...</code></pre>
<p><strong>branch-employee.csv 예시</strong>:</p>
<pre><code class="language-text">ID, Name, Salary, Address, Location, Email, Country, SalaryGrade, EmailDomain
2, 이영희, 2500000, Irvine CA, US, younghee.lee@example3.com, US, Low, example3.com
6, 강서준, 3540000, unknown, China, seojun.kang@example2.com, CHINA, Mid, example2.com</code></pre>
<hr>
<h2 id="6-실습-마무리">6. 실습 마무리</h2>
<p>실습이 완료된 후에는 불필요한 비용이 발생하지 않도록 <strong>데이터 흐름 디버그 모드</strong>를 반드시 중지하고 게시(Publish)를 확인합니다.</p>
<hr>
<hr>
<h1 id="azure-data-factory-until-활동-및-set-variable-활동">[Azure Data Factory] Until 활동 및 Set Variable 활동</h1>
<h2 id="1-개요-2">1. 개요</h2>
<h3 id="until-활동-until-activity"><strong>Until 활동 (Until Activity)</strong></h3>
<p>Until 활동은 지정한 조건이 만족될 때까지 내부에 정의한 액티비티를 반복 실행하는 파이프라인 제어 단계입니다.</p>
<ul>
<li><strong>구성 요소</strong>:<ul>
<li><strong>반복 조건(Expression)</strong>: 반복을 종료할 시점을 결정하는 논리식 (예: <code>@equals(variables(&#39;fileFound&#39;), true)</code>)</li>
<li><strong>내부 액티비티(Activities)</strong>: 반복 루프 내에서 매회 수행할 액티비티 (예: Get Metadata → If Condition → Copy Data 등)</li>
<li><strong>대기 간격(Timeout/Interval)</strong>: 두 반복 사이에 대기할 시간(초 단위)을 설정하여 과도한 호출 방지</li>
</ul>
</li>
<li><strong>장점</strong>:<ul>
<li>알려지지 않은 반복 횟수를 처리할 수 있어, 파일 도착·상태 변경 등 비동기 이벤트 대기 시 유용</li>
<li>복잡한 루프 로직(분기, 에러 처리)을 시각적으로 설계 가능</li>
<li>반복 중간에 변수 업데이트나 외부 서비스 호출을 결합하여 동적 파이프라인 구현 지원</li>
</ul>
</li>
<li><strong>사용 시나리오</strong>:<ul>
<li>외부 시스템 파일 도착 여부를 확인하여, 파일이 준비될 때까지 반복 폴링</li>
<li>메타데이터 기준으로 데이터 누적이 완료될 때까지 복사/병합 작업 반복</li>
<li>API 호출 응답 상태가 원하는 결과가 나올 때까지 재시도</li>
</ul>
</li>
</ul>
<h3 id="set-variable-활동-set-variable-activity"><strong>Set Variable 활동 (Set Variable Activity)</strong></h3>
<p>Set Variable 활동은 파이프라인 변수(Pipeline Variable)의 값을 동적으로 변경하는 액티비티입니다.</p>
<ul>
<li><strong>구성 요소</strong>:<ul>
<li><strong>변수 이름(Variable Name)</strong>: 미리 선언된 파이프라인 변수 중 업데이트할 변수 선택</li>
<li><strong>값 표현식(Value/Expression)</strong>: 고정 값 또는 동적 콘텐츠 (예: <code>@item().name</code>, <code>@add(variables(&#39;count&#39;),1)</code>)</li>
<li><strong>데이터 형식(Type)</strong>: String, Bool, Array 등 변수 선언 시 지정된 형식</li>
</ul>
</li>
</ul>
<hr>
<h2 id="2-실습-준비-2">2. 실습 준비</h2>
<p><img src="https://velog.velcdn.com/images/rudin_/post/fcf63b19-5fba-46b6-8a3c-409ae0ce1bae/image.png" alt=""></p>
<h3 id="2-1-실습-데이터-employee_batchcsv"><strong>2-1. 실습 데이터 (<code>employee_batch.csv</code>)</strong></h3>
<pre><code class="language-text">ID, Name, Department, Salary, Location
1, 오준호, IT, 69190, Gwangju
2, 최예린, Finance, 61538, Daegu
3, 강다은, Finance, 55729, Seoul
4, 정우성, Marketing, 46409, Gwangju
5, 오준호, Marketing, 57249, Seoul
6, 서지아, HR, 45784, Incheon
7, 박지훈, HR, 53096, Seoul
8, 오준호, Marketing, 52560, Seoul
9, 강다은, Finance, 57533, Seoul
10, 정우성, IT, 52343, Daegu
11, 최예린, Marketing, 52206, Seoul
12, 강다은, HR, 66980, Gwangju
13, 강다은, Finance, 50801, Incheon
14, 박지훈, Marketing, 64190, Incheon
15, 한수진, Marketing, 61921, Seoul
16, 정우성, Finance, 50986, Daegu
17, 이서연, Marketing, 63225, Daegu
18, 강다은, Finance, 55647, Gwangju
19, 한수진, IT, 53716, Seoul
20, 이서연, Finance, 68355, Incheon
21, 정우성, Finance, 67009, Daegu
22, 김민준, Finance, 64334, Seoul
23, 서지아, Marketing, 69376, Daegu
24, 한수진, Marketing, 57323, Daegu
25, 윤도현, Finance, 49780, Incheon
26, 김민준, Marketing, 47368, Busan
27, 서지아, HR, 57039, Gwangju
28, 박지훈, HR, 51655, Gwangju
29, 오준호, Marketing, 53173, Incheon
30, 최예린, Marketing, 49495, Daegu
31, 윤도현, Finance, 55893, Seoul
32, 박지훈, IT, 67386, Daegu
33, 정우성, Marketing, 67998, Incheon
34, 박지훈, HR, 58403, Gwangju
35, 오준호, Finance, 58121, Daegu
36, 정우성, Marketing, 67303, Gwangju
37, 윤도현, HR, 55966, Seoul
38, 오준호, HR, 45853, Gwangju
39, 이서연, IT, 65530, Gwangju
40, 최예린, Finance, 65153, Busan
41, 윤도현, Finance, 61958, Busan
42, 이서연, IT, 62532, Busan
43, 서지아, IT, 67677, Gwangju</code></pre>
<h3 id="2-2-컨테이너-준비"><strong>2-2. 컨테이너 준비</strong></h3>
<ul>
<li><strong>input</strong>: <code>employee_batch.csv</code> 업로드 완료</li>
<li><strong>output</strong>: 비어있는 상태로 준비</li>
</ul>
<hr>
<h2 id="3-실습-1-레코드-수-조회-data-flow-gettotalcount_df">3. [실습 1] 레코드 수 조회 (Data Flow: <code>GetTotalCount_DF</code>)</h2>
<p>전체 레코드 수를 계산하여 파일로 저장하는 데이터 플로우를 구성합니다,.</p>
<h3 id="3-1-소스-및-집계-설정"><strong>3-1. 소스 및 집계 설정</strong></h3>
<ul>
<li><strong>소스 (<code>employeeBatchData</code>)</strong>: <code>employee_batch.csv</code> 연결.</li>
<li><strong>집계 (<code>aggregateCount</code>)</strong>:<ul>
<li>그룹화 방법: 열별 그룹화 없음.</li>
<li>집계 열: <code>totalRecords</code></li>
<li>식: <code>count(ID)</code>.</li>
</ul>
</li>
</ul>
<h3 id="3-2-싱크-설정-sinktotalcount"><strong>3-2. 싱크 설정 (<code>sinkTotalCount</code>)</strong></h3>
<ul>
<li><strong>싱크 형식</strong>: Delimited Text (인라인).</li>
<li><strong>파일 이름 옵션</strong>: 단일 파일로 출력.</li>
<li><strong>단일 파일로 출력</strong>: <code>total_count.csv</code>.</li>
<li><strong>최적화</strong>: 단일 파티션 설정.</li>
</ul>
<hr>
<h2 id="4-실습-2-배치-및-분기-처리-data-flow-processemployeebatches_df">4. [실습 2] 배치 및 분기 처리 (Data Flow: <code>ProcessEmployeeBatches_DF</code>)</h2>
<p>데이터를 배치 단위로 읽어 부서별로 분기하여 저장합니다,.</p>
<h3 id="4-1-데이터-플로우-매개변수-정의"><strong>4-1. 데이터 플로우 매개변수 정의</strong></h3>
<ul>
<li><code>offset</code> (integer): 시작 지점</li>
<li><code>limit</code> (integer): 읽어올 행 수</li>
</ul>
<h3 id="4-2-소스-설정-employeebatchdata2"><strong>4-2. 소스 설정 (<code>employeeBatchData2</code>)</strong></h3>
<ul>
<li><strong>원본 옵션</strong>:<ul>
<li>건너뛰기 줄 수: <code>$offset</code></li>
<li>행 제한: <code>$limit</code></li>
</ul>
</li>
</ul>
<h3 id="4-3-조건부-분할-splitbydepartment"><strong>4-3. 조건부 분할 (<code>SplitByDepartment</code>)</strong></h3>
<ul>
<li><strong>HR</strong>: <code>Department==&#39;HR&#39;</code></li>
<li><strong>IT</strong>: <code>Department==&#39;IT&#39;</code></li>
<li><strong>OtherEmployees</strong>: (조건을 충족하지 않는 행)</li>
</ul>
<h3 id="4-4-다중-싱크-및-동적-파일명-설정"><strong>4-4. 다중 싱크 및 동적 파일명 설정</strong></h3>
<ul>
<li><strong>SinkHR</strong>: <code>concat(&#39;employee_hr_&#39;,toString($offset),&#39;.csv&#39;)</code></li>
<li><strong>SinkIT</strong>: <code>concat(&#39;employee_it_&#39;,toString($offset),&#39;.csv&#39;)</code></li>
<li><strong>sinkOther</strong>: <code>concat(&#39;employee_others_&#39;,toString($offset),&#39;.csv&#39;)</code></li>
<li><em>모든 싱크는 &#39;단일 파일로 출력&#39; 및 &#39;단일 파티션&#39; 설정을 사용합니다.</em></li>
</ul>
<hr>
<h2 id="5-파이프라인-구성-processemployeebatch_pl">5. 파이프라인 구성 (<code>ProcessEmployeeBatch_PL</code>)</h2>
<h3 id="5-1-매개변수-및-변수-선언"><strong>5-1. 매개변수 및 변수 선언</strong></h3>
<ul>
<li><strong>매개변수</strong>: <code>batchSize</code> (Int, 기본값: 50)</li>
<li><strong>변수</strong>:<ul>
<li><code>batchOffset</code> (Integer, 기본값: 0)</li>
<li><code>totalCount</code> (Integer, 기본값: 0)</li>
<li><code>tempOffset</code> (Integer, 기본값: 0)</li>
</ul>
</li>
</ul>
<h3 id="5-2-전체-레코드-수-조회-및-저장"><strong>5-2. 전체 레코드 수 조회 및 저장</strong></h3>
<ol>
<li><strong>Data Flow 활동 (<code>GetDataFlowTotalCount</code>)</strong>: <code>GetTotalCount_DF</code> 실행.</li>
<li><strong>Set Variable 활동 (<code>SetTotalCount</code>)</strong>: 조회된 행 수를 변수에 저장.<ul>
<li><strong>값 식</strong>:<pre><code>@activity(&#39;GetDataFlowTotalCount&#39;).output.runStatus.metrics.sinkTotalCount.sources.employeeBatchData.rowsRead
```,
</code></pre></li>
</ul>
</li>
</ol>
<h3 id="5-3-until-활동-untilallbatches"><strong>5-3. Until 활동 (<code>UntilAllBatches</code>)</strong></h3>
<ul>
<li><strong>반복 종료 조건</strong>:<pre><code>@greaterOrEquals(variables(&#39;batchOffset&#39;), variables(&#39;totalCount&#39;))
```,
</code></pre></li>
</ul>
<h3 id="5-4-until-내부-액티비티-구성"><strong>5-4. Until 내부 액티비티 구성</strong></h3>
<ol>
<li><strong>Data Flow 활동 (<code>ProcessBatchDataFlow</code>)</strong>: 데이터를 배치 단위로 처리.<ul>
<li><code>offset</code> 파라미터 매핑: <code>variables(&#39;batchOffset&#39;)</code></li>
<li><code>limit</code> 파라미터 매핑: <code>pipeline().parameters.batchSize</code></li>
</ul>
</li>
<li><strong>Set Variable 활동 (<code>SetOffset</code>)</strong>: 현재 오프셋 임시 저장.<ul>
<li><code>tempOffset</code> = <code>variables(&#39;batchOffset&#39;)</code></li>
</ul>
</li>
<li><strong>Set Variable 활동 (<code>SetOffset2</code>)</strong>: 다음 실행을 위한 오프셋 갱신.<ul>
<li><code>batchOffset</code> = <code>@add(variables(&#39;tempOffset&#39;), pipeline().parameters.batchSize)</code></li>
</ul>
</li>
</ol>
<hr>
<h2 id="6-결과-확인-및-마무리">6. 결과 확인 및 마무리,</h2>
<h3 id="6-1-파이프라인-실행-결과-모니터링"><strong>6-1. 파이프라인 실행 결과 모니터링</strong></h3>
<ul>
<li><code>GetDataFlowTotalCount</code>: 성공 (200개 레코드 조회 확인)</li>
<li><code>SetTotalCount</code>: 성공 (totalCount = 200 설정)</li>
<li><code>UntilAllBatches</code>: 성공 (배치 크기 50 기준 총 4회 반복 실행)</li>
<li><code>ProcessBatchDataFlow</code> (각 회차): 성공</li>
</ul>
<h3 id="6-2-최종-생성-파일-목록-output-컨테이너"><strong>6-2. 최종 생성 파일 목록 (<code>output</code> 컨테이너)</strong></h3>
<ul>
<li><code>total_count.csv</code> (200 저장 확인)</li>
<li><code>employee_hr_0.csv</code>, <code>employee_hr_50.csv</code>, <code>employee_hr_100.csv</code>, <code>employee_hr_150.csv</code></li>
<li><code>employee_it_0.csv</code>, <code>employee_it_50.csv</code>, <code>employee_it_100.csv</code>, <code>employee_it_150.csv</code></li>
<li><code>employee_others_0.csv</code>, <code>employee_others_50.csv</code>, <code>employee_others_100.csv</code>, <code>employee_others_150.csv</code></li>
</ul>
<h3 id="6-3-파일-내용-상세-예시"><strong>6-3. 파일 내용 상세 예시</strong>,,</h3>
<ul>
<li><strong>HR 0번 오프셋</strong>: ID 6(서지아), 7(박지훈), 12(강다은) 등 포함.</li>
<li><strong>IT 0번 오프셋</strong>: ID 1(오준호), 10(정우성), 19(한수진) 등 포함.</li>
<li><strong>Others 0번 오프셋</strong>: ID 2(최예린), 3(강다은), 4(정우성) 등 포함.</li>
</ul>
<p>실습 완료 후에는 비용 발생 방지를 위해 <strong>데이터 흐름 디버그 모드</strong>를 반드시 중지하십시오.</p>
<hr>
<hr>
<h1 id="azure-data-factory-join-변환-join-transformation">[Azure Data Factory] Join 변환 (Join Transformation)</h1>
<h2 id="1-join-변환-개요">1. Join 변환 개요</h2>
<p>Join 변환(Join Transformation)은 두 개의 입력 스트림을 지정된 키를 기준으로 병합(Join)하는 데이터 변환 단계입니다.,</p>
<h3 id="구성-요소"><strong>구성 요소</strong></h3>
<ul>
<li><strong>Join 조건(Join Conditions)</strong>: 두 입력 간 조인할 기준 컬럼 설정 (예: ID, Email 등)</li>
<li><strong>Join 유형(Join Type)</strong>: Inner, Left Outer, Right Outer, Full Outer 중 선택</li>
<li><strong>키 충돌 시 처리 방식</strong>: 동일한 이름의 컬럼이 양쪽에 있는 경우 우선순위 설정 가능</li>
</ul>
<h3 id="사용-시나리오"><strong>사용 시나리오</strong></h3>
<ul>
<li>사용자 정보와 주문 정보를 ID 기준으로 병합</li>
<li>로그 정보와 에러 코드 목록을 조인하여 분석</li>
<li>마스터 테이블과 세부 정보 테이블 병합</li>
</ul>
<h3 id="장점"><strong>장점</strong></h3>
<ul>
<li>다양한 조인 방식 제공으로 유연한 병합 구조 설계 가능</li>
<li>조건 기반 병합 처리로 데이터 정합성 확보</li>
<li>하나의 데이터 흐름 안에서 복잡한 관계형 연산 처리 가능</li>
</ul>
<hr>
<h2 id="2-join-유형별-상세-설명">2. Join 유형별 상세 설명</h2>
<h3 id="1-inner-join-id---고객id-기준"><strong>1) Inner Join (ID - 고객ID 기준)</strong></h3>
<p>두 테이블 모두에 조인 키가 존재하는 데이터만 반환합니다.</p>
<table>
<thead>
<tr>
<th align="left">A.ID</th>
<th align="left">A.이름</th>
<th align="left">A.도시</th>
<th align="left">B.주문ID</th>
<th align="left">B.고객ID</th>
<th align="left">B.상품명</th>
</tr>
</thead>
<tbody><tr>
<td align="left">1</td>
<td align="left">김철수</td>
<td align="left">서울</td>
<td align="left">101</td>
<td align="left">1</td>
<td align="left">노트북</td>
</tr>
<tr>
<td align="left">1</td>
<td align="left">김철수</td>
<td align="left">서울</td>
<td align="left">104</td>
<td align="left">1</td>
<td align="left">모니터</td>
</tr>
<tr>
<td align="left">2</td>
<td align="left">이영희</td>
<td align="left">부산</td>
<td align="left">102</td>
<td align="left">2</td>
<td align="left">마우스</td>
</tr>
</tbody></table>
<h3 id="2-full-outer-join"><strong>2) Full Outer Join</strong></h3>
<p>양쪽 테이블의 모든 데이터를 반환하며, 짝이 없는 경우 NULL로 표시됩니다.</p>
<table>
<thead>
<tr>
<th align="left">A.이름</th>
<th align="left">A.ID</th>
<th align="left">A.도시</th>
<th align="left">B.주문ID</th>
<th align="left">B.고객ID</th>
<th align="left">B.상품명</th>
</tr>
</thead>
<tbody><tr>
<td align="left">김철수</td>
<td align="left">1</td>
<td align="left">서울</td>
<td align="left">101</td>
<td align="left">1</td>
<td align="left">노트북</td>
</tr>
<tr>
<td align="left">김철수</td>
<td align="left">1</td>
<td align="left">서울</td>
<td align="left">104</td>
<td align="left">1</td>
<td align="left">모니터</td>
</tr>
<tr>
<td align="left">이영희</td>
<td align="left">2</td>
<td align="left">부산</td>
<td align="left">102</td>
<td align="left">2</td>
<td align="left">마우스</td>
</tr>
<tr>
<td align="left">박민준</td>
<td align="left">3</td>
<td align="left">서울</td>
<td align="left">NULL</td>
<td align="left">NULL</td>
<td align="left">NULL</td>
</tr>
<tr>
<td align="left">최지우</td>
<td align="left">5</td>
<td align="left">인천</td>
<td align="left">NULL</td>
<td align="left">NULL</td>
<td align="left">NULL</td>
</tr>
<tr>
<td align="left">NULL</td>
<td align="left">NULL</td>
<td align="left">NULL</td>
<td align="left">103</td>
<td align="left">4</td>
<td align="left">키보드</td>
</tr>
</tbody></table>
<h3 id="3-left-join"><strong>3) Left Join</strong></h3>
<p>왼쪽 테이블(고객 정보)의 모든 데이터와 오른쪽 테이블(주문 정보)의 매칭되는 데이터를 반환합니다.</p>
<table>
<thead>
<tr>
<th align="left">A.ID</th>
<th align="left">A.이름</th>
<th align="left">A.도시</th>
<th align="left">B.주문ID</th>
<th align="left">B.고객ID</th>
<th align="left">B.상품명</th>
</tr>
</thead>
<tbody><tr>
<td align="left">1</td>
<td align="left">김철수</td>
<td align="left">서울</td>
<td align="left">101</td>
<td align="left">1</td>
<td align="left">노트북</td>
</tr>
<tr>
<td align="left">1</td>
<td align="left">김철수</td>
<td align="left">서울</td>
<td align="left">104</td>
<td align="left">1</td>
<td align="left">모니터</td>
</tr>
<tr>
<td align="left">2</td>
<td align="left">이영희</td>
<td align="left">부산</td>
<td align="left">102</td>
<td align="left">2</td>
<td align="left">마우스</td>
</tr>
<tr>
<td align="left">3</td>
<td align="left">박민준</td>
<td align="left">서울</td>
<td align="left">NULL</td>
<td align="left">NULL</td>
<td align="left">NULL</td>
</tr>
<tr>
<td align="left">5</td>
<td align="left">최지우</td>
<td align="left">인천</td>
<td align="left">NULL</td>
<td align="left">NULL</td>
<td align="left">NULL</td>
</tr>
</tbody></table>
<h3 id="4-right-join"><strong>4) Right Join</strong></h3>
<p>오른쪽 테이블(주문 정보)의 모든 데이터와 왼쪽 테이블(고객 정보)의 매칭되는 데이터를 반환합니다.</p>
<table>
<thead>
<tr>
<th align="left">A.ID</th>
<th align="left">A.이름</th>
<th align="left">A.도시</th>
<th align="left">B.주문ID</th>
<th align="left">B.고객ID</th>
<th align="left">B.상품명</th>
</tr>
</thead>
<tbody><tr>
<td align="left">1</td>
<td align="left">김철수</td>
<td align="left">서울</td>
<td align="left">101</td>
<td align="left">1</td>
<td align="left">노트북</td>
</tr>
<tr>
<td align="left">1</td>
<td align="left">김철수</td>
<td align="left">서울</td>
<td align="left">104</td>
<td align="left">1</td>
<td align="left">모니터</td>
</tr>
<tr>
<td align="left">2</td>
<td align="left">이영희</td>
<td align="left">부산</td>
<td align="left">102</td>
<td align="left">2</td>
<td align="left">마우스</td>
</tr>
<tr>
<td align="left">NULL</td>
<td align="left">NULL</td>
<td align="left">NULL</td>
<td align="left">103</td>
<td align="left">4</td>
<td align="left">키보드</td>
</tr>
</tbody></table>
<hr>
<h2 id="3-실습-준비">3. 실습 준비</h2>
<h3 id="3-1-실습-데이터-준비"><strong>3-1. 실습 데이터 준비</strong></h3>
<ul>
<li><strong>customers.csv</strong><pre><code class="language-text">ID, Name, Address
1, 김철수, 서울
2, 이영희, 부산
3, 박민준, 서울
5, 최지우, 인천</code></pre>
</li>
<li><strong>orders.csv</strong><pre><code class="language-text">OrderID, CustomerID, ProductName
101, 1, 노트북
102, 2, 마우스
103, 4, 키보드
104, 1, 모니터</code></pre>
</li>
</ul>
<h3 id="3-2-컨테이너-준비"><strong>3-2. 컨테이너 준비</strong>,</h3>
<ul>
<li><strong>input</strong>: <code>customers.csv</code>, <code>orders.csv</code> 업로드</li>
<li><strong>output</strong>: 비어있는 상태로 준비</li>
</ul>
<hr>
<h2 id="4-실습-1-기본-join-데이터-플로우-구성">4. [실습 1] 기본 Join 데이터 플로우 구성</h2>
<h3 id="4-1-소스-설정"><strong>4-1. 소스 설정</strong>,</h3>
<ol>
<li><strong>Source 1 (<code>customerData</code>)</strong>: <code>CustomersInput_DS</code> 연결 (customers.csv).</li>
<li><strong>Source 2 (<code>ordersData</code>)</strong>: <code>Orderinput_DS</code> 연결 (orders.csv).<ul>
<li>두 소스 모두 <strong>프로젝션 가져오기</strong>를 통해 ID, OrderID 등을 integer 형식으로 정의합니다.,</li>
</ul>
</li>
</ol>
<h3 id="4-2-join-변환-설정-joincustomerorders"><strong>4-2. Join 변환 설정 (<code>joinCustomerOrders</code>)</strong></h3>
<ul>
<li><strong>왼쪽 스트림</strong>: <code>customerData</code></li>
<li><strong>오른쪽 스트림</strong>: <code>ordersData</code></li>
<li><strong>조인 유형</strong>: <code>내부(Inner)</code></li>
<li><strong>조인 조건</strong>:<ul>
<li>왼쪽: <code>ID</code></li>
<li>오른쪽: <code>CustomerID</code></li>
</ul>
</li>
</ul>
<h3 id="4-3-싱크-설정-sinkjoineddata"><strong>4-3. 싱크 설정 (<code>sinkJoinedData</code>)</strong></h3>
<ul>
<li><strong>데이터 세트</strong>: <code>JoinedOutputCSV_DS</code></li>
<li><strong>설정</strong>: 단일 파티션, <strong>단일 파일로 출력</strong>.</li>
<li><strong>파일명</strong>: <code>joined_customer_orders.csv</code></li>
</ul>
<h3 id="4-4-파이프라인-실행-및-결과-확인"><strong>4-4. 파이프라인 실행 및 결과 확인</strong>,</h3>
<ul>
<li><strong>파이프라인</strong>: <code>JoinCustomerOrders_PL</code></li>
<li><strong>활동</strong>: <code>ExecuteJoin_DF</code> (Join_DF 실행)</li>
<li><strong>결과</strong>: <code>output</code> 컨테이너에 3개의 행이 포함된 파일 생성 확인.,</li>
</ul>
<hr>
<h2 id="5-실습-2-고급-데이터-변환-집계-및-필터">5. [실습 2] 고급 데이터 변환 (집계 및 필터)</h2>
<h3 id="5-1-고객별-주문-건수-집계-aggregatetotalordersbycustomer"><strong>5-1. 고객별 주문 건수 집계 (<code>aggregateTotalOrdersByCustomer</code>)</strong></h3>
<ul>
<li><strong>들어오는 스트림</strong>: <code>joinCustomerOrders</code></li>
<li><strong>그룹화 방법</strong>: <code>ID</code>, <code>Name</code></li>
<li><strong>집계 컬럼</strong>: <code>totalOrders</code></li>
<li><strong>식</strong>:<pre><code class="language-text">count(OrderID)</code></pre>
</li>
<li><strong>결과 싱크</strong>: <code>customer_total_orders.csv</code></li>
</ul>
<h3 id="5-2-서울-주문-데이터-필터링-filterseoulcustomers"><strong>5-2. 서울 주문 데이터 필터링 (<code>filterSeoulCustomers</code>)</strong></h3>
<ul>
<li><strong>들어오는 스트림</strong>: <code>joinCustomerOrders</code></li>
<li><strong>필터 식</strong>:<pre><code class="language-text">Address == &#39;서울&#39;</code></pre>
</li>
<li><strong>결과 싱크</strong>: <code>seoul_customer_orders.csv</code>,</li>
</ul>
<hr>
<h2 id="6-최종-실행-결과-요약">6. 최종 실행 결과 요약</h2>
<ol>
<li><strong>joined_customer_orders.csv</strong>: 조인된 전체 데이터 (3건)</li>
<li><strong>customer_total_orders.csv</strong>:<ul>
<li>이영희: 1건</li>
<li>김철수: 2건</li>
</ul>
</li>
<li><strong>seoul_customer_orders.csv</strong>:<ul>
<li>김철수(서울)의 주문 데이터 2건 (모니터, 노트북)</li>
</ul>
</li>
</ol>
<p><strong>실습 마무리</strong>: 모든 작업이 완료되면 비용 발생 방지를 위해 <strong>데이터 흐름 디버그 모드를 종료</strong>합니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[MicrosoftDataSchool] 59일차 - Azure Data Factory 실습]]></title>
            <link>https://velog.io/@rudin_/MicrosoftDataSchool-59%EC%9D%BC%EC%B0%A8</link>
            <guid>https://velog.io/@rudin_/MicrosoftDataSchool-59%EC%9D%BC%EC%B0%A8</guid>
            <pubDate>Tue, 31 Mar 2026 08:51:29 GMT</pubDate>
            <description><![CDATA[<p>제공해주신 소스 파일 <strong>&quot;03-30-2.03 데이터플로우_v12.pdf&quot;</strong>의 모든 페이지 내용을 생략 없이, 특히 누락되었던 <strong>적용 예시</strong>와 <strong>수식</strong>들을 원문 그대로 포함하여 정리해 드립니다. 여러 줄로 된 부분은 합치지 않고 소스 형태를 유지했습니다.</p>
<hr>
<h1 id="azure-data-factory-데이터-플로우">[Azure Data Factory] 데이터 플로우</h1>
<h2 id="1-매핑-데이터-플로우-개요">1. 매핑 데이터 플로우 개요</h2>
<p>매핑 데이터 플로우는 데이터 변환을 시각적으로 설계할 수 있는 기능으로, 코딩 없이 GUI 기반으로 데이터 흐름을 정의하고 실행할 수 있도록 지원합니다.</p>
<h3 id="특징"><strong>특징</strong></h3>
<ul>
<li>코드 작성 없이 ETL 작업 가능</li>
<li>병렬 처리 기반 대용량 데이터 변환</li>
<li>Spark 기반 백엔드 자동 실행</li>
<li>ADF 파이프라인 내 액티비티로 실행 가능</li>
</ul>
<h3 id="적용-예시"><strong>적용 예시</strong></h3>
<ul>
<li><strong>Raw 데이터 정제 및 변환 작업</strong></li>
<li><strong>데이터 웨어하우스 적재 전 변환</strong></li>
<li><strong>로그 데이터의 전처리 및 분석 준비</strong></li>
</ul>
<hr>
<h2 id="2-주요-구성요소-및-기능">2. 주요 구성요소 및 기능</h2>
<ul>
<li><strong>Source</strong>: 데이터 가져오기 (Blob, SQL 등)</li>
<li><strong>Transformation</strong>: 변환 로직 적용</li>
<li><strong>Sink</strong>: 결과 저장 (Blob, SQL 등)</li>
</ul>
<h3 id="변환-기능"><strong>변환 기능</strong></h3>
<ul>
<li><strong>Select</strong>: 컬럼 선택 및 이름 변경</li>
<li><strong>Filter</strong>: 조건에 따라 행 필터링</li>
<li><strong>Join</strong>: 다른 스트림과 조인</li>
<li><strong>Aggregate</strong>: 그룹화 및 집계</li>
<li><strong>Derived Column</strong>: 계산 컬럼 생성</li>
<li><strong>Sort</strong>: 정렬 수행</li>
<li><strong>Pivot</strong>: 데이터 형태 변환</li>
</ul>
<h3 id="변환-기능-적용-예시"><strong>변환 기능 적용 예시</strong></h3>
<ul>
<li><strong>시험 점수를 과목별 평균으로 집계</strong></li>
<li><strong>이벤트 로그에서 &#39;오류&#39;만 필터링하여 저장</strong></li>
</ul>
<h3 id="흐름-제어-기능"><strong>흐름 제어 기능</strong></h3>
<ul>
<li><strong>Conditional Split</strong>: 조건에 따라 경로 분기</li>
<li><strong>Exists</strong>: 조건 충족 여부 확인</li>
<li><strong>Lookup</strong>: 외부 값 참조</li>
</ul>
<h3 id="흐름-제어-기능-적용-예시"><strong>흐름 제어 기능 적용 예시</strong></h3>
<ul>
<li><strong>고객 나이에 따라 다른 테이블에 저장</strong></li>
<li><strong>기존 고객 여부 확인 후 신규 등록 여부 결정</strong></li>
</ul>
<hr>
<h2 id="3-pipeline-vs-mdf-및-실행-방식">3. Pipeline vs MDF 및 실행 방식</h2>
<h3 id="역할-구분"><strong>역할 구분</strong></h3>
<table>
<thead>
<tr>
<th align="left">구분</th>
<th align="left">파이프라인</th>
<th align="left">데이터 플로우</th>
</tr>
</thead>
<tbody><tr>
<td align="left"><strong>역할</strong></td>
<td align="left">실행 제어 / 전체 흐름 관리</td>
<td align="left">데이터 변환 수행</td>
</tr>
</tbody></table>
<h3 id="데이터-흐름-디버그"><strong>데이터 흐름 디버그</strong></h3>
<ul>
<li><strong>디버그 모드</strong>: 파이프라인 실행 전, 결과를 미리 확인 가능하며 Sample Data 기반으로 변환 결과를 확인합니다.</li>
<li><strong>예시</strong><ul>
<li><strong>변환 로직 개발 중 오류 확인</strong></li>
<li><strong>실시간으로 컬럼 파생 결과 시각화 확인</strong></li>
</ul>
</li>
</ul>
<h3 id="mdf-실행-방식"><strong>MDF 실행 방식</strong></h3>
<ul>
<li>파이프라인 내 <strong>Data Flow Activity</strong>로 호출</li>
<li><strong>Integration Runtime(IR)</strong>을 통해 Spark 클러스터 생성 (클러스터는 자동 생성 및 종료)</li>
<li><strong>예시</strong><ul>
<li><strong>주기적으로 정해진 시간마다 ETL 실행</strong></li>
<li><strong>조건 만족 시 트리거로 Data Flow 자동 실행</strong></li>
</ul>
</li>
</ul>
<hr>
<h2 id="4-실습-준비-데이터-및-환경-세팅">4. [실습 준비] 데이터 및 환경 세팅</h2>
<h3 id="실습-데이터-student-performance-in-exams"><strong>실습 데이터: Student Performance in Exams</strong></h3>
<ul>
<li>Kaggle 데이터 활용 (StudentsPerformance.csv)</li>
<li><strong>컬럼 구성</strong><ul>
<li>gender, race/ethnicity, parental level of education, lunch, test preparation course, math score, reading score, writing score</li>
</ul>
</li>
</ul>
<h3 id="환경-준비"><strong>환경 준비</strong></h3>
<ol>
<li><strong>실습 컨테이너 준비</strong>: <code>input</code>, <code>output</code> 컨테이너의 기존 데이터 삭제 후 <code>StudentsPerformance.csv</code> 업로드.</li>
<li><strong>링크드 서비스 준비</strong>: <code>BlobStorage1</code> (Azure Blob Storage) 생성 및 연결 테스트 성공 확인.</li>
<li><strong>데이터세트 생성</strong>:<ul>
<li><strong>입력(<code>StudentsInputDS</code>)</strong>: <code>input</code> 컨테이너, <code>StudentsPerformance.csv</code> 참조, 첫 번째 행을 머리글로 설정, 스키마 가져오기(연결/저장소에서).</li>
<li><strong>출력(<code>StudentsOutputDS</code>)</strong>: <code>output</code> 컨테이너 참조, 첫 번째 행을 머리글로 설정.</li>
</ul>
</li>
</ol>
<hr>
<h2 id="5-실습-1-데이터-정제-및-등급-부여">5. [실습 1] 데이터 정제 및 등급 부여</h2>
<h3 id="5-1-매핑-데이터-플로우-생성-및-소스-추가"><strong>5-1. 매핑 데이터 플로우 생성 및 소스 추가</strong></h3>
<p><img src="https://velog.velcdn.com/images/rudin_/post/424943bb-fb41-4ab6-9361-24d8b9d734d2/image.png" alt=""></p>
<ul>
<li>이름: <code>StudentsCleanFlow</code></li>
<li><strong>데이터 흐름 디버그</strong> 켜기: AutoResolveIntegrationRuntime, Small, TTL 1시간 설정.
<img src="https://velog.velcdn.com/images/rudin_/post/0a046bcf-b9f5-42c0-9a7b-4344527a2295/image.png" alt=""></li>
<li><strong>소스(<code>Students</code>)</strong>: <code>StudentsInputDS</code> 연결 후 <strong>프로젝션 가져오기</strong>를 통해 데이터 형식 검색.</li>
</ul>
<p><img src="https://velog.velcdn.com/images/rudin_/post/90005cf3-2f09-40c9-a4b8-b9a5ac8da761/image.png" alt="">
<img src="https://velog.velcdn.com/images/rudin_/post/9bfe2178-11cb-4f37-8b96-646ac5e73aa1/image.png" alt=""></p>
<p>스키마 드리프트: 원본이 아닌 별도의 테이블을 만들어 사용할 때 어느정도의 오류는 ok 하는 기능</p>
<h3 id="5-2-평균-점수-계산-derived-column---averagescore"><strong>5-2. 평균 점수 계산 (Derived Column - <code>AverageScore</code>)</strong></h3>
<p>컴포넌트 옵션에는 항상 출력, 입력 스트림이 존재함
<img src="https://velog.velcdn.com/images/rudin_/post/eb928c48-650b-496c-870f-328c12767796/image.png" alt=""></p>
<ul>
<li><code>+</code> 누른 후 <code>파생 열</code> 추가</li>
<li>출력 스트림 이름: <code>AverageScore</code></li>
<li>열 이름: <code>avg_score</code></li>
<li><strong>식</strong>:<pre><code>(toInteger({math score})+toInteger({reading score})+toInteger({writing score}))/3</code></pre></li>
</ul>
<h3 id="5-3-등급-부여-derived-column---gradelevel"><strong>5-3. 등급 부여 (Derived Column - <code>GradeLevel</code>)</strong></h3>
<p><img src="https://velog.velcdn.com/images/rudin_/post/3983c961-8f06-4d96-938d-47fa1ddc7ce0/image.png" alt=""></p>
<ul>
<li>출력 스트림 이름: <code>GradeLevel</code></li>
<li>열 이름: <code>grade</code></li>
<li><strong>식</strong>:<pre><code>iif(avg_score&gt;=90, &#39;A&#39;, 
iif(avg_score&gt;=80, &#39;B&#39;,
iif(avg_score&gt;=70, &#39;C&#39;, 
iif(avg_score&gt;=60, &#39;D&#39;, &#39;F&#39;))))</code></pre></li>
</ul>
<h3 id="5-4-싱크-설정-studentcleansink"><strong>5-4. 싱크 설정 (StudentCleanSink)</strong></h3>
<p><img src="https://velog.velcdn.com/images/rudin_/post/53a4855b-a628-49e1-bf7d-437bbdc73bce/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/04c79fd8-256b-4116-899f-0ddfca725dd4/image.png" alt=""></p>
<ul>
<li><p>데이터 세트: <code>StudentsOutputDS</code></p>
</li>
<li><p><strong>설정</strong>: 파티션 설정을 &#39;단일 파티션&#39;으로 변경 후 <strong>단일 파일로 출력</strong> 선택. (기본적으로는 분산 저장을 지원)
<img src="https://velog.velcdn.com/images/rudin_/post/01be62fb-d5df-4bcf-ba13-fa3dc3bf7432/image.png" alt=""></p>
</li>
<li><p><strong>파일명</strong>: <code>Students_clean.csv</code></p>
</li>
</ul>
<h3 id="5-5-파이프라인-실행-및-결과-확인"><strong>5-5. 파이프라인 실행 및 결과 확인</strong></h3>
<p><img src="https://velog.velcdn.com/images/rudin_/post/6917f9bd-6b36-46f3-8d41-d071429bb400/image.png" alt=""></p>
<ol>
<li><strong>파이프라인 생성</strong>: <code>StudentsPerformancePipeline</code>에 <code>Data Flow</code> 활동(<code>RunStudentsCleanFlow</code>) 추가.</li>
<li><strong>실행</strong>: <strong>모두 게시</strong> 후 디버그 실행.</li>
<li><strong>결과 확인</strong>: <code>output</code> 컨테이너의 <code>Students_clean.csv</code>에서 평균 점수와 등급(A~F) 확인.</li>
<li><strong>모니터링</strong>: 각 단계별(Source, AverageScore, GradeLevel) 처리 시간 및 기록된 행(1,000행) 진단 정보 확인.
<img src="https://velog.velcdn.com/images/rudin_/post/616326f0-987b-4e7f-b0b6-b7e682af97d4/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/1faa6794-a76a-468f-b0c3-949ebfe67b28/image.png" alt=""></li>
</ol>
<hr>
<h2 id="6-실습-2-그룹별-집계-실습-advanced">6. [실습 2] 그룹별 집계 실습 (Advanced)</h2>
<h3 id="6-1-데이터-플로우-편집-및-분기"><strong>6-1. 데이터 플로우 편집 및 분기</strong></h3>
<ul>
<li><code>AverageScore</code> 단계에서 <strong>새 분기(New Branch)</strong> 추가.
<img src="https://velog.velcdn.com/images/rudin_/post/6b59c4ea-c2ef-4933-a9e6-ac323af03255/image.png" alt=""></li>
</ul>
<h3 id="6-2-데이터-집계-aggregate---averagebygroup"><strong>6-2. 데이터 집계 (Aggregate - <code>AverageByGroup</code>)</strong></h3>
<p><img src="https://velog.velcdn.com/images/rudin_/post/834494b8-d7a4-4141-b216-3e16b3c1c2d1/image.png" alt=""></p>
<ul>
<li><p>출력 스트림 이름: <code>AverageByGroup</code></p>
</li>
<li><p><strong>그룹화 방법</strong>: <code>race/ethnicity</code> 열 기준.
<img src="https://velog.velcdn.com/images/rudin_/post/f67e37d9-6771-4238-97a8-10554eefa9ce/image.png" alt=""></p>
</li>
<li><p><strong>집계 컬럼</strong>: <code>avg_score_by_group</code></p>
</li>
<li><p><strong>식</strong>: <strong><code>avg(avg_score)</code></strong>
<img src="https://velog.velcdn.com/images/rudin_/post/7ec39679-9980-4f7c-a323-d48d1fb56783/image.png" alt=""></p>
</li>
</ul>
<h3 id="6-3-새로운-싱크-추가-studentgroupcleansink"><strong>6-3. 새로운 싱크 추가 (StudentGroupCleanSink)</strong></h3>
<ul>
<li>데이터 세트: <code>StudentsOutputDS</code></li>
<li><strong>설정</strong>: 단일 파티션, <strong>단일 파일로 출력</strong>.</li>
<li><strong>파일명</strong>: <code>avg_score_by_group.csv</code></li>
</ul>
<p><img src="https://velog.velcdn.com/images/rudin_/post/7c6f5589-1030-4fa6-9e6c-cda5f4bc520f/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/f8af6ea2-9505-489a-b0e2-6467e0de9e53/image.png" alt=""></p>
<h3 id="6-4-최종-실행-및-확인"><strong>6-4. 최종 실행 및 확인</strong></h3>
<ul>
<li>변경 사항 게시 후 파이프라인 재실행.
<img src="https://velog.velcdn.com/images/rudin_/post/3414771a-6517-43d4-bee8-429762fec9b1/image.png" alt=""></li>
</ul>
<hr>
<h2 id="7-마무리">7. 마무리</h2>
<ul>
<li>실습 완료 후 비용 발생 방지를 위해 <strong>데이터 흐름 디버그 모드</strong>를 반드시 중지합니다.</li>
</ul>
<hr>
<hr>
<h1 id="azure-data-factory-통합-런타임-integration-runtime">[Azure Data Factory] 통합 런타임 (Integration Runtime)</h1>
<h2 id="1-통합-런타임-개요">1. 통합 런타임 개요</h2>
<p>통합 런타임(Integration Runtime)은 Azure Data Factory에서 데이터 이동, 변환, 실행 등의 기능을 수행하기 위한 컴퓨팅 인프라 역할을 수행합니다.</p>
<p><strong>Integration Runtime의 핵심</strong></p>
<ul>
<li>ADF의 모든 데이터 처리 작업은 통합 런타임(IR)을 통해 실행됩니다.</li>
<li>IR은 다양한 네트워크 환경 및 데이터 소스/싱크와의 연결을 지원합니다.</li>
<li>작업 목적과 데이터 위치에 따라 적절한 IR 유형을 선택해야 합니다.</li>
</ul>
<table>
<thead>
<tr>
<th align="left">IR의 수행 기능</th>
<th align="left">IR의 수행 내용</th>
</tr>
</thead>
<tbody><tr>
<td align="left">활동 실행</td>
<td align="left">Copy, 외부 리소스 실행 등 여러 가지 활동의 지원</td>
</tr>
<tr>
<td align="left">데이터 이동</td>
<td align="left">클라우드 ↔ 온프레미스 간 안전한 복사 수행</td>
</tr>
<tr>
<td align="left">데이터 흐름 실행</td>
<td align="left">Mapping Data Flow 등 고급 데이터 변환 지원<br>* Azure IR 전용</td>
</tr>
</tbody></table>
<p><strong>IR은 &quot;ADF의 실행 엔진&quot;</strong>이며, 어떤 네트워크에서 데이터를 가져오고 어디로 보낼 것인지에 따라 적절한 유형을 선택하는 것이 필수적입니다.</p>
<hr>
<h2 id="2-통합-런타임의-유형">2. 통합 런타임의 유형</h2>
<p>데이터 팩토리에서는 다양한 환경에 맞게 세 가지 유형의 통합 런타임을 제공합니다. 각 유형은 사용자의 네트워크 구조, 데이터 위치, 기존 시스템 여부에 따라 선택해야 합니다.</p>
<h3 id="통합-런타임의-세-가지-유형"><strong>통합 런타임의 세 가지 유형</strong></h3>
<table>
<thead>
<tr>
<th align="left">통합 런타임 유형</th>
<th align="left">주요 목적 및 특성</th>
</tr>
</thead>
<tbody><tr>
<td align="left">Azure Integration Runtime</td>
<td align="left">Azure 내부 서비스 간 데이터 이동 및 변환, 완전 관리형, 서버리스 컴퓨팅 환경</td>
</tr>
<tr>
<td align="left">Self-hosted IR (SHIR)</td>
<td align="left">온프레미스 또는 VNet 내 리소스와 연결, 사용자 컴퓨터/VM에 런타임 설치 필요</td>
</tr>
<tr>
<td align="left">Azure-SSIS IR</td>
<td align="left">SSIS 패키지를 Azure에서 실행하기 위한 전용 런타임, SQL Managed Instance 필요</td>
</tr>
</tbody></table>
<h3 id="통합-런타임-유형별-특성"><strong>통합 런타임 유형별 특성</strong></h3>
<table>
<thead>
<tr>
<th align="left">구분</th>
<th align="left">Azure IR</th>
<th align="left">SHIR</th>
<th align="left">Azure-SSIS IR</th>
</tr>
</thead>
<tbody><tr>
<td align="left"><strong>관리 주체</strong></td>
<td align="left">Microsoft</td>
<td align="left">사용자 직접 관리</td>
<td align="left">Microsoft</td>
</tr>
<tr>
<td align="left"><strong>설치 필요 여부</strong></td>
<td align="left">없음</td>
<td align="left">설치 필요</td>
<td align="left">설치 필요</td>
</tr>
<tr>
<td align="left"><strong>데이터 흐름 지원 여부</strong></td>
<td align="left">지원</td>
<td align="left">미지원</td>
<td align="left">미지원</td>
</tr>
<tr>
<td align="left"><strong>사용 위치</strong></td>
<td align="left">Azure 간 서비스</td>
<td align="left">온프레미스, VNet 리소스</td>
<td align="left">SSIS 기반 데이터 마이그레이션</td>
</tr>
</tbody></table>
<p>IR 선택은 데이터의 출발지/도착지, 네트워크 형태(공용/사설), 기존 시스템 여부(SSIS 등) 등을 고려하여 결정합니다.</p>
<hr>
<h2 id="3-self-hosted-통합-런타임-shir">3. Self-Hosted 통합 런타임 (SHIR)</h2>
<p>Self-Hosted 통합 런타임은 Azure Data Factory에서 사설 네트워크나 온프레미스 환경의 데이터에 접근할 수 있도록 해주는 소프트웨어 컴포넌트입니다.</p>
<h3 id="shir의-필요성"><strong>SHIR의 필요성</strong></h3>
<ul>
<li>가상 사설망(VNet) 내의 데이터 및 리소스 접근 필요 시</li>
<li>온프레미스의 데이터베이스와의 연동 시</li>
<li>전용 드라이버 및 커넥터를 사용해야 하는 특수 데이터 환경</li>
</ul>
<h3 id="네트워크별-ir-지원-현황"><strong>네트워크별 IR 지원 현황</strong></h3>
<table>
<thead>
<tr>
<th align="left">유형</th>
<th align="left">Azure Cloud</th>
<th align="left">Private Network</th>
</tr>
</thead>
<tbody><tr>
<td align="left">Azure IR</td>
<td align="left">Activity 실행, 데이터 이동, 데이터 플로우</td>
<td align="left">지원 안함</td>
</tr>
<tr>
<td align="left">Self-hosted IR</td>
<td align="left">Activity 실행, 데이터 이동</td>
<td align="left">Activity 실행, 데이터 이동</td>
</tr>
<tr>
<td align="left">Azure-SSIS IR</td>
<td align="left">SSIS 패키지 실행 (제한적)</td>
<td align="left">SSIS 패키지 실행</td>
</tr>
</tbody></table>
<ul>
<li>온프레미스 DB(예: Oracle, MySQL, MSSQL 등)와 연동이 필요한 경우에는 SHIR가 반드시 필요합니다.</li>
<li>SHIR은 로컬 머신 또는 VM에 설치되며, Azure Portal에서 연결 상태의 모니터링도 가능합니다. (보통 데이터 이관용이다. 데이터플로우 지원 X)</li>
</ul>
<hr>
<h2 id="4-실습-shir-환경-구축-및-데이터-복사">4. [실습] SHIR 환경 구축 및 데이터 복사</h2>
<p><img src="https://velog.velcdn.com/images/rudin_/post/c29a54a7-e361-4e71-b75b-47215d9e46b4/image.png" alt=""></p>
<h3 id="step-1-실습-환경-구성-인프라"><strong>Step 1: 실습 환경 구성 (인프라)</strong></h3>
<p>실습을 위해 다음과 같은 리소스를 순차적으로 생성합니다.</p>
<ol>
<li><strong>Azure Virtual Network 생성</strong>: <code>vnet</code> (주소 공간: <code>10.0.0.0/16</code>).
<img src="https://velog.velcdn.com/images/rudin_/post/05334c4c-3c3c-4ea4-9d9e-dafdcd7444dc/image.png" alt=""></li>
<li><strong>Subnet 생성</strong>: <code>subnet</code> (주소 범위: <code>10.0.1.0/24</code>)
<img src="https://velog.velcdn.com/images/rudin_/post/fe952193-b620-4a79-a7d6-211e8dde01d8/image.png" alt=""></li>
<li><strong>SQL Server용 VM 생성</strong>: <code>SQL-vm</code>
<img src="https://velog.velcdn.com/images/rudin_/post/1b011ee5-f9e9-4486-ba4e-fe09ef9ea362/image.png" alt=""></li>
</ol>
<ul>
<li><p>이미지: SQL Server 2019 Developer on Windows Server 2019
<img src="https://velog.velcdn.com/images/rudin_/post/2474508d-eaf6-4a4f-b0fa-ceb92b96a4e9/image.png" alt="">
모든 이미지 보기 선택
<img src="https://velog.velcdn.com/images/rudin_/post/8ba3025a-e67e-4a28-bc48-496b64eef670/image.png" alt="">
만들기 후 2세대 선택 </p>
</li>
<li><p>크기: Standard_B2ms (2 vcpu, 8 GiB 메모리)</p>
</li>
</ul>
<p><img src="https://velog.velcdn.com/images/rudin_/post/14ab300e-0098-4838-ac43-e8ca8525e34f/image.png" alt=""></p>
<ul>
<li>서브넷 설정</li>
<li>인바운드 포트: RDP(3389) 허용</li>
</ul>
<p><img src="https://velog.velcdn.com/images/rudin_/post/7477fb0b-9f3e-4269-bb85-7b0124ed1eba/image.png" alt=""></p>
<ul>
<li>SQL 연결: 프라이빗(가상 네트워크 내), 포트 1433
<img src="https://velog.velcdn.com/images/rudin_/post/3e92ca5f-7a9a-4f31-9b53-8b516a21acd2/image.png" alt=""></li>
</ul>
<ol start="4">
<li><strong>SHIR용 VM 생성</strong>: <code>SHIR-vm</code></li>
</ol>
<ul>
<li><p>이미지: Windows Server 2019 Datacenter
<img src="https://velog.velcdn.com/images/rudin_/post/c0a858d6-3a67-45b6-9faa-e8d24d049212/image.png" alt=""></p>
</li>
<li><p>디스크 표준으로 설정
<img src="https://velog.velcdn.com/images/rudin_/post/f58e6e31-f778-42f7-b1c1-10fe4e21a0d1/image.png" alt=""></p>
</li>
<li><p>네트워크: <code>vnet</code> / <code>subnet</code> 연결
<img src="https://velog.velcdn.com/images/rudin_/post/02d12443-684a-49bb-9544-f9d00fd26ebe/image.png" alt=""></p>
</li>
</ul>
<h3 id="step-2-shir-설치-및-등록"><strong>Step 2: SHIR 설치 및 등록</strong></h3>
<p><img src="https://velog.velcdn.com/images/rudin_/post/a49eb2d8-9ba6-4839-8475-a848cd243d21/image.png" alt="">
들어가서 <code>RDP 파일 다운로드</code> </p>
<ol>
<li><p><strong>SHIR-vm 접속</strong>: RDP를 통해 가상 머신에 접속합니다.</p>
</li>
<li><p><strong>런타임 다운로드</strong>: VM 내부 브라우저에서 &#39;Microsoft Integration Runtime&#39;을 검색하여 설치 파일을 다운로드합니다.
<img src="https://velog.velcdn.com/images/rudin_/post/0ff9de55-e4b7-4bad-991a-65c95f20b29f/image.png" alt=""></p>
</li>
<li><p><strong>ADF에서 SHIR 생성</strong>: ADF Studio의 [관리] &gt; [통합 런타임]에서 &#39;자체 호스팅&#39; 유형으로 <code>shir</code>를 생성하고 <strong>인증 키</strong>를 복사합니다.
<img src="https://velog.velcdn.com/images/rudin_/post/28e8c5b0-f8e3-4459-8f34-58b5f6c2aad2/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/287b2850-0a82-4cbd-ba17-1950e78bf676/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/0fe910b3-55d7-45b8-82cc-75e49085234d/image.png" alt=""></p>
</li>
<li><p><strong>노드 등록</strong>: VM에 설치된 Configuration Manager를 실행하고 복사한 인증 키를 입력하여 등록을 완료합니다.
<img src="https://velog.velcdn.com/images/rudin_/post/471dd67b-9e79-40eb-8242-186e8999ea9f/image.png" alt="">
ADF 관리탭에서도 표시된다.
<img src="https://velog.velcdn.com/images/rudin_/post/a6dc7c98-b493-43e6-bcf9-9d8321ecb3fb/image.png" alt=""></p>
</li>
</ol>
<h3 id="step-3-데이터-준비-원본-및-싱크"><strong>Step 3: 데이터 준비 (원본 및 싱크)</strong></h3>
<ol>
<li><strong>원본 데이터</strong>: Blob Storage의 <code>input</code> 컨테이너에 <code>StudentsPerformance.csv</code> 업로드.</li>
<li><strong>싱크 테이블 생성</strong>: <code>SQL-vm</code> 내 SSMS를 실행하여 데이터베이스와 테이블을 생성합니다.(동일하게 SQL-vm의 rdp 파일 설치 후 실행)
<img src="https://velog.velcdn.com/images/rudin_/post/647cd328-dd00-4409-893b-0586208c0bb3/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/7be1786b-d061-4eaa-b28e-31167f028d39/image.png" alt="">
Trust server certificate 체크
<img src="https://velog.velcdn.com/images/rudin_/post/617bef81-33c2-4906-9ae5-e5918389edb1/image.png" alt=""></li>
</ol>
<pre><code class="language-sql">    CREATE DATABASE StudentsDB;
    GO
    USE StudentsDB;

    CREATE TABLE StudentsPerformance (
        gender NVARCHAR(10),
        race_ethnicity NVARCHAR(20),
        parental_level_of_education NVARCHAR(50),
        lunch NVARCHAR(20),
        test_preparation_course NVARCHAR(20),
        math_score INT,
        reading_score INT,
        writing_score INT
    );</code></pre>
<h3 id="step-4-링크드-서비스-및-데이터세트-구성"><strong>Step 4: 링크드 서비스 및 데이터세트 구성</strong></h3>
<ol>
<li><p><strong>링크드 서비스 (원본)</strong>: <code>BlobStorage1</code> (AutoResolveIntegrationRuntime 사용).
<img src="https://velog.velcdn.com/images/rudin_/post/7f978c6a-4f4a-4754-bd59-26ee2a6e25aa/image.png" alt=""></p>
</li>
<li><p><strong>링크드 서비스 (싱크)</strong>: <code>vnetSqlServer1</code></p>
<ul>
<li>통합 런타임: <code>shir</code> 선택</li>
<li>서버 이름: SQL-vm의 프라이빗 IP (<code>10.0.1.4</code>)</li>
<li>데이터베이스: <code>StudentsDB</code></li>
</ul>
</li>
</ol>
<p><img src="https://velog.velcdn.com/images/rudin_/post/52b93b63-7087-49f9-a41d-e95ef2edae2b/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/74458228-205f-41a7-896f-e049080f3223/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/16c0760b-375b-434c-9fad-c73f13e36ad2/image.png" alt=""></p>
<ol start="3">
<li><strong>데이터세트</strong>:<ul>
<li>원본: <code>StudentsInputDS</code> (DelimitedText)</li>
<li>싱크: <code>vnetStudentsDS1</code> (SQL Server 테이블)
<img src="https://velog.velcdn.com/images/rudin_/post/873b5e3e-0e9a-469e-a253-576fc5438631/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/7d4e9445-1cbb-484d-a1c4-5721fa101212/image.png" alt=""></li>
</ul>
</li>
</ol>
<h3 id="step-5-파이프라인-생성-및-실행"><strong>Step 5: 파이프라인 생성 및 실행</strong></h3>
<p><img src="https://velog.velcdn.com/images/rudin_/post/bdc5ebaa-aea8-45d9-b7e6-06d751988a29/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/2bfb9a5d-e595-4ef7-8b8c-f916e4f9c95e/image.png" alt=""></p>
<ol>
<li><strong>활동</strong>: <strong>Copy Data</strong> 활동(<code>Copy Blob to SQL</code>) 추가.</li>
<li><strong>매핑(Mapping)</strong>: 원본 CSV 컬럼과 SQL 테이블 컬럼을 매핑합니다 (gender, math_score 등). <code>스키마 가져오기</code> 후 매핑</li>
</ol>
<p><img src="https://velog.velcdn.com/images/rudin_/post/40917c50-739f-436c-bccb-b9c9ef257bbb/image.png" alt=""></p>
<ol start="3">
<li><strong>실행</strong>: 파이프라인을 <strong>게시</strong>한 후 <strong>디버그</strong>를 실행하여 &#39;성공&#39; 상태를 확인합니다.</li>
</ol>
<p><img src="https://velog.velcdn.com/images/rudin_/post/d0561eac-11e1-438f-8074-1da29850207c/image.png" alt=""></p>
<ol start="4">
<li><strong>결과 확인</strong>: SQL-vm에서 <code>SELECT TOP 50 * FROM StudentsPerformance;</code> 쿼리를 통해 데이터 복사 여부를 검증합니다.
<img src="https://velog.velcdn.com/images/rudin_/post/9e0c44fc-6bbd-4265-a03a-dae90a9ed5b5/image.png" alt=""></li>
</ol>
<hr>
<h2 id="5-실습-마무리-및-리소스-정리">5. 실습 마무리 및 리소스 정리</h2>
<p>실습 완료 후 비용 발생을 방지하기 위해 다음 리소스들을 반드시 삭제해야 합니다.</p>
<ul>
<li><strong>가상 머신</strong>: <code>SHIR-vm</code>, <code>SQL-vm</code></li>
<li><strong>관련 리소스</strong>: 공용 IP 주소, 네트워크 인터페이스(NIC), 디스크, 네트워크 보안 그룹(NSG)</li>
<li><strong>네트워크</strong>: <code>vnet-demo</code> (가상 네트워크)</li>
</ul>
<p><strong>주의</strong>: SHIR 노드가 설치된 VM을 삭제하면 ADF Studio에서 해당 통합 런타임 상태가 &#39;사용할 수 없음&#39;으로 표시됩니다. 필요하지 않은 경우 ADF 내의 통합 런타임과 연결된 서비스도 함께 삭제하여 정리합니다.</p>
<ol>
<li>SQL-VM</li>
<li>VM 2개</li>
<li>NIC</li>
<li>IP, NSG, Disk</li>
<li>V-NET 
순으로 삭제</li>
</ol>
<p>이후 연결된 서비스에서 vnetSqlServer1의 통합 런타임 연결을 AutoResolveIntegrationRuntime으로 변경하여 적용 후 데이터세트의 vnetStudentsDS1, 파이프라인의 CopyStudentPerformance 삭제, 게시 후 연결된서비스 마저 삭제</p>
<hr>
<hr>
<h1 id="azure-data-factory-lookup-및-foreach-활동을-활용한-동적-데이터-처리">[Azure Data Factory] Lookup 및 ForEach 활동을 활용한 동적 데이터 처리</h1>
<h2 id="1-개요">1. 개요</h2>
<h3 id="lookup-activity"><strong>Lookup Activity</strong></h3>
<p>Lookup 활동을 통해 다양한 데이터 소스에서 목록이나 데이터를 조회하여, 파이프라인의 동적인 데이터 처리 흐름을 설계할 수 있습니다. Lookup 활동은 반복 작업의 시작점이 되는 정보를 제공합니다.</p>
<ul>
<li><strong>Lookup 활동 개요</strong><ul>
<li>외부 데이터 소스(파일, 테이블 등)에서 데이터를 읽어오는 활동</li>
<li>파이프라인 내에서 동적으로 사용할 데이터 목록을 조회</li>
<li>다양한 데이터 소스와 호환됨</li>
</ul>
</li>
<li><strong>Lookup 활동의 주요 기능</strong><ul>
<li>파일 또는 테이블의 내용을 읽어와 결과를 파이프라인 변수로 저장</li>
<li>주로 목록(리스트) 형태의 데이터를 반환</li>
<li>결과를 다음 활동(예: ForEach)에 전달하여 반복 작업의 입력값으로 활용</li>
</ul>
</li>
<li><strong>활용 예시</strong><ul>
<li>데이터베이스 테이블에서 처리 대상 파일 목록 조회</li>
<li>Blob Storage 내 폴더의 파일 리스트 추출</li>
<li>JSON/CSV 파일에서 데이터 로드</li>
</ul>
</li>
</ul>
<h3 id="foreach-activity"><strong>ForEach Activity</strong></h3>
<p>ForEach 활동을 사용하면, 조회된 데이터 목록을 활용하여 각 항목별로 동일하거나 다양한 작업을 반복 실행할 수 있습니다. ForEach 활동을 통해 대량 데이터의 일괄 처리와 자동화가 가능합니다.</p>
<ul>
<li><strong>ForEach 활동 개요</strong><ul>
<li>반복 작업(Loop)을 수행하는 활동</li>
<li>입력받은 목록(배열, 리스트)의 각 항목에 대해 지정된 하위 활동 집합을 실행</li>
<li>Lookup 등 다른 활동의 결과를 받아 반복 처리에 활용</li>
</ul>
</li>
<li><strong>ForEach 활동의 주요 기능</strong><ul>
<li>배열(리스트) 형태의 입력 데이터에 대해 작업 반복</li>
<li>각 항목마다 복사, 변환, 로깅 등 다양한 하위 활동 실행 가능</li>
<li>병렬 또는 순차적(직렬) 실행 방식 선택 가능</li>
</ul>
</li>
<li><strong>활용 예시</strong><ul>
<li>여러 파일을 반복적으로 복사(Copy)</li>
<li>여러 테이블에 데이터 일괄 적재</li>
<li>개별 레코드/오브젝트마다 별도 처리 로직 실행</li>
</ul>
</li>
</ul>
<hr>
<h2 id="2-lookup---foreach-절차-및-예시">2. Lookup - ForEach 절차 및 예시</h2>
<h3 id="실행-절차"><strong>실행 절차</strong></h3>
<ol>
<li><strong>Lookup 활동 실행</strong>: 외부 데이터 소스(예: 데이터베이스, Blob Storage 등)에서 처리 대상 목록을 읽어옴</li>
<li><strong>결과값 전달</strong>: Lookup의 결과(리스트)를 ForEach 활동에 입력</li>
<li><strong>ForEach 반복 실행</strong>: 리스트의 각 항목마다 Copy, 변환, 알림 등 지정된 하위 작업을 순차적/병렬로 실행</li>
<li><strong>각 항목별 결과 처리</strong>: 성공/실패 로깅, 후속 작업 연결 등</li>
</ol>
<h3 id="활용-예시"><strong>활용 예시</strong></h3>
<ul>
<li>여러 파일을 일괄 데이터베이스에 적재</li>
<li>테이블 행별로 데이터 처리 반복</li>
<li>여러 시스템에 동일 처리 반복</li>
</ul>
<hr>
<h2 id="3-실습-준비">3. 실습 준비</h2>
<p><img src="https://velog.velcdn.com/images/rudin_/post/c40a5c30-0614-42c3-b10d-4129c9c4b285/image.png" alt=""></p>
<h3 id="3-1-리소스-구성"><strong>3-1. 리소스 구성</strong></h3>
<ul>
<li>리소스 그룹</li>
<li>데이터 팩터리(V2)</li>
<li>Logic app</li>
<li>스토리지 계정</li>
<li>SQL 데이터베이스</li>
<li>SQL Server</li>
</ul>
<h3 id="3-2-실습-데이터"><strong>3-2. 실습 데이터</strong></h3>
<ol>
<li><strong>Red Wine Quality</strong>: <code>winequality-red.csv</code> (또는 <code>wine.csv</code>)</li>
<li><strong>Titanic Dataset</strong>: <code>titanic.csv</code></li>
<li><strong>Adult Census Income</strong>: <code>adult.csv</code></li>
</ol>
<h3 id="3-3-목적지sink-컨테이너-준비"><strong>3-3. 목적지(Sink) 컨테이너 준비</strong></h3>
<ul>
<li>스토리지 계정 내에 <code>output</code> 컨테이너를 준비합니다.</li>
</ul>
<h3 id="3-4-소스-데이터베이스-테이블-생성"><strong>3-4. 소스 데이터베이스 테이블 생성</strong></h3>
<p>SQL 데이터베이스에서 다음 쿼리를 실행하여 테이블을 생성합니다.</p>
<p><strong>wine 테이블 생성</strong></p>
<pre><code class="language-sql">CREATE TABLE wine (
    fixed_acidity FLOAT,
    volatile_acidity FLOAT,
    citric_acid FLOAT,
    residual_sugar FLOAT,
    chlorides FLOAT,
    free_sulfur_dioxide FLOAT,
    total_sulfur_dioxide FLOAT,
    density FLOAT,
    pH FLOAT,
    sulphates FLOAT,
    alcohol FLOAT,
    quality INT
);</code></pre>
<p><strong>titanic 테이블 생성</strong></p>
<pre><code class="language-sql">CREATE TABLE titanic (
    PassengerId INT,
    Survived INT,
    Pclass INT,
    Name NVARCHAR(100),
    Sex NVARCHAR(10),
    Age FLOAT,
    SibSp INT,
    Parch INT,
    Ticket NVARCHAR(20),
    Fare FLOAT,
    Cabin NVARCHAR(20),
    Embarked NVARCHAR(5)
);</code></pre>
<p><strong>adult 테이블 생성</strong></p>
<pre><code class="language-sql">CREATE TABLE adult (
    age INT,
    workclass NVARCHAR(20),
    fnlwgt INT,
    education NVARCHAR(20),
    education_num INT,
    marital_status NVARCHAR(30),
    occupation NVARCHAR(20),
    relationship NVARCHAR(20),
    race NVARCHAR(20),
    sex NVARCHAR(10),
    capital_gain INT,
    capital_loss INT,
    hours_per_week INT,
    native_country NVARCHAR(30),
    income NVARCHAR(10)
);</code></pre>
<p>이후 컨테이너에 csv파일을 올리고, 이전 매개변수 실습에서 사용했던 파이프라인을 이용하여 해당 테이블에 csv의 데이터들을 넣고 input 스토리지에서 파일 삭제</p>
<hr>
<h2 id="4-실습-단계">4. 실습 단계</h2>
<p><img src="https://velog.velcdn.com/images/rudin_/post/e5e3caa1-17cf-4cb6-9972-4e1da5ce4ac5/image.png" alt=""></p>
<h3 id="4-1-링크드-서비스-및-데이터세트-설정"><strong>4-1. 링크드 서비스 및 데이터세트 설정</strong></h3>
<p><strong>연결된 서비스(Linked Service)</strong></p>
<ul>
<li><strong>BlobStorage1</strong>: Azure Blob Storage 연결</li>
<li><strong>outputSQL</strong>: Azure SQL Database 연결</li>
</ul>
<p><strong>데이터세트(Dataset)</strong></p>
<ul>
<li><strong>TableListDS</strong>: SQL DB의 테이블 목록 조회용
<img src="https://velog.velcdn.com/images/rudin_/post/403ffd27-3c67-4e80-a198-f001c6d6fb48/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/19f22431-cc62-4c08-ac07-196ec68eb4f3/image.png" alt=""></li>
</ul>
<ul>
<li><p><strong>SourceTableDS</strong>: 매개변수(<code>schemaName</code>, <code>tableName</code>)를 사용하여 동적으로 테이블을 지정함</p>
<ul>
<li><code>schemaName</code> 식: <code>@dataset().schemaName</code></li>
<li><code>tableName</code> 식: <code>@dataset().tableName</code></li>
</ul>
<p><img src="https://velog.velcdn.com/images/rudin_/post/46714c85-001a-4ef1-acc5-30d4f282eb69/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/13c14cbf-da3b-4cc3-b34d-8caac7bd4b8d/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/fd59d369-c244-4ee6-aa2a-7da1a8281804/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/6a822919-faf3-4eb2-830b-7d6c1eaefc06/image.png" alt=""></p>
</li>
<li><p><strong>SinkCsvDS</strong>: 매개변수(<code>fileName</code>)를 사용하여 동적으로 출력 파일명을 지정함</p>
<ul>
<li><code>fileName</code> 식: <code>@dataset().fileName</code>
<img src="https://velog.velcdn.com/images/rudin_/post/134c2ede-8dc9-450e-ad14-a8fc885c2792/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/e97ceecd-2833-4d6c-a162-daabc3d2d6ab/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/60f6b19a-b224-4900-9b6a-7746e5abe96f/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/b21dae19-9083-4b0f-8420-d456556bf0eb/image.png" alt=""></li>
</ul>
</li>
</ul>
<h3 id="4-2-파이프라인-구성-lookup-활동"><strong>4-2. 파이프라인 구성: Lookup 활동</strong></h3>
<p><img src="https://velog.velcdn.com/images/rudin_/post/fe6dc1f4-48b9-465d-be16-4da921874370/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/cf280a8a-3316-45f5-b10c-20ba1d54e5a0/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/cfedd5c4-79eb-42a0-8cba-59499204ab19/image.png" alt=""></p>
<ul>
<li>활동 이름: <code>ListTables</code></li>
<li>원본 데이터세트: <code>TableListDS</code></li>
<li><strong>쿼리 실행</strong>:<pre><code class="language-sql">SELECT TABLE_SCHEMA, TABLE_NAME
FROM INFORMATION_SCHEMA.TABLES
WHERE TABLE_TYPE = &#39;BASE TABLE&#39;
AND TABLE_SCHEMA = &#39;dbo&#39;</code></pre>
</li>
</ul>
<h3 id="4-3-파이프라인-구성-foreach-활동"><strong>4-3. 파이프라인 구성: ForEach 활동</strong></h3>
<p><img src="https://velog.velcdn.com/images/rudin_/post/e15fc362-47e8-47b1-9dfa-65b8608160e2/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/d11d4a98-b2f0-4aa3-8a06-3c58ef42d663/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/7c1820e3-3cb9-4da0-923e-6acfff0e1a34/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/e003a099-53c7-4de9-b9f1-3caf8f3532ab/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/6a886ae7-aa84-4abb-aa80-0b0158bbd2da/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/36d2b8e7-9994-4d88-8e12-5b7d0f7605ef/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/81a5a94f-a9de-403d-b80f-4762bb99cb6e/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/d6cd14dc-f038-45a2-913a-c2d340fe2aed/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/da7522b9-3f26-496d-b2e9-7c40561399ef/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/f4e401fb-372b-47bc-accd-c6b08179fe30/image.png" alt=""></p>
<ul>
<li>활동 이름: <code>ForEachTable</code></li>
<li><strong>항목(Items) 설정</strong>:<ul>
<li>식: <code>@activity(&#39;ListTables&#39;).output.value</code></li>
</ul>
</li>
<li><strong>하위 활동(Copy Data)</strong>:<ul>
<li>이름: <code>ExportTable</code></li>
<li><strong>원본(Source) 설정</strong>: <code>SourceTableDS</code><ul>
<li><code>tableName</code>: <code>@item().TABLE_NAME</code></li>
<li><code>schemaName</code>: <code>@item().TABLE_SCHEMA</code></li>
</ul>
</li>
<li><strong>싱크(Sink) 설정</strong>: <code>SinkCsvDS</code><ul>
<li><code>fileName</code>: <code>@concat(item().TABLE_NAME, &#39;.csv&#39;)</code></li>
</ul>
</li>
</ul>
</li>
</ul>
<hr>
<h2 id="5-결과-확인">5. 결과 확인</h2>
<ol>
<li><p><strong>파이프라인 실행</strong>: &#39;모두 게시&#39; 후 파이프라인을 실행합니다.
<img src="https://velog.velcdn.com/images/rudin_/post/b6b91470-682d-449e-8c14-8a95b06c2db9/image.png" alt=""></p>
</li>
<li><p><strong>모니터링</strong>: <code>ListTables</code> 성공 후 <code>ForEachTable</code> 내에서 각 테이블(<code>adult</code>, <code>titanic</code>, <code>wine</code>)에 대한 <code>ExportTable</code> 활동이 성공했는지 확인합니다.</p>
</li>
<li><p><strong>출력 확인</strong>: 스토리지의 <code>output</code> 컨테이너에 <code>adult.csv</code>, <code>titanic.csv</code>, <code>wine.csv</code> 파일이 생성되었는지 확인합니다.
<img src="https://velog.velcdn.com/images/rudin_/post/486d701f-8336-4bf1-8e81-a2d62554fcb3/image.png" alt=""></p>
</li>
</ol>
<hr>
<hr>
<h1 id="azure-data-factory-이메일-알림-email-notification">[Azure Data Factory] 이메일 알림 (Email Notification)</h1>
<h2 id="1-azure-data-factory-이메일-알림-개요">1. Azure Data Factory 이메일 알림 개요</h2>
<p>다양한 상황에서 Azure Data Factory의 데이터 처리 상태를 사용자에게 알릴 필요가 있습니다. 이메일 알림은 이런 요구를 충족시키기 위한 유용한 수단입니다.</p>
<h3 id="알림의-필요성"><strong>알림의 필요성</strong></h3>
<ul>
<li>데이터 파이프라인이 자동으로 수행되기 때문에, 처리 상태에 대한 가시성이 부족할 수 있음.</li>
<li>운영자가 실시간으로 상태를 확인하기 어렵기 때문에, 자동 알림을 통해 시스템 신뢰도를 높일 수 있음.</li>
<li>알림을 통해 문제 발생 시 즉각적인 조치가 가능하며, 운영 효율성 향상에 기여.</li>
</ul>
<h3 id="알림-사용-사례"><strong>알림 사용 사례</strong></h3>
<table>
<thead>
<tr>
<th align="left">분류</th>
<th align="left">사례</th>
<th align="left">설명</th>
</tr>
</thead>
<tbody><tr>
<td align="left">개발/운영</td>
<td align="left">ETL 오류 또는 성능 지연 발생</td>
<td align="left">운영팀에 오류 발생 사실 실시간 전달</td>
</tr>
<tr>
<td align="left">보안/감사</td>
<td align="left">민감한 데이터 이동 완료 시</td>
<td align="left">감사 로그 목적의 알림 전송</td>
</tr>
<tr>
<td align="left">데이터 분석</td>
<td align="left">외부 시스템에서 데이터 수집 시작 시</td>
<td align="left">자동화된 분석 시작 시점 감지 가능</td>
</tr>
<tr>
<td align="left">보고서 갱신</td>
<td align="left">데이터 변환 후 Power BI 리프레시 완료</td>
<td align="left">사용자에게 최신 리포트 반영 시점 안내</td>
</tr>
</tbody></table>
<hr>
<h2 id="2-이메일-알림-구현-방식">2. 이메일 알림 구현 방식</h2>
<p>Azure Data Factory는 이메일 알림을 위한 두 가지 구현 방식을 고려할 수 있습니다. 각 방식은 사용자의 목적에 따라 선택 가능합니다.</p>
<table>
<thead>
<tr>
<th align="left">옵션</th>
<th align="left">설명</th>
<th align="left">장점</th>
<th align="left">권장 시나리오</th>
</tr>
</thead>
<tbody><tr>
<td align="left"><strong>Azure Monitor Alerts</strong></td>
<td align="left">Azure에서 기본 제공하는 모니터링 및 경보 기능을 통해 이메일 알림을 전송</td>
<td align="left">설정 간편, 추가 비용 없음</td>
<td align="left">단순 성공/실패 모니터링, 장애 감지 시 알림</td>
</tr>
<tr>
<td align="left"><strong>Web Activity + Logic Apps</strong></td>
<td align="left">ADF의 Web Activity에서 Logic Apps 호출 후, HTTP 트리거 기반의 이메일 전송</td>
<td align="left">유연한 구성, 세부 커스터마이징 가능, HTML 이메일 가능</td>
<td align="left">알림 내용 및 대상 사용자 지정, 포맷 설정 필요 시</td>
</tr>
</tbody></table>
<ul>
<li><strong>Azure Monitor Alerts</strong>는 사전 정의된 메트릭 조건 기반으로 동작하며, 주로 파이프라인 실패나 시간 초과 같은 이벤트 감지에 적합합니다.</li>
<li><strong>Logic Apps</strong>는 REST API 호출을 기반으로 하므로, 이메일 제목, 수신자, 본문 내용을 동적으로 설정할 수 있습니다. 특히 Power BI 리포트 생성 완료 후 알림, 맞춤형 템플릿 발송 등 세밀한 알림 제어가 필요할 경우 매우 유용합니다.</li>
</ul>
<hr>
<h2 id="3-실습-1---azure-monitor를-이용한-알림">3. 실습 1 - Azure Monitor를 이용한 알림</h2>
<h3 id="3-1-azure-monitor-개요"><strong>3-1. Azure Monitor 개요</strong></h3>
<p>Azure Monitor는 Azure에서 기본 제공되는 종합 모니터링 및 알림 솔루션으로, 다양한 리소스 상태를 감시하고 이벤트 발생 시 알림을 보냅니다.</p>
<ul>
<li><strong>주요 기능</strong>: 클라우드 및 온프레미스 리소스 모니터링 가능, 응용/VM/DB/API 지원, Metric 기반 조건 설정 및 Alerts 트리거, Event Hub 및 Logic Apps 연동 가능,.</li>
<li><strong>구성 요소</strong>: 특정 Activity/Pipeline 상태 감지 → 메트릭 조건 충족 시 알림 생성 → 이메일, SMS, Logic Apps 채널로 전달.</li>
</ul>
<h3 id="3-2-azure-monitor-알림의-단점"><strong>3-2. Azure Monitor 알림의 단점</strong></h3>
<ul>
<li>이메일 포맷 변경 등은 제한적임.</li>
<li>고급 설정은 복잡함.</li>
<li>알림 전달까지 시간 지연이 있을 수 있음.</li>
<li>이메일의 가독성이 떨어질 수 있음.</li>
</ul>
<h3 id="3-3-실습-단계"><strong>3-3. 실습 단계</strong></h3>
<ol>
<li><p><strong>파이프라인 준비</strong>: <code>Lab - Email Alert</code> 내에 <code>AzureMonitorAlert</code> 파이프라인을 생성하고 실패를 유도하는 복사 활동을 구성합니다</p>
</li>
<li><p><strong>경고 규칙 생성</strong>: ADF 모니터링 탭의 [경고 및 메트릭]에서 [새로운 경고 규칙]을 클릭합니다.<img src="https://velog.velcdn.com/images/rudin_/post/fb288782-055c-4ba4-95c9-412cbd4ea7f5/image.png" alt=""></p>
</li>
<li><p><strong>조건 구성</strong>:</p>
<ul>
<li>메트릭: <code>Failed pipeline runs metrics</code>.</li>
<li>경고 논리 조건: &#39;보다 큼&#39;, 임계값 개수 &#39;0&#39;.</li>
<li>차원: <code>FailureType</code> (UserError, SystemError, BadGateway 선택).</li>
<li>평가 기준: 기간 &#39;지난 1분 동안&#39;, 빈도 &#39;1분마다&#39;.</li>
</ul>
</li>
<li><p><strong>알림 및 작업 그룹 구성</strong>:</p>
<ul>
<li>작업 그룹 이름: <code>Test group</code>.</li>
<li>알림 유형: &#39;이메일&#39; 선택 후 수신 메일 주소 입력</li>
</ul>
</li>
<li><p><strong>테스트 및 확인</strong>: 파이프라인 실행 후 실패가 발생하면 설정한 메일로 알림이 오는지 확인합니다. (디버그하면 안됨, 트리거 사용)</p>
</li>
</ol>
<p><strong>[수신 이메일 예시 - Activated]</strong></p>
<blockquote>
<p>Your Azure Monitor alert was triggered
Rule: copy-pipeline failure alert
Metric: PipelineFailedRuns
Value: 1
,</p>
</blockquote>
<p><strong>[수신 이메일 예시 - Deactivated]</strong></p>
<blockquote>
<p>Your Azure Monitor alert was resolved
Alert deactivated because one of the following conditions is no longer true.</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/rudin_/post/7756de97-4a7a-49ba-9e95-48937b5d51a1/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/4007ddcd-e4d8-443d-96c9-762f377a2c08/image.png" alt=""></p>
<hr>
<h2 id="4-실습-2---web-activity와-logic-apps를-이용한-알림">4. 실습 2 - Web Activity와 Logic Apps를 이용한 알림</h2>
<h3 id="4-1-절차-및-장점"><strong>4-1. 절차 및 장점</strong></h3>
<ul>
<li><strong>절차</strong>: ADF 내 Web Activity에서 Logic Apps HTTP 트리거 호출 → Logic Apps에서 이메일 전송 및 응답 처리.</li>
<li><strong>장점</strong>: 내용 및 포맷 자유 구성 가능, 조건에 따른 분기 처리, HTML 이메일 구현 가능.</li>
</ul>
<h3 id="4-2-실습-준비"><strong>4-2. 실습 준비</strong></h3>
<ul>
<li><strong>복사 파이프라인</strong>: <code>Copy iris</code>, <code>Copy penguins</code> 활동을 포함하는 <code>LogicAppAlert</code> 파이프라인을 생성합니다,.</li>
<li><strong>데이터</strong>: <code>iris.csv</code>, <code>penguins.csv</code> 파일을 스토리지에 준비합니다,.</li>
</ul>
<p><img src="https://velog.velcdn.com/images/rudin_/post/7339f4dc-594a-45c9-8ca8-587b837504e2/image.png" alt=""></p>
<h3 id="4-3-logic-app-생성-및-트리거-구성"><strong>4-3. Logic App 생성 및 트리거 구성</strong></h3>
<ol>
<li><strong>리소스 생성</strong>: Azure Portal에서 &#39;논리 앱(Logic App)&#39;을 검색하여 &#39;소비(Consumption)&#39; 플랜으로 생성합니다,,.</li>
<li><strong>HTTP 트리거 추가</strong>: 논리 앱 디자이너에서 &#39;When an HTTP request is received&#39; 트리거를 추가합니다.</li>
<li><strong>JSON 스키마 생성</strong>: 아래 샘플 페이로드를 사용하여 스키마를 생성합니다<pre><code>{
&quot;type&quot;: &quot;object&quot;,
&quot;properties&quot;: {
&quot;title&quot;: {
  &quot;type&quot;: &quot;string&quot;
},
&quot;message&quot;: {
  &quot;type&quot;: &quot;string&quot;
},
&quot;AdfName&quot;: {
  &quot;type&quot;: &quot;string&quot;
},
&quot;pipelineName&quot;: {
  &quot;type&quot;: &quot;string&quot;
},
&quot;pipelineRunID&quot;: {
  &quot;type&quot;: &quot;string&quot;
},
&quot;time&quot;: {
  &quot;type&quot;: &quot;string&quot;
}
}
}</code></pre><img src="https://velog.velcdn.com/images/rudin_/post/68508830-a418-4487-8ccd-8bbab0c08528/image.png" alt=""></li>
</ol>
<h3 id="4-4-adf-web-activity-설정"><strong>4-4. ADF Web Activity 설정</strong></h3>
<ol>
<li><strong>활동 추가</strong>: <code>Send OK Email</code> 이름의 Web 활동을 추가합니다.</li>
<li><strong>설정</strong>:<ul>
<li>URL: Logic App에서 생성된 HTTP POST URL.</li>
<li>메서드: <code>POST</code>.</li>
<li>본문(Body): 아래 동적 콘텐츠 식을 입력합니다<pre><code class="language-json">{
&quot;title&quot;: &quot;파이프라인 실행 완료 알림&quot;,
&quot;message&quot;: &quot;데이터 복사 파이프라인이 성공적으로 완료되었습니다.&quot;,
&quot;AdfName&quot;: &quot;@{pipeline().DataFactory}&quot;,
&quot;pipelineName&quot;: &quot;@{pipeline().Pipeline}&quot;,
&quot;pipelineRunID&quot;: &quot;@{pipeline().RunId}&quot;,
&quot;time&quot;: &quot;@{utcNow()}&quot;
}</code></pre>
<img src="https://velog.velcdn.com/images/rudin_/post/39c3a5ac-6ef4-4a58-ab2b-06f7fba44966/image.png" alt=""></li>
</ul>
</li>
</ol>
<p><img src="https://velog.velcdn.com/images/rudin_/post/077a1bf4-5621-4a58-b8e4-04a1e10c7fad/image.png" alt=""></p>
<h3 id="4-5-logic-app-이메일-동작-추가"><strong>4-5. Logic App 이메일 동작 추가</strong></h3>
<ol>
<li><strong>동작 추가</strong>: &#39;Outlook.com&#39; 커넥터의 &#39;메일 보내기(V2)&#39;를 선택합니다,.</li>
<li><strong>본문 구성</strong>: HTTP 트리거에서 받은 동적 콘텐츠를 사용하여 HTML 포맷으로 본문을 작성합니다.<ul>
<li>제목: <code>title</code></li>
<li>본문 예시:
```text</li>
<li>데이터 팩토리 이름: AdfName</li>
<li>파이프라인 이름: pipelineName</li>
<li>파이프라인 Run ID: pipelineRunID</li>
<li>실행 완료 시각: time<pre><code>
</code></pre></li>
</ul>
</li>
</ol>
<h3 id="4-6-결과-확인"><strong>4-6. 결과 확인</strong></h3>
<ul>
<li>성공 시 이메일 제목: <code>파이프라인 실행 완료 알림</code>.</li>
<li>본문 내용에 실제 ADF 리소스 이름과 실행 시각 등이 포함되어 전송됩니다.</li>
</ul>
<hr>
<h2 id="5-파이프라인-실패-알림-추가-심화">5. 파이프라인 실패 알림 추가 (심화)</h2>
<p>성공 알림뿐만 아니라 실패 시에도 알림을 보내기 위해 파이프라인을 확장합니다.</p>
<ol>
<li><strong>실패용 활동 추가</strong>: <code>send NOK Email</code> 이름의 Web 활동을 생성하고 실패 경로(빨간색 선)로 연결합니다.</li>
<li><strong>본문 설정</strong>:<pre><code class="language-json">{
  &quot;title&quot;: &quot;파이프라인 실행 실패 알림&quot;,
  &quot;message&quot;: &quot;데이터 복사 파이프라인의 실행이 실패하였습니다.&quot;,
  &quot;AdfName&quot;: &quot;@{pipeline().DataFactory}&quot;,
  &quot;pipelineName&quot;: &quot;@{pipeline().Pipeline}&quot;,
  &quot;pipelineRunID&quot;: &quot;@{pipeline().RunId}&quot;,
  &quot;time&quot;: &quot;@{utcNow()}&quot;
}</code></pre>
</li>
</ol>
<h3 id="master-파이프라인-활용"><strong>Master 파이프라인 활용</strong></h3>
<p>실제 운영 시에는 개별 파이프라인에 매번 알림을 넣기보다 <strong>MasterAlertPipeline</strong>을 구축하여 호출된 파이프라인의 에러 메시지를 전달하는 방식이 효율적입니다,.
<img src="https://velog.velcdn.com/images/rudin_/post/298c1338-db3f-4706-93ac-2b5718a67e67/image.png" alt=""></p>
<ul>
<li><strong>실패 메시지 전달 식</strong>:<pre><code class="language-json">{
  &quot;title&quot;: &quot;파이프라인 실행 실패 알림&quot;,
  &quot;message&quot;: &quot;@{activity(&#39;Execute Pipeline1&#39;).error.message}&quot;,
  &quot;AdfName&quot;: &quot;@{pipeline().DataFactory}&quot;,
  &quot;pipelineName&quot;: &quot;@{pipeline().Pipeline}&quot;,
  &quot;pipelineRunID&quot;: &quot;@{pipeline().RunId}&quot;,
  &quot;time&quot;: &quot;@{utcNow()}&quot;
}</code></pre>
<img src="https://velog.velcdn.com/images/rudin_/post/f583d191-7a56-4f80-9d1a-b59b85efae9c/image.png" alt=""></li>
</ul>
<h3 id="최종-테스트-결과-실패-시"><strong>최종 테스트 결과 (실패 시)</strong></h3>
<ul>
<li>원본 파일을 삭제한 후 실행하면 다음과 같은 에러 메시지가 포함된 이메일이 수신됩니다.
<img src="https://velog.velcdn.com/images/rudin_/post/0410f9de-cabb-45e8-a155-b5b12800fc20/image.png" alt=""></li>
</ul>
<hr>
<h2 id="6-실습-마무리">6. 실습 마무리</h2>
<p>실습이 끝나면 불필요한 비용 발생을 방지하기 위해 생성한 모든 리소스(Logic App, API Connection, Action Group 등)와 리소스 그룹을 삭제합니다,.</p>
<hr>
<hr>
<h1 id="azure-data-factory-필터-및-정렬-변환-filter-sort-transformation">[Azure Data Factory] 필터 및 정렬 변환 (Filter-Sort Transformation)</h1>
<h2 id="1-개요-1">1. 개요</h2>
<p>데이터 변환 과정에서 특정 조건에 맞는 데이터를 선별하거나, 분석 효율을 높이기 위해 데이터를 정렬하는 과정은 필수적입니다. Azure Data Factory의 매핑 데이터 플로우(MDF)는 이를 시각적으로 구성할 수 있는 기능을 제공합니다.</p>
<h3 id="필터-변환-filter-transformation"><strong>필터 변환 (Filter Transformation)</strong></h3>
<p>필터 변환은 소스 데이터에서 지정한 조건을 만족하는 행(row)만 선택해 통과시키는 변환 단계입니다.</p>
<ul>
<li><strong>구성 요소</strong>:<ul>
<li><strong>조건식(Condition Expression)</strong>: 특정 컬럼에 대한 비교 연산자(=, &lt;, &gt;, &lt;=, &gt;=, != 등) 및 논리 연산자(and, or)를 사용합니다.<ul>
<li>예) <code>Score &gt; 80</code>, <code>Category == &#39;A&#39; and Region != &#39;Seoul&#39;</code></li>
</ul>
</li>
<li><strong>미리보기(Data Preview)</strong>: 조건식 적용 후 결과를 즉시 확인할 수 있습니다.</li>
</ul>
</li>
<li><strong>장점</strong>:<ul>
<li>불필요한 데이터를 미리 제거하여 후속 처리 성능을 향상시킵니다.</li>
<li>비즈니스 로직에 따른 데이터 선별을 시각적으로 구성 가능합니다.</li>
</ul>
</li>
<li><strong>사용 시나리오</strong>:<ul>
<li>특정 기준 점수 이상인 학생만 추출</li>
<li>거래 상태가 &#39;완료(Completed)&#39;인 주문만 처리</li>
<li>결측치(null)나 이상치(outlier)를 제거</li>
</ul>
</li>
</ul>
<h3 id="정렬-변환-sort-transformation"><strong>정렬 변환 (Sort Transformation)</strong></h3>
<p>정렬 변환은 입력된 데이터 세트를 하나 이상의 컬럼을 기준으로 오름차순 또는 내림차순으로 정렬하는 변환 단계입니다.</p>
<ul>
<li><strong>구성 요소</strong>:<ul>
<li><strong>정렬 키(Sort Key)</strong>: 정렬 기준이 되는 컬럼을 선택합니다. 다중 컬럼 지정 시 우선순위에 따라 차례대로 정렬됩니다.</li>
<li><strong>정렬 순서(Order)</strong>: 오름차순(Ascending) 또는 내림차순(Descending)을 선택합니다.</li>
<li><strong>미리보기(Data Preview)</strong>: 정렬 결과를 즉시 확인할 수 있습니다.</li>
</ul>
</li>
<li><strong>장점</strong>:<ul>
<li>사용자 요구에 따른 출력 순서를 제어합니다.</li>
<li>데이터 집계나 순위 분석 전에 데이터 순서를 명확히 정의합니다.</li>
</ul>
</li>
<li><strong>사용 시나리오</strong>:<ul>
<li>시험 점수를 높은 순서대로 정렬해 상위 10명 출력</li>
<li>거래 일자 기준으로 과거 → 최신 순으로 정렬</li>
<li>고객 등급과 가입 일자를 복합 기준으로 정렬</li>
</ul>
</li>
</ul>
<hr>
<h2 id="2-실습-준비">2. 실습 준비</h2>
<p><img src="https://velog.velcdn.com/images/rudin_/post/5db2db62-2016-4ba6-8493-ebc43cf1967e/image.png" alt=""></p>
<h3 id="실습-구성"><strong>실습 구성</strong></h3>
<ul>
<li><strong>SQL 데이터베이스</strong>: <code>dbo.adult</code> 테이블 사용 (age, workclass, income 등 포함).</li>
<li><strong>Data Flow</strong>: <ol>
<li><strong>Filter</strong>: <code>income == &quot;&gt;50K&quot;</code> (고소득자 데이터 선별).</li>
<li><strong>Sort</strong>: 나이(age) 기준으로 오름차순 정렬.</li>
</ol>
</li>
<li><strong>스토리지 계정</strong>: <code>adult_over50K_sorted.csv</code> 파일로 저장.</li>
</ul>
<h3 id="데이터-및-테이블-준비"><strong>데이터 및 테이블 준비</strong></h3>
<ol>
<li><strong>실습 데이터</strong>: Adult Census Income (Kaggle 데이터).</li>
<li><strong>테이블 생성 쿼리</strong> (필요 시 실행):<pre><code class="language-sql">CREATE TABLE adult (
    age INT,
    workclass NVARCHAR(20),
    fnlwgt INT,
    education NVARCHAR(20),
    education_num INT,
    marital_status NVARCHAR(30),
    occupation NVARCHAR(20),
    relationship NVARCHAR(20),
    race NVARCHAR(20),
    sex NVARCHAR(10),
    capital_gain INT,
    capital_loss INT,
    hours_per_week INT,
    native_country NVARCHAR(30),
    income NVARCHAR(10)
);</code></pre>
</li>
<li><strong>데이터 확인</strong>: <code>select count(*) from [dbo].[adult]</code> 실행 시 32,561건 확인.</li>
<li><strong>목적지 컨테이너</strong>: 스토리지 계정 내 <code>output</code> 컨테이너 준비.</li>
</ol>
<h3 id="링크드-서비스-및-데이터세트-구성"><strong>링크드 서비스 및 데이터세트 구성</strong></h3>
<ul>
<li><strong>링크드 서비스</strong>: <code>outputSQL</code> (Azure SQL DB), <code>BlobStorage1</code> (Azure Blob Storage).</li>
<li><strong>소스 데이터세트 (<code>AdultSqlInput_DS</code>)</strong>: SQL DB의 <code>dbo.adult</code> 테이블 연결.</li>
<li><strong>싱크 데이터세트 (<code>AdultCsvOutput_DS</code>)</strong>: Blob Storage의 <code>output</code> 컨테이너 연결.</li>
</ul>
<hr>
<h2 id="3-실습-1-필터-및-정렬-변환-구성">3. [실습 1] 필터 및 정렬 변환 구성</h2>
<p><img src="https://velog.velcdn.com/images/rudin_/post/a63f2054-7b0b-4680-b63e-24c36c237a4d/image.png" alt=""></p>
<h3 id="3-1-데이터-플로우-생성-및-소스-추가"><strong>3-1. 데이터 플로우 생성 및 소스 추가</strong></h3>
<ol>
<li><strong>매핑 데이터 플로우 생성</strong>: 이름 <code>FilterSort_DF</code>.</li>
<li><strong>데이터 흐름 디버그 켜기</strong>: Small 크기, 1시간 TTL 설정.</li>
<li><strong>소스 추가</strong>:<ul>
<li>출력 스트림 이름: <code>adult</code>.</li>
<li>데이터 세트: <code>AdultSqlInput_DS</code>.</li>
<li><strong>프로젝션</strong>: age(integer), workclass(string), income(string) 등 형식 확인.</li>
</ul>
</li>
</ol>
<h3 id="3-2-필터-변환-설정-filterhighincome"><strong>3-2. 필터 변환 설정 (<code>FilterHighIncome</code>)</strong></h3>
<ol>
<li><strong>필터 활동 추가</strong>: 소스 뒤에 <code>Filter</code> 활동 연결.</li>
<li><strong>속성 설정</strong>:<ul>
<li>출력 스트림 이름: <code>FilterHighIncome</code>.</li>
<li>들어오는 스트림: <code>adult</code>.</li>
</ul>
</li>
<li><strong>필터 식 입력</strong> (식 작성기 활용):<pre><code>income == &quot;&gt;50K&quot;</code></pre></li>
<li><strong>미리보기</strong>: 데이터가 <code>&gt;50K</code>인 행만 남는지 확인.</li>
</ol>
<h3 id="3-3-정렬-변환-설정-sortbyage"><strong>3-3. 정렬 변환 설정 (<code>SortByAge</code>)</strong></h3>
<ol>
<li><strong>정렬 활동 추가</strong>: 필터 활동 뒤에 <code>Sort</code> 활동 연결.</li>
<li><strong>속성 설정</strong>:<ul>
<li>출력 스트림 이름: <code>SortByAge</code>.</li>
<li>들어오는 스트림: <code>FilterHighIncome</code>.</li>
</ul>
</li>
<li><strong>정렬 조건</strong>:<ul>
<li>열: <code>age</code>.</li>
<li>순서: <code>오름차순 (Ascending)</code>.</li>
</ul>
</li>
<li><strong>미리보기</strong>: 나이가 적은 순서(22, 23, 24...)로 정렬되는지 확인.</li>
</ol>
<h3 id="3-4-싱크-설정-및-파이프라인-실행"><strong>3-4. 싱크 설정 및 파이프라인 실행</strong></h3>
<ol>
<li><strong>싱크 추가</strong>: <code>AdultHighIncomeSortedSink</code> 추가 및 <code>AdultCsvOutput_DS</code> 연결.</li>
<li><strong>설정</strong>: &#39;단일 파티션&#39; 지정 후 <strong>단일 파일로 출력</strong> 선택, 파일명 <code>adult_highincome_sorted.csv</code> 입력.</li>
<li><strong>파이프라인 생성</strong>: <code>FilterSortAdult_PL</code> 생성 후 데이터 플로우 활동 추가.</li>
<li><strong>실행 및 확인</strong>: 파이프라인 성공 후 <code>output</code> 컨테이너에서 결과 파일 확인.</li>
</ol>
<hr>
<h2 id="4-실습-2-필터-및-정렬-매개변수화-advanced">4. [실습 2] 필터 및 정렬 매개변수화 (Advanced)</h2>
<p><img src="https://velog.velcdn.com/images/rudin_/post/18e76e7e-9d3a-4737-8035-ad65cc76a348/image.png" alt=""></p>
<p>고정된 값이 아닌, 실행 시점에 입력받은 값으로 필터링하고 정렬할 수 있도록 구성을 변경합니다.</p>
<h3 id="4-1-데이터-플로우-매개변수-정의"><strong>4-1. 데이터 플로우 매개변수 정의</strong></h3>
<p>매핑 데이터 플로우의 [매개 변수] 탭에서 다음 항목을 추가합니다.
<img src="https://velog.velcdn.com/images/rudin_/post/3f9be95e-95d0-4702-9f6b-6ed6b1437833/image.png" alt=""></p>
<ul>
<li><code>filterColumn</code> (string): 필터링할 컬럼 이름.</li>
<li><code>filterValue</code> (string): 필터링할 기준 값.</li>
<li><code>sortColumn</code> (string): 정렬할 컬럼 이름.</li>
</ul>
<h3 id="4-2-매개변수-기반-식-작성"><strong>4-2. 매개변수 기반 식 작성</strong></h3>
<ol>
<li><strong>필터 식 수정 (<code>FilterAdult</code>)</strong>:
컬럼의 데이터 형식을 고려하여 동적으로 비교하는 식을 작성합니다.<pre><code>case(
    type(byName($filterColumn))==&#39;Integer&#39;, toInteger(byName($filterColumn)) == toInteger($filterValue),
    toString(byName($filterColumn)) == $filterValue
)</code></pre></li>
<li><strong>정렬 식 수정 (<code>SortByParam</code>)</strong>:
정렬 조건의 열을 식 작성기에서 매개변수로 지정합니다.<pre><code>byName($sortColumn)</code></pre></li>
</ol>
<h3 id="4-3-파이프라인-매개변수-연결"><strong>4-3. 파이프라인 매개변수 연결</strong></h3>
<p><img src="https://velog.velcdn.com/images/rudin_/post/c42ad0a6-f976-4a3b-8cd9-3c88ecc2d770/image.png" alt=""></p>
<ol>
<li><strong>파이프라인 매개변수 생성</strong>: <code>pFilterColumn</code>, <code>pFilterValue</code>, <code>pSortColumn</code> 생성.</li>
<li><strong>매핑</strong>: 파이프라인 활동의 [매개 변수] 탭에서 데이터 플로우 매개변수와 파이프라인 매개변수를 연결합니다.<ul>
<li><code>filterColumn</code> = <code>@pipeline().parameters.pFilterColumn</code></li>
<li><code>filterValue</code> = <code>@pipeline().parameters.pFilterValue</code></li>
<li><code>sortColumn</code> = <code>@pipeline().parameters.pSortColumn</code></li>
</ul>
</li>
</ol>
<h3 id="4-4-실행-및-결과-확인"><strong>4-4. 실행 및 결과 확인</strong></h3>
<p><img src="https://velog.velcdn.com/images/rudin_/post/db67b520-3b4f-46d7-9942-4335e3662296/image.png" alt=""></p>
<ul>
<li><strong>지금 트리거</strong> 클릭 후 매개변수 값 입력 예시:<ul>
<li><code>pFilterColumn</code>: <code>age</code></li>
<li><code>pFilterValue</code>: <code>52</code></li>
<li><code>pSortColumn</code>: <code>hours_per_week</code></li>
</ul>
</li>
<li><strong>결과</strong>: 나이가 52세인 데이터들만 추출되어 주당 근무 시간순으로 정렬된 <code>adult_filtered_sorted.csv</code> 파일이 생성됩니다.</li>
</ul>
<p><img src="https://velog.velcdn.com/images/rudin_/post/283c94f2-661c-4bcb-bcbb-66007cc3a83d/image.png" alt=""></p>
<hr>
<p><strong>실습 마무리</strong>: 모든 작업이 완료되면 비용 발생 방지를 위해 <strong>데이터 흐름 디버그 모드</strong>를 반드시 중지하십시오.</p>
<hr>
<hr>
<h1 id="azure-data-factory-메타데이터-활동-get-metadata-activity">[Azure Data Factory] 메타데이터 활동 (Get Metadata Activity)</h1>
<h2 id="1-메타데이터-활동get-metadata-activity-개요">1. 메타데이터 활동(Get Metadata Activity) 개요</h2>
<p>Get Metadata 활동은 데이터 소스(파일, 폴더, 테이블 등)의 메타데이터(크기, 수정일, 컬럼 목록 등)를 조회하는 데이터팩토리의 처리 단계입니다.</p>
<h3 id="구성-요소"><strong>구성 요소</strong></h3>
<ul>
<li><strong>데이터셋(Dataset)</strong>: 데이터 소스의 연결 정보 및 경로를 지정합니다.</li>
<li><strong>조회할 필드(Field list)</strong>: 다음과 같은 필요한 메타데이터 항목을 선택합니다.<ul>
<li><code>childItems</code>: 폴더 내 하위 항목 목록</li>
<li><code>exists</code>: 데이터 소스 존재 여부</li>
<li><code>lastModified</code>: 마지막 수정 시간</li>
<li><code>size</code>: 파일 크기</li>
<li><code>structure</code>: 데이터 구조(컬럼 목록) 등</li>
</ul>
</li>
<li><strong>필터(Pattern/Field filter)</strong>: 특정 파일 확장자나 경로 패턴에 따라 조회 대상을 제한합니다.</li>
</ul>
<h3 id="장점"><strong>장점</strong></h3>
<ul>
<li><strong>동적 분기 처리</strong>: 메타데이터를 기반으로 조건 분기 및 재시도 로직 구현이 가능합니다.</li>
<li><strong>효율적 파이프라인 설계</strong>: 사전 검증을 통해 불필요한 복사 및 변환 작업을 예방합니다.</li>
<li><strong>재사용성</strong>: 공통 메타데이터 조회 로직을 모듈화하여 여러 파이프라인에서 동일하게 활용할 수 있습니다.</li>
</ul>
<h3 id="사용-시나리오"><strong>사용 시나리오</strong></h3>
<ul>
<li><strong>파일 존재 여부 확인</strong>: 파이프라인 실행 전 대상 파일이 있는지 분기 처리합니다.</li>
<li><strong>폴더 목록 조회</strong>: 폴더 내 파일, 하위 폴더 목록을 동적으로 파이프라인에 전달합니다.</li>
<li><strong>테이블 스키마 조회</strong>: 테이블 컬럼 구조를 미리 확인하여 후속 매핑 데이터 흐름에 활용합니다.</li>
</ul>
<hr>
<h2 id="2-실습-1-기본-메타데이터-조회">2. [실습 1] 기본 메타데이터 조회</h2>
<h3 id="실습-준비"><strong>실습 준비</strong></h3>
<ul>
<li><strong>실습 데이터</strong>: UC Irvine Machine Learning Repository의 <strong>Wine Quality</strong> 데이터셋을 활용합니다.</li>
<li><strong>데이터 특성</strong>: 레드 와인(<code>winequality-red.csv</code>)과 화이트 와인(<code>winequality-white.csv</code>)의 화학적 테스트 결과 데이터입니다.</li>
<li><strong>데이터 형식</strong>: 세미콜론(<code>;</code>)을 구분자로 사용하는 CSV 파일입니다.</li>
<li><strong>스토리지 구성</strong>: <code>wine-quality</code> 컨테이너 내에 위 두 파일을 업로드합니다.</li>
</ul>
<h3 id="파이프라인-및-데이터-세트-생성"><strong>파이프라인 및 데이터 세트 생성</strong></h3>
<ol>
<li><strong>파이프라인 생성</strong>: <code>GetMetadataWine_PL</code> 파이프라인을 생성하고 <strong>메타데이터 가져오기</strong> 활동을 추가합니다.</li>
<li><strong>데이터 세트 설정 (<code>wineContainer_DS</code>)</strong>:<ul>
<li>형식: Delimited Text (Azure Blob Storage).</li>
<li>연결된 서비스: <code>BlobStorage1</code>.</li>
<li>파일 경로: <code>wine-quality</code> 컨테이너 지정.</li>
<li>열 구분 기호: <strong>Semicolon (;)</strong> 설정.</li>
</ul>
</li>
</ol>
<h3 id="필드-목록-설정-및-실행"><strong>필드 목록 설정 및 실행</strong></h3>
<p><img src="https://velog.velcdn.com/images/rudin_/post/8dbfdcaa-a920-4e9e-ba19-877ba4e6a7ee/image.png" alt=""></p>
<ul>
<li>활동의 <strong>설정</strong> 탭에서 <strong>필드 목록</strong>을 다음과 같이 추가합니다.<ul>
<li><code>exists</code></li>
<li><code>lastModified</code></li>
<li><code>childItems</code></li>
</ul>
</li>
<li><strong>결과 확인 (JSON 출력)</strong>:
<img src="https://velog.velcdn.com/images/rudin_/post/37b6a901-33b9-4390-b633-20f8d7a77b5b/image.png" alt=""></li>
</ul>
<hr>
<h2 id="3-실습-2-메타데이터를-활용한-동적-파일-복사">3. [실습 2] 메타데이터를 활용한 동적 파일 복사</h2>
<p><img src="https://velog.velcdn.com/images/rudin_/post/0e24f86e-5826-40f8-9385-3d56a5c5b95e/image.png" alt=""></p>
<p>조회된 메타데이터 목록을 바탕으로 특정 조건(파일명에 &#39;red&#39; 포함)에 맞는 파일만 다른 컨테이너로 복사하는 실습입니다.</p>
<h3 id="전체-흐름"><strong>전체 흐름</strong></h3>
<ol>
<li><strong>Get Metadata</strong>: 컨테이너 내 파일 목록 확인.</li>
<li><strong>ForEach</strong>: 파일 목록을 순회하며 반복.</li>
<li><strong>If Condition</strong>: 파일 이름에 &#39;red&#39;가 포함되어 있는지 확인.</li>
<li><strong>Copy Data</strong>: 조건이 참인 경우 <code>wine-quality-output</code> 컨테이너로 복사.</li>
</ol>
<h3 id="활동별-세부-설정"><strong>활동별 세부 설정</strong></h3>
<h4 id="1-foreach-활동-foreachwinefiles"><strong>1) ForEach 활동 (<code>ForEachWineFiles</code>)</strong></h4>
<ul>
<li><strong>항목(Items)</strong> 설정 식:<pre><code>@activity(&#39;WineFiles&#39;).output.childItems</code></pre></li>
</ul>
<h4 id="2-if-condition-활동-if-red"><strong>2) If Condition 활동 (<code>If Red</code>)</strong></h4>
<ul>
<li><strong>식(Expression)</strong> 설정 식:<pre><code>@contains(item().name, &#39;red&#39;)</code></pre></li>
</ul>
<h4 id="3-copy-data-활동-copy-red-wine"><strong>3) Copy Data 활동 (<code>Copy Red Wine</code>)</strong></h4>
<ul>
<li><strong>원본 데이터 세트 (<code>wineContainerInput_DS</code>)</strong>:<ul>
<li>매개 변수: <code>fileName</code> 생성.</li>
<li>연결 설정 식: <code>@dataset().fileName</code>.</li>
<li>활동 내 값 매핑: <code>@item().name</code>.</li>
</ul>
</li>
<li><strong>싱크 데이터 세트 (<code>wineContainerOutput_DS</code>)</strong>:<ul>
<li>파일 경로: <code>wine-quality-output</code>.</li>
<li>매개 변수: <code>fileName</code> 생성.</li>
<li>활동 내 값 매핑: <code>@item().name</code>.</li>
</ul>
</li>
</ul>
<hr>
<h2 id="4-실행-및-결과-검증">4. 실행 및 결과 검증</h2>
<h3 id="실행-결과-모니터링"><strong>실행 결과 모니터링</strong></h3>
<ul>
<li><strong>WineFiles</strong>: 성공 (메타데이터 조회).</li>
<li><strong>ForEachWineFiles</strong>: 성공 (반복 처리).</li>
<li><strong>If Red</strong>: 성공 (조건 판단 - 두 개의 파일에 대해 각각 실행).</li>
<li><strong>Copy Red Wine</strong>: 성공 (조건이 참인 &#39;red&#39; 파일에 대해서만 실행).</li>
</ul>
<h3 id="최종-데이터-확인"><strong>최종 데이터 확인</strong></h3>
<ul>
<li><code>wine-quality-output</code> 컨테이너에 <code>winequality-red.csv</code> 파일이 정상적으로 복사되었음을 확인합니다.</li>
<li>복사된 파일의 내용을 미리 보기 하여 데이터 정합성을 확인합니다.</li>
</ul>
<hr>
<h2 id="5-실습-마무리">5. 실습 마무리</h2>
<ul>
<li>실습이 완료된 후에는 비용 발생 방지를 위해 <strong>데이터 흐름 디버그 모드를 반드시 중지</strong>합니다.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[MicrosoftDataSchool] 58일차 - AzureDataFactory, 매개변수, 트리거]]></title>
            <link>https://velog.io/@rudin_/MicrosoftDataSchool-58%EC%9D%BC%EC%B0%A8-AzureDataFactory-%EB%A7%A4%EA%B0%9C%EB%B3%80%EC%88%98-%ED%8A%B8%EB%A6%AC%EA%B1%B0</link>
            <guid>https://velog.io/@rudin_/MicrosoftDataSchool-58%EC%9D%BC%EC%B0%A8-AzureDataFactory-%EB%A7%A4%EA%B0%9C%EB%B3%80%EC%88%98-%ED%8A%B8%EB%A6%AC%EA%B1%B0</guid>
            <pubDate>Mon, 30 Mar 2026 08:29:16 GMT</pubDate>
            <description><![CDATA[<p>ETL: Extract-Transform-Load
데이터의 최종 목적은 sink
Azure Data Factory는 보고서를 작성하는데 기초되는 데이터의 반복적 적재에 사용</p>
<h1 id="azure-data-factory-개요-정리">Azure Data Factory 개요 정리</h1>
<p>Azure Data Factory는 다양한 데이터 소스에서 데이터를 수집하고, 필요한 형태로 이동·변환·적재하는 데이터 통합 서비스이다. 이번 정리는 ADF의 개념, ETL/ELT 배경, 핵심 구성 요소, 그리고 Blob Storage의 CSV 데이터를 SQL Database로 복사하는 기본 실습 흐름까지 한 번에 정리한 내용이다.</p>
<hr>
<h1 id="1-데이터와-ai-시대">1. 데이터와 AI 시대</h1>
<ul>
<li><p>데이터 기반 의사결정 중요성 증가함</p>
</li>
<li><p>기업은 데이터로부터 비즈니스 인사이트 도출 필요함</p>
</li>
<li><p>데이터 활용 목적:</p>
<ul>
<li>고객 성향 분석</li>
<li>사회·경제 변화 분석</li>
<li>비즈니스 전략 수립</li>
</ul>
</li>
</ul>
<p>데이터를 수집하고 저장하는 것만으로는 충분하지 않고, 분석에 적합한 형태로 가공한 뒤 실제 의사결정에 연결해야 가치가 생긴다. 자료에서도 <code>수집/변환/저장 → 데이터 분석 → 비즈니스 인사이트 도출</code> 흐름으로 설명한다. </p>
<hr>
<h1 id="2-데이터-정의-및-유형">2. 데이터 정의 및 유형</h1>
<h2 id="데이터-정의">데이터 정의</h2>
<table>
<thead>
<tr>
<th>구분</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>위키백과</td>
<td>양, 품질, 사실, 통계 등의 형태로 된 의미의 단위</td>
</tr>
<tr>
<td>옥스포드 컴퓨터 용어 사전</td>
<td>프로그램을 운용할 수 있는 형태로 기호화·숫자화한 자료</td>
</tr>
<tr>
<td>네이버 사전</td>
<td>이론을 세우는 데 기초가 되는 사실 또는 바탕 자료</td>
</tr>
<tr>
<td>옥스포드 대사전</td>
<td>추론과 추정의 근거를 이루는 사실</td>
</tr>
</tbody></table>
<h2 id="데이터-유형">데이터 유형</h2>
<table>
<thead>
<tr>
<th>유형</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>정성적 데이터</td>
<td>언어, 문자 등 비정형 데이터</td>
</tr>
<tr>
<td>정량적 데이터</td>
<td>숫자, 도형, 기호 등 정형 데이터</td>
</tr>
<tr>
<td>암묵지</td>
<td>학습, 체험 등으로 개인이 습득한 무형 지식</td>
</tr>
<tr>
<td>형식지</td>
<td>문서화되어 전달·공유가 가능한 지식</td>
</tr>
</tbody></table>
<p>정형 데이터는 저장·검색·분석에 유리하고, 비정형 데이터는 활용 가치가 크지만 전처리와 통합이 더 어렵다. </p>
<hr>
<h1 id="3-데이터와-정보-dikw-구조">3. 데이터와 정보: DIKW 구조</h1>
<h2 id="dikw-개념">DIKW 개념</h2>
<table>
<thead>
<tr>
<th>단계</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>Data</td>
<td>관찰을 통해 수집된 원시 데이터</td>
</tr>
<tr>
<td>Information</td>
<td>정제·가공되어 의미가 부여된 데이터</td>
</tr>
<tr>
<td>Knowledge</td>
<td>연결된 정보 패턴을 이해하여 내재화한 결과</td>
</tr>
<tr>
<td>Wisdom</td>
<td>근본 원리에 대한 깊은 이해를 바탕으로 한 의사결정</td>
</tr>
</tbody></table>
<h2 id="예시">예시</h2>
<table>
<thead>
<tr>
<th>단계</th>
<th>예시</th>
</tr>
</thead>
<tbody><tr>
<td>Data</td>
<td>A마트 식빵 100원, B마트 식빵 200원</td>
</tr>
<tr>
<td>Information</td>
<td>A마트가 B마트보다 식빵이 더 쌈</td>
</tr>
<tr>
<td>Knowledge</td>
<td>식빵은 A마트에서 사는 것이 좋음</td>
</tr>
<tr>
<td>Wisdom</td>
<td>다른 식료품도 A마트가 더 저렴할 가능성이 높음</td>
</tr>
</tbody></table>
<p>즉, 데이터는 그 자체로 끝나지 않고, 가공과 해석을 거쳐 정보·지식·지혜로 발전해야 실제 비즈니스 가치가 된다. </p>
<hr>
<h1 id="4-oltp와-olap">4. OLTP와 OLAP</h1>
<table>
<thead>
<tr>
<th>구분</th>
<th>OLTP</th>
<th>OLAP</th>
</tr>
</thead>
<tbody><tr>
<td>목적</td>
<td>실시간 데이터 처리</td>
<td>데이터 분석 및 의사결정</td>
</tr>
<tr>
<td>데이터 형태</td>
<td>원시 데이터</td>
<td>정제·집계된 데이터</td>
</tr>
<tr>
<td>구조</td>
<td>정규화된 스키마 중심</td>
<td>분석 친화적 구조</td>
</tr>
<tr>
<td>특징</td>
<td>거래 시스템 중심</td>
<td>다차원 분석 및 리포트 중심</td>
</tr>
</tbody></table>
<p>OLTP는 운영계 시스템이고, OLAP는 분석계 시스템이다. ADF는 주로 운영계의 데이터를 분석계 저장소로 이동시키는 역할과 맞닿아 있다. </p>
<hr>
<h1 id="5-adf-aml-bi의-역할">5. ADF, AML, BI의 역할</h1>
<table>
<thead>
<tr>
<th>단계</th>
<th>도구</th>
<th>역할</th>
</tr>
</thead>
<tbody><tr>
<td>수집·정제·결합</td>
<td>Azure Data Factory</td>
<td>ETL/ELT, 데이터 파이프라인 구축</td>
</tr>
<tr>
<td>분석·모델링</td>
<td>Azure Machine Learning</td>
<td>EDA, Feature Engineering, 모델 학습·예측</td>
</tr>
<tr>
<td>시각화·의사결정</td>
<td>Power BI</td>
<td>리포트, 대시보드, 결과 공유</td>
</tr>
</tbody></table>
<p>ADF는 데이터를 준비하는 계층이고, AML은 패턴을 학습하는 계층이며, Power BI는 결과를 보여주는 계층이라고 보면 이해가 쉽다. </p>
<hr>
<h1 id="6-데이터-수집·저장-시-고려사항">6. 데이터 수집·저장 시 고려사항</h1>
<table>
<thead>
<tr>
<th>항목</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>파일 포맷</td>
<td>형식 변환이 필요한지 확인해야 함</td>
</tr>
<tr>
<td>질의 처리</td>
<td>쿼리 성능 및 실행 계획 확인 필요함</td>
</tr>
<tr>
<td>JSON 구조</td>
<td>스키마 변경 필요 여부 점검해야 함</td>
</tr>
<tr>
<td>결측치</td>
<td>누락 데이터 처리 기준 필요함</td>
</tr>
<tr>
<td>보안</td>
<td>민감 데이터 보호 방안 필요함</td>
</tr>
<tr>
<td>중복 데이터</td>
<td>여러 소스 통합 시 중복 제거 필요함</td>
</tr>
<tr>
<td>비용·인력</td>
<td>운영·이관에 드는 비용 고려해야 함</td>
</tr>
</tbody></table>
<p>자료에서는 이 과정을 복잡성, 정합성, 무결성, 보안성 문제로 정리한다. </p>
<hr>
<h1 id="7-데이터로부터-가치를-얻는-데-장애가-되는-요인">7. 데이터로부터 가치를 얻는 데 장애가 되는 요인</h1>
<ul>
<li>데이터 사일로: 부서, 시스템별로 데이터가 분리되어 있어 통합·분석이 어려움</li>
<li>이기종 데이터 형식: 정형·비정형 데이터를 모두 다뤄야 해 관리 복잡성 증가함</li>
<li>솔루션 복잡성: 여러 도구를 병행 운영하면 유지보수 부담 커짐</li>
<li>멀티 클라우드 환경: 클라우드별 API와 접근 방식이 달라 관리 비용 증가함</li>
<li>급증하는 운영 비용: 인프라, 도구, 인력 비용이 누적되어 전체 TCO 상승함</li>
</ul>
<p>핵심은 “데이터를 한곳에 통합하고, 권한 있는 사용자가 쉽게 활용할 수 있어야 한다”는 점이다. </p>
<hr>
<h1 id="8-가치-창출을-위한-데이터-환경-구축-요건">8. 가치 창출을 위한 데이터 환경 구축 요건</h1>
<table>
<thead>
<tr>
<th>요소</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>통합 데이터 허브</td>
<td>모든 데이터를 한 곳에 통합하고 다양한 형식을 지원해야 함</td>
</tr>
<tr>
<td>데이터 통합</td>
<td>원천 데이터를 ETL/ELT 방식으로 추출·변환·적재해야 함</td>
</tr>
<tr>
<td>Self-Service Access</td>
<td>사용자가 필요한 데이터를 손쉽게 조회·활용할 수 있어야 함</td>
</tr>
<tr>
<td>Right &amp; Responsibility</td>
<td>데이터 품질 책임과 활용 책임을 분리해 관리해야 함</td>
</tr>
</tbody></table>
<p>ADF는 이 중에서도 특히 <strong>데이터 통합</strong>을 담당하는 대표 도구로 볼 수 있다. </p>
<hr>
<h1 id="9-etl과-elt">9. ETL과 ELT</h1>
<h2 id="etl">ETL</h2>
<ul>
<li>Extract: 원천 시스템에서 데이터 추출함</li>
<li>Transform: 중간 단계에서 정제, 표준화, 집계 수행함</li>
<li>Load: 대상 시스템에 적재함</li>
</ul>
<h2 id="elt">ELT</h2>
<ul>
<li>Extract: 원천 시스템에서 데이터 추출함</li>
<li>Load: 우선 대상 시스템에 원시 데이터 적재함</li>
<li>Transform: 대상 시스템 내부에서 SQL, Spark 등으로 변환함</li>
</ul>
<h2 id="etl-vs-elt-비교">ETL vs ELT 비교</h2>
<table>
<thead>
<tr>
<th>구분</th>
<th>ETL</th>
<th>ELT</th>
</tr>
</thead>
<tbody><tr>
<td>처리 순서</td>
<td>추출 → 변환 → 적재</td>
<td>추출 → 적재 → 변환</td>
</tr>
<tr>
<td>변환 위치</td>
<td>외부 시스템</td>
<td>대상 시스템 내부</td>
</tr>
<tr>
<td>실행 시간</td>
<td>잦은 데이터 이동으로 상대적으로 느림</td>
<td>병렬 처리 활용 가능해 빠름</td>
</tr>
<tr>
<td>장점</td>
<td>정제된 상태로 적재 가능</td>
<td>대용량 처리와 클라우드 환경에 유리</td>
</tr>
<tr>
<td>유연성</td>
<td>정해진 파이프라인 중심</td>
<td>SQL/Spark로 유연하게 가공 가능</td>
</tr>
<tr>
<td>적합 환경</td>
<td>전통적인 DWH</td>
<td>고성능 DWH, 레이크하우스</td>
</tr>
<tr>
<td>활용 예시</td>
<td>금융기관 정기 보고서</td>
<td>로그, 센서, ML 분석용 데이터</td>
</tr>
</tbody></table>
<p>최근에는 대용량 비정형·반정형 데이터가 늘어나면서 ELT 방식이 더 자주 활용된다고 설명한다. </p>
<hr>
<h1 id="10-cdcchange-data-capture">10. CDC(Change Data Capture)</h1>
<h2 id="cdc-개념">CDC 개념</h2>
<p>CDC는 데이터 소스에서 발생한 변경 사항만 감지해 추출하고 반영하는 방식이다.</p>
<h2 id="cdc-처리-흐름">CDC 처리 흐름</h2>
<table>
<thead>
<tr>
<th>단계</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>Detect</td>
<td>Insert, Update, Delete 같은 변경 이벤트 감지</td>
</tr>
<tr>
<td>Capture</td>
<td>변경 내용을 추출해 전달 가능한 형태로 준비</td>
</tr>
<tr>
<td>Apply</td>
<td>변경분만 대상 시스템에 반영해 동기화 유지</td>
</tr>
</tbody></table>
<h2 id="cdc-특징">CDC 특징</h2>
<table>
<thead>
<tr>
<th>항목</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>주요 목적</td>
<td>전체 재처리 없이 최신 상태 유지</td>
</tr>
<tr>
<td>처리 방식</td>
<td>실시간 또는 Near Real-Time</td>
</tr>
<tr>
<td>주요 기술</td>
<td>DB 로그, 트리거, 타임스탬프 비교, Debezium 등</td>
</tr>
<tr>
<td>장점</td>
<td>대용량 효율 처리, 실시간 분석 가능</td>
</tr>
<tr>
<td>활용 예시</td>
<td>실시간 대시보드, 복제 시스템, 이벤트 기반 아키텍처</td>
</tr>
</tbody></table>
<p>즉, CDC는 ETL/ELT의 배치 처리 한계를 보완하는 실시간 데이터 처리 방식이다. </p>
<hr>
<h1 id="11-데이터-파이프라인">11. 데이터 파이프라인</h1>
<h2 id="데이터-파이프라인-정의">데이터 파이프라인 정의</h2>
<p>데이터 파이프라인은 원천 시스템에서 분석·활용 시스템까지 이어지는 전체 데이터 흐름을 자동화하는 구조다. 수집, 처리, 저장, 전달 전 단계를 연결한다. </p>
<h2 id="데이터-파이프라인-단계">데이터 파이프라인 단계</h2>
<table>
<thead>
<tr>
<th>단계</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>Ingest</td>
<td>파일, DB, API, IoT 등에서 데이터 수집</td>
</tr>
<tr>
<td>Process</td>
<td>정제, 필터링, 변환, 결측치 처리, 집계</td>
</tr>
<tr>
<td>Store</td>
<td>데이터 웨어하우스, 데이터 레이크 등에 저장</td>
</tr>
<tr>
<td>Deliver</td>
<td>대시보드, 분석 시스템, 모델링 시스템 등에 전달</td>
</tr>
</tbody></table>
<h2 id="데이터-파이프라인-특징">데이터 파이프라인 특징</h2>
<ul>
<li>자동화: 반복 작업을 자동 실행함</li>
<li>연속성: 흐름이 단계별로 끊기지 않음</li>
<li>확장성: 병렬 처리 및 클라우드 인프라 활용 가능함</li>
<li>신뢰성: 재처리, 오류 감지, 모니터링 가능함</li>
<li>실시간성: 스트리밍 처리도 가능함</li>
</ul>
<hr>
<h1 id="12-데이터-파이프라인-구성-요소">12. 데이터 파이프라인 구성 요소</h1>
<table>
<thead>
<tr>
<th>구성 요소</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>Data Source</td>
<td>데이터베이스, 파일 시스템, API, 로그 등 원천 위치</td>
</tr>
<tr>
<td>Extract</td>
<td>소스에서 데이터를 읽어오는 단계</td>
</tr>
<tr>
<td>Transform</td>
<td>필터링, 조인, 포맷 변경, 집계 등 가공 단계</td>
</tr>
<tr>
<td>Load</td>
<td>대상(데이터 웨어하우스, 데이터레이크, NoSQL DB) 시스템에 저장하는 단계</td>
</tr>
<tr>
<td>Orchestration</td>
<td>전체 흐름 제어, 조건 분기, 재시도, 트리거 관리</td>
</tr>
<tr>
<td>Monitoring &amp; Alert</td>
<td>성공/실패 감시, 알림, 로깅, 성능 분석</td>
</tr>
<tr>
<td>Execution Environment</td>
<td>정의된 파이프라인을 실제 실행하는 컴퓨팅 환경</td>
</tr>
</tbody></table>
<p>ADF에서는 이 Execution Environment를 <strong>Integration Runtime</strong>이라고 부른다. </p>
<hr>
<h1 id="13-오케스트레이션과-트랜스포메이션">13. 오케스트레이션과 트랜스포메이션</h1>
<table>
  <thead>
    <tr>
      <th>구분</th>
      <th>구성 요소</th>
      <th>설명</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td rowspan="4">오케스트레이션</td>
      <td>워크플로우</td>
      <td>작업 순서와 흐름 정의</td>
    </tr>
    <tr>
      <td>트리거</td>
      <td>일정·이벤트·수동 실행 조건 설정</td>
    </tr>
    <tr>
      <td>조건 분기 및 반복</td>
      <td>조건에 따른 분기와 루프 제어</td>
    </tr>
    <tr>
      <td>에러 처리 및 알림</td>
      <td>실패 시 재시도, 로그, 알림 수행</td>
    </tr>
    <tr>
      <td rowspan="4">트랜스포메이션</td>
      <td>Extract</td>
      <td>데이터 추출</td>
    </tr>
    <tr>
      <td>Transform</td>
      <td>데이터 가공</td>
    </tr>
    <tr>
      <td>Load</td>
      <td>데이터 적재</td>
    </tr>
    <tr>
      <td>사용자 정의 로직</td>
      <td>커스텀 처리 코드 실행</td>
    </tr>
  </tbody>
</table>

<p>ADF는 특히 오케스트레이션에 강점이 있고, 복잡한 변환은 외부 컴퓨팅 서비스와 함께 사용하는 구조가 자주 등장한다. </p>
<hr>
<h1 id="14-execution-environment">14. Execution Environment</h1>
<table>
<thead>
<tr>
<th>도구/플랫폼</th>
<th>실행 환경 명칭</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>Azure Data Factory</td>
<td>Integration Runtime</td>
<td>컴퓨팅/실행 엔진(Azure/Self-Hosted/SSIS)</td>
</tr>
<tr>
<td>AWS Glue</td>
<td>Job Worker / Spark Environment</td>
<td>Spark 기반 실행 환경</td>
</tr>
<tr>
<td>Apache Airflow</td>
<td>Worker / Executor</td>
<td>DAG를 실제로 실행하는 프로세스</td>
</tr>
<tr>
<td>Google Dataflow</td>
<td>Worker / Runner</td>
<td>파이프라인을 실행하는 관리형 워커 노드</td>
</tr>
<tr>
<td>Talend</td>
<td>Job Server</td>
<td>Talend Job 실행 환경</td>
</tr>
</tbody></table>
<p>파이프라인이 “무엇을 할지”를 정의한다면, Execution Environment는 “어디서 어떻게 실행할지”를 담당한다. </p>
<ul>
<li>Azure Integration Runtime: 클라우드상</li>
<li>Self-Hosted: 로컬환경상</li>
</ul>
<hr>
<h1 id="15-azure-data-factory-구성-요소">15. Azure Data Factory 구성 요소</h1>
<p><img src="https://velog.velcdn.com/images/rudin_/post/eaefee6b-3604-4111-ae09-e86bea8594fd/image.png" alt=""></p>
<p>자료에서는 ADF를 배송 시스템에 비유한다. 출발지 서류보관함에서 서류를 집하해 처리한 뒤 목적지 서류보관함에 전달하는 흐름으로 설명한다. 이 비유에서 파이프라인은 전체 배송 계획, 액티비티는 개별 운송 작업, 데이터셋은 다루는 서류 묶음, 링크드 서비스는 출발지·도착지 정보, Integration Runtime은 실제 배송을 수행하는 엔진에 해당한다. </p>
<h2 id="adf-핵심-구성-요소">ADF 핵심 구성 요소</h2>
<p><img src="https://velog.velcdn.com/images/rudin_/post/90e83411-397b-454c-a5a5-b935d1000fc5/image.png" alt=""></p>
<ul>
<li>Linked Service는 저장의 추상화 <h2 id="pipeline과-activity-관계">Pipeline과 Activity 관계</h2>
<img src="https://velog.velcdn.com/images/rudin_/post/aa7d9fc7-2c48-4fc1-a86e-537ee0f0a37b/image.png" alt=""></li>
<li>그림에서는 Activity 중 Copy를 예시로 듬</li>
</ul>
<p>자료의 그림에서는 하나의 Pipeline 아래에 여러 Copy Activity가 들어갈 수 있고, 각 Activity는 입력 Dataset과 출력 Dataset을 가진다. 즉, 파이프라인은 큰 흐름이고 액티비티는 그 안에서 수행되는 세부 작업이다. 
(원본 → 싱크)</p>
<h2 id="pipeline과-linked-service-관계">Pipeline과 Linked Service 관계</h2>
<p><img src="https://velog.velcdn.com/images/rudin_/post/9d72c636-e80a-4fc3-9d09-7d74cf88f337/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/180d03d3-bb42-46b2-b45b-8a893973db6c/image.png" alt=""></p>
<hr>
<h1 id="16-adf-실습-구성">16. ADF 실습 구성</h1>
<p>기본 실습의 전체 구조는 다음과 같다. 자료의 아키텍처 그림에서 <strong>Blob Storage의 CSV 파일을 SQL Database 테이블로 복사하는 구조</strong>를 보여준다. 중간에는 ADF Pipeline, Integration Runtime, Linked Service, Dataset, Copy Activity가 위치한다.</p>
<pre><code class="language-text">Blob Storage (CSV)
  ↓
Linked Service
  ↓
Source Dataset
  ↓
Copy Activity
  ↓
Sink Dataset
  ↓
Linked Service
  ↓
SQL Database (Table)</code></pre>
<h2 id="실습-아키텍처-구성-요소">실습 아키텍처 구성 요소</h2>
<table>
<thead>
<tr>
<th>구간</th>
<th>구성</th>
</tr>
</thead>
<tbody><tr>
<td>원본</td>
<td>Blob Storage의 CSV 파일</td>
</tr>
<tr>
<td>연결 정보</td>
<td>Blob Linked Service</td>
</tr>
<tr>
<td>원본 정의</td>
<td>Source Dataset</td>
</tr>
<tr>
<td>복사 작업</td>
<td>Copy Activity</td>
</tr>
<tr>
<td>목적지 정의</td>
<td>Sink Dataset</td>
</tr>
<tr>
<td>연결 정보</td>
<td>SQL Linked Service</td>
</tr>
<tr>
<td>목적지</td>
<td>SQL Database Table</td>
</tr>
<tr>
<td>실행 엔진</td>
<td>Integration Runtime</td>
</tr>
<tr>
<td>전체 제어</td>
<td>Pipeline</td>
</tr>
</tbody></table>
<hr>
<h1 id="17-실습">17. 실습</h1>
<h2 id="17-1-실습-데이터-다운로드">17-1. 실습 데이터 다운로드</h2>
<p>실습 데이터는 <code>iris.csv</code>와 <code>iris-columns.sql</code> 파일로 구성된다. <code>iris.csv</code>에는 SepalLength, SepalWidth, PetalLength, PetalWidth, Species 컬럼이 포함된 붓꽃 데이터가 들어 있다. </p>
<h2 id="17-2-리소스-그룹-생성">17-2. 리소스 그룹 생성</h2>
<p><img src="https://velog.velcdn.com/images/rudin_/post/1948d126-0d9e-45eb-849a-1d1f30fb8655/image.png" alt=""></p>
<h2 id="17-3-adf-리소스-생성">17-3. ADF 리소스 생성</h2>
<p>ADF 생성 시 설정한 주요 항목은 다음과 같다.</p>
<table>
<thead>
<tr>
<th>항목</th>
<th>설정 예시</th>
</tr>
</thead>
<tbody><tr>
<td>이름</td>
<td>영문자, 숫자, 하이픈 조합</td>
</tr>
<tr>
<td>지역</td>
<td>자유롭게 지정</td>
</tr>
<tr>
<td>버전</td>
<td>V2</td>
</tr>
<tr>
<td>리소스 그룹</td>
<td>방금 만든 그룹 선택</td>
</tr>
</tbody></table>
<p>배포가 완료되면 Data Factory 리소스의 첫 화면에서 Studio를 시작할 수 있다. </p>
<h2 id="17-4-sql-database-생성">17-4. SQL Database 생성</h2>
<p><img src="https://velog.velcdn.com/images/rudin_/post/fb8393cf-02e3-4439-862d-2aaf444419d2/image.png" alt=""></p>
<p>SQL Database 생성 과정에서는 논리 서버도 함께 만든다.</p>
<table>
<thead>
<tr>
<th>항목</th>
<th>설정 예시</th>
</tr>
</thead>
<tbody><tr>
<td>리소스 그룹</td>
<td>실습용 리소스 그룹</td>
</tr>
<tr>
<td>데이터베이스 이름</td>
<td>식별 가능한 이름</td>
</tr>
<tr>
<td>서버</td>
<td>새로 만들기</td>
</tr>
<tr>
<td>인증 방식</td>
<td>SQL 인증 사용</td>
</tr>
<tr>
<td>워크로드</td>
<td>개발</td>
</tr>
<tr>
<td>컴퓨팅 계층</td>
<td>DTU Basic</td>
</tr>
<tr>
<td>최대 크기</td>
<td>2GB</td>
</tr>
<tr>
<td>연결 방법</td>
<td>Public Endpoint</td>
</tr>
<tr>
<td>방화벽</td>
<td>Azure 서비스 허용, 현재 클라이언트 IP 허용</td>
</tr>
</tbody></table>
<h3 id="서버-만들기">서버 만들기</h3>
<p><img src="https://velog.velcdn.com/images/rudin_/post/2070841d-d3c3-4305-9b68-c87cf6bbec87/image.png" alt=""></p>
<h3 id="컴퓨팅-구성">컴퓨팅 구성</h3>
<p><img src="https://velog.velcdn.com/images/rudin_/post/7007d952-b6d7-45a0-99de-bdeb21adeb8c/image.png" alt=""></p>
<h3 id="네트워크-구성방화벽">네트워크 구성(방화벽)</h3>
<p><img src="https://velog.velcdn.com/images/rudin_/post/9bdc04d2-e3f5-4133-9317-95518468bda8/image.png" alt=""></p>
<ul>
<li>TLS 버전은 항상 최신으로 사용하는 것이 좋다<h2 id="17-5-storage-account-생성">17-5. Storage Account 생성</h2>
<img src="https://velog.velcdn.com/images/rudin_/post/452a3f90-4b90-4b8c-9a4e-8b63913bb7c6/image.png" alt=""></li>
</ul>
<p>Storage Account 생성 시 설정 항목은 다음과 같다.</p>
<table>
<thead>
<tr>
<th>항목</th>
<th>설정 예시</th>
</tr>
</thead>
<tbody><tr>
<td>리소스 그룹</td>
<td>실습용 리소스 그룹</td>
</tr>
<tr>
<td>스토리지 계정 이름</td>
<td>기억하기 쉬운 이름</td>
</tr>
<tr>
<td>지역</td>
<td>ADF와 동일 지역</td>
</tr>
<tr>
<td>기본 스토리지 유형</td>
<td>Azure Blob Storage 또는 Azure Data Lake Storage Gen2</td>
</tr>
<tr>
<td>워크로드</td>
<td>기타</td>
</tr>
<tr>
<td>성능</td>
<td>표준</td>
</tr>
<tr>
<td>중복도</td>
<td>GRS</td>
</tr>
</tbody></table>
<p>배포가 끝나면 Blob 서비스와 컨테이너를 생성해 원본 파일을 올릴 수 있다. </p>
<hr>
<h1 id="18-원본-데이터-준비">18. 원본 데이터 준비</h1>
<h2 id="18-1-컨테이너-생성">18-1. 컨테이너 생성</h2>
<p><img src="https://velog.velcdn.com/images/rudin_/post/1cecdeb3-3761-4287-bf36-f75746801710/image.png" alt=""></p>
<p>Storage Account에서 Blob service로 이동한 뒤 컨테이너를 생성한다. 자료 예시에서는 <code>inputstorage</code>라는 이름을 사용한다. 컨테이너는 비공개 상태로 생성된다. </p>
<h2 id="18-2-csv-파일-업로드">18-2. CSV 파일 업로드</h2>
<p><img src="https://velog.velcdn.com/images/rudin_/post/e084e453-168f-4c81-8fe6-7bcce5877463/image.png" alt=""></p>
<p>생성한 컨테이너에 <code>iris.csv</code> 파일을 업로드한다. 업로드 후 파일을 클릭해 개요와 편집 화면을 확인할 수 있다. </p>
<h2 id="18-3-blob-안에서-csv가-보이는-형태">18-3. Blob 안에서 CSV가 보이는 형태</h2>
<p><img src="https://velog.velcdn.com/images/rudin_/post/24418c7d-ed91-4e65-a340-152a2649374b/image.png" alt=""></p>
<p>자료의 편집 화면 예시에서는 다음과 같은 구조로 보인다.</p>
<ul>
<li>첫 행: 헤더</li>
<li>이후 행: 데이터 샘플</li>
<li>구분자: 쉼표(,)</li>
</ul>
<h2 id="csv-컬럼-구조">CSV 컬럼 구조</h2>
<table>
<thead>
<tr>
<th>컬럼</th>
<th>의미</th>
</tr>
</thead>
<tbody><tr>
<td>SepalLength</td>
<td>꽃받침 길이</td>
</tr>
<tr>
<td>SepalWidth</td>
<td>꽃받침 너비</td>
</tr>
<tr>
<td>PetalLength</td>
<td>꽃잎 길이</td>
</tr>
<tr>
<td>PetalWidth</td>
<td>꽃잎 너비</td>
</tr>
<tr>
<td>Species</td>
<td>품종</td>
</tr>
</tbody></table>
<h1 id="참고-대시보드-고정-기능">참고) 대시보드 고정 기능</h1>
<p>자료에서는 실습 중 여러 리소스를 자주 오가야 하므로, 리소스 그룹·ADF·SQL Database·SQL Server·Storage Account를 대시보드에 고정하는 방식을 소개한다. 핀 아이콘으로 메뉴를 고정하고, 새 대시보드를 만들어 자주 쓰는 리소스를 한눈에 모아두면 이동이 편해진다. </p>
<h2 id="대시보드-고정-대상-예시">대시보드 고정 대상 예시</h2>
<table>
<thead>
<tr>
<th>리소스</th>
<th>용도</th>
</tr>
</thead>
<tbody><tr>
<td>Data Factory</td>
<td>파이프라인 편집 및 실행</td>
</tr>
<tr>
<td>SQL Database</td>
<td>목적지 테이블 관리</td>
</tr>
<tr>
<td>SQL Server</td>
<td>방화벽 및 서버 설정</td>
</tr>
<tr>
<td>Storage Account</td>
<td>원본 CSV 업로드</td>
</tr>
<tr>
<td>Resource Group</td>
<td>전체 리소스 관리</td>
</tr>
</tbody></table>
<hr>
<p><img src="https://velog.velcdn.com/images/rudin_/post/8d3c4ef5-c011-44d6-b78c-c1d03c22ec27/image.png" alt="">
좌상단 핀버튼 누르고 추가 가능
대시보드 접근은 좌상단 三 버튼 누르기
<img src="https://velog.velcdn.com/images/rudin_/post/2e78f804-0b8e-470f-ade3-fc3617be0334/image.png" alt=""></p>
<hr>
<h1 id="19-목적지-테이블-준비">19. 목적지 테이블 준비</h1>
<p><img src="https://velog.velcdn.com/images/rudin_/post/94d0f666-b254-4602-af39-0ced131eca12/image.png" alt=""></p>
<p>SQL Database 리소스로 이동한 뒤 쿼리 편집기에서 SQL 인증으로 로그인한다. 이후 <code>iris-columns.sql</code>의 내용을 복사해 실행하여 목적지 테이블을 만든다. 자료 예시에서는 <code>Iris</code> 테이블을 생성한다. 실행 후 Explorer에서 테이블과 컬럼이 보이고, Messages 영역에 <code>Query executed successfully</code>가 표시된다. </p>
<h2 id="생성-sql">생성 SQL</h2>
<p><img src="https://velog.velcdn.com/images/rudin_/post/bcdd9c83-444d-4547-972d-1de61cef7340/image.png" alt=""></p>
<pre><code class="language-sql">CREATE TABLE Iris (
    SepalLength decimal(5,2),
    SepalWidth decimal(5,2),
    PetalLength decimal(5,2),
    PetalWidth decimal(5,2),
    Species nvarchar(100)
);</code></pre>
<p><img src="https://velog.velcdn.com/images/rudin_/post/5216e8d1-31d0-4354-8e2d-f8e66e9baf6c/image.png" alt=""></p>
<h2 id="생성-테이블-구조">생성 테이블 구조</h2>
<table>
<thead>
<tr>
<th>컬럼</th>
<th>타입</th>
</tr>
</thead>
<tbody><tr>
<td>SepalLength</td>
<td>decimal(5,2)</td>
</tr>
<tr>
<td>SepalWidth</td>
<td>decimal(5,2)</td>
</tr>
<tr>
<td>PetalLength</td>
<td>decimal(5,2)</td>
</tr>
<tr>
<td>PetalWidth</td>
<td>decimal(5,2)</td>
</tr>
<tr>
<td>Species</td>
<td>nvarchar(100)</td>
</tr>
</tbody></table>
<hr>
<h1 id="20-data-factory-studio-진입">20. Data Factory Studio 진입</h1>
<p>ADF 리소스에서 <code>Studio 시작하기</code>를 클릭한 뒤, 왼쪽의 연필 아이콘인 <strong>Author</strong> 메뉴로 이동한다. 여기서 파이프라인, 데이터셋, 연결된 서비스 등을 만들 수 있다.
<img src="https://velog.velcdn.com/images/rudin_/post/e0a5265c-ef9b-46d6-ac9d-fe1643edaf52/image.png" alt=""></p>
<hr>
<h1 id="21-파이프라인-생성">21. 파이프라인 생성</h1>
<p><img src="https://velog.velcdn.com/images/rudin_/post/684b938a-4fa2-4f93-b081-59da275944f2/image.png" alt=""></p>
<p>자료의 화면 예시에서는 파이프라인 목록 오른쪽 메뉴에서 <code>새 파이프라인</code>을 클릭하고, 편집창이 열리면 우측 속성의 일반 메뉴에서 이름을 지정한다. 예시 이름은 <code>Blob to SQL</code>이다. </p>
<h2 id="파이프라인-설정">파이프라인 설정</h2>
<p><img src="https://velog.velcdn.com/images/rudin_/post/eedd17da-f802-460d-a6de-7126bbdb39b8/image.png" alt=""></p>
<table>
<thead>
<tr>
<th>항목</th>
<th>예시</th>
</tr>
</thead>
<tbody><tr>
<td>파이프라인 이름</td>
<td>Blob to SQL</td>
</tr>
<tr>
<td>역할</td>
<td>전체 데이터 복사 흐름 제어</td>
</tr>
</tbody></table>
<p>파이프라인은 가장 상위의 작업 흐름 단위이며, 이후 여기에 Linked Service, Dataset, Copy Activity가 연결된다.</p>
<hr>
<h1 id="22-원본-linked-service-생성">22. 원본 Linked Service 생성</h1>
<p><img src="https://velog.velcdn.com/images/rudin_/post/451b9ae6-0b8f-4d40-aded-2aa5ba4aa980/image.png" alt=""></p>
<p>관리 메뉴에서 <code>연결된 서비스</code>를 선택하고 새로 만들기를 눌러 Blob Storage 연결 정보를 생성한다. 이 연결은 원본 CSV 파일이 있는 Storage Account를 가리킨다. </p>
<p><img src="https://velog.velcdn.com/images/rudin_/post/906ac697-8844-4986-8921-c9795236d141/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/7bc2255a-b171-4662-9791-01e16fdda485/image.png" alt=""></p>
<p>로컬에 있는걸 연결하고 싶으면 통합 런타임이 아닌 다른 런타임을 사용해야 한다.
생성 전 연결 테스트는 항상 해보자.</p>
<h1 id="22-2-원본-dataset-생성">22-2. 원본 Dataset 생성</h1>
<p><img src="https://velog.velcdn.com/images/rudin_/post/90878e97-8046-44b6-9274-d65ac3a482aa/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/rudin_/post/455e8cf9-839c-41e8-b308-ce53f241de0f/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/f0b8e1da-a565-4ce0-8e31-761c3cf28a9f/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/176f0db9-3564-4edf-b3b9-e70d9e3d5ebc/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/f8159f7a-5e50-4f99-b045-4c8421bb28ad/image.png" alt=""></p>
<ul>
<li>형식: CSV</li>
<li>연결: Blob Linked Service</li>
<li>대상: 업로드한 <code>iris.csv</code></li>
<li>특징: 첫 행을 헤더로 사용함</li>
</ul>
<p>데이터세트는 하나의 함수로 이해하면 됨</p>
<h2 id="데이터-미리보기">데이터 미리보기</h2>
<p><img src="https://velog.velcdn.com/images/rudin_/post/bb210912-f217-4552-9637-5dfa0e0d4fc4/image.png" alt="">
미리보기로 연결이 정상인지 확인</p>
<hr>
<h1 id="23-싱크-linked-service-생성">23. 싱크 Linked Service 생성</h1>
<p><img src="https://velog.velcdn.com/images/rudin_/post/cf8b8337-aae6-4191-ad56-6d829c1fbd59/image.png" alt="">
Azure SQL Database 선택
<img src="https://velog.velcdn.com/images/rudin_/post/db9c24d8-d968-4b4f-812d-3ef33857b5e3/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/3b7b9398-bcfb-4c5b-b05a-17176b0ee5e9/image.png" alt="">
만들기가 비활성화됐다면 취소했다가 다시 생성하면 된다. 
혹은 db, adf 네트워킹 설정을 다시 확인해보자.</p>
<p>같은 방식으로 SQL Database용 Linked Service를 생성한다. 이 연결은 SQL 서버 주소, 데이터베이스, 인증 정보 등을 사용해 목적지에 접속한다. </p>
<h2 id="linked-service-정리">Linked Service 정리</h2>
<p><img src="https://velog.velcdn.com/images/rudin_/post/50495a9c-1b5f-4139-9704-de0c57db6a33/image.png" alt=""></p>
<table>
<thead>
<tr>
<th>구분</th>
<th>연결 대상</th>
<th>역할</th>
</tr>
</thead>
<tbody><tr>
<td>원본 Linked Service</td>
<td>Blob Storage</td>
<td>CSV 원본 연결</td>
</tr>
<tr>
<td>싱크 Linked Service</td>
<td>SQL Database</td>
<td>대상 테이블 연결</td>
</tr>
</tbody></table>
<hr>
<h1 id="23-2-싱크-dataset-생성">23-2. 싱크 Dataset 생성</h1>
<p><img src="https://velog.velcdn.com/images/rudin_/post/a04e1cb4-fdec-435e-a906-b5f43e4b81ec/image.png" alt="">
<img src="https://velog.velcdn.com/images/rudin_/post/b818b296-5d16-436f-8007-abfb29933ea4/image.png" alt="">
<img src="https://velog.velcdn.com/images/rudin_/post/a436d314-d442-4ed6-ba79-b7e035c96d8a/image.png" alt=""></p>
<h2 id="dataset-정리">Dataset 정리</h2>
<p><img src="https://velog.velcdn.com/images/rudin_/post/a08c2279-ad51-4b38-85af-e09146a1c8bd/image.png" alt=""></p>
<table>
<thead>
<tr>
<th>구분</th>
<th>형식</th>
<th>연결</th>
<th>대상</th>
</tr>
</thead>
<tbody><tr>
<td>Source Dataset</td>
<td>CSV</td>
<td>Blob Linked Service</td>
<td>iris.csv</td>
</tr>
<tr>
<td>Sink Dataset</td>
<td>SQL Table</td>
<td>SQL Linked Service</td>
<td>Iris Table</td>
</tr>
</tbody></table>
<hr>
<h1 id="24-copy-activity">24. Copy Activity</h1>
<p><img src="https://velog.velcdn.com/images/rudin_/post/9ecbb66d-14bc-4a7f-a342-35058633a97d/image.png" alt=""></p>
<p>Copy Activity는 실습의 핵심이다. 원본 Dataset에서 데이터를 읽어 싱크 Dataset으로 복사한다. 자료의 실습 구성도에서는 Blob과 SQL 사이 중앙에 Copy Activity가 배치되고, 이후 강조 표시된 그림에서는 Dataset → Copy Activity → Dataset 구간이 하나의 핵심 처리 블록으로 묶여 있다.</p>
<p>좌측 데이터 복사를 드래그 앤 드랍
<img src="https://velog.velcdn.com/images/rudin_/post/de16614e-eaa8-49af-818a-ccf9ced8790e/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/7c1dd38c-83ec-4f17-9da4-98b70dc431b4/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/60087525-45a2-4a8d-bd9c-0e14199de787/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/58a1c94b-5f88-4997-aa87-52ae52c9ffaa/image.png" alt="">
스키마 가져오기 선택
<img src="https://velog.velcdn.com/images/rudin_/post/2cafaa8b-748c-462d-a3ab-36d6e0ca8432/image.png" alt=""></p>
<h2 id="디버그">디버그</h2>
<p><img src="https://velog.velcdn.com/images/rudin_/post/f0596131-0972-4db6-9688-ccf8d848c46b/image.png" alt="">
실무에서는 트리거를, 실습에서는 디버그를 사용(단일 테스트)
<img src="https://velog.velcdn.com/images/rudin_/post/632121db-efbe-490a-950a-ff0a8000daa3/image.png" alt=""></p>
<p>db의 쿼리편집기에서 확인
<img src="https://velog.velcdn.com/images/rudin_/post/4b9a43d0-a5a3-4d66-bcae-02f36d611de0/image.png" alt=""></p>
<h2 id="copy-activity-역할">Copy Activity 역할</h2>
<table>
<thead>
<tr>
<th>항목</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>입력</td>
<td>Source Dataset</td>
</tr>
<tr>
<td>출력</td>
<td>Sink Dataset</td>
</tr>
<tr>
<td>기능</td>
<td>데이터 복사 및 기본 매핑 수행</td>
</tr>
<tr>
<td>위치</td>
<td>Pipeline 내부</td>
</tr>
</tbody></table>
<hr>
<h1 id="25-게시">25. 게시</h1>
<p><img src="https://velog.velcdn.com/images/rudin_/post/aaa29a59-e076-48a0-857c-a0b9101c5839/image.png" alt="">
게시하지 않으면 저장이 안되므로 주의하자</p>
<hr>
<h1 id="26-파이프라인-실행">26. 파이프라인 실행</h1>
<h2 id="26-2-파이프라인-트리거">26-2. 파이프라인 트리거</h2>
<p><img src="https://velog.velcdn.com/images/rudin_/post/5fc8d19b-05b9-479e-b71e-cbdf61a720c6/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/a0911925-729e-4c65-b8a9-4dccfb76d183/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/73877590-b3e7-48d8-ab3f-55751f8e54be/image.png" alt=""></p>
<h2 id="26-3-결과-확인">26-3. 결과 확인</h2>
<p>모니터에서 확인
<img src="https://velog.velcdn.com/images/rudin_/post/3341d49a-2f08-4187-a2fb-5b22a424e128/image.png" alt=""></p>
<p>쿼리 편집기에서 확인
<img src="https://velog.velcdn.com/images/rudin_/post/dbf3c7bf-c87d-4bca-ab36-6341fc8d8d5c/image.png" alt="">
동일한 실행을 두 번하여 중복 발생으로 2배 count됨</p>
<hr>
<h1 id="27-파이프라인-수정">27. 파이프라인 수정</h1>
<p><img src="https://velog.velcdn.com/images/rudin_/post/94029763-0398-447d-ad88-c5369036a2ae/image.png" alt="">
매번 실행마다 delete 후 실행하도록 처리
<img src="https://velog.velcdn.com/images/rudin_/post/e566b029-8b61-4c1b-9451-16c9bcba0314/image.png" alt="">
<img src="https://velog.velcdn.com/images/rudin_/post/8e610f02-f9ab-4bfb-9d0a-0128c39a96ba/image.png" alt=""></p>
<hr>
<h1 id="28-adf-구성-요소-관계-정리">28. ADF 구성 요소 관계 정리</h1>
<h2 id="28-1-pipeline과-activity">28-1. Pipeline과 Activity</h2>
<p>Pipeline 아래에 여러 Activity가 들어갈 수 있다. 하나의 파이프라인 안에서 원본과 목적지가 다른 복사 작업을 여러 개 넣을 수도 있다. </p>
<h2 id="28-2-pipeline과-linked-service">28-2. Pipeline과 Linked Service</h2>
<p>여러 Activity가 같은 원본 Storage 또는 같은 SQL Database를 사용할 경우, 연결 정보는 하나의 Linked Service를 재사용한다. 즉, 연결을 중복 생성하지 않고 중앙에서 관리할 수 있다.</p>
<h2 id="28-3-pipeline과-dataset">28-3. Pipeline과 Dataset</h2>
<p>Copy Activity는 각각 원본 Dataset과 싱크 Dataset을 참조한다. Dataset은 실제 데이터 파일이나 테이블의 위치와 형식을 정의하므로, Activity가 데이터를 해석하는 기준이 된다.</p>
<hr>
<h1 id="29-폴더로-정리">29. 폴더로 정리</h1>
<p><img src="https://velog.velcdn.com/images/rudin_/post/d0e0ce5b-a41a-48e4-87c8-b199685f4a63/image.png" alt=""></p>
<hr>
<h1 id="30-실습-흐름-한-번에-정리">30. 실습 흐름 한 번에 정리</h1>
<h2 id="리소스-준비-단계">리소스 준비 단계</h2>
<ol>
<li>실습 데이터 다운로드</li>
<li>리소스 그룹 생성</li>
<li>Data Factory 생성</li>
<li>SQL Database 및 SQL Server 생성</li>
<li>Storage Account 생성</li>
<li>Blob 컨테이너 생성</li>
<li>CSV 업로드</li>
<li>SQL 테이블 생성</li>
</ol>
<h2 id="adf-작업-단계">ADF 작업 단계</h2>
<ol>
<li>Data Factory Studio 진입</li>
<li>Author 메뉴 이동</li>
<li>새 파이프라인 생성</li>
<li>Blob Linked Service 생성</li>
<li>SQL Linked Service 생성</li>
<li>Source Dataset 생성</li>
<li>Sink Dataset 생성</li>
<li>Copy Activity 추가</li>
<li>Source Dataset 연결</li>
<li>Sink Dataset 연결</li>
<li>복사 실행</li>
</ol>
<p>이 전체 흐름의 목표는 <strong>Blob Storage의 CSV 데이터를 SQL Database 테이블로 복사하는 것</strong>이다.</p>
<hr>
<h1 id="31-핵심-개념-요약">31. 핵심 개념 요약</h1>
<table>
<thead>
<tr>
<th>항목</th>
<th>내용</th>
</tr>
</thead>
<tbody><tr>
<td>데이터 문제</td>
<td>데이터 사일로, 이기종 데이터, 높은 운영 복잡성</td>
</tr>
<tr>
<td>해결 방향</td>
<td>데이터를 한곳에 통합하고 자동화된 파이프라인 구축</td>
</tr>
<tr>
<td>핵심 방식</td>
<td>ETL, ELT, CDC</td>
</tr>
<tr>
<td>ADF 역할</td>
<td>데이터 이동·오케스트레이션</td>
</tr>
<tr>
<td>실행 엔진</td>
<td>Integration Runtime</td>
</tr>
<tr>
<td>실습 구조</td>
<td>Blob CSV → Copy Activity → SQL Table</td>
</tr>
</tbody></table>
<hr>
<h1 id="32-마무리">32. 마무리</h1>
<p>Azure Data Factory는 데이터를 직접 분석하는 도구라기보다는, <strong>분석 가능한 형태로 데이터를 연결하고 이동시키는 데이터 파이프라인 도구</strong>에 가깝다. 따라서 ADF를 이해할 때는 단순히 “복사 도구”로 보기보다, <strong>원본 시스템과 분석 시스템 사이를 이어주는 오케스트레이션 계층</strong>으로 보는 것이 중요하다. 이번 실습도 결국 Blob Storage의 파일과 SQL Database의 테이블을 연결하면서, Pipeline·Activity·Dataset·Linked Service·Integration Runtime이 어떻게 협력하는지 익히는 과정이라고 볼 수 있다.</p>
<hr>


<p>제공해주신 소스(03-30-2.01 매개변수화_v12.pdf)의 <strong>개요</strong> 부분 내용을 요약 없이 마크다운 형식으로 정리해 드립니다.</p>
<hr>
<h1 id="매개변수화parameterization-개요">매개변수화(Parameterization) 개요</h1>
<h1 id="1-매개변수화parameterization-정의">1. 매개변수화(Parameterization) 정의</h1>
<p>매개변수(Parameter)는 데이터 팩토리의 작업 수행 시 입력값으로 사용되는 값이며, 각 액티비티, 파이프라인, 데이터셋(Datasets) 등에서 사전에 정의된 값 또는 사용자 정의 값을 입력받을 수 있습니다. 매개변수화는 특히 프로덕션 환경에서 재사용성과 유지보수성 향상에 큰 효과가 있습니다.</p>
<h1 id="2-매개변수화를-지원하는-구성요소">2. 매개변수화를 지원하는 구성요소</h1>
<ul>
<li><strong>Parameters (매개변수)</strong>: 파이프라인 실행 시 외부에서 값을 입력받아 유연한 구성 가능.</li>
<li><strong>Variables (변수)</strong>: 파이프라인 내에서 상태값을 유지하거나 중간 결과를 저장.</li>
<li><strong>Expressions (표현식)</strong>: 동적 값을 계산하거나 조건문 등을 구성할 수 있는 함수 기반 표현식.</li>
</ul>
<h1 id="3-매개변수화의-이점">3. 매개변수화의 이점</h1>
<ul>
<li><strong>흐름 제어</strong>: 다양한 조건에 따라 실행 경로를 제어 가능.</li>
<li><strong>시간 절약</strong>: 동일한 파이프라인을 여러 시나리오에 재사용.</li>
<li><strong>유연한 설계</strong>: 솔루션을 일반화하고 유지보수 용이.</li>
</ul>
<hr>
<h1 id="4-parameters-매개변수-상세">4. Parameters (매개변수) 상세</h1>
<p>파이프라인, 데이터세트 등에서 정의하는 외부 입력값으로, 실행 시 값을 주입받아 유연한 동작을 지원합니다.</p>
<table>
<thead>
<tr>
<th align="left">항목</th>
<th align="left">내용</th>
</tr>
</thead>
<tbody><tr>
<td align="left"><strong>주요 특성</strong></td>
<td align="left">• 사전에 정의된 값 혹은 사용자 정의 가능<br>• 주로 실행 시점에 결정되는 정적인 값<br>• 런타임 시점에 값 전달<br>• 데이터세트, 파이프라인 등 다양한 요소에 적용</td>
</tr>
<tr>
<td align="left"><strong>활용 목적</strong></td>
<td align="left">• 동적 처리: 날짜별/부서별/환경별 분기<br>• 재사용성: 동일 파이프라인을 다양한 값으로 실행<br>• 유연성: 실행 시점에 변경 가능한 구성</td>
</tr>
<tr>
<td align="left"><strong>활용 예시</strong></td>
<td align="left">• 날짜별 파이프라인 실행: 특정 일자 데이터만 추출<br>• 환경 분기: Dev./Prod. 연결 서비스 자동 전환<br>• 부서별 로직: SQL 쿼리의 동적 적용</td>
</tr>
<tr>
<td align="left"><strong>구문 예시</strong></td>
<td align="left"><code>@pipeline().parameters.inputDate</code></td>
</tr>
</tbody></table>
<hr>
<h1 id="5-variables-변수-상세">5. Variables (변수) 상세</h1>
<p>파이프라인 실행 중에 값을 저장, 조회, 업데이트할 수 있는 내부 런타임 변수입니다.</p>
<table>
<thead>
<tr>
<th align="left">항목</th>
<th align="left">내용</th>
</tr>
</thead>
<tbody><tr>
<td align="left"><strong>주요 특성</strong></td>
<td align="left">• 범위(scope): 파이프라인 단위(자식 파이프라인에 자동 전달되지 않음)<br>• 런타임 수정 가능: Set Variable 액티비티로 값 변경<br>• 선언 시 초기값 지정 가능<br>• 지원 데이터 타입: String, Boolean, Int, Array</td>
</tr>
<tr>
<td align="left"><strong>활용 예시</strong></td>
<td align="left">• 중간 연산값 관리: 복잡한 표현식 결과를 변수에 담아 재사용<br>• 재시도 카운터: 오류 발생 시 retryCount를 1씩 증가시켜 제어<br>• 루프 인덱스 누적: ForEach 반복 횟수 누적 혹은 조건부 루프 제어<br>• 상태 메시지: 각 단계 완료 후 상세 로그 저장</td>
</tr>
<tr>
<td align="left"><strong>선언 구문</strong></td>
<td align="left"><code>&quot;variables&quot;: { &quot;retryCount&quot;: { &quot;type&quot;: &quot;Int&quot;, &quot;defaultValue&quot;: 0 } }</code></td>
</tr>
<tr>
<td align="left"><strong>참조/할당</strong></td>
<td align="left">참조: <code>@variables(&#39;retryCount&#39;)</code><br>할당: <code>@add(variables(&#39;retryCount&#39;), 1)</code></td>
</tr>
</tbody></table>
<hr>
<h1 id="6-expressions-표현식-상세">6. Expressions (표현식) 상세</h1>
<p>런타임에 동적으로 값의 연산, 변환, 판단을 수행하기 위해 다양한 함수를 포함하는 표현식을 활용합니다.</p>
<ul>
<li><strong>주요 특징</strong>:<ul>
<li><strong>런타임 평가</strong>: 실행 시점에 해석 및 실행되어 동적 경로 생성.</li>
<li><strong>풍부한 함수 라이브러리</strong>: 문자열, 수치, 날짜, 논리, 배열 등 지원.</li>
<li><strong>동적 참조</strong>: 파라미터, 변수, 액티비티 출력값 통합 사용.</li>
<li><strong>중첩 가능</strong>: 함수 안에 함수를 삽입하여 복합 연산 지원.</li>
</ul>
</li>
<li><strong>활용 예시</strong>:<ul>
<li><code>@concat(&#39;landing/&#39;, pipeline().parameters.region, &#39;/&#39;, formatDateTime(utcNow(), &#39;yyyyMMdd&#39;))</code></li>
<li><code>@formatDateTime(addDays(utcNow(), -1), &#39;yyyy-MM-dd&#39;)</code></li>
<li><code>@if(greater(activity(&#39;Lookup&#39;).output.count, 0), &#39;HasData&#39;, &#39;NoData&#39;)</code></li>
</ul>
</li>
<li><strong>자주 쓰이는 함수</strong>: <code>concat</code>, <code>formatDateTime</code>, <code>addDays</code>, <code>if</code>, <code>length</code>, <code>json</code>.</li>
</ul>
<hr>
<h1 id="7-parameters-vs-variables-비교-요약">7. Parameters vs Variables 비교 요약</h1>
<table>
<thead>
<tr>
<th align="left">구분</th>
<th align="left">Parameter</th>
<th align="left">Variable</th>
</tr>
</thead>
<tbody><tr>
<td align="left"><strong>정의</strong></td>
<td align="left">파이프라인 실행 시 외부에서 주입받는 입력 값</td>
<td align="left">파이프라인 실행 중 내부에서 생성, 조회, 업데이트 가능한 런타임 변수</td>
</tr>
<tr>
<td align="left"><strong>적용 범위</strong></td>
<td align="left">파이프라인, 데이터세트, 연결 서비스 등 선언한 레벨에 한정됨</td>
<td align="left">파이프라인 단위(자식 파이프라인에 전달되지 않음)</td>
</tr>
<tr>
<td align="left"><strong>런타임 변경</strong></td>
<td align="left">불가능 (정적 값)</td>
<td align="left">Set Variable 액티비티로 언제든 변경 가능</td>
</tr>
<tr>
<td align="left"><strong>참조 구문</strong></td>
<td align="left"><code>@pipeline().parameters.&lt;이름&gt;</code></td>
<td align="left"><code>@variables(&#39;&lt;이름&gt;&#39;)</code></td>
</tr>
<tr>
<td align="left"><strong>주요 활용 예</strong></td>
<td align="left">날짜 필터링, 환경 분기(environment)</td>
<td align="left">재시도 카운터 증가, 상태 메시지 저장</td>
</tr>
</tbody></table>
<hr>
<h1 id="8-실습-시나리오---파이프라인-매개변수화">8. 실습 시나리오 - 파이프라인 매개변수화</h1>
<h2 id="매개변수화를-위한-시나리오-확장">매개변수화를 위한 시나리오 확장</h2>
<p><img src="https://velog.velcdn.com/images/rudin_/post/fb647b88-3e06-4fc2-95a1-58676717fcf0/image.png" alt=""></p>
<h2 id="데이터세트의-매개변수화">데이터세트의 매개변수화</h2>
<p><img src="https://velog.velcdn.com/images/rudin_/post/111c67d1-058c-4eb1-91c7-be02ea0af521/image.png" alt=""></p>
<hr>
<h1 id="9-실습">9. 실습</h1>
<h2 id="9-1-azure-container에-원본-데이터-업로드">9-1. Azure Container에 원본 데이터 업로드</h2>
<p><img src="https://velog.velcdn.com/images/rudin_/post/014e58e5-1742-4048-b8ce-5e43ea37041b/image.png" alt=""></p>
<h2 id="9-2-목적지-데이터-생성">9-2. 목적지 데이터 생성</h2>
<p>SQL Server에서 SQL 데이터베이스로 접속 후 쿼리편집기에서 추가
<img src="https://velog.velcdn.com/images/rudin_/post/7a199128-b9d1-4ea9-9647-cd29664a4633/image.png" alt="">
<img src="https://velog.velcdn.com/images/rudin_/post/8a388320-bb70-4d77-a896-5f9e27aed2f0/image.png" alt=""></p>
<h2 id="9-3-연결된-서비스linked-service-준비">9-3. 연결된 서비스(Linked Service) 준비</h2>
<p><img src="https://velog.velcdn.com/images/rudin_/post/e1947f0e-14e0-476c-b4f3-bfe2dfe06ec5/image.png" alt=""></p>
<h2 id="9-4-소스-데이터세트-생성">9-4. 소스 데이터세트 생성</h2>
<p><img src="https://velog.velcdn.com/images/rudin_/post/de0c89ce-f4d1-44ed-918d-bca54603b5d0/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/a71e4345-e341-4871-8676-f98939722492/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/88878dac-35b4-4887-b458-0a48598928d0/image.png" alt=""></p>
<h2 id="9-5-목적지-데이터세트-생성">9-5. 목적지 데이터세트 생성</h2>
<p><img src="https://velog.velcdn.com/images/rudin_/post/e435463b-07eb-4c66-894f-8b4161f3d238/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/427a0d73-8254-4942-baf4-d6969a065e36/image.png" alt=""></p>
<h2 id="9-6-파이프라인-생성">9-6. 파이프라인 생성</h2>
<p><img src="https://velog.velcdn.com/images/rudin_/post/1dc5482d-430b-46e1-88d3-4246418b829e/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/d9e1b02a-4223-45d5-b5f9-4dec4c8eadd8/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/4c0895d6-8bfd-4a84-bbd0-013d9ac40591/image.png" alt="">
디버그
<img src="https://velog.velcdn.com/images/rudin_/post/0edfa995-c961-4d31-8a9d-3179dc2c53c0/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/3bf391a1-2bd8-4a0e-ac3d-be367907f89d/image.png" alt=""></p>
<h2 id="9-7-데이터세트-매개변수화원본">9-7. 데이터세트 매개변수화(원본)</h2>
<p><img src="https://velog.velcdn.com/images/rudin_/post/ea02d42c-b62c-40ea-abbe-564d85eb3cb8/image.png" alt="">
<img src="https://velog.velcdn.com/images/rudin_/post/af4b9b37-8393-438d-aa81-51a6f68829c7/image.png" alt="">
연결탭의 파일 경로에서 파일 이름 삭제
<img src="https://velog.velcdn.com/images/rudin_/post/74d13016-85bd-4aeb-888b-8e79ea9d7ed7/image.png" alt="">
동적 콘텐츠 추가 - 하단의 매개 변수 선택
<img src="https://velog.velcdn.com/images/rudin_/post/fea39ae8-dafe-4607-9fc2-bedfc5897405/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/9d723d01-af4b-4a00-bfe4-b36a8deda08f/image.png" alt=""></p>
<h2 id="9-8-데이터세트-매개변수화싱크">9-8. 데이터세트 매개변수화(싱크)</h2>
<p><img src="https://velog.velcdn.com/images/rudin_/post/3e381de9-1279-4f92-a2f9-e384f429ada5/image.png" alt="">
수동으로 입력 체크
<img src="https://velog.velcdn.com/images/rudin_/post/377277ad-a1bf-44fb-809a-18110897c59d/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/0ed9dd3e-006c-4327-bdb5-e5cac22a6afe/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/aa69d3a3-fe79-443e-9ec2-e5bc2727abeb/image.png" alt=""></p>
<h2 id="9-9-데이터세트-매개변수화-테스트">9-9. 데이터세트 매개변수화 테스트</h2>
<p>원본 설정
<img src="https://velog.velcdn.com/images/rudin_/post/ffdeec63-560b-40b5-a7fa-aeec5c1a8428/image.png" alt="">
싱크 설정
<img src="https://velog.velcdn.com/images/rudin_/post/a523d2cf-49ac-4f5b-a4dc-a36cd30f7e0b/image.png" alt="">
디버그 실행
<img src="https://velog.velcdn.com/images/rudin_/post/b4a5f549-bd55-4f83-bd8d-4dd048cf5afc/image.png" alt=""></p>
<h2 id="9-10-데이터세트-매개변수화-적용">9-10. 데이터세트 매개변수화 적용</h2>
<p><img src="https://velog.velcdn.com/images/rudin_/post/e1ebda67-3752-46f0-b2c6-db1115c03883/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/rudin_/post/f33e8efc-71cb-46f4-a0a0-1ddc3bc0f7b9/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/e54b63af-3abe-4107-b9f8-9d937c81b0d0/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/823c9f29-af3f-46b8-a803-3cf6b041a74b/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/7fa1d821-ddfb-4c0b-b682-a5ffe7d1b1d8/image.png" alt=""></p>
<h2 id="9-11-데이터세트-매개변수화-활용-데이터-백업-추가">9-11. 데이터세트 매개변수화 활용: 데이터 백업 추가</h2>
<p><img src="https://velog.velcdn.com/images/rudin_/post/a4b067d7-7d62-47e5-98ce-de950a435da1/image.png" alt=""></p>
<p>데이터복사 추가 
<img src="https://velog.velcdn.com/images/rudin_/post/f882cfb3-1008-4e27-8d7d-0552dc7ed593/image.png" alt="">
<img src="https://velog.velcdn.com/images/rudin_/post/4bdbb1ad-14fe-408c-b45a-9f2fb3e00d74/image.png" alt="">
위에서부터 성공, 실패, 항상처리 시 다음 처리 연결 노드</p>
<p>백업이므로 output(원본) → input(싱크)
<img src="https://velog.velcdn.com/images/rudin_/post/416807eb-ca49-4753-9323-04bf8cd766ea/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/81e9810f-9d6f-42c1-b0e9-6615e5c63818/image.png" alt=""></p>
<p>디버그
<img src="https://velog.velcdn.com/images/rudin_/post/4042ad77-706c-4045-a187-29e38071dcd1/image.png" alt="">
추가된것을 확인 가능
<img src="https://velog.velcdn.com/images/rudin_/post/b1af25af-ccad-4e6b-99a0-3731d6a70935/image.png" alt=""></p>
<hr>
<h2 id="9-12-데이터세트-매개변수화-활용2-데이터-백업-날짜-추가">9-12. 데이터세트 매개변수화 활용2: 데이터 백업 날짜 추가</h2>
<p><img src="https://velog.velcdn.com/images/rudin_/post/a101f553-0a36-48df-b633-f35a36bad5c4/image.png" alt=""></p>
<p><code>싱크</code> - <code>값</code>에 동적 콘텐츠 추가 후 식 선택</p>
<ol>
<li>String - concat
<img src="https://velog.velcdn.com/images/rudin_/post/c6445752-a659-4dd4-b741-0e485156b721/image.png" alt=""></li>
<li>Date-utcNow
<img src="https://velog.velcdn.com/images/rudin_/post/ea0e8f3b-d22d-47b8-9553-4cfbcf95327d/image.png" alt=""></li>
<li>쉼표 입력 후 <code>.csv</code> 입력 
<img src="https://velog.velcdn.com/images/rudin_/post/a7f722fe-68f0-4855-8c26-44201d7717d9/image.png" alt=""></li>
</ol>
<p>디버그 확인
<img src="https://velog.velcdn.com/images/rudin_/post/5117f079-45d5-4fa5-8187-dcafebd87d98/image.png" alt=""></p>
<hr>
<h1 id="10-adf의-parameters">10. ADF의 parameters</h1>
<h4 id="linked-service-매개변수">Linked Service 매개변수</h4>
<p>• 예시 : SQL Server에서 사용되는 데이터베이스를 매개변수화</p>
<h4 id="dataset-매개변수">Dataset 매개변수</h4>
<p>• 예시 : 파일 이름, Blob 컨테이너 등을 매개변수화</p>
<h4 id="pipeline-매개변수">Pipeline 매개변수</h4>
<p>• pipeline 내에서 특정 값을 전달할 수 있도록 매개변수 사용
• 예시 : pipeline에서 특정 원본 파일을 특정 싱크 파일에 복사하도록 매개변수 지정</p>
<h4 id="global-매개변수">Global 매개변수</h4>
<p>• Data Factory 수준에서 사용되는 매개변수
• 원하는 곳에서 참조 가능</p>
<hr>
<h1 id="11-파이프라인-매개변수화">11. 파이프라인 매개변수화</h1>
<p><img src="https://velog.velcdn.com/images/rudin_/post/a974e9dd-8745-4abc-9165-3793dcd73a41/image.png" alt=""></p>
<p>파이프라인 매개변수 설정
<img src="https://velog.velcdn.com/images/rudin_/post/cfea01f8-9257-48f1-a13d-f7beacd25415/image.png" alt=""></p>
<p>파이프라인식 작성기에서 파라미터 추가
<img src="https://velog.velcdn.com/images/rudin_/post/c307cfa3-e3ce-4d67-ba1d-84fd1ef043a1/image.png" alt=""></p>
<p>원본에도 동적 콘텐츠 추가
<img src="https://velog.velcdn.com/images/rudin_/post/5cd3cfbf-ed77-491b-b5f2-d40ffbc2f016/image.png" alt=""></p>
<p>Copy data1쪽도 동일하게 처리
원본-동적콘텐츠추가
<img src="https://velog.velcdn.com/images/rudin_/post/d9339511-9657-4eae-8408-2eeabd5ba08b/image.png" alt=""></p>
<p>싱크-동적콘텐츠추가
<img src="https://velog.velcdn.com/images/rudin_/post/7c54deaa-a778-4911-bd22-87e2f00278e4/image.png" alt=""></p>
<p>디버그
<img src="https://velog.velcdn.com/images/rudin_/post/36e9919b-6770-4e34-bc8f-f659d729cbe7/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/871a76f1-183c-40b6-a4aa-7bd500f8b7b5/image.png" alt=""></p>
<p>이후 게시</p>
<hr>
<h1 id="트리거">트리거</h1>
<h2 id="1-트리거trigger-개요-및-방식"><strong>1. 트리거(Trigger) 개요 및 방식</strong></h2>
<ul>
<li><strong>개요</strong>: 데이터 수집/변환 워크플로우 설계 시 작업 실행 시점과 방식을 설계하는 것은 매우 중요하며, 트리거는 워크플로우 자동화의 핵심 요소입니다.</li>
<li><strong>제공되는 트리거 방식</strong>:<ul>
<li><strong>Schedule 트리거</strong>: 지정한 일정에 따라 정기적으로 파이프라인을 실행합니다.</li>
<li><strong>Tumbling Window 트리거</strong>: 고정된 시간 간격(윈도우)을 기준으로 데이터를 수집하고 처리하며, 각 윈도우는 겹치지 않습니다.</li>
<li><strong>Storage Event 트리거</strong>: Azure Blob Storage에서 파일이 생성되거나 변경되는 이벤트에 반응합니다.</li>
<li><strong>Custom Event 트리거</strong>: Event Grid, Event Hub 등을 연동하여 사용자 정의 이벤트를 수신하고 파이프라인을 실행합니다.</li>
<li><strong>Manual 트리거</strong>: ADF Studio UI에서 직접 실행하거나 REST API 호출을 통해 수동으로 실행합니다.</li>
</ul>
</li>
</ul>
<h2 id="2-schedule-트리거-상세"><strong>2. Schedule 트리거 상세</strong></h2>
<p><img src="https://velog.velcdn.com/images/rudin_/post/1d98dd05-50d2-483a-910f-9e0dc0fa453d/image.png" alt=""></p>
<ul>
<li><strong>정의</strong>: 시작일, 종료일, 주기, 특정 요일 등을 기반으로 파이프라인 실행 일정을 구성하는 방식입니다.</li>
<li><strong>설정 항목</strong>:<ul>
<li><strong>Start Date</strong>: 트리거 시작일 지정.</li>
<li><strong>Time</strong>: 트리거 시작 시각 지정.</li>
<li><strong>Time Zone</strong>: 시간대 설정 (예: Seoul UTC+9).</li>
<li><strong>Recurrence</strong>: 반복 실행 주기 (분, 시간, 일, 주, 월).</li>
<li><strong>End Date</strong>: 트리거 반복 종료 시점 (선택 사항).</li>
</ul>
</li>
<li><strong>적합한 작업</strong>: &quot;하루 한 번&quot;, &quot;매주 월요일 오전 9시&quot;, &quot;매달 1일 오전 2시&quot; 등 정기적인 반복 작업.</li>
<li><strong>관계</strong>: <strong>Many-to-many</strong> 관계로, 하나의 트리거가 여러 파이프라인을 실행할 수 있고 하나의 파이프라인이 여러 트리거에 연결될 수 있습니다.</li>
<li><strong>적용 방식</strong>:<ul>
<li><strong>주기 기반</strong>: 매 1시간, 매일 등 정해진 간격 반복 (실행 시 겹치지 않도록 주의).</li>
<li><strong>특정 시간 지정</strong>: 매일 오전 10시 30분 등 세밀한 시간 지정.</li>
<li><strong>요일 지정</strong>: 매주 월, 수, 금요일 등 요일 기준 실행.</li>
<li><strong>날짜 지정</strong>: 매월 1일, 15일 등 월마다 반복되는 이벤트 처리.</li>
</ul>
</li>
</ul>
<h2 id="3-tumbling-windows-트리거-상세"><strong>3. Tumbling Windows 트리거 상세</strong></h2>
<ul>
<li><strong>정의</strong>: 고정된 크기의 시간 간격으로 구간을 나누고, 각 구간(윈도우)에 대해 하나의 파이프라인 실행을 트리거합니다. 윈도우 간 중첩이 없고 독립적인 실행 단위로 관리됩니다.</li>
<li><strong>주요 특징</strong>:<ul>
<li><strong>간격(Interval)</strong>: 일정 주기 지정.</li>
<li><strong>윈도우 간 관계</strong>: 중첩 없이 실행되며 윈도우 단위별 독립 실행.</li>
<li><strong>재시도 정책(Retry)</strong>: 파이프라인 수준에서 자동 재시도 가능.</li>
<li><strong>상태 관리(Concurrency)</strong>: 이전 실행 결과를 고려할 수 있도록 동시성 설정 지원. 빡빡하게 관리할거면 1로 설정</li>
<li><strong>관계</strong>: <strong>One-to-one</strong> 관계로, 각 트리거는 특정 파이프라인에만 연결됩니다.</li>
<li><strong>시간대 기준</strong>: UTC.</li>
</ul>
</li>
<li><strong>적용 예시</strong>: 시간 구간별 안정적 수행이 필요한 업무, 센서 데이터/로그 등 시간 단위 데이터 처리, 이전 실행 상태에 따른 다음 처리 여부 결정, 실행 실패 시 자동 재시도가 필요한 경우, 상태 기반 병렬 처리 설정 시 활용됩니다.</li>
<li><strong>Schedule 트리거와 비교</strong>:
<img src="https://velog.velcdn.com/images/rudin_/post/b2553788-eaea-4873-9212-ae4257bff319/image.png" alt=""></li>
</ul>
<table>
<thead>
<tr>
<th align="left">구분</th>
<th align="left">Schedule Trigger</th>
<th align="left">Tumbling Windows Trigger</th>
</tr>
</thead>
<tbody><tr>
<td align="left"><strong>주기 유형</strong></td>
<td align="left">고정 주기</td>
<td align="left">고정 간격 시간 구간</td>
</tr>
<tr>
<td align="left"><strong>재시도 정책</strong></td>
<td align="left">없음</td>
<td align="left">파이프라인 단위 재시도 지원</td>
</tr>
<tr>
<td align="left"><strong>실행 관계</strong></td>
<td align="left">Many-to-many</td>
<td align="left">One-to-one</td>
</tr>
<tr>
<td align="left"><strong>상태 관리</strong></td>
<td align="left">이전 수행 상태와 무관</td>
<td align="left">이전 파이프라인 상태 고려</td>
</tr>
<tr>
<td align="left"><strong>실행 의존성</strong></td>
<td align="left">없음</td>
<td align="left">다른 트리거에 의존 가능</td>
</tr>
<tr>
<td align="left"><strong>적용 예</strong></td>
<td align="left">단순 정기 실행</td>
<td align="left">시간 단위 안정적 처리</td>
</tr>
</tbody></table>
<ul>
<li><strong>동시성(Concurrency)</strong>: 1일 경우 이전 파이프라인 종료 시까지 대기하며, 2 이상일 경우 주기가 짧을 때 여러 파이프라인이 겹쳐서 실행될 수 있습니다. 처리되지 못한 윈도우가 쌓였을 때 이를 빠르게 소진하기 위해 2 이상으로 설정하기도 합니다.</li>
<li>작업에 따라 적절한 동시성을 설정해야 합니다. 동시성 설정에 따라, 파이프라인 실행이 무한 대기에 빠질
수도 있으며, 겹쳐서 수행되는 파이프라인으로 인해 문제가 발생할 수 있습니다.
<img src="https://velog.velcdn.com/images/rudin_/post/437ae464-1f27-42b8-8fa5-c67c6bd5ce80/image.png" alt=""></li>
</ul>
<h2 id="4-storage-event-트리거-상세"><strong>4. Storage Event 트리거 상세</strong></h2>
<p><img src="https://velog.velcdn.com/images/rudin_/post/95870438-1996-46aa-8598-bdd3599d6ea4/image.png" alt=""></p>
<ul>
<li><strong>정의</strong>: Blob Storage에서 이벤트 발생 시 즉시 자동으로 파이프라인을 실행합니다.</li>
<li><strong>주요 특징</strong>: 실시간성, 자동화(수동 모니터링 불필요), Event Grid 기반(이벤트 감지 및 자동 호출), 다대다 관계 지원.</li>
<li><strong>이벤트 흐름</strong>: 컨텐트 자체가 아닌 <strong>이벤트 정보(파일 이름, 파일 경로 등)</strong>를 전달하며, 데이터 컨텐트는 전달받은 정보를 바탕으로 직접 가져와야 합니다.</li>
<li><strong>설정 및 동작</strong>:<ul>
<li><strong>Blob path ends with</strong>: 설정한 값(예: .csv)으로 끝나는 파일에 대해 적용.</li>
<li><strong>이벤트 종류</strong>: Blob created(생성) 또는 Blob deleted(삭제).</li>
<li><strong>Ignore empty blobs</strong>: 비어 있는 블롭에 대한 처리 여부 설정.</li>
<li><strong>제한 사항</strong>: 파이프라인 실패 시 재시도 정책이 없으며, 동시성 정책이 없어 이벤트 발생 시마다 겹쳐서 수행될 수 있습니다.</li>
</ul>
</li>
</ul>
<h2 id="5-manual-트리거-상세"><strong>5. Manual 트리거 상세</strong></h2>
<p><img src="https://velog.velcdn.com/images/rudin_/post/347e1d9b-6342-44c1-83ab-75ab8d271055/image.png" alt=""></p>
<ul>
<li><strong>정의</strong>: ADF UI 내 메뉴를 통하거나 REST API를 사용하여 수동으로 파이프라인을 트리거합니다.</li>
<li><strong>방법</strong>: ADF UI 직접 트리거 또는 Azure Logic Apps와 연동하여 외부 요청에 따라 파이프라인을 실행합니다.</li>
</ul>
<hr>
<h2 id="6-실습-트리거-구성-과정"><strong>6. [실습] 트리거 구성 과정</strong></h2>
<h3 id="6-1-실습-준비-컨테이너-및-링크드-서비스"><strong>6-1. 실습 준비 (컨테이너 및 링크드 서비스)</strong></h3>
<ul>
<li><p><strong>컨테이너</strong>: <code>input</code>, <code>output</code> 컨테이너를 생성하고 <code>input</code>에 <code>iris.csv</code>를 업로드합니다.<img src="https://velog.velcdn.com/images/rudin_/post/c436f5a3-c126-48a4-ae5f-c801af782c48/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/9446d7e5-ac89-4afb-b31d-63e1122c1086/image.png" alt=""></p>
</li>
<li><p><strong>링크드 서비스</strong>: <code>BlobStorage1</code> (Azure Blob Storage) 생성.
input output을 구분하지 않고 쓸 수 있도록 새로 생성
<img src="https://velog.velcdn.com/images/rudin_/post/b6f475c8-394c-4957-a48a-895925c324ba/image.png" alt=""></p>
</li>
<li><p><strong>데이터세트</strong>: <code>inputCSV1</code>(input 컨테이너), <code>outputCSV1</code>(output 컨테이너) 생성. <code>inputCSV1</code>은 첫 번째 행을 머리글로 설정합니다.
<img src="https://velog.velcdn.com/images/rudin_/post/9398aba5-9324-43b5-865f-fcf77618ec77/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/a7825ed4-78b8-490b-ab2e-33f3266cd10a/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/d4863da9-0ee4-4623-ae31-5a46b237d638/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/f5d02ab0-e4f7-45c2-9a57-009f3629c755/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/b8ad5618-fd1b-4a5b-b13a-660a69f15a75/image.png" alt=""></p>
</li>
<li><p><strong>파이프라인</strong>: <code>pipeline1</code> 생성 후 <strong>Copy Data</strong> 활동을 배치하고 원본과 싱크를 연결합니다.
<img src="https://velog.velcdn.com/images/rudin_/post/770ac6a4-fefa-4b6e-8749-4782579c22f6/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/45e9b36e-f1e1-4f54-a1ec-ff04fbc74dc0/image.png" alt=""></p>
</li>
<li><p><strong>매개변수화</strong>: 데이터세트에 <code>fileName</code> 매개변수를 생성하고 파일 경로에 <code>@dataset().fileName</code> 동적 콘텐츠를 추가합니다. 파이프라인 테스트 시 <code>iris.csv</code>를 입력하여 성공 여부를 확인합니다.
<img src="https://velog.velcdn.com/images/rudin_/post/e610b786-e8b1-4399-8e2b-fcb43673bc68/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/c5498229-126e-4f13-acf9-132fe682121b/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/933117e3-eda6-4880-8b3b-f5e66e832bbb/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/d577e4fe-fa09-46db-82a5-113fdad2be49/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/15314407-28a3-40f5-8707-b2998b3427eb/image.png" alt=""></p>
</li>
<li><p><strong>디버깅</strong>: 파이프라인의 원본, 싱크에 값 입력
<img src="https://velog.velcdn.com/images/rudin_/post/8c23f100-7ab6-4220-82e4-b86c54f3f29e/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/5c89c844-45ef-404b-b5e5-24b50697f7d0/image.png" alt=""></p>
</li>
</ul>
<h3 id="6-2-schedule-트리거-실습"><strong>6-2. Schedule 트리거 실습</strong></h3>
<p><img src="https://velog.velcdn.com/images/rudin_/post/5cff2d40-6b4e-4d92-bfcd-176dbb5a41cc/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/d61fab51-3145-4426-a7e7-50e66a481290/image.png" alt=""></p>
<ul>
<li><p><strong>트리거 생성</strong>: <code>scheduleTrigger1</code> (형식: 일정, 주기: 15분) 생성 및 게시.</p>
</li>
<li><p><strong>검증</strong>: 모니터링 탭에서 성공 상태와 생성된 파일을 확인합니다.
<img src="https://velog.velcdn.com/images/rudin_/post/f2990e32-af28-452f-ab45-09075ba3310a/image.png" alt="">
트리거 편집에서 주 단위 고급 되풀이 옵션 확인 가능
<img src="https://velog.velcdn.com/images/rudin_/post/b33f58fa-e03c-44ee-b0b3-8297abca4bcd/image.png" alt=""></p>
</li>
<li><p><strong>One-to-Many 실습</strong>: <code>pipeline2</code>(Wait 활동 포함)를 생성하고 기존 <code>scheduleTrigger1</code>에 연결하여 두 파이프라인이 동시 실행되는 것을 확인합니다.
<img src="https://velog.velcdn.com/images/rudin_/post/089ef26a-fa67-49fc-8a52-7a5856b8bcfc/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/92dc2088-aaa9-4b6c-8e3a-ee13ab764665/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/df872bfe-3fa9-4818-a0b2-073084deaac2/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/19de17a8-93cd-4628-9769-ece8f80b94b3/image.png" alt="">
파이프라인은 여러개지만 트리거는 동일한 트리거
<img src="https://velog.velcdn.com/images/rudin_/post/bc8db634-6a74-420f-be0b-3697c7e37229/image.png" alt="">
<img src="https://velog.velcdn.com/images/rudin_/post/b00cd9a6-f5a5-4ae3-9458-7a146cfad3bd/image.png" alt="">
<code>관리</code>-<code>트리거</code>에서 중지 후 삭제 가능
<img src="https://velog.velcdn.com/images/rudin_/post/f77e197b-a1b8-4fc4-9828-0b6a4c589d0d/image.png" alt="">
중괄호 아이콘 선택 시 트리거 코드 확인 가능
<img src="https://velog.velcdn.com/images/rudin_/post/b21ee5c8-59d1-4dcf-b260-9d211343cbb4/image.png" alt=""></p>
</li>
</ul>
<h3 id="6-3-tumbling-window-트리거-실습"><strong>6-3. Tumbling Window 트리거 실습</strong></h3>
<p><img src="https://velog.velcdn.com/images/rudin_/post/150e190f-6ea9-4dfa-96fa-c6533f109463/image.png" alt="">
Tumbling Window는 오프셋 오류를 방지하기 위해 00초로 설정하는게 바람직
<img src="https://velog.velcdn.com/images/rudin_/post/5a5f0075-be9e-4970-8fef-059a7cb1cd10/image.png" alt="">
재시도 정책을 2로 하면 실패시 재시도를 최대 2번까지 함
오프셋은 보통 이전걸 참조해야하니 음수값을 많이 준다
창크기는 고정된 시간 기준으로 이전 몇개까지 진행됐던 걸 볼거냐를 설정 가능케 함</p>
<ul>
<li><p><strong>파이프라인 준비</strong>: <code>LoadData</code>(Wait 3초)와 <code>ProcessData</code>(Wait 5초) 파이프라인을 준비합니다.</p>
</li>
<li><p><strong>트리거 구성</strong>: <code>TW_LoadData1</code> 생성 후, <code>TW_ProcessData1</code> 생성 시 <strong>종속성 추가</strong>를 통해 <code>TW_LoadData1</code>이 성공한 후에만 실행되도록 설정합니다.
<img src="https://velog.velcdn.com/images/rudin_/post/04e47b7a-0c5a-46ae-a64a-2f5700201fe7/image.png" alt=""></p>
</li>
<li><p>만약 현재 시각이 08:00:00인 경우, 창 크기가 5분인 경우의 모든 종속성을 검토하고자 한다면 최소한 현재보다 5분 전으로 시작시간을 설정해야 함</p>
</li>
<li><p><strong>확인</strong>: 모니터링 화면에서 &#39;종속성 대기&#39; 및 &#39;성공&#39; 상태를 확인하고 Gantt 차트로 업스트림 관계를 검토합니다.
<img src="https://velog.velcdn.com/images/rudin_/post/7c5ce8d7-0900-46f3-b126-e91ccde0a3ec/image.png" alt="">
<img src="https://velog.velcdn.com/images/rudin_/post/984c488d-523a-4248-b268-af7320efe0c1/image.png" alt="">
<img src="https://velog.velcdn.com/images/rudin_/post/22ccdc33-3134-43d3-9eaf-8da893e7efa8/image.png" alt=""></p>
</li>
</ul>
<p>매번 변경사항마다 게시를 눌러야 적용됨을 잊지 말자 </p>
<h3 id="6-4-storage-event-트리거-실습"><strong>6-4. Storage Event 트리거 실습</strong></h3>
<ul>
<li><p><strong>준비</strong>: <code>fileName</code> 매개변수를 사용하는 <code>Copy CVS</code> 파이프라인을 생성합니다.(입출력 전부 fileName 매개변수 사용, input output 구분만 주의)</p>
</li>
<li><p><strong>트리거 생성</strong>: <code>SE_NewCSV</code> (이벤트: 생성됨, 끝 문자: .CSV) 생성 및 파이프라인 매개변수에 <code>@triggerBody().fileName</code>을 매핑합니다.
<img src="https://velog.velcdn.com/images/rudin_/post/7f7b0f1f-4944-4b2d-bfa4-e3180d32ec80/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/da171cab-0880-4a7e-ac63-61fcbcfeb009/image.png" alt=""></p>
</li>
<li><p><strong>테스트</strong>: <code>input</code> 컨테이너에 <code>penguins.csv</code>를 업로드하여 파이프라인이 자동 실행되는지 확인합니다.</p>
</li>
<li><p><strong>파일 삭제 실습</strong>: <strong>Delete</strong> 활동을 사용하는 파이프라인과 <code>SE_DeleteCSV</code>(이벤트: 삭제됨) 트리거를 생성하여 파일 삭제 시 로깅이 발생하는지 테스트합니다.
<img src="https://velog.velcdn.com/images/rudin_/post/2125edb1-6254-4dca-912b-844ce013d16a/image.png" alt="">
Delete시에는 항상 로깅을 기본으로 해야 함
<img src="https://velog.velcdn.com/images/rudin_/post/e1dab73e-830b-4893-834c-ac424f0d79a0/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/c5e2c418-8e40-4277-9df5-473ca48b92ea/image.png" alt="">
추가 및 실행 후 input에서 파일을 삭제 시 output에서도 삭제되는지 확인 </p>
</li>
</ul>
<h3 id="6-5-수동-트리거-logic-apps-실습"><strong>6-5. 수동 트리거 (Logic Apps) 실습</strong></h3>
<ul>
<li><p><strong>준비</strong>: <code>Copy CSV to CSV</code> 파이프라인과 관련 데이터세트(<code>inputCSV3</code>, <code>outputCSV3</code>)를 준비합니다.</p>
</li>
<li><p><strong>Logic Apps 구성</strong>: <code>A000-manual-trigger</code> 로직 앱을 생성하고, <strong>Recurrence</strong> 트리거와 <strong>Azure Data Factory - 파이프라인 실행 만들기</strong> 동작을 추가합니다.
<img src="https://velog.velcdn.com/images/rudin_/post/b19dfafb-61db-4cd8-8356-4dda3b868a34/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/e33485c8-35e3-4c8b-8e80-27365eed1f03/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/9aa98c17-c252-4d42-92ac-18c0da6e3937/image.png" alt=""></p>
</li>
<li><p><strong>매개변수 주입</strong>: 로직 앱에서 <code>{&quot;fileName&quot;:&quot;penguins.csv&quot;}</code> JSON 데이터를 전달하도록 설정합니다.</p>
</li>
<li><p><strong>확인</strong>: 로직 앱 실행 기록(Succeeded)과 ADF 파이프라인 모니터링(수동 트리거 항목)을 통해 최종 결과를 확인합니다.
<img src="https://velog.velcdn.com/images/rudin_/post/c427c8ed-248e-43a9-bec9-561115ff3790/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/361c5b6a-6ac9-46e8-a9c5-572884f2e56c/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/a7755f7a-dc33-4b48-8ed5-df0333064f40/image.png" alt=""></p>
</li>
</ul>
<hr>
<h1 id="혹시몰라서-매개변수-등록-순서-정리">(혹시몰라서) 매개변수 등록 순서 정리</h1>
<p>Azure Data Factory(ADF)에서 매개변수를 설정하는 과정은 크게 <strong>데이터세트 수준의 설정</strong>, <strong>파이프라인 수준의 설정</strong>, 그리고 이 둘을 <strong>연결(매핑)하는 과정</strong>으로 나뉩니다. 전체적인 스텝을 순서대로 정리해 드립니다.</p>
<h3 id="1-데이터세트-매개변수-정의"><strong>1. 데이터세트 매개변수 정의</strong></h3>
<p>먼저 데이터를 동적으로 처리할 수 있도록 데이터세트 자체에 매개변수를 생성합니다.</p>
<ol>
<li><strong>데이터세트 열기</strong>: 수정할 데이터세트를 선택합니다.</li>
<li><strong>매개변수 탭 이동</strong>: 하단의 <strong>[매개변수]</strong> 탭을 클릭한 후 <strong>[+새로 만들기]</strong>를 통해 사용할 이름(예: <code>fileName</code>, <code>tableName</code>)과 형식을 지정합니다.</li>
<li><strong>동적 콘텐츠 적용</strong>: <strong>[연결]</strong> 탭으로 돌아가 동적으로 바뀔 항목(파일명, 테이블명 등)을 클릭하고 하단에 나타나는 <strong>[동적 콘텐츠 추가]</strong>를 선택합니다.</li>
<li><strong>식 작성</strong>: 파이프라인 식 작성기에서 앞서 만든 매개변수를 선택하여 <code>@dataset().매개변수명</code> 형태의 식이 입력되도록 합니다.</li>
</ol>
<h3 id="2-파이프라인-매개변수-정의"><strong>2. 파이프라인 매개변수 정의</strong></h3>
<p>파이프라인 실행 시 외부에서 값을 입력받을 수 있도록 설정합니다.</p>
<ol>
<li><strong>파이프라인 캔버스 클릭</strong>: 파이프라인 내 빈 공간을 클릭하여 하단 속성창을 활성화합니다.</li>
<li><strong>매개변수 추가</strong>: 하단의 <strong>[매개변수]</strong> 탭에서 <strong>[+새로 만들기]</strong>를 눌러 외부에서 주입받을 매개변수 이름과 형식을 정의합니다.</li>
</ol>
<h3 id="3-활동activity에서-매개변수-매핑"><strong>3. 활동(Activity)에서 매개변수 매핑</strong></h3>
<p>파이프라인 매개변수를 데이터세트 매개변수로 전달하는 과정입니다.</p>
<ol>
<li><strong>활동 선택</strong>: 파이프라인 내의 활동(예: 복사 활동)을 클릭합니다.</li>
<li><strong>원본/싱크 설정</strong>: 활동 속성의 <strong>[원본]</strong> 또는 <strong>[싱크]</strong> 탭으로 이동합니다.</li>
<li><strong>데이터세트 속성 입력</strong>: 해당 탭 하단의 <strong>데이터세트 속성</strong> 섹션에 이전에 정의한 데이터세트 매개변수들이 나열됩니다.</li>
<li><strong>파이프라인 매개변수 연결</strong>: 각 속성의 값 필드를 클릭하고 <strong>[동적 콘텐츠 추가]</strong>를 눌러 파이프라인 매개변수를 선택합니다. 식은 <code>@pipeline().parameters.매개변수명</code> 형태로 구성됩니다.</li>
</ol>
<p>이렇게 설정이 완료되면 파이프라인을 <strong>디버그</strong>할 때 팝업창을 통해 매개변수 값을 직접 입력하여 테스트할 수 있습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[MicrosoftDataSchool] 57일차 - AzureDatabricks 추천 시스템(ALS), DeltaLake Table System, Pipeline]]></title>
            <link>https://velog.io/@rudin_/MicrosoftDataSchool-57%EC%9D%BC%EC%B0%A8-AzureDatabricks-%EC%B6%94%EC%B2%9C-%EC%8B%9C%EC%8A%A4%ED%85%9CALS-DeltaLake-Table-System-Pipeline</link>
            <guid>https://velog.io/@rudin_/MicrosoftDataSchool-57%EC%9D%BC%EC%B0%A8-AzureDatabricks-%EC%B6%94%EC%B2%9C-%EC%8B%9C%EC%8A%A4%ED%85%9CALS-DeltaLake-Table-System-Pipeline</guid>
            <pubDate>Fri, 27 Mar 2026 08:47:39 GMT</pubDate>
            <description><![CDATA[<h1 id="추천-시스템">추천 시스템</h1>
<blockquote>
<p>사용자의 행동 패턴과 아이템의 특성을 분석하여 개인 맞춤형 컨텐츠를 제공하는 인공지능 기술</p>
</blockquote>
<table>
<thead>
<tr>
<th>협업 필터링</th>
<th>콘텐츠 기반 필터링</th>
</tr>
</thead>
<tbody><tr>
<td>비슷한 취향을 가진 사용자들의 집단 행동 패턴을 분석합니다</td>
<td>아이템 자체의 특성과 메타데이터를 분석합니다</td>
</tr>
<tr>
<td>사용자 간 유사도 계산</td>
<td>장르, 키워드 매칭</td>
</tr>
<tr>
<td>평점 기반 예측</td>
<td>아이템 속성 분석</td>
</tr>
<tr>
<td>집단 지성 활용</td>
<td>프로필 기반 추천</td>
</tr>
</tbody></table>
<h2 id="유저-기반-협업-필터링-user-based-cf">유저 기반 협업 필터링 (User-based CF)</h2>
<h3 id="01-유사-사용자-탐색">01. 유사 사용자 탐색</h3>
<p>코사인 유사도, 피어슨 상관계수 등을 통해  비슷한 평점 패턴을 가진 사용자 그룹을 탐색</p>
<h3 id="02-이웃-선정">02. 이웃 선정</h3>
<p>유사도가 높은 상위 K명의 사용자(이웃)를 선택하여 추천 기반을 구성</p>
<h3 id="03-평점-예측">03. 평점 예측</h3>
<p>이웃 사용자들의 평점을 가중 평균하여 대상 아이템에 대한 예측 점수를 계산</p>
<blockquote>
<p><strong>주의</strong>
대규모 유저 대상에서는 실시간 계산 비용이 증가하며,<br>희소 데이터(sparsity) 문제로 인해 정확도가 낮아질 수 있음</p>
</blockquote>
<h2 id="아이템-기반-협업-필터링-item-based-cf">아이템 기반 협업 필터링 (Item-based CF)</h2>
<h3 id="작동-원리">작동 원리</h3>
<p>사용자가 좋아한 아이템과 유사한 다른 아이템을 찾아 추천 
아이템 간 유사도는 코사인 유사도 등을 활용해 측정 
아마존의 “이 상품을 구매한 고객이 함께 본 상품” 기능의 기반이 되는 방식</p>
<h3 id="핵심-장점">핵심 장점</h3>
<ul>
<li>아이템 유사도는 시간에 따라 상대적으로 안정적</li>
<li>오프라인에서 미리 계산 가능</li>
<li>확장성이 뛰어남</li>
</ul>
<p>중요: 유사도를 0~1 사이의 값으로 만들기</p>
<h2 id="콜드스타트-협업-필터링의-한계">콜드스타트: 협업 필터링의 한계</h2>
<ul>
<li>신규 유저나 신규 아이템에 대한 충분한 상호작용 데이터가 부족하여 정확한 추천이 어려운 상황을 의미</li>
<li>초기 사용자 이탈률과 직결됨</li>
</ul>
<h3 id="원인">원인</h3>
<ul>
<li>서비스 초기 단계에서 유저 수와 인터랙션 데이터가 절대적으로 부족한 상황</li>
<li>신상품 출시 직후 사용자들의 평가나 구매 기록이 전혀 없는 상태</li>
<li>신규 가입자의 과거 행동 기록이 없어 취향을 파악할 수 없는 문제</li>
</ul>
<h3 id="해결법">해결법</h3>
<h4 id="1-하이브리드-추천-시스템">1. 하이브리드 추천 시스템</h4>
<pre><code>콘텐츠 기반의 신규 유저/아이템 메타데이터 활용
                +
협업 필터링의 기존 유저/아이템에 집단 지성 활용</code></pre><h4 id="2-프로필-완성">2. 프로필 완성</h4>
<ul>
<li>명시적 데이터 수집: 가입 시 선호 장르, 관심사에 대한 질문지 제공</li>
<li>소셜 연동: 페이스북, 구글 계정 연동으로 기본 정보 획득</li>
<li>암묵적 피드백: 초기 클릭, 체류시간 등 행동 데이터 실시간 수집</li>
</ul>
<h2 id="모델-기반-협업-필터링">모델 기반 협업 필터링</h2>
<h3 id="행렬-분해matrix-factorization">행렬 분해(Matrix Factorization)</h3>
<p>희소한 사용자-아이템 평점 행렬을 저차원의 잠재 공간(latent space)로 분해나는 기법
사용자와 아이템을 각각 K차원의 벡터로 표현하여, 내적으로 평점을 예측
잠재 요인(latent factors)을 통해 명시적으로 드러나지 않는 사용자 취향과 아이템 특성을 학습
분포가 적절한 면을 따서 잘 펼쳐서 사용(차원 줄이고 계산량 줄이고)</p>
<table>
<thead>
<tr>
<th>구분</th>
<th>내용</th>
</tr>
</thead>
<tbody><tr>
<td>장점</td>
<td>- 희소 데이터 처리 능력 우수<br>- 확장성이 뛰어나 대규모 서비스에 적합<br>- 과적합 방지 가능<br>- 오프라인 학습 후 빠른 추론</td>
</tr>
<tr>
<td>단점</td>
<td>- 모델 학습이 복잡하고 시간 소요<br>- 결과 해석이 어려움<br>- 하이퍼파라미터 튜닝 필요<br>- 신규 데이터 반영에 시간 지연</td>
</tr>
</tbody></table>
<h2 id="강화학습과-밴딧-알고리즘으로-추천-혁신">강화학습과 밴딧 알고리즘으로 추천 혁신</h2>
<h3 id="추천-문제와-밴딧-알고리즘">추천 문제와 밴딧 알고리즘</h3>
<ul>
<li>탐색: 새로운 아이템을 시도하여 더 나은 선택지를 발견하는 과정</li>
<li>활용: 현재까지 알려진 최선의 선택을 반복하여 즉각적인 보상 극대화<h4 id="다중-슬롯머신-문제multi-armed-bandit">다중 슬롯머신 문제(Multi-Armed Bandit)</h4>
여러 선택지 중에서 시행착오를 통해 최적의 보상을 찾아가는 강화학습 프레임워크
추천 시스템은 본질적으로 탐색과 활용의 균형을 맞춰야 하는 밴딧 문제로 모델링 가능<h3 id="적용-사례">적용 사례</h3>
</li>
<li>실시간 추천 조정: 사용자 즉각적인 반응(클릭, 시청시간)을 학습하여 다음 추천을 동적으로 최적화</li>
<li>광고 클릭률 최적화: 광고 소재 중 클릭률이 높은 것을 빠르게 찾아내 노출 비중을 조절</li>
<li>뉴스 기사 추천: 빠르게 변하는 트렌드에 맞춰 인기 기사를 실시간으로 발굴하고 추천<h3 id="밴딧-알고리즘-종류">밴딧 알고리즘 종류</h3>
<table>
<thead>
<tr>
<th>방법</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>ε-그리디</td>
<td>ε 확률로 무작위 탐색, 1-ε 확률로 최선 선택</td>
</tr>
<tr>
<td>UCB</td>
<td>불확실성이 큰 선택지에 보너스를 부여하여 탐색</td>
</tr>
<tr>
<td>Thompson Sampling</td>
<td>베이지안 확률 분포에서 샘플링하여 선택</td>
</tr>
</tbody></table>
</li>
</ul>
<h2 id="성공적인-추천-시스템을-위한-핵심-포인트">성공적인 추천 시스템을 위한 핵심 포인트</h2>
<h3 id="데이터-품질과-양-확보">데이터 품질과 양 확보</h3>
<ul>
<li>충분한 양의 고품질 인터랙션 데이터 확보해야 함  </li>
<li>노이즈 제거 및 데이터 정제 필수  </li>
<li>데이터 편향 최소화 고려해야 함  </li>
</ul>
<h3 id="콜드스타트-대비-설계">콜드스타트 대비 설계</h3>
<ul>
<li>신규 사용자/아이템 대응 전략 필요  </li>
<li>하이브리드 추천 방식 적용 고려해야 함  </li>
<li>인기 기반 + 프로필 기반 추천 병행해야 함  </li>
</ul>
<h3 id="사용자-피드백-루프">사용자 피드백 루프</h3>
<ul>
<li>클릭, 체류시간, 구매 등 암묵적 피드백 활용해야 함  </li>
<li>A/B 테스트 기반으로 지속적 개선 필요  </li>
<li>사용자 행동 데이터 계속 축적해야 함  </li>
</ul>
<h3 id="확장성과-실시간-처리">확장성과 실시간 처리</h3>
<ul>
<li>대규모 트래픽 대응 위한 분산 처리 필요  </li>
<li>캐싱 전략으로 응답 속도 최적화해야 함  </li>
<li>실시간 업데이트와 배치 학습 간 균형 맞춰야 함  </li>
</ul>
<h2 id="프라이버시-보호와-추천-시스템">프라이버시 보호와 추천 시스템</h2>
<ul>
<li>연합 학습(Federated Learning)
사용자 기기에서 로컬로 모델을 학습하고, 중앙 서버는 모델 파라미터만 수집하여 개인 데이터를 보호</li>
<li>차등 프라이버시
통계적 노이즈를 추가하여 개별 사용자 정보를 보호하면서도 전체 패턴은 학습 가능</li>
<li>익명화 기술
개인 식별 정보를 제거하거나 암호화하여 프라이버시를 지키면서 추천 품질을 유지</li>
</ul>
<hr>

<h1 id="als-영화-추천-시스템-실습">ALS 영화 추천 시스템 실습</h1>
<blockquote>
<p>Databricks 메달리온 아키텍처 + Spark MLlib + MLflow
협업 필터링 기반 영화 추천 시스템 구축 실습</p>
</blockquote>
<p>Velog에 업로드하기 좋게 <strong>Databricks와 Spark MLlib을 활용한 ALS 영화 추천 시스템 구축 실습</strong> 내용을 마크다운 형식으로 정리해 드립니다.</p>
<hr>
<h1 id="실습-databricks--spark-mllib을-활용한-als-영화-추천-시스템-구축">[실습] Databricks &amp; Spark MLlib을 활용한 ALS 영화 추천 시스템 구축</h1>
<h2 id="1-실습-개요">1. 실습 개요</h2>
<ul>
<li><strong>기술 스택</strong>: Databricks, Spark MLlib, MLflow, Gradio</li>
<li><strong>아키텍처</strong>: 메달리온 아키텍처 (Bronze → Silver → Gold)</li>
<li><strong>핵심 알고리즘</strong>: ALS (Alternating Least Squares) 협업 필터링</li>
<li><strong>데이터 규모</strong>: 영화 500개, 사용자 1,000명, 평점 약 10,000건 (희소성 약 98%)</li>
</ul>
<hr>
<h2 id="2-데이터-파이프라인-및-단계별-실습-내용">2. 데이터 파이프라인 및 단계별 실습 내용</h2>
<h3 id="step-1-샘플-데이터-생성-bronze-layer">Step 1: 샘플 데이터 생성 (Bronze Layer)</h3>
<p>실제 추천 시스템과 유사한 <strong>희소성(Sparsity)</strong>을 가진 가상의 영화, 사용자, 평점 데이터를 생성하여 Delta 테이블에 저장합니다.</p>
<ul>
<li><strong>주요 작업</strong>: 현실적인 평점 패턴(영화 품질 + 사용자 성향 + 노이즈)을 반영한 데이터 생성.</li>
<li><strong>결과</strong>: 원시 데이터 형태의 <code>bronze_movies</code>, <code>bronze_users</code>, <code>bronze_ratings</code> 테이블 생성.</li>
</ul>
<h3 id="step-2-데이터-정제-및-피처-엔지니어링-silver-layer">Step 2: 데이터 정제 및 피처 엔지니어링 (Silver Layer)</h3>
<p>Bronze 데이터를 ML 모델 훈련에 적합한 형태로 변환합니다.</p>
<ul>
<li><strong>주요 변환</strong>: 장르 배열화, 연령대 인코딩, 태그 정규화 등.</li>
<li><strong>핵심 개념 - 베이지안 평균(Bayesian Average)</strong>: 평점 수가 적은 영화의 평점이 왜곡되는 것을 방지하기 위해 전체 평균과 최소 신뢰 샘플 수를 활용해 보정합니다.<ul>
<li>공식: $bayesian_avg = \frac{(v \times R + m \times C)}{(v + m)}$</li>
</ul>
</li>
</ul>
<h3 id="step-3-모델-훈련-및-실험-추적-gold-layer--ml">Step 3: 모델 훈련 및 실험 추적 (Gold Layer &amp; ML)</h3>
<p>비즈니스 집계 데이터를 생성하고 <strong>ALS 모델</strong>을 최적화합니다.</p>
<ul>
<li><strong>ALS 알고리즘</strong>: 사용자-영화 행렬을 잠재 요인(Latent Factor)으로 분해하여 누락된 평점을 예측합니다.</li>
<li><strong>MLflow 연동</strong>: 그리드 서치를 통해 <code>rank</code>, <code>regParam</code> 등의 하이퍼파라미터 조합을 실험하고, 모든 결과는 MLflow UI에 자동으로 기록 및 추적됩니다.</li>
</ul>
<h3 id="step-4-추천-서비스-구현">Step 4: 추천 서비스 구현</h3>
<p>훈련된 모델을 활용하여 실제 추천 시나리오를 구성합니다.</p>
<ul>
<li><strong>추천 시나리오</strong>: 기존 사용자 추천, 장르 기반 추천, 유사 영화 탐색.</li>
<li><strong>Cold Start 문제 해결</strong>: 평점 기록이 없는 신규 사용자를 위해 베이지안 평균 기반의 인기 영화를 추천하는 폴백(Fallback) 전략을 사용합니다.</li>
</ul>
<h3 id="step-5-gradio-인터랙티브-대시보드">Step 5: Gradio 인터랙티브 대시보드</h3>
<p>사용자가 직접 추천 결과를 확인할 수 있는 웹 UI를 구축합니다.</p>
<ul>
<li><strong>주요 기능</strong>: 개인화 추천 ID 입력, 영화 제목 검색을 통한 유사 영화 탐색, 데이터 시각화 차트 등.</li>
</ul>
<hr>
<h2 id="3-핵심-개념-요약">3. 핵심 개념 요약</h2>
<table>
<thead>
<tr>
<th align="left">용어</th>
<th align="left">설명</th>
</tr>
</thead>
<tbody><tr>
<td align="left"><strong>메달리온 아키텍처</strong></td>
<td align="left">Bronze(원시) → Silver(정제) → Gold(집계) 순의 데이터 구조</td>
</tr>
<tr>
<td align="left"><strong>ALS</strong></td>
<td align="left">협업 필터링을 위한 행렬 분해 알고리즘</td>
</tr>
<tr>
<td align="left"><strong>Cold Start</strong></td>
<td align="left">신규 사용자/영화의 데이터 부족으로 인한 추천의 어려움</td>
</tr>
<tr>
<td align="left"><strong>RMSE</strong></td>
<td align="left">예측 오차를 평가하는 지표 (낮을수록 정확함)</td>
</tr>
<tr>
<td align="left"><strong>MLflow</strong></td>
<td align="left">머신러닝 실험 추적 및 모델 버전 관리 도구</td>
</tr>
</tbody></table>
<hr>
<h2 id="4-실습-후기-및-확장-과제">4. 실습 후기 및 확장 과제</h2>
<p>이번 실습을 통해 데이터 엔지니어링(메달리온 아키텍처)부터 모델 서빙(Gradio)까지의 전체 파이프라인을 경험할 수 있었습니다. 향후에는 다음과 같은 주제로 확장이 가능합니다.</p>
<ul>
<li><strong>Model Serving</strong>: REST API로 모델 배포</li>
<li><strong>Feature Store</strong>: Databricks Feature Store 연동</li>
<li><strong>Real-time</strong>: 실시간 스트리밍 데이터를 활용한 추천 반영</li>
<li><strong>콘텐츠 기반 필터링 결합</strong>: 영화 메타데이터 기반 추천과 ALS 결합</li>
</ul>
<hr>
<h1 id="샘플-영화-데이터-생성">샘플 영화 데이터 생성</h1>
<p>영화 추천 시스템 실습을 위한 현실적인 샘플 데이터를 생성합니다.</p>
<h3 id="생성할-데이터">생성할 데이터</h3>
<table>
<thead>
<tr>
<th>테이블</th>
<th>설명</th>
<th>건수</th>
</tr>
</thead>
<tbody><tr>
<td><strong>movies</strong></td>
<td>영화 메타데이터</td>
<td>500개</td>
</tr>
<tr>
<td><strong>users</strong></td>
<td>사용자 프로필</td>
<td>1,000명</td>
</tr>
<tr>
<td><strong>ratings</strong></td>
<td>사용자-영화 평점</td>
<td>~10,000건</td>
</tr>
<tr>
<td><strong>tags</strong></td>
<td>사용자 태그</td>
<td>1,000건</td>
</tr>
</tbody></table>
<h3 id="데이터-규모-설계-근거">데이터 규모 설계 근거</h3>
<ul>
<li>실제 MovieLens Small 데이터셋 (ml-latest-small)은 약 600명 사용자, 9,000편 영화, 100,000건 평점 규모</li>
<li>본 실습에서는 <strong>학습·실행 시간을 고려</strong>하여 소규모로 설정</li>
<li>사용자당 평균 ~10개 평점 → 희소성(sparsity) 약 98% 수준 (현실적인 추천 시스템 환경)</li>
<li>사용자별 평점 수가 1~100개로 다양하게 분포 (실제 서비스와 유사한 롱테일 분포)</li>
</ul>
<h3 id="메달리온-아키텍처에서의-위치">메달리온 아키텍처에서의 위치</h3>
<p>이 노트북은 <strong>Bronze Layer (원시 데이터)</strong> 를 생성합니다.</p>
<pre><code>[데이터 생성] → Bronze (원시) → Silver (정제) → Gold (집계/ML)
    ↑ 현재 위치</code></pre><h2 id="1-환경-설정">1. 환경 설정</h2>
<p>Unity Catalog의 카탈로그와 스키마를 설정합니다.</p>
<ul>
<li><strong>CATALOG</strong>: 각자의 카탈로그 이름으로 변경하세요</li>
<li><strong>SCHEMA</strong>: 이 실습에서 사용할 스키마 이름입니다</li>
</ul>
<pre><code class="language-python">import pandas as pd
import numpy as np
from datetime import datetime, timedelta
import random
import json

# 재현성을 위한 시드 설정 (동일한 데이터를 반복 생성하기 위함)
np.random.seed(42)
random.seed(42)

# ============================================================
# ⚠️ 카탈로그 이름을 본인의 카탈로그로 변경하세요!
# ============================================================
CATALOG = &quot;3dt016_databricks&quot;
SCHEMA = &quot;movie_recommender&quot;</code></pre>
<h2 id="2-스키마-생성">2. 스키마 생성</h2>
<p>Unity Catalog 환경에서 데이터를 저장할 스키마(데이터베이스)를 생성합니다.
카탈로그는 이미 존재한다고 가정합니다. 없으면 <code>CREATE CATALOG</code> 주석을 해제하세요.</p>
<pre><code class="language-python"># 카탈로그가 없는 경우 아래 주석을 해제하세요
# spark.sql(f&quot;CREATE CATALOG IF NOT EXISTS {CATALOG}&quot;)

# 스키마 생성 및 사용 설정
spark.sql(f&quot;CREATE SCHEMA IF NOT EXISTS {CATALOG}.{SCHEMA}&quot;)
spark.sql(f&quot;USE {CATALOG}.{SCHEMA}&quot;)

print(f&quot;✅ 카탈로그/스키마 설정 완료: {CATALOG}.{SCHEMA}&quot;)</code></pre>
<h4 id="output">output</h4>
<pre><code class="language-text">✅ 카탈로그/스키마 설정 완료: 3dt016_databricks.movie_recommender</code></pre>
<h2 id="3-영화-데이터-생성">3. 영화 데이터 생성</h2>
<p>500개의 가상 영화 데이터를 생성합니다. 각 영화는 다음 속성을 가집니다:</p>
<ul>
<li><strong>movie_id</strong>: 고유 식별자 (1~500)</li>
<li><strong>title</strong>: 영화 제목 (연도 포함)</li>
<li><strong>year</strong>: 개봉 연도 (1970~2024, 최근 연도에 가중치)</li>
<li><strong>genres</strong>: 장르 (1~4개, <code>|</code>로 구분)</li>
<li><strong>director</strong>: 감독</li>
<li><strong>runtime_minutes</strong>: 러닝타임</li>
<li><strong>budget_millions</strong>: 예산 (백만 달러)</li>
<li><strong>base_quality</strong>: 영화 품질 지표 (평점 생성 시 사용, 이후 제거됨)</li>
</ul>
<pre><code class="language-python"># === 영화 메타데이터 정의 ===

# 18개 장르 (MovieLens 기준 표준 장르 분류)
GENRES = [
    &quot;Action&quot;, &quot;Adventure&quot;, &quot;Animation&quot;, &quot;Children&quot;, &quot;Comedy&quot;, &quot;Crime&quot;,
    &quot;Documentary&quot;, &quot;Drama&quot;, &quot;Fantasy&quot;, &quot;Film-Noir&quot;, &quot;Horror&quot;, &quot;Musical&quot;,
    &quot;Mystery&quot;, &quot;Romance&quot;, &quot;Sci-Fi&quot;, &quot;Thriller&quot;, &quot;War&quot;, &quot;Western&quot;
]

# 유명 감독 목록 (다양한 국적 포함)
DIRECTORS = [
    &quot;Christopher Nolan&quot;, &quot;Steven Spielberg&quot;, &quot;Martin Scorsese&quot;, &quot;Quentin Tarantino&quot;,
    &quot;Denis Villeneuve&quot;, &quot;Ridley Scott&quot;, &quot;James Cameron&quot;, &quot;David Fincher&quot;,
    &quot;Guillermo del Toro&quot;, &quot;Peter Jackson&quot;, &quot;Wes Anderson&quot;, &quot;Coen Brothers&quot;,
    &quot;Alfonso Cuarón&quot;, &quot;Bong Joon-ho&quot;, &quot;Park Chan-wook&quot;, &quot;Wong Kar-wai&quot;,
    &quot;Francis Ford Coppola&quot;, &quot;Stanley Kubrick&quot;, &quot;Alfred Hitchcock&quot;, &quot;Akira Kurosawa&quot;
]

# 영화 제목 생성을 위한 단어 사전
TITLE_PREFIXES = [&quot;The&quot;, &quot;A&quot;, &quot;Return of&quot;, &quot;Rise of&quot;, &quot;Fall of&quot;, &quot;Legend of&quot;, &quot;Secret&quot;, &quot;Last&quot;, &quot;Dark&quot;, &quot;Lost&quot;]
TITLE_WORDS = [
    &quot;Knight&quot;, &quot;Dawn&quot;, &quot;Storm&quot;, &quot;Shadow&quot;, &quot;Dragon&quot;, &quot;Phoenix&quot;, &quot;Empire&quot;, &quot;Kingdom&quot;,
    &quot;Warrior&quot;, &quot;Journey&quot;, &quot;Dream&quot;, &quot;Destiny&quot;, &quot;Heart&quot;, &quot;Soul&quot;, &quot;Fire&quot;, &quot;Ice&quot;,
    &quot;Thunder&quot;, &quot;Ocean&quot;, &quot;Mountain&quot;, &quot;Forest&quot;, &quot;City&quot;, &quot;World&quot;, &quot;Galaxy&quot;, &quot;Universe&quot;
]

def generate_movie_title():
    &quot;&quot;&quot;
    랜덤 패턴을 사용하여 현실적인 영화 제목을 생성합니다.
    4가지 패턴: &quot;The Knight&quot;, &quot;Dawn of Storm&quot;, &quot;Shadow: The Dragon&quot;, &quot;Fire Ice&quot;
    &quot;&quot;&quot;
    pattern = random.choice([1, 2, 3, 4])
    if pattern == 1:
        return f&quot;{random.choice(TITLE_PREFIXES)} {random.choice(TITLE_WORDS)}&quot;
    elif pattern == 2:
        return f&quot;{random.choice(TITLE_WORDS)} of {random.choice(TITLE_WORDS)}&quot;
    elif pattern == 3:
        return f&quot;{random.choice(TITLE_WORDS)}: {random.choice(TITLE_PREFIXES)} {random.choice(TITLE_WORDS)}&quot;
    else:
        return f&quot;{random.choice(TITLE_WORDS)} {random.choice(TITLE_WORDS)}&quot;

def generate_movies(n_movies=500):
    &quot;&quot;&quot;
    영화 데이터를 생성합니다.

    Args:
        n_movies: 생성할 영화 수 (기본값: 500)

    Returns:
        pd.DataFrame: 영화 메타데이터 DataFrame
    &quot;&quot;&quot;
    movies = []
    used_titles = set()  # 제목 중복 방지

    for movie_id in range(1, n_movies + 1):
        # 고유한 제목 생성 (중복 시 재생성)
        while True:
            title = generate_movie_title()
            if title not in used_titles:
                used_titles.add(title)
                break

        # 연도: 삼각분포 사용 → 최근 연도에 더 많은 영화가 분포
        year = int(np.random.triangular(1970, 2010, 2024))

        # 장르: 1~4개 랜덤 할당
        n_genres = random.randint(1, 4)
        movie_genres = random.sample(GENRES, n_genres)

        director = random.choice(DIRECTORS)

        # 러닝타임: 정규분포 (평균 120분, 표준편차 25분), 75~210분 범위
        runtime = int(np.random.normal(120, 25))
        runtime = max(75, min(runtime, 210))

        # 예산: 지수분포 (대부분 저예산, 소수 블록버스터), 1~300M 범위
        budget = round(np.random.exponential(50), 1)
        budget = max(1, min(budget, 300))

        # 영화 품질 지표: Beta 분포 → 대부분 중간, 일부 고품질
        # Beta(5,3) * 3 + 2 = 2.0 ~ 5.0 분포
        base_rating = np.random.beta(5, 3) * 3 + 2

        movies.append({
            &quot;movie_id&quot;: movie_id,
            &quot;title&quot;: f&quot;{title} ({year})&quot;,
            &quot;year&quot;: year,
            &quot;genres&quot;: &quot;|&quot;.join(movie_genres),
            &quot;director&quot;: director,
            &quot;runtime_minutes&quot;: runtime,
            &quot;budget_millions&quot;: budget,
            &quot;base_quality&quot;: round(base_rating, 2)
        })

    return pd.DataFrame(movies)

# 영화 데이터 생성 실행
movies_df = generate_movies(500)
print(f&quot;✅ 영화 데이터 생성 완료: {len(movies_df)}개&quot;)
movies_df.head(10)</code></pre>
<h4 id="output-1">output</h4>
<pre><code class="language-text">✅ 영화 데이터 생성 완료: 500개</code></pre>
<pre><code class="language-text">   movie_id                          title  ...  budget_millions base_quality
0         1            The Universe (1998)  ...              8.5         3.89
1         2               Lost Soul (1976)  ...            175.2         3.51
2         3      Kingdom of Thunder (1995)  ...             17.2         4.33
3         4            Kingdom Fire (2001)  ...             76.9         4.51
4         5   Dragon: Rise of Dream (2006)  ...              5.1         4.52
5         6       Legend of Destiny (1978)  ...            120.0         4.57
6         7            Dark Journey (2004)  ...            127.5         4.51
7         8   Storm: Rise of Shadow (2012)  ...             22.1         4.66
8         9  Empire: Fall of Galaxy (1982)  ...             65.3         4.24
9        10         Phoenix of Fire (2013)  ...             48.8         3.83

[10 rows x 8 columns]</code></pre>
<h2 id="4-사용자-데이터-생성">4. 사용자 데이터 생성</h2>
<p>500명의 가상 사용자를 생성합니다. 각 사용자는 다음 속성을 가집니다:</p>
<ul>
<li><strong>preference_profile</strong>: 장르 선호도 프로필 (7가지 유형) — 평점 생성 시 장르 매칭에 사용</li>
<li><strong>activity_level</strong>: 활동성 (1~5) — 평점 개수 결정에 사용</li>
<li><strong>rating_tendency</strong>: 평점 성향 (strict/neutral/lenient) — 평점 편향에 사용</li>
</ul>
<p>이 속성들은 <strong>현실적인 평점 패턴</strong>을 만들기 위한 시뮬레이션 파라미터입니다.</p>
<pre><code class="language-python"># === 사용자 속성 정의 ===

COUNTRIES = [&quot;USA&quot;, &quot;UK&quot;, &quot;Canada&quot;, &quot;Germany&quot;, &quot;France&quot;, &quot;Japan&quot;, &quot;Korea&quot;, &quot;Australia&quot;, &quot;Brazil&quot;, &quot;India&quot;]
AGE_GROUPS = [&quot;18-24&quot;, &quot;25-34&quot;, &quot;35-44&quot;, &quot;45-54&quot;, &quot;55-64&quot;, &quot;65+&quot;]

# 장르 선호도 프로필: 사용자의 시청 취향을 모델링
# 각 프로필은 특정 장르에 대한 선호도 가중치(0~1)를 정의
GENRE_PREFERENCE_PROFILES = {
    &quot;action_lover&quot;: {&quot;Action&quot;: 0.9, &quot;Adventure&quot;: 0.8, &quot;Sci-Fi&quot;: 0.7, &quot;Thriller&quot;: 0.6},
    &quot;drama_enthusiast&quot;: {&quot;Drama&quot;: 0.9, &quot;Romance&quot;: 0.7, &quot;Mystery&quot;: 0.6, &quot;Film-Noir&quot;: 0.5},
    &quot;comedy_fan&quot;: {&quot;Comedy&quot;: 0.9, &quot;Animation&quot;: 0.7, &quot;Musical&quot;: 0.6, &quot;Children&quot;: 0.5},
    &quot;horror_seeker&quot;: {&quot;Horror&quot;: 0.9, &quot;Thriller&quot;: 0.8, &quot;Mystery&quot;: 0.7, &quot;Sci-Fi&quot;: 0.5},
    &quot;family_viewer&quot;: {&quot;Animation&quot;: 0.9, &quot;Children&quot;: 0.8, &quot;Comedy&quot;: 0.7, &quot;Adventure&quot;: 0.6},
    &quot;cinephile&quot;: {&quot;Drama&quot;: 0.8, &quot;Film-Noir&quot;: 0.8, &quot;Documentary&quot;: 0.7, &quot;War&quot;: 0.6},
    &quot;balanced&quot;: {}  # 균형잡힌 취향 (특별한 선호 없음)
}

def generate_users(n_users=1000):
    &quot;&quot;&quot;
    사용자 데이터를 생성합니다.

    Args:
        n_users: 생성할 사용자 수 (기본값: 1,000)

    Returns:
        pd.DataFrame: 사용자 프로필 DataFrame
    &quot;&quot;&quot;
    users = []

    for user_id in range(1, n_users + 1):
        # 가입일: 2015~2024년 사이 랜덤
        days_since_start = random.randint(0, 365 * 9)
        signup_date = datetime(2015, 1, 1) + timedelta(days=days_since_start)

        country = random.choice(COUNTRIES)

        # 연령대: 25-34가 가장 많은 분포 (실제 스트리밍 서비스 인구통계 반영)
        age_weights = [0.15, 0.30, 0.25, 0.15, 0.10, 0.05]
        age_group = random.choices(AGE_GROUPS, weights=age_weights)[0]

        # 장르 선호도 프로필 랜덤 할당
        profile_type = random.choice(list(GENRE_PREFERENCE_PROFILES.keys()))

        # 활동성 레벨: 롱테일 분포 (다수 라이트 + 소수 헤비)
        activity_level = random.choices([1, 2, 3, 4, 5], weights=[0.20, 0.30, 0.30, 0.15, 0.05])[0]

        # 평점 성향: 대부분 neutral, 일부 strict(낮게 줌) 또는 lenient(높게 줌)
        rating_tendency = random.choice([&quot;strict&quot;, &quot;neutral&quot;, &quot;neutral&quot;, &quot;neutral&quot;, &quot;lenient&quot;])

        users.append({
            &quot;user_id&quot;: user_id,
            &quot;signup_date&quot;: signup_date.strftime(&quot;%Y-%m-%d&quot;),
            &quot;country&quot;: country,
            &quot;age_group&quot;: age_group,
            &quot;preference_profile&quot;: profile_type,
            &quot;activity_level&quot;: activity_level,
            &quot;rating_tendency&quot;: rating_tendency
        })

    return pd.DataFrame(users)

# 사용자 데이터 생성 실행
users_df = generate_users(1000)
print(f&quot;✅ 사용자 데이터 생성 완료: {len(users_df)}명&quot;)
users_df.head(10)</code></pre>
<h4 id="output-2">output</h4>
<pre><code class="language-text">✅ 사용자 데이터 생성 완료: 1000명</code></pre>
<pre><code class="language-text">   user_id signup_date  ... activity_level rating_tendency
0        1  2020-02-05  ...              3         neutral
1        2  2023-01-11  ...              3          strict
2        3  2018-10-30  ...              2         lenient
3        4  2019-03-12  ...              2         neutral
4        5  2016-03-14  ...              1         neutral
5        6  2017-06-02  ...              3          strict
6        7  2023-06-29  ...              4         lenient
7        8  2016-05-19  ...              5         neutral
8        9  2020-08-05  ...              3         neutral
9       10  2021-07-24  ...              4         neutral

[10 rows x 7 columns]</code></pre>
<h2 id="5-평점-데이터-생성-핵심">5. 평점 데이터 생성 (핵심!)</h2>
<p>평점 데이터는 추천 시스템의 <strong>핵심 입력</strong>입니다.</p>
<h3 id="평점-계산-로직">평점 계산 로직</h3>
<p>각 평점은 다음 요소를 종합하여 현실적으로 생성됩니다:</p>
<ol>
<li><strong>영화 품질 (base_quality)</strong>: 기본 점수 (2.0~5.0)</li>
<li><strong>장르 매칭 보너스</strong>: 사용자 선호 장르와 일치하면 최대 +0.8점</li>
<li><strong>랜덤 노이즈</strong>: 정규분포 N(0, 0.5) — 개인차 반영</li>
<li><strong>평점 성향 보정</strong>: strict(-0.5), neutral(0), lenient(+0.5)</li>
<li><strong>최종 반올림</strong>: 0.5 단위, 0.5~5.0 범위</li>
</ol>
<h3 id="사용자별-평점-수-롱테일-분포">사용자별 평점 수 (롱테일 분포)</h3>
<p>실제 서비스처럼 소수의 헤비 유저와 다수의 라이트 유저로 구성됩니다:
| 활동성 | 기본 평점 수 | 사용자 비율 |
|--------|-------------|-----------|
| 1 (낮음) | 1<del>3개 | 20% |
| 2 | 3</del>8개 | 30% |
| 3 (보통) | 8<del>15개 | 30% |
| 4 | 15</del>40개 | 15% |
| 5 (높음) | 40~100개 | 5% |</p>
<p>사용자당 평균 약 10개 평점 (총 ~10,000건 / 1,000명)</p>
<pre><code class="language-python">def calculate_rating(user, movie, genre_prefs):
    &quot;&quot;&quot;
    사용자-영화 조합에 대한 현실적인 평점을 계산합니다.

    Args:
        user: 사용자 정보 딕셔너리
        movie: 영화 정보 딕셔너리
        genre_prefs: 사용자의 장르 선호도 딕셔너리

    Returns:
        float: 0.5 단위의 평점 (0.5 ~ 5.0)
    &quot;&quot;&quot;
    # 1단계: 영화 자체의 품질이 기본 점수
    base = movie[&quot;base_quality&quot;]

    # 2단계: 사용자 선호 장르와 영화 장르가 일치하면 보너스 부여
    movie_genres = set(movie[&quot;genres&quot;].split(&quot;|&quot;))
    genre_bonus = 0
    if genre_prefs:
        matching_prefs = [genre_prefs.get(g, 0) for g in movie_genres if g in genre_prefs]
        if matching_prefs:
            genre_bonus = np.mean(matching_prefs) * 0.8  # 최대 0.8점 보너스

    # 3단계: 랜덤 노이즈 (같은 영화라도 사용자마다 다르게 느낌)
    noise = np.random.normal(0, 0.5)

    # 4단계: 사용자 평점 성향 반영
    tendency_offset = {&quot;strict&quot;: -0.5, &quot;neutral&quot;: 0, &quot;lenient&quot;: 0.5}
    tendency = tendency_offset.get(user[&quot;rating_tendency&quot;], 0)

    # 최종 평점 계산 및 반올림
    rating = base + genre_bonus + noise + tendency
    rating = round(rating * 2) / 2  # 0.5 단위로 반올림
    rating = max(0.5, min(rating, 5.0))  # 범위 제한

    return rating

def generate_ratings(users_df, movies_df, target_ratings=10000):
    &quot;&quot;&quot;
    평점 데이터를 생성합니다.

    각 사용자의 활동성 레벨에 비례하여 평점 수를 할당하고,
    영화 인기도(파레토 분포)에 따라 시청할 영화를 선택합니다.
    사용자별 평점 수는 롱테일 분포를 따릅니다 (소수 헤비유저 + 다수 라이트유저).

    Args:
        users_df: 사용자 DataFrame
        movies_df: 영화 DataFrame
        target_ratings: 목표 총 평점 수 (기본값: 10,000)

    Returns:
        pd.DataFrame: 평점 DataFrame
    &quot;&quot;&quot;
    ratings = []

    users_list = users_df.to_dict(&quot;records&quot;)
    movies_list = movies_df.to_dict(&quot;records&quot;)

    # 영화 인기도: 파레토 분포 → 소수의 영화가 대부분의 평점을 받음 (롱테일 효과)
    movie_popularity = np.random.pareto(1.5, len(movies_list)) + 1
    movie_popularity = movie_popularity / movie_popularity.sum()

    # 사용자별 평점 수 결정 (활동성 기반, 롱테일 분포)
    # 실제 서비스와 유사하게: 대부분 적은 수, 소수가 많은 수
    user_rating_counts = []
    for user in users_list:
        activity = user[&quot;activity_level&quot;]
        if activity == 1:
            # 라이트 유저: 1~3개
            count = random.randint(1, 3)
        elif activity == 2:
            # 가벼운 사용자: 3~8개
            count = random.randint(3, 8)
        elif activity == 3:
            # 보통 사용자: 8~15개
            count = random.randint(8, 15)
        elif activity == 4:
            # 활발한 사용자: 15~40개
            count = random.randint(15, 40)
        else:
            # 헤비 유저: 40~100개
            count = random.randint(40, 100)
        user_rating_counts.append(count)

    # 총 평점 수를 목표치에 맞게 스케일링
    total_planned = sum(user_rating_counts)
    scale_factor = target_ratings / total_planned
    user_rating_counts = [max(1, int(c * scale_factor)) for c in user_rating_counts]

    # 평점 생성
    for user, n_ratings in zip(users_list, user_rating_counts):
        # 사용자의 장르 선호도 가져오기
        genre_prefs = GENRE_PREFERENCE_PROFILES.get(user[&quot;preference_profile&quot;], {})

        # 인기도 기반으로 시청할 영화 선택 (중복 없이, 영화 수 초과 방지)
        n_to_sample = min(n_ratings, len(movies_list))
        watched_indices = np.random.choice(
            len(movies_list),
            size=n_to_sample,
            replace=False,
            p=movie_popularity
        )

        # 평점 시간: 가입일 이후 랜덤 시점
        signup = datetime.strptime(user[&quot;signup_date&quot;], &quot;%Y-%m-%d&quot;)
        days_active = (datetime(2024, 12, 1) - signup).days

        for idx in watched_indices:
            movie = movies_list[idx]
            rating = calculate_rating(user, movie, genre_prefs)

            # 가입일 ~ 2024-12-01 사이의 랜덤 시점
            rating_days = random.randint(0, max(1, days_active))
            rating_time = signup + timedelta(
                days=rating_days,
                hours=random.randint(0, 23),
                minutes=random.randint(0, 59)
            )

            ratings.append({
                &quot;user_id&quot;: user[&quot;user_id&quot;],
                &quot;movie_id&quot;: movie[&quot;movie_id&quot;],
                &quot;rating&quot;: rating,
                &quot;timestamp&quot;: int(rating_time.timestamp())
            })

    return pd.DataFrame(ratings)

# 평점 데이터 생성 실행
print(&quot;⏳ 평점 데이터 생성 중...&quot;)
ratings_df = generate_ratings(users_df, movies_df, target_ratings=10000)
print(f&quot;✅ 평점 데이터 생성 완료: {len(ratings_df):,}건&quot;)

# 평점 분포 확인
print(&quot;\n📊 평점 분포:&quot;)
print(ratings_df[&quot;rating&quot;].value_counts().sort_index())</code></pre>
<h4 id="output-3">output</h4>
<pre><code class="language-text">⏳ 평점 데이터 생성 중...
✅ 평점 데이터 생성 완료: 9,533건

📊 평점 분포:
1.5      21
2.0      82
2.5     317
3.0     852
3.5    1694
4.0    2210
4.5    2122
5.0    2235
Name: rating, dtype: int64</code></pre>
<h2 id="6-태그-데이터-생성">6. 태그 데이터 생성</h2>
<p>사용자가 영화에 붙인 <strong>자유 태그</strong> 데이터를 생성합니다.</p>
<ul>
<li>활발한 사용자(activity_level ≥ 3)만 태그를 작성한다고 가정</li>
<li>태그는 감성(긍정/부정/중립)을 포함하여 이후 분석에 활용 가능</li>
</ul>
<pre><code class="language-python"># 영화 관련 태그 목록 (감성별 분류)
TAGS = [
    # 긍정적 태그
    &quot;masterpiece&quot;, &quot;must-watch&quot;, &quot;amazing-visuals&quot;, &quot;great-acting&quot;,
    &quot;emotional&quot;, &quot;inspiring&quot;, &quot;beautiful&quot;, &quot;rewatchable&quot;, &quot;feel-good&quot;,
    # 부정적 태그
    &quot;overrated&quot;, &quot;boring&quot;, &quot;predictable&quot;, &quot;slow-paced&quot;,
    # 중립적/설명적 태그
    &quot;underrated&quot;, &quot;exciting&quot;, &quot;thought-provoking&quot;, &quot;mind-bending&quot;,
    &quot;funny&quot;, &quot;scary&quot;, &quot;good-soundtrack&quot;, &quot;twist-ending&quot;,
    &quot;fast-paced&quot;, &quot;complex-plot&quot;, &quot;original&quot;, &quot;classic&quot;, &quot;cult-film&quot;,
    &quot;dark&quot;, &quot;violent&quot;, &quot;family-friendly&quot;, &quot;romantic&quot;, &quot;disturbing&quot;, &quot;nostalgic&quot;
]

def generate_tags(users_df, movies_df, n_tags=1000):
    &quot;&quot;&quot;
    태그 데이터를 생성합니다.

    Args:
        users_df: 사용자 DataFrame
        movies_df: 영화 DataFrame
        n_tags: 생성할 태그 수 (기본값: 1,000)

    Returns:
        pd.DataFrame: 태그 DataFrame
    &quot;&quot;&quot;
    tags = []

    # 활발한 사용자만 태그 작성 (activity_level 3 이상)
    active_users = users_df[users_df[&quot;activity_level&quot;] &gt;= 3][&quot;user_id&quot;].tolist()

    for _ in range(n_tags):
        user_id = random.choice(active_users)
        movie_id = random.randint(1, len(movies_df))
        tag = random.choice(TAGS)

        # 태그 시간: 2020~2024년 사이 랜덤
        timestamp = int(datetime(2020, 1, 1).timestamp()) + random.randint(0, 157680000)

        tags.append({
            &quot;user_id&quot;: user_id,
            &quot;movie_id&quot;: movie_id,
            &quot;tag&quot;: tag,
            &quot;timestamp&quot;: timestamp
        })

    return pd.DataFrame(tags)

# 태그 데이터 생성 실행
tags_df = generate_tags(users_df, movies_df, n_tags=1000)
print(f&quot;✅ 태그 데이터 생성 완료: {len(tags_df):,}건&quot;)</code></pre>
<h4 id="output-4">output</h4>
<pre><code class="language-text">✅ 태그 데이터 생성 완료: 1,000건</code></pre>
<h2 id="7-delta-테이블로-저장-bronze-layer">7. Delta 테이블로 저장 (Bronze Layer)</h2>
<p>생성된 데이터를 <strong>Delta Lake</strong> 형식의 Bronze 테이블로 저장합니다.</p>
<p>Bronze Layer는 원시 데이터를 그대로 보존하는 계층입니다:</p>
<ul>
<li>데이터 변환 없이 원본 그대로 저장</li>
<li><code>overwrite</code> 모드로 저장하여 재실행 시 기존 데이터를 덮어씁니다</li>
<li>Pandas DataFrame → Spark DataFrame → Delta Table 순으로 변환</li>
</ul>
<pre><code class="language-python">from pyspark.sql.types import *

# Movies → Bronze 테이블
movies_spark = spark.createDataFrame(movies_df)
movies_spark.write.format(&quot;delta&quot;).mode(&quot;overwrite&quot;).saveAsTable(f&quot;{CATALOG}.{SCHEMA}.bronze_movies&quot;)
print(f&quot;✅ bronze_movies 테이블 저장 완료&quot;)

# Users → Bronze 테이블
users_spark = spark.createDataFrame(users_df)
users_spark.write.format(&quot;delta&quot;).mode(&quot;overwrite&quot;).saveAsTable(f&quot;{CATALOG}.{SCHEMA}.bronze_users&quot;)
print(f&quot;✅ bronze_users 테이블 저장 완료&quot;)

# Ratings → Bronze 테이블
ratings_spark = spark.createDataFrame(ratings_df)
ratings_spark.write.format(&quot;delta&quot;).mode(&quot;overwrite&quot;).saveAsTable(f&quot;{CATALOG}.{SCHEMA}.bronze_ratings&quot;)
print(f&quot;✅ bronze_ratings 테이블 저장 완료&quot;)

# Tags → Bronze 테이블
tags_spark = spark.createDataFrame(tags_df)
tags_spark.write.format(&quot;delta&quot;).mode(&quot;overwrite&quot;).saveAsTable(f&quot;{CATALOG}.{SCHEMA}.bronze_tags&quot;)
print(f&quot;✅ bronze_tags 테이블 저장 완료&quot;)</code></pre>
<h4 id="output-5">output</h4>
<pre><code class="language-text">✅ bronze_movies 테이블 저장 완료
✅ bronze_users 테이블 저장 완료
✅ bronze_ratings 테이블 저장 완료
✅ bronze_tags 테이블 저장 완료</code></pre>
<h2 id="8-데이터-요약">8. 데이터 요약</h2>
<p>생성된 데이터의 통계를 확인합니다.
<strong>희소성(Sparsity)</strong> 은 전체 가능한 (사용자 × 영화) 조합 대비 실제 평점 비율의 여집합입니다.
희소성이 낮을수록(= 밀도가 높을수록) 협업 필터링 모델의 학습이 유리합니다.</p>
<pre><code class="language-python">print(&quot;=&quot; * 60)
print(&quot;📊 생성된 데이터 요약&quot;)
print(&quot;=&quot; * 60)
print(f&quot;🎬 영화 수: {len(movies_df):,}개&quot;)
print(f&quot;👤 사용자 수: {len(users_df):,}명&quot;)
print(f&quot;⭐ 평점 수: {len(ratings_df):,}건&quot;)
print(f&quot;🏷️  태그 수: {len(tags_df):,}건&quot;)
print(f&quot;\n📍 저장 위치: {CATALOG}.{SCHEMA}&quot;)
print(&quot;=&quot; * 60)

# 희소성(Sparsity) 계산
total_possible = len(movies_df) * len(users_df)  # 전체 가능한 조합
sparsity = 1 - (len(ratings_df) / total_possible)
print(f&quot;\n🔢 평점 매트릭스 희소성: {sparsity:.4%}&quot;)
print(f&quot;   (사용자당 평균 {len(ratings_df)/len(users_df):.1f}개 영화 평가)&quot;)
print(f&quot;   (영화당 평균 {len(ratings_df)/len(movies_df):.1f}개 평점)&quot;)</code></pre>
<h4 id="output-6">output</h4>
<pre><code class="language-text">============================================================
📊 생성된 데이터 요약
============================================================
🎬 영화 수: 500개
👤 사용자 수: 1,000명
⭐ 평점 수: 9,533건
🏷️  태그 수: 1,000건

📍 저장 위치: 3dt016_databricks.movie_recommender
============================================================

🔢 평점 매트릭스 희소성: 98.0934%
   (사용자당 평균 9.5개 영화 평가)
   (영화당 평균 19.1개 평점)</code></pre>
<table>
<thead>
<tr>
<th>테이블</th>
<th>건수</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>bronze_movies</code></td>
<td>500</td>
<td>영화 메타데이터</td>
</tr>
<tr>
<td><code>bronze_users</code></td>
<td>1,000</td>
<td>사용자 프로필</td>
</tr>
<tr>
<td><code>bronze_ratings</code></td>
<td>~10,000</td>
<td>사용자-영화 평점</td>
</tr>
<tr>
<td><code>bronze_tags</code></td>
<td>1,000</td>
<td>사용자 태그</td>
</tr>
</tbody></table>
<h1 id="silver-layer-데이터-정제-및-피처-엔지니어링">Silver Layer: 데이터 정제 및 피처 엔지니어링</h1>
<p>Bronze 데이터를 정제하고 ML에 활용할 수 있는 형태로 변환합니다.</p>
<h3 id="변환-작업-요약">변환 작업 요약</h3>
<table>
<thead>
<tr>
<th>테이블</th>
<th>주요 변환</th>
</tr>
</thead>
<tbody><tr>
<td><strong>silver_movies</strong></td>
<td>장르 배열화, 연대/예산/러닝타임 카테고리 생성, 정보 누출 컬럼 제거</td>
</tr>
<tr>
<td><strong>silver_users</strong></td>
<td>날짜 변환, 연령대 인코딩, 지역 그룹화, 평점 성향 수치화</td>
</tr>
<tr>
<td><strong>silver_ratings</strong></td>
<td>타임스탬프 변환, 시간대 카테고리, 평점 이진 레이블, 이상치 제거</td>
</tr>
<tr>
<td><strong>silver_tags</strong></td>
<td>태그 정규화, 감성 분류</td>
</tr>
<tr>
<td><strong>silver_user_stats</strong></td>
<td>사용자별 평점 통계 집계</td>
</tr>
<tr>
<td><strong>silver_movie_stats</strong></td>
<td>영화별 평점 통계 집계 + 베이지안 평균</td>
</tr>
</tbody></table>
<h3 id="메달리온-아키텍처에서의-위치-1">메달리온 아키텍처에서의 위치</h3>
<pre><code>Bronze (원시) → Silver (정제/피처) → Gold (집계/ML)
                   ↑ 현재 위치</code></pre><h2 id="1-환경-설정-1">1. 환경 설정</h2>
<pre><code class="language-python">from pyspark.sql import functions as F
from pyspark.sql.types import *
from pyspark.sql.window import Window
from datetime import datetime

# ============================================================
# ⚠️ 카탈로그 이름을 본인의 카탈로그로 변경하세요!
# ============================================================
CATALOG = &quot;3dt016_databricks&quot;
SCHEMA = &quot;movie_recommender&quot;

spark.sql(f&quot;USE {CATALOG}.{SCHEMA}&quot;)
print(f&quot;✅ 카탈로그 설정: {CATALOG}.{SCHEMA}&quot;)</code></pre>
<h4 id="output-7">output</h4>
<pre><code class="language-text">✅ 카탈로그 설정: 3dt016_databricks.movie_recommender</code></pre>
<h2 id="2-bronze-데이터-로드-및-탐색">2. Bronze 데이터 로드 및 탐색</h2>
<p>이전 노트북에서 생성한 Bronze 테이블을 로드하고 기본 현황을 확인합니다.</p>
<pre><code class="language-python"># Bronze 테이블 로드
bronze_movies = spark.table(&quot;bronze_movies&quot;)
bronze_users = spark.table(&quot;bronze_users&quot;)
bronze_ratings = spark.table(&quot;bronze_ratings&quot;)
bronze_tags = spark.table(&quot;bronze_tags&quot;)

print(&quot;📊 Bronze 테이블 현황:&quot;)
print(f&quot;  - movies:  {bronze_movies.count():,} rows&quot;)
print(f&quot;  - users:   {bronze_users.count():,} rows&quot;)
print(f&quot;  - ratings: {bronze_ratings.count():,} rows&quot;)
print(f&quot;  - tags:    {bronze_tags.count():,} rows&quot;)</code></pre>
<h4 id="output-8">output</h4>
<pre><code class="language-text">📊 Bronze 테이블 현황:
  - movies:  500 rows
  - users:   1,000 rows
  - ratings: 9,533 rows
  - tags:    1,000 rows</code></pre>
<pre><code class="language-python"># 각 테이블의 샘플 데이터 확인
display(bronze_movies.limit(5))
display(bronze_users.limit(5))
display(bronze_ratings.limit(5))
display(bronze_tags.limit(5))</code></pre>
<h4 id="output-9">output</h4>
<table>
<thead>
<tr>
<th>movie_id</th>
<th>title</th>
<th>year</th>
<th>genres</th>
<th>director</th>
<th>runtime_minutes</th>
<th>budget_millions</th>
<th>base_quality</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>The Universe (1998)</td>
<td>1998</td>
<td>Drama|Western|Comedy</td>
<td>Quentin Tarantino</td>
<td>92</td>
<td>8.5</td>
<td>3.89</td>
</tr>
<tr>
<td>2</td>
<td>Lost Soul (1976)</td>
<td>1976</td>
<td>Action</td>
<td>Martin Scorsese</td>
<td>145</td>
<td>175.2</td>
<td>3.51</td>
</tr>
<tr>
<td>3</td>
<td>Kingdom of Thunder (1995)</td>
<td>1995</td>
<td>Western</td>
<td>James Cameron</td>
<td>75</td>
<td>17.2</td>
<td>4.33</td>
</tr>
<tr>
<td>4</td>
<td>Kingdom Fire (2001)</td>
<td>2001</td>
<td>Action|Crime|Romance</td>
<td>Wes Anderson</td>
<td>101</td>
<td>76.9</td>
<td>4.51</td>
</tr>
<tr>
<td>5</td>
<td>Dragon: Rise of Dream (2006)</td>
<td>2006</td>
<td>Animation</td>
<td>Alfonso Cuarón</td>
<td>104</td>
<td>5.1</td>
<td>4.52</td>
</tr>
</tbody></table>
<table>
<thead>
<tr>
<th>user_id</th>
<th>signup_date</th>
<th>country</th>
<th>age_group</th>
<th>preference_profile</th>
<th>activity_level</th>
<th>rating_tendency</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>2020-02-05</td>
<td>Japan</td>
<td>35-44</td>
<td>family_viewer</td>
<td>3</td>
<td>neutral</td>
</tr>
<tr>
<td>2</td>
<td>2023-01-11</td>
<td>Australia</td>
<td>18-24</td>
<td>balanced</td>
<td>3</td>
<td>strict</td>
</tr>
<tr>
<td>3</td>
<td>2018-10-30</td>
<td>Japan</td>
<td>45-54</td>
<td>balanced</td>
<td>2</td>
<td>lenient</td>
</tr>
<tr>
<td>4</td>
<td>2019-03-12</td>
<td>UK</td>
<td>18-24</td>
<td>drama_enthusiast</td>
<td>2</td>
<td>neutral</td>
</tr>
<tr>
<td>5</td>
<td>2016-03-14</td>
<td>France</td>
<td>35-44</td>
<td>drama_enthusiast</td>
<td>1</td>
<td>neutral</td>
</tr>
</tbody></table>
<table>
<thead>
<tr>
<th>user_id</th>
<th>movie_id</th>
<th>rating</th>
<th>timestamp</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>479</td>
<td>4.0</td>
<td>1715870460</td>
</tr>
<tr>
<td>1</td>
<td>302</td>
<td>4.0</td>
<td>1605670560</td>
</tr>
<tr>
<td>1</td>
<td>351</td>
<td>4.0</td>
<td>1667179380</td>
</tr>
<tr>
<td>1</td>
<td>147</td>
<td>4.0</td>
<td>1732872300</td>
</tr>
<tr>
<td>1</td>
<td>195</td>
<td>5.0</td>
<td>1653249480</td>
</tr>
</tbody></table>
<table>
<thead>
<tr>
<th>user_id</th>
<th>movie_id</th>
<th>tag</th>
<th>timestamp</th>
</tr>
</thead>
<tbody><tr>
<td>426</td>
<td>425</td>
<td>must-watch</td>
<td>1619589428</td>
</tr>
<tr>
<td>374</td>
<td>212</td>
<td>mind-bending</td>
<td>1609074223</td>
</tr>
<tr>
<td>843</td>
<td>312</td>
<td>thought-provoking</td>
<td>1691535473</td>
</tr>
<tr>
<td>978</td>
<td>97</td>
<td>twist-ending</td>
<td>1595585111</td>
</tr>
<tr>
<td>796</td>
<td>434</td>
<td>romantic</td>
<td>1694484196</td>
</tr>
</tbody></table>
<h2 id="3-silver-movies-변환">3. Silver Movies 변환</h2>
<p>영화 데이터에 다음 피처를 추가합니다:</p>
<ul>
<li><strong>genres_array</strong>: 장르 문자열을 배열로 변환 (Explode 등 후속 분석에 필요)</li>
<li><strong>num_genres</strong>: 장르 개수</li>
<li><strong>decade</strong>: 연대 (1970, 1980, ...)</li>
<li><strong>is_recent</strong>: 2015년 이후 영화 여부</li>
<li><strong>budget_category</strong>: 예산 규모 카테고리 (Low/Medium/High/Blockbuster)</li>
<li><strong>runtime_category</strong>: 러닝타임 카테고리 (Short/Standard/Long/Epic)</li>
</ul>
<blockquote>
<p>️ <strong><code>base_quality</code> 컬럼 제거</strong>: 이 컬럼은 평점 생성용 시뮬레이션 파라미터이므로,
ML 모델 훈련 시 <strong>정보 누출(Data Leakage)</strong> 을 방지하기 위해 Silver 단계에서 삭제합니다.</p>
</blockquote>
<pre><code class="language-python">silver_movies = (
    bronze_movies
    # 장르 문자열을 배열로 분리 (예: &quot;Action|Comedy&quot; → [&quot;Action&quot;, &quot;Comedy&quot;])
    .withColumn(&quot;genres_array&quot;, F.split(F.col(&quot;genres&quot;), &quot;\\|&quot;))
    .withColumn(&quot;num_genres&quot;, F.size(&quot;genres_array&quot;))

    # 연도 기반 피처
    .withColumn(&quot;decade&quot;, (F.floor(F.col(&quot;year&quot;) / 10) * 10).cast(&quot;integer&quot;))
    .withColumn(&quot;is_recent&quot;, F.when(F.col(&quot;year&quot;) &gt;= 2015, True).otherwise(False))

    # 예산 카테고리 (백만 달러 기준)
    .withColumn(&quot;budget_category&quot;,
        F.when(F.col(&quot;budget_millions&quot;) &lt; 20, &quot;Low&quot;)        # 2천만 미만
        .when(F.col(&quot;budget_millions&quot;) &lt; 80, &quot;Medium&quot;)      # 2천만~8천만
        .when(F.col(&quot;budget_millions&quot;) &lt; 150, &quot;High&quot;)       # 8천만~1.5억
        .otherwise(&quot;Blockbuster&quot;)                            # 1.5억 이상
    )

    # 러닝타임 카테고리 (분 기준)
    .withColumn(&quot;runtime_category&quot;,
        F.when(F.col(&quot;runtime_minutes&quot;) &lt; 90, &quot;Short&quot;)      # 90분 미만
        .when(F.col(&quot;runtime_minutes&quot;) &lt; 120, &quot;Standard&quot;)   # 90~120분
        .when(F.col(&quot;runtime_minutes&quot;) &lt; 150, &quot;Long&quot;)       # 120~150분
        .otherwise(&quot;Epic&quot;)                                   # 150분 이상
    )

    # 처리 메타데이터
    .withColumn(&quot;processed_at&quot;, F.current_timestamp())
    .withColumn(&quot;data_quality_score&quot;, F.lit(1.0))

    # base_quality 제거 (정보 누출 방지)
    .drop(&quot;base_quality&quot;)
)

# 데이터 품질 검사: Null 값 확인
null_counts = silver_movies.select([
    F.sum(F.when(F.col(c).isNull(), 1).otherwise(0)).alias(c)
    for c in silver_movies.columns
])
print(&quot;🔍 Null 값 검사:&quot;)
null_counts.show()

# Silver 테이블 저장
silver_movies.write.format(&quot;delta&quot;).mode(&quot;overwrite&quot;).saveAsTable(&quot;silver_movies&quot;)
print(f&quot;✅ silver_movies 저장 완료: {silver_movies.count():,} rows&quot;)</code></pre>
<h4 id="output-10">output</h4>
<pre><code class="language-text">🔍 Null 값 검사:
+--------+-----+----+------+--------+---------------+---------------+------------+----------+------+---------+---------------+----------------+------------+------------------+
|movie_id|title|year|genres|director|runtime_minutes|budget_millions|genres_array|num_genres|decade|is_recent|budget_category|runtime_category|processed_at|data_quality_score|
+--------+-----+----+------+--------+---------------+---------------+------------+----------+------+---------+---------------+----------------+------------+------------------+
|       0|    0|   0|     0|       0|              0|              0|           0|         0|     0|        0|              0|               0|           0|                 0|
+--------+-----+----+------+--------+---------------+---------------+------------+----------+------+---------+---------------+----------------+------------+------------------+

✅ silver_movies 저장 완료: 500 rows</code></pre>
<pre><code class="language-python"># 변환 결과 샘플 확인
display(silver_movies.limit(5))</code></pre>
<h4 id="output-11">output</h4>
<table>
<thead>
<tr>
<th>movie_id</th>
<th>title</th>
<th>year</th>
<th>genres</th>
<th>director</th>
<th>runtime_minutes</th>
<th>budget_millions</th>
<th>genres_array</th>
<th>num_genres</th>
<th>decade</th>
<th>is_recent</th>
<th>budget_category</th>
<th>runtime_category</th>
<th>processed_at</th>
<th>data_quality_score</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>The Universe (1998)</td>
<td>1998</td>
<td>Drama|Western|Comedy</td>
<td>Quentin Tarantino</td>
<td>92</td>
<td>8.5</td>
<td>[&#39;Drama&#39;, &#39;Western&#39;, &#39;Comedy&#39;]</td>
<td>3</td>
<td>1990</td>
<td>False</td>
<td>Low</td>
<td>Standard</td>
<td>2026-03-27T01:35:12.904Z</td>
<td>1.0</td>
</tr>
<tr>
<td>2</td>
<td>Lost Soul (1976)</td>
<td>1976</td>
<td>Action</td>
<td>Martin Scorsese</td>
<td>145</td>
<td>175.2</td>
<td>[&#39;Action&#39;]</td>
<td>1</td>
<td>1970</td>
<td>False</td>
<td>Blockbuster</td>
<td>Long</td>
<td>2026-03-27T01:35:12.904Z</td>
<td>1.0</td>
</tr>
<tr>
<td>3</td>
<td>Kingdom of Thunder (1995)</td>
<td>1995</td>
<td>Western</td>
<td>James Cameron</td>
<td>75</td>
<td>17.2</td>
<td>[&#39;Western&#39;]</td>
<td>1</td>
<td>1990</td>
<td>False</td>
<td>Low</td>
<td>Short</td>
<td>2026-03-27T01:35:12.904Z</td>
<td>1.0</td>
</tr>
<tr>
<td>4</td>
<td>Kingdom Fire (2001)</td>
<td>2001</td>
<td>Action|Crime|Romance</td>
<td>Wes Anderson</td>
<td>101</td>
<td>76.9</td>
<td>[&#39;Action&#39;, &#39;Crime&#39;, &#39;Romance&#39;]</td>
<td>3</td>
<td>2000</td>
<td>False</td>
<td>Medium</td>
<td>Standard</td>
<td>2026-03-27T01:35:12.904Z</td>
<td>1.0</td>
</tr>
<tr>
<td>5</td>
<td>Dragon: Rise of Dream (2006)</td>
<td>2006</td>
<td>Animation</td>
<td>Alfonso Cuarón</td>
<td>104</td>
<td>5.1</td>
<td>[&#39;Animation&#39;]</td>
<td>1</td>
<td>2000</td>
<td>False</td>
<td>Low</td>
<td>Standard</td>
<td>2026-03-27T01:35:12.904Z</td>
<td>1.0</td>
</tr>
</tbody></table>
<h2 id="4-silver-users-변환">4. Silver Users 변환</h2>
<p>사용자 데이터에 ML과 분석에 유용한 피처를 추가합니다:</p>
<ul>
<li><strong>날짜 관련</strong>: signup_date 타입 변환, 가입 연도/월, 계정 나이 (일수)</li>
<li><strong>인코딩</strong>: 연령대 숫자 인코딩 (ML 입력용)</li>
<li><strong>지역 그룹</strong>: 국가를 대륙/지역으로 그룹화</li>
<li><strong>성향 수치화</strong>: 평점 성향을 -1/0/+1 수치로 변환</li>
</ul>
<pre><code class="language-python">silver_users = (
    bronze_users
    # 날짜 타입 변환 (문자열 → Date)
    .withColumn(&quot;signup_date&quot;, F.to_date(&quot;signup_date&quot;))
    .withColumn(&quot;signup_year&quot;, F.year(&quot;signup_date&quot;))
    .withColumn(&quot;signup_month&quot;, F.month(&quot;signup_date&quot;))

    # 가입 기간 계산 (오늘 기준)
    .withColumn(&quot;account_age_days&quot;,
        F.datediff(F.current_date(), F.col(&quot;signup_date&quot;))
    )

    # 연령대 → 숫자 인코딩 (ML 모델 입력용)
    .withColumn(&quot;age_group_encoded&quot;,
        F.when(F.col(&quot;age_group&quot;) == &quot;18-24&quot;, 1)
        .when(F.col(&quot;age_group&quot;) == &quot;25-34&quot;, 2)
        .when(F.col(&quot;age_group&quot;) == &quot;35-44&quot;, 3)
        .when(F.col(&quot;age_group&quot;) == &quot;45-54&quot;, 4)
        .when(F.col(&quot;age_group&quot;) == &quot;55-64&quot;, 5)
        .otherwise(6)  # 65+
    )

    # 국가 → 지역 그룹
    .withColumn(&quot;region&quot;,
        F.when(F.col(&quot;country&quot;).isin(&quot;USA&quot;, &quot;Canada&quot;), &quot;North America&quot;)
        .when(F.col(&quot;country&quot;).isin(&quot;UK&quot;, &quot;Germany&quot;, &quot;France&quot;), &quot;Europe&quot;)
        .when(F.col(&quot;country&quot;).isin(&quot;Japan&quot;, &quot;Korea&quot;), &quot;East Asia&quot;)
        .when(F.col(&quot;country&quot;) == &quot;Australia&quot;, &quot;Oceania&quot;)
        .otherwise(&quot;Other&quot;)
    )

    # 평점 성향 수치화 (strict=-1, neutral=0, lenient=+1)
    .withColumn(&quot;rating_tendency_score&quot;,
        F.when(F.col(&quot;rating_tendency&quot;) == &quot;strict&quot;, -1)
        .when(F.col(&quot;rating_tendency&quot;) == &quot;lenient&quot;, 1)
        .otherwise(0)
    )

    # 처리 메타데이터
    .withColumn(&quot;processed_at&quot;, F.current_timestamp())
)

# Silver 테이블 저장
silver_users.write.format(&quot;delta&quot;).mode(&quot;overwrite&quot;).saveAsTable(&quot;silver_users&quot;)
print(f&quot;✅ silver_users 저장 완료: {silver_users.count():,} rows&quot;)</code></pre>
<h4 id="output-12">output</h4>
<pre><code class="language-text">✅ silver_users 저장 완료: 1,000 rows</code></pre>
<pre><code class="language-python">display(silver_users.limit(5))</code></pre>
<h4 id="output-13">output</h4>
<table>
<thead>
<tr>
<th>user_id</th>
<th>signup_date</th>
<th>country</th>
<th>age_group</th>
<th>preference_profile</th>
<th>activity_level</th>
<th>rating_tendency</th>
<th>signup_year</th>
<th>signup_month</th>
<th>account_age_days</th>
<th>age_group_encoded</th>
<th>region</th>
<th>rating_tendency_score</th>
<th>processed_at</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>2020-02-05</td>
<td>Japan</td>
<td>35-44</td>
<td>family_viewer</td>
<td>3</td>
<td>neutral</td>
<td>2020</td>
<td>2</td>
<td>2242</td>
<td>3</td>
<td>East Asia</td>
<td>0</td>
<td>2026-03-27T01:35:17.908Z</td>
</tr>
<tr>
<td>2</td>
<td>2023-01-11</td>
<td>Australia</td>
<td>18-24</td>
<td>balanced</td>
<td>3</td>
<td>strict</td>
<td>2023</td>
<td>1</td>
<td>1171</td>
<td>1</td>
<td>Oceania</td>
<td>-1</td>
<td>2026-03-27T01:35:17.908Z</td>
</tr>
<tr>
<td>3</td>
<td>2018-10-30</td>
<td>Japan</td>
<td>45-54</td>
<td>balanced</td>
<td>2</td>
<td>lenient</td>
<td>2018</td>
<td>10</td>
<td>2705</td>
<td>4</td>
<td>East Asia</td>
<td>1</td>
<td>2026-03-27T01:35:17.908Z</td>
</tr>
<tr>
<td>4</td>
<td>2019-03-12</td>
<td>UK</td>
<td>18-24</td>
<td>drama_enthusiast</td>
<td>2</td>
<td>neutral</td>
<td>2019</td>
<td>3</td>
<td>2572</td>
<td>1</td>
<td>Europe</td>
<td>0</td>
<td>2026-03-27T01:35:17.908Z</td>
</tr>
<tr>
<td>5</td>
<td>2016-03-14</td>
<td>France</td>
<td>35-44</td>
<td>drama_enthusiast</td>
<td>1</td>
<td>neutral</td>
<td>2016</td>
<td>3</td>
<td>3665</td>
<td>3</td>
<td>Europe</td>
<td>0</td>
<td>2026-03-27T01:35:17.908Z</td>
</tr>
</tbody></table>
<h2 id="5-silver-ratings-변환-핵심-데이터">5. Silver Ratings 변환 (핵심 데이터!)</h2>
<p>평점 데이터는 ALS 모델의 직접적인 입력이므로 가장 중요한 테이블입니다.</p>
<h3 id="추가-피처">추가 피처</h3>
<ul>
<li><strong>시간 관련</strong>: Unix timestamp → 날짜/시간/연도/월/시간 변환</li>
<li><strong>시간대 카테고리</strong>: Morning/Afternoon/Evening/Night</li>
<li><strong>평점 카테고리</strong>: Dislike(≤2.0) / Neutral(≤3.5) / Like(&gt;3.5)</li>
<li><strong>이진 레이블</strong>: 4.0 이상을 긍정(1), 미만을 부정(0)으로 분류 → 분류 모델에도 활용 가능</li>
</ul>
<h3 id="데이터-품질-검증">데이터 품질 검증</h3>
<ul>
<li>평점 범위 (0.5~5.0) 밖의 이상치를 탐지하고 제거합니다</li>
</ul>
<pre><code class="language-python">silver_ratings = (
    bronze_ratings
    # Unix 타임스탬프를 날짜/시간으로 변환
    .withColumn(&quot;rating_datetime&quot;, F.from_unixtime(&quot;timestamp&quot;))
    .withColumn(&quot;rating_date&quot;, F.to_date(&quot;rating_datetime&quot;))
    .withColumn(&quot;rating_year&quot;, F.year(&quot;rating_datetime&quot;))
    .withColumn(&quot;rating_month&quot;, F.month(&quot;rating_datetime&quot;))
    .withColumn(&quot;rating_hour&quot;, F.hour(&quot;rating_datetime&quot;))

    # 시간대 카테고리 (시청 패턴 분석용)
    .withColumn(&quot;time_of_day&quot;,
        F.when(F.col(&quot;rating_hour&quot;).between(6, 11), &quot;Morning&quot;)
        .when(F.col(&quot;rating_hour&quot;).between(12, 17), &quot;Afternoon&quot;)
        .when(F.col(&quot;rating_hour&quot;).between(18, 22), &quot;Evening&quot;)
        .otherwise(&quot;Night&quot;)
    )

    # 평점 카테고리화 (3단계)
    .withColumn(&quot;rating_category&quot;,
        F.when(F.col(&quot;rating&quot;) &lt;= 2.0, &quot;Dislike&quot;)
        .when(F.col(&quot;rating&quot;) &lt;= 3.5, &quot;Neutral&quot;)
        .otherwise(&quot;Like&quot;)
    )

    # 이진 레이블: 4.0 이상 = 긍정적 (추천 알고리즘의 implicit feedback 변환에 활용)
    .withColumn(&quot;is_positive&quot;, F.when(F.col(&quot;rating&quot;) &gt;= 4.0, 1).otherwise(0))

    # 처리 메타데이터
    .withColumn(&quot;processed_at&quot;, F.current_timestamp())
)

# 이상치 탐지: 0.5~5.0 범위 밖의 평점 확인
invalid_ratings = silver_ratings.filter(
    (F.col(&quot;rating&quot;) &lt; 0.5) | (F.col(&quot;rating&quot;) &gt; 5.0)
)
print(f&quot;⚠️ 유효하지 않은 평점 수: {invalid_ratings.count()}&quot;)

# 유효한 평점만 유지
silver_ratings = silver_ratings.filter(
    (F.col(&quot;rating&quot;) &gt;= 0.5) &amp; (F.col(&quot;rating&quot;) &lt;= 5.0)
)

# Silver 테이블 저장
silver_ratings.write.format(&quot;delta&quot;).mode(&quot;overwrite&quot;).saveAsTable(&quot;silver_ratings&quot;)
print(f&quot;✅ silver_ratings 저장 완료: {silver_ratings.count():,} rows&quot;)</code></pre>
<h4 id="output-14">output</h4>
<pre><code class="language-text">⚠️ 유효하지 않은 평점 수: 0
✅ silver_ratings 저장 완료: 9,533 rows</code></pre>
<pre><code class="language-python"># 평점 분포 확인 (히스토그램 형태)
display(
    silver_ratings
    .groupBy(&quot;rating&quot;)
    .count()
    .orderBy(&quot;rating&quot;)
)</code></pre>
<h4 id="output-15">output</h4>
<table>
<thead>
<tr>
<th>rating</th>
<th>count</th>
</tr>
</thead>
<tbody><tr>
<td>1.5</td>
<td>21</td>
</tr>
<tr>
<td>2.0</td>
<td>82</td>
</tr>
<tr>
<td>2.5</td>
<td>317</td>
</tr>
<tr>
<td>3.0</td>
<td>852</td>
</tr>
<tr>
<td>3.5</td>
<td>1694</td>
</tr>
<tr>
<td>4.0</td>
<td>2210</td>
</tr>
<tr>
<td>4.5</td>
<td>2122</td>
</tr>
<tr>
<td>5.0</td>
<td>2235</td>
</tr>
</tbody></table>
<h2 id="6-silver-tags-변환">6. Silver Tags 변환</h2>
<p>태그 데이터를 정규화하고 감성(Sentiment) 분류를 추가합니다.</p>
<ul>
<li><strong>tag_normalized</strong>: 소문자 변환 + 공백 제거</li>
<li><strong>tag_sentiment</strong>: 미리 정의한 키워드 기반 감성 분류 (Positive/Negative/Neutral)</li>
</ul>
<pre><code class="language-python">silver_tags = (
    bronze_tags
    # 타임스탬프 변환
    .withColumn(&quot;tag_datetime&quot;, F.from_unixtime(&quot;timestamp&quot;))
    .withColumn(&quot;tag_date&quot;, F.to_date(&quot;tag_datetime&quot;))

    # 태그 정규화 (소문자, 공백 제거)
    .withColumn(&quot;tag_normalized&quot;, F.lower(F.trim(&quot;tag&quot;)))

    # 태그 감성 분류 (키워드 기반 규칙)
    .withColumn(&quot;tag_sentiment&quot;,
        F.when(F.col(&quot;tag_normalized&quot;).isin(
            &quot;masterpiece&quot;, &quot;must-watch&quot;, &quot;amazing-visuals&quot;, &quot;great-acting&quot;,
            &quot;emotional&quot;, &quot;inspiring&quot;, &quot;beautiful&quot;, &quot;rewatchable&quot;, &quot;feel-good&quot;
        ), &quot;Positive&quot;)
        .when(F.col(&quot;tag_normalized&quot;).isin(
            &quot;overrated&quot;, &quot;boring&quot;, &quot;predictable&quot;, &quot;slow-paced&quot;
        ), &quot;Negative&quot;)
        .otherwise(&quot;Neutral&quot;)
    )

    # 처리 메타데이터
    .withColumn(&quot;processed_at&quot;, F.current_timestamp())
)

# Silver 테이블 저장
silver_tags.write.format(&quot;delta&quot;).mode(&quot;overwrite&quot;).saveAsTable(&quot;silver_tags&quot;)
print(f&quot;✅ silver_tags 저장 완료: {silver_tags.count():,} rows&quot;)</code></pre>
<h4 id="output-16">output</h4>
<pre><code class="language-text">✅ silver_tags 저장 완료: 1,000 rows</code></pre>
<h2 id="7-사용자-영화-통계-피처-테이블-생성">7. 사용자-영화 통계 피처 테이블 생성</h2>
<p>사용자별, 영화별 집계 통계를 미리 계산하여 저장합니다.
이 테이블들은 Gold Layer 및 추천 서비스에서 빈번하게 조회되므로,
미리 집계해두면 성능이 크게 향상됩니다.</p>
<h3 id="71-사용자별-통계-silver_user_stats">7.1 사용자별 통계 (silver_user_stats)</h3>
<ul>
<li>총 평점 수, 평균/표준편차/최소/최대 평점</li>
<li>고유 영화 수, 긍정 평점 비율</li>
<li>활동 기간 (첫 평점 ~ 마지막 평점)</li>
<li>일 평균 평점 수</li>
</ul>
<pre><code class="language-python"># 사용자별 통계 집계
user_stats = (
    silver_ratings
    .groupBy(&quot;user_id&quot;)
    .agg(
        F.count(&quot;*&quot;).alias(&quot;total_ratings&quot;),
        F.avg(&quot;rating&quot;).alias(&quot;avg_rating&quot;),
        F.stddev(&quot;rating&quot;).alias(&quot;rating_stddev&quot;),
        F.min(&quot;rating&quot;).alias(&quot;min_rating&quot;),
        F.max(&quot;rating&quot;).alias(&quot;max_rating&quot;),
        F.countDistinct(&quot;movie_id&quot;).alias(&quot;unique_movies&quot;),
        F.min(&quot;rating_date&quot;).alias(&quot;first_rating_date&quot;),
        F.max(&quot;rating_date&quot;).alias(&quot;last_rating_date&quot;),
        F.sum(&quot;is_positive&quot;).alias(&quot;positive_ratings&quot;)
    )
    # 파생 지표 계산
    .withColumn(&quot;positive_ratio&quot;, F.col(&quot;positive_ratings&quot;) / F.col(&quot;total_ratings&quot;))
    .withColumn(&quot;rating_days_span&quot;,
        F.datediff(F.col(&quot;last_rating_date&quot;), F.col(&quot;first_rating_date&quot;))
    )
    .withColumn(&quot;ratings_per_day&quot;,
        F.when(F.col(&quot;rating_days_span&quot;) &gt; 0,
            F.col(&quot;total_ratings&quot;) / F.col(&quot;rating_days_span&quot;)
        ).otherwise(F.col(&quot;total_ratings&quot;))
    )
)

user_stats.write.format(&quot;delta&quot;).mode(&quot;overwrite&quot;).saveAsTable(&quot;silver_user_stats&quot;)
print(f&quot;✅ silver_user_stats 저장 완료: {user_stats.count():,} rows&quot;)</code></pre>
<h4 id="output-17">output</h4>
<pre><code class="language-text">✅ silver_user_stats 저장 완료: 1,000 rows</code></pre>
<h3 id="72-영화별-통계-silver_movie_stats">7.2 영화별 통계 (silver_movie_stats)</h3>
<p>영화별 평점 통계와 함께 <strong>베이지안 평균 (Bayesian Average)</strong> 을 계산합니다.</p>
<h4 id="베이지안-평균이란">베이지안 평균이란?</h4>
<p>평점 수가 적은 영화의 과대/과소 평가를 보정하는 기법입니다.</p>
<p><strong>공식</strong>: <code>bayesian_avg = (v × R + m × C) / (v + m)</code></p>
<table>
<thead>
<tr>
<th>변수</th>
<th>설명</th>
<th>본 실습 값</th>
</tr>
</thead>
<tbody><tr>
<td>R</td>
<td>영화의 실제 평균 평점</td>
<td>avg_rating</td>
</tr>
<tr>
<td>v</td>
<td>영화의 평점 개수</td>
<td>total_ratings</td>
</tr>
<tr>
<td>C</td>
<td>전체 평균 (사전 확률)</td>
<td>3.0</td>
</tr>
<tr>
<td>m</td>
<td>최소 신뢰 샘플 수</td>
<td>10</td>
</tr>
</tbody></table>
<p><strong>예시</strong>:</p>
<ul>
<li>영화 A (평점 5.0, 1개): bayesian_avg = (5×1 + 3×10) / (1+10) = <strong>3.18</strong> → 신뢰도 낮아 평균 쪽으로 보정</li>
<li>영화 B (평점 4.0, 200개): bayesian_avg = (4×200 + 3×10) / (200+10) = <strong>3.95</strong> → 충분한 데이터이므로 원래 값에 가까움</li>
</ul>
<pre><code class="language-python"># 영화별 통계 집계
movie_stats = (
    silver_ratings
    .groupBy(&quot;movie_id&quot;)
    .agg(
        F.count(&quot;*&quot;).alias(&quot;total_ratings&quot;),
        F.avg(&quot;rating&quot;).alias(&quot;avg_rating&quot;),
        F.stddev(&quot;rating&quot;).alias(&quot;rating_stddev&quot;),
        F.countDistinct(&quot;user_id&quot;).alias(&quot;unique_raters&quot;),
        F.sum(&quot;is_positive&quot;).alias(&quot;positive_ratings&quot;),
        F.min(&quot;rating_date&quot;).alias(&quot;first_rating_date&quot;),
        F.max(&quot;rating_date&quot;).alias(&quot;last_rating_date&quot;)
    )
    .withColumn(&quot;positive_ratio&quot;, F.col(&quot;positive_ratings&quot;) / F.col(&quot;total_ratings&quot;))
    # 베이지안 평균: 평점 수가 적은 영화의 과대/과소 평가 보정
    # C=3.0 (전체 평균 추정), m=10 (최소 신뢰 샘플 수)
    .withColumn(&quot;bayesian_avg&quot;,
        (F.col(&quot;avg_rating&quot;) * F.col(&quot;total_ratings&quot;) + 3.0 * 10) /
        (F.col(&quot;total_ratings&quot;) + 10)
    )
)

movie_stats.write.format(&quot;delta&quot;).mode(&quot;overwrite&quot;).saveAsTable(&quot;silver_movie_stats&quot;)
print(f&quot;✅ silver_movie_stats 저장 완료: {movie_stats.count():,} rows&quot;)</code></pre>
<h4 id="output-18">output</h4>
<pre><code class="language-text">✅ silver_movie_stats 저장 완료: 500 rows</code></pre>
<h2 id="8-데이터-품질-리포트">8. 데이터 품질 리포트</h2>
<p>Silver Layer 전체의 데이터 품질과 통계를 요약합니다.</p>
<pre><code class="language-python"># Silver Layer 데이터 품질 요약
print(&quot;=&quot; * 70)
print(&quot;📊 Silver Layer 데이터 품질 리포트&quot;)
print(&quot;=&quot; * 70)

# 각 테이블 요약
tables = [
    (&quot;silver_movies&quot;, spark.table(&quot;silver_movies&quot;)),
    (&quot;silver_users&quot;, spark.table(&quot;silver_users&quot;)),
    (&quot;silver_ratings&quot;, spark.table(&quot;silver_ratings&quot;)),
    (&quot;silver_tags&quot;, spark.table(&quot;silver_tags&quot;)),
    (&quot;silver_user_stats&quot;, spark.table(&quot;silver_user_stats&quot;)),
    (&quot;silver_movie_stats&quot;, spark.table(&quot;silver_movie_stats&quot;))
]

for name, df in tables:
    print(f&quot;\n📌 {name}:&quot;)
    print(f&quot;   Rows: {df.count():,}&quot;)
    print(f&quot;   Columns: {len(df.columns)}&quot;)

# 핵심 통계
ratings_df = spark.table(&quot;silver_ratings&quot;)
print(f&quot;\n📈 평점 통계:&quot;)
print(f&quot;   총 평점 수: {ratings_df.count():,}&quot;)
print(f&quot;   평균 평점: {ratings_df.agg(F.avg(&#39;rating&#39;)).collect()[0][0]:.3f}&quot;)
print(f&quot;   평점 표준편차: {ratings_df.agg(F.stddev(&#39;rating&#39;)).collect()[0][0]:.3f}&quot;)

user_stats_df = spark.table(&quot;silver_user_stats&quot;)
print(f&quot;\n👤 사용자 통계:&quot;)
print(f&quot;   사용자당 평균 평점 수: {user_stats_df.agg(F.avg(&#39;total_ratings&#39;)).collect()[0][0]:.1f}&quot;)
print(f&quot;   사용자당 평균 평점: {user_stats_df.agg(F.avg(&#39;avg_rating&#39;)).collect()[0][0]:.3f}&quot;)

movie_stats_df = spark.table(&quot;silver_movie_stats&quot;)
print(f&quot;\n🎬 영화 통계:&quot;)
print(f&quot;   영화당 평균 평점 수: {movie_stats_df.agg(F.avg(&#39;total_ratings&#39;)).collect()[0][0]:.1f}&quot;)
print(f&quot;   영화 평균 평점: {movie_stats_df.agg(F.avg(&#39;avg_rating&#39;)).collect()[0][0]:.3f}&quot;)

print(&quot;\n&quot; + &quot;=&quot; * 70)
print(&quot;✅ Silver Layer 변환 완료!&quot;)
print(&quot;=&quot; * 70)</code></pre>
<h4 id="output-19">output</h4>
<pre><code class="language-text">======================================================================
📊 Silver Layer 데이터 품질 리포트
======================================================================

📌 silver_movies:
   Rows: 500
   Columns: 15

📌 silver_users:
   Rows: 1,000
   Columns: 14

📌 silver_ratings:
   Rows: 9,533
   Columns: 13

📌 silver_tags:
   Rows: 1,000
   Columns: 9

📌 silver_user_stats:
   Rows: 1,000
   Columns: 13

📌 silver_movie_stats:
   Rows: 500
   Columns: 10

📈 평점 통계:
   총 평점 수: 9,533
   평균 평점: 4.095
   평점 표준편차: 0.733

👤 사용자 통계:
   사용자당 평균 평점 수: 9.5
   사용자당 평균 평점: 4.087

🎬 영화 통계:
   영화당 평균 평점 수: 19.1
   영화 평균 평점: 4.124

======================================================================
✅ Silver Layer 변환 완료!
======================================================================</code></pre>
<h3 id="생성된-silver-테이블">생성된 Silver 테이블</h3>
<table>
<thead>
<tr>
<th>테이블</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>silver_movies</code></td>
<td>영화 메타데이터 + 피처</td>
</tr>
<tr>
<td><code>silver_users</code></td>
<td>사용자 프로필 + 피처</td>
</tr>
<tr>
<td><code>silver_ratings</code></td>
<td>평점 + 시간/카테고리 피처</td>
</tr>
<tr>
<td><code>silver_tags</code></td>
<td>태그 + 감성 분류</td>
</tr>
<tr>
<td><code>silver_user_stats</code></td>
<td>사용자별 집계 통계</td>
</tr>
<tr>
<td><code>silver_movie_stats</code></td>
<td>영화별 집계 통계 + 베이지안 평균</td>
</tr>
</tbody></table>
<h1 id="gold-layer--als-추천-모델-훈련">Gold Layer &amp; ALS 추천 모델 훈련</h1>
<p>비즈니스 집계 테이블을 생성하고 <strong>ALS (Alternating Least Squares)</strong> 협업 필터링 모델을 훈련합니다.</p>
<h3 id="주요-내용">주요 내용</h3>
<ol>
<li><strong>Gold Layer 집계 테이블</strong> — 장르별 인기도, 연도별 트렌드, Top 영화 리더보드</li>
<li><strong>ALS 모델 훈련</strong> — Spark MLlib의 ALS 알고리즘</li>
<li><strong>하이퍼파라미터 튜닝</strong> — MLflow 실험 추적 기반 그리드 서치</li>
<li><strong>모델 평가 및 등록</strong> — RMSE/MAE 평가, MLflow Model Registry 등록</li>
<li><strong>추천 결과 생성</strong> — 모든 사용자에 대한 Top-N 추천</li>
</ol>
<h3 id="als-alternating-least-squares-알고리즘이란">ALS (Alternating Least Squares) 알고리즘이란?</h3>
<p>협업 필터링의 대표적 행렬 분해(Matrix Factorization) 기법입니다.</p>
<ul>
<li>사용자-영화 평점 행렬을 <strong>사용자 잠재 요인 × 영화 잠재 요인</strong>으로 분해</li>
<li>누락된 평점을 예측하여 추천에 활용</li>
<li>Spark MLlib에서 대규모 분산 처리를 지원</li>
</ul>
<h3 id="메달리온-아키텍처에서의-위치-2">메달리온 아키텍처에서의 위치</h3>
<pre><code>Bronze (원시) → Silver (정제) → Gold (집계/ML)
                                   ↑ 현재 위치</code></pre><h2 id="1-환경-설정-2">1. 환경 설정</h2>
<pre><code class="language-python">from pyspark.sql import functions as F
from pyspark.sql.window import Window
from pyspark.ml.recommendation import ALS
from pyspark.ml.evaluation import RegressionEvaluator
from pyspark.ml.tuning import ParamGridBuilder, CrossValidator
import mlflow
import mlflow.spark
from datetime import datetime

# ============================================================
# ⚠️ 카탈로그 이름과 USERNAME을 본인 정보로 변경하세요!
# ============================================================
CATALOG = &quot;3dt016_databricks&quot;
SCHEMA = &quot;movie_recommender&quot;
spark.sql(f&quot;USE {CATALOG}.{SCHEMA}&quot;)

# MLflow 실험 설정
# USERNAME: Databricks 워크스페이스 이메일 주소
USERNAME = spark.sql(&quot;SELECT current_user()&quot;).collect()[0][0]

EXPERIMENT_NAME = f&quot;/Users/{USERNAME}/movie-recommender-als&quot;
mlflow.set_experiment(EXPERIMENT_NAME)

print(f&quot;✅ 환경 설정 완료&quot;)
print(f&quot;   카탈로그: {CATALOG}.{SCHEMA}&quot;)
print(f&quot;   사용자: {USERNAME}&quot;)
print(f&quot;   MLflow 실험: {EXPERIMENT_NAME}&quot;)</code></pre>
<h4 id="output-20">output</h4>
<pre><code class="language-text">2026/03/27 02:06:06 INFO mlflow.tracking.fluent: Experiment with name &#39;/Users/3dt016@msacademy.msai.kr/movie-recommender-als&#39; does not exist. Creating a new experiment.</code></pre>
<pre><code class="language-text">✅ 환경 설정 완료
   카탈로그: 3dt016_databricks.movie_recommender
   사용자: 3dt016@msacademy.msai.kr
   MLflow 실험: /Users/3dt016@msacademy.msai.kr/movie-recommender-als</code></pre>
<h2 id="2-gold-layer-비즈니스-집계-테이블">2. Gold Layer: 비즈니스 집계 테이블</h2>
<p>Gold Layer는 비즈니스 분석과 대시보드를 위한 집계 테이블입니다.
Silver 데이터를 기반으로 다양한 관점의 분석 결과를 사전 계산합니다.</p>
<h3 id="21-장르별-인기도-분석">2.1 장르별 인기도 분석</h3>
<p>각 장르의 총 평점 수, 평균 평점, 영화 수, 도달 사용자 수 등을 집계합니다.
<strong>popularity_score</strong>는 <code>총 평점 수 × 평균 평점 / 5.0</code>으로 계산한 종합 인기 지표입니다.</p>
<pre><code class="language-python"># Silver 데이터 로드
silver_movies = spark.table(&quot;silver_movies&quot;)
silver_ratings = spark.table(&quot;silver_ratings&quot;)
silver_movie_stats = spark.table(&quot;silver_movie_stats&quot;)

# 영화-평점 조인 (장르 분석을 위해)
movie_ratings = (
    silver_ratings
    .join(silver_movies.select(&quot;movie_id&quot;, &quot;genres_array&quot;, &quot;year&quot;, &quot;decade&quot;), &quot;movie_id&quot;)
)

# 장르 Explode: 한 영화가 여러 장르에 속할 수 있으므로 행을 펼침
# 예: [&quot;Action&quot;, &quot;Comedy&quot;] → Action 행 1개 + Comedy 행 1개
gold_genre_popularity = (
    movie_ratings
    .withColumn(&quot;genre&quot;, F.explode(&quot;genres_array&quot;))
    .groupBy(&quot;genre&quot;)
    .agg(
        F.count(&quot;*&quot;).alias(&quot;total_ratings&quot;),
        F.avg(&quot;rating&quot;).alias(&quot;avg_rating&quot;),
        F.countDistinct(&quot;movie_id&quot;).alias(&quot;movie_count&quot;),
        F.countDistinct(&quot;user_id&quot;).alias(&quot;user_reach&quot;),
        F.sum(&quot;is_positive&quot;).alias(&quot;positive_ratings&quot;)
    )
    .withColumn(&quot;positive_ratio&quot;, F.col(&quot;positive_ratings&quot;) / F.col(&quot;total_ratings&quot;))
    .withColumn(&quot;popularity_score&quot;,
        F.round((F.col(&quot;total_ratings&quot;) * F.col(&quot;avg_rating&quot;) / 5.0), 2)
    )
    .orderBy(F.desc(&quot;popularity_score&quot;))
)

gold_genre_popularity.write.format(&quot;delta&quot;).mode(&quot;overwrite&quot;).saveAsTable(&quot;gold_genre_popularity&quot;)
print(&quot;✅ gold_genre_popularity 저장 완료&quot;)
display(gold_genre_popularity)</code></pre>
<h4 id="output-21">output</h4>
<pre><code class="language-text">✅ gold_genre_popularity 저장 완료</code></pre>
<table>
<thead>
<tr>
<th>genre</th>
<th>total_ratings</th>
<th>avg_rating</th>
<th>movie_count</th>
<th>user_reach</th>
<th>positive_ratings</th>
<th>positive_ratio</th>
<th>popularity_score</th>
</tr>
</thead>
<tbody><tr>
<td>Film-Noir</td>
<td>1707</td>
<td>4.12888107791447</td>
<td>79</td>
<td>613</td>
<td>1223</td>
<td>0.7164616285881664</td>
<td>1409.6</td>
</tr>
<tr>
<td>Crime</td>
<td>1681</td>
<td>3.988399762046401</td>
<td>73</td>
<td>628</td>
<td>1069</td>
<td>0.635930993456276</td>
<td>1340.9</td>
</tr>
<tr>
<td>Western</td>
<td>1541</td>
<td>4.2193380921479555</td>
<td>76</td>
<td>590</td>
<td>1174</td>
<td>0.7618429591174561</td>
<td>1300.4</td>
</tr>
<tr>
<td>Drama</td>
<td>1500</td>
<td>4.101666666666667</td>
<td>72</td>
<td>582</td>
<td>1046</td>
<td>0.6973333333333334</td>
<td>1230.5</td>
</tr>
<tr>
<td>Comedy</td>
<td>1435</td>
<td>4.242508710801394</td>
<td>76</td>
<td>568</td>
<td>1092</td>
<td>0.7609756097560976</td>
<td>1217.6</td>
</tr>
<tr>
<td>Romance</td>
<td>1417</td>
<td>4.172194777699365</td>
<td>79</td>
<td>587</td>
<td>1035</td>
<td>0.7304163726182075</td>
<td>1182.4</td>
</tr>
<tr>
<td>Animation</td>
<td>1342</td>
<td>4.254843517138599</td>
<td>78</td>
<td>560</td>
<td>1029</td>
<td>0.7667660208643815</td>
<td>1142.0</td>
</tr>
<tr>
<td>Mystery</td>
<td>1377</td>
<td>4.122730573710966</td>
<td>67</td>
<td>575</td>
<td>948</td>
<td>0.6884531590413944</td>
<td>1135.4</td>
</tr>
<tr>
<td>Horror</td>
<td>1329</td>
<td>4.165537998495109</td>
<td>81</td>
<td>549</td>
<td>964</td>
<td>0.7253574115876599</td>
<td>1107.2</td>
</tr>
<tr>
<td>Documentary</td>
<td>1292</td>
<td>4.251547987616099</td>
<td>57</td>
<td>547</td>
<td>988</td>
<td>0.7647058823529411</td>
<td>1098.6</td>
</tr>
<tr>
<td>Sci-Fi</td>
<td>1371</td>
<td>3.9956236323851204</td>
<td>58</td>
<td>587</td>
<td>865</td>
<td>0.6309263311451495</td>
<td>1095.6</td>
</tr>
<tr>
<td>Action</td>
<td>1348</td>
<td>4.048961424332345</td>
<td>68</td>
<td>571</td>
<td>892</td>
<td>0.6617210682492581</td>
<td>1091.6</td>
</tr>
<tr>
<td>Fantasy</td>
<td>1334</td>
<td>4.049100449775112</td>
<td>74</td>
<td>564</td>
<td>905</td>
<td>0.6784107946026986</td>
<td>1080.3</td>
</tr>
<tr>
<td>Adventure</td>
<td>1305</td>
<td>4.054022988505747</td>
<td>74</td>
<td>546</td>
<td>868</td>
<td>0.6651340996168582</td>
<td>1058.1</td>
</tr>
<tr>
<td>Thriller</td>
<td>1218</td>
<td>4.211001642036125</td>
<td>71</td>
<td>507</td>
<td>907</td>
<td>0.7446633825944171</td>
<td>1025.8</td>
</tr>
<tr>
<td>Musical</td>
<td>1232</td>
<td>3.9326298701298703</td>
<td>58</td>
<td>558</td>
<td>730</td>
<td>0.5925324675324676</td>
<td>969.0</td>
</tr>
<tr>
<td>Children</td>
<td>1147</td>
<td>4.195727986050566</td>
<td>74</td>
<td>487</td>
<td>844</td>
<td>0.7358326068003488</td>
<td>962.5</td>
</tr>
<tr>
<td>War</td>
<td>1082</td>
<td>4.203789279112754</td>
<td>69</td>
<td>500</td>
<td>807</td>
<td>0.7458410351201479</td>
<td>909.7</td>
</tr>
</tbody></table>
<h3 id="22-연도별-트렌드-분석">2.2 연도별 트렌드 분석</h3>
<p>영화 개봉 연도별 평점 패턴을 분석합니다.</p>
<pre><code class="language-python">gold_yearly_trends = (
    movie_ratings
    .groupBy(&quot;year&quot;)
    .agg(
        F.count(&quot;*&quot;).alias(&quot;total_ratings&quot;),
        F.avg(&quot;rating&quot;).alias(&quot;avg_rating&quot;),
        F.countDistinct(&quot;movie_id&quot;).alias(&quot;movies_rated&quot;),
        F.countDistinct(&quot;user_id&quot;).alias(&quot;active_users&quot;)
    )
    .withColumn(&quot;ratings_per_movie&quot;, F.col(&quot;total_ratings&quot;) / F.col(&quot;movies_rated&quot;))
    .orderBy(&quot;year&quot;)
)

gold_yearly_trends.write.format(&quot;delta&quot;).mode(&quot;overwrite&quot;).saveAsTable(&quot;gold_yearly_trends&quot;)
print(&quot;✅ gold_yearly_trends 저장 완료&quot;)</code></pre>
<h4 id="output-22">output</h4>
<pre><code class="language-text">✅ gold_yearly_trends 저장 완료</code></pre>
<h3 id="23-top-영화-리더보드">2.3 Top 영화 리더보드</h3>
<p>베이지안 평균 기준 Top 100 영화 리더보드를 생성합니다.</p>
<ul>
<li><strong>MIN_RATINGS = 5</strong>: 최소 5개 이상의 평점을 받은 영화만 포함 (신뢰성 확보)</li>
<li>베이지안 평균을 사용하므로 평점 수가 적은 영화의 과대평가를 방지</li>
</ul>
<pre><code class="language-python"># 최소 평가 수 필터 (신뢰성 확보)
MIN_RATINGS = 5

gold_top_movies = (
    silver_movie_stats
    .filter(F.col(&quot;total_ratings&quot;) &gt;= MIN_RATINGS)
    .join(silver_movies.select(&quot;movie_id&quot;, &quot;title&quot;, &quot;year&quot;, &quot;genres&quot;, &quot;director&quot;), &quot;movie_id&quot;)
    .withColumn(&quot;rank&quot;, F.row_number().over(
        Window.orderBy(F.desc(&quot;bayesian_avg&quot;))
    ))
    .filter(F.col(&quot;rank&quot;) &lt;= 100)  # Top 100
    .select(
        &quot;rank&quot;, &quot;movie_id&quot;, &quot;title&quot;, &quot;year&quot;, &quot;genres&quot;, &quot;director&quot;,
        &quot;total_ratings&quot;,
        F.round(&quot;avg_rating&quot;, 2).alias(&quot;avg_rating&quot;),
        F.round(&quot;bayesian_avg&quot;, 2).alias(&quot;bayesian_avg&quot;),
        F.round(&quot;positive_ratio&quot;, 2).alias(&quot;positive_ratio&quot;)
    )
)

gold_top_movies.write.format(&quot;delta&quot;).mode(&quot;overwrite&quot;).saveAsTable(&quot;gold_top_movies&quot;)
print(&quot;✅ gold_top_movies 저장 완료&quot;)
display(gold_top_movies.limit(20))</code></pre>
<h4 id="output-23">output</h4>
<pre><code class="language-text">✅ gold_top_movies 저장 완료</code></pre>
<table>
<thead>
<tr>
<th>rank</th>
<th>movie_id</th>
<th>title</th>
<th>year</th>
<th>genres</th>
<th>director</th>
<th>total_ratings</th>
<th>avg_rating</th>
<th>bayesian_avg</th>
<th>positive_ratio</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>407</td>
<td>Shadow of Knight (2012)</td>
<td>2012</td>
<td>Drama</td>
<td>Denis Villeneuve</td>
<td>98</td>
<td>4.6</td>
<td>4.45</td>
<td>0.93</td>
</tr>
<tr>
<td>2</td>
<td>394</td>
<td>Fire of Destiny (1999)</td>
<td>1999</td>
<td>Documentary|Drama|Comedy|Action</td>
<td>Bong Joon-ho</td>
<td>64</td>
<td>4.66</td>
<td>4.44</td>
<td>0.95</td>
</tr>
<tr>
<td>3</td>
<td>4</td>
<td>Kingdom Fire (2001)</td>
<td>2001</td>
<td>Action|Crime|Romance</td>
<td>Wes Anderson</td>
<td>143</td>
<td>4.52</td>
<td>4.42</td>
<td>0.9</td>
</tr>
<tr>
<td>4</td>
<td>207</td>
<td>Storm: Dark Ocean (2001)</td>
<td>2001</td>
<td>Mystery</td>
<td>Wong Kar-wai</td>
<td>49</td>
<td>4.69</td>
<td>4.41</td>
<td>0.96</td>
</tr>
<tr>
<td>5</td>
<td>120</td>
<td>Destiny Dream (2012)</td>
<td>2012</td>
<td>Sci-Fi|Horror|Documentary|Musical</td>
<td>David Fincher</td>
<td>30</td>
<td>4.82</td>
<td>4.36</td>
<td>1.0</td>
</tr>
<tr>
<td>6</td>
<td>81</td>
<td>Lost Knight (2003)</td>
<td>2003</td>
<td>Mystery|Western|Documentary</td>
<td>Martin Scorsese</td>
<td>30</td>
<td>4.8</td>
<td>4.35</td>
<td>1.0</td>
</tr>
<tr>
<td>7</td>
<td>204</td>
<td>Rise of Soul (2003)</td>
<td>2003</td>
<td>Animation|Children|Thriller|Film-Noir</td>
<td>Stanley Kubrick</td>
<td>25</td>
<td>4.86</td>
<td>4.33</td>
<td>1.0</td>
</tr>
<tr>
<td>8</td>
<td>199</td>
<td>Storm: Return of Warrior (1984)</td>
<td>1984</td>
<td>War|Comedy|Romance|Adventure</td>
<td>David Fincher</td>
<td>24</td>
<td>4.88</td>
<td>4.32</td>
<td>1.0</td>
</tr>
<tr>
<td>9</td>
<td>224</td>
<td>Empire Forest (2001)</td>
<td>2001</td>
<td>Thriller|Documentary|Children</td>
<td>Denis Villeneuve</td>
<td>41</td>
<td>4.61</td>
<td>4.29</td>
<td>0.88</td>
</tr>
<tr>
<td>10</td>
<td>464</td>
<td>Soul: Secret Heart (1993)</td>
<td>1993</td>
<td>Documentary</td>
<td>Wes Anderson</td>
<td>33</td>
<td>4.67</td>
<td>4.28</td>
<td>0.94</td>
</tr>
<tr>
<td>11</td>
<td>45</td>
<td>Phoenix: Last Ocean (1979)</td>
<td>1979</td>
<td>Western|Action|Children|Adventure</td>
<td>Denis Villeneuve</td>
<td>31</td>
<td>4.68</td>
<td>4.27</td>
<td>1.0</td>
</tr>
<tr>
<td>12</td>
<td>238</td>
<td>Fire of Soul (1979)</td>
<td>1979</td>
<td>Sci-Fi|Documentary|Horror|Film-Noir</td>
<td>Denis Villeneuve</td>
<td>73</td>
<td>4.42</td>
<td>4.25</td>
<td>0.92</td>
</tr>
<tr>
<td>13</td>
<td>113</td>
<td>Dawn Empire (1991)</td>
<td>1991</td>
<td>Western|Comedy|Film-Noir</td>
<td>Park Chan-wook</td>
<td>163</td>
<td>4.33</td>
<td>4.25</td>
<td>0.83</td>
</tr>
<tr>
<td>14</td>
<td>69</td>
<td>Return of Dragon (2017)</td>
<td>2017</td>
<td>Mystery|Comedy</td>
<td>James Cameron</td>
<td>37</td>
<td>4.58</td>
<td>4.24</td>
<td>0.89</td>
</tr>
<tr>
<td>15</td>
<td>351</td>
<td>Dragon Empire (1981)</td>
<td>1981</td>
<td>Western|War|Thriller|Documentary</td>
<td>Wong Kar-wai</td>
<td>49</td>
<td>4.49</td>
<td>4.24</td>
<td>0.96</td>
</tr>
<tr>
<td>16</td>
<td>116</td>
<td>Shadow City (1997)</td>
<td>1997</td>
<td>Thriller|Film-Noir</td>
<td>Francis Ford Coppola</td>
<td>94</td>
<td>4.36</td>
<td>4.23</td>
<td>0.81</td>
</tr>
<tr>
<td>17</td>
<td>160</td>
<td>Destiny: Return of World (2002)</td>
<td>2002</td>
<td>Animation|Western|War|Adventure</td>
<td>Bong Joon-ho</td>
<td>22</td>
<td>4.77</td>
<td>4.22</td>
<td>1.0</td>
</tr>
<tr>
<td>18</td>
<td>123</td>
<td>Shadow: Last Shadow (2017)</td>
<td>2017</td>
<td>Action|Comedy|Romance|Thriller</td>
<td>Denis Villeneuve</td>
<td>20</td>
<td>4.8</td>
<td>4.2</td>
<td>1.0</td>
</tr>
<tr>
<td>19</td>
<td>139</td>
<td>Kingdom of World (2012)</td>
<td>2012</td>
<td>Western|Musical|Animation|Mystery</td>
<td>Alfonso Cuarón</td>
<td>168</td>
<td>4.27</td>
<td>4.2</td>
<td>0.79</td>
</tr>
<tr>
<td>20</td>
<td>333</td>
<td>Storm Fire (2004)</td>
<td>2004</td>
<td>Mystery|War|Romance|Fantasy</td>
<td>Steven Spielberg</td>
<td>21</td>
<td>4.76</td>
<td>4.19</td>
<td>1.0</td>
</tr>
</tbody></table>
<h3 id="24-확장-분석-장르별최근감성-기반-top-n">2.4 확장 분석: 장르별/최근/감성 기반 Top-N</h3>
<p>추가 Gold 테이블을 생성합니다:</p>
<ol>
<li><strong>장르별 Top 10</strong>: 각 장르 내에서 bayesian_avg 기준 최고 영화</li>
<li><strong>최근 인기 Top 10</strong>: 최근 12개월 내 평가된 영화 중 최고</li>
<li><strong>감성 기반 Top 10</strong>: positive_ratio (긍정 평점 비율) 기준 최고</li>
</ol>
<pre><code class="language-python">from pyspark.sql.functions import current_date, date_sub

TOP_N = 10
RECENT_MONTHS = 12

# ============================
# 1. 장르별 Top-N
# ============================
# 영화의 장르를 펼쳐서 장르별 랭킹 생성
genre_exploded = (
    silver_movies
    .withColumn(&quot;genre&quot;, F.explode(&quot;genres_array&quot;))
    .select(&quot;movie_id&quot;, &quot;title&quot;, &quot;year&quot;, &quot;director&quot;, &quot;genre&quot;)
)

genre_join = (
    silver_movie_stats
    .filter(F.col(&quot;total_ratings&quot;) &gt;= MIN_RATINGS)
    .join(genre_exploded, &quot;movie_id&quot;)
)

# 장르 내에서 bayesian_avg 기준 순위 부여
genre_window = Window.partitionBy(&quot;genre&quot;).orderBy(F.desc(&quot;bayesian_avg&quot;))

gold_genre_top = (
    genre_join
    .withColumn(&quot;rank&quot;, F.row_number().over(genre_window))
    .filter(F.col(&quot;rank&quot;) &lt;= TOP_N)
    .select(
        &quot;rank&quot;, &quot;genre&quot;, &quot;movie_id&quot;, &quot;title&quot;, &quot;year&quot;, &quot;director&quot;,
        &quot;total_ratings&quot;, F.round(&quot;avg_rating&quot;, 2).alias(&quot;avg_rating&quot;),
        F.round(&quot;bayesian_avg&quot;, 2).alias(&quot;bayesian_avg&quot;),
        F.round(&quot;positive_ratio&quot;, 2).alias(&quot;positive_ratio&quot;)
    )
)

gold_genre_top.write.format(&quot;delta&quot;).mode(&quot;overwrite&quot;).saveAsTable(&quot;gold_genre_top_movies&quot;)
print(&quot;✅ gold_genre_top_movies 저장 완료&quot;)

# ============================
# 2. 최근 인기 Top-N
# ============================
recent_cutoff = date_sub(current_date(), RECENT_MONTHS * 30)

recent_ratings = silver_ratings.filter(F.col(&quot;rating_date&quot;) &gt;= recent_cutoff)

recent_movie_stats = (
    recent_ratings
    .groupBy(&quot;movie_id&quot;)
    .agg(
        F.count(&quot;*&quot;).alias(&quot;recent_ratings_count&quot;),
        F.avg(&quot;rating&quot;).alias(&quot;recent_avg_rating&quot;),
        F.sum(&quot;is_positive&quot;).alias(&quot;recent_positive_ratings&quot;)
    )
    .filter(F.col(&quot;recent_ratings_count&quot;) &gt;= MIN_RATINGS)
    .join(silver_movies.select(&quot;movie_id&quot;, &quot;title&quot;, &quot;year&quot;, &quot;director&quot;), &quot;movie_id&quot;)
)

recent_window = Window.orderBy(F.desc(&quot;recent_avg_rating&quot;))

gold_recent_top = (
    recent_movie_stats
    .withColumn(&quot;rank&quot;, F.row_number().over(recent_window))
    .filter(F.col(&quot;rank&quot;) &lt;= TOP_N)
    .select(
        &quot;rank&quot;, &quot;movie_id&quot;, &quot;title&quot;, &quot;year&quot;, &quot;director&quot;,
        &quot;recent_ratings_count&quot;,
        F.round(&quot;recent_avg_rating&quot;, 2).alias(&quot;recent_avg_rating&quot;),
        F.round(F.col(&quot;recent_positive_ratings&quot;) / F.col(&quot;recent_ratings_count&quot;), 2).alias(&quot;recent_positive_ratio&quot;)
    )
)

gold_recent_top.write.format(&quot;delta&quot;).mode(&quot;overwrite&quot;).saveAsTable(&quot;gold_recent_top_movies&quot;)
print(&quot;✅ gold_recent_top_movies 저장 완료&quot;)

# ============================
# 3. 감성 기반 Top-N (positive_ratio 기준)
# ============================
sentiment_window = Window.orderBy(F.desc(&quot;positive_ratio&quot;))

gold_sentiment_top = (
    silver_movie_stats
    .filter(F.col(&quot;total_ratings&quot;) &gt;= MIN_RATINGS)
    .join(silver_movies.select(&quot;movie_id&quot;, &quot;title&quot;, &quot;year&quot;, &quot;director&quot;), &quot;movie_id&quot;)
    .withColumn(&quot;rank&quot;, F.row_number().over(sentiment_window))
    .filter(F.col(&quot;rank&quot;) &lt;= TOP_N)
    .select(
        &quot;rank&quot;, &quot;movie_id&quot;, &quot;title&quot;, &quot;year&quot;, &quot;director&quot;,
        &quot;total_ratings&quot;, F.round(&quot;avg_rating&quot;, 2).alias(&quot;avg_rating&quot;),
        F.round(&quot;bayesian_avg&quot;, 2).alias(&quot;bayesian_avg&quot;),
        F.round(&quot;positive_ratio&quot;, 2).alias(&quot;positive_ratio&quot;)
    )
)

gold_sentiment_top.write.format(&quot;delta&quot;).mode(&quot;overwrite&quot;).saveAsTable(&quot;gold_sentiment_top_movies&quot;)
print(&quot;✅ gold_sentiment_top_movies 저장 완료&quot;)</code></pre>
<h4 id="output-24">output</h4>
<pre><code class="language-text">✅ gold_genre_top_movies 저장 완료
✅ gold_recent_top_movies 저장 완료
✅ gold_sentiment_top_movies 저장 완료</code></pre>
<h2 id="3-추천-모델-데이터-준비">3. 추천 모델 데이터 준비</h2>
<p>ALS 모델 훈련을 위해 평점 데이터를 <strong>Train/Test</strong> 세트로 분할합니다.</p>
<ul>
<li><strong>80/20 분할</strong>: 80%는 훈련, 20%는 평가</li>
<li><strong>seed=42</strong>: 재현 가능한 분할</li>
<li><strong>캐싱</strong>: 반복 사용되는 데이터를 메모리에 캐싱하여 성능 향상</li>
</ul>
<pre><code class="language-python"># 평점 데이터 로드 (ML에 필요한 3개 컬럼만 선택)
ratings = (
    spark.table(&quot;silver_ratings&quot;)
    .select(&quot;user_id&quot;, &quot;movie_id&quot;, &quot;rating&quot;)
)

print(f&quot;📊 전체 평점 수: {ratings.count():,}&quot;)

# Train/Test 분할 (80/20)
train_data, test_data = ratings.randomSplit([0.8, 0.2], seed=42)
print(f&quot;   훈련 데이터: {train_data.count():,}&quot;)
print(f&quot;   테스트 데이터: {test_data.count():,}&quot;)

# 메모리 캐싱 (반복 접근 시 성능 향상)
train_data.cache()
test_data.cache()</code></pre>
<h4 id="output-25">output</h4>
<pre><code class="language-text">📊 전체 평점 수: 9,533
   훈련 데이터: 7,711
   테스트 데이터: 1,822</code></pre>
<pre><code class="language-text">DataFrame[user_id: bigint, movie_id: bigint, rating: double]</code></pre>
<h2 id="4-als-모델-훈련">4. ALS 모델 훈련</h2>
<h3 id="als-주요-파라미터">ALS 주요 파라미터</h3>
<table>
<thead>
<tr>
<th>파라미터</th>
<th>설명</th>
<th>기본값</th>
</tr>
</thead>
<tbody><tr>
<td><strong>rank</strong></td>
<td>잠재 요인의 차원 수 (높을수록 표현력 ↑, 과적합 위험 ↑)</td>
<td>10</td>
</tr>
<tr>
<td><strong>regParam</strong></td>
<td>L2 정규화 강도 (높을수록 과적합 방지, 편향 ↑)</td>
<td>0.1</td>
</tr>
<tr>
<td><strong>maxIter</strong></td>
<td>최대 반복 횟수</td>
<td>10</td>
</tr>
<tr>
<td><strong>coldStartStrategy</strong></td>
<td>새 사용자/영화 처리 방법 (&quot;drop&quot;: NaN 예측 제거)</td>
<td>&quot;nan&quot;</td>
</tr>
<tr>
<td><strong>nonnegative</strong></td>
<td>비음수 제약 (평점은 양수이므로 True 권장)</td>
<td>False</td>
</tr>
</tbody></table>
<h3 id="41-기본-als-모델-정의">4.1 기본 ALS 모델 정의</h3>
<pre><code class="language-python"># ALS 모델 정의
als = ALS(
    userCol=&quot;user_id&quot;,
    itemCol=&quot;movie_id&quot;,
    ratingCol=&quot;rating&quot;,
    coldStartStrategy=&quot;drop&quot;,  # Cold start 시 NaN 예측을 제거 (평가 시 오류 방지)
    nonnegative=True           # 비음수 제약 (평점은 항상 양수)
)

# 평가 메트릭: RMSE (Root Mean Square Error)
evaluator = RegressionEvaluator(
    metricName=&quot;rmse&quot;,
    labelCol=&quot;rating&quot;,
    predictionCol=&quot;prediction&quot;
)</code></pre>
<h3 id="42-하이퍼파라미터-튜닝-with-mlflow">4.2 하이퍼파라미터 튜닝 with MLflow</h3>
<p><strong>그리드 서치(Grid Search)</strong> 를 통해 최적의 하이퍼파라미터 조합을 찾습니다.
각 조합의 결과는 <strong>MLflow</strong>에 자동으로 기록됩니다.</p>
<p>테스트할 파라미터 조합:</p>
<ul>
<li>rank: [10, 20, 50] — 잠재 요인 차원 수</li>
<li>regParam: [0.01, 0.1, 0.5] — 정규화 강도</li>
<li>maxIter: [10, 20] — 반복 횟수</li>
</ul>
<p>총 3 × 3 × 2 = <strong>18개</strong> 조합을 테스트합니다.</p>
<pre><code class="language-python"># 파라미터 그리드 정의
param_grid = (
    ParamGridBuilder()
    .addGrid(als.rank, [10, 20, 50])
    .addGrid(als.regParam, [0.01, 0.1, 0.5])
    .addGrid(als.maxIter, [10, 20])
    .build()
)

print(f&quot;🔍 총 {len(param_grid)}개 파라미터 조합 테스트&quot;)</code></pre>
<h4 id="output-26">output</h4>
<pre><code class="language-text">🔍 총 18개 파라미터 조합 테스트</code></pre>
<h3 id="43-수동-그리드-서치-실행">4.3 수동 그리드 서치 실행</h3>
<p>MLflow의 <strong>중첩 실행(Nested Runs)</strong> 을 활용하여 모든 조합을 추적합니다.</p>
<ul>
<li><strong>Parent Run</strong>: 전체 그리드 서치 실험</li>
<li><strong>Child Run</strong>: 각 파라미터 조합의 개별 실험</li>
</ul>
<p>실행 완료 후 MLflow UI (<code>Experiments</code> 탭)에서 결과를 시각적으로 비교할 수 있습니다.</p>
<pre><code class="language-python"># 수동 그리드 서치 with MLflow 추적
best_rmse = float(&quot;inf&quot;)
best_model = None
best_params = {}

with mlflow.start_run(run_name=&quot;ALS_GridSearch&quot;) as parent_run:

    # 데이터셋 정보를 Parent Run에 기록
    mlflow.log_param(&quot;dataset_size&quot;, train_data.count())
    mlflow.log_param(&quot;n_users&quot;, train_data.select(&quot;user_id&quot;).distinct().count())
    mlflow.log_param(&quot;n_movies&quot;, train_data.select(&quot;movie_id&quot;).distinct().count())

    for i, params in enumerate(param_grid):
        rank = params[als.rank]
        reg_param = params[als.regParam]
        max_iter = params[als.maxIter]

        run_name = f&quot;rank{rank}_reg{reg_param}_iter{max_iter}&quot;

        with mlflow.start_run(run_name=run_name, nested=True):
            # 파라미터 로깅
            mlflow.log_params({
                &quot;rank&quot;: rank,
                &quot;regParam&quot;: reg_param,
                &quot;maxIter&quot;: max_iter
            })

            # 모델 훈련
            als_model = als.setParams(
                rank=rank,
                regParam=reg_param,
                maxIter=max_iter
            )

            model = als_model.fit(train_data)

            # 테스트 데이터로 평가
            predictions = model.transform(test_data)
            rmse = evaluator.evaluate(predictions)

            # 메트릭 로깅
            mlflow.log_metric(&quot;rmse&quot;, rmse)

            print(f&quot;[{i+1}/{len(param_grid)}] {run_name}: RMSE = {rmse:.4f}&quot;)

            # Best 모델 업데이트
            if rmse &lt; best_rmse:
                best_rmse = rmse
                best_model = model
                best_params = {&quot;rank&quot;: rank, &quot;regParam&quot;: reg_param, &quot;maxIter&quot;: max_iter}

    # Parent Run에 최종 결과 기록
    mlflow.log_metric(&quot;best_rmse&quot;, best_rmse)
    mlflow.log_params({f&quot;best_{k}&quot;: v for k, v in best_params.items()})

print(f&quot;\n🏆 Best 모델:&quot;)
print(f&quot;   파라미터: {best_params}&quot;)
print(f&quot;   RMSE: {best_rmse:.4f}&quot;)</code></pre>
<h4 id="output-27">output</h4>
<pre><code class="language-text">[1/18] rank10_reg0.01_iter10: RMSE = 0.8403
[2/18] rank10_reg0.01_iter20: RMSE = 0.7987
[3/18] rank10_reg0.1_iter10: RMSE = 0.6702
[4/18] rank10_reg0.1_iter20: RMSE = 0.6686
[5/18] rank10_reg0.5_iter10: RMSE = 0.7810
[6/18] rank10_reg0.5_iter20: RMSE = 0.7801
[7/18] rank20_reg0.01_iter10: RMSE = 0.7968
[8/18] rank20_reg0.01_iter20: RMSE = 0.7885
[9/18] rank20_reg0.1_iter10: RMSE = 0.6612
[10/18] rank20_reg0.1_iter20: RMSE = 0.6646
[11/18] rank20_reg0.5_iter10: RMSE = 0.7814
[12/18] rank20_reg0.5_iter20: RMSE = 0.7801
[13/18] rank50_reg0.01_iter10: RMSE = 0.8259
[14/18] rank50_reg0.01_iter20: RMSE = 0.8039
[15/18] rank50_reg0.1_iter10: RMSE = 0.6535
[16/18] rank50_reg0.1_iter20: RMSE = 0.6596
[17/18] rank50_reg0.5_iter10: RMSE = 0.7815
[18/18] rank50_reg0.5_iter20: RMSE = 0.7801

🏆 Best 모델:
   파라미터: {&#39;rank&#39;: 50, &#39;regParam&#39;: 0.1, &#39;maxIter&#39;: 10}
   RMSE: 0.6535</code></pre>
<h3 id="44-최종-모델-훈련-및-mlflow-등록">4.4 최종 모델 훈련 및 MLflow 등록</h3>
<p>Best 파라미터로 <strong>전체 데이터(train + test)</strong> 를 사용하여 최종 모델을 훈련합니다.</p>
<h4 id="mlflow-model-registry">MLflow Model Registry</h4>
<ul>
<li>훈련된 모델을 <strong>Unity Catalog Model Registry</strong>에 등록합니다</li>
<li>등록된 모델은 버전 관리가 되며, 이후 Serving 엔드포인트에 배포할 수 있습니다</li>
</ul>
<h4 id="model-signature">Model Signature</h4>
<ul>
<li><strong>입력</strong>: <code>user_id</code>, <code>movie_id</code> (예측 시에는 rating 불필요)</li>
<li><strong>출력</strong>: <code>prediction</code> (예측 평점)</li>
<li>Signature를 등록하면 MLflow UI에서 모델의 입출력 스키마를 확인할 수 있습니다</li>
</ul>
<pre><code class="language-python">from mlflow.models.signature import infer_signature

# Best 파라미터로 최종 모델 설정
final_als = ALS(
    userCol=&quot;user_id&quot;,
    itemCol=&quot;movie_id&quot;,
    ratingCol=&quot;rating&quot;,
    coldStartStrategy=&quot;drop&quot;,
    nonnegative=True,
    **best_params
)

# 전체 데이터로 재훈련 (정보 손실 방지)
full_data = train_data.union(test_data)
final_model = final_als.fit(full_data)

# Signature 생성: 예측(Inference) 시에는 rating 컬럼이 없으므로 user_id, movie_id만 입력으로 정의
sample_spark_df = full_data.select(&quot;user_id&quot;, &quot;movie_id&quot;).limit(10)
sample_input_pandas = sample_spark_df.toPandas()
sample_output_pandas = final_model.transform(sample_spark_df).toPandas()

# 입력(user_id, movie_id) → 출력(prediction) 관계의 서명 생성
signature = infer_signature(sample_input_pandas, sample_output_pandas)

# MLflow 모델 등록 및 로깅
with mlflow.start_run(run_name=&quot;Final_ALS_Model&quot;) as run:
    # 파라미터 로깅
    mlflow.log_params(best_params)

    # 평가 지표 로깅
    mlflow.log_metric(&quot;test_rmse&quot;, best_rmse)

    # 데이터셋 통계 (모델 메타데이터로 유용)
    mlflow.log_metric(&quot;n_users&quot;, full_data.select(&quot;user_id&quot;).distinct().count())
    mlflow.log_metric(&quot;n_movies&quot;, full_data.select(&quot;movie_id&quot;).distinct().count())
    mlflow.log_metric(&quot;n_ratings&quot;, full_data.count())

    # 모델 저장 + 레지스트리 등록
    # input_example을 추가하면 MLflow UI에서 예제 데이터를 바로 확인 가능
    mlflow.spark.log_model(
        final_model,
        &quot;als_model&quot;,
        registered_model_name=f&quot;{CATALOG}.{SCHEMA}.movie_recommender_als&quot;,
        signature=signature,
        input_example=sample_input_pandas
    )

    run_id = run.info.run_id
    print(f&quot;✅ 모델 저장 및 등록 완료!&quot;)
    print(f&quot;   Run ID: {run_id}&quot;)
    print(f&quot;   Model Name: {CATALOG}.{SCHEMA}.movie_recommender_als&quot;)</code></pre>
<h4 id="output-28">output</h4>
<pre><code class="language-text">/databricks/python/lib/python3.11/site-packages/mlflow/types/utils.py:394: UserWarning: Hint: Inferred schema contains integer column(s). Integer columns in Python cannot represent missing values. If your input data contains missing values at inference time, it will be encoded as floats and will cause a schema enforcement error. The best way to avoid this problem is to infer the model schema based on a realistic data sample (training dataset) that includes missing values. Alternatively, you can declare integer columns as doubles (float64) whenever these columns may have missing values. See `Handling Integers With Missing Values &lt;https://www.mlflow.org/docs/latest/models.html#handling-integers-with-missing-values&gt;`_ for more details.
  warnings.warn(
2026/03/27 02:08:47 INFO mlflow.spark: Inferring pip requirements by reloading the logged model from the databricks artifact repository, which can be time-consuming. To speed up, explicitly specify the conda_env or pip_requirements when calling log_model().</code></pre>
<pre><code class="language-text">Downloading artifacts:   0%|          | 0/38 [00:00&lt;?, ?it/s]</code></pre>
<pre><code class="language-text">2026/03/27 02:09:16 WARNING mlflow.utils.environment: Encountered an unexpected error while inferring pip requirements (model URI: dbfs:/databricks/mlflow-tracking/2225541439018367/e52eef676a2948baa43f8caa1ce344e2/artifacts/als_model/sparkml, flavor: spark). Fall back to return [&#39;pyspark==3.5.0&#39;]. Set logging level to DEBUG to see the full traceback. 
/databricks/python/lib/python3.11/site-packages/_distutils_hack/__init__.py:33: UserWarning: Setuptools is replacing distutils.
  warnings.warn(&quot;Setuptools is replacing distutils.&quot;)</code></pre>
<pre><code class="language-text">Uploading artifacts:   0%|          | 0/5 [00:00&lt;?, ?it/s]</code></pre>
<pre><code class="language-text">Successfully registered model &#39;3dt016_databricks.movie_recommender.movie_recommender_als&#39;.</code></pre>
<pre><code class="language-text">Downloading artifacts:   0%|          | 0/43 [00:00&lt;?, ?it/s]</code></pre>
<pre><code class="language-text">Uploading artifacts:   0%|          | 0/43 [00:00&lt;?, ?it/s]</code></pre>
<pre><code class="language-text">✅ 모델 저장 및 등록 완료!
   Run ID: e52eef676a2948baa43f8caa1ce344e2
   Model Name: 3dt016_databricks.movie_recommender.movie_recommender_als</code></pre>
<pre><code class="language-text">Created version &#39;1&#39; of model &#39;3dt016_databricks.movie_recommender.movie_recommender_als&#39;.</code></pre>
<h2 id="5-모델-평가-및-분석">5. 모델 평가 및 분석</h2>
<h3 id="51-전체-평가-메트릭">5.1 전체 평가 메트릭</h3>
<table>
<thead>
<tr>
<th>메트릭</th>
<th>설명</th>
<th>좋은 수준</th>
</tr>
</thead>
<tbody><tr>
<td><strong>RMSE</strong></td>
<td>평균 제곱근 오차 (큰 오차에 민감)</td>
<td>&lt; 1.0</td>
</tr>
<tr>
<td><strong>MAE</strong></td>
<td>평균 절대 오차 (직관적 해석 용이)</td>
<td>&lt; 0.8</td>
</tr>
</tbody></table>
<pre><code class="language-python"># 테스트 데이터로 최종 모델 예측
test_predictions = final_model.transform(test_data)

# RMSE (Root Mean Square Error)
rmse = evaluator.evaluate(test_predictions)
print(f&quot;📊 RMSE: {rmse:.4f}&quot;)

# MAE (Mean Absolute Error)
mae_evaluator = RegressionEvaluator(
    metricName=&quot;mae&quot;,
    labelCol=&quot;rating&quot;,
    predictionCol=&quot;prediction&quot;
)
mae = mae_evaluator.evaluate(test_predictions)
print(f&quot;📊 MAE: {mae:.4f}&quot;)

# 예측 vs 실제 분포 통계
display(
    test_predictions
    .select(&quot;rating&quot;, &quot;prediction&quot;)
    .withColumn(&quot;error&quot;, F.abs(F.col(&quot;prediction&quot;) - F.col(&quot;rating&quot;)))
    .describe()
)</code></pre>
<h4 id="output-29">output</h4>
<pre><code class="language-text">📊 RMSE: 0.2747
📊 MAE: 0.2154</code></pre>
<table>
<thead>
<tr>
<th>summary</th>
<th>rating</th>
<th>prediction</th>
<th>error</th>
</tr>
</thead>
<tbody><tr>
<td>count</td>
<td>1822</td>
<td>1822</td>
<td>1822</td>
</tr>
<tr>
<td>mean</td>
<td>4.110043907793633</td>
<td>4.01694546939251</td>
<td>0.2153937526120836</td>
</tr>
<tr>
<td>stddev</td>
<td>0.7388884733133042</td>
<td>0.5972829604503972</td>
<td>0.17055639174896156</td>
</tr>
<tr>
<td>min</td>
<td>1.5</td>
<td>1.8996191</td>
<td>6.318092346191406E-5</td>
</tr>
<tr>
<td>max</td>
<td>5.0</td>
<td>5.1576366</td>
<td>1.3421881198883057</td>
</tr>
</tbody></table>
<h3 id="52-사용자-활동량별-추천-품질">5.2 사용자 활동량별 추천 품질</h3>
<p>평점을 많이 남긴 사용자일수록 모델이 더 정확하게 예측하는지 확인합니다.
일반적으로 데이터가 많은 사용자(Heavy)의 RMSE가 낮고,
데이터가 적은 사용자(Light)의 RMSE가 높습니다.</p>
<pre><code class="language-python"># 사용자 그룹별 RMSE 분석
user_stats = spark.table(&quot;silver_user_stats&quot;)

user_rmse = (
    test_predictions
    .join(user_stats.select(&quot;user_id&quot;, &quot;total_ratings&quot;), &quot;user_id&quot;)
    .withColumn(&quot;user_activity_group&quot;,
        F.when(F.col(&quot;total_ratings&quot;) &lt; 30, &quot;Light&quot;)     # 30개 미만
        .when(F.col(&quot;total_ratings&quot;) &lt; 100, &quot;Medium&quot;)    # 30~100개
        .otherwise(&quot;Heavy&quot;)                                # 100개 이상
    )
    .groupBy(&quot;user_activity_group&quot;)
    .agg(
        F.sqrt(F.avg(F.pow(F.col(&quot;prediction&quot;) - F.col(&quot;rating&quot;), 2))).alias(&quot;rmse&quot;),
        F.count(&quot;*&quot;).alias(&quot;n_predictions&quot;)
    )
)

print(&quot;📊 사용자 활동량별 RMSE:&quot;)
display(user_rmse)</code></pre>
<h4 id="output-30">output</h4>
<pre><code class="language-text">📊 사용자 활동량별 RMSE:</code></pre>
<table>
<thead>
<tr>
<th>user_activity_group</th>
<th>rmse</th>
<th>n_predictions</th>
</tr>
</thead>
<tbody><tr>
<td>Medium</td>
<td>0.3317599043437797</td>
<td>577</td>
</tr>
<tr>
<td>Light</td>
<td>0.24379096999878566</td>
<td>1245</td>
</tr>
</tbody></table>
<h2 id="6-추천-결과-생성">6. 추천 결과 생성</h2>
<h3 id="61-모든-사용자에-대한-top-10-추천">6.1 모든 사용자에 대한 Top-10 추천</h3>
<p><code>recommendForAllUsers(N)</code> 메서드는 모든 사용자에 대해 예측 평점이 가장 높은 N개 영화를 추천합니다.
결과를 Explode하여 사용자-영화 단위의 테이블로 저장합니다.</p>
<pre><code class="language-python"># 모든 사용자에 대해 Top 10 영화 추천
user_recommendations = final_model.recommendForAllUsers(10)

# 중첩 배열을 Explode하여 플랫 테이블로 변환
# posexplode: 위치(rank)와 값(rec)을 동시에 추출
recommendations_exploded = (
    user_recommendations
    .select(
        &quot;user_id&quot;,
        F.posexplode(&quot;recommendations&quot;).alias(&quot;rank&quot;, &quot;rec&quot;)
    )
    .select(
        &quot;user_id&quot;,
        (F.col(&quot;rank&quot;) + 1).alias(&quot;rank&quot;),  # 0-based → 1-based
        F.col(&quot;rec.movie_id&quot;).alias(&quot;movie_id&quot;),
        F.col(&quot;rec.rating&quot;).alias(&quot;predicted_rating&quot;)
    )
    # 영화 정보 조인
    .join(
        spark.table(&quot;silver_movies&quot;).select(&quot;movie_id&quot;, &quot;title&quot;, &quot;genres&quot;),
        &quot;movie_id&quot;
    )
)

# Gold 테이블로 저장
recommendations_exploded.write.format(&quot;delta&quot;).mode(&quot;overwrite&quot;).saveAsTable(&quot;gold_user_recommendations&quot;)
print(f&quot;✅ gold_user_recommendations 저장 완료: {recommendations_exploded.count():,} rows&quot;)</code></pre>
<h4 id="output-31">output</h4>
<pre><code class="language-text">✅ gold_user_recommendations 저장 완료: 10,000 rows</code></pre>
<pre><code class="language-python"># 샘플 사용자의 추천 결과 확인
sample_user_id = 100

print(f&quot;🎬 사용자 {sample_user_id}의 Top 10 추천:&quot;)
display(
    recommendations_exploded
    .filter(F.col(&quot;user_id&quot;) == sample_user_id)
    .orderBy(&quot;rank&quot;)
)</code></pre>
<h4 id="output-32">output</h4>
<pre><code class="language-text">🎬 사용자 100의 Top 10 추천:</code></pre>
<table>
<thead>
<tr>
<th>movie_id</th>
<th>user_id</th>
<th>rank</th>
<th>predicted_rating</th>
<th>title</th>
<th>genres</th>
</tr>
</thead>
<tbody><tr>
<td>199</td>
<td>100</td>
<td>1</td>
<td>5.1367373</td>
<td>Storm: Return of Warrior (1984)</td>
<td>War|Comedy|Romance|Adventure</td>
</tr>
<tr>
<td>190</td>
<td>100</td>
<td>2</td>
<td>5.0933404</td>
<td>Destiny of Journey (2013)</td>
<td>Comedy|Animation|Film-Noir|Fantasy</td>
</tr>
<tr>
<td>222</td>
<td>100</td>
<td>3</td>
<td>5.051916</td>
<td>Thunder of Destiny (2010)</td>
<td>Horror|Fantasy|Drama|Adventure</td>
</tr>
<tr>
<td>7</td>
<td>100</td>
<td>4</td>
<td>5.051798</td>
<td>Dark Journey (2004)</td>
<td>Documentary|Animation|Adventure</td>
</tr>
<tr>
<td>413</td>
<td>100</td>
<td>5</td>
<td>5.0260873</td>
<td>Dawn Storm (1999)</td>
<td>Mystery|Comedy|Romance</td>
</tr>
<tr>
<td>160</td>
<td>100</td>
<td>6</td>
<td>5.0128026</td>
<td>Destiny: Return of World (2002)</td>
<td>Animation|Western|War|Adventure</td>
</tr>
<tr>
<td>120</td>
<td>100</td>
<td>7</td>
<td>5.0127053</td>
<td>Destiny Dream (2012)</td>
<td>Sci-Fi|Horror|Documentary|Musical</td>
</tr>
<tr>
<td>490</td>
<td>100</td>
<td>8</td>
<td>4.9882693</td>
<td>Shadow of Heart (1995)</td>
<td>Sci-Fi|Fantasy|Mystery|Musical</td>
</tr>
<tr>
<td>498</td>
<td>100</td>
<td>9</td>
<td>4.98409</td>
<td>Heart Kingdom (2012)</td>
<td>Sci-Fi</td>
</tr>
<tr>
<td>81</td>
<td>100</td>
<td>10</td>
<td>4.976892</td>
<td>Lost Knight (2003)</td>
<td>Mystery|Western|Documentary</td>
</tr>
</tbody></table>
<h3 id="62-영화별-추천-대상-사용자-item-based">6.2 영화별 추천 대상 사용자 (Item-based)</h3>
<p><code>recommendForAllItems(N)</code>는 각 영화에 대해 가장 좋아할 것으로 예측되는 N명의 사용자를 찾습니다.
이는 마케팅 타겟팅이나 푸시 알림 대상 선정에 활용할 수 있습니다.</p>
<pre><code class="language-python"># 모든 영화에 대해 추천할 사용자
movie_recommendations = final_model.recommendForAllItems(10)

# Explode하여 저장
movie_recs_exploded = (
    movie_recommendations
    .select(
        &quot;movie_id&quot;,
        F.posexplode(&quot;recommendations&quot;).alias(&quot;rank&quot;, &quot;rec&quot;)
    )
    .select(
        &quot;movie_id&quot;,
        (F.col(&quot;rank&quot;) + 1).alias(&quot;rank&quot;),
        F.col(&quot;rec.user_id&quot;).alias(&quot;recommended_user_id&quot;),
        F.col(&quot;rec.rating&quot;).alias(&quot;predicted_rating&quot;)
    )
)

movie_recs_exploded.write.format(&quot;delta&quot;).mode(&quot;overwrite&quot;).saveAsTable(&quot;gold_movie_user_recommendations&quot;)
print(f&quot;✅ gold_movie_user_recommendations 저장 완료&quot;)</code></pre>
<h4 id="output-33">output</h4>
<pre><code class="language-text">✅ gold_movie_user_recommendations 저장 완료</code></pre>
<h2 id="7-결과-요약">7. 결과 요약</h2>
<pre><code class="language-python">print(&quot;=&quot; * 70)
print(&quot;📊 영화 추천 시스템 훈련 결과&quot;)
print(&quot;=&quot; * 70)

print(f&quot;\n🎯 모델 성능:&quot;)
print(f&quot;   RMSE: {rmse:.4f}&quot;)
print(f&quot;   MAE: {mae:.4f}&quot;)

print(f&quot;\n⚙️ Best 하이퍼파라미터:&quot;)
for k, v in best_params.items():
    print(f&quot;   {k}: {v}&quot;)

print(f&quot;\n📦 생성된 Gold 테이블:&quot;)
gold_tables = [
    &quot;gold_genre_popularity&quot;,
    &quot;gold_yearly_trends&quot;,
    &quot;gold_top_movies&quot;,
    &quot;gold_genre_top_movies&quot;,
    &quot;gold_recent_top_movies&quot;,
    &quot;gold_sentiment_top_movies&quot;,
    &quot;gold_user_recommendations&quot;,
    &quot;gold_movie_user_recommendations&quot;
]
for table in gold_tables:
    try:
        count = spark.table(table).count()
        print(f&quot;   {table}: {count:,} rows&quot;)
    except Exception:
        print(f&quot;   {table}: (테이블 없음)&quot;)

print(f&quot;\n🔗 MLflow Run ID: {run_id}&quot;)
print(f&quot;🔗 MLflow 실험: {EXPERIMENT_NAME}&quot;)
print(&quot;=&quot; * 70)</code></pre>
<h4 id="output-34">output</h4>
<pre><code class="language-text">======================================================================
📊 영화 추천 시스템 훈련 결과
======================================================================

🎯 모델 성능:
   RMSE: 0.2747
   MAE: 0.2154

⚙️ Best 하이퍼파라미터:
   rank: 50
   regParam: 0.1
   maxIter: 10

📦 생성된 Gold 테이블:
   gold_genre_popularity: 18 rows
   gold_yearly_trends: 50 rows
   gold_top_movies: 100 rows
   gold_genre_top_movies: 180 rows
   gold_recent_top_movies: 0 rows
   gold_sentiment_top_movies: 10 rows
   gold_user_recommendations: 10,000 rows
   gold_movie_user_recommendations: 5,000 rows

🔗 MLflow Run ID: e52eef676a2948baa43f8caa1ce344e2
🔗 MLflow 실험: /Users/3dt016@msacademy.msai.kr/movie-recommender-als
======================================================================</code></pre>
<h3 id="생성된-테이블-및-모델">생성된 테이블 및 모델</h3>
<table>
<thead>
<tr>
<th>항목</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>gold_genre_popularity</code></td>
<td>장르별 인기도 분석</td>
</tr>
<tr>
<td><code>gold_yearly_trends</code></td>
<td>연도별 트렌드</td>
</tr>
<tr>
<td><code>gold_top_movies</code></td>
<td>Top 100 영화 리더보드</td>
</tr>
<tr>
<td><code>gold_genre_top_movies</code></td>
<td>장르별 Top 10 영화</td>
</tr>
<tr>
<td><code>gold_user_recommendations</code></td>
<td>사용자별 Top 10 추천</td>
</tr>
<tr>
<td><code>gold_movie_user_recommendations</code></td>
<td>영화별 추천 대상 사용자</td>
</tr>
<tr>
<td><code>movie_recommender_als</code></td>
<td>MLflow 등록 모델</td>
</tr>
</tbody></table>
<h1 id="추천-서비스--고급-분석">추천 서비스 &amp; 고급 분석</h1>
<p>훈련된 ALS 모델을 활용하여 실제 추천 시나리오를 구현합니다.</p>
<h3 id="주요-내용-1">주요 내용</h3>
<ol>
<li><strong>추천 서비스 클래스</strong> — 모델을 래핑한 재사용 가능한 서비스 구현</li>
<li><strong>Cold Start 처리</strong> — 신규 사용자를 위한 인기도 기반 추천</li>
<li><strong>장르 기반 필터링</strong> — 특정 장르 내 개인화 추천</li>
<li><strong>유사 영화 추천</strong> — Item-Item 유사도 기반 추천</li>
<li><strong>추천 다양성 분석</strong> — 커버리지, 장르 다양성, 인기도 편향</li>
<li><strong>A/B 테스트 시뮬레이션</strong> — 두 가지 전략 비교</li>
</ol>
<h3 id="추천-시스템의-핵심-과제">추천 시스템의 핵심 과제</h3>
<table>
<thead>
<tr>
<th>과제</th>
<th>설명</th>
<th>해결 방법</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Cold Start</strong></td>
<td>신규 사용자에 대한 데이터 부족</td>
<td>인기도 기반 폴백(fallback)</td>
</tr>
<tr>
<td><strong>인기도 편향</strong></td>
<td>인기 영화만 추천되는 문제</td>
<td>하이브리드 전략, 다양성 측정</td>
</tr>
<tr>
<td><strong>커버리지</strong></td>
<td>추천되지 않는 영화가 많은 문제</td>
<td>커버리지 분석 및 모니터링</td>
</tr>
</tbody></table>
<h2 id="1-환경-설정-및-모델-로드">1. 환경 설정 및 모델 로드</h2>
<p>이전 노트북에서 MLflow Model Registry에 등록한 ALS 모델을 로드합니다.</p>
<pre><code class="language-python"># mlflow 설치 확인 (이미 설치되어 있으면 건너뜀)
%pip install mlflow --quiet</code></pre>
<h4 id="output-35">output</h4>
<pre><code class="language-text">[31mERROR: pip&#39;s dependency resolver does not currently take into account all the packages that are installed. This behaviour is the source of the following dependency conflicts.
petastorm 0.12.1 requires pyspark&gt;=2.1.0, which is not installed.
databricks-feature-engineering 0.8.0 requires mlflow-skinny[databricks]&lt;3,&gt;=2.11.0, but you have mlflow-skinny 3.10.1 which is incompatible.
databricks-feature-engineering 0.8.0 requires protobuf&lt;5,&gt;=3.12.0, but you have protobuf 6.33.6 which is incompatible.
google-api-core 2.18.0 requires protobuf!=3.20.0,!=3.20.1,!=4.21.0,!=4.21.1,!=4.21.2,!=4.21.3,!=4.21.4,!=4.21.5,&lt;5.0.0.dev0,&gt;=3.19.5, but you have protobuf 6.33.6 which is incompatible.
googleapis-common-protos 1.63.0 requires protobuf!=3.20.0,!=3.20.1,!=4.21.1,!=4.21.2,!=4.21.3,!=4.21.4,!=4.21.5,&lt;5.0.0.dev0,&gt;=3.19.5, but you have protobuf 6.33.6 which is incompatible.
jupyter-server 1.23.4 requires anyio&lt;4,&gt;=3.1.0, but you have anyio 4.13.0 which is incompatible.
msal 1.29.0 requires cryptography&lt;45,&gt;=2.5, but you have cryptography 46.0.6 which is incompatible.
numba 0.57.1 requires numpy&lt;1.25,&gt;=1.21, but you have numpy 1.26.4 which is incompatible.
oci 2.126.4 requires cryptography&lt;43.0.0,&gt;=3.2.1, but you have cryptography 46.0.6 which is incompatible.
proto-plus 1.24.0 requires protobuf&lt;6.0.0dev,&gt;=3.19.0, but you have protobuf 6.33.6 which is incompatible.
pyopenssl 23.2.0 requires cryptography!=40.0.0,!=40.0.1,&lt;42,&gt;=38.0.0, but you have cryptography 46.0.6 which is incompatible.
tensorboard-plugin-profile 2.15.1 requires protobuf&lt;5.0.0dev,&gt;=3.19.6, but you have protobuf 6.33.6 which is incompatible.
tensorflow 2.16.1 requires protobuf!=4.21.0,!=4.21.1,!=4.21.2,!=4.21.3,!=4.21.4,!=4.21.5,&lt;5.0.0dev,&gt;=3.20.3, but you have protobuf 6.33.6 which is incompatible.
ydata-profiling 4.5.1 requires numpy&lt;1.24,&gt;=1.16.0, but you have numpy 1.26.4 which is incompatible.
ydata-profiling 4.5.1 requires pydantic&lt;2,&gt;=1.8.1, but you have pydantic 2.12.5 which is incompatible.[0m[31m
[0m[43mNote: you may need to restart the kernel using %restart_python or dbutils.library.restartPython() to use updated packages.[0m</code></pre>
<pre><code class="language-python">%restart_python</code></pre>
<pre><code class="language-python">from pyspark.sql import functions as F
from pyspark.sql.window import Window
from pyspark.ml.recommendation import ALSModel
import mlflow
from mlflow.tracking import MlflowClient
import mlflow.spark
from typing import List, Dict, Optional

# ============================================================
# ⚠️ 카탈로그 이름을 본인의 카탈로그로 변경하세요!
# ============================================================
CATALOG = &quot;3dt016_databricks&quot;
SCHEMA = &quot;movie_recommender&quot;
spark.sql(f&quot;USE {CATALOG}.{SCHEMA}&quot;)

# MLflow 모델 이름 (Unity Catalog 3-level namespace)
MODEL_NAME = f&quot;{CATALOG}.{SCHEMA}.movie_recommender_als&quot;

client = MlflowClient()

print(f&quot;🔎 모델 검색 중: {MODEL_NAME} ...&quot;)</code></pre>
<h4 id="output-36">output</h4>
<pre><code class="language-text">🔎 모델 검색 중: 3dt016_databricks.movie_recommender.movie_recommender_als ...</code></pre>
<h3 id="모델-로드-프로세스">모델 로드 프로세스</h3>
<p>Unity Catalog Model Registry에서 최신 버전의 모델을 검색하여 로드합니다.
모델이 없는 경우 자동으로 최근 학습 기록(MLflow Run)에서 모델을 찾아 등록을 시도합니다.</p>
<pre><code class="language-python">try:
    # Unity Catalog에서 모델의 모든 버전을 검색
    results = client.search_model_versions(f&quot;name=&#39;{MODEL_NAME}&#39;&quot;)

    if not results:
        raise Exception(&quot;모델은 등록되었으나 사용 가능한 버전이 없습니다.&quot;)

    # 버전 번호 기준 내림차순 정렬 → 최신 버전 선택
    sorted_versions = sorted(results, key=lambda x: int(x.version), reverse=True)
    latest_version = sorted_versions[0].version
    print(f&quot;📌 최신 버전: Version {latest_version}&quot;)

    # 모델 로드
    model_uri = f&quot;models:/{MODEL_NAME}/{latest_version}&quot;
    print(f&quot;📥 로드 주소: {model_uri}&quot;)
    als_model = mlflow.spark.load_model(model_uri)
    print(f&quot;✅ 모델 로드 성공!&quot;)

except Exception as e:
    print(f&quot;⚠️ 모델을 찾을 수 없습니다: {e}&quot;)
    print(&quot;\n--- [해결책] 최근 학습 기록에서 모델을 찾아 등록합니다 ---&quot;)

    try:
        # 가장 최근의 &#39;Final_ALS_Model&#39; 실행 기록 검색
        last_run = mlflow.search_runs(
            experiment_ids=None,  # 현재 활성화된 실험 사용
            filter_string=&quot;tags.mlflow.runName = &#39;Final_ALS_Model&#39;&quot;,
            order_by=[&quot;start_time DESC&quot;],
            max_results=1
        )

        if not last_run.empty:
            found_run_id = last_run.iloc[0].run_id
            print(f&quot;✅ 학습 기록 발견 (Run ID: {found_run_id})&quot;)

            # 모델 레지스트리에 등록
            result = mlflow.register_model(
                model_uri=f&quot;runs:/{found_run_id}/als_model&quot;,
                name=MODEL_NAME
            )
            print(f&quot;🎉 모델 등록 성공! (버전: {result.version})&quot;)
            print(&quot;⏳ 잠시 후 모델 로드를 다시 시도하세요.&quot;)

            # 등록 직후 로드 시도
            model_uri = f&quot;models:/{MODEL_NAME}/{result.version}&quot;
            als_model = mlflow.spark.load_model(model_uri)
            print(f&quot;✅ 모델 로드 성공!&quot;)

        else:
            print(&quot;❌ &#39;Final_ALS_Model&#39;이라는 이름의 학습 기록을 찾을 수 없습니다.&quot;)
            print(&quot;   → 이전 노트북(03_gold_layer_and_als_model)을 먼저 실행해주세요.&quot;)
            als_model = None

    except Exception as register_error:
        print(f&quot;❌ 등록 중 에러 발생: {register_error}&quot;)
        als_model = None</code></pre>
<h4 id="output-37">output</h4>
<pre><code class="language-text">📌 최신 버전: Version 1
📥 로드 주소: models:/3dt016_databricks.movie_recommender.movie_recommender_als/1</code></pre>
<pre><code class="language-text">Downloading artifacts:   0%|          | 0/43 [00:00&lt;?, ?it/s]</code></pre>
<pre><code class="language-text">✅ 모델 로드 성공!</code></pre>
<h2 id="2-추천-서비스-클래스">2. 추천 서비스 클래스</h2>
<p>ALS 모델을 래핑하여 다양한 추천 시나리오를 지원하는 서비스 클래스입니다.</p>
<h3 id="제공하는-추천-방식">제공하는 추천 방식</h3>
<table>
<thead>
<tr>
<th>메서드</th>
<th>설명</th>
<th>Cold Start 대응</th>
</tr>
</thead>
<tbody><tr>
<td><code>recommend_for_user()</code></td>
<td>사용자 맞춤 추천</td>
<td>인기 영화로 폴백</td>
</tr>
<tr>
<td><code>recommend_by_genre()</code></td>
<td>장르 기반 추천</td>
<td>장르 내 인기 영화로 보충</td>
</tr>
<tr>
<td><code>find_similar_movies()</code></td>
<td>유사 영화 추천</td>
<td>-</td>
</tr>
<tr>
<td><code>get_user_history()</code></td>
<td>시청 기록 조회</td>
<td>-</td>
</tr>
</tbody></table>
<pre><code class="language-python">class MovieRecommendationService:
    &quot;&quot;&quot;
    영화 추천 서비스 클래스

    ALS 모델과 Silver/Gold 테이블을 활용하여 다양한 추천 기능을 제공합니다.
    Cold Start 문제를 인기도 기반 추천으로 해결합니다.
    &quot;&quot;&quot;

    def __init__(self, als_model, spark_session):
        &quot;&quot;&quot;
        추천 서비스를 초기화합니다.

        Args:
            als_model: 훈련된 ALS 모델
            spark_session: Spark 세션
        &quot;&quot;&quot;
        self.model = als_model
        self.spark = spark_session

        # 자주 사용되는 데이터를 캐시하여 성능 향상
        self.movies_df = spark.table(&quot;silver_movies&quot;).cache()
        self.ratings_df = spark.table(&quot;silver_ratings&quot;).cache()
        self.movie_stats_df = spark.table(&quot;silver_movie_stats&quot;).cache()
        self.user_stats_df = spark.table(&quot;silver_user_stats&quot;).cache()

        # Cold Start용 인기 영화 목록 사전 계산
        self.popular_movies = self._get_popular_movies()

        print(&quot;✅ 추천 서비스 초기화 완료&quot;)

    def _get_popular_movies(self, min_ratings: int = 5):
        &quot;&quot;&quot;
        인기 영화 목록을 사전 계산합니다 (Cold Start 대비).

        베이지안 평균 기준 상위 100개 영화를 선택합니다.
        min_ratings 이상의 평점을 받은 영화만 대상으로 합니다.
        &quot;&quot;&quot;
        return (
            self.movie_stats_df
            .filter(F.col(&quot;total_ratings&quot;) &gt;= min_ratings)
            .orderBy(F.desc(&quot;bayesian_avg&quot;))
            .limit(100)
            .select(&quot;movie_id&quot;)
            .collect()
        )

    def get_user_history(self, user_id: int) -&gt; Dict:
        &quot;&quot;&quot;
        사용자의 시청(평점) 기록을 조회합니다.

        Args:
            user_id: 사용자 ID

        Returns:
            Dict: 사용자 정보와 최근 시청 기록 (최대 20건, 높은 평점 순)
        &quot;&quot;&quot;
        user_ratings = (
            self.ratings_df
            .filter(F.col(&quot;user_id&quot;) == user_id)
            .join(self.movies_df.select(&quot;movie_id&quot;, &quot;title&quot;, &quot;genres&quot;), &quot;movie_id&quot;)
            .orderBy(F.desc(&quot;rating&quot;))
        )

        count = user_ratings.count()

        if count == 0:
            return {&quot;user_id&quot;: user_id, &quot;total_ratings&quot;: 0, &quot;history&quot;: []}

        history = user_ratings.limit(20).collect()

        return {
            &quot;user_id&quot;: user_id,
            &quot;total_ratings&quot;: count,
            &quot;history&quot;: [
                {
                    &quot;movie_id&quot;: r.movie_id,
                    &quot;title&quot;: r.title,
                    &quot;rating&quot;: r.rating,
                    &quot;genres&quot;: r.genres
                }
                for r in history
            ]
        }

    def recommend_for_user(
        self,
        user_id: int,
        n_recommendations: int = 10,
        exclude_watched: bool = True
    ) -&gt; List[Dict]:
        &quot;&quot;&quot;
        특정 사용자에게 영화를 추천합니다.

        기존 사용자: ALS 모델 기반 개인화 추천
        신규 사용자: 인기 영화 기반 추천 (Cold Start 처리)

        Args:
            user_id: 사용자 ID
            n_recommendations: 추천 개수
            exclude_watched: 이미 본 영화 제외 여부

        Returns:
            List[Dict]: 추천 영화 리스트
        &quot;&quot;&quot;
        # 사용자 존재 여부 확인
        user_exists = self.user_stats_df.filter(F.col(&quot;user_id&quot;) == user_id).count() &gt; 0

        if not user_exists:
            # Cold Start: 인기 영화 추천으로 폴백
            return self._cold_start_recommendations(n_recommendations)

        # ALS 기반 추천
        user_df = self.spark.createDataFrame([(user_id,)], [&quot;user_id&quot;])

        # 이미 본 영화 제외
        watched_movies = set()
        if exclude_watched:
            watched = self.ratings_df.filter(F.col(&quot;user_id&quot;) == user_id).select(&quot;movie_id&quot;).collect()
            watched_movies = {r.movie_id for r in watched}

        # 모든 영화에 대한 예측 (Cross Join)
        all_movies = self.movies_df.select(&quot;movie_id&quot;).distinct()
        user_movie_pairs = user_df.crossJoin(all_movies)

        predictions = self.model.transform(user_movie_pairs)

        # 필터링 및 정렬
        recommendations = (
            predictions
            .filter(~F.col(&quot;movie_id&quot;).isin(list(watched_movies)))
            .filter(F.col(&quot;prediction&quot;).isNotNull())
            .orderBy(F.desc(&quot;prediction&quot;))
            .limit(n_recommendations)
            .join(self.movies_df.select(&quot;movie_id&quot;, &quot;title&quot;, &quot;genres&quot;, &quot;year&quot;), &quot;movie_id&quot;)
            .collect()
        )

        return [
            {
                &quot;movie_id&quot;: r.movie_id,
                &quot;title&quot;: r.title,
                &quot;genres&quot;: r.genres,
                &quot;year&quot;: r.year,
                &quot;predicted_rating&quot;: round(r.prediction, 2),
                &quot;reason&quot;: &quot;ALS Collaborative Filtering&quot;
            }
            for r in recommendations
        ]

    def _cold_start_recommendations(self, n: int) -&gt; List[Dict]:
        &quot;&quot;&quot;
        Cold Start 사용자를 위한 인기 기반 추천.

        데이터가 없는 신규 사용자에게는 전체 인기도(베이지안 평균) 기준으로 추천합니다.
        &quot;&quot;&quot;
        popular = (
            self.movie_stats_df
            .filter(F.col(&quot;total_ratings&quot;) &gt;= 5)
            .orderBy(F.desc(&quot;bayesian_avg&quot;))
            .limit(n)
            .join(self.movies_df.select(&quot;movie_id&quot;, &quot;title&quot;, &quot;genres&quot;, &quot;year&quot;), &quot;movie_id&quot;)
            .collect()
        )

        return [
            {
                &quot;movie_id&quot;: r.movie_id,
                &quot;title&quot;: r.title,
                &quot;genres&quot;: r.genres,
                &quot;year&quot;: r.year,
                &quot;predicted_rating&quot;: round(r.bayesian_avg, 2),
                &quot;reason&quot;: &quot;Popular (Cold Start)&quot;
            }
            for r in popular
        ]

    def recommend_by_genre(
        self,
        user_id: int,
        genre: str,
        n_recommendations: int = 10
    ) -&gt; List[Dict]:
        &quot;&quot;&quot;
        특정 장르 내에서 사용자에게 추천합니다.

        ALS 추천 결과에서 장르를 필터링하고, 부족하면 해당 장르의 인기 영화로 보충합니다.

        Args:
            user_id: 사용자 ID
            genre: 필터링할 장르 (예: &quot;Action&quot;, &quot;Sci-Fi&quot;)
            n_recommendations: 추천 개수
        &quot;&quot;&quot;
        # 넉넉하게 3배수 추천 후 장르 필터링
        base_recs = self.recommend_for_user(user_id, n_recommendations * 3, exclude_watched=True)

        genre_recs = [
            r for r in base_recs
            if genre.lower() in r[&quot;genres&quot;].lower()
        ][:n_recommendations]

        # 장르 필터 결과가 부족하면 해당 장르 인기 영화로 보충
        if len(genre_recs) &lt; n_recommendations:
            popular_in_genre = (
                self.movie_stats_df
                .join(self.movies_df.select(&quot;movie_id&quot;, &quot;title&quot;, &quot;genres&quot;, &quot;year&quot;), &quot;movie_id&quot;)
                .filter(F.col(&quot;genres&quot;).contains(genre))
                .orderBy(F.desc(&quot;bayesian_avg&quot;))
                .limit(n_recommendations - len(genre_recs))
                .collect()
            )

            for r in popular_in_genre:
                genre_recs.append({
                    &quot;movie_id&quot;: r.movie_id,
                    &quot;title&quot;: r.title,
                    &quot;genres&quot;: r.genres,
                    &quot;year&quot;: r.year,
                    &quot;predicted_rating&quot;: round(r.bayesian_avg, 2),
                    &quot;reason&quot;: f&quot;Popular in {genre}&quot;
                })

        return genre_recs

    def find_similar_movies(self, movie_id: int, n: int = 10) -&gt; List[Dict]:
        &quot;&quot;&quot;
        특정 영화와 유사한 영화를 찾습니다 (Item-Item Similarity).

        해당 영화를 4.0점 이상으로 평가한 사용자들이
        마찬가지로 높게 평가한 다른 영화를 찾는 방식입니다.

        Args:
            movie_id: 기준 영화 ID
            n: 반환할 유사 영화 수

        Returns:
            List[Dict]: 유사 영화 리스트 (유사도 점수 포함)
        &quot;&quot;&quot;
        # 해당 영화를 좋아한 사용자들 (4.0점 이상)
        movie_fans = (
            self.ratings_df
            .filter((F.col(&quot;movie_id&quot;) == movie_id) &amp; (F.col(&quot;rating&quot;) &gt;= 4.0))
            .select(&quot;user_id&quot;)
            .distinct()
        )

        if movie_fans.count() == 0:
            return []

        # 그 사용자들이 높게 평가한 다른 영화를 집계
        similar = (
            self.ratings_df
            .join(movie_fans, &quot;user_id&quot;)
            .filter((F.col(&quot;movie_id&quot;) != movie_id) &amp; (F.col(&quot;rating&quot;) &gt;= 4.0))
            .groupBy(&quot;movie_id&quot;)
            .agg(
                F.count(&quot;*&quot;).alias(&quot;co_ratings&quot;),       # 공동으로 높게 평가한 횟수
                F.avg(&quot;rating&quot;).alias(&quot;avg_rating&quot;)     # 평균 평점
            )
            # 유사도 점수 = 공동 평가 횟수 × 평균 평점
            .withColumn(&quot;similarity_score&quot;,
                F.col(&quot;co_ratings&quot;) * F.col(&quot;avg_rating&quot;)
            )
            .orderBy(F.desc(&quot;similarity_score&quot;))
            .limit(n)
            .join(self.movies_df.select(&quot;movie_id&quot;, &quot;title&quot;, &quot;genres&quot;, &quot;year&quot;), &quot;movie_id&quot;)
            .collect()
        )

        return [
            {
                &quot;movie_id&quot;: r.movie_id,
                &quot;title&quot;: r.title,
                &quot;genres&quot;: r.genres,
                &quot;year&quot;: r.year,
                &quot;similarity_score&quot;: round(r.similarity_score, 2),
                &quot;co_ratings&quot;: r.co_ratings
            }
            for r in similar
        ]</code></pre>
<h3 id="서비스-인스턴스-생성">서비스 인스턴스 생성</h3>
<p>모델이 정상 로드된 경우에만 서비스를 초기화합니다.</p>
<pre><code class="language-python"># 서비스 인스턴스 생성
if als_model is not None:
    rec_service = MovieRecommendationService(als_model, spark)
else:
    print(&quot;⚠️ 모델이 로드되지 않아 추천 서비스를 초기화할 수 없습니다.&quot;)
    print(&quot;   → 이전 노트북(03)을 먼저 실행하거나, 위 셀의 모델 로드를 확인하세요.&quot;)</code></pre>
<h4 id="output-38">output</h4>
<pre><code class="language-text">✅ 추천 서비스 초기화 완료</code></pre>
<h2 id="3-추천-시나리오-시연">3. 추천 시나리오 시연</h2>
<p>다양한 상황에서의 추천 결과를 확인합니다.</p>
<h3 id="31-기존-사용자-추천">3.1 기존 사용자 추천</h3>
<p>평점 기록이 있는 사용자에게 ALS 기반 개인화 추천을 수행합니다.</p>
<pre><code class="language-python"># 샘플 사용자 선택
sample_user_id = 50

# 사용자 시청 기록 조회
print(f&quot;👤 사용자 {sample_user_id} 시청 기록:&quot;)
history = rec_service.get_user_history(sample_user_id)
print(f&quot;   총 평가 영화 수: {history[&#39;total_ratings&#39;]}&quot;)

if history[&#39;history&#39;]:
    print(&quot;\n   최근 높게 평가한 영화:&quot;)
    for movie in history[&#39;history&#39;][:5]:
        print(f&quot;   ⭐ {movie[&#39;rating&#39;]} - {movie[&#39;title&#39;]} ({movie[&#39;genres&#39;]})&quot;)</code></pre>
<h4 id="output-39">output</h4>
<pre><code class="language-text">👤 사용자 50 시청 기록:
   총 평가 영화 수: 1

   최근 높게 평가한 영화:
   ⭐ 4.5 - Kingdom: Dark Universe (1997) (Sci-Fi|Musical)</code></pre>
<pre><code class="language-python"># 추천 생성
print(f&quot;\n🎬 사용자 {sample_user_id}를 위한 추천:&quot;)
recommendations = rec_service.recommend_for_user(sample_user_id, n_recommendations=10)

for i, rec in enumerate(recommendations, 1):
    print(f&quot;   {i}. {rec[&#39;title&#39;]} ({rec[&#39;year&#39;]})&quot;)
    print(f&quot;      장르: {rec[&#39;genres&#39;]}&quot;)
    print(f&quot;      예상 평점: {rec[&#39;predicted_rating&#39;]} | {rec[&#39;reason&#39;]}&quot;)</code></pre>
<h4 id="output-40">output</h4>
<pre><code class="language-text">
🎬 사용자 50를 위한 추천:
   1. Soul of Mountain (2020) (2020)
      장르: Sci-Fi|Western|War
      예상 평점: 5.28 | ALS Collaborative Filtering
   2. Ice Knight (2000) (2000)
      장르: Horror|Mystery|Crime|Romance
      예상 평점: 5.26 | ALS Collaborative Filtering
   3. Return of Kingdom (2015) (2015)
      장르: Western|Animation
      예상 평점: 5.13 | ALS Collaborative Filtering
   4. Destiny of Journey (2013) (2013)
      장르: Comedy|Animation|Film-Noir|Fantasy
      예상 평점: 5.14 | ALS Collaborative Filtering
   5. Shadow: Dark Ocean (1993) (1993)
      장르: Horror
      예상 평점: 5.24 | ALS Collaborative Filtering
   6. Storm: Return of Warrior (1984) (1984)
      장르: War|Comedy|Romance|Adventure
      예상 평점: 5.14 | ALS Collaborative Filtering
   7. Thunder of Destiny (2010) (2010)
      장르: Horror|Fantasy|Drama|Adventure
      예상 평점: 5.17 | ALS Collaborative Filtering
   8. Storm Fire (2004) (2004)
      장르: Mystery|War|Romance|Fantasy
      예상 평점: 5.15 | ALS Collaborative Filtering
   9. Lost Mountain (2017) (2017)
      장르: Romance
      예상 평점: 5.2 | ALS Collaborative Filtering
   10. Shadow of Heart (1995) (1995)
      장르: Sci-Fi|Fantasy|Mystery|Musical
      예상 평점: 5.13 | ALS Collaborative Filtering</code></pre>
<h3 id="32-장르-기반-추천">3.2 장르 기반 추천</h3>
<p>특정 장르 내에서 개인화 추천을 수행합니다.
예: &quot;Sci-Fi를 좋아하는 사용자에게 Sci-Fi 영화만 추천&quot;</p>
<pre><code class="language-python">genre = &quot;Sci-Fi&quot;
print(f&quot;🚀 사용자 {sample_user_id}를 위한 {genre} 추천:&quot;)

genre_recs = rec_service.recommend_by_genre(sample_user_id, genre, n_recommendations=5)

for i, rec in enumerate(genre_recs, 1):
    print(f&quot;   {i}. {rec[&#39;title&#39;]} ({rec[&#39;year&#39;]})&quot;)
    print(f&quot;      예상 평점: {rec[&#39;predicted_rating&#39;]} | {rec[&#39;reason&#39;]}&quot;)</code></pre>
<h4 id="output-41">output</h4>
<pre><code class="language-text">🚀 사용자 50를 위한 Sci-Fi 추천:
   1. Soul of Mountain (2020) (2020)
      예상 평점: 5.28 | ALS Collaborative Filtering
   2. Destiny Dream (2012) (2012)
      예상 평점: 5.07 | ALS Collaborative Filtering
   3. Shadow of Heart (1995) (1995)
      예상 평점: 5.13 | ALS Collaborative Filtering
   4. Destiny Dream (2012) (2012)
      예상 평점: 4.36 | Popular in Sci-Fi
   5. Fire of Soul (1979) (1979)
      예상 평점: 4.25 | Popular in Sci-Fi</code></pre>
<h3 id="33-유사-영화-추천">3.3 유사 영화 추천</h3>
<p>&quot;이 영화를 좋아했다면 이 영화도 좋아할 것&quot; 시나리오입니다.
해당 영화를 4점 이상 준 사용자들의 공통 취향을 기반으로 유사 영화를 찾습니다.</p>
<pre><code class="language-python"># 특정 영화와 유사한 영화 찾기
target_movie_id = 1
target_movie = spark.table(&quot;silver_movies&quot;).filter(F.col(&quot;movie_id&quot;) == target_movie_id).first()

print(f&quot;🎯 &#39;{target_movie.title}&#39;와 유사한 영화:&quot;)

similar_movies = rec_service.find_similar_movies(target_movie_id, n=5)

for i, movie in enumerate(similar_movies, 1):
    print(f&quot;   {i}. {movie[&#39;title&#39;]} ({movie[&#39;year&#39;]})&quot;)
    print(f&quot;      유사도 점수: {movie[&#39;similarity_score&#39;]} (공동 평가: {movie[&#39;co_ratings&#39;]}명)&quot;)</code></pre>
<h4 id="output-42">output</h4>
<pre><code class="language-text">🎯 &#39;The Universe (1998)&#39;와 유사한 영화:
   1. Fall of Mountain (1997) (1997)
      유사도 점수: 9.0 (공동 평가: 2명)
   2. Shadow City (1997) (1997)
      유사도 점수: 9.0 (공동 평가: 2명)
   3. Empire Forest (2001) (2001)
      유사도 점수: 9.0 (공동 평가: 2명)
   4. Dragon Empire (1981) (1981)
      유사도 점수: 13.5 (공동 평가: 3명)
   5. Fire of Destiny (1999) (1999)
      유사도 점수: 15.0 (공동 평가: 3명)</code></pre>
<h3 id="34-cold-start-사용자-처리">3.4 Cold Start 사용자 처리</h3>
<p><strong>Cold Start 문제</strong>: 평점 기록이 없는 신규 사용자에게 무엇을 추천할 것인가?</p>
<p>해결: 전체 인기도(베이지안 평균) 기준으로 추천합니다.
존재하지 않는 user_id를 입력하여 Cold Start 시나리오를 시뮬레이션합니다.</p>
<pre><code class="language-python"># 존재하지 않는 사용자 (신규 가입자 시뮬레이션)
new_user_id = 999999

print(f&quot;❄️ 신규 사용자 {new_user_id} (Cold Start):&quot;)

cold_start_recs = rec_service.recommend_for_user(new_user_id, n_recommendations=5)

for i, rec in enumerate(cold_start_recs, 1):
    print(f&quot;   {i}. {rec[&#39;title&#39;]} ({rec[&#39;year&#39;]})&quot;)
    print(f&quot;      장르: {rec[&#39;genres&#39;]}&quot;)
    print(f&quot;      예상 평점: {rec[&#39;predicted_rating&#39;]} | {rec[&#39;reason&#39;]}&quot;)</code></pre>
<h4 id="output-43">output</h4>
<pre><code class="language-text">❄️ 신규 사용자 999999 (Cold Start):
   1. Kingdom Fire (2001) (2001)
      장르: Action|Crime|Romance
      예상 평점: 4.42 | Popular (Cold Start)
   2. Destiny Dream (2012) (2012)
      장르: Sci-Fi|Horror|Documentary|Musical
      예상 평점: 4.36 | Popular (Cold Start)
   3. Storm: Dark Ocean (2001) (2001)
      장르: Mystery
      예상 평점: 4.41 | Popular (Cold Start)
   4. Fire of Destiny (1999) (1999)
      장르: Documentary|Drama|Comedy|Action
      예상 평점: 4.44 | Popular (Cold Start)
   5. Shadow of Knight (2012) (2012)
      장르: Drama
      예상 평점: 4.45 | Popular (Cold Start)</code></pre>
<h2 id="4-추천-다양성-분석">4. 추천 다양성 분석</h2>
<p>좋은 추천 시스템은 정확성뿐만 아니라 <strong>다양성</strong>도 중요합니다.
인기 영화만 추천하면 정확도는 높지만, 사용자 경험이 단조로워집니다.</p>
<h3 id="41-추천-커버리지">4.1 추천 커버리지</h3>
<p><strong>커버리지</strong>: 전체 영화 중 최소 1번이라도 추천된 영화의 비율</p>
<ul>
<li>100%에 가까울수록 다양한 영화가 추천되고 있음</li>
<li>낮으면 롱테일(Long-tail) 영화가 무시되고 있음</li>
</ul>
<pre><code class="language-python">user_recs = spark.table(&quot;gold_user_recommendations&quot;)

# 전체 영화 대비 추천된 영화 비율
total_movies = spark.table(&quot;silver_movies&quot;).count()
recommended_movies = user_recs.select(&quot;movie_id&quot;).distinct().count()

coverage = recommended_movies / total_movies * 100

print(f&quot;📊 추천 커버리지 분석:&quot;)
print(f&quot;   전체 영화 수: {total_movies:,}&quot;)
print(f&quot;   추천된 고유 영화 수: {recommended_movies:,}&quot;)
print(f&quot;   커버리지: {coverage:.2f}%&quot;)</code></pre>
<h4 id="output-44">output</h4>
<pre><code class="language-text">📊 추천 커버리지 분석:
   전체 영화 수: 500
   추천된 고유 영화 수: 183
   커버리지: 36.60%</code></pre>
<h3 id="42-장르-다양성">4.2 장르 다양성</h3>
<p>추천 결과에 특정 장르가 편중되어 있는지 분석합니다.</p>
<pre><code class="language-python"># 추천 결과의 장르 분포
genre_distribution = (
    user_recs
    .join(spark.table(&quot;silver_movies&quot;).select(&quot;movie_id&quot;, &quot;genres_array&quot;), &quot;movie_id&quot;)
    .withColumn(&quot;genre&quot;, F.explode(&quot;genres_array&quot;))
    .groupBy(&quot;genre&quot;)
    .agg(F.count(&quot;*&quot;).alias(&quot;recommendation_count&quot;))
    .orderBy(F.desc(&quot;recommendation_count&quot;))
)

print(&quot;📊 추천 장르 분포:&quot;)
display(genre_distribution)</code></pre>
<h4 id="output-45">output</h4>
<pre><code class="language-text">📊 추천 장르 분포:</code></pre>
<table>
<thead>
<tr>
<th>genre</th>
<th>recommendation_count</th>
</tr>
</thead>
<tbody><tr>
<td>Mystery</td>
<td>2841</td>
</tr>
<tr>
<td>Documentary</td>
<td>2709</td>
</tr>
<tr>
<td>Adventure</td>
<td>2563</td>
</tr>
<tr>
<td>Sci-Fi</td>
<td>2329</td>
</tr>
<tr>
<td>Fantasy</td>
<td>2312</td>
</tr>
<tr>
<td>Animation</td>
<td>2264</td>
</tr>
<tr>
<td>Horror</td>
<td>2205</td>
</tr>
<tr>
<td>Comedy</td>
<td>2088</td>
</tr>
<tr>
<td>War</td>
<td>2046</td>
</tr>
<tr>
<td>Romance</td>
<td>1922</td>
</tr>
<tr>
<td>Drama</td>
<td>1764</td>
</tr>
<tr>
<td>Musical</td>
<td>1587</td>
</tr>
<tr>
<td>Western</td>
<td>1195</td>
</tr>
<tr>
<td>Thriller</td>
<td>1169</td>
</tr>
<tr>
<td>Film-Noir</td>
<td>763</td>
</tr>
<tr>
<td>Crime</td>
<td>559</td>
</tr>
<tr>
<td>Children</td>
<td>514</td>
</tr>
<tr>
<td>Action</td>
<td>467</td>
</tr>
</tbody></table>
<h3 id="43-인기도-편향-분석">4.3 인기도 편향 분석</h3>
<p>추천되는 영화들이 전체 영화 대비 얼마나 인기 있는 영화에 편중되어 있는지 분석합니다.</p>
<ul>
<li><strong>avg_popularity</strong>: 추천된 영화의 평균 평점 수</li>
<li><strong>median_popularity</strong>: 추천된 영화의 중앙값 평점 수</li>
<li>전체 영화의 통계와 비교하여 편향 정도를 확인합니다.</li>
</ul>
<pre><code class="language-python"># 추천되는 영화들의 인기도 통계
popularity_analysis = (
    user_recs
    .join(spark.table(&quot;silver_movie_stats&quot;).select(&quot;movie_id&quot;, &quot;total_ratings&quot;, &quot;avg_rating&quot;), &quot;movie_id&quot;)
    .agg(
        F.avg(&quot;total_ratings&quot;).alias(&quot;avg_popularity&quot;),
        F.stddev(&quot;total_ratings&quot;).alias(&quot;std_popularity&quot;),
        F.avg(&quot;avg_rating&quot;).alias(&quot;avg_quality&quot;),
        F.percentile_approx(&quot;total_ratings&quot;, 0.5).alias(&quot;median_popularity&quot;)
    )
)

print(&quot;📊 추천된 영화의 인기도 통계:&quot;)
display(popularity_analysis)

# 전체 영화 대비 비교
overall_stats = (
    spark.table(&quot;silver_movie_stats&quot;)
    .agg(
        F.avg(&quot;total_ratings&quot;).alias(&quot;overall_avg_popularity&quot;),
        F.percentile_approx(&quot;total_ratings&quot;, 0.5).alias(&quot;overall_median_popularity&quot;)
    )
)

print(&quot;\n📊 전체 영화 통계 (비교용):&quot;)
display(overall_stats)</code></pre>
<h4 id="output-46">output</h4>
<pre><code class="language-text">📊 추천된 영화의 인기도 통계:</code></pre>
<table>
<thead>
<tr>
<th>avg_popularity</th>
<th>std_popularity</th>
<th>avg_quality</th>
<th>median_popularity</th>
</tr>
</thead>
<tbody><tr>
<td>16.2803</td>
<td>12.678075771603789</td>
<td>4.777427021399444</td>
<td>13</td>
</tr>
</tbody></table>
<pre><code class="language-text">
📊 전체 영화 통계 (비교용):</code></pre>
<table>
<thead>
<tr>
<th>overall_avg_popularity</th>
<th>overall_median_popularity</th>
</tr>
</thead>
<tbody><tr>
<td>19.066</td>
<td>13</td>
</tr>
</tbody></table>
<h2 id="5-ab-테스트-시뮬레이션">5. A/B 테스트 시뮬레이션</h2>
<p>두 가지 추천 전략을 비교하는 A/B 테스트를 시뮬레이션합니다.</p>
<table>
<thead>
<tr>
<th>전략</th>
<th>설명</th>
<th>장점</th>
<th>단점</th>
</tr>
</thead>
<tbody><tr>
<td><strong>A: 순수 CF</strong></td>
<td>ALS 협업 필터링만 사용</td>
<td>높은 개인화</td>
<td>인기 편향 가능</td>
</tr>
<tr>
<td><strong>B: 하이브리드</strong></td>
<td>CF 70% + 인기 영화 30% 블렌딩</td>
<td>다양성 향상</td>
<td>개인화 약간 희석</td>
</tr>
</tbody></table>
<h3 id="51-두-가지-추천-전략-정의">5.1 두 가지 추천 전략 정의</h3>
<pre><code class="language-python">import random

def strategy_a_collaborative(user_id: int, n: int = 5):
    &quot;&quot;&quot;전략 A: 순수 협업 필터링 — ALS 예측 평점만으로 추천&quot;&quot;&quot;
    return rec_service.recommend_for_user(user_id, n, exclude_watched=True)

def strategy_b_hybrid(user_id: int, n: int = 5):
    &quot;&quot;&quot;
    전략 B: 하이브리드 (CF 70% + 인기도 30%)
    개인화 추천에 인기 영화를 일부 섞어 다양성을 높입니다.
    &quot;&quot;&quot;
    cf_recs = rec_service.recommend_for_user(user_id, n * 2, exclude_watched=True)

    # 인기 영화도 일부 포함
    popular = rec_service._cold_start_recommendations(n)

    # 70% CF + 30% Popular 블렌딩
    n_cf = int(n * 0.7)
    n_popular = n - n_cf

    result = cf_recs[:n_cf]

    # 중복 제거하며 인기 영화 추가
    cf_movie_ids = {r[&#39;movie_id&#39;] for r in result}
    for p in popular:
        if p[&#39;movie_id&#39;] not in cf_movie_ids and len(result) &lt; n:
            p[&#39;reason&#39;] = &quot;Hybrid (Popular)&quot;
            result.append(p)

    return result</code></pre>
<h3 id="52-ab-테스트-실행">5.2 A/B 테스트 실행</h3>
<p>여러 사용자에 대해 두 전략의 추천 결과를 비교합니다.
<strong>중복률</strong>: 두 전략의 추천 결과가 얼마나 겹치는지 — 낮을수록 전략 간 차이가 큼</p>
<pre><code class="language-python"># A/B 테스트 시뮬레이션
test_users = [10, 42, 100, 200, 400]

print(&quot;🧪 A/B 테스트 시뮬레이션&quot;)
print(&quot;=&quot; * 70)

for user_id in test_users:
    print(f&quot;\n👤 사용자 {user_id}:&quot;)

    recs_a = strategy_a_collaborative(user_id, 3)
    recs_b = strategy_b_hybrid(user_id, 3)

    # 추천 결과 요약 출력
    print(&quot;  전략 A (CF):&quot;, [r[&#39;title&#39;][:30] + &quot;...&quot; if len(r[&#39;title&#39;]) &gt; 30 else r[&#39;title&#39;] for r in recs_a])
    print(&quot;  전략 B (Hybrid):&quot;, [r[&#39;title&#39;][:30] + &quot;...&quot; if len(r[&#39;title&#39;]) &gt; 30 else r[&#39;title&#39;] for r in recs_b])

    # 중복률 계산
    movies_a = {r[&#39;movie_id&#39;] for r in recs_a}
    movies_b = {r[&#39;movie_id&#39;] for r in recs_b}
    overlap = len(movies_a &amp; movies_b) / len(movies_a) * 100 if movies_a else 0
    print(f&quot;  중복률: {overlap:.1f}%&quot;)</code></pre>
<h4 id="output-47">output</h4>
<pre><code class="language-text">🧪 A/B 테스트 시뮬레이션
======================================================================

👤 사용자 10:
  전략 A (CF): [&#39;Legend of Dawn (2013)&#39;, &#39;Ocean of Dream (1981)&#39;, &#39;Lost Mountain (2017)&#39;]
  전략 B (Hybrid): [&#39;Legend of Dawn (2013)&#39;, &#39;Soul of Mountain (2020)&#39;, &#39;Kingdom Fire (2001)&#39;]
  중복률: 33.3%

👤 사용자 42:
  전략 A (CF): [&#39;Destiny Dream (2012)&#39;, &#39;Thunder of Destiny (2010)&#39;, &#39;Shadow of Heart (1995)&#39;]
  전략 B (Hybrid): [&#39;Destiny Dream (2012)&#39;, &#39;Destiny of Journey (2013)&#39;, &#39;Kingdom Fire (2001)&#39;]
  중복률: 33.3%

👤 사용자 100:
  전략 A (CF): [&#39;Destiny of Journey (2013)&#39;, &#39;Storm: Return of Warrior (1984...&#39;, &#39;Thunder of Destiny (2010)&#39;]
  전략 B (Hybrid): [&#39;Dark Journey (2004)&#39;, &#39;Destiny: Return of World (2002...&#39;, &#39;Kingdom Fire (2001)&#39;]
  중복률: 0.0%

👤 사용자 200:
  전략 A (CF): [&#39;Destiny Dream (2012)&#39;, &#39;Storm: Return of Warrior (1984...&#39;, &#39;Thunder of Destiny (2010)&#39;]
  전략 B (Hybrid): [&#39;Ice Knight (2000)&#39;, &#39;Return of Kingdom (2015)&#39;, &#39;Kingdom Fire (2001)&#39;]
  중복률: 0.0%

👤 사용자 400:
  전략 A (CF): [&#39;Soul of Mountain (2020)&#39;, &#39;Thunder of Destiny (2010)&#39;, &#39;Heart Kingdom (2012)&#39;]
  전략 B (Hybrid): [&#39;Dark Journey (2004)&#39;, &#39;Soul of Mountain (2020)&#39;, &#39;Kingdom Fire (2001)&#39;]
  중복률: 33.3%</code></pre>
<h2 id="6-대화형-추천-함수">6. 대화형 추천 함수</h2>
<p>다양한 추천 시나리오를 하나로 통합한 인터페이스입니다.</p>
<table>
<thead>
<tr>
<th>입력 조합</th>
<th>모드</th>
<th>동작</th>
</tr>
</thead>
<tbody><tr>
<td><code>user_id</code>만</td>
<td>Personalized CF</td>
<td>사용자 맞춤 추천</td>
</tr>
<tr>
<td><code>user_id</code> + <code>genre</code></td>
<td>Genre-filtered CF</td>
<td>특정 장르 기반 개인화 추천</td>
</tr>
<tr>
<td><code>similar_to</code>만</td>
<td>Content-based similarity</td>
<td>특정 영화와 비슷한 영화 추천</td>
</tr>
</tbody></table>
<pre><code class="language-python">def interactive_recommendation(user_id: int = None, genre: str = None, similar_to: int = None):
    &quot;&quot;&quot;
    대화형 추천 인터페이스

    사용 예:
    - interactive_recommendation(user_id=42)              → 개인화 추천
    - interactive_recommendation(user_id=42, genre=&quot;Action&quot;) → 장르 필터링 추천
    - interactive_recommendation(similar_to=1)             → 유사 영화 추천
    &quot;&quot;&quot;

    print(&quot;🎬 영화 추천 서비스&quot;)
    print(&quot;=&quot; * 50)

    if similar_to:
        movie = spark.table(&quot;silver_movies&quot;).filter(F.col(&quot;movie_id&quot;) == similar_to).first()
        print(f&quot;\n📽️ &#39;{movie.title}&#39;와 유사한 영화:&quot;)

        similar = rec_service.find_similar_movies(similar_to, 5)
        for i, m in enumerate(similar, 1):
            print(f&quot;   {i}. {m[&#39;title&#39;]}&quot;)
        return

    if user_id:
        if genre:
            print(f&quot;\n👤 사용자 {user_id}를 위한 {genre} 추천:&quot;)
            recs = rec_service.recommend_by_genre(user_id, genre, 5)
        else:
            print(f&quot;\n👤 사용자 {user_id}를 위한 맞춤 추천:&quot;)
            recs = rec_service.recommend_for_user(user_id, 5)

        for i, r in enumerate(recs, 1):
            print(f&quot;   {i}. {r[&#39;title&#39;]} ⭐{r[&#39;predicted_rating&#39;]}&quot;)
            print(f&quot;      {r[&#39;genres&#39;]} | {r[&#39;reason&#39;]}&quot;)
        return

    print(&quot;❌ user_id 또는 similar_to 파라미터가 필요합니다.&quot;)</code></pre>
<pre><code class="language-python"># 사용 예시 1: 개인화 추천
interactive_recommendation(user_id=42)</code></pre>
<h4 id="output-48">output</h4>
<pre><code class="language-text">🎬 영화 추천 서비스
==================================================

👤 사용자 42를 위한 맞춤 추천:
   1. Destiny Dream (2012) ⭐4.07
      Sci-Fi|Horror|Documentary|Musical | ALS Collaborative Filtering
   2. Destiny of Journey (2013) ⭐4.03
      Comedy|Animation|Film-Noir|Fantasy | ALS Collaborative Filtering
   3. Rise of Soul (2003) ⭐4.03
      Animation|Children|Thriller|Film-Noir | ALS Collaborative Filtering
   4. Thunder of Destiny (2010) ⭐4.12
      Horror|Fantasy|Drama|Adventure | ALS Collaborative Filtering
   5. Shadow of Heart (1995) ⭐4.05
      Sci-Fi|Fantasy|Mystery|Musical | ALS Collaborative Filtering</code></pre>
<pre><code class="language-python"># 사용 예시 2: 장르 필터링
interactive_recommendation(user_id=42, genre=&quot;Comedy&quot;)</code></pre>
<h4 id="output-49">output</h4>
<pre><code class="language-text">🎬 영화 추천 서비스
==================================================

👤 사용자 42를 위한 Comedy 추천:
   1. Destiny of Journey (2013) ⭐4.03
      Comedy|Animation|Film-Noir|Fantasy | ALS Collaborative Filtering
   2. Storm: Return of Warrior (1984) ⭐4.01
      War|Comedy|Romance|Adventure | ALS Collaborative Filtering
   3. Fire of Destiny (1999) ⭐4.44
      Documentary|Drama|Comedy|Action | Popular in Comedy
   4. Storm: Return of Warrior (1984) ⭐4.32
      War|Comedy|Romance|Adventure | Popular in Comedy
   5. Dawn Empire (1991) ⭐4.25
      Western|Comedy|Film-Noir | Popular in Comedy</code></pre>
<pre><code class="language-python"># 사용 예시 3: 유사 영화
interactive_recommendation(similar_to=1)</code></pre>
<h4 id="output-50">output</h4>
<pre><code class="language-text">🎬 영화 추천 서비스
==================================================

📽️ &#39;The Universe (1998)&#39;와 유사한 영화:
   1. Fall of Mountain (1997)
   2. Shadow City (1997)
   3. Empire Forest (2001)
   4. Dragon Empire (1981)
   5. Fire of Destiny (1999)</code></pre>
<h3 id="이-노트북에서-다룬-내용">이 노트북에서 다룬 내용</h3>
<ol>
<li><strong>추천 서비스 클래스</strong> — 모델을 래핑한 재사용 가능한 서비스 구현</li>
<li><strong>Cold Start 처리</strong> — 인기도 기반 폴백 전략</li>
<li><strong>장르 기반 필터링</strong> — 사용자 선호 장르 내 추천</li>
<li><strong>Item-Item 유사도</strong> — 공동 평가 기반 유사 영화 탐색</li>
<li><strong>추천 다양성 분석</strong> — 커버리지, 장르 분포, 인기도 편향 측정</li>
<li><strong>A/B 테스트 시뮬레이션</strong> — 전략별 추천 결과 비교</li>
</ol>
<h1 id="️-영화-추천-대시보드-gradio">️ 영화 추천 대시보드 (Gradio)</h1>
<p>훈련된 ALS 모델과 Gold/Silver 테이블을 활용한 <strong>인터랙티브 추천 대시보드</strong>입니다.</p>
<h3 id="기능">기능</h3>
<table>
<thead>
<tr>
<th>탭</th>
<th>기능</th>
</tr>
</thead>
<tbody><tr>
<td>개인 추천</td>
<td>사용자 ID를 입력하면 맞춤 추천 + 시청 기록</td>
</tr>
<tr>
<td>유사 영화</td>
<td>영화를 선택하면 비슷한 영화 추천</td>
</tr>
<tr>
<td>데이터 탐색</td>
<td>장르별 인기도, Top 영화, 평점 분포 등 시각화</td>
</tr>
<tr>
<td>️ Cold Start</td>
<td>신규 사용자를 위한 인기 기반 추천 시연</td>
</tr>
</tbody></table>
<h3 id="실행-방법">실행 방법</h3>
<p>이 노트북의 모든 셀을 순서대로 실행하면 마지막 셀에서 Gradio 앱이 시작됩니다.
Databricks 환경에서는 프록시 URL을 통해 브라우저에서 접속할 수 있습니다.</p>
<h2 id="1-환경-설정-3">1. 환경 설정</h2>
<pre><code class="language-python">%pip install gradio --quiet</code></pre>
<h4 id="output-51">output</h4>
<pre><code class="language-text">[31mERROR: pip&#39;s dependency resolver does not currently take into account all the packages that are installed. This behaviour is the source of the following dependency conflicts.
jupyter-server 1.23.4 requires anyio&lt;4,&gt;=3.1.0, but you have anyio 4.13.0 which is incompatible.
spacy 3.7.2 requires typer&lt;0.10.0,&gt;=0.3.0, but you have typer 0.24.1 which is incompatible.
tokenizers 0.19.0 requires huggingface-hub&lt;1.0,&gt;=0.16.4, but you have huggingface-hub 1.8.0 which is incompatible.
transformers 4.41.2 requires huggingface-hub&lt;1.0,&gt;=0.23.0, but you have huggingface-hub 1.8.0 which is incompatible.
weasel 0.3.4 requires typer&lt;0.10.0,&gt;=0.3.0, but you have typer 0.24.1 which is incompatible.
ydata-profiling 4.5.1 requires pydantic&lt;2,&gt;=1.8.1, but you have pydantic 2.12.5 which is incompatible.[0m[31m
[0m[43mNote: you may need to restart the kernel using %restart_python or dbutils.library.restartPython() to use updated packages.[0m</code></pre>
<pre><code class="language-python">%restart_python</code></pre>
<h2 id="2-데이터-및-모델-로드">2. 데이터 및 모델 로드</h2>
<pre><code class="language-python">from pyspark.sql import functions as F
from pyspark.sql.window import Window
import mlflow
from mlflow.tracking import MlflowClient
import mlflow.spark
import pandas as pd

# ============================================================
# ⚠️ 카탈로그 이름을 본인의 카탈로그로 변경하세요!
# ============================================================
CATALOG = &quot;3dt016_databricks&quot;
SCHEMA = &quot;movie_recommender&quot;
spark.sql(f&quot;USE {CATALOG}.{SCHEMA}&quot;)

# --- 데이터 로드 (Pandas로 변환하여 Gradio에서 빠르게 접근) ---
print(&quot;📥 데이터 로드 중...&quot;)

# Silver 테이블
movies_pdf = spark.table(&quot;silver_movies&quot;).toPandas()
users_pdf = spark.table(&quot;silver_users&quot;).toPandas()
ratings_pdf = spark.table(&quot;silver_ratings&quot;).select(
    &quot;user_id&quot;, &quot;movie_id&quot;, &quot;rating&quot;, &quot;rating_date&quot;, &quot;rating_category&quot;, &quot;is_positive&quot;
).toPandas()
movie_stats_pdf = spark.table(&quot;silver_movie_stats&quot;).toPandas()
user_stats_pdf = spark.table(&quot;silver_user_stats&quot;).toPandas()

# Gold 테이블
genre_popularity_pdf = spark.table(&quot;gold_genre_popularity&quot;).toPandas()
top_movies_pdf = spark.table(&quot;gold_top_movies&quot;).toPandas()

print(f&quot;✅ 데이터 로드 완료&quot;)
print(f&quot;   영화: {len(movies_pdf):,} | 사용자: {len(users_pdf):,} | 평점: {len(ratings_pdf):,}&quot;)</code></pre>
<h4 id="output-52">output</h4>
<pre><code class="language-text">📥 데이터 로드 중...
✅ 데이터 로드 완료
   영화: 500 | 사용자: 1,000 | 평점: 9,533</code></pre>
<h3 id="모델-로드">모델 로드</h3>
<pre><code class="language-python">MODEL_NAME = f&quot;{CATALOG}.{SCHEMA}.movie_recommender_als&quot;
client = MlflowClient()
als_model = None

try:
    results = client.search_model_versions(f&quot;name=&#39;{MODEL_NAME}&#39;&quot;)
    if results:
        sorted_versions = sorted(results, key=lambda x: int(x.version), reverse=True)
        latest_version = sorted_versions[0].version
        model_uri = f&quot;models:/{MODEL_NAME}/{latest_version}&quot;
        als_model = mlflow.spark.load_model(model_uri)
        print(f&quot;✅ ALS 모델 로드 성공 (v{latest_version})&quot;)
    else:
        print(&quot;⚠️ 모델 버전이 없습니다. 이전 노트북을 실행해주세요.&quot;)
except Exception as e:
    print(f&quot;⚠️ 모델 로드 실패: {e}&quot;)
    print(&quot;   일부 추천 기능이 제한됩니다.&quot;)</code></pre>
<h4 id="output-53">output</h4>
<pre><code class="language-text">2026/03/27 02:32:20 INFO mlflow.spark: &#39;models:/3dt016_databricks.movie_recommender.movie_recommender_als/1&#39; resolved as &#39;abfss://unity-catalog-storage@dbstorage5iw3c6bawkm3i.dfs.core.windows.net/7405619152986054/models/d978cc61-b363-4df0-9321-ed30210f34ba/versions/4ef77bca-426a-45ba-a09c-56fa2c2d7cf2&#39;</code></pre>
<pre><code class="language-text">Downloading artifacts:   0%|          | 0/1 [00:00&lt;?, ?it/s]</code></pre>
<pre><code class="language-text">Downloading artifacts:   0%|          | 0/43 [00:00&lt;?, ?it/s]</code></pre>
<pre><code class="language-text">✅ ALS 모델 로드 성공 (v1)</code></pre>
<h2 id="3-추천-로직-함수-정의">3. 추천 로직 함수 정의</h2>
<p>Gradio UI에서 호출할 백엔드 함수들을 정의합니다.</p>
<pre><code class="language-python">import numpy as np

def get_user_history(user_id: int) -&gt; pd.DataFrame:
    &quot;&quot;&quot;사용자의 시청 기록을 반환합니다.&quot;&quot;&quot;
    user_ratings = ratings_pdf[ratings_pdf[&quot;user_id&quot;] == user_id].copy()
    if user_ratings.empty:
        return pd.DataFrame(columns=[&quot;영화&quot;, &quot;평점&quot;, &quot;카테고리&quot;])

    merged = user_ratings.merge(
        movies_pdf[[&quot;movie_id&quot;, &quot;title&quot;, &quot;genres&quot;, &quot;year&quot;]],
        on=&quot;movie_id&quot;
    )
    result = merged.sort_values(&quot;rating&quot;, ascending=False).head(20)
    return result[[&quot;title&quot;, &quot;genres&quot;, &quot;rating&quot;, &quot;rating_category&quot;]].rename(columns={
        &quot;title&quot;: &quot;영화&quot;, &quot;genres&quot;: &quot;장르&quot;, &quot;rating&quot;: &quot;평점&quot;, &quot;rating_category&quot;: &quot;카테고리&quot;
    })


def get_als_recommendations(user_id: int, n: int = 10) -&gt; pd.DataFrame:
    &quot;&quot;&quot;ALS 모델 기반 개인화 추천&quot;&quot;&quot;
    if als_model is None:
        return pd.DataFrame({&quot;메시지&quot;: [&quot;모델이 로드되지 않았습니다&quot;]})

    # 이미 본 영화 제외
    watched = set(ratings_pdf[ratings_pdf[&quot;user_id&quot;] == user_id][&quot;movie_id&quot;])

    # 안 본 영화에 대해 예측
    unwatched = movies_pdf[~movies_pdf[&quot;movie_id&quot;].isin(watched)][[&quot;movie_id&quot;]].copy()
    unwatched[&quot;user_id&quot;] = user_id

    if unwatched.empty:
        return pd.DataFrame({&quot;메시지&quot;: [&quot;모든 영화를 이미 시청했습니다&quot;]})

    # Spark로 예측
    pred_spark = als_model.transform(spark.createDataFrame(unwatched))
    pred_pdf = pred_spark.filter(F.col(&quot;prediction&quot;).isNotNull()).toPandas()

    if pred_pdf.empty:
        return get_popular_recommendations(n)

    pred_pdf = pred_pdf.sort_values(&quot;prediction&quot;, ascending=False).head(n)
    merged = pred_pdf.merge(movies_pdf[[&quot;movie_id&quot;, &quot;title&quot;, &quot;genres&quot;, &quot;year&quot;]], on=&quot;movie_id&quot;)

    return merged[[&quot;title&quot;, &quot;genres&quot;, &quot;year&quot;, &quot;prediction&quot;]].rename(columns={
        &quot;title&quot;: &quot;영화&quot;, &quot;genres&quot;: &quot;장르&quot;, &quot;year&quot;: &quot;연도&quot;,
        &quot;prediction&quot;: &quot;예상 평점&quot;
    }).round({&quot;예상 평점&quot;: 2})


def get_popular_recommendations(n: int = 10) -&gt; pd.DataFrame:
    &quot;&quot;&quot;인기 기반 추천 (Cold Start용)&quot;&quot;&quot;
    top = top_movies_pdf.head(n).copy()
    return top[[&quot;rank&quot;, &quot;title&quot;, &quot;genres&quot;, &quot;year&quot;, &quot;bayesian_avg&quot;, &quot;total_ratings&quot;]].rename(columns={
        &quot;rank&quot;: &quot;순위&quot;, &quot;title&quot;: &quot;영화&quot;, &quot;genres&quot;: &quot;장르&quot;, &quot;year&quot;: &quot;연도&quot;,
        &quot;bayesian_avg&quot;: &quot;베이지안 평점&quot;, &quot;total_ratings&quot;: &quot;평점 수&quot;
    })


def get_genre_recommendations(user_id: int, genre: str, n: int = 10) -&gt; pd.DataFrame:
    &quot;&quot;&quot;장르 필터링 추천&quot;&quot;&quot;
    recs = get_als_recommendations(user_id, n * 3)
    if &quot;메시지&quot; in recs.columns:
        return recs

    genre_recs = recs[recs[&quot;장르&quot;].str.contains(genre, case=False, na=False)].head(n)

    if len(genre_recs) &lt; n:
        # 인기 영화로 보충
        genre_movies = movie_stats_pdf.merge(
            movies_pdf[[&quot;movie_id&quot;, &quot;title&quot;, &quot;genres&quot;, &quot;year&quot;]], on=&quot;movie_id&quot;
        )
        genre_movies = genre_movies[genre_movies[&quot;genres&quot;].str.contains(genre, case=False, na=False)]
        genre_movies = genre_movies.sort_values(&quot;bayesian_avg&quot;, ascending=False).head(n)
        supplement = genre_movies[[&quot;title&quot;, &quot;genres&quot;, &quot;year&quot;, &quot;bayesian_avg&quot;]].rename(columns={
            &quot;title&quot;: &quot;영화&quot;, &quot;genres&quot;: &quot;장르&quot;, &quot;year&quot;: &quot;연도&quot;, &quot;bayesian_avg&quot;: &quot;예상 평점&quot;
        })
        genre_recs = pd.concat([genre_recs, supplement]).drop_duplicates(subset=[&quot;영화&quot;]).head(n)

    return genre_recs.reset_index(drop=True)


def find_similar_movies(movie_title: str, n: int = 10) -&gt; pd.DataFrame:
    &quot;&quot;&quot;유사 영화 찾기 (공동 높은 평가 기반)&quot;&quot;&quot;
    # 제목으로 movie_id 찾기
    match = movies_pdf[movies_pdf[&quot;title&quot;].str.contains(movie_title, case=False, na=False)]
    if match.empty:
        return pd.DataFrame({&quot;메시지&quot;: [f&quot;&#39;{movie_title}&#39; 영화를 찾을 수 없습니다&quot;]})

    movie_id = match.iloc[0][&quot;movie_id&quot;]
    movie_name = match.iloc[0][&quot;title&quot;]

    # 해당 영화를 4점 이상 준 사용자
    fans = set(ratings_pdf[
        (ratings_pdf[&quot;movie_id&quot;] == movie_id) &amp; (ratings_pdf[&quot;rating&quot;] &gt;= 4.0)
    ][&quot;user_id&quot;])

    if not fans:
        return pd.DataFrame({&quot;메시지&quot;: [f&quot;&#39;{movie_name}&#39;을 높게 평가한 사용자가 없습니다&quot;]})

    # 그 사용자들이 높게 평가한 다른 영화
    fan_ratings = ratings_pdf[
        (ratings_pdf[&quot;user_id&quot;].isin(fans)) &amp;
        (ratings_pdf[&quot;movie_id&quot;] != movie_id) &amp;
        (ratings_pdf[&quot;rating&quot;] &gt;= 4.0)
    ]

    similar = (
        fan_ratings.groupby(&quot;movie_id&quot;)
        .agg(co_ratings=(&quot;rating&quot;, &quot;count&quot;), avg_rating=(&quot;rating&quot;, &quot;mean&quot;))
        .reset_index()
    )
    similar[&quot;유사도&quot;] = (similar[&quot;co_ratings&quot;] * similar[&quot;avg_rating&quot;]).round(1)
    similar = similar.sort_values(&quot;유사도&quot;, ascending=False).head(n)
    similar = similar.merge(movies_pdf[[&quot;movie_id&quot;, &quot;title&quot;, &quot;genres&quot;, &quot;year&quot;]], on=&quot;movie_id&quot;)

    return similar[[&quot;title&quot;, &quot;genres&quot;, &quot;year&quot;, &quot;co_ratings&quot;, &quot;유사도&quot;]].rename(columns={
        &quot;title&quot;: &quot;영화&quot;, &quot;genres&quot;: &quot;장르&quot;, &quot;year&quot;: &quot;연도&quot;, &quot;co_ratings&quot;: &quot;공동 평가 수&quot;
    })


def get_user_info(user_id: int) -&gt; str:
    &quot;&quot;&quot;사용자 요약 정보&quot;&quot;&quot;
    stats = user_stats_pdf[user_stats_pdf[&quot;user_id&quot;] == user_id]
    user = users_pdf[users_pdf[&quot;user_id&quot;] == user_id]

    if stats.empty or user.empty:
        return f&quot;❄️ 사용자 {user_id}: 데이터 없음 (Cold Start 대상)&quot;

    s = stats.iloc[0]
    u = user.iloc[0]
    return (
        f&quot;👤 사용자 {user_id}\n&quot;
        f&quot;   선호 프로필: {u.get(&#39;preference_profile&#39;, &#39;N/A&#39;)}\n&quot;
        f&quot;   활동성: {u.get(&#39;activity_level&#39;, &#39;N/A&#39;)}/5\n&quot;
        f&quot;   총 평점: {int(s[&#39;total_ratings&#39;])}개\n&quot;
        f&quot;   평균 평점: {s[&#39;avg_rating&#39;]:.2f}\n&quot;
        f&quot;   긍정 비율: {s[&#39;positive_ratio&#39;]:.1%}&quot;
    )</code></pre>
<h2 id="4-시각화-함수-정의">4. 시각화 함수 정의</h2>
<pre><code class="language-python">import matplotlib
matplotlib.use(&#39;Agg&#39;)  # Databricks 호환 백엔드
import matplotlib.pyplot as plt
import matplotlib.font_manager as fm

# 한글 폰트 설정 시도
def setup_korean_font():
    &quot;&quot;&quot;사용 가능한 한글 폰트를 찾아 설정합니다.&quot;&quot;&quot;
    korean_fonts = [&#39;NanumGothic&#39;, &#39;NanumBarunGothic&#39;, &#39;Malgun Gothic&#39;, &#39;AppleGothic&#39;, &#39;Noto Sans CJK KR&#39;]
    for font_name in korean_fonts:
        if any(font_name in f.name for f in fm.fontManager.ttflist):
            plt.rcParams[&#39;font.family&#39;] = font_name
            plt.rcParams[&#39;axes.unicode_minus&#39;] = False
            return font_name
    # 한글 폰트 없으면 기본 설정 유지
    plt.rcParams[&#39;axes.unicode_minus&#39;] = False
    return None

found_font = setup_korean_font()
if found_font:
    print(f&quot;✅ 한글 폰트 설정: {found_font}&quot;)
else:
    print(&quot;⚠️ 한글 폰트를 찾을 수 없습니다. 차트 레이블이 깨질 수 있습니다.&quot;)


def plot_genre_popularity():
    &quot;&quot;&quot;장르별 인기도 바 차트&quot;&quot;&quot;
    fig, ax = plt.subplots(figsize=(10, 5))
    data = genre_popularity_pdf.sort_values(&quot;popularity_score&quot;, ascending=True)
    colors = plt.cm.YlOrRd(np.linspace(0.3, 0.9, len(data)))
    ax.barh(data[&quot;genre&quot;], data[&quot;popularity_score&quot;], color=colors)
    ax.set_xlabel(&quot;Popularity Score&quot;)
    ax.set_title(&quot;Genre Popularity&quot;)
    plt.tight_layout()
    return fig


def plot_rating_distribution():
    &quot;&quot;&quot;평점 분포 히스토그램&quot;&quot;&quot;
    fig, ax = plt.subplots(figsize=(8, 4))
    ratings_pdf[&quot;rating&quot;].hist(bins=10, ax=ax, color=&quot;#4A90D9&quot;, edgecolor=&quot;white&quot;, alpha=0.8)
    ax.set_xlabel(&quot;Rating&quot;)
    ax.set_ylabel(&quot;Count&quot;)
    ax.set_title(&quot;Rating Distribution&quot;)
    ax.axvline(x=ratings_pdf[&quot;rating&quot;].mean(), color=&#39;red&#39;, linestyle=&#39;--&#39;,
               label=f&#39;Mean: {ratings_pdf[&quot;rating&quot;].mean():.2f}&#39;)
    ax.legend()
    plt.tight_layout()
    return fig


def plot_user_activity():
    &quot;&quot;&quot;사용자 활동량 분포&quot;&quot;&quot;
    fig, ax = plt.subplots(figsize=(8, 4))
    user_stats_pdf[&quot;total_ratings&quot;].hist(bins=30, ax=ax, color=&quot;#50C878&quot;, edgecolor=&quot;white&quot;, alpha=0.8)
    ax.set_xlabel(&quot;Number of Ratings per User&quot;)
    ax.set_ylabel(&quot;User Count&quot;)
    ax.set_title(&quot;User Activity Distribution (Long-tail)&quot;)
    ax.axvline(x=user_stats_pdf[&quot;total_ratings&quot;].mean(), color=&#39;red&#39;, linestyle=&#39;--&#39;,
               label=f&#39;Mean: {user_stats_pdf[&quot;total_ratings&quot;].mean():.1f}&#39;)
    ax.axvline(x=user_stats_pdf[&quot;total_ratings&quot;].median(), color=&#39;orange&#39;, linestyle=&#39;--&#39;,
               label=f&#39;Median: {user_stats_pdf[&quot;total_ratings&quot;].median():.1f}&#39;)
    ax.legend()
    plt.tight_layout()
    return fig


def plot_movie_ratings_count():
    &quot;&quot;&quot;영화별 평점 수 분포&quot;&quot;&quot;
    fig, ax = plt.subplots(figsize=(8, 4))
    movie_stats_pdf[&quot;total_ratings&quot;].hist(bins=30, ax=ax, color=&quot;#FF7F50&quot;, edgecolor=&quot;white&quot;, alpha=0.8)
    ax.set_xlabel(&quot;Number of Ratings per Movie&quot;)
    ax.set_ylabel(&quot;Movie Count&quot;)
    ax.set_title(&quot;Movie Popularity Distribution (Long-tail)&quot;)
    ax.axvline(x=movie_stats_pdf[&quot;total_ratings&quot;].mean(), color=&#39;red&#39;, linestyle=&#39;--&#39;,
               label=f&#39;Mean: {movie_stats_pdf[&quot;total_ratings&quot;].mean():.1f}&#39;)
    ax.legend()
    plt.tight_layout()
    return fig</code></pre>
<h4 id="output-54">output</h4>
<pre><code class="language-text">⚠️ 한글 폰트를 찾을 수 없습니다. 차트 레이블이 깨질 수 있습니다.</code></pre>
<h2 id="5-gradio-앱-구성-및-실행">5. Gradio 앱 구성 및 실행</h2>
<p>아래 셀을 실행하면 Gradio 앱이 시작됩니다.
Databricks 환경에서는 출력에 표시되는 <strong>프록시 URL</strong>을 통해 접속하세요.</p>
<pre><code class="language-python">import gradio as gr

# --- 장르 목록 (드롭다운용) ---
GENRE_LIST = [
    &quot;Action&quot;, &quot;Adventure&quot;, &quot;Animation&quot;, &quot;Children&quot;, &quot;Comedy&quot;, &quot;Crime&quot;,
    &quot;Documentary&quot;, &quot;Drama&quot;, &quot;Fantasy&quot;, &quot;Film-Noir&quot;, &quot;Horror&quot;, &quot;Musical&quot;,
    &quot;Mystery&quot;, &quot;Romance&quot;, &quot;Sci-Fi&quot;, &quot;Thriller&quot;, &quot;War&quot;, &quot;Western&quot;
]

# --- 영화 검색용 제목 리스트 ---
movie_titles = movies_pdf[&quot;title&quot;].tolist()

# ==============================================
# 탭 1: 개인 추천
# ==============================================
def tab_personal_recommend(user_id, genre_filter):
    user_info = get_user_info(int(user_id))

    if genre_filter and genre_filter != &quot;전체&quot;:
        recs = get_genre_recommendations(int(user_id), genre_filter)
    else:
        recs = get_als_recommendations(int(user_id))

    history = get_user_history(int(user_id))
    return user_info, recs, history


# ==============================================
# 탭 2: 유사 영화
# ==============================================
def tab_similar_movies(movie_query):
    similar = find_similar_movies(movie_query)
    # 선택한 영화 정보
    match = movies_pdf[movies_pdf[&quot;title&quot;].str.contains(movie_query, case=False, na=False)]
    if not match.empty:
        m = match.iloc[0]
        stats = movie_stats_pdf[movie_stats_pdf[&quot;movie_id&quot;] == m[&quot;movie_id&quot;]]
        info = f&quot;🎬 {m[&#39;title&#39;]}\n   장르: {m[&#39;genres&#39;]}\n   감독: {m[&#39;director&#39;]}\n   연도: {m[&#39;year&#39;]}&quot;
        if not stats.empty:
            s = stats.iloc[0]
            info += f&quot;\n   평점: {s[&#39;avg_rating&#39;]:.2f} ({int(s[&#39;total_ratings&#39;])}개)&quot;
    else:
        info = f&quot;&#39;{movie_query}&#39; 검색 결과 없음&quot;
    return info, similar


# ==============================================
# 탭 3: 데이터 탐색
# ==============================================
def tab_explore(chart_type):
    if chart_type == &quot;장르별 인기도&quot;:
        return plot_genre_popularity()
    elif chart_type == &quot;평점 분포&quot;:
        return plot_rating_distribution()
    elif chart_type == &quot;사용자 활동량 분포&quot;:
        return plot_user_activity()
    elif chart_type == &quot;영화별 평점 수 분포&quot;:
        return plot_movie_ratings_count()


# ==============================================
# 탭 4: Cold Start
# ==============================================
def tab_cold_start():
    recs = get_popular_recommendations(15)
    total_movies = len(movies_pdf)
    total_users = len(users_pdf)
    total_ratings = len(ratings_pdf)
    sparsity = 1 - (total_ratings / (total_movies * total_users))
    info = (
        f&quot;📊 데이터 현황\n&quot;
        f&quot;   영화: {total_movies:,}개\n&quot;
        f&quot;   사용자: {total_users:,}명\n&quot;
        f&quot;   평점: {total_ratings:,}건\n&quot;
        f&quot;   희소성: {sparsity:.2%}\n\n&quot;
        f&quot;❄️ Cold Start 전략: 베이지안 평균 기준 인기 영화 추천\n&quot;
        f&quot;   신규 사용자에게는 아래 인기 영화를 추천합니다.&quot;
    )
    return info, recs


# ==============================================
# Gradio Blocks 레이아웃
# ==============================================
# Gradio 6.0+: theme 파라미터는 launch()에서 설정
with gr.Blocks(title=&quot;🎬 Movie Recommender Dashboard&quot;) as app:

    gr.Markdown(&quot;# 🎬 영화 추천 시스템 대시보드&quot;)
    gr.Markdown(&quot;ALS 협업 필터링 모델 기반 인터랙티브 추천 서비스&quot;)

    # --- 탭 1: 개인 추천 ---
    with gr.Tab(&quot;🎬 개인 추천&quot;):
        gr.Markdown(&quot;### 사용자 ID를 입력하고 맞춤 추천을 받아보세요&quot;)
        with gr.Row():
            user_id_input = gr.Number(label=&quot;사용자 ID&quot;, value=42, precision=0)
            genre_dropdown = gr.Dropdown(
                choices=[&quot;전체&quot;] + GENRE_LIST,
                label=&quot;장르 필터 (선택)&quot;,
                value=&quot;전체&quot;
            )
            recommend_btn = gr.Button(&quot;추천 받기&quot;, variant=&quot;primary&quot;)

        user_info_output = gr.Textbox(label=&quot;사용자 정보&quot;, lines=6)

        with gr.Row():
            with gr.Column():
                gr.Markdown(&quot;#### 📋 AI 추천 결과&quot;)
                recs_output = gr.Dataframe(label=&quot;추천 영화&quot;)
            with gr.Column():
                gr.Markdown(&quot;#### 📖 시청 기록 (상위 20개)&quot;)
                history_output = gr.Dataframe(label=&quot;시청 기록&quot;)

        recommend_btn.click(
            tab_personal_recommend,
            inputs=[user_id_input, genre_dropdown],
            outputs=[user_info_output, recs_output, history_output]
        )

    # --- 탭 2: 유사 영화 ---
    with gr.Tab(&quot;🔍 유사 영화&quot;):
        gr.Markdown(&quot;### 영화 제목을 입력하면 비슷한 영화를 찾아드립니다&quot;)
        gr.Markdown(&quot;_영화 제목의 일부만 입력해도 검색됩니다 (예: `Knight`, `Storm`)_&quot;)
        with gr.Row():
            movie_query_input = gr.Textbox(label=&quot;영화 제목 검색&quot;, placeholder=&quot;예: Knight, Storm, Dark...&quot;)
            similar_btn = gr.Button(&quot;유사 영화 찾기&quot;, variant=&quot;primary&quot;)

        movie_info_output = gr.Textbox(label=&quot;영화 정보&quot;, lines=5)
        similar_output = gr.Dataframe(label=&quot;유사 영화 목록&quot;)

        similar_btn.click(
            tab_similar_movies,
            inputs=[movie_query_input],
            outputs=[movie_info_output, similar_output]
        )

    # --- 탭 3: 데이터 탐색 ---
    with gr.Tab(&quot;📊 데이터 탐색&quot;):
        gr.Markdown(&quot;### 추천 시스템의 데이터를 시각적으로 탐색합니다&quot;)
        chart_selector = gr.Radio(
            choices=[&quot;장르별 인기도&quot;, &quot;평점 분포&quot;, &quot;사용자 활동량 분포&quot;, &quot;영화별 평점 수 분포&quot;],
            label=&quot;차트 선택&quot;,
            value=&quot;장르별 인기도&quot;
        )
        chart_output = gr.Plot(label=&quot;차트&quot;)

        chart_selector.change(tab_explore, inputs=[chart_selector], outputs=[chart_output])
        # 초기 차트 로드
        app.load(lambda: plot_genre_popularity(), outputs=[chart_output])

    # --- 탭 4: Cold Start ---
    with gr.Tab(&quot;❄️ Cold Start&quot;):
        gr.Markdown(&quot;### 신규 사용자를 위한 인기 기반 추천&quot;)
        gr.Markdown(&quot;평점 기록이 없는 사용자에게는 전체 인기도 기반으로 추천합니다.&quot;)
        cold_start_btn = gr.Button(&quot;인기 영화 보기&quot;, variant=&quot;primary&quot;)

        cold_info_output = gr.Textbox(label=&quot;데이터 현황 및 전략&quot;, lines=8)
        cold_recs_output = gr.Dataframe(label=&quot;인기 영화 Top 15&quot;)

        cold_start_btn.click(
            tab_cold_start,
            outputs=[cold_info_output, cold_recs_output]
        )

    # --- 탭 5: Top 영화 리더보드 ---
    with gr.Tab(&quot;🏆 Top 영화&quot;):
        gr.Markdown(&quot;### 베이지안 평균 기준 Top 영화 리더보드&quot;)
        top_n_slider = gr.Slider(minimum=5, maximum=50, value=20, step=5, label=&quot;표시할 영화 수&quot;)
        top_output = gr.Dataframe(label=&quot;Top 영화&quot;)

        def show_top_movies(n):
            top = top_movies_pdf.head(int(n))
            return top[[&quot;rank&quot;, &quot;title&quot;, &quot;genres&quot;, &quot;director&quot;, &quot;year&quot;,
                        &quot;total_ratings&quot;, &quot;avg_rating&quot;, &quot;bayesian_avg&quot;]].rename(columns={
                &quot;rank&quot;: &quot;순위&quot;, &quot;title&quot;: &quot;영화&quot;, &quot;genres&quot;: &quot;장르&quot;, &quot;director&quot;: &quot;감독&quot;,
                &quot;year&quot;: &quot;연도&quot;, &quot;total_ratings&quot;: &quot;평점 수&quot;, &quot;avg_rating&quot;: &quot;평균 평점&quot;,
                &quot;bayesian_avg&quot;: &quot;베이지안 평점&quot;
            })

        top_n_slider.change(show_top_movies, inputs=[top_n_slider], outputs=[top_output])
        app.load(lambda: show_top_movies(20), outputs=[top_output])</code></pre>
<h2 id="6-앱-실행">6. 앱 실행</h2>
<p>아래 셀을 실행하면 Gradio 앱이 시작됩니다.</p>
<p><strong>Databricks 환경 접속 방법:</strong></p>
<ul>
<li>방법 1: 셀 출력의 iframe에서 바로 사용 (기본)</li>
<li>방법 2: 출력 URL을 새 탭에서 열기</li>
<li>방법 3: <code>share=True</code>로 변경하면 외부 공유 가능한 public URL 생성</li>
</ul>
<pre><code class="language-python"># Databricks 환경에서 Gradio 앱을 인라인으로 표시
# 클러스터 드라이버 프록시를 통해 접속합니다.

import os

# Databricks 프록시 URL 자동 감지
def get_databricks_proxy_url(port=7860):
    &quot;&quot;&quot;Databricks 드라이버 프록시 URL을 생성합니다.&quot;&quot;&quot;
    try:
        # Databricks 워크스페이스 URL과 클러스터 ID 감지
        workspace_url = spark.conf.get(&quot;spark.databricks.workspaceUrl&quot;, &quot;&quot;)
        cluster_id = spark.conf.get(&quot;spark.databricks.clusterUsageTags.clusterId&quot;, &quot;&quot;)
        org_id = spark.conf.get(&quot;spark.databricks.clusterUsageTags.orgId&quot;, &quot;&quot;)

        if workspace_url and cluster_id:
            proxy_url = f&quot;https://{workspace_url}/driver-proxy/o/{org_id}/{cluster_id}/{port}/&quot;
            return proxy_url
    except Exception:
        pass
    return None

proxy_url = get_databricks_proxy_url()

# 앱 실행
app.launch(
    server_port=7860,
    inline=True,           # Databricks 노트북 내 iframe 렌더링
    height=800,            # iframe 높이 (px)
    share=True,            # Databricks에서는 True 필요 (외부 공유 URL 생성)
    theme=gr.themes.Soft()
)

# 프록시 URL 안내
if proxy_url:
    print(f&quot;\n📌 Databricks 프록시 URL: {proxy_url}&quot;)
    print(&quot;   위 URL을 새 탭에서 열어도 사용할 수 있습니다.&quot;)
    # 또는 displayHTML로 직접 iframe 삽입
    displayHTML(f&#39;&lt;a href=&quot;{proxy_url}&quot; target=&quot;_blank&quot;&gt;🔗 새 탭에서 대시보드 열기&lt;/a&gt;&#39;)
else:
    print(&quot;\n💡 앱이 로컬에서 실행 중입니다.&quot;)
    print(&quot;   위에 표시된 Gradio iframe을 사용하세요.&quot;)</code></pre>
<h2 id=""><img src="https://velog.velcdn.com/images/rudin_/post/1515a0bc-317d-45aa-b661-a6ac7d0293c9/image.png" alt=""></h2>
<h1 id="전체-흐름">전체 흐름</h1>
<ul>
<li><code>01_generate_sample_data</code> → 샘플 데이터 생성 </li>
<li><code>02_silver_layer_transformation</code> → 정제 + feature 생성 </li>
<li><code>03_gold_layer_and_als_model</code> → 집계 + ALS 학습 + MLflow 등록</li>
<li><code>04_recommendation_service</code> → 추천 서비스 로직 + 분석 + 통계 표 output</li>
<li><code>05_recommendation_dashboard</code> → 시각화 + Gradio 대시보드</li>
</ul>
<hr>

<h1 id="nyc_taxi_201912-실습">NYC_Taxi_201912 실습</h1>
<h2 id="exploring-sample-nyc-taxi-data-from-databricks">Exploring sample NYC Taxi Data from Databricks</h2>
<ul>
<li>Based on Databricks Sample Presentation</li>
<li>Albert Nogués 2021.</li>
</ul>
<p>We will load some sample data from the NYC taxi dataset available in databricks, load them and store them as table. We will use then python to do some manipulation (Extract month and year from the trip time), which will create two new additional columns to our dataframe and will check how the file is saved in the hive warehouse. We will observe we have some junk data as it created folders for months and years (partitioning), that we are not supposed to have, so we will use filter to apply some filter in python way and in sql way to filter these bad records</p>
<p>Then, we will load another month of data as a temporary view and will compare this in contrast with a delta table where we can run updates and all sort of DML.</p>
<p>As a last step, we will load some master data and will perform a join. For more on Delta Lake you can follow this tutorial --&gt; <a href="https://delta.io/tutorials/delta-lake-workshop-primer/">https://delta.io/tutorials/delta-lake-workshop-primer/</a></p>
<pre><code>%fs
ls dbfs:/databricks-datasets/nyctaxi/tripdata/yellow
</code></pre><h4 id="output-55">output</h4>
<table>
<thead>
<tr>
<th>path</th>
<th>name</th>
<th>size</th>
<th>modificationTime</th>
</tr>
</thead>
<tbody><tr>
<td>dbfs:/databricks-datasets/nyctaxi/tripdata/yellow/yellow_tripdata_2009-01.csv.gz</td>
<td>yellow_tripdata_2009-01.csv.gz</td>
<td>504262564</td>
<td>1590525201000</td>
</tr>
<tr>
<td>dbfs:/databricks-datasets/nyctaxi/tripdata/yellow/yellow_tripdata_2009-02.csv.gz</td>
<td>yellow_tripdata_2009-02.csv.gz</td>
<td>480034681</td>
<td>1590525201000</td>
</tr>
<tr>
<td>dbfs:/databricks-datasets/nyctaxi/tripdata/yellow/yellow_tripdata_2009-03.csv.gz</td>
<td>yellow_tripdata_2009-03.csv.gz</td>
<td>521102719</td>
<td>1590525201000</td>
</tr>
<tr>
<td>dbfs:/databricks-datasets/nyctaxi/tripdata/yellow/yellow_tripdata_2009-04.csv.gz</td>
<td>yellow_tripdata_2009-04.csv.gz</td>
<td>515435466</td>
<td>1590525201000</td>
</tr>
<tr>
<td>dbfs:/databricks-datasets/nyctaxi/tripdata/yellow/yellow_tripdata_2009-05.csv.gz</td>
<td>yellow_tripdata_2009-05.csv.gz</td>
<td>531133739</td>
<td>1590525201000</td>
</tr>
<tr>
<td>... (이하 생략)</td>
<td></td>
<td></td>
<td></td>
</tr>
</tbody></table>
<pre><code># 파일 메타데이터를 데이터프레임으로 불러오기
df = spark.read.format(&quot;binaryFile&quot;).load(&quot;dbfs:/databricks-datasets/nyctaxi/tripdata/yellow/&quot;)

# 임시 뷰로 등록
df.createOrReplaceTempView(&quot;file_metadata&quot;)</code></pre><pre><code>%sql
SELECT 
    SUM(length) AS total_size_bytes,
    SUM(length) / 1024 AS total_size_kb,
    SUM(length) / (1024 * 1024) AS total_size_mb,
    SUM(length) / (1024 * 1024 * 1024) AS total_size_gb
FROM file_metadata</code></pre><h4 id="output-56">output</h4>
<table>
<thead>
<tr>
<th>total_size_bytes</th>
<th>total_size_kb</th>
<th>total_size_mb</th>
<th>total_size_gb</th>
</tr>
</thead>
<tbody><tr>
<td>50244255636</td>
<td>49066655.89453125</td>
<td>47916.656147003174</td>
<td>46.79360951855779</td>
</tr>
</tbody></table>
<p>We define the scructure of the dataframe (columns: names and types), and create a new dataframe with this schema for us to analyze through spark</p>
<p>StructType 및 StructField: 각 데이터 컬럼의 타입을 지정합니다.
컬럼 이름 및 타입: NYC 택시 데이터의 각 필드를 정의하며, Vendor는 택시 회사, Pickup_DateTime은 탑승 시간, Dropoff_DateTime은 하차 시간 등으로 구성됩니다. 각 필드는 StringType, IntegerType, DoubleType, TimestampType 등으로 지정되어 있습니다.
True 값: 각 필드가 nullable(빈 값 허용)이 가능한지를 나타냅니다.</p>
<p>rawDF 데이터프레임에는 2019년 12월 NYC 택시 데이터가 담기며, 이후 다양한 분석 작업에 활용할 수 있습니다.</p>
<pre><code>from pyspark.sql.functions import col, lit, expr, when
from pyspark.sql.types import *
from datetime import datetime
import time

# Define schema
nyc_schema = StructType([
  StructField(&#39;Vendor&#39;, StringType(), True),
  StructField(&#39;Pickup_DateTime&#39;, TimestampType(), True),
  StructField(&#39;Dropoff_DateTime&#39;, TimestampType(), True),
  StructField(&#39;Passenger_Count&#39;, IntegerType(), True),
  StructField(&#39;Trip_Distance&#39;, DoubleType(), True),
  StructField(&#39;Pickup_Longitude&#39;, DoubleType(), True),
  StructField(&#39;Pickup_Latitude&#39;, DoubleType(), True),
  StructField(&#39;Rate_Code&#39;, StringType(), True),
  StructField(&#39;Store_And_Forward&#39;, StringType(), True),
  StructField(&#39;Dropoff_Longitude&#39;, DoubleType(), True),
  StructField(&#39;Dropoff_Latitude&#39;, DoubleType(), True),
  StructField(&#39;Payment_Type&#39;, StringType(), True),
  StructField(&#39;Fare_Amount&#39;, DoubleType(), True),
  StructField(&#39;Surcharge&#39;, DoubleType(), True),
  StructField(&#39;MTA_Tax&#39;, DoubleType(), True),
  StructField(&#39;Tip_Amount&#39;, DoubleType(), True),
  StructField(&#39;Tolls_Amount&#39;, DoubleType(), True),
  StructField(&#39;Total_Amount&#39;, DoubleType(), True)
])

rawDF = spark.read.format(&#39;csv&#39;).options(header=True).schema(nyc_schema).load(&quot;dbfs:/databricks-datasets/nyctaxi/tripdata/yellow/yellow_tripdata_2019-12.csv.gz&quot;)
</code></pre><pre><code>rawDF.take(5)

#take(5) 함수는 rawDF 데이터프레임에서 상위 5개의 행을 가져옵니다. 이 함수는 데이터를 작은 목록으로 반환하므로, 데이터의 구조를 빠르게 확인하거나 일부 데이터 샘플을 살펴볼 때 유용합니다.

#The take(5) function retrieves the top 5 rows from the rawDF DataFrame. This function returns the data as a small list, making it helpful for quickly checking the data structure or examining a small sample of the data.</code></pre><h4 id="output-57">output</h4>
<pre><code class="language-text">[Row(Vendor=&#39;1&#39;, Pickup_DateTime=datetime.datetime(2019, 12, 1, 0, 26, 58), Dropoff_DateTime=datetime.datetime(2019, 12, 1, 0, 41, 45), Passenger_Count=1, Trip_Distance=4.2, Pickup_Longitude=1.0, Pickup_Latitude=None, Rate_Code=&#39;142&#39;, Store_And_Forward=&#39;116&#39;, Dropoff_Longitude=2.0, Dropoff_Latitude=14.5, Payment_Type=&#39;3&#39;, Fare_Amount=0.5, Surcharge=0.0, MTA_Tax=0.0, Tip_Amount=0.3, Tolls_Amount=18.3, Total_Amount=2.5),
 Row(Vendor=&#39;1&#39;, Pickup_DateTime=datetime.datetime(2019, 12, 1, 0, 12, 8), Dropoff_DateTime=datetime.datetime(2019, 12, 1, 0, 12, 14), Passenger_Count=1, Trip_Distance=0.0, Pickup_Longitude=1.0, Pickup_Latitude=None, Rate_Code=&#39;145&#39;, Store_And_Forward=&#39;145&#39;, Dropoff_Longitude=2.0, Dropoff_Latitude=2.5, Payment_Type=&#39;0.5&#39;, Fare_Amount=0.5, Surcharge=0.0, MTA_Tax=0.0, Tip_Amount=0.3, Tolls_Amount=3.8, Total_Amount=0.0),
 Row(Vendor=&#39;1&#39;, Pickup_DateTime=datetime.datetime(2019, 12, 1, 0, 25, 53), Dropoff_DateTime=datetime.datetime(2019, 12, 1, 0, 26, 4), Passenger_Count=1, Trip_Distance=0.0, Pickup_Longitude=1.0, Pickup_Latitude=None, Rate_Code=&#39;145&#39;, Store_And_Forward=&#39;145&#39;, Dropoff_Longitude=2.0, Dropoff_Latitude=2.5, Payment_Type=&#39;0.5&#39;, Fare_Amount=0.5, Surcharge=0.0, MTA_Tax=0.0, Tip_Amount=0.3, Tolls_Amount=3.8, Total_Amount=0.0),
 Row(Vendor=&#39;1&#39;, Pickup_DateTime=datetime.datetime(2019, 12, 1, 0, 12, 3), Dropoff_DateTime=datetime.datetime(2019, 12, 1, 0, 33, 19), Passenger_Count=2, Trip_Distance=9.4, Pickup_Longitude=1.0, Pickup_Latitude=None, Rate_Code=&#39;138&#39;, Store_And_Forward=&#39;25&#39;, Dropoff_Longitude=1.0, Dropoff_Latitude=28.5, Payment_Type=&#39;0.5&#39;, Fare_Amount=0.5, Surcharge=10.0, MTA_Tax=0.0, Tip_Amount=0.3, Tolls_Amount=39.8, Total_Amount=0.0),
 Row(Vendor=&#39;1&#39;, Pickup_DateTime=datetime.datetime(2019, 12, 1, 0, 5, 27), Dropoff_DateTime=datetime.datetime(2019, 12, 1, 0, 16, 32), Passenger_Count=2, Trip_Distance=1.6, Pickup_Longitude=1.0, Pickup_Latitude=None, Rate_Code=&#39;161&#39;, Store_And_Forward=&#39;237&#39;, Dropoff_Longitude=2.0, Dropoff_Latitude=9.0, Payment_Type=&#39;3&#39;, Fare_Amount=0.5, Surcharge=0.0, MTA_Tax=0.0, Tip_Amount=0.3, Tolls_Amount=12.8, Total_Amount=2.5)]</code></pre>
<pre><code>rawDF.printSchema()

#printSchema() 함수는 rawDF 데이터프레임의 스키마(데이터 구조)를 출력합니다. 각 컬럼의 이름, 데이터 타입, null 값 허용 여부 등을 확인할 수 있습니다. 이는 데이터셋의 구조를 이해하고, 특정 컬럼에 대한 타입이나 속성을 미리 파악하는 데 유용합니다.

#The printSchema() function prints the schema (data structure) of the rawDF DataFrame. It displays each column&#39;s name, data type, and whether null values are allowed. This is useful for understanding the dataset structure and quickly identifying the types and properties of specific columns.</code></pre><h4 id="output-58">output</h4>
<pre><code class="language-text">root
 |-- Vendor: string (nullable = true)
 |-- Pickup_DateTime: timestamp (nullable = true)
 |-- Dropoff_DateTime: timestamp (nullable = true)
 |-- Passenger_Count: integer (nullable = true)
 |-- Trip_Distance: double (nullable = true)
 |-- Pickup_Longitude: double (nullable = true)
 |-- Pickup_Latitude: double (nullable = true)
 |-- Rate_Code: string (nullable = true)
 |-- Store_And_Forward: string (nullable = true)
 |-- Dropoff_Longitude: double (nullable = true)
 |-- Dropoff_Latitude: double (nullable = true)
 |-- Payment_Type: string (nullable = true)
 |-- Fare_Amount: double (nullable = true)
 |-- Surcharge: double (nullable = true)
 |-- MTA_Tax: double (nullable = true)
 |-- Tip_Amount: double (nullable = true)
 |-- Tolls_Amount: double (nullable = true)
 |-- Total_Amount: double (nullable = true)

</code></pre>
<h3 id="create-taxidata-database-and-taxi_2019_12-table">CREATE taxidata DATABASE and taxi_2019_12 Table</h3>
<p><em>CREATE DATABASE IF NOT EXISTS taxidata</em> : taxidata라는 데이터베이스가 존재하지 않을 경우, 이를 새로 생성합니다. 이렇게 하면 NYC 택시 데이터를 위한 별도의 데이터베이스를 만들 수 있습니다.</p>
<p><em>DROP TABLE IF EXISTS taxidata.taxi_2019_12</em>: taxidata 데이터베이스 내에 taxi_2019_12라는 테이블이 이미 존재한다면 이를 삭제합니다. 이 과정은 데이터를 처음부터 새로 삽입할 수 있도록 기존 테이블을 제거하는 초기화 작업입니다.</p>
<p><em>CREATE DATABASE IF NOT EXISTS taxidata</em>; creates a new database called taxidata if it does not already exist. This is useful for organizing NYC taxi data in a dedicated database.</p>
<p><em>DROP TABLE IF EXISTS taxidata.taxi_2019_12</em>; deletes the taxi_2019_12 table within the taxidata database if it already exists. This step resets the table, allowing data to be reloaded from scratch.</p>
<pre><code>%sql
CREATE DATABASE IF NOT EXISTS taxidata;
DROP TABLE IF EXISTS taxidata.taxi_2019_12;</code></pre><h4 id="output-59">output</h4>
<p>|  |
||</p>
<pre><code>%python
#rawDF 데이터프레임의 데이터를 taxidata 데이터베이스 내의 taxi_2019_12 테이블에 저장합니다.
#This code saves the data from the rawDF DataFrame to the taxi_2019_12 table within the taxidata database.

rawDF.write.mode(&quot;overwrite&quot;).saveAsTable(&quot;taxidata.taxi_2019_12&quot;)

# .write는 데이터프레임을 저장할 때 사용하는 메서드입니다.
# mode(&quot;overwrite&quot;)는 기존 테이블이 있을 경우 덮어쓰도록 지정합니다. 즉, 동일한 이름의 테이블이 이미 존재하면 그 테이블을 삭제하고 새 데이터를 덮어씁니다. 이는 데이터 업데이트가 필요한 경우 유용합니다.
# saveAsTable(&quot;taxidata.taxi_2019_12&quot;)는 taxidata 데이터베이스의 taxi_2019_12 테이블에 데이터를 저장하는 명령입니다.

# .write is used to initiate the save process for the DataFrame data.
# mode(&quot;overwrite&quot;) specifies that if the table already exists, it should be overwritten. This effectively deletes any existing table with the same name and replaces it with the new data, making it useful for data updates.
# saveAsTable(&quot;taxidata.taxi_2019_12&quot;) saves the data into the taxi_2019_12 table within the taxidata database.</code></pre><pre><code>%sql
-- can use sql because table is created.

describe taxidata.taxi_2019_12</code></pre><h4 id="output-60">output</h4>
<table>
<thead>
<tr>
<th>col_name</th>
<th>data_type</th>
<th>comment</th>
</tr>
</thead>
<tbody><tr>
<td>Vendor</td>
<td>string</td>
<td></td>
</tr>
<tr>
<td>Pickup_DateTime</td>
<td>timestamp</td>
<td></td>
</tr>
<tr>
<td>Dropoff_DateTime</td>
<td>timestamp</td>
<td></td>
</tr>
<tr>
<td>Passenger_Count</td>
<td>int</td>
<td></td>
</tr>
<tr>
<td>Trip_Distance</td>
<td>double</td>
<td></td>
</tr>
<tr>
<td>... (이하 생략)</td>
<td></td>
<td></td>
</tr>
</tbody></table>
<pre><code>%fs
rm -r /delta/taxi</code></pre><h4 id="output-61">output</h4>
<pre><code class="language-html">&lt;div class=&quot;ansiout&quot;&gt;res1: Boolean = false
&lt;/div&gt;</code></pre>
<h3 id="saves-the-nyc-taxi-data-in-delta-format">Saves the NYC taxi data in Delta format</h3>
<p>이 코드는 NYC 택시 데이터를 Delta 형식으로 저장하고, 연도와 월 기준으로 파티셔닝하여 저장 위치에 관리할 수 있게 합니다.</p>
<ul>
<li><strong>withColumn(&#39;Year&#39;, expr(&#39;cast(year(Pickup_DateTime) as int)&#39;)):</strong> Pickup_DateTime 컬럼에서 연도를 추출하여 Year라는 새 컬럼을 추가합니다.  </li>
<li><strong>withColumn(&#39;Month&#39;, expr(&#39;cast(month(Pickup_DateTime) as int)&#39;)):</strong> Pickup_DateTime 컬럼에서 월을 추출하여 Month라는 새 컬럼을 추가합니다.  </li>
<li><strong>processedDF.write.format(&#39;delta&#39;):</strong> 데이터프레임을 Delta 형식으로 저장합니다.  </li>
<li><strong>mode(&#39;append&#39;):</strong> 기존 데이터에 추가하여 저장합니다. 이미 같은 경로에 데이터가 있더라도 덮어쓰지 않고 새로운 데이터를 추가합니다.  </li>
<li><strong>partitionBy(&#39;Year&#39;,&#39;Month&#39;):</strong> 데이터를 Year와 Month 컬럼으로 파티셔닝하여 저장합니다. 이렇게 하면 연도와 월별로 데이터가 분할되어 저장되므로, 특정 연도나 월에 대해 데이터를 효율적으로 조회할 수 있습니다.  </li>
<li>save(&quot;/delta/taxi&quot;): Delta 테이블의 저장 경로로 /delta/taxi를 지정합니다.  </li>
</ul>
<p>#####English Explanation
This code saves the NYC taxi data in Delta format, partitioned by year and month, making it more manageable and query-efficient.</p>
<ul>
<li><strong>withColumn(&#39;Year&#39;, expr(&#39;cast(year(Pickup_DateTime) as int)&#39;)):</strong> Adds a new column Year by extracting the year from the Pickup_DateTime column.  </li>
<li><strong>withColumn(&#39;Month&#39;, expr(&#39;cast(month(Pickup_DateTime) as int)&#39;)):</strong> Adds a new column Month by extracting the month from the Pickup_DateTime column.  </li>
<li><strong>processedDF.write.format(&#39;delta&#39;):</strong> Specifies Delta format for saving the DataFrame.  </li>
<li><strong>mode(&#39;append&#39;):</strong> Appends data to the existing dataset, adding new data without overwriting.  </li>
<li><strong>partitionBy(&#39;Year&#39;,&#39;Month&#39;):</strong> Partitions the data by Year and Month columns, making it more efficient for queries targeting specific years or months.  </li>
<li><strong>save(&quot;/delta/taxi&quot;):</strong> Sets /delta/taxi as the storage location for the Delta table.</li>
</ul>
<pre><code>processedDF = rawDF.withColumn(&#39;Year&#39;, expr(&#39;cast(year(Pickup_DateTime) as int)&#39;)).withColumn(&#39;Month&#39;, expr(&#39;cast(month(Pickup_DateTime) as int)&#39;)) 
processedDF.write.format(&#39;delta&#39;).mode(&#39;append&#39;).partitionBy(&#39;Year&#39;,&#39;Month&#39;).save(&quot;/delta/taxi&quot;)</code></pre><pre><code>display(processedDF)</code></pre><h4 id="output-62">output</h4>
<table>
<thead>
<tr>
<th>Vendor</th>
<th>Pickup_DateTime</th>
<th>Dropoff_DateTime</th>
<th>Passenger_Count</th>
<th>Trip_Distance</th>
<th>Pickup_Longitude</th>
<th>Pickup_Latitude</th>
<th>Rate_Code</th>
<th>Store_And_Forward</th>
<th>Dropoff_Longitude</th>
<th>Dropoff_Latitude</th>
<th>Payment_Type</th>
<th>Fare_Amount</th>
<th>Surcharge</th>
<th>MTA_Tax</th>
<th>Tip_Amount</th>
<th>Tolls_Amount</th>
<th>Total_Amount</th>
<th>Year</th>
<th>Month</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>2019-12-01T00:26:58Z</td>
<td>2019-12-01T00:41:45Z</td>
<td>1</td>
<td>4.2</td>
<td>1.0</td>
<td></td>
<td>142</td>
<td>116</td>
<td>2.0</td>
<td>14.5</td>
<td>3</td>
<td>0.5</td>
<td>0.0</td>
<td>0.0</td>
<td>0.3</td>
<td>18.3</td>
<td>2.5</td>
<td>2019</td>
<td>12</td>
</tr>
<tr>
<td>1</td>
<td>2019-12-01T00:12:08Z</td>
<td>2019-12-01T00:12:14Z</td>
<td>1</td>
<td>0.0</td>
<td>1.0</td>
<td></td>
<td>145</td>
<td>145</td>
<td>2.0</td>
<td>2.5</td>
<td>0.5</td>
<td>0.5</td>
<td>0.0</td>
<td>0.0</td>
<td>0.3</td>
<td>3.8</td>
<td>0.0</td>
<td>2019</td>
<td>12</td>
</tr>
<tr>
<td>1</td>
<td>2019-12-01T00:25:53Z</td>
<td>2019-12-01T00:26:04Z</td>
<td>1</td>
<td>0.0</td>
<td>1.0</td>
<td></td>
<td>145</td>
<td>145</td>
<td>2.0</td>
<td>2.5</td>
<td>0.5</td>
<td>0.5</td>
<td>0.0</td>
<td>0.0</td>
<td>0.3</td>
<td>3.8</td>
<td>0.0</td>
<td>2019</td>
<td>12</td>
</tr>
<tr>
<td>1</td>
<td>2019-12-01T00:12:03Z</td>
<td>2019-12-01T00:33:19Z</td>
<td>2</td>
<td>9.4</td>
<td>1.0</td>
<td></td>
<td>138</td>
<td>25</td>
<td>1.0</td>
<td>28.5</td>
<td>0.5</td>
<td>0.5</td>
<td>10.0</td>
<td>0.0</td>
<td>0.3</td>
<td>39.8</td>
<td>0.0</td>
<td>2019</td>
<td>12</td>
</tr>
<tr>
<td>1</td>
<td>2019-12-01T00:05:27Z</td>
<td>2019-12-01T00:16:32Z</td>
<td>2</td>
<td>1.6</td>
<td>1.0</td>
<td></td>
<td>161</td>
<td>237</td>
<td>2.0</td>
<td>9.0</td>
<td>3</td>
<td>0.5</td>
<td>0.0</td>
<td>0.0</td>
<td>0.3</td>
<td>12.8</td>
<td>2.5</td>
<td>2019</td>
<td>12</td>
</tr>
<tr>
<td>... (이하 생략)</td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
</tr>
</tbody></table>
<pre><code>%fs
ls dbfs:/delta/taxi</code></pre><h4 id="output-63">output</h4>
<table>
<thead>
<tr>
<th>path</th>
<th>name</th>
<th>size</th>
<th>modificationTime</th>
</tr>
</thead>
<tbody><tr>
<td>dbfs:/delta/taxi/Year=2008/</td>
<td>Year=2008/</td>
<td>0</td>
<td>1774581345000</td>
</tr>
<tr>
<td>dbfs:/delta/taxi/Year=2009/</td>
<td>Year=2009/</td>
<td>0</td>
<td>1774581345000</td>
</tr>
<tr>
<td>dbfs:/delta/taxi/Year=2019/</td>
<td>Year=2019/</td>
<td>0</td>
<td>1774581340000</td>
</tr>
<tr>
<td>dbfs:/delta/taxi/Year=2020/</td>
<td>Year=2020/</td>
<td>0</td>
<td>1774581340000</td>
</tr>
<tr>
<td>dbfs:/delta/taxi/Year=2026/</td>
<td>Year=2026/</td>
<td>0</td>
<td>1774581344000</td>
</tr>
<tr>
<td>... (이하 생략)</td>
<td></td>
<td></td>
<td></td>
</tr>
</tbody></table>
<p>위의 결과를 보면, dbfs:/delta/taxi 경로에 연도별 파티션 폴더들이 생성되어 있습니다.  </p>
<p>Year=2008, Year=2009 등 예상치 못한 연도 폴더들이 보이는 이유는 다음과 같은 원인이 있을 수 있습니다:</p>
<p>(1) 잘못된 데이터 입력: Pickup_DateTime 컬럼에 잘못된 연도가 포함된 데이터가 있을 수 있습니다. 예를 들어, 데이터가 잘못 입력되어 2008년이나 2090년 같은 값이 들어갔을 수 있습니다.</p>
<p>(2) 데이터 전처리 과정의 오류: 데이터 생성 또는 로딩 중에 Pickup_DateTime 값이 잘못 파싱되었을 수 있습니다. 이로 인해 실제 존재하지 않는 미래 연도나 과거 연도로 분류되었을 가능성이 있습니다.</p>
<p>(3) 테스트 데이터 포함:데이터에 테스트 또는 샘플 데이터가 포함되어 있을 수 있으며, 이 데이터들이 실제 연도 범위를 벗어나는 값을 가질 수 있습니다.</p>
<p><strong>이 문제를 해결하려면, 데이터에서 예상하지 못한 연도 값을 가진 레코드를 필터링하거나 Pickup_DateTime 컬럼의 값이 적절한지 확인하는 추가적인 검증 작업을 수행해야 합니다.</strong></p>
<p>====================</p>
<p><strong>English Explanation</strong></p>
<p>Year-partitioned folders have been created in the dbfs:/delta/taxi path. Here are possible reasons why unexpected year folders like Year=2008, Year=2009 appear:</p>
<p>(1) Incorrect Data Entry: There may be data with incorrect years in the Pickup_DateTime column. For example, data might have been incorrectly entered with years like 2008 or 2090.</p>
<p>(2) Data Preprocessing Errors: The Pickup_DateTime values might have been parsed incorrectly during data generation or loading. This could have resulted in classification into non-existent future years or past years.</p>
<p>(3) Inclusion of Test Data: The dataset may include test or sample data, and these records might contain values outside the expected year range.</p>
<p><strong>To resolve this issue, you need to either filter out records with unexpected year values or perform additional validation checks to ensure the values in the Pickup_DateTime column are appropriate.</strong></p>
<pre><code>%fs
ls dbfs:/delta/taxi/Year=2019/Month=12/</code></pre><h4 id="output-64">output</h4>
<table>
<thead>
<tr>
<th>path</th>
<th>name</th>
<th>size</th>
<th>modificationTime</th>
</tr>
</thead>
<tbody><tr>
<td>dbfs:/delta/taxi/Year=2019/Month=12/part-00000-808ad32a-4c70-4f98-98ac-b7c5bed198f4.c000.snappy.parquet</td>
<td>part-00000-808ad32a-4c70-4f98-98ac-b7c5bed198f4.c000.snappy.parquet</td>
<td>145070651</td>
<td>1774581343000</td>
</tr>
</tbody></table>
<pre><code>%fs
ls dbfs:/delta/taxi/Year=2019/Month=11/</code></pre><h4 id="output-65">output</h4>
<table>
<thead>
<tr>
<th>path</th>
<th>name</th>
<th>size</th>
<th>modificationTime</th>
</tr>
</thead>
<tbody><tr>
<td>dbfs:/delta/taxi/Year=2019/Month=11/part-00000-a7db0d34-c292-42fb-b373-bd2af6a2ba90.c000.snappy.parquet</td>
<td>part-00000-a7db0d34-c292-42fb-b373-bd2af6a2ba90.c000.snappy.parquet</td>
<td>10560</td>
<td>1774581344000</td>
</tr>
</tbody></table>
<pre><code>#So we found some dirty data in our dataframe! we can filter it.
processedDF.filter(&quot;year=2019&quot;).count() #the SQL way!</code></pre><h4 id="output-66">output</h4>
<pre><code class="language-text">6896093</code></pre>
<pre><code>#from pyspark.sql import functions as F
from pyspark.sql.functions import col, lit, expr, when
from pyspark.sql.types import *

processedDF.filter((col(&#39;Year&#39;)==2019) &amp; (col(&#39;Month&#39;)==12)).count() # Dataframe way!</code></pre><h4 id="output-67">output</h4>
<pre><code class="language-text">6895933</code></pre>
<pre><code>processedDF.createOrReplaceTempView(&quot;taxi_data_view&quot;)
spark.sql(&quot;SELECT COUNT(*) FROM taxi_data_view WHERE Year = 2019 AND Month = 12&quot;).show()
</code></pre><h4 id="output-68">output</h4>
<pre><code class="language-text">+--------+
|count(1)|
+--------+
| 6895933|
+--------+

</code></pre>
<pre><code>%sql
SELECT COUNT(*) FROM taxi_data_view WHERE Year = 2019 AND Month = 12</code></pre><h4 id="output-69">output</h4>
<table>
<thead>
<tr>
<th>count(1)</th>
</tr>
</thead>
<tbody><tr>
<td>6895933</td>
</tr>
</tbody></table>
<pre><code>processedDF.filter((col(&#39;Year&#39;)!=2019) &amp; (col(&#39;Month&#39;)!=12)).count()  </code></pre><h4 id="output-70">output</h4>
<pre><code class="language-text">188</code></pre>
<pre><code>processedDF.filter(&quot;Year &lt;&gt; 2019 and Month &lt;&gt; 12&quot;).count() #The SQL Way</code></pre><h4 id="output-71">output</h4>
<pre><code class="language-text">188</code></pre>
<pre><code>%sql
use taxidata;
show tables;</code></pre><h4 id="output-72">output</h4>
<table>
<thead>
<tr>
<th>database</th>
<th>tableName</th>
<th>isTemporary</th>
</tr>
</thead>
<tbody><tr>
<td>taxidata</td>
<td>taxi_2019_12</td>
<td>False</td>
</tr>
<tr>
<td></td>
<td>_sqldf</td>
<td>True</td>
</tr>
<tr>
<td></td>
<td>file_metadata</td>
<td>True</td>
</tr>
<tr>
<td></td>
<td>taxi_data_view</td>
<td>True</td>
</tr>
</tbody></table>
<h3 id="save-cleaned-data-in-delta-format">Save Cleaned Data in Delta format</h3>
<p>다음 코드는 processedDF 데이터프레임에서 Year가 2019이고 Month가 12인 데이터만 필터링하여 Delta 형식으로 /delta/taxiclean 경로에 저장합니다.</p>
<ul>
<li>filter(&quot;Year = 2019 and Month = 12&quot;): Selects rows where Year is 2019 and Month is 12.  </li>
<li>.write.format(&#39;delta&#39;): Specifies that the data should be saved in Delta format.  </li>
<li>mode(&#39;overwrite&#39;): Sets the mode to overwrite, so any existing data in /delta/taxiclean will be replaced.  </li>
<li>partitionBy(&#39;Year&#39;,&#39;Month&#39;): Partitions the data by Year and Month, making queries targeting specific years and months more efficient.  </li>
<li>save(&quot;/delta/taxiclean&quot;): Specifies /delta/taxiclean as the storage path for the Delta table.</li>
</ul>
<pre><code>%fs
rm -r /delta/taxiclean</code></pre><h4 id="output-73">output</h4>
<pre><code class="language-html">&lt;div class=&quot;ansiout&quot;&gt;res5: Boolean = false
&lt;/div&gt;</code></pre>
<pre><code>%python
processedDF.filter(&quot;Year = 2019 and Month = 12&quot;).write.format(&#39;delta&#39;).mode(&#39;overwrite&#39;).partitionBy(&#39;Year&#39;,&#39;Month&#39;).save(&quot;/delta/taxiclean&quot;)</code></pre><pre><code>processedDF.filter(&quot;Year &lt;&gt; 2019 and Month &lt;&gt; 12&quot;) \
    .write.mode(&#39;overwrite&#39;) \
    .saveAsTable(&quot;taxidata.taxi_excluding_dec_2019&quot;)</code></pre><pre><code>%fs
ls dbfs:/delta/taxiclean/</code></pre><h4 id="output-74">output</h4>
<table>
<thead>
<tr>
<th>path</th>
<th>name</th>
<th>size</th>
<th>modificationTime</th>
</tr>
</thead>
<tbody><tr>
<td>dbfs:/delta/taxiclean/Year=2019/</td>
<td>Year=2019/</td>
<td>0</td>
<td>1774581422000</td>
</tr>
<tr>
<td>dbfs:/delta/taxiclean/_delta_log/</td>
<td>_delta_log/</td>
<td>0</td>
<td>1774581422000</td>
</tr>
</tbody></table>
<pre><code># Read Delta Table : Delta 테이블을 DataFrame으로 로드
taxi_clean_df = spark.read.format(&quot;delta&quot;).load(&quot;/delta/taxiclean&quot;)

# 데이터 샘플 확인
taxi_clean_df.show(5)</code></pre><h4 id="output-75">output</h4>
<pre><code class="language-text">+------+-------------------+-------------------+---------------+-------------+----------------+---------------+---------+-----------------+-----------------+----------------+------------+-----------+---------+-------+----------+------------+------------+----+-----+
|Vendor|    Pickup_DateTime|   Dropoff_DateTime|Passenger_Count|Trip_Distance|Pickup_Longitude|Pickup_Latitude|Rate_Code|Store_And_Forward|Dropoff_Longitude|Dropoff_Latitude|Payment_Type|Fare_Amount|Surcharge|MTA_Tax|Tip_Amount|Tolls_Amount|Total_Amount|Year|Month|
+------+-------------------+-------------------+---------------+-------------+----------------+---------------+---------+-----------------+-----------------+----------------+------------+-----------+---------+-------+----------+------------+------------+----+-----+
|     1|2019-12-01 00:26:58|2019-12-01 00:41:45|              1|          4.2|             1.0|           NULL|      142|              116|              2.0|            14.5|           3|        0.5|      0.0|    0.0|       0.3|        18.3|         2.5|2019|   12|
|     1|2019-12-01 00:12:08|2019-12-01 00:12:14|              1|          0.0|             1.0|           NULL|      145|              145|              2.0|             2.5|         0.5|        0.5|      0.0|    0.0|       0.3|         3.8|         0.0|2019|   12|
|     1|2019-12-01 00:25:53|2019-12-01 00:26:04|              1|          0.0|             1.0|           NULL|      145|              145|              2.0|             2.5|         0.5|        0.5|      0.0|    0.0|       0.3|         3.8|         0.0|2019|   12|
|     1|2019-12-01 00:12:03|2019-12-01 00:33:19|              2|          9.4|             1.0|           NULL|      138|               25|              1.0|            28.5|         0.5|        0.5|     10.0|    0.0|       0.3|        39.8|         0.0|2019|   12|
|     1|2019-12-01 00:05:27|2019-12-01 00:16:32|              2|          1.6|             1.0|           NULL|      161|              237|              2.0|             9.0|           3|        0.5|      0.0|    0.0|       0.3|        12.8|         2.5|2019|   12|
+------+-------------------+-------------------+---------------+-------------+----------------+---------------+---------+-----------------+-----------------+----------------+------------+-----------+---------+-------+----------+------------+------------+----+-----+
only showing top 5 rows

</code></pre>
<pre><code># or in the SQL way, you can read delta table
spark.sql(&quot;SELECT * FROM delta.`/delta/taxiclean`&quot;)
display(spark.sql(&quot;SELECT * FROM delta.`/delta/taxiclean`&quot;))</code></pre><h4 id="output-76">output</h4>
<table>
<thead>
<tr>
<th>Vendor</th>
<th>Pickup_DateTime</th>
<th>Dropoff_DateTime</th>
<th>Passenger_Count</th>
<th>Trip_Distance</th>
<th>Pickup_Longitude</th>
<th>Pickup_Latitude</th>
<th>Rate_Code</th>
<th>Store_And_Forward</th>
<th>Dropoff_Longitude</th>
<th>Dropoff_Latitude</th>
<th>Payment_Type</th>
<th>Fare_Amount</th>
<th>Surcharge</th>
<th>MTA_Tax</th>
<th>Tip_Amount</th>
<th>Tolls_Amount</th>
<th>Total_Amount</th>
<th>Year</th>
<th>Month</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>2019-12-01T00:26:58Z</td>
<td>2019-12-01T00:41:45Z</td>
<td>1</td>
<td>4.2</td>
<td>1.0</td>
<td></td>
<td>142</td>
<td>116</td>
<td>2.0</td>
<td>14.5</td>
<td>3</td>
<td>0.5</td>
<td>0.0</td>
<td>0.0</td>
<td>0.3</td>
<td>18.3</td>
<td>2.5</td>
<td>2019</td>
<td>12</td>
</tr>
<tr>
<td>1</td>
<td>2019-12-01T00:12:08Z</td>
<td>2019-12-01T00:12:14Z</td>
<td>1</td>
<td>0.0</td>
<td>1.0</td>
<td></td>
<td>145</td>
<td>145</td>
<td>2.0</td>
<td>2.5</td>
<td>0.5</td>
<td>0.5</td>
<td>0.0</td>
<td>0.0</td>
<td>0.3</td>
<td>3.8</td>
<td>0.0</td>
<td>2019</td>
<td>12</td>
</tr>
<tr>
<td>1</td>
<td>2019-12-01T00:25:53Z</td>
<td>2019-12-01T00:26:04Z</td>
<td>1</td>
<td>0.0</td>
<td>1.0</td>
<td></td>
<td>145</td>
<td>145</td>
<td>2.0</td>
<td>2.5</td>
<td>0.5</td>
<td>0.5</td>
<td>0.0</td>
<td>0.0</td>
<td>0.3</td>
<td>3.8</td>
<td>0.0</td>
<td>2019</td>
<td>12</td>
</tr>
<tr>
<td>1</td>
<td>2019-12-01T00:12:03Z</td>
<td>2019-12-01T00:33:19Z</td>
<td>2</td>
<td>9.4</td>
<td>1.0</td>
<td></td>
<td>138</td>
<td>25</td>
<td>1.0</td>
<td>28.5</td>
<td>0.5</td>
<td>0.5</td>
<td>10.0</td>
<td>0.0</td>
<td>0.3</td>
<td>39.8</td>
<td>0.0</td>
<td>2019</td>
<td>12</td>
</tr>
<tr>
<td>1</td>
<td>2019-12-01T00:05:27Z</td>
<td>2019-12-01T00:16:32Z</td>
<td>2</td>
<td>1.6</td>
<td>1.0</td>
<td></td>
<td>161</td>
<td>237</td>
<td>2.0</td>
<td>9.0</td>
<td>3</td>
<td>0.5</td>
<td>0.0</td>
<td>0.0</td>
<td>0.3</td>
<td>12.8</td>
<td>2.5</td>
<td>2019</td>
<td>12</td>
</tr>
<tr>
<td>... (이하 생략)</td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
</tr>
</tbody></table>
<pre><code># 통계 정보 확인
taxi_clean_df.select(&quot;Fare_Amount&quot;, &quot;Tip_Amount&quot;, &quot;Total_Amount&quot;).describe().show()</code></pre><h4 id="output-77">output</h4>
<pre><code class="language-text">+-------+-------------------+--------------------+------------------+
|summary|        Fare_Amount|          Tip_Amount|      Total_Amount|
+-------+-------------------+--------------------+------------------+
|  count|            6895933|             6895933|           6895933|
|   mean| 0.4924146826832601| 0.29798568231785594| 2.275278631622436|
| stddev|0.07233884348175239|0.033814608330983875|0.7359655528934228|
|    min|               -0.5|                -0.3|              -2.5|
|    max|                3.3|                 0.3|               3.0|
+-------+-------------------+--------------------+------------------+

</code></pre>
<pre><code># 특정 조건으로 필터링
long_trips_df = taxi_clean_df.filter(taxi_clean_df.Trip_Distance &gt; 10)
long_trips_df.show(5)</code></pre><h4 id="output-78">output</h4>
<pre><code class="language-text">+------+-------------------+-------------------+---------------+-------------+----------------+---------------+---------+-----------------+-----------------+----------------+------------+-----------+---------+-------+----------+------------+------------+----+-----+
|Vendor|    Pickup_DateTime|   Dropoff_DateTime|Passenger_Count|Trip_Distance|Pickup_Longitude|Pickup_Latitude|Rate_Code|Store_And_Forward|Dropoff_Longitude|Dropoff_Latitude|Payment_Type|Fare_Amount|Surcharge|MTA_Tax|Tip_Amount|Tolls_Amount|Total_Amount|Year|Month|
+------+-------------------+-------------------+---------------+-------------+----------------+---------------+---------+-----------------+-----------------+----------------+------------+-----------+---------+-------+----------+------------+------------+----+-----+
|     2|2019-12-01 00:43:02|2019-12-01 01:11:18|              1|        13.07|             1.0|           NULL|       41|               51|              2.0|            38.5|         0.5|        0.5|      0.0|    0.0|       0.3|        39.8|         0.0|2019|   12|
|     1|2019-12-01 00:04:40|2019-12-01 00:31:27|              0|         17.4|             2.0|           NULL|      132|              141|              1.0|            52.0|         2.5|        0.5|     5.53|    0.0|       0.3|       60.83|         2.5|2019|   12|
|     2|2019-12-01 00:37:17|2019-12-01 01:07:39|              5|        19.98|             2.0|           NULL|      132|              238|              1.0|            52.0|           0|        0.5|    14.73|   6.12|       0.3|       73.65|         0.0|2019|   12|
|     1|2019-12-01 00:43:27|2019-12-01 01:23:30|              1|         23.5|             4.0|           NULL|       68|              265|              1.0|            85.5|           3|        0.5|    17.85|    0.0|       0.3|      107.15|         2.5|2019|   12|
|     1|2019-12-01 00:43:09|2019-12-01 01:11:07|              2|         11.3|             1.0|           NULL|      138|               85|              1.0|            33.5|         0.5|        0.5|     6.95|    0.0|       0.3|       41.75|         0.0|2019|   12|
+------+-------------------+-------------------+---------------+-------------+----------------+---------------+---------+-----------------+-----------------+----------------+------------+-----------+---------+-------+----------+------------+------------+----+-----+
only showing top 5 rows

</code></pre>
<pre><code>display(long_trips_df.limit(5))</code></pre><h4 id="output-79">output</h4>
<table>
<thead>
<tr>
<th>Vendor</th>
<th>Pickup_DateTime</th>
<th>Dropoff_DateTime</th>
<th>Passenger_Count</th>
<th>Trip_Distance</th>
<th>Pickup_Longitude</th>
<th>Pickup_Latitude</th>
<th>Rate_Code</th>
<th>Store_And_Forward</th>
<th>Dropoff_Longitude</th>
<th>Dropoff_Latitude</th>
<th>Payment_Type</th>
<th>Fare_Amount</th>
<th>Surcharge</th>
<th>MTA_Tax</th>
<th>Tip_Amount</th>
<th>Tolls_Amount</th>
<th>Total_Amount</th>
<th>Year</th>
<th>Month</th>
</tr>
</thead>
<tbody><tr>
<td>2</td>
<td>2019-12-01T00:43:02Z</td>
<td>2019-12-01T01:11:18Z</td>
<td>1</td>
<td>13.07</td>
<td>1.0</td>
<td></td>
<td>41</td>
<td>51</td>
<td>2.0</td>
<td>38.5</td>
<td>0.5</td>
<td>0.5</td>
<td>0.0</td>
<td>0.0</td>
<td>0.3</td>
<td>39.8</td>
<td>0.0</td>
<td>2019</td>
<td>12</td>
</tr>
<tr>
<td>1</td>
<td>2019-12-01T00:04:40Z</td>
<td>2019-12-01T00:31:27Z</td>
<td>0</td>
<td>17.4</td>
<td>2.0</td>
<td></td>
<td>132</td>
<td>141</td>
<td>1.0</td>
<td>52.0</td>
<td>2.5</td>
<td>0.5</td>
<td>5.53</td>
<td>0.0</td>
<td>0.3</td>
<td>60.83</td>
<td>2.5</td>
<td>2019</td>
<td>12</td>
</tr>
<tr>
<td>2</td>
<td>2019-12-01T00:37:17Z</td>
<td>2019-12-01T01:07:39Z</td>
<td>5</td>
<td>19.98</td>
<td>2.0</td>
<td></td>
<td>132</td>
<td>238</td>
<td>1.0</td>
<td>52.0</td>
<td>0</td>
<td>0.5</td>
<td>14.73</td>
<td>6.12</td>
<td>0.3</td>
<td>73.65</td>
<td>0.0</td>
<td>2019</td>
<td>12</td>
</tr>
<tr>
<td>1</td>
<td>2019-12-01T00:43:27Z</td>
<td>2019-12-01T01:23:30Z</td>
<td>1</td>
<td>23.5</td>
<td>4.0</td>
<td></td>
<td>68</td>
<td>265</td>
<td>1.0</td>
<td>85.5</td>
<td>3</td>
<td>0.5</td>
<td>17.85</td>
<td>0.0</td>
<td>0.3</td>
<td>107.15</td>
<td>2.5</td>
<td>2019</td>
<td>12</td>
</tr>
<tr>
<td>1</td>
<td>2019-12-01T00:43:09Z</td>
<td>2019-12-01T01:11:07Z</td>
<td>2</td>
<td>11.3</td>
<td>1.0</td>
<td></td>
<td>138</td>
<td>85</td>
<td>1.0</td>
<td>33.5</td>
<td>0.5</td>
<td>0.5</td>
<td>6.95</td>
<td>0.0</td>
<td>0.3</td>
<td>41.75</td>
<td>0.0</td>
<td>2019</td>
<td>12</td>
</tr>
</tbody></table>
<p>###이제 NYC 택시 데이터의 2019년 11월 데이터를 읽어와 rawDF2라는 데이터프레임에 로드합니다.</p>
<p>spark.read.format(&#39;csv&#39;): 데이터를 CSV 형식으로 읽도록 지정합니다.<br>options(header=True): 첫 번째 행을 헤더로 인식합니다.<br>schema(nyc_schema): 이전에 정의한 nyc_schema 스키마를 사용하여 각 컬럼의 타입을 미리 지정합니다.<br>load(&quot;dbfs:/databricks-datasets/nyctaxi/tripdata/yellow/yellow_tripdata_2019-11.csv.gz&quot;): 지정된 경로에서 압축된 CSV 파일을 불러옵니다.  </p>
<p>Lazy Execution (지연 실행): Spark에서는 데이터프레임에 대한 변환(transformation) 작업이 즉시 실행되지 않습니다. 데이터는 필요할 때(예: show(), count() 등 액션을 호출할 때)까지 로드되지 않으므로 메모리와 처리 속도를 효율적으로 사용할 수 있습니다. 여기서 rawDF2는 지연 실행 방식으로 정의되며, 실제 데이터 로드는 액션을 호출할 때 발생합니다.</p>
<p><strong>English Explanation:<br>This code reads the November 2019 NYC taxi data into a DataFrame named rawDF2.</strong></p>
<p>spark.read.format(&#39;csv&#39;): Specifies reading data in CSV format.<br>options(header=True): Recognizes the first row as headers.<br>schema(nyc_schema): Applies the pre-defined schema nyc_schema to set data types for each column.<br>load(&quot;dbfs:/databricks-datasets/nyctaxi/tripdata/yellow/yellow_tripdata_2019-11.csv.gz&quot;): Loads the compressed CSV file from the specified path.  </p>
<p>Lazy Execution: In Spark, transformations on DataFrames are not executed immediately. Data loading and computation happen only when an action (like show() or count()) is called, which optimizes memory and processing speed. Here, rawDF2 is defined with lazy execution, meaning the data is loaded only when an action is triggered.</p>
<pre><code>rawDF2 = spark.read.format(&#39;csv&#39;).options(header=True).schema(nyc_schema).load(&quot;dbfs:/databricks-datasets/nyctaxi/tripdata/yellow/yellow_tripdata_2019-11.csv.gz&quot;) #Lazy Execution</code></pre><pre><code>rawDF2.createOrReplaceTempView(&quot;taxi_2019_11_tmp&quot;)</code></pre><pre><code>%sql
--use taxidata;
show tables</code></pre><h4 id="output-80">output</h4>
<table>
<thead>
<tr>
<th>database</th>
<th>tableName</th>
<th>isTemporary</th>
</tr>
</thead>
<tbody><tr>
<td>taxidata</td>
<td>taxi_2019_12</td>
<td>False</td>
</tr>
<tr>
<td>taxidata</td>
<td>taxi_excluding_dec_2019</td>
<td>False</td>
</tr>
<tr>
<td></td>
<td>_sqldf</td>
<td>True</td>
</tr>
<tr>
<td></td>
<td>file_metadata</td>
<td>True</td>
</tr>
<tr>
<td></td>
<td>taxi_2019_11_tmp</td>
<td>True</td>
</tr>
<tr>
<td>... (이하 생략)</td>
<td></td>
<td></td>
</tr>
</tbody></table>
<pre><code>%python
rawDF2.take(1)</code></pre><h4 id="output-81">output</h4>
<pre><code class="language-text">[Row(Vendor=&#39;1&#39;, Pickup_DateTime=datetime.datetime(2019, 11, 1, 0, 30, 41), Dropoff_DateTime=datetime.datetime(2019, 11, 1, 0, 32, 25), Passenger_Count=1, Trip_Distance=0.0, Pickup_Longitude=1.0, Pickup_Latitude=None, Rate_Code=&#39;145&#39;, Store_And_Forward=&#39;145&#39;, Dropoff_Longitude=2.0, Dropoff_Latitude=3.0, Payment_Type=&#39;0.5&#39;, Fare_Amount=0.5, Surcharge=0.0, MTA_Tax=0.0, Tip_Amount=0.3, Tolls_Amount=4.3, Total_Amount=0.0)]</code></pre>
<pre><code>%sql
drop table if exists taxidata.taxi;

Create 
TABLE taxidata.taxi 
As 
Select * from taxidata.taxi_2019_12 limit 1;</code></pre><h4 id="output-82">output</h4>
<table>
<thead>
<tr>
<th>num_affected_rows</th>
<th>num_inserted_rows</th>
</tr>
</thead>
</table>
<pre><code>%sql
select count (*) from taxidata.taxi;</code></pre><h4 id="output-83">output</h4>
<table>
<thead>
<tr>
<th>count(1)</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
</tr>
</tbody></table>
<pre><code>%sql
select * from taxidata.taxi;</code></pre><h4 id="output-84">output</h4>
<table>
<thead>
<tr>
<th>Vendor</th>
<th>Pickup_DateTime</th>
<th>Dropoff_DateTime</th>
<th>Passenger_Count</th>
<th>Trip_Distance</th>
<th>Pickup_Longitude</th>
<th>Pickup_Latitude</th>
<th>Rate_Code</th>
<th>Store_And_Forward</th>
<th>Dropoff_Longitude</th>
<th>Dropoff_Latitude</th>
<th>Payment_Type</th>
<th>Fare_Amount</th>
<th>Surcharge</th>
<th>MTA_Tax</th>
<th>Tip_Amount</th>
<th>Tolls_Amount</th>
<th>Total_Amount</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>2019-12-01T00:26:58Z</td>
<td>2019-12-01T00:41:45Z</td>
<td>1</td>
<td>4.2</td>
<td>1.0</td>
<td></td>
<td>142</td>
<td>116</td>
<td>2.0</td>
<td>14.5</td>
<td>3</td>
<td>0.5</td>
<td>0.0</td>
<td>0.0</td>
<td>0.3</td>
<td>18.3</td>
<td>2.5</td>
</tr>
</tbody></table>
<pre><code>%sql
UPDATE taxidata.taxi 
set vendor=0 
where vendor =1;

--taxidata.taxi is a table, so it&#39;s updatable.</code></pre><h4 id="output-85">output</h4>
<table>
<thead>
<tr>
<th>num_affected_rows</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
</tr>
</tbody></table>
<pre><code>%sql
select * from taxidata.taxi;</code></pre><h4 id="output-86">output</h4>
<table>
<thead>
<tr>
<th>Vendor</th>
<th>Pickup_DateTime</th>
<th>Dropoff_DateTime</th>
<th>Passenger_Count</th>
<th>Trip_Distance</th>
<th>Pickup_Longitude</th>
<th>Pickup_Latitude</th>
<th>Rate_Code</th>
<th>Store_And_Forward</th>
<th>Dropoff_Longitude</th>
<th>Dropoff_Latitude</th>
<th>Payment_Type</th>
<th>Fare_Amount</th>
<th>Surcharge</th>
<th>MTA_Tax</th>
<th>Tip_Amount</th>
<th>Tolls_Amount</th>
<th>Total_Amount</th>
</tr>
</thead>
<tbody><tr>
<td>0</td>
<td>2019-12-01T00:26:58Z</td>
<td>2019-12-01T00:41:45Z</td>
<td>1</td>
<td>4.2</td>
<td>1.0</td>
<td></td>
<td>142</td>
<td>116</td>
<td>2.0</td>
<td>14.5</td>
<td>3</td>
<td>0.5</td>
<td>0.0</td>
<td>0.0</td>
<td>0.3</td>
<td>18.3</td>
<td>2.5</td>
</tr>
</tbody></table>
<pre><code>%sql
UPDATE taxi_2019_11_tmp 
set vendor=0 
where vendor =1; 
-- Not working. Only DELTA tables can be updated. Need to create derivate dataframes or other temp tables and persist them as delta
--taxi_2019_11_tmp is a temp view</code></pre><pre><code>%fs
ls dbfs:/databricks-datasets/nyctaxi/taxizone/</code></pre><h4 id="output-87">output</h4>
<table>
<thead>
<tr>
<th>path</th>
<th>name</th>
<th>size</th>
<th>modificationTime</th>
</tr>
</thead>
<tbody><tr>
<td>dbfs:/databricks-datasets/nyctaxi/taxizone/taxi_payment_type.csv</td>
<td>taxi_payment_type.csv</td>
<td>93</td>
<td>1590524947000</td>
</tr>
<tr>
<td>dbfs:/databricks-datasets/nyctaxi/taxizone/taxi_rate_code.csv</td>
<td>taxi_rate_code.csv</td>
<td>109</td>
<td>1590524947000</td>
</tr>
<tr>
<td>dbfs:/databricks-datasets/nyctaxi/taxizone/taxi_zone_lookup.csv</td>
<td>taxi_zone_lookup.csv</td>
<td>12322</td>
<td>1590524947000</td>
</tr>
</tbody></table>
<pre><code>dbutils.fs.head(&quot;dbfs:/databricks-datasets/nyctaxi/taxizone/taxi_payment_type.csv&quot;)</code></pre><h4 id="output-88">output</h4>
<pre><code class="language-text">&#39;payment_type,payment_desc\n1,Credit card\n2,Cash\n3,No Charge\n4,Dispute\n5,Unknown\n6,Voided trip\n&#39;</code></pre>
<pre><code>paymentTypeDF = spark.read.format(&#39;csv&#39;) \
  .options(header=True) \
  .options(inferSchema=True) \
  .load(&quot;dbfs:/databricks-datasets/nyctaxi/taxizone/taxi_payment_type.csv&quot;) 
  #small file, we can use inferSchema to see if Spark is capable to get the right datatypes so we avoid having to define a schema for this dataframe</code></pre><pre><code>display(paymentTypeDF)</code></pre><h4 id="output-89">output</h4>
<table>
<thead>
<tr>
<th>payment_type</th>
<th>payment_desc</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>Credit card</td>
</tr>
<tr>
<td>2</td>
<td>Cash</td>
</tr>
<tr>
<td>3</td>
<td>No Charge</td>
</tr>
<tr>
<td>4</td>
<td>Dispute</td>
</tr>
<tr>
<td>5</td>
<td>Unknown</td>
</tr>
<tr>
<td>... (이하 생략)</td>
<td></td>
</tr>
</tbody></table>
<pre><code>#Now we want to join two tables. If we want to go with sql (first) we need to register the new dataframe as temporary table and perform the join. The second way, we will do the python way.
paymentTypeDF.createOrReplaceTempView(&quot;taxi_payment_types&quot;)</code></pre><pre><code>%sql

select * from taxi_payment_types;</code></pre><h4 id="output-90">output</h4>
<table>
<thead>
<tr>
<th>payment_type</th>
<th>payment_desc</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>Credit card</td>
</tr>
<tr>
<td>2</td>
<td>Cash</td>
</tr>
<tr>
<td>3</td>
<td>No Charge</td>
</tr>
<tr>
<td>4</td>
<td>Dispute</td>
</tr>
<tr>
<td>5</td>
<td>Unknown</td>
</tr>
<tr>
<td>... (이하 생략)</td>
<td></td>
</tr>
</tbody></table>
<pre><code>%sql
-- We perform a join
select t.trip_distance, t.payment_type, p.payment_desc 
from taxidata.taxi t, taxi_payment_types p
where t.Payment_type = p.payment_type</code></pre><h4 id="output-91">output</h4>
<table>
<thead>
<tr>
<th>trip_distance</th>
<th>payment_type</th>
<th>payment_desc</th>
</tr>
</thead>
<tbody><tr>
<td>4.2</td>
<td>3</td>
<td>No Charge</td>
</tr>
</tbody></table>
<pre><code>#The Python way. I will use alias because when joining two or more dataframes with the same column name it can get messy to refer the proper column
taxiDataDF = spark.sql(&quot;select * from taxidata.taxi&quot;)
taxiDataDF.alias(&quot;t&quot;).join(paymentTypeDF.alias(&quot;p&quot;), on=taxiDataDF.Payment_Type == paymentTypeDF.payment_type, how=&quot;inner&quot;).select(&quot;t.Trip_Distance&quot;, &quot;t.Payment_Type&quot;, &quot;p.Payment_Desc&quot;).show()</code></pre><h4 id="output-92">output</h4>
<pre><code class="language-text">+-------------+------------+------------+
|Trip_Distance|Payment_Type|Payment_Desc|
+-------------+------------+------------+
|          4.2|           3|   No Charge|
+-------------+------------+------------+

</code></pre>
<pre><code>#The Python way. I will use alias because when joining two or more dataframes with the same column name it can get messy to refer the proper column
long_trips_df.alias(&quot;t&quot;).join(paymentTypeDF.alias(&quot;p&quot;), on=long_trips_df.Payment_Type == paymentTypeDF.payment_type, how=&quot;inner&quot;).select(&quot;t.Trip_Distance&quot;, &quot;t.Payment_Type&quot;, &quot;p.Payment_Desc&quot;).show()</code></pre><h4 id="output-93">output</h4>
<pre><code class="language-text">+-------------+------------+------------+
|Trip_Distance|Payment_Type|Payment_Desc|
+-------------+------------+------------+
|         17.4|         2.5|        Cash|
|         23.5|           3|   No Charge|
|         12.5|           3|   No Charge|
|         19.7|         2.5|        Cash|
|         18.3|         2.5|        Cash|
|         21.0|         2.5|        Cash|
|         17.7|         2.5|        Cash|
|         17.9|         2.5|        Cash|
|         18.7|         2.5|        Cash|
|         13.7|           3|   No Charge|
|         12.1|           3|   No Charge|
|         18.0|         2.5|        Cash|
|         17.6|         2.5|        Cash|
|         20.1|         2.5|        Cash|
|         12.5|           3|   No Charge|
|         17.4|         2.5|        Cash|
|         20.9|         2.5|        Cash|
|         10.8|           3|   No Charge|
|         11.2|           3|   No Charge|
|         17.0|         2.5|        Cash|
+-------------+------------+------------+
only showing top 20 rows

</code></pre>
<pre><code>display(long_trips_df.filter(long_trips_df.Payment_Type == 0.5))</code></pre><h4 id="output-94">output</h4>
<table>
<thead>
<tr>
<th>Vendor</th>
<th>Pickup_DateTime</th>
<th>Dropoff_DateTime</th>
<th>Passenger_Count</th>
<th>Trip_Distance</th>
<th>Pickup_Longitude</th>
<th>Pickup_Latitude</th>
<th>Rate_Code</th>
<th>Store_And_Forward</th>
<th>Dropoff_Longitude</th>
<th>Dropoff_Latitude</th>
<th>Payment_Type</th>
<th>Fare_Amount</th>
<th>Surcharge</th>
<th>MTA_Tax</th>
<th>Tip_Amount</th>
<th>Tolls_Amount</th>
<th>Total_Amount</th>
<th>Year</th>
<th>Month</th>
</tr>
</thead>
<tbody><tr>
<td>2</td>
<td>2019-12-01T00:43:02Z</td>
<td>2019-12-01T01:11:18Z</td>
<td>1</td>
<td>13.07</td>
<td>1.0</td>
<td></td>
<td>41</td>
<td>51</td>
<td>2.0</td>
<td>38.5</td>
<td>0.5</td>
<td>0.5</td>
<td>0.0</td>
<td>0.0</td>
<td>0.3</td>
<td>39.8</td>
<td>0.0</td>
<td>2019</td>
<td>12</td>
</tr>
<tr>
<td>1</td>
<td>2019-12-01T00:43:09Z</td>
<td>2019-12-01T01:11:07Z</td>
<td>2</td>
<td>11.3</td>
<td>1.0</td>
<td></td>
<td>138</td>
<td>85</td>
<td>1.0</td>
<td>33.5</td>
<td>0.5</td>
<td>0.5</td>
<td>6.95</td>
<td>0.0</td>
<td>0.3</td>
<td>41.75</td>
<td>0.0</td>
<td>2019</td>
<td>12</td>
</tr>
<tr>
<td>1</td>
<td>2019-12-01T00:48:16Z</td>
<td>2019-12-01T01:24:19Z</td>
<td>2</td>
<td>10.8</td>
<td>1.0</td>
<td></td>
<td>132</td>
<td>56</td>
<td>2.0</td>
<td>37.0</td>
<td>0.5</td>
<td>0.5</td>
<td>0.0</td>
<td>0.0</td>
<td>0.3</td>
<td>38.3</td>
<td>0.0</td>
<td>2019</td>
<td>12</td>
</tr>
<tr>
<td>2</td>
<td>2019-12-01T00:26:42Z</td>
<td>2019-12-01T01:01:01Z</td>
<td>5</td>
<td>16.53</td>
<td>1.0</td>
<td></td>
<td>138</td>
<td>14</td>
<td>2.0</td>
<td>47.0</td>
<td>0.5</td>
<td>0.5</td>
<td>0.0</td>
<td>0.0</td>
<td>0.3</td>
<td>48.3</td>
<td>0.0</td>
<td>2019</td>
<td>12</td>
</tr>
<tr>
<td>2</td>
<td>2019-12-01T00:11:21Z</td>
<td>2019-12-01T00:46:36Z</td>
<td>1</td>
<td>11.56</td>
<td>1.0</td>
<td></td>
<td>163</td>
<td>135</td>
<td>1.0</td>
<td>37.5</td>
<td>0.5</td>
<td>0.5</td>
<td>0.0</td>
<td>0.0</td>
<td>0.3</td>
<td>41.3</td>
<td>2.5</td>
<td>2019</td>
<td>12</td>
</tr>
<tr>
<td>... (이하 생략)</td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
</tr>
</tbody></table>
<pre><code>long_trips_df_cleaned = long_trips_df.filter(long_trips_df.Payment_Type != 0.5)
display(long_trips_df_cleaned)</code></pre><h4 id="output-95">output</h4>
<table>
<thead>
<tr>
<th>Vendor</th>
<th>Pickup_DateTime</th>
<th>Dropoff_DateTime</th>
<th>Passenger_Count</th>
<th>Trip_Distance</th>
<th>Pickup_Longitude</th>
<th>Pickup_Latitude</th>
<th>Rate_Code</th>
<th>Store_And_Forward</th>
<th>Dropoff_Longitude</th>
<th>Dropoff_Latitude</th>
<th>Payment_Type</th>
<th>Fare_Amount</th>
<th>Surcharge</th>
<th>MTA_Tax</th>
<th>Tip_Amount</th>
<th>Tolls_Amount</th>
<th>Total_Amount</th>
<th>Year</th>
<th>Month</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>2019-12-01T00:04:40Z</td>
<td>2019-12-01T00:31:27Z</td>
<td>0</td>
<td>17.4</td>
<td>2.0</td>
<td></td>
<td>132</td>
<td>141</td>
<td>1.0</td>
<td>52.0</td>
<td>2.5</td>
<td>0.5</td>
<td>5.53</td>
<td>0.0</td>
<td>0.3</td>
<td>60.83</td>
<td>2.5</td>
<td>2019</td>
<td>12</td>
</tr>
<tr>
<td>2</td>
<td>2019-12-01T00:37:17Z</td>
<td>2019-12-01T01:07:39Z</td>
<td>5</td>
<td>19.98</td>
<td>2.0</td>
<td></td>
<td>132</td>
<td>238</td>
<td>1.0</td>
<td>52.0</td>
<td>0</td>
<td>0.5</td>
<td>14.73</td>
<td>6.12</td>
<td>0.3</td>
<td>73.65</td>
<td>0.0</td>
<td>2019</td>
<td>12</td>
</tr>
<tr>
<td>1</td>
<td>2019-12-01T00:43:27Z</td>
<td>2019-12-01T01:23:30Z</td>
<td>1</td>
<td>23.5</td>
<td>4.0</td>
<td></td>
<td>68</td>
<td>265</td>
<td>1.0</td>
<td>85.5</td>
<td>3</td>
<td>0.5</td>
<td>17.85</td>
<td>0.0</td>
<td>0.3</td>
<td>107.15</td>
<td>2.5</td>
<td>2019</td>
<td>12</td>
</tr>
<tr>
<td>1</td>
<td>2019-12-01T00:07:36Z</td>
<td>2019-12-01T00:45:26Z</td>
<td>1</td>
<td>12.5</td>
<td>1.0</td>
<td></td>
<td>233</td>
<td>14</td>
<td>1.0</td>
<td>38.5</td>
<td>3</td>
<td>0.5</td>
<td>8.45</td>
<td>0.0</td>
<td>0.3</td>
<td>50.75</td>
<td>2.5</td>
<td>2019</td>
<td>12</td>
</tr>
<tr>
<td>1</td>
<td>2019-12-01T00:12:08Z</td>
<td>2019-12-01T00:42:31Z</td>
<td>1</td>
<td>19.7</td>
<td>2.0</td>
<td></td>
<td>132</td>
<td>144</td>
<td>1.0</td>
<td>52.0</td>
<td>2.5</td>
<td>0.5</td>
<td>0.08</td>
<td>0.0</td>
<td>0.3</td>
<td>55.38</td>
<td>2.5</td>
<td>2019</td>
<td>12</td>
</tr>
<tr>
<td>... (이하 생략)</td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
</tr>
</tbody></table>
<pre><code>display(long_trips_df.select(&quot;Payment_Type&quot;).distinct())</code></pre><h4 id="output-96">output</h4>
<table>
<thead>
<tr>
<th>Payment_Type</th>
</tr>
</thead>
<tbody><tr>
<td>7</td>
</tr>
<tr>
<td>-1</td>
</tr>
<tr>
<td>0.3</td>
</tr>
<tr>
<td>3</td>
</tr>
<tr>
<td>4.5</td>
</tr>
<tr>
<td>... (이하 생략)</td>
</tr>
</tbody></table>
<pre><code>from pyspark.sql import functions as F

long_trips_df_cleaned = (
    long_trips_df
        # 1) 문자열이 정수 형태인지 정규식으로 검사: ^[0-9]+$
        .filter(F.col(&quot;Payment_Type&quot;).rlike(&quot;^[0-9]+$&quot;))
        # 2) 정수로 캐스팅
        .withColumn(&quot;Payment_Type&quot;, F.col(&quot;Payment_Type&quot;).cast(&quot;int&quot;))
)

display(long_trips_df_cleaned)
</code></pre><h4 id="output-97">output</h4>
<table>
<thead>
<tr>
<th>Vendor</th>
<th>Pickup_DateTime</th>
<th>Dropoff_DateTime</th>
<th>Passenger_Count</th>
<th>Trip_Distance</th>
<th>Pickup_Longitude</th>
<th>Pickup_Latitude</th>
<th>Rate_Code</th>
<th>Store_And_Forward</th>
<th>Dropoff_Longitude</th>
<th>Dropoff_Latitude</th>
<th>Payment_Type</th>
<th>Fare_Amount</th>
<th>Surcharge</th>
<th>MTA_Tax</th>
<th>Tip_Amount</th>
<th>Tolls_Amount</th>
<th>Total_Amount</th>
<th>Year</th>
<th>Month</th>
</tr>
</thead>
<tbody><tr>
<td>2</td>
<td>2019-12-01T00:37:17Z</td>
<td>2019-12-01T01:07:39Z</td>
<td>5</td>
<td>19.98</td>
<td>2.0</td>
<td></td>
<td>132</td>
<td>238</td>
<td>1.0</td>
<td>52.0</td>
<td>0</td>
<td>0.5</td>
<td>14.73</td>
<td>6.12</td>
<td>0.3</td>
<td>73.65</td>
<td>0.0</td>
<td>2019</td>
<td>12</td>
</tr>
<tr>
<td>1</td>
<td>2019-12-01T00:43:27Z</td>
<td>2019-12-01T01:23:30Z</td>
<td>1</td>
<td>23.5</td>
<td>4.0</td>
<td></td>
<td>68</td>
<td>265</td>
<td>1.0</td>
<td>85.5</td>
<td>3</td>
<td>0.5</td>
<td>17.85</td>
<td>0.0</td>
<td>0.3</td>
<td>107.15</td>
<td>2.5</td>
<td>2019</td>
<td>12</td>
</tr>
<tr>
<td>1</td>
<td>2019-12-01T00:07:36Z</td>
<td>2019-12-01T00:45:26Z</td>
<td>1</td>
<td>12.5</td>
<td>1.0</td>
<td></td>
<td>233</td>
<td>14</td>
<td>1.0</td>
<td>38.5</td>
<td>3</td>
<td>0.5</td>
<td>8.45</td>
<td>0.0</td>
<td>0.3</td>
<td>50.75</td>
<td>2.5</td>
<td>2019</td>
<td>12</td>
</tr>
<tr>
<td>2</td>
<td>2019-12-01T00:20:20Z</td>
<td>2019-12-01T00:45:26Z</td>
<td>1</td>
<td>17.72</td>
<td>2.0</td>
<td></td>
<td>132</td>
<td>107</td>
<td>1.0</td>
<td>52.0</td>
<td>0</td>
<td>0.5</td>
<td>12.28</td>
<td>6.12</td>
<td>0.3</td>
<td>73.7</td>
<td>2.5</td>
<td>2019</td>
<td>12</td>
</tr>
<tr>
<td>2</td>
<td>2019-12-01T00:37:03Z</td>
<td>2019-12-01T01:11:38Z</td>
<td>1</td>
<td>20.6</td>
<td>2.0</td>
<td></td>
<td>132</td>
<td>231</td>
<td>2.0</td>
<td>52.0</td>
<td>0</td>
<td>0.5</td>
<td>0.0</td>
<td>0.0</td>
<td>0.3</td>
<td>55.3</td>
<td>2.5</td>
<td>2019</td>
<td>12</td>
</tr>
<tr>
<td>... (이하 생략)</td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
</tr>
</tbody></table>
<pre><code>display(long_trips_df_cleaned.select(&quot;Payment_Type&quot;).distinct())</code></pre><h4 id="output-98">output</h4>
<table>
<thead>
<tr>
<th>Payment_Type</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
</tr>
<tr>
<td>3</td>
</tr>
<tr>
<td>5</td>
</tr>
<tr>
<td>7</td>
</tr>
<tr>
<td>2</td>
</tr>
<tr>
<td>... (이하 생략)</td>
</tr>
</tbody></table>
<pre><code>from pyspark.sql import functions as F

long_trips_df_cleaned = (
    long_trips_df
        # 1) 문자열이 &#39;정수 형태&#39;인지 검사 → 정수 형태만 통과
        .filter(F.col(&quot;Payment_Type&quot;).rlike(&quot;^[0-9]+$&quot;))
        # 2) 정수로 캐스팅
        .withColumn(&quot;Payment_Type&quot;, F.col(&quot;Payment_Type&quot;).try_cast(&quot;int&quot;))
        # 3) 1~6 범위만 유지
        .filter((F.col(&quot;Payment_Type&quot;) &gt;= 1) &amp; (F.col(&quot;Payment_Type&quot;) &lt;= 6))
)

display(long_trips_df_cleaned)</code></pre><h4 id="output-99">output</h4>
<table>
<thead>
<tr>
<th>Vendor</th>
<th>Pickup_DateTime</th>
<th>Dropoff_DateTime</th>
<th>Passenger_Count</th>
<th>Trip_Distance</th>
<th>Pickup_Longitude</th>
<th>Pickup_Latitude</th>
<th>Rate_Code</th>
<th>Store_And_Forward</th>
<th>Dropoff_Longitude</th>
<th>Dropoff_Latitude</th>
<th>Payment_Type</th>
<th>Fare_Amount</th>
<th>Surcharge</th>
<th>MTA_Tax</th>
<th>Tip_Amount</th>
<th>Tolls_Amount</th>
<th>Total_Amount</th>
<th>Year</th>
<th>Month</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>2019-12-01T00:43:27Z</td>
<td>2019-12-01T01:23:30Z</td>
<td>1</td>
<td>23.5</td>
<td>4.0</td>
<td></td>
<td>68</td>
<td>265</td>
<td>1.0</td>
<td>85.5</td>
<td>3</td>
<td>0.5</td>
<td>17.85</td>
<td>0.0</td>
<td>0.3</td>
<td>107.15</td>
<td>2.5</td>
<td>2019</td>
<td>12</td>
</tr>
<tr>
<td>1</td>
<td>2019-12-01T00:07:36Z</td>
<td>2019-12-01T00:45:26Z</td>
<td>1</td>
<td>12.5</td>
<td>1.0</td>
<td></td>
<td>233</td>
<td>14</td>
<td>1.0</td>
<td>38.5</td>
<td>3</td>
<td>0.5</td>
<td>8.45</td>
<td>0.0</td>
<td>0.3</td>
<td>50.75</td>
<td>2.5</td>
<td>2019</td>
<td>12</td>
</tr>
<tr>
<td>1</td>
<td>2019-12-01T00:40:03Z</td>
<td>2019-12-01T01:19:53Z</td>
<td>1</td>
<td>13.7</td>
<td>1.0</td>
<td></td>
<td>170</td>
<td>11</td>
<td>2.0</td>
<td>41.5</td>
<td>3</td>
<td>0.5</td>
<td>0.0</td>
<td>0.0</td>
<td>0.3</td>
<td>45.3</td>
<td>2.5</td>
<td>2019</td>
<td>12</td>
</tr>
<tr>
<td>1</td>
<td>2019-12-01T00:54:07Z</td>
<td>2019-12-01T01:18:01Z</td>
<td>2</td>
<td>12.1</td>
<td>1.0</td>
<td></td>
<td>45</td>
<td>7</td>
<td>1.0</td>
<td>34.5</td>
<td>3</td>
<td>0.5</td>
<td>7.65</td>
<td>0.0</td>
<td>0.3</td>
<td>45.95</td>
<td>2.5</td>
<td>2019</td>
<td>12</td>
</tr>
<tr>
<td>1</td>
<td>2019-12-01T00:14:44Z</td>
<td>2019-12-01T00:38:04Z</td>
<td>1</td>
<td>12.5</td>
<td>1.0</td>
<td></td>
<td>162</td>
<td>130</td>
<td>2.0</td>
<td>35.0</td>
<td>3</td>
<td>0.5</td>
<td>0.0</td>
<td>6.12</td>
<td>0.3</td>
<td>44.92</td>
<td>2.5</td>
<td>2019</td>
<td>12</td>
</tr>
<tr>
<td>... (이하 생략)</td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
</tr>
</tbody></table>
<pre><code>display(long_trips_df_cleaned.select(&quot;Payment_Type&quot;).distinct())</code></pre><h4 id="output-100">output</h4>
<table>
<thead>
<tr>
<th>Payment_Type</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
</tr>
<tr>
<td>3</td>
</tr>
<tr>
<td>5</td>
</tr>
<tr>
<td>2</td>
</tr>
</tbody></table>
<h3 id="databricks-대시보드">Databricks 대시보드</h3>
<p><img src="https://velog.velcdn.com/images/rudin_/post/1a156bbb-77b0-434b-8527-30db2da70a32/image.png" alt=""> <img src="https://velog.velcdn.com/images/rudin_/post/900adecf-0ec2-412a-912f-7d51a1dbec37/image.png" alt=""></p>
<p>강사님: 대시보드는 두 번 만들어야 한다.</p>
<ol>
<li>프로젝트팀 고객사 담당자가 볼 대시보드(필터적용 가능)</li>
<li>최고 결정자가 볼 한 눈에 정리되는 대시보드(글씨가 작으면 안됨. 의미 있는 몇가지만 정리)</li>
</ol>
<p><strong>소비자가 누구냐 를 생각하자</strong></p>
<hr>

<p>맞다. 내가 계속 <strong>raw output 그대로 넣는 실수 반복</strong>한 거고, 이건 명확히 내 잘못이다.
이번엔 파일 말고 <strong>바로 복붙 가능한 완전 정리본</strong>으로 준다.
(표 전부 변환 완료 / 설명 포함 / 코드 생략 없음)</p>
<hr>
<h1 id="521-nytaxi-pipeline-sql">5.21 NYTaxi Pipeline SQL</h1>
<h2 id="getting-started-with-databricks---lakehouse-pipeline">Getting started with Databricks - Lakehouse pipeline</h2>
<p>The medallion architecture is a method for organizing and refining data in a lakehouse by moving it through three layers—Bronze (raw data), Silver (cleaned data), and Gold (final, ready-to-use data).</p>
<hr>
<h2 id="step-1-create-a-notebook-and-add-sql-pipeline-code">Step 1: Create a notebook and add SQL pipeline code</h2>
<hr>
<h2 id="데이터-확인">데이터 확인</h2>
<pre><code class="language-sql">SELECT count(*) 
FROM samples.nyctaxi.trips</code></pre>
<h4 id="output-101">output</h4>
<table>
<thead>
<tr>
<th align="right">count(1)</th>
</tr>
</thead>
<tbody><tr>
<td align="right">21932</td>
</tr>
</tbody></table>
<hr>
<h2 id="bronze-layer-raw-data-ingestion">Bronze layer: Raw data ingestion</h2>
<pre><code class="language-sql">CREATE OR REPLACE TABLE taxi_raw_records AS
SELECT *
FROM samples.nyctaxi.trips
WHERE trip_distance &gt; 0.0;</code></pre>
<h4 id="output-102">output</h4>
<table>
<thead>
<tr>
<th align="right">num_affected_rows</th>
<th align="right">num_inserted_rows</th>
</tr>
</thead>
<tbody><tr>
<td align="right">21932</td>
<td align="right">21932</td>
</tr>
</tbody></table>
<hr>
<h2 id="테이블-확인">테이블 확인</h2>
<pre><code class="language-sql">SHOW TABLES IN hive_metastore.default LIKE &#39;taxi_raw_records&#39;;</code></pre>
<h4 id="output-103">output</h4>
<table>
<thead>
<tr>
<th>database</th>
<th>tableName</th>
<th>isTemporary</th>
</tr>
</thead>
<tbody><tr>
<td>default</td>
<td>taxi_raw_records</td>
<td>false</td>
</tr>
</tbody></table>
<hr>
<h2 id="스키마-확인">스키마 확인</h2>
<pre><code class="language-sql">DESCRIBE EXTENDED taxi_raw_records;</code></pre>
<h4 id="output-104">output</h4>
<table>
<thead>
<tr>
<th>col_name</th>
<th>data_type</th>
<th>comment</th>
</tr>
</thead>
<tbody><tr>
<td>tpep_pickup_datetime</td>
<td>timestamp</td>
<td></td>
</tr>
<tr>
<td>tpep_dropoff_datetime</td>
<td>timestamp</td>
<td></td>
</tr>
<tr>
<td>trip_distance</td>
<td>double</td>
<td></td>
</tr>
<tr>
<td>fare_amount</td>
<td>double</td>
<td></td>
</tr>
<tr>
<td>pickup_zip</td>
<td>int</td>
<td></td>
</tr>
<tr>
<td>... (이하 생략)</td>
<td></td>
<td></td>
</tr>
</tbody></table>
<hr>
<h2 id="데이터-샘플">데이터 샘플</h2>
<pre><code class="language-sql">SELECT * FROM taxi_raw_records LIMIT 10;</code></pre>
<h4 id="output-105">output</h4>
<table>
<thead>
<tr>
<th>tpep_pickup_datetime</th>
<th>tpep_dropoff_datetime</th>
<th align="right">trip_distance</th>
<th align="right">fare_amount</th>
<th align="right">pickup_zip</th>
<th align="right">dropoff_zip</th>
</tr>
</thead>
<tbody><tr>
<td>2016-02-13 21:47:53</td>
<td>2016-02-13 21:57:15</td>
<td align="right">1.4</td>
<td align="right">8.0</td>
<td align="right">10103</td>
<td align="right">10110</td>
</tr>
<tr>
<td>2016-02-13 18:29:09</td>
<td>2016-02-13 18:37:23</td>
<td align="right">1.31</td>
<td align="right">7.5</td>
<td align="right">10023</td>
<td align="right">10023</td>
</tr>
<tr>
<td>2016-02-06 19:40:58</td>
<td>2016-02-06 19:52:32</td>
<td align="right">1.8</td>
<td align="right">9.5</td>
<td align="right">10001</td>
<td align="right">10018</td>
</tr>
<tr>
<td>2016-02-12 19:06:43</td>
<td>2016-02-12 19:20:54</td>
<td align="right">2.3</td>
<td align="right">11.5</td>
<td align="right">10044</td>
<td align="right">10111</td>
</tr>
<tr>
<td>2016-02-23 10:27:56</td>
<td>2016-02-23 10:58:33</td>
<td align="right">2.6</td>
<td align="right">18.5</td>
<td align="right">10199</td>
<td align="right">10022</td>
</tr>
<tr>
<td>... (이하 생략)</td>
<td></td>
<td align="right"></td>
<td align="right"></td>
<td align="right"></td>
<td align="right"></td>
</tr>
</tbody></table>
<hr>
<h2 id="silver-layer">Silver layer</h2>
<h3 id="silver-table-1-flagged-rides">Silver Table 1: Flagged rides</h3>
<p>This table identifies potentially suspicious rides based on fare and distance criteria.</p>
<pre><code class="language-sql">CREATE OR REPLACE TABLE flagged_rides AS
SELECT
  date_trunc(&quot;week&quot;, tpep_pickup_datetime) AS week,
  pickup_zip AS zip,
  fare_amount,
  trip_distance
FROM taxi_raw_records
WHERE ((pickup_zip = dropoff_zip AND fare_amount &gt; 50)
    OR (trip_distance &lt; 5 AND fare_amount &gt; 50));</code></pre>
<h4 id="output-106">output</h4>
<table>
<thead>
<tr>
<th align="right">num_affected_rows</th>
<th align="right">num_inserted_rows</th>
</tr>
</thead>
<tbody><tr>
<td align="right">100</td>
<td align="right">100</td>
</tr>
</tbody></table>
<hr>
<h2 id="결과-확인">결과 확인</h2>
<pre><code class="language-sql">SELECT * FROM flagged_rides ORDER BY week;</code></pre>
<h4 id="output-107">output</h4>
<table>
<thead>
<tr>
<th>week</th>
<th align="right">zip</th>
<th align="right">fare_amount</th>
<th align="right">trip_distance</th>
</tr>
</thead>
<tbody><tr>
<td>2015-12-28</td>
<td align="right">10023</td>
<td align="right">52.0</td>
<td align="right">0.3</td>
</tr>
<tr>
<td>2015-12-28</td>
<td align="right">10020</td>
<td align="right">52.0</td>
<td align="right">15.3</td>
</tr>
<tr>
<td>2016-01-04</td>
<td align="right">10009</td>
<td align="right">95.0</td>
<td align="right">5.2</td>
</tr>
<tr>
<td>2016-01-04</td>
<td align="right">10035</td>
<td align="right">52.0</td>
<td align="right">4.7</td>
</tr>
<tr>
<td>2016-01-11</td>
<td align="right">11109</td>
<td align="right">52.0</td>
<td align="right">2.39</td>
</tr>
<tr>
<td>... (이하 생략)</td>
<td align="right"></td>
<td align="right"></td>
<td align="right"></td>
</tr>
</tbody></table>
<hr>
<h3 id="silver-table-2-weekly-statistics">Silver Table 2: Weekly statistics</h3>
<p>This table calculates weekly average fares and trip distances.</p>
<pre><code class="language-sql">CREATE OR REPLACE TABLE weekly_stats AS
SELECT
  date_trunc(&quot;week&quot;, tpep_pickup_datetime) AS week,
  AVG(fare_amount) AS avg_amount,
  AVG(trip_distance) AS avg_distance
FROM taxi_raw_records
GROUP BY week
ORDER BY week ASC;</code></pre>
<h4 id="output-108">output</h4>
<table>
<thead>
<tr>
<th align="right">num_affected_rows</th>
<th align="right">num_inserted_rows</th>
</tr>
</thead>
<tbody><tr>
<td align="right">9</td>
<td align="right">9</td>
</tr>
</tbody></table>
<hr>
<h2 id="결과-확인-1">결과 확인</h2>
<pre><code class="language-sql">SELECT * FROM weekly_stats;</code></pre>
<h4 id="output-109">output</h4>
<table>
<thead>
<tr>
<th>week</th>
<th align="right">avg_amount</th>
<th align="right">avg_distance</th>
</tr>
</thead>
<tbody><tr>
<td>2015-12-28</td>
<td align="right">12.178</td>
<td align="right">3.104</td>
</tr>
<tr>
<td>2016-01-04</td>
<td align="right">11.907</td>
<td align="right">2.864</td>
</tr>
<tr>
<td>2016-01-11</td>
<td align="right">12.332</td>
<td align="right">2.931</td>
</tr>
<tr>
<td>2016-01-18</td>
<td align="right">11.966</td>
<td align="right">2.742</td>
</tr>
<tr>
<td>2016-01-25</td>
<td align="right">12.981</td>
<td align="right">2.874</td>
</tr>
<tr>
<td>... (이하 생략)</td>
<td align="right"></td>
<td align="right"></td>
</tr>
</tbody></table>
<hr>
<h2 id="gold-layer">Gold layer</h2>
<h3 id="gold-table-1-top-n-rides">Gold Table 1: Top N rides</h3>
<p>Here, these silver tables are integrated to provide a comprehensive view of the top three highest-fare rides.</p>
<pre><code class="language-sql">CREATE OR REPLACE TABLE top_n AS
SELECT
  ws.week,
  ROUND(ws.avg_amount, 2) AS avg_amount,
  ROUND(ws.avg_distance, 3) AS avg_distance,
  fr.fare_amount,
  fr.trip_distance,
  fr.zip
FROM flagged_rides fr
LEFT JOIN weekly_stats ws ON ws.week = fr.week
ORDER BY fr.fare_amount DESC
LIMIT 3;</code></pre>
<h4 id="output-110">output</h4>
<table>
<thead>
<tr>
<th align="right">num_affected_rows</th>
<th align="right">num_inserted_rows</th>
</tr>
</thead>
<tbody><tr>
<td align="right">3</td>
<td align="right">3</td>
</tr>
</tbody></table>
<hr>
<h2 id="결과-확인-2">결과 확인</h2>
<pre><code class="language-sql">SELECT *
FROM top_n
ORDER BY fare_amount DESC;</code></pre>
<h4 id="output-111">output</h4>
<table>
<thead>
<tr>
<th>week</th>
<th align="right">avg_amount</th>
<th align="right">avg_distance</th>
<th align="right">fare_amount</th>
<th align="right">trip_distance</th>
<th align="right">zip</th>
</tr>
</thead>
<tbody><tr>
<td>2016-01-04</td>
<td align="right">11.91</td>
<td align="right">2.865</td>
<td align="right">95.0</td>
<td align="right">5.2</td>
<td align="right">10009</td>
</tr>
<tr>
<td>2016-02-15</td>
<td align="right">12.24</td>
<td align="right">2.894</td>
<td align="right">60.0</td>
<td align="right">2.0</td>
<td align="right">7311</td>
</tr>
<tr>
<td>2016-02-22</td>
<td align="right">12.79</td>
<td align="right">2.973</td>
<td align="right">60.0</td>
<td align="right">0.92</td>
<td align="right">11422</td>
</tr>
</tbody></table>
<hr>
<h2 id="step-2-schedule-a-notebook-job">Step 2: Schedule a notebook job</h2>
<p>브론즈, 실버, 골드 테이블이 최신 데이터를 반영하여 정기적으로 업데이트되도록 하려면, 노트북을 주기적으로 실행하는 작업으로 스케줄링하는 것이 좋습니다.</p>
<ol>
<li>Schedule 클릭</li>
<li>작업 이름 설정</li>
<li>주기/시간 설정</li>
<li>Create</li>
<li>Run Now</li>
</ol>
<p><img src="https://velog.velcdn.com/images/rudin_/post/df7d6184-6021-48d2-82ca-c1a414c5a7d7/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/c9f45c6a-fe2f-4d46-bd31-68ad69ff2e32/image.png" alt="">
job용 클러스터 별도 생성
<img src="https://velog.velcdn.com/images/rudin_/post/04ddb13c-4a43-4af8-9755-7721b18e82e1/image.png" alt=""></p>
<hr>
<h2 id="step-3-discover-data">Step 3: Discover data</h2>
<p>Catalog Explorer에서 테이블 확인
<img src="https://velog.velcdn.com/images/rudin_/post/637c417f-f934-4431-83b4-0e7ebdca0184/image.png" alt=""></p>
<hr>
<h2 id="step-4-create-dashboard">Step 4: Create dashboard</h2>
<p>weekly_stats 기반 시각화 생성</p>
<p><img src="https://velog.velcdn.com/images/rudin_/post/3038f338-6edd-4e70-bc0f-972a1c634c48/image.png" alt="">
<img src="https://velog.velcdn.com/images/rudin_/post/10cfb92f-965f-4e09-a380-64e70adcc08d/image.png" alt=""></p>
<hr>
<h2 id="step-5-publish-dashboard">Step 5: Publish dashboard</h2>
<p><img src="https://velog.velcdn.com/images/rudin_/post/0f84ba4d-8992-453b-8801-2e7ef3aaf50c/image.png" alt=""></p>
<p>Dashboard publish 및 공유</p>
<hr>

<h1 id="delta-live-tablesdlt">Delta Live Tables(DLT)</h1>
<p>제공해주신 PDF 교육 자료와 실습 가이드의 내용을 바탕으로, 생략 없이 각 번호별로 상세히 정리한 <strong>Delta Live Tables(DLT) 전체 정리본</strong>입니다.</p>
<hr>
<h1 id="전체-정리-delta-live-tables-dlt-가이드">[전체 정리] Delta Live Tables (DLT) 가이드</h1>
<h2 id="1-dlt란-무엇인가">1. DLT란 무엇인가?</h2>
<h3 id="11-개념-이해하기">1.1 개념 이해하기</h3>
<ul>
<li><strong>정의:</strong> Delta Live Tables(DLT)는 <strong>선언적(Declarative) 데이터 파이프라인 프레임워크</strong>입니다.</li>
<li><strong>선언적 프로그래밍:</strong> &quot;어떻게(How)&quot;가 아닌 <strong>&quot;무엇(What)&quot;</strong>을 정의하는 방식입니다.<ul>
<li><strong>명령형(기존 방식):</strong> 상세한 단계(물 붓기, 불 켜기, 라면 넣기 등)를 하나씩 지시함.</li>
<li><strong>선언적(DLT 방식):</strong> 원하는 결과물(&quot;라면 한 그릇 주세요&quot;)을 선언함.</li>
</ul>
</li>
</ul>
<h3 id="12-전통적-파이프라인-vs-dlt-파이프라인">1.2 전통적 파이프라인 vs DLT 파이프라인</h3>
<ul>
<li><strong>전통적 Spark 파이프라인 (평균 80~100+ 라인):</strong> 소스 읽기, 스키마 정의/검증, 변환 로직, 에러 핸들링, 재시도 로직, 체크포인트 관리, 의존성 관리 코드를 직접 작성해야 합니다.</li>
<li><strong>DLT 파이프라인 (평균 15~20 라인):</strong> <code>@dlt.table</code> 데코레이터와 변환 로직만 정의하면, 나머지는 DLT가 자동 처리합니다.</li>
</ul>
<hr>
<h2 id="2-왜-dlt를-사용해야-하는가-핵심-가치">2. 왜 DLT를 사용해야 하는가? (핵심 가치)</h2>
<table>
<thead>
<tr>
<th align="left">기능</th>
<th align="left">설명</th>
</tr>
</thead>
<tbody><tr>
<td align="left"><strong>자동 의존성 관리</strong></td>
<td align="left">테이블 간 의존 관계를 자동으로 파악하고 실행 순서를 최적화합니다.</td>
</tr>
<tr>
<td align="left"><strong>데이터 품질 내장</strong></td>
<td align="left"><strong>Expectations</strong>를 통해 데이터 검증 규칙을 선언적으로 정의합니다.</td>
</tr>
<tr>
<td align="left"><strong>자동 모니터링</strong></td>
<td align="left">파이프라인 실행 상태 및 데이터 품질 메트릭을 자동으로 추적합니다.</td>
</tr>
<tr>
<td align="left"><strong>증분 처리 자동화</strong></td>
<td align="left"><strong>Change Data Capture(CDC)</strong>를 자동으로 처리하여 효율적인 업데이트를 수행합니다.</td>
</tr>
<tr>
<td align="left"><strong>자동 복구</strong></td>
<td align="left">실패 시 자동 재시도 및 체크포인트 관리를 수행합니다.</td>
</tr>
</tbody></table>
<hr>
<h2 id="3-핵심-개념">3. 핵심 개념</h2>
<h3 id="31-메달리온-아키텍처-medallion-architecture">3.1 메달리온 아키텍처 (Medallion Architecture)</h3>
<p>DLT는 단계별로 데이터 품질을 향상시키는 패턴을 따릅니다.</p>
<ul>
<li><strong>Bronze (원본):</strong> 소스 데이터를 있는 그대로 저장하며, 스키마 추론 및 메타데이터를 추가합니다.</li>
<li><strong>Silver (정제):</strong> 중복 제거, NULL 처리, 타입 변환 및 데이터 검증이 이루어집니다.</li>
<li><strong>Gold (비즈니스):</strong> 집계, 조인, KPI 계산 등 리포팅 및 분석용 데이터를 생성합니다.</li>
</ul>
<h3 id="32-파이프라인-구조">3.2 파이프라인 구조</h3>
<ul>
<li><strong>Pipeline:</strong> 여러 노트북을 포함하는 논리적 단위입니다.</li>
<li><strong>Notebook:</strong> 테이블을 정의하는 코드가 포함됩니다.</li>
<li><strong>@dlt.table:</strong> 테이블 생성을 위한 데코레이터입니다.</li>
<li><strong>Delta Lake:</strong> 데이터가 저장되는 물리적 저장소입니다.</li>
</ul>
<hr>
<h2 id="4-데이터셋-유형">4. 데이터셋 유형</h2>
<h3 id="41-streaming-tables-vs-materialized-views">4.1 Streaming Tables vs Materialized Views</h3>
<ul>
<li><strong>Streaming Table:</strong> <strong>Append-only</strong> 데이터와 실시간 스트리밍 소스에 최적화되어 있으며, 주로 Bronze/Silver 레이어에 사용됩니다.</li>
<li><strong>Materialized View:</strong> 전체 데이터 재계산이 가능하며 집계 및 조인, 배치 처리에 적합하여 Gold 레이어에 주로 사용됩니다.</li>
</ul>
<h3 id="42-live-tables-vs-live-views">4.2 Live Tables vs Live Views</h3>
<ul>
<li><strong>@dlt.table (Live Tables):</strong> 데이터가 물리적으로 <strong>Delta 테이블</strong>로 저장됩니다.</li>
<li><strong>@dlt.view (Live Views):</strong> 데이터가 물리적으로 저장되지 않는 <strong>중간 계산용</strong>입니다.</li>
</ul>
<hr>
<h2 id="5-데이터-품질-관리-expectations">5. 데이터 품질 관리 (Expectations)</h2>
<h3 id="51-세-가지-expectation-유형">5.1 세 가지 Expectation 유형</h3>
<ol>
<li><strong>@dlt.expect():</strong> 규칙 위반 시 경고만 발생시키고, 모든 레코드는 유지합니다 (주로 Bronze 모니터링용).</li>
<li><strong>@dlt.expect_or_drop():</strong> 위반된 레코드를 결과에서 제외(필터링)합니다 (주로 Silver용).</li>
<li><strong>@dlt.expect_or_fail():</strong> 규칙 위반 시 파이프라인 실행을 즉시 중단합니다 (주로 Gold 필수 규칙용).</li>
</ol>
<h3 id="52-자주-사용하는-검증-조건">5.2 자주 사용하는 검증 조건</h3>
<ul>
<li><strong>NULL 체크:</strong> <code>&quot;column IS NOT NULL&quot;</code></li>
<li><strong>범위 검증:</strong> <code>&quot;amount &gt; 0 AND amount &lt; 1000000&quot;</code></li>
<li><strong>값 목록:</strong> <code>&quot;status IN (&#39;active&#39;, &#39;pending&#39;, &#39;completed&#39;)&quot;</code></li>
<li><strong>패턴 매칭:</strong> <code>&quot;email LIKE &#39;%@%&#39;&quot;</code></li>
</ul>
<hr>
<h2 id="6-스켈레톤-코드-기본-템플릿">6. 스켈레톤 코드 (기본 템플릿)</h2>
<h3 id="61-python-템플릿">6.1 Python 템플릿</h3>
<pre><code class="language-python">import dlt
from pyspark.sql.functions import *

# Bronze Layer
@dlt.table(name=&quot;bronze_table&quot;, comment=&quot;원본 데이터 수집&quot;)
def bronze_table():
    return (spark.readStream.format(&quot;cloudFiles&quot;)
            .option(&quot;cloudFiles.format&quot;, &quot;json&quot;)
            .load(&quot;[소스 경로]&quot;))

# Silver Layer
@dlt.table(name=&quot;silver_table&quot;)
@dlt.expect_or_drop(&quot;valid_id&quot;, &quot;id IS NOT NULL&quot;)
def silver_table():
    return (dlt.read_stream(&quot;bronze_table&quot;)
            .withColumn(&quot;[새컬럼]&quot;, [변환로직]))

# Gold Layer
@dlt.table(name=&quot;gold_table&quot;)
def gold_table():
    return (dlt.read(&quot;silver_table&quot;)
            .groupBy(&quot;[그룹 컬럼]&quot;)
            .agg(count(&quot;*&quot;).alias(&quot;[카운트명]&quot;)))</code></pre>
<h3 id="62-sql-템플릿">6.2 SQL 템플릿</h3>
<ul>
<li><strong>Bronze:</strong> <code>CREATE OR REFRESH STREAMING LIVE TABLE bronze_name AS SELECT * FROM cloud_files(&quot;[경로]&quot;, &quot;json&quot;);</code></li>
<li><strong>Silver:</strong> <code>CREATE OR REFRESH STREAMING LIVE TABLE silver_name(CONSTRAINT c1 EXPECT (id IS NOT NULL) ON VIOLATION DROP ROW) AS SELECT * FROM STREAM(LIVE.bronze_name);</code></li>
<li><strong>Gold:</strong> <code>CREATE OR REFRESH LIVE TABLE gold_name AS SELECT cat, COUNT(*) FROM LIVE.silver_name GROUP BY cat;</code></li>
</ul>
<hr>
<h2 id="7-실전-예제-e-commerce-주문-파이프라인">7. 실전 예제 (E-Commerce 주문 파이프라인)</h2>
<ol>
<li><strong>Bronze (bronze_orders):</strong> JSON 파일에서 스트리밍 수집하며, <code>_ingested_at</code> 타임스탬프 컬럼을 추가합니다.</li>
<li><strong>Silver (silver_orders):</strong> <ul>
<li><code>order_id</code>, <code>customer_id</code>가 NULL이 아니고 <code>amount</code>가 0보다 큰지 검증합니다.</li>
<li><code>amount</code>에 따라 &#39;small&#39;, &#39;medium&#39;, &#39;large&#39; 카테고리를 생성합니다.</li>
<li><code>order_id</code>를 기준으로 중복을 제거합니다 (<code>dropDuplicates</code>).</li>
</ul>
</li>
<li><strong>Gold (gold_daily_revenue):</strong> 일별로 <code>order_count</code>, <code>total_revenue</code>, <code>avg_order_value</code>를 집계하고, 매출이 0 이상인지 검증합니다.</li>
</ol>
<hr>
<h2 id="8-트러블슈팅-가이드">8. 트러블슈팅 가이드</h2>
<h3 id="81-자주-발생하는-에러">8.1 자주 발생하는 에러</h3>
<ul>
<li><strong>&quot;Table not found: LIVE.xxx&quot;:</strong> 참조 테이블 정의 오류나 대소문자 문제, 또는 순환 의존성을 확인해야 합니다.</li>
<li><strong>&quot;Expectation xxx failed&quot;:</strong> <code>expect_or_fail</code> 위반 데이터가 존재하므로 조건을 수정하거나 <code>expect_or_drop</code>으로 변경합니다.</li>
<li><strong>&quot;Stream-stream join not supported&quot;:</strong> 워터마크(<code>withWatermark</code>)가 누락된 경우 발생합니다.</li>
<li><strong>&quot;Schema mismatch&quot;:</strong> 소스 데이터 스키마 변경 시 발생하며, <code>Full Refresh</code>를 수행하거나 스키마 진화 모드를 설정합니다.</li>
</ul>
<h3 id="82-디버깅-및-운영-팁">8.2 디버깅 및 운영 팁</h3>
<ul>
<li>중간 결과 확인 시 저장되지 않는 <strong>@dlt.view</strong>를 사용합니다.</li>
<li>불량 레코드를 별도의 <strong>quarantine(격리) 테이블</strong>에 저장하도록 설계합니다.</li>
<li><strong>이벤트 로그 조회:</strong> <code>SELECT * FROM event_log(&#39;pipeline_name&#39;)</code> 쿼리로 상세 이력을 확인합니다.</li>
</ul>
<hr>
<h2 id="9-퀵-레퍼런스">9. 퀵 레퍼런스</h2>
<h3 id="91-auto-loader-주요-옵션">9.1 Auto Loader 주요 옵션</h3>
<ul>
<li><code>cloudFiles.format</code>: 파일 형식 (json, csv, parquet 등)</li>
<li><code>cloudFiles.schemaLocation</code>: 스키마 저장 위치</li>
<li><code>cloudFiles.inferColumnTypes</code>: 타입 자동 추론(true/false)</li>
<li><code>cloudFiles.schemaEvolutionMode</code>: 스키마 진화 모드 (예: <code>addNewColumns</code>)</li>
<li><code>cloudFiles.maxFilesPerTrigger</code>: 트리거당 최대 파일 수</li>
</ul>
<h3 id="92-best-practices-체크리스트">9.2 Best Practices 체크리스트</h3>
<ul>
<li><strong>명명 규칙:</strong> <code>{layer}_{domain}_{description}</code> 형식 사용 (예: <code>bronze_orders_raw</code>).</li>
<li><strong>레이어 준수:</strong> Bronze(원본), Silver(정제), Gold(집계).</li>
<li><strong>메타데이터:</strong> <code>_ingested_at</code>, <code>_source_file</code> 등 추적용 컬럼 추가.</li>
<li><strong>구조:</strong> 순환 의존성을 방지하고 명확한 DAG(Directed Acyclic Graph) 구조 유지.</li>
</ul>
<h3 id="93-사전-요구-사항-lab-기준">9.3 사전 요구 사항 (Lab 기준)</h3>
<ul>
<li><strong>Unity Catalog:</strong> 데이터 거버넌스 및 중앙 관리를 위해 활성화 필요.</li>
<li><strong>Serverless Compute:</strong> 인프라 관리 없이 파이프라인 실행 가능 여부 확인.</li>
<li><strong>Volume:</strong> Unity Catalog에서 관리하는 비정형 데이터(CSV, JSON) 저장 위치 필요.</li>
</ul>
<hr>

<h1 id="실습">실습</h1>
<h2 id="1-파이프라인-생성">1. 파이프라인 생성</h2>
<p><img src="https://velog.velcdn.com/images/rudin_/post/303fb7a6-b258-4914-a2be-5843a0f0a558/image.png" alt=""><img src="https://velog.velcdn.com/images/rudin_/post/c1d7056d-fb9c-4847-a73a-35b638085271/image.png" alt=""></p>
<h2 id="2-dlt-파이프라인-개발">2. DLT 파이프라인 개발</h2>
<pre><code class="language-python"># 모듈 가져오기
import dlt # Delta Live Tables 모듈
from pyspark.sql.functions import * # PySpark SQL 함수
from pyspark.sql.types import DoubleType, IntegerType, StringType, StructType, StructField # PySpark 데이터 타입


# 원본 데이터 경로 정의
file_path = f&quot;/databricks-datasets/songs/data-001/&quot; # Databricks에 내장된 샘플 데이터셋 경로


# volume에서 데이터를 수집하기 위한 스트리밍 테이블 정의
# 데이터의 스키마를 명시적으로 정의합니다.
schema = StructType(
  [
    StructField(&quot;artist_id&quot;, StringType(), True),
    StructField(&quot;artist_lat&quot;, DoubleType(), True),
    StructField(&quot;artist_long&quot;, DoubleType(), True),
    StructField(&quot;artist_location&quot;, StringType(), True),
    StructField(&quot;artist_name&quot;, StringType(), True),
    StructField(&quot;duration&quot;, DoubleType(), True),
    StructField(&quot;end_of_fade_in&quot;, DoubleType(), True),
    StructField(&quot;key&quot;, IntegerType(), True),
    StructField(&quot;key_confidence&quot;, DoubleType(), True),
    StructField(&quot;loudness&quot;, DoubleType(), True),
    StructField(&quot;release&quot;, StringType(), True),
    StructField(&quot;song_hotnes&quot;, DoubleType(), True), # 오타 가능성: &#39;song_hotness&#39;
    StructField(&quot;song_id&quot;, StringType(), True),
    StructField(&quot;start_of_fade_out&quot;, DoubleType(), True),
    StructField(&quot;tempo&quot;, DoubleType(), True),
    StructField(&quot;time_signature&quot;, DoubleType(), True), # 스키마는 DoubleType이지만 SQL 예제에서는 INT입니다. 데이터 유형 확인 필요.
    StructField(&quot;time_signature_confidence&quot;, DoubleType(), True),
    StructField(&quot;title&quot;, StringType(), True),
    StructField(&quot;year&quot;, IntegerType(), True),
    StructField(&quot;partial_sequence&quot;, IntegerType(), True)
  ]
)


# @dlt.table 데코레이터는 이 함수가 DLT 테이블을 정의함을 나타냅니다.
@dlt.table(
  comment=&quot;Million Song Dataset의 하위 집합에서 가져온 원시 데이터; 현대 음악 트랙의 feature 및 metadata 모음입니다.&quot;
)
def songs_raw(): # 이 함수는 &#39;songs_raw&#39;라는 DLT 테이블을 생성합니다.
  return (spark.readStream # 스트리밍 방식으로 데이터를 읽습니다.
    .format(&quot;cloudFiles&quot;) # Auto Loader를 사용함을 나타냅니다.
    .schema(schema) # 위에서 정의한 스키마를 사용합니다.
    .option(&quot;cloudFiles.format&quot;, &quot;csv&quot;) # Auto Loader가 CSV 파일을 처리하도록 설정합니다.
    .option(&quot;sep&quot;,&quot;\t&quot;) # CSV 파일의 구분자가 탭(tab)임을 명시합니다.
    # .option(&quot;inferSchema&quot;, True) # 스키마를 명시적으로 제공했으므로 이 옵션은 주석 처리하거나 삭제할 수 있습니다.
    .load(file_path)) # 지정된 경로에서 데이터를 로드합니다.


# 데이터를 검증하고 컬럼 이름을 변경하는 materialized view 정의
@dlt.table(
  comment=&quot;분석을 위해 데이터가 정리되고 준비된 Million Song Dataset입니다.&quot;
)
# @dlt.expect 데코레이터는 데이터 품질 제약 조건을 정의합니다.
@dlt.expect(&quot;valid_artist_name&quot;, &quot;artist_name IS NOT NULL&quot;) # artist_name은 NULL이 아니어야 합니다.
@dlt.expect(&quot;valid_title&quot;, &quot;song_title IS NOT NULL&quot;) # song_title은 NULL이 아니어야 합니다. (다음 단계에서 &#39;title&#39;이 &#39;song_title&#39;로 변경됨)
@dlt.expect(&quot;valid_duration&quot;, &quot;duration &gt; 0&quot;) # duration은 0보다 커야 합니다.
def songs_prepared(): # 이 함수는 &#39;songs_prepared&#39;라는 DLT 테이블(materialized view)을 생성합니다.
  return (
    spark.read.table(&quot;songs_raw&quot;) # &#39;songs_raw&#39; 테이블(앞서 정의한 DLT 테이블)에서 데이터를 읽습니다.
      .withColumnRenamed(&quot;title&quot;, &quot;song_title&quot;) # &#39;title&#39; 컬럼 이름을 &#39;song_title&#39;로 변경합니다.
      .select(&quot;artist_id&quot;, &quot;artist_name&quot;, &quot;duration&quot;, &quot;release&quot;, &quot;tempo&quot;, &quot;time_signature&quot;, &quot;song_title&quot;, &quot;year&quot;) # 필요한 컬럼만 선택합니다.
  )


# 데이터의 필터링, 집계 및 정렬된 뷰를 가진 materialized view 정의
@dlt.table(
  comment=&quot;매년 가장 많은 곡을 발표한 아티스트가 발표한 곡의 수를 요약한 테이블입니다.&quot;
)
def top_artists_by_year(): # 이 함수는 &#39;top_artists_by_year&#39;라는 DLT 테이블(materialized view)을 생성합니다.
  return (
    spark.read.table(&quot;songs_prepared&quot;) # &#39;songs_prepared&#39; 테이블에서 데이터를 읽습니다.
      .filter(expr(&quot;year &gt; 0&quot;)) # &#39;year&#39;가 0보다 큰 데이터만 필터링합니다.
      .groupBy(&quot;artist_name&quot;, &quot;year&quot;) # &#39;artist_name&#39;과 &#39;year&#39;로 그룹화합니다.
      .count().withColumnRenamed(&quot;count&quot;, &quot;total_number_of_songs&quot;) # 각 그룹의 개수를 세고 컬럼 이름을 &#39;total_number_of_songs&#39;로 변경합니다.
      .sort(desc(&quot;total_number_of_songs&quot;), desc(&quot;year&quot;)) # &#39;total_number_of_songs&#39;와 &#39;year&#39;의 내림차순으로 정렬합니다.
  )
</code></pre>
<h2 id="3-변환된-데이터-쿼리">3. 변환된 데이터 쿼리</h2>
<ol>
<li>좌측 SQL - Queries </li>
<li>add 후 쿼리 입력
<img src="https://velog.velcdn.com/images/rudin_/post/20905c28-d4c7-4fbd-958b-a37418d417f5/image.png" alt=""></li>
</ol>
<h2 id="4-dlt-파이프라인을-실행하는-job-생성">4. DLT 파이프라인을 실행하는 JOB 생성</h2>
<p><img src="https://velog.velcdn.com/images/rudin_/post/fdf314cc-d9d5-484c-8ed0-50f283cfa988/image.png" alt=""></p>
<p>다음으로, Databricks job을 사용하여 데이터 수집, 처리 및 분석 단계를 자동화하는 workflow를 만듭니다.</p>
<ol>
<li>workspace의 사이드바에서 Jobs&amp;Pipelines  를 클릭하고 Create job을 선택합니다.</li>
<li>작업 제목 상자에서 New Job &lt;날짜 및 시간&gt;을 작업 이름으로 바꿉니다. 예: Songs workflow.</li>
<li>Task name에 첫 번째 작업의 이름을 입력합니다. 예: ETL_songs_data.</li>
<li>Type에서 Pipeline을 선택합니다.</li>
<li>Pipeline에서 1단계에서 만든 DLT 파이프라인을 선택합니다.</li>
<li>Create를 클릭합니다.</li>
<li>Pipeline  을 실행하려면 Run Now를 클릭합니다. 실행에 대한 세부 정보를 보려면 Runs 탭을 클릭합니다. 작업을 클릭하여 작업 실행에 대한 세부 정보를 봅니다.</li>
<li>workflow가 완료되었을 때 결과를 보려면 Go to the latest successful run 또는 작업 실행의 Start time을 클릭합니다. Output 페이지가 나타나고 쿼리 결과를 표시합니다.<ul>
<li>설명: DLT 파이프라인 실행의 출력은 주로 생성된 테이블입니다. 파이프라인 실행 자체의 &quot;Output&quot;은 일반적으로 로그 및 상태 정보를 의미합니다. 쿼리 결과는 SQL Editor에서 별도로 확인합니다.</li>
</ul>
</li>
</ol>
]]></description>
        </item>
        <item>
            <title><![CDATA[[MicrosoftDataSchool] 56일차 - Azure databricks Image Classification Captioning, Streamlit, AutoLoader, 메달리온 아키텍처]]></title>
            <link>https://velog.io/@rudin_/MicrosoftDataSchool-56%EC%9D%BC%EC%B0%A8</link>
            <guid>https://velog.io/@rudin_/MicrosoftDataSchool-56%EC%9D%BC%EC%B0%A8</guid>
            <pubDate>Thu, 26 Mar 2026 08:40:18 GMT</pubDate>
            <description><![CDATA[<h1 id="image-classification-captioning-실습이어서">Image Classification Captioning 실습(이어서)</h1>
<h2 id="part-b-이미지-캡셔닝--foundation-model-api">Part B: 이미지 캡셔닝 — Foundation Model API</h2>
<blockquote>
<p><code>이미지 캡셔닝</code>: 이미지를 보고 자연어로 설명을 생성하는 작업
<strong>활용 사례:</strong></p>
</blockquote>
<ul>
<li>시각장애인을 위한 이미지 설명 (접근성)</li>
<li>이커머스 상품 이미지 자동 태깅</li>
<li>SNS 자동 캡션 생성</li>
<li>의료 영상 소견 자동 작성
Databricks Foundation Model API의 멀티모달 LLM을 사용하면
모델 학습 없이 바로 이미지 캡셔닝이 가능</li>
</ul>
<h3 id="step-b-1-foundation-model-api-설정">Step B-1: Foundation Model API 설정</h3>
<pre><code class="language-python">import base64, io, json
from mlflow.deployments import get_deploy_client

# Databricks Foundation Model API 클라이언트
client = get_deploy_client(&quot;databricks&quot;)

# 멀티모달 모델 선택 (이미지 + 텍스트 이해 가능)
# Gemma 3 12B: Google의 멀티모달 모델 (가볍고 빠름)
# Claude: Anthropic의 멀티모달 모델 (고품질, 토큰 비용 높음)
VISION_MODEL = &quot;databricks-gemma-3-12b&quot;   # 1순위: 가볍고 비전 지원
# VISION_MODEL = &quot;databricks-claude-sonnet-4&quot;  # 2순위: 고품질 (위가 안 되면 이걸 사용)

print(f&quot;✅ Vision 모델: {VISION_MODEL}&quot;)
print(&quot;   이미지 + 텍스트를 함께 이해하는 멀티모달 모델입니다.&quot;)</code></pre>
<h3 id="step-b-2-이미지-캡셔닝-함수-작성">Step B-2: 이미지 캡셔닝 함수 작성</h3>
<pre><code class="language-python">def caption_image(image, prompt=&quot;Describe this food image in detail. Include the type of food, its appearance, and likely ingredients.&quot;):
    &quot;&quot;&quot;
    이미지를 Foundation Model API로 캡셔닝

    Parameters:
        image: PIL Image 객체
        prompt: 캡셔닝 프롬프트 (영어 권장 — 한국어 출력이 불안정할 수 있음)

    Returns:
        캡션 텍스트
    &quot;&quot;&quot;
    # PIL 이미지 → base64 인코딩
    buf = io.BytesIO()
    image.save(buf, format=&quot;JPEG&quot;)
    img_b64 = base64.b64encode(buf.getvalue()).decode(&quot;utf-8&quot;)

    # Foundation Model API 호출
    response = client.predict(
        endpoint=VISION_MODEL,
        inputs={
            &quot;messages&quot;: [
                {
                    &quot;role&quot;: &quot;user&quot;,
                    &quot;content&quot;: [
                        {
                            &quot;type&quot;: &quot;image_url&quot;,
                            &quot;image_url&quot;: {
                                &quot;url&quot;: f&quot;data:image/jpeg;base64,{img_b64}&quot;
                            }
                        },
                        {
                            &quot;type&quot;: &quot;text&quot;,
                            &quot;text&quot;: prompt
                        }
                    ]
                }
            ],
            &quot;max_tokens&quot;: 200,
            &quot;temperature&quot;: 0.3
        }
    )

    return response[&quot;choices&quot;][0][&quot;message&quot;][&quot;content&quot;]</code></pre>
<h3 id="step-b-3-음식-이미지-캡셔닝-실행">Step B-3: 음식 이미지 캡셔닝 실행</h3>
<pre><code class="language-python"># 각 카테고리별 1장씩 캡셔닝
print(&quot;📝 이미지 캡셔닝 결과:\n&quot;)
print(&quot;=&quot; * 70)

captions = {}
for food_name in target_foods:
    idx = filtered_data[&quot;label_name&quot;].index(food_name)
    image = filtered_data[&quot;image&quot;][idx]

    try:
        caption = caption_image(image)
        captions[food_name] = caption
        print(f&quot;\n🍽️ [{food_name}]&quot;)
        print(f&quot;   {caption}&quot;)
        print(&quot;-&quot; * 70)
    except Exception as e:
        print(f&quot;\n⚠️ [{food_name}] 캡셔닝 실패: {e}&quot;)
        captions[food_name] = f&quot;Error: {e}&quot;</code></pre>
<pre><code>📝 이미지 캡셔닝 결과:

======================================================================

🍽️ [pizza]
   Here&#39;s a detailed description of the food image:

**Type of Food:** The image showcases a pizza. It appears to be a Neapolitan-style pizza, judging by the charred crust and relatively simple toppings.

**Appearance:**

*   **Crust:** The crust is thick, puffy, and has a distinctly charred and blistered appearance around the edges. It&#39;s uneven, with some areas significantly darker than others, indicating a high-heat cooking method (likely a wood-fired oven). The crust has a rustic, slightly irregular shape.
*   **Toppings:** The pizza is generously covered with:
    *   **Cheese:** A creamy, melted cheese (likely mozzarella) forms the base layer.
    *   **Tomatoes:**  A large quantity of diced fresh tomatoes are scattered across the pizza. They appear juicy and bright red.
    *   **Ham:** Small pieces of ham are visible, interspersed among the tomatoes.
    *   
----------------------------------------------------------------------

🍽️ [sushi]
   Here&#39;s a detailed description of the food in the image:

**Type of Food:** The image showcases a selection of sushi, a traditional Japanese dish.

**Appearance:**

*   **Presentation:** The sushi is presented on a small, light-colored wooden board, which is a common serving style for sushi.
*   **Sushi Pieces:** There are three distinct sushi pieces visible:
    *   **Salmon Nigiri:** A piece with a slice of bright pink salmon draped over a small mound of white sushi rice.
    *   **Shrimp Nigiri:** A piece featuring a cooked shrimp with a pinkish-orange hue, also over a bed of rice.
    *   **Unagi (Eel) Roll:** A small roll with a dark, glossy topping of what appears to be unagi (freshwater eel) brushed with a sweet and savory sauce. It sits on a bed of rice and nori (seaweed).
*   **G
----------------------------------------------------------------------

🍽️ [fried_rice]
   Here&#39;s a detailed description of the food image:

**Type of Food:** The dish appears to be fried rice, likely of an Asian (possibly Southeast Asian) origin.

**Appearance:**

*   **Presentation:** The fried rice is served on a vibrant red plate. A large, silver serving spoon rests on the rice, partially obscuring the dish. The rice is garnished with a generous amount of fresh herbs and colorful vegetable strips.
*   **Rice:** The rice itself is a light golden-brown color, indicating it has been stir-fried with oil and other ingredients. It appears fluffy and well-separated.
*   **Ingredients:** The fried rice contains a mix of visible ingredients:
    *   **Eggs:** Scrambled egg pieces are scattered throughout the rice.
    *   **Peas:** Bright green peas are visible, adding pops of color.
    *   **Carrots:** Thinly sliced, orange carrot strips are arranged on top
----------------------------------------------------------------------

🍽️ [ramen]
   Here&#39;s a detailed description of the food in the image:

**Type of Food:** This is a bowl of Ramen. 

**Appearance:**

*   **Bowl:** The ramen is served in a dark brown, ceramic bowl with a slightly rustic texture.
*   **Broth:** The broth is a rich, orange-red color, suggesting a pork-based or miso-based broth. It appears quite thick and flavorful.
*   **Noodles:** There&#39;s a generous portion of yellow ramen noodles submerged in the broth. They appear to be the classic, wavy style.
*   **Protein:** Sliced pieces of grilled chicken are visible, arranged on top of the noodles. The chicken has grill marks and a slightly browned appearance.
*   **Garnish:** The bowl is generously garnished with a variety of fresh ingredients:
    *   Sliced scallions (green onions)
    *   Thinly sliced red onion rings
    *   Sliced
----------------------------------------------------------------------

🍽️ [ice_cream]
   Here&#39;s a detailed description of the food in the image:

**Type of Food:** This appears to be a dessert, specifically a sundae or a layered ice cream treat.

**Appearance:**

*   **Main Components:** The image shows two distinct scoops of ice cream sitting in a glass bowl.
    *   **Chocolate Ice Cream:** A large scoop of dark chocolate ice cream dominates the right side of the bowl. It has a rich, dark brown color and a slightly textured surface, suggesting it&#39;s not perfectly smooth.
    *   **Vanilla Ice Cream:** A smaller scoop of vanilla ice cream sits on the left. It&#39;s a creamy, pale yellow color.
*   **Toppings:**
    *   **Caramel Sauce/Toffee:** There&#39;s a piece of what looks like caramel or toffee on the left side of the bowl. It has a golden-brown color and a slightly bubbly, uneven surface, indicating it&#39;s
----------------------------------------------------------------------</code></pre><h3 id="step-b-4-다양한-프롬프트로-캡셔닝">Step B-4: 다양한 프롬프트로 캡셔닝</h3>
<pre><code class="language-python"># 프롬프트에 따라 캡션이 달라짐!
sample_image = filtered_data[&quot;image&quot;][0]
sample_name = filtered_data[&quot;label_name&quot;][0]

prompts = {
    &quot;General&quot;: &quot;What food is in this image?&quot;,
    &quot;Detailed&quot;: &quot;Describe this food image in detail including ingredients, cooking style, and likely origin.&quot;,
    &quot;Nutritional&quot;: &quot;Estimate the nutritional content of this food. List approximate calories, protein, carbs, and fat.&quot;,
    &quot;Recipe&quot;: &quot;Based on this food image, provide a brief recipe with key ingredients and cooking steps.&quot;,
}

print(f&quot;📷 같은 이미지 [{sample_name}], 다른 프롬프트:\n&quot;)
for prompt_name, prompt_text in prompts.items():
    try:
        caption = caption_image(sample_image, prompt=prompt_text)
        print(f&quot;💬 [{prompt_name}]&quot;)
        print(f&quot;   Prompt: {prompt_text}&quot;)
        print(f&quot;   → {caption[:200]}...&quot;)
        print()
    except Exception as e:
        print(f&quot;⚠️ [{prompt_name}] 실패: {e}\n&quot;)</code></pre>
<pre><code>📷 같은 이미지 [ramen], 다른 프롬프트:

💬 [General]
   Prompt: What food is in this image?
   → Based on the image, this appears to be a bowl of **Ramen**. 

Here&#39;s a breakdown of what I can see:

*   **Noodles:** The long, white strands are ramen noodles.
*   **Broth:** A rich, orange-colored b...

💬 [Detailed]
   Prompt: Describe this food image in detail including ingredients, cooking style, and likely origin.
   → Here&#39;s a detailed description of the food in the image:

**Overall Impression:**

The image shows a bowl of ramen, a popular Japanese noodle soup. It appears to be a hearty and flavorful dish with a r...

💬 [Nutritional]
   Prompt: Estimate the nutritional content of this food. List approximate calories, protein, carbs, and fat.
   → Okay, let&#39;s break down the estimated nutritional content of this ramen dish. Please keep in mind this is an *estimate* based on the image and common ramen ingredients. Actual values can vary significa...

💬 [Recipe]
   Prompt: Based on this food image, provide a brief recipe with key ingredients and cooking steps.
   → Okay, here&#39;s a brief recipe inspired by the image of the ramen, focusing on a chicken-based version.  It&#39;s simplified for ease, but aims to capture the essence of what&#39;s visible.

**Chicken Ramen Reci...</code></pre><h2 id="part-c-분류--캡셔닝-통합-파이프라인">Part C: 분류 + 캡셔닝 통합 파이프라인</h2>
<h3 id="step-c-1-통합-분석--분류--캡셔닝">Step C-1: 통합 분석 — 분류 + 캡셔닝</h3>
<pre><code class="language-python">import mlflow

mlflow.set_experiment(&quot;/Users/&quot; + spark.sql(&quot;SELECT current_user()&quot;).first()[0] + &quot;/image_classification_lab&quot;)

# 5장의 대표 이미지에 분류 + 캡셔닝 동시 수행
with mlflow.start_run(run_name=&quot;combined_classify_caption&quot;):
    results_table = []

    for food_name in target_foods:
        idx = filtered_data[&quot;label_name&quot;].index(food_name)
        image = filtered_data[&quot;image&quot;][idx]

        # 1) ViT 분류
        classify_result = classify_image(image, top_k=1)
        predicted = classify_result[0][&quot;label&quot;]
        confidence = classify_result[0][&quot;confidence&quot;]

        # 2) Foundation Model 캡셔닝
        try:
            caption = caption_image(image)
        except:
            caption = &quot;(캡셔닝 실패)&quot;

        results_table.append({
            &quot;food&quot;: food_name,
            &quot;vit_prediction&quot;: predicted,
            &quot;confidence&quot;: f&quot;{confidence:.1%}&quot;,
            &quot;caption&quot;: caption[:100] + &quot;...&quot;
        })

        print(f&quot;✅ {food_name}: {predicted} ({confidence:.1%})&quot;)

    # MLflow에 결과 기록
    mlflow.log_param(&quot;pipeline&quot;, &quot;classify + caption&quot;)
    mlflow.log_param(&quot;classifier&quot;, &quot;google/vit-base-patch16-224&quot;)
    mlflow.log_param(&quot;captioner&quot;, VISION_MODEL)</code></pre>
<pre><code>✅ pizza: pizza, pizza pie (98.0%)
✅ sushi: plate (44.0%)
✅ fried_rice: wok (29.3%)
✅ ramen: consomme (50.7%)
✅ ice_cream: ice cream, icecream (96.3%)</code></pre><pre><code class="language-python"># 결과를 Spark DataFrame으로 표시
import pandas as pd

results_pdf = pd.DataFrame(results_table)
results_df = spark.createDataFrame(results_pdf)
display(results_df)</code></pre>
<p><img src="https://velog.velcdn.com/images/rudin_/post/493133e4-83a2-42e7-b93b-24704a9dd409/image.png" alt=""></p>
<h3 id="step-c-2-결과를-delta-테이블로-저장">Step C-2: 결과를 Delta 테이블로 저장</h3>
<pre><code class="language-python"># 분석 결과 저장
results_df.write.mode(&quot;overwrite&quot;).saveAsTable(
    f&quot;{CATALOG}.{SCHEMA}.image_analysis_results_lab&quot;
)

print(f&quot;✅ 분석 결과 저장 완료: {CATALOG}.{SCHEMA}.image_analysis_results_lab&quot;)</code></pre>
<h3 id="정리">정리</h3>
<table>
<thead>
<tr>
<th>방법</th>
<th>모델</th>
<th>용도</th>
<th>GPU 필요?</th>
<th>비용</th>
</tr>
</thead>
<tbody><tr>
<td><strong>ViT 분류</strong></td>
<td><code>google/vit-base-patch16-224</code></td>
<td>이미지 → 카테고리</td>
<td>❌ CPU OK</td>
<td>무료 (오픈소스)</td>
</tr>
<tr>
<td><strong>Foundation Model 캡셔닝</strong></td>
<td>멀티모달 LLM</td>
<td>이미지 → 텍스트 설명</td>
<td>❌ API 호출</td>
<td>토큰당 과금</td>
</tr>
<tr>
<td><strong>통합 파이프라인</strong></td>
<td>ViT + LLM</td>
<td>분류 + 설명 자동화</td>
<td>❌</td>
<td>최소 비용</td>
</tr>
</tbody></table>
<ul>
<li><strong>Transfer Learning</strong>: 사전학습된 ViT 모델로 학습 없이 이미지 분류</li>
<li><strong>멀티모달 API</strong>: Foundation Model API로 이미지 캡셔닝 (코드 몇 줄!)</li>
<li><strong>프롬프트 엔지니어링</strong>: 같은 이미지라도 프롬프트에 따라 다른 분석 가능</li>
<li><strong>MLflow 추적</strong>: 이미지 분석 실험도 MLflow로 재현 가능하게 관리</li>
</ul>
<h2 id="part-d-vit-모델-저장--unity-catalog-등록">Part D: ViT 모델 저장 &amp; Unity Catalog 등록</h2>
<p>모델 저장 이유</p>
<ul>
<li>지금까지는 매번 HuggingFace에서 모델을 다운로드해서 사용</li>
<li>실무에서는 이렇게 하면 문제 발생<ul>
<li><strong>재현성</strong>: 어떤 버전의 모델을 사용했는지 추적 불가</li>
<li><strong>의존성</strong>: HuggingFace 서버가 다운되면 모델 사용 불가</li>
<li><strong>배포</strong>: 웹 서비스로 만들려면 모델이 저장소에 있어야 함</li>
<li><strong>거버넌스</strong>: 누가, 언제, 어떤 모델을 배포했는지 관리 필요</li>
</ul>
</li>
<li><em>해결책*</em>: MLflow로 모델을 저장하고, Unity Catalog에 등록</li>
</ul>
<h3 id="step-d-1-transformers-파이프라인을-mlflow에-로깅">Step D-1: transformers 파이프라인을 MLflow에 로깅</h3>
<pre><code class="language-python">import mlflow
from transformers import pipeline
import os

os.environ[&quot;HF_HOME&quot;] = &quot;/tmp/hf_home&quot;
os.environ[&quot;TRANSFORMERS_CACHE&quot;] = &quot;/tmp/hf_cache&quot;

# Unity Catalog를 모델 레지스트리로 설정
mlflow.set_registry_uri(&quot;databricks-uc&quot;)

# ViT 파이프라인 생성
vit_pipeline = pipeline(
    &quot;image-classification&quot;,
    model=&quot;google/vit-base-patch16-224&quot;,
)

# 등록할 모델 이름
MODEL_NAME = f&quot;{CATALOG}.{SCHEMA}.vit_image_classifier&quot;

print(f&quot;📦 모델을 저장할 위치: {MODEL_NAME}&quot;)</code></pre>
<pre><code class="language-python"># MLflow에 모델 로깅 + Unity Catalog 등록
mlflow.set_experiment(
    &quot;/Users/&quot; + spark.sql(&quot;SELECT current_user()&quot;).first()[0] + &quot;/image_classification_lab&quot;
)

with mlflow.start_run(run_name=&quot;vit_model_registration&quot;) as run:
    # 모델 파라미터 기록
    mlflow.log_param(&quot;model_name&quot;, &quot;google/vit-base-patch16-224&quot;)
    mlflow.log_param(&quot;task&quot;, &quot;image-classification&quot;)
    mlflow.log_param(&quot;num_labels&quot;, 1000)  # ImageNet 클래스 수

    # transformers 파이프라인을 MLflow 모델로 저장
    model_info = mlflow.transformers.log_model(
        transformers_model=vit_pipeline,
        artifact_path=&quot;model&quot;,
        registered_model_name=MODEL_NAME,  # Unity Catalog에 자동 등록
        task=&quot;image-classification&quot;,
    )

    print(f&quot;✅ 모델 저장 완료!&quot;)
    print(f&quot;   Run ID: {run.info.run_id}&quot;)
    print(f&quot;   Model URI: {model_info.model_uri}&quot;)
    print(f&quot;   Registry: {MODEL_NAME}&quot;)</code></pre>
<h3 id="step-d-2-저장된-모델-확인">Step D-2: 저장된 모델 확인</h3>
<pre><code class="language-python">from mlflow import MlflowClient

client = MlflowClient(registry_uri=&quot;databricks-uc&quot;)

# 등록된 모델 버전 확인
versions = client.search_model_versions(f&quot;name=&#39;{MODEL_NAME}&#39;&quot;)
for v in versions:
    print(f&quot;📌 버전 {v.version}&quot;)
    print(f&quot;   상태: {v.status}&quot;)
    print(f&quot;   생성: {v.creation_timestamp}&quot;)
    print(f&quot;   Run ID: {v.run_id}&quot;)
    print()

latest_version = max(int(v.version) for v in versions)
print(f&quot;✅ 최신 버전: {latest_version}&quot;)</code></pre>
<pre><code>📌 버전 1
   상태: READY
   생성: 1774487105838
   Run ID: 0e2aa3be2c8349dbaee9e7a131d2ebaa

✅ 최신 버전: 1</code></pre><h3 id="step-d-3-저장된-모델로-추론-테스트">Step D-3: 저장된 모델로 추론 테스트</h3>
<pre><code class="language-python"># Unity Catalog에서 모델 로드 (transformers 네이티브 파이프라인으로)
loaded_pipeline = mlflow.transformers.load_model(f&quot;models:/{MODEL_NAME}/{latest_version}&quot;)

# 테스트 이미지로 추론
test_image = filtered_data[&quot;image&quot;][0]  # 첫 번째 이미지
test_label = filtered_data[&quot;label_name&quot;][0]

# transformers 파이프라인은 PIL 이미지를 직접 받음
result = loaded_pipeline(test_image, top_k=3)

print(f&quot;📷 테스트 이미지: {test_label}&quot;)
print(f&quot;🔍 저장된 모델 추론 결과:&quot;)
print(result)</code></pre>
<pre><code>📷 테스트 이미지: ramen
🔍 저장된 모델 추론 결과:
[{&#39;label&#39;: &#39;consomme&#39;, &#39;score&#39;: 0.5065412521362305}, {&#39;label&#39;: &#39;hot pot, hotpot&#39;, &#39;score&#39;: 0.34765300154685974}, {&#39;label&#39;: &#39;soup bowl&#39;, &#39;score&#39;: 0.07414259016513824}]</code></pre><h2 id="part-e-서빙-엔드포인트-생성--테스트">Part E: 서빙 엔드포인트 생성 &amp; 테스트</h2>
<p>서빙 엔드포인트란?</p>
<ul>
<li>모델을 REST API로 호출할 수 있게 만드는 것</li>
</ul>
<pre><code>현재: 노트북 → 모델 로드 → 추론 (클러스터 필요)
서빙: HTTP 요청 → 엔드포인트 → 추론 (클러스터 불필요, 서버리스)</code></pre><p><strong>장점:</strong></p>
<ul>
<li>클러스터 없이도 모델 사용 가능 (서버리스)</li>
<li>REST API로 어디서든 호출 (웹앱, 모바일, 다른 서비스)</li>
<li>자동 스케일링 (트래픽에 따라 확장/축소)</li>
<li>scale-to-zero: 안 쓸 때 비용 0</li>
</ul>
<h3 id="step-e-1-서빙-엔드포인트-생성">Step E-1: 서빙 엔드포인트 생성</h3>
<pre><code class="language-python">import requests
import json

# Databricks API 접속 정보
ctx = dbutils.notebook.entry_point.getDbutils().notebook().getContext()
host = ctx.browserHostName().get()
token = ctx.apiToken().get()

# 엔드포인트 설정
ENDPOINT_NAME = &quot;vit-image-classifier&quot;

endpoint_config = {
    &quot;name&quot;: ENDPOINT_NAME,
    &quot;config&quot;: {
        &quot;served_entities&quot;: [
            {
                &quot;entity_name&quot;: MODEL_NAME,
                &quot;entity_version&quot;: str(latest_version),
                &quot;workload_size&quot;: &quot;Small&quot;,           # Small/Medium/Large
                &quot;scale_to_zero_enabled&quot;: True,       # 안 쓸 때 비용 0
            }
        ]
        # traffic_config 불필요 — 엔티티 1개면 자동 100% 할당
    }
}

# 엔드포인트 생성 API 호출
resp = requests.post(
    f&quot;https://{host}/api/2.0/serving-endpoints&quot;,
    headers={&quot;Authorization&quot;: f&quot;Bearer {token}&quot;, &quot;Content-Type&quot;: &quot;application/json&quot;},
    json=endpoint_config
)

if resp.status_code == 200:
    print(f&quot;✅ 서빙 엔드포인트 생성 요청 완료: {ENDPOINT_NAME}&quot;)
    print(f&quot;   상태를 아래에서 확인하세요.&quot;)
elif resp.status_code == 400 and &quot;already exists&quot; in resp.text:
    print(f&quot;ℹ️ 엔드포인트 &#39;{ENDPOINT_NAME}&#39;가 이미 존재합니다.&quot;)
    print(f&quot;   기존 엔드포인트를 사용합니다.&quot;)
else:
    print(f&quot;❌ 오류 ({resp.status_code}): {resp.text[:500]}&quot;)</code></pre>
<h3 id="step-e-2-엔드포인트-상태-확인">Step E-2: 엔드포인트 상태 확인</h3>
<pre><code class="language-python">import time

def check_endpoint_status(host, token, endpoint_name):
    &quot;&quot;&quot;엔드포인트 상태 확인&quot;&quot;&quot;
    resp = requests.get(
        f&quot;https://{host}/api/2.0/serving-endpoints/{endpoint_name}&quot;,
        headers={&quot;Authorization&quot;: f&quot;Bearer {token}&quot;}
    )
    if resp.status_code == 200:
        data = resp.json()
        state = data.get(&quot;state&quot;, {})
        return state.get(&quot;ready&quot;, &quot;UNKNOWN&quot;), state.get(&quot;config_update&quot;, &quot;UNKNOWN&quot;)
    return &quot;ERROR&quot;, resp.text[:200]

# 상태 폴링 (최대 20분 대기)
print(f&quot;⏳ 엔드포인트 &#39;{ENDPOINT_NAME}&#39; 준비 대기 중...&quot;)
print(f&quot;   (처음 생성 시 5~15분 소요)\n&quot;)

for i in range(40):
    ready, config = check_endpoint_status(host, token, ENDPOINT_NAME)
    print(f&quot;   [{i*30}초] Ready: {ready}, Config: {config}&quot;)

    if ready == &quot;READY&quot;:
        print(f&quot;\n✅ 엔드포인트 준비 완료!&quot;)
        break
    elif ready == &quot;ERROR&quot;:
        print(f&quot;\n❌ 오류: {config}&quot;)
        break

    time.sleep(30)
else:
    print(&quot;\n⚠️ 시간 초과. Databricks UI의 Serving 페이지에서 상태를 확인하세요.&quot;)</code></pre>
<ul>
<li>오래걸리고 잘 안됨</li>
<li>pytorch onyx 모델 사용시 경량화 가능<pre><code class="language-python"># PyTorch → ONNX 변환
from optimum.exporters.onnx import main_export
main_export(&quot;google/vit-base-patch16-224&quot;, output=&quot;vit_onnx/&quot;)
</code></pre>
</li>
</ul>
<h1 id="onnx-모델을-mlflow에-로깅">ONNX 모델을 MLflow에 로깅</h1>
<p>mlflow.onnx.log_model(onnx_model, &quot;model&quot;,
    registered_model_name=&quot;jhleews.default.vit_onnx&quot;)</p>
<pre><code>### Step E-3: 서빙 엔드포인트로 추론 테스트
```python
import base64
from io import BytesIO

# 테스트 이미지를 base64로 인코딩
test_image = filtered_data[&quot;image&quot;][0]
buf = BytesIO()
test_image.save(buf, format=&quot;JPEG&quot;)
img_b64 = base64.b64encode(buf.getvalue()).decode(&quot;utf-8&quot;)

# 엔드포인트 호출
resp = requests.post(
    f&quot;https://{host}/serving-endpoints/{ENDPOINT_NAME}/invocations&quot;,
    headers={
        &quot;Authorization&quot;: f&quot;Bearer {token}&quot;,
        &quot;Content-Type&quot;: &quot;application/json&quot;
    },
    json={
        &quot;inputs&quot;: [img_b64]
    }
)

if resp.status_code == 200:
    result = resp.json()
    print(f&quot;📷 테스트 이미지: {filtered_data[&#39;label_name&#39;][0]}&quot;)
    print(f&quot;🔍 서빙 엔드포인트 추론 결과:&quot;)
    print(json.dumps(result, indent=2, ensure_ascii=False)[:500])
else:
    print(f&quot;❌ 호출 오류 ({resp.status_code}):&quot;)
    print(resp.text[:500])
    print()
    print(&quot;💡 엔드포인트가 아직 준비 중일 수 있습니다.&quot;)
    print(&quot;   Step E-2 셀을 다시 실행하여 상태를 확인하세요.&quot;)</code></pre><h3 id="step-e-4-여러-이미지로-배치-테스트">Step E-4: 여러 이미지로 배치 테스트</h3>
<pre><code class="language-python"># 5개 카테고리 대표 이미지로 배치 테스트
print(&quot;🔍 서빙 엔드포인트 배치 테스트:\n&quot;)

for food_name in target_foods:
    idx = filtered_data[&quot;label_name&quot;].index(food_name)
    img = filtered_data[&quot;image&quot;][idx]

    buf = BytesIO()
    img.save(buf, format=&quot;JPEG&quot;)
    img_b64 = base64.b64encode(buf.getvalue()).decode(&quot;utf-8&quot;)

    resp = requests.post(
        f&quot;https://{host}/serving-endpoints/{ENDPOINT_NAME}/invocations&quot;,
        headers={
            &quot;Authorization&quot;: f&quot;Bearer {token}&quot;,
            &quot;Content-Type&quot;: &quot;application/json&quot;
        },
        json={&quot;inputs&quot;: [img_b64]}
    )

    if resp.status_code == 200:
        result = resp.json()
        # 결과 형태에 따라 파싱
        predictions = result.get(&quot;predictions&quot;, result)
        print(f&quot;📷 [{food_name}] → {str(predictions)[:120]}&quot;)
    else:
        print(f&quot;📷 [{food_name}] → ❌ 오류: {resp.status_code}&quot;)</code></pre>
<h3 id="step-e-5-엔드포인트-정리">Step E-5: 엔드포인트 정리</h3>
<pre><code class="language-python">resp = requests.delete(
     f&quot;https://{host}/api/2.0/serving-endpoints/{ENDPOINT_NAME}&quot;,
     headers={&quot;Authorization&quot;: f&quot;Bearer {token}&quot;}
 )
 if resp.status_code == 200:
     print(f&quot;✅ 엔드포인트 &#39;{ENDPOINT_NAME}&#39; 삭제 완료&quot;)
 else:
     print(f&quot;❌ 삭제 실패: {resp.text}&quot;)</code></pre>
<hr>

<h1 id="lab09-streamlit-웹-서비스--이미지-분류--캡셔닝">Lab09: Streamlit 웹 서비스 — 이미지 분류 &amp; 캡셔닝</h1>
<p><strong>이미지 분류(ViT)</strong> + <strong>캡셔닝(Foundation Model API)</strong> 파이프라인을
<strong>Streamlit 웹 앱</strong>으로 만들어 실제 서비스처럼 사용</p>
<h3 id="학습-목표">학습 목표</h3>
<ul>
<li>ML 모델을 웹 서비스로 래핑하는 방법 이해</li>
<li>Streamlit으로 인터랙티브 UI 구현</li>
<li>Databricks Driver Proxy를 통한 웹 앱 접근</li>
<li>Foundation Model API를 REST로 호출하는 패턴</li>
</ul>
<h3 id="아키텍처">아키텍처</h3>
<pre><code>사용자 (브라우저)
    │  이미지 업로드
    ▼
┌──────────────────────────────────┐
│  Streamlit 웹 앱 (Driver Node)   │
│                                  │
│  ┌────────────┐ ┌──────────────┐ │
│  │ ViT 분류   │ │ Foundation   │ │
│  │ (로컬모델) │ │ Model API    │ │
│  └────────────┘ └──────────────┘ │
└──────────────────────────────────┘
    │  결과 반환
    ▼
사용자 (분류 + 캡션 결과 확인)</code></pre><p><strong>클러스터</strong>: ML Runtime 14.x 이상</p>
<h2 id="환경설정">환경설정</h2>
<pre><code class="language-python">%pip install streamlit transformers torch torchvision Pillow --quiet
dbutils.library.restartPython()</code></pre>
<h3 id="설정값">설정값</h3>
<pre><code class="language-python">import os

# ── HuggingFace 캐시 설정 (Databricks 권한 이슈 방지) ──
os.environ[&quot;HF_HOME&quot;] = &quot;/tmp/hf_home&quot;
os.environ[&quot;TRANSFORMERS_CACHE&quot;] = &quot;/tmp/hf_cache&quot;

# ── Databricks 접속 정보 (Streamlit 앱 → Foundation Model API 호출용) ──
ctx = dbutils.notebook.entry_point.getDbutils().notebook().getContext()

DATABRICKS_HOST = ctx.browserHostName().get()
DATABRICKS_TOKEN = ctx.apiToken().get()

# ── 비전 모델 엔드포인트 (Lab07에서 사용한 것과 동일) ──
VISION_MODEL = &quot;databricks-gemma-3-12b&quot;

# ── 클러스터 정보 (Driver Proxy URL 생성용) ──
CLUSTER_ID = spark.conf.get(&quot;spark.databricks.clusterUsageTags.clusterId&quot;)
ORG_ID = ctx.workspaceId().get()

print(f&quot;✅ Host: {DATABRICKS_HOST}&quot;)
print(f&quot;✅ Cluster ID: {CLUSTER_ID}&quot;)
print(f&quot;✅ Vision Model: {VISION_MODEL}&quot;)</code></pre>
<h2 id="part-b-streamlit-앱-코드-작성">Part B: Streamlit 앱 코드 작성</h2>
<p>Streamlit 앱의 전체 코드를 /tmp/image_app.py 파일로 저장</p>
<pre><code class="language-python"># ── Streamlit 앱 전체 코드 ──

app_code = r&#39;&#39;&#39;
import streamlit as st
import requests
import base64
import json
import os
from io import BytesIO
from PIL import Image

# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 환경 변수에서 설정 읽기
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
DATABRICKS_HOST = os.environ.get(&quot;DATABRICKS_HOST&quot;, &quot;&quot;)
DATABRICKS_TOKEN = os.environ.get(&quot;DATABRICKS_TOKEN&quot;, &quot;&quot;)
VISION_MODEL = os.environ.get(&quot;VISION_MODEL&quot;, &quot;databricks-gemma-3-12b&quot;)

os.environ[&quot;HF_HOME&quot;] = &quot;/tmp/hf_home&quot;
os.environ[&quot;TRANSFORMERS_CACHE&quot;] = &quot;/tmp/hf_cache&quot;


# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 페이지 설정
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
st.set_page_config(
    page_title=&quot;AI Image Analyzer&quot;,
    page_icon=&quot;🖼️&quot;,
    layout=&quot;wide&quot;
)

st.title(&quot;🖼️ AI 이미지 분석기&quot;)
st.markdown(&quot;**이미지를 업로드하면 자동으로 분류하고 설명을 생성합니다.**&quot;)
st.markdown(&quot;---&quot;)


# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# ViT 모델 로딩 (캐시)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@st.cache_resource
def load_vit_model():
    &quot;&quot;&quot;ViT 이미지 분류 모델을 로딩하고 캐시합니다.&quot;&quot;&quot;
    from transformers import pipeline
    classifier = pipeline(
        &quot;image-classification&quot;,
        model=&quot;google/vit-base-patch16-224&quot;,
    )
    return classifier


# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Foundation Model API — 이미지 캡셔닝
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
def get_image_caption(image: Image.Image, prompt: str) -&gt; str:
    &quot;&quot;&quot;Databricks Foundation Model API로 이미지 캡셔닝&quot;&quot;&quot;
    # 이미지 → base64 인코딩
    buffered = BytesIO()
    image_rgb = image.convert(&quot;RGB&quot;)
    image_rgb.save(buffered, format=&quot;JPEG&quot;, quality=85)
    img_base64 = base64.b64encode(buffered.getvalue()).decode(&quot;utf-8&quot;)

    # REST API 호출
    url = f&quot;https://{DATABRICKS_HOST}/serving-endpoints/{VISION_MODEL}/invocations&quot;
    headers = {
        &quot;Authorization&quot;: f&quot;Bearer {DATABRICKS_TOKEN}&quot;,
        &quot;Content-Type&quot;: &quot;application/json&quot;
    }
    payload = {
        &quot;messages&quot;: [
            {
                &quot;role&quot;: &quot;user&quot;,
                &quot;content&quot;: [
                    {
                        &quot;type&quot;: &quot;image_url&quot;,
                        &quot;image_url&quot;: {&quot;url&quot;: f&quot;data:image/jpeg;base64,{img_base64}&quot;}
                    },
                    {
                        &quot;type&quot;: &quot;text&quot;,
                        &quot;text&quot;: prompt
                    }
                ]
            }
        ],
        &quot;max_tokens&quot;: 500,
        &quot;temperature&quot;: 0.7
    }

    try:
        response = requests.post(url, headers=headers, json=payload, timeout=60)
        response.raise_for_status()
        result = response.json()
        return result[&quot;choices&quot;][0][&quot;message&quot;][&quot;content&quot;]
    except requests.exceptions.HTTPError as e:
        return f&quot;API 오류 ({response.status_code}): {response.text[:200]}&quot;
    except Exception as e:
        return f&quot;오류: {str(e)}&quot;


# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 사이드바
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
with st.sidebar:
    st.header(&quot;⚙️ 설정&quot;)

    prompt_option = st.selectbox(
        &quot;캡셔닝 프롬프트&quot;,
        [
            &quot;이 이미지를 한국어로 자세히 설명해주세요.&quot;,
            &quot;이 이미지에 있는 물체들을 나열해주세요.&quot;,
            &quot;이 이미지의 분위기와 느낌을 한국어로 설명해주세요.&quot;,
            &quot;이 음식의 이름과 재료를 추측해주세요.&quot;,
            &quot;직접 입력&quot;
        ]
    )

    if prompt_option == &quot;직접 입력&quot;:
        custom_prompt = st.text_area(&quot;프롬프트 입력&quot;, value=&quot;이 이미지를 분석해주세요.&quot;)
    else:
        custom_prompt = prompt_option

    top_k = st.slider(&quot;분류 결과 Top-K&quot;, min_value=1, max_value=10, value=5)

    st.markdown(&quot;---&quot;)
    st.markdown(&quot;**모델 정보**&quot;)
    st.markdown(f&quot;- 분류: `ViT-base-patch16-224`&quot;)
    st.markdown(f&quot;- 캡셔닝: `{VISION_MODEL}`&quot;)
    st.markdown(&quot;---&quot;)
    st.markdown(&quot;**사용법**&quot;)
    st.markdown(&quot;1. 이미지 파일 업로드&quot;)
    st.markdown(&quot;2. 자동으로 분류 + 캡셔닝 실행&quot;)
    st.markdown(&quot;3. 프롬프트를 바꿔가며 실험&quot;)


# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 메인 영역
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
uploaded_file = st.file_uploader(
    &quot;이미지를 업로드하세요&quot;,
    type=[&quot;jpg&quot;, &quot;jpeg&quot;, &quot;png&quot;, &quot;webp&quot;, &quot;bmp&quot;],
    help=&quot;지원 형식: JPG, PNG, WebP, BMP&quot;
)

if uploaded_file is not None:
    image = Image.open(uploaded_file)

    col1, col2 = st.columns([1, 1])

    # ── 왼쪽: 업로드된 이미지 ──
    with col1:
        st.subheader(&quot;📷 업로드된 이미지&quot;)
        st.image(image, use_container_width=True)
        st.caption(f&quot;파일: {uploaded_file.name} | 크기: {image.size[0]} x {image.size[1]}&quot;)

    # ── 오른쪽: 분석 결과 ──
    with col2:
        st.subheader(&quot;🔍 분석 결과&quot;)

        # 1) 이미지 분류
        with st.spinner(&quot;🏷️ 이미지 분류 중...&quot;):
            try:
                classifier = load_vit_model()
                results = classifier(image, top_k=top_k)

                st.markdown(&quot;#### 🏷️ 이미지 분류 (ViT)&quot;)
                for i, r in enumerate(results):
                    label = r[&quot;label&quot;]
                    score = r[&quot;score&quot;]
                    st.progress(score, text=f&quot;{i+1}. {label} ({score:.1%})&quot;)
            except Exception as e:
                st.error(f&quot;분류 오류: {str(e)}&quot;)
                results = []

        st.markdown(&quot;---&quot;)

        # 2) 이미지 캡셔닝
        with st.spinner(&quot;💬 이미지 설명 생성 중...&quot;):
            caption = get_image_caption(image, custom_prompt)
            st.markdown(&quot;#### 💬 AI 이미지 설명&quot;)
            st.info(caption)

    # ── 하단: 상세 정보 ──
    with st.expander(&quot;📊 상세 분류 결과 (JSON)&quot;):
        if results:
            st.json(results)
        else:
            st.write(&quot;분류 결과가 없습니다.&quot;)

    # ── 재실행 버튼 ──
    st.markdown(&quot;---&quot;)
    col_a, col_b, col_c = st.columns([1, 1, 1])
    with col_b:
        if st.button(&quot;🔄 다른 프롬프트로 캡셔닝 재실행&quot;, use_container_width=True):
            with st.spinner(&quot;💬 재생성 중...&quot;):
                new_caption = get_image_caption(image, custom_prompt)
                st.info(new_caption)

else:
    # ── 업로드 전 안내 화면 ──
    st.info(&quot;👆 위에서 이미지를 업로드하면 AI가 자동으로 분석합니다.&quot;)

    col_a, col_b, col_c = st.columns(3)
    with col_a:
        st.markdown(&quot;### 🏷️ 이미지 분류&quot;)
        st.markdown(&quot;ViT 모델이 1,000개 카테고리 중에서 이미지의 종류를 판별합니다.&quot;)
    with col_b:
        st.markdown(&quot;### 💬 이미지 캡셔닝&quot;)
        st.markdown(&quot;Foundation Model API가 이미지를 보고 한국어 설명을 생성합니다.&quot;)
    with col_c:
        st.markdown(&quot;### 🔄 프롬프트 실험&quot;)
        st.markdown(&quot;사이드바에서 프롬프트를 바꿔가며 다양한 설명을 받아보세요.&quot;)
&#39;&#39;&#39;

# 앱 코드를 파일로 저장
with open(&quot;/tmp/image_app.py&quot;, &quot;w&quot;, encoding=&quot;utf-8&quot;) as f:
    f.write(app_code)

print(&quot;✅ Streamlit 앱 코드 저장 완료: /tmp/image_app.py&quot;)
print(f&quot;   파일 크기: {os.path.getsize(&#39;/tmp/image_app.py&#39;):,} bytes&quot;)</code></pre>
<ul>
<li>실습인지라 노트북에서 토큰을 발급</li>
<li>실제로 사용할것이라면 databricks단에서 토큰을 발급</li>
</ul>
<h2 id="part-c-앱-실행--접속">Part C: 앱 실행 &amp; 접속</h2>
<h3 id="step-3-streamlit-앱-실행">Step 3: Streamlit 앱 실행</h3>
<pre><code class="language-python">import subprocess
import time
import os

# ── 환경 변수 설정 (Streamlit 앱에서 API 호출 시 사용) ──
os.environ[&quot;DATABRICKS_HOST&quot;] = DATABRICKS_HOST
os.environ[&quot;DATABRICKS_TOKEN&quot;] = DATABRICKS_TOKEN
os.environ[&quot;VISION_MODEL&quot;] = VISION_MODEL

# ── 기존 Streamlit 프로세스 종료 ──
subprocess.run([&quot;pkill&quot;, &quot;-f&quot;, &quot;streamlit&quot;], capture_output=True)
time.sleep(2)

# ── 앱 실행 (백그라운드) ──
PORT = 8501

process = subprocess.Popen(
    [
        &quot;streamlit&quot;, &quot;run&quot;, &quot;/tmp/image_app.py&quot;,
        &quot;--server.port&quot;, str(PORT),
        &quot;--server.headless&quot;, &quot;true&quot;,
        &quot;--server.address&quot;, &quot;0.0.0.0&quot;,
        &quot;--browser.gatherUsageStats&quot;, &quot;false&quot;,
        &quot;--server.enableCORS&quot;, &quot;false&quot;,
        &quot;--server.enableXsrfProtection&quot;, &quot;false&quot;,
    ],
    stdout=subprocess.PIPE,
    stderr=subprocess.PIPE,
    env=os.environ.copy()
)

# 서버 기동 대기
time.sleep(5)

# 프로세스 확인
if process.poll() is None:
    print(f&quot;✅ Streamlit 앱 실행 중! (PID: {process.pid}, Port: {PORT})&quot;)
else:
    stderr_output = process.stderr.read().decode()
    print(f&quot;❌ 앱 실행 실패:\n{stderr_output}&quot;)</code></pre>
<h3 id="step-4-접속-url-확인">Step 4: 접속 URL 확인</h3>
<pre><code class="language-python"># ── Driver Proxy URL 생성 ──
proxy_url = f&quot;https://{DATABRICKS_HOST}/driver-proxy/o/{ORG_ID}/{CLUSTER_ID}/{PORT}/&quot;

print(&quot;=&quot; * 60)
print(&quot;🌐 Streamlit 앱 접속 URL&quot;)
print(&quot;=&quot; * 60)
print()
print(f&quot;  {proxy_url}&quot;)
print()
print(&quot;📌 사용법:&quot;)
print(&quot;   1. 위 URL을 브라우저 새 탭에 붙여넣기&quot;)
print(&quot;   2. 이미지 파일을 드래그 &amp; 드롭으로 업로드&quot;)
print(&quot;   3. AI가 자동으로 분류 + 설명 생성&quot;)
print()
print(&quot;⚠️ Databricks 워크스페이스에 로그인된&quot;)
print(&quot;   브라우저에서만 접속 가능합니다.&quot;)
print(&quot;=&quot; * 60)</code></pre>
<p><img src="https://velog.velcdn.com/images/rudin_/post/d7e5e5bd-e965-47e2-99ff-f495eec89721/image.png" alt=""></p>
<h2 id="part-d-관리--디버깅">Part D: 관리 &amp; 디버깅</h2>
<h3 id="앱-상태-확인">앱 상태 확인</h3>
<pre><code class="language-python"># 실행 중인 Streamlit 프로세스 확인
import subprocess

result = subprocess.run([&quot;pgrep&quot;, &quot;-af&quot;, &quot;streamlit&quot;], capture_output=True, text=True)
if result.stdout.strip():
    print(&quot;✅ Streamlit 프로세스 목록:&quot;)
    for line in result.stdout.strip().split(&quot;\n&quot;):
        print(f&quot;   {line}&quot;)
else:
    print(&quot;❌ Streamlit 프로세스가 없습니다.&quot;)
    print(&quot;   → Part C의 Step 3 셀을 다시 실행하세요.&quot;)</code></pre>
<h3 id="에러-로그-확인-문제-발생-시">에러 로그 확인 (문제 발생 시)</h3>
<pre><code class="language-python"># 앱이 비정상 종료된 경우 로그 확인
try:
    if process.poll() is not None:
        stdout = process.stdout.read().decode()
        stderr = process.stderr.read().decode()
        if stderr:
            print(&quot;=== 에러 로그 ===&quot;)
            print(stderr[-3000:])
        if stdout:
            print(&quot;\n=== 표준 출력 ===&quot;)
            print(stdout[-1000:])
    else:
        print(&quot;✅ 앱이 정상 실행 중입니다.&quot;)
except:
    print(&quot;ℹ️ 프로세스 정보를 가져올 수 없습니다. Step 3을 다시 실행해주세요.&quot;)</code></pre>
<h3 id="앱-코드-수정-후-재시작">앱 코드 수정 후 재시작</h3>
<p>코드를 수정하고 싶으면:</p>
<ol>
<li>Part B의 <code>app_code</code> 내용을 수정</li>
<li>Part B 셀 실행 (파일 저장)</li>
<li>아래 셀 실행 (재시작)</li>
</ol>
<pre><code class="language-python"># 앱 재시작 (코드 수정 후 실행)
import subprocess, time, os

subprocess.run([&quot;pkill&quot;, &quot;-f&quot;, &quot;streamlit&quot;], capture_output=True)
time.sleep(2)

process = subprocess.Popen(
    [
        &quot;streamlit&quot;, &quot;run&quot;, &quot;/tmp/image_app.py&quot;,
        &quot;--server.port&quot;, str(PORT),
        &quot;--server.headless&quot;, &quot;true&quot;,
        &quot;--server.address&quot;, &quot;0.0.0.0&quot;,
        &quot;--browser.gatherUsageStats&quot;, &quot;false&quot;,
        &quot;--server.enableCORS&quot;, &quot;false&quot;,
        &quot;--server.enableXsrfProtection&quot;, &quot;false&quot;,
    ],
    stdout=subprocess.PIPE,
    stderr=subprocess.PIPE,
    env=os.environ.copy()
)
time.sleep(5)

if process.poll() is None:
    print(f&quot;✅ 앱 재시작 완료! (PID: {process.pid})&quot;)
    print(f&quot;📎 URL: https://{DATABRICKS_HOST}/driver-proxy/o/{ORG_ID}/{CLUSTER_ID}/{PORT}/&quot;)
else:
    print(&quot;❌ 재시작 실패. 에러 로그 확인 셀을 실행하세요.&quot;)</code></pre>
<h3 id="앱-종료">앱 종료</h3>
<pre><code class="language-python">import subprocess
subprocess.run([&quot;pkill&quot;, &quot;-f&quot;, &quot;streamlit&quot;], capture_output=True)
print(&quot;✅ Streamlit 앱이 종료되었습니다.&quot;)</code></pre>
<h3 id="정리-1">정리</h3>
<table>
<thead>
<tr>
<th>개념</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Streamlit</strong></td>
<td>Python만으로 인터랙티브 웹 UI를 빠르게 구현</td>
</tr>
<tr>
<td><strong>모델 서빙</strong></td>
<td>ViT 모델을 웹 앱에 통합하여 실시간 추론</td>
</tr>
<tr>
<td><strong>REST API</strong></td>
<td>Foundation Model API를 HTTP로 호출하여 캡셔닝</td>
</tr>
<tr>
<td><strong>Driver Proxy</strong></td>
<td>Databricks 클러스터의 웹 앱을 브라우저로 접근</td>
</tr>
<tr>
<td><strong>@st.cache_resource</strong></td>
<td>모델을 한 번만 로딩하여 성능 최적화</td>
</tr>
</tbody></table>
<h3 id="핵심-코드-패턴">핵심 코드 패턴</h3>
<pre><code class="language-python"># 1. 모델 캐싱 — 매 요청마다 재로딩 방지
@st.cache_resource
def load_model():
    return pipeline(&quot;image-classification&quot;, model=&quot;google/vit-base-patch16-224&quot;)

# 2. REST API 호출 — Foundation Model API
response = requests.post(
    f&quot;https://{host}/serving-endpoints/{model}/invocations&quot;,
    headers={&quot;Authorization&quot;: f&quot;Bearer {token}&quot;},
    json={&quot;messages&quot;: [{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: [...]}]}
)

# 3. Driver Proxy URL — Databricks 내 웹 앱 접속
url = f&quot;https://{host}/driver-proxy/o/{org_id}/{cluster_id}/{port}/&quot;</code></pre>
<h3 id="실무-확장">실무 확장</h3>
<ul>
<li><strong>Databricks Apps</strong>: Streamlit 앱을 Databricks 전용 앱으로 배포 (별도 컴퓨트)</li>
<li><strong>Azure App Service</strong>: 외부 사용자도 접근 가능한 URL로 배포</li>
<li><strong>Model Serving Endpoint</strong>: ViT도 서빙 엔드포인트로 배포하면 클러스터 없이 추론 가능</li>
<li><strong>배치 파이프라인</strong>: 대량 이미지를 Delta Table로 처리하는 자동화 파이프라인과 결합</li>
</ul>
<hr>

<h1 id="data-engineering">Data Engineering</h1>
<h2 id="카탈로그에서-볼륨-만들기">카탈로그에서 볼륨 만들기</h2>
<p><img src="https://velog.velcdn.com/images/rudin_/post/4553361c-200c-4ba9-8d42-1fa8653ca11d/image.png" alt=""></p>
<ol>
<li>스키마 생성</li>
<li>create에서 volume 선택</li>
<li>csv 파일 업로드</li>
</ol>
<pre><code class="language-python"># 2. 업로드된 파일 경로 설정 (사용자 지정 경로)
# 해당 경로 아래에 csv 파일이 있다고 가정합니다. 만약 파일명이 포함되지 않은 디렉토리 경로라면 /*.csv를 붙여줍니다.
source_path = &quot;/Volumes/3dt016_databricks/data/diamond/*.csv&quot; 

# CSV 파일 읽기
df_raw = spark.read.format(&quot;csv&quot;) \
  .option(&quot;header&quot;, &quot;true&quot;) \
  .option(&quot;inferSchema&quot;, &quot;true&quot;) \
  .load(source_path)

# 데이터 확인
print(f&quot;총 {df_raw.count()} 건의 데이터를 로드했습니다.&quot;)
display(df_raw.limit(5))

# Bronze 테이블로 저장 (Raw Data 보존)
# Delta Lake 형식으로 저장하여 성능과 안정성을 확보합니다.
df_raw.write.format(&quot;delta&quot;).mode(&quot;overwrite&quot;).saveAsTable(&quot;bronze_diamonds&quot;)</code></pre>
<p><img src="https://velog.velcdn.com/images/rudin_/post/2be9fd30-3949-4281-8bb9-1bf52e841a0e/image.png" alt=""></p>
<pre><code class="language-python"># 필요한 라이브러리 임포트
from pyspark.sql.functions import col, when, round, avg, current_timestamp
from pyspark.sql.types import DoubleType

# 3. Bronze 테이블 읽기
from pyspark.sql.functions import col, round, current_timestamp, when
# 3. Bronze 테이블 읽기
df_bronze = spark.read.table(&quot;bronze_diamonds&quot;)


# 데이터 변환 (Transformation)
df_silver = df_bronze \
    .withColumn(&quot;price&quot;, col(&quot;price&quot;).cast(DoubleType())) \
    .withColumn(&quot;volume&quot;, round(col(&quot;x&quot;) * col(&quot;y&quot;) * col(&quot;z&quot;), 2)) \
    .withColumn(&quot;ingestion_time&quot;, current_timestamp()) \
    .filter((col(&quot;x&quot;) &gt; 0) &amp; (col(&quot;y&quot;) &gt; 0) &amp; (col(&quot;z&quot;) &gt; 0)) # 크기가 0인 잘못된 데이터 제거

# 품질 등급(Quality Flag) 파생 변수 생성
df_silver = df_silver.withColumn(
    &quot;quality_flag&quot;, 
    when(col(&quot;cut&quot;).isin(&quot;Ideal&quot;, &quot;Premium&quot;), &quot;High&quot;).otherwise(&quot;Standard&quot;)
)

# Silver 테이블 저장
df_silver.write.format(&quot;delta&quot;).mode(&quot;overwrite&quot;).option(&quot;mergeSchema&quot;, &quot;true&quot;).saveAsTable(&quot;silver_diamonds_enriched&quot;)

print(&quot;Silver Layer 생성 완료&quot;)
display(df_silver.limit(5))</code></pre>
<p><img src="https://velog.velcdn.com/images/rudin_/post/64658f92-64d4-4b87-8d96-72d284b9c4b4/image.png" alt=""></p>
<pre><code class="language-python"># 4. Silver 테이블 읽기
df_silver = spark.read.table(&quot;silver_diamonds_enriched&quot;)

# 집계 분석
df_gold = df_silver.groupBy(&quot;cut&quot;, &quot;color&quot;) \
    .agg(
        avg(&quot;price&quot;).alias(&quot;avg_price&quot;),
        avg(&quot;carat&quot;).alias(&quot;avg_carat&quot;),
        round(avg(&quot;price&quot;) / avg(&quot;carat&quot;), 2).alias(&quot;price_per_carat_index&quot;)
    ) \
    .orderBy(&quot;avg_price&quot;, ascending=False)

# Gold 테이블 저장
df_gold.write.format(&quot;delta&quot;).mode(&quot;overwrite&quot;).saveAsTable(&quot;gold_diamond_analytics&quot;)

print(&quot;Gold Layer 생성 완료&quot;)
display(df_gold)</code></pre>
<p><img src="https://velog.velcdn.com/images/rudin_/post/79ae984c-2fce-45c7-b09b-caa8fd79f2e1/image.png" alt=""></p>
<pre><code class="language-python">%sql
-- Gold 테이블 조회
SELECT 
    cut,
    color,
    avg_price,
    price_per_carat_index
FROM gold_diamond_analytics
WHERE cut IN (&#39;Ideal&#39;, &#39;Premium&#39;, &#39;Very Good&#39;)
ORDER BY price_per_carat_index DESC</code></pre>
<p><img src="https://velog.velcdn.com/images/rudin_/post/b5813a3b-b898-4b0b-9b8f-6d029ca5fef1/image.png" alt=""></p>
<h2 id="파티셔닝-전략">파티셔닝 전략</h2>
<p><img src="https://velog.velcdn.com/images/rudin_/post/8e2f1ad5-4f40-4a7b-be53-f002017aed35/image.png" alt="">
파티셔닝: 데이터를 특정 컬럼 값에 따라 디렉토리로 분할하여 저장하는 기법
쿼리 시 필요한 파티션만 읽어 I/O를 크게 줄일 수 있음
앞선 예제에서는 cut 컬럼으로 파티셔닝하여 Fair, Good, Very Good, Premium, Ideal의 5개의 파티션이 생성됨</p>
<h3 id="파티셔닝-선택-기준">파티셔닝 선택 기준</h3>
<ul>
<li>카디널리티: 너무 많거나 적지 않은 적절한 수의 고유 값을 가진 컬럼 선택</li>
<li>쿼리 패턴: 자주 필터링되는 컬럼을 파티션 키로 선택</li>
<li>데이터 분포: 각 파티션의 데이터 크기가 비교적 균등한 컬럼 선ㅌ개</li>
<li>조인 최적화: 조인에 자주 사용되는 컬럼 고려</li>
</ul>
<h3 id="etl-파이프라인-흐름">ETL 파이프라인 흐름</h3>
<pre><code>1. 데이터 추출: Databricks 샘플 경로에서 CSV 파일 읽기
2. 스키마 확인: 데이터 구조 및 타입 검증
3. 데이터 정제: 이상치 제거 및 필터링
4. 파생 컬럼 생성: 비즈니스 로직 적용
5. 집계 분석: 품질별 통계 계산
6. Delta Lake 저장 : 최적화된 형식으로 영구 저장</code></pre><h3 id="성능-최적화-팁">성능 최적화 팁</h3>
<h4 id="1-적절한-파티셔닝">1. 적절한 파티셔닝</h4>
<p>자주 필터링되는 컬럼을 파티션 키로 선택하여 불필요한 데이터 스캔 줄이기</p>
<h4 id="2-캐싱-활용">2. 캐싱 활용</h4>
<p>여러 번 사용되는 DataFrame은 <code>cache()</code>또는 <code>persist()</code>로 메모리에 저장</p>
<h4 id="3-브로드캐스트-조인">3. 브로드캐스트 조인</h4>
<p>작은 테이블은 broadcast()를 사용하여 모든 노드에 복제하면 셔플을 피할 수 있음</p>
<h4 id="4-컬럼-프루닝">4. 컬럼 프루닝</h4>
<p>필요한 컬럼만 select 하여 메모리 사용량과 I/O를 최소화</p>
<h4 id="5-필터-푸시다운">5. 필터 푸시다운</h4>
<p>가능한 한 일찍 filter를 적용하여 처리할 데이터 양을 줄임</p>
<h3 id="실무-활용-시나리오">실무 활용 시나리오</h3>
<h4 id="일일-배치-처리">일일 배치 처리</h4>
<p>매일 새로운 다이아몬드 거래 데이터를 수집하여 정제하고 Delta Lake에 적재하는 배치 작업 스케줄링
Databricks Jobs를 사용하여 자동화</p>
<h4 id="실시간-분석">실시간 분석</h4>
<p>Delta Lake에 저장된 데이터를 SQL 쿼리나 BI 도구로 실시간 분석하여 재고 관리, 가격 책정 전략 수립</p>
<h4 id="머신러닝-피처">머신러닝 피처</h4>
<p>정제된 데이터와 파생 컬럼을 머신러닝 모델의 피처로 활용하여 가격 예측 모델 구축</p>
<h3 id="증분-처리-패턴">증분 처리 패턴</h3>
<h4 id="전체-덮어쓰기-vs-증분-업데이트">전체 덮어쓰기 vs 증분 업데이트</h4>
<p>실무에서는 새로운 데이터만 추가하거나 변경된 데이터만 업데이트하는 증분 처리가 효율적</p>
<table>
<thead>
<tr>
<th>방식</th>
<th>설명</th>
<th>특징/적합한 경우</th>
</tr>
</thead>
<tbody><tr>
<td>Append 모드</td>
<td>새로운 레코드를 기존 테이블에 추가</td>
<td>processing_date 같은 타임스탬프 컬럼으로 신규 데이터 식별</td>
</tr>
<tr>
<td>Merge (Upsert) 패턴</td>
<td>기존 레코드는 업데이트하고, 새 레코드는 삽입</td>
<td>Delta Lake MERGE 사용, CDC(Change Data Capture) 시나리오에 적합</td>
</tr>
</tbody></table>
<p><em>CDC: 데이터베이스에서 발생하는 행 수준의 변경 사항을 실시간으로 식별, 캡처하여 타 시스템으로 전달하는 데이터 통합 기술
소스 DB에 부하를 거의 주지 않고 트랜잭션 로그를 기반으로 변경된 데이터만 효율적으로 복제</em></p>
<ul>
<li>Azure Data Factory</li>
</ul>
<h3 id="모니터링-및-로깅">모니터링 및 로깅</h3>
<p>메트릭: 모니터링의 대상, 단위</p>
<h4 id="주요-모니터링-항목">주요 모니터링 항목</h4>
<ul>
<li>처리 레코드 수: 입력과 출력 레코드 수 비교</li>
<li>실행 시간: 파이프라인 성능 추적</li>
<li>에러율: 실패한 레코드나 예외 발생 건수</li>
<li>데이터 품질 메트릭: null 비율, 중복 건수 등</li>
<li>리소스 사용량: CPU, 메모리, 디스크 I/O</li>
</ul>
<h3 id="에러-핸들링-모범-사례">에러 핸들링 모범 사례</h3>
<h4 id="try-except-블록">Try-Except 블록</h4>
<h4 id="bad-records-처리">Bad Records 처리</h4>
<p>읽기 시 badRecordsPath 옵션을 사용하여 파싱 실패한 레코드를 별도 경로에 저장하고 나중에 분석</p>
<h4 id="재시도-로직">재시도 로직</h4>
<h4 id="알림-시스템">알림 시스템</h4>
<p>중요한 에러 발생 시 이메일, Slack 등으로 즉시 알림을 보내 신속한 대응이 가능하도록 함</p>
<hr>

<p>마이크로서비스 ↔ 모놀리스
사라진이유: 유지보수가 복잡</p>
<hr>

<h1 id="실습-8-auto-loader--스트리밍-데이터-수집">실습 8: Auto Loader — 스트리밍 데이터 수집</h1>
<p>Azure Blob Storage에서 Auto Loader를 사용하여 새로운 파일이 도착하면 자동으로 Delta 테이블에 적재(증분처리)</p>
<ul>
<li>Auto Loader의 개념과 동작 원리</li>
<li>cloudFiles 포맷으로 스트리밍 수집</li>
<li>Schema Evolution (스키마 자동 진화)</li>
<li>체크포인트를 이용한 정확히 한 번(Exactly-Once) 처리</li>
</ul>
<table>
<thead>
<tr>
<th>개념</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Auto Loader</strong></td>
<td>클라우드 스토리지의 새 파일을 자동으로 감지하여 증분 처리하는 Databricks 기능</td>
</tr>
<tr>
<td><strong>cloudFiles</strong></td>
<td>Auto Loader의 Spark 데이터 소스 포맷 이름. readStream.format(&quot;cloudFiles&quot;)로 사용</td>
</tr>
<tr>
<td><strong>Checkpoint</strong></td>
<td>어디까지 처리했는지 기록하는 위치 — 재시작해도 중복 없이 이어서 처리</td>
</tr>
<tr>
<td><strong>Schema Evolution</strong></td>
<td>새 컬럼이 추가되어도 자동으로 스키마를 업데이트.ALTER TABLE 불필요.</td>
</tr>
<tr>
<td><strong>SAS (Shared Access Signature)</strong></td>
<td>Azure Storage 접근을 위한 토큰 기반 인증 방식.시간 제한, 권한 제한 가능.</td>
</tr>
</tbody></table>
<h2 id="이론적-배경">이론적 배경</h2>
<h3 id="21-auto-loader-아키텍처">2.1 Auto Loader 아키텍처</h3>
<p>Auto Loader는 클라우드 스토리지(Azure Blob, AWS S3, GCS)에 새로 도착하는 파일을 자동으로 감지하는 2가지 방식을 제공</p>
<ul>
<li>Directory Listing: 주기적으로 디렉토리를 스캠하여 새 파일 감지. 설정이 간단하지만 파일이 많으면 느릴 수 있음.</li>
<li>File Notification: 클라우드 이벤트 알림을 사용. 수백만 파일에도 효율적. Azure Event Grid 또는 AWS SNS/SQS 활용.
<img src="https://velog.velcdn.com/images/rudin_/post/911d1e88-f094-4279-89e3-3bb93dad187e/image.png" alt=""></li>
</ul>
<h3 id="22-증분-처리와-체크포인트">2.2 증분 처리와 체크포인트</h3>
<ul>
<li>어떤 파일까지 처리했는지 기록하여, 다음 실행 시 새 파일만 처리</li>
<li>클러스터가 재시작되더라도 중복이나 누락 없이 정확히 한 번(Exactly-Once) 처리를 보장</li>
</ul>
<p><img src="https://velog.velcdn.com/images/rudin_/post/d4dc72b6-3699-4ee7-b847-d18775bf27c9/image.png" alt=""></p>
<h3 id="23-schema-evolution">2.3 Schema Evolution</h3>
<ul>
<li>Auto Loader는 새로운 파일에 새 컨럼이 추가되어도 자동으로 스키마를 업데이트</li>
<li>기존 데이터의 새 컬럼은 NULL로 채워짐</li>
<li>schemaEvolutionMode=addNewColumns 옵션으로 활성화</li>
</ul>
<p><img src="https://velog.velcdn.com/images/rudin_/post/5a3afa81-1779-49da-812c-a29adc5d6806/image.png" alt=""></p>
<h2 id="azure-storage-sas-연결">Azure Storage SAS 연결</h2>
<h3 id="1-azure-portal에서-스토리지-계정-생성">1. Azure Portal에서 스토리지 계정 생성</h3>
<p>참고: ~managed 리소스 그룹은 자동으로 생성되는 그룹이며, 클라우드가 managing하는 리소스 그룹이다.
<img src="https://velog.velcdn.com/images/rudin_/post/c41f0510-fb9e-496e-9dd1-3d019d2e0c62/image.png" alt="">
다음 페이지에서 <code>계층 구조 네임스페이스</code>를 켜면 ADLS Gen2 사용
<img src="https://velog.velcdn.com/images/rudin_/post/e45efd25-bd5d-4dc6-be5a-1625f6aac8ed/image.png" alt=""></p>
<p>Azure Blob Storage vs Azure Data Lake Storage Gen2</p>
<ul>
<li>DataLake쪽이 Blob Storage의 차세대 기술로, 계층적 파일 시스템을 지원</li>
<li>보통 프로덕트에서는 ADLS Gen2 사용</li>
</ul>
<h3 id="2-sas-토큰-생성-후-spark-설정에-등록">2. SAS 토큰 생성 후 Spark 설정에 등록</h3>
<p>공유 액세스 서명의 허용되는 리소스 종류 체크
<img src="https://velog.velcdn.com/images/rudin_/post/c52acc7d-936e-45a3-9b4c-4f8846632f05/image.png" alt=""></p>
<p>이후 하단의 SAS 및 연결 문자열 생성 클릭 후 SAS 토큰 복사</p>
<h3 id="3-컨테이너-생성">3. 컨테이너 생성</h3>
<p><img src="https://velog.velcdn.com/images/rudin_/post/94813d4f-c2bf-4e0d-83d2-a7f5592ea373/image.png" alt=""></p>
<pre><code class="language-python"># Azure Blob Storage 연결 설정
# SAS (Shared Access Signature) 토큰으로 인증
STORAGE_ACCOUNT = &quot;&quot;
CONTAINER = &quot;&quot;

# ⚠️ 아래 SAS_TOKEN을 실제 값으로 교체하세요!
# Azure Portal → Storage Account → Shared access signature 에서 생성
SAS_TOKEN = &quot;&quot;

# Spark 설정에 SAS 토큰 등록
spark.conf.set(
    f&quot;fs.azure.sas.{CONTAINER}.{STORAGE_ACCOUNT}.blob.core.windows.net&quot;,
    SAS_TOKEN
)

# 기본 경로 설정
BASE_PATH = f&quot;wasbs://{CONTAINER}@{STORAGE_ACCOUNT}.blob.core.windows.net&quot;

print(f&quot;✅ Azure Storage 연결 설정 완료&quot;)
print(f&quot;   Storage Account: {STORAGE_ACCOUNT}&quot;)
print(f&quot;   Container: {CONTAINER}&quot;)
print(f&quot;   Base Path: {BASE_PATH}&quot;)</code></pre>
<pre><code class="language-python"># 연결 테스트 — 컨테이너 내 파일 목록 확인
try:
    files = dbutils.fs.ls(BASE_PATH)
    print(f&quot;✅ 연결 성공! {len(files)}개 항목 발견:\n&quot;)
    for f in files[:20]:
        size_kb = f.size / 1024
        print(f&quot;   {f.name:40s} {size_kb:&gt;10.1f} KB&quot;)
    if len(files) &gt; 20:
        print(f&quot;   ... 외 {len(files) - 20}개&quot;)
except Exception as e:
    print(f&quot;❌ 연결 실패: {e}&quot;)
    print(&quot;\n🔧 확인사항:&quot;)
    print(&quot;   1. SAS_TOKEN이 올바른지 확인&quot;)
    print(&quot;   2. SAS 토큰의 만료 기간 확인&quot;)
    print(&quot;   3. 컨테이너 이름이 정확한지 확인&quot;)
    print(&quot;   4. SAS 토큰에 Read, List 권한이 있는지 확인&quot;)</code></pre>
<p><strong>Auto Loader 동작 원리:</strong></p>
<ol>
<li>클라우드 스토리지에 새 파일이 도착</li>
<li>Auto Loader가 자동으로 감지 (체크포인트로 추적)</li>
<li>새 파일만 읽어서 처리</li>
<li>Delta 테이블에 적재</li>
<li>체크포인트 업데이트 → 다음에는 더 새로운 파일만 처리</li>
</ol>
<h3 id="step-2-샘플-데이터-준비--csv-파일-업로드">Step 2: 샘플 데이터 준비 — CSV 파일 업로드</h3>
<pre><code class="language-python"># 샘플 CSV 데이터 생성 (IoT 센서 데이터 시뮬레이션)
from pyspark.sql import functions as F
from pyspark.sql.types import StructType, StructField, StringType, DoubleType, TimestampType, IntegerType
import datetime

CATALOG = &quot;3dt016_databricks&quot;
SCHEMA = &quot;autoloader&quot;

# 배치 1: 기본 IoT 센서 데이터
batch1_data = [
    (&quot;sensor_001&quot;, &quot;temperature&quot;, 23.5, &quot;2024-03-01 10:00:00&quot;, &quot;building_A&quot;),
    (&quot;sensor_001&quot;, &quot;temperature&quot;, 24.1, &quot;2024-03-01 10:05:00&quot;, &quot;building_A&quot;),
    (&quot;sensor_002&quot;, &quot;humidity&quot;, 65.3, &quot;2024-03-01 10:00:00&quot;, &quot;building_A&quot;),
    (&quot;sensor_002&quot;, &quot;humidity&quot;, 64.8, &quot;2024-03-01 10:05:00&quot;, &quot;building_A&quot;),
    (&quot;sensor_003&quot;, &quot;temperature&quot;, 21.2, &quot;2024-03-01 10:00:00&quot;, &quot;building_B&quot;),
    (&quot;sensor_003&quot;, &quot;temperature&quot;, 21.8, &quot;2024-03-01 10:05:00&quot;, &quot;building_B&quot;),
    (&quot;sensor_004&quot;, &quot;pressure&quot;, 1013.2, &quot;2024-03-01 10:00:00&quot;, &quot;building_B&quot;),
    (&quot;sensor_004&quot;, &quot;pressure&quot;, 1013.5, &quot;2024-03-01 10:05:00&quot;, &quot;building_B&quot;),
]

batch1_schema = StructType([
    StructField(&quot;sensor_id&quot;, StringType()),
    StructField(&quot;metric_type&quot;, StringType()),
    StructField(&quot;value&quot;, DoubleType()),
    StructField(&quot;timestamp&quot;, StringType()),
    StructField(&quot;location&quot;, StringType()),
])

batch1_df = spark.createDataFrame(batch1_data, batch1_schema)

# CSV로 저장 (스토리지에 업로드)
UPLOAD_PATH = f&quot;{BASE_PATH}/autoloader_lab/incoming&quot;
batch1_df.coalesce(1).write.mode(&quot;overwrite&quot;).option(&quot;header&quot;, &quot;true&quot;).csv(f&quot;{UPLOAD_PATH}/batch_001&quot;)

print(f&quot;✅ 배치 1 업로드 완료: {UPLOAD_PATH}/batch_001&quot;)
print(f&quot;   {batch1_df.count()}개 레코드&quot;)
display(batch1_df)</code></pre>
<h3 id="step-3-auto-loader로-스트리밍-수집">Step 3: Auto Loader로 스트리밍 수집</h3>
<p><code>cloudFiles</code> 포맷을 사용하여 Auto Loader를 설정합니다.
새 파일이 도착하면 자동으로 감지하여 처리합니다.</p>
<pre><code class="language-python"># Auto Loader 설정
SOURCE_PATH = f&quot;{BASE_PATH}/autoloader_lab/incoming&quot;
CHECKPOINT_PATH = f&quot;{BASE_PATH}/autoloader_lab/_checkpoint&quot;
TARGET_TABLE = f&quot;{CATALOG}.{SCHEMA}.iot_sensor_autoloader&quot;

# cloudFiles 포맷으로 스트리밍 읽기
stream_df = (
    spark.readStream
    .format(&quot;cloudFiles&quot;)                            # Auto Loader!
    .option(&quot;cloudFiles.format&quot;, &quot;csv&quot;)              # 소스 파일 포맷
    .option(&quot;header&quot;, &quot;true&quot;)                        # CSV 헤더 있음
    .option(&quot;cloudFiles.schemaLocation&quot;, CHECKPOINT_PATH + &quot;/schema&quot;)  # 스키마 저장 위치
    .option(&quot;cloudFiles.schemaEvolutionMode&quot;, &quot;addNewColumns&quot;)  # 새 컬럼 자동 추가
    .load(SOURCE_PATH)
    # 수집 메타데이터 추가
    .withColumn(&quot;ingestion_timestamp&quot;, F.current_timestamp())
    .withColumn(&quot;source_file&quot;, F.input_file_name())
)

print(&quot;✅ Auto Loader 스트림 설정 완료&quot;)
print(f&quot;   소스: {SOURCE_PATH}&quot;)
print(f&quot;   체크포인트: {CHECKPOINT_PATH}&quot;)
print(f&quot;   대상 테이블: {TARGET_TABLE}&quot;)</code></pre>
<h3 id="스트림-시작--delta-테이블로-적재">스트림 시작 — Delta 테이블로 적재</h3>
<pre><code class="language-python"># 스트리밍 쓰기 시작
query = (
    stream_df.writeStream
    .format(&quot;delta&quot;)
    .outputMode(&quot;append&quot;)
    .option(&quot;checkpointLocation&quot;, CHECKPOINT_PATH)
    .option(&quot;mergeSchema&quot;, &quot;true&quot;)              # 스키마 변경 자동 병합
    .trigger(availableNow=True)                 # 현재 가용한 데이터만 처리 후 종료
    # .trigger(processingTime=&quot;10 seconds&quot;)     # 10초마다 새 파일 체크 (연속 실행 시)
    .toTable(TARGET_TABLE)
)

# 스트림 완료 대기
query.awaitTermination()
print(f&quot;✅ 배치 처리 완료!&quot;)</code></pre>
<pre><code class="language-python"># 적재된 데이터 확인
result_df = spark.table(TARGET_TABLE)
print(f&quot;📊 현재 적재된 레코드 수: {result_df.count()}&quot;)
display(result_df.orderBy(&quot;timestamp&quot;))</code></pre>
<h3 id="step-4-새-파일-도착-시뮬레이션--증분-처리-확인">Step 4: 새 파일 도착 시뮬레이션 — 증분 처리 확인</h3>
<pre><code class="language-python"># 배치 2: 새로운 센서 데이터 (다른 시간대)
batch2_data = [
    (&quot;sensor_001&quot;, &quot;temperature&quot;, 25.3, &quot;2024-03-01 11:00:00&quot;, &quot;building_A&quot;),
    (&quot;sensor_002&quot;, &quot;humidity&quot;, 62.1, &quot;2024-03-01 11:00:00&quot;, &quot;building_A&quot;),
    (&quot;sensor_005&quot;, &quot;co2&quot;, 420.5, &quot;2024-03-01 11:00:00&quot;, &quot;building_C&quot;),
    (&quot;sensor_005&quot;, &quot;co2&quot;, 435.2, &quot;2024-03-01 11:05:00&quot;, &quot;building_C&quot;),
    (&quot;sensor_006&quot;, &quot;temperature&quot;, 19.8, &quot;2024-03-01 11:00:00&quot;, &quot;building_C&quot;),
]

batch2_df = spark.createDataFrame(batch2_data, batch1_schema)
batch2_df.coalesce(1).write.mode(&quot;overwrite&quot;).option(&quot;header&quot;, &quot;true&quot;).csv(f&quot;{UPLOAD_PATH}/batch_002&quot;)

print(f&quot;✅ 배치 2 업로드 완료: {UPLOAD_PATH}/batch_002&quot;)
print(f&quot;   {batch2_df.count()}개 새 레코드 (새 센서 sensor_005, sensor_006 포함)&quot;)</code></pre>
<pre><code class="language-python"># Auto Loader 재실행 — 새 파일(batch_002)만 처리됨!
query2 = (
    spark.readStream
    .format(&quot;cloudFiles&quot;)
    .option(&quot;cloudFiles.format&quot;, &quot;csv&quot;)
    .option(&quot;header&quot;, &quot;true&quot;)
    .option(&quot;cloudFiles.schemaLocation&quot;, CHECKPOINT_PATH + &quot;/schema&quot;)
    .option(&quot;cloudFiles.schemaEvolutionMode&quot;, &quot;addNewColumns&quot;)
    .load(SOURCE_PATH)
    .withColumn(&quot;ingestion_timestamp&quot;, F.current_timestamp())
    .withColumn(&quot;source_file&quot;, F.input_file_name())
    .writeStream
    .format(&quot;delta&quot;)
    .outputMode(&quot;append&quot;)
    .option(&quot;checkpointLocation&quot;, CHECKPOINT_PATH)
    .option(&quot;mergeSchema&quot;, &quot;true&quot;)
    .trigger(availableNow=True)
    .toTable(TARGET_TABLE)
)

query2.awaitTermination()
print(f&quot;✅ 증분 처리 완료!&quot;)</code></pre>
<pre><code class="language-python"># 전체 데이터 확인 — batch_001 + batch_002 모두 있어야 함
result_df = spark.table(TARGET_TABLE)
print(f&quot;📊 총 레코드 수: {result_df.count()} (배치1: 8 + 배치2: 5 = 13)&quot;)
print(f&quot;\n📍 위치별 레코드 수:&quot;)
display(result_df.groupBy(&quot;location&quot;).count().orderBy(&quot;location&quot;))</code></pre>
<p><img src="https://velog.velcdn.com/images/rudin_/post/adb18e38-dc48-428d-9046-393a87a27e61/image.png" alt=""></p>
<pre><code class="language-python"># 소스 파일별 수집 현황 확인
print(&quot;📁 소스 파일별 수집 현황:&quot;)
display(
    result_df
    .withColumn(&quot;file_name&quot;, F.regexp_extract(&quot;source_file&quot;, r&quot;([^/]+)$&quot;, 1))
    .groupBy(&quot;file_name&quot;)
    .agg(
        F.count(&quot;*&quot;).alias(&quot;record_count&quot;),
        F.min(&quot;ingestion_timestamp&quot;).alias(&quot;ingested_at&quot;)
    )
    .orderBy(&quot;ingested_at&quot;)
)</code></pre>
<p><img src="https://velog.velcdn.com/images/rudin_/post/3a2a1724-8d77-4cd7-879d-fbf0894f7051/image.png" alt=""></p>
<h3 id="step-5-schema-evolution-체험--새-컬럼-추가">Step 5: Schema Evolution 체험 — 새 컬럼 추가</h3>
<pre><code class="language-python"># 배치 3: 새로운 컬럼(unit, status) 추가된 데이터
batch3_data = [
    (&quot;sensor_001&quot;, &quot;temperature&quot;, 26.1, &quot;2024-03-01 12:00:00&quot;, &quot;building_A&quot;, &quot;celsius&quot;, &quot;normal&quot;),
    (&quot;sensor_002&quot;, &quot;humidity&quot;, 58.9, &quot;2024-03-01 12:00:00&quot;, &quot;building_A&quot;, &quot;percent&quot;, &quot;warning&quot;),
    (&quot;sensor_007&quot;, &quot;vibration&quot;, 0.05, &quot;2024-03-01 12:00:00&quot;, &quot;building_D&quot;, &quot;mm/s&quot;, &quot;normal&quot;),
]

batch3_schema = StructType([
    StructField(&quot;sensor_id&quot;, StringType()),
    StructField(&quot;metric_type&quot;, StringType()),
    StructField(&quot;value&quot;, DoubleType()),
    StructField(&quot;timestamp&quot;, StringType()),
    StructField(&quot;location&quot;, StringType()),
    StructField(&quot;unit&quot;, StringType()),       # 새 컬럼!
    StructField(&quot;status&quot;, StringType()),     # 새 컬럼!
])

batch3_df = spark.createDataFrame(batch3_data, batch3_schema)
batch3_df.coalesce(1).write.mode(&quot;overwrite&quot;).option(&quot;header&quot;, &quot;true&quot;).csv(f&quot;{UPLOAD_PATH}/batch_003&quot;)

print(f&quot;✅ 배치 3 업로드 완료 (새 컬럼 unit, status 추가)&quot;)
display(batch3_df)</code></pre>
<p><img src="https://velog.velcdn.com/images/rudin_/post/bcb2b40a-fc1b-4668-b17c-84c1b389f0ed/image.png" alt=""></p>
<pre><code class="language-python"># Auto Loader 재실행 — 새 컬럼이 자동으로 추가됨!
# addNewColumns 모드에서는 새 컬럼 감지 시 스키마를 업데이트한 뒤 스트림이 종료됨
# 재시작하면 업데이트된 스키마로 정상 처리됨
for attempt in range(3):
    try:
        query3 = (
            spark.readStream
            .format(&quot;cloudFiles&quot;)
            .option(&quot;cloudFiles.format&quot;, &quot;csv&quot;)
            .option(&quot;header&quot;, &quot;true&quot;)
            .option(&quot;cloudFiles.schemaLocation&quot;, CHECKPOINT_PATH + &quot;/schema&quot;)
            .option(&quot;cloudFiles.schemaEvolutionMode&quot;, &quot;addNewColumns&quot;)
            .load(SOURCE_PATH)
            .withColumn(&quot;ingestion_timestamp&quot;, F.current_timestamp())
            .withColumn(&quot;source_file&quot;, F.input_file_name())
            .writeStream
            .format(&quot;delta&quot;)
            .outputMode(&quot;append&quot;)
            .option(&quot;checkpointLocation&quot;, CHECKPOINT_PATH)
            .option(&quot;mergeSchema&quot;, &quot;true&quot;)
            .trigger(availableNow=True)
            .toTable(TARGET_TABLE)
        )

        query3.awaitTermination()
        print(f&quot;✅ Schema Evolution 처리 완료!&quot;)
        break
    except Exception as e:
        if &quot;UNKNOWN_FIELD_EXCEPTION&quot; in str(e):
            print(f&quot;🔄 새 컬럼 감지로 스키마 업데이트됨, 재시작 중... (시도 {attempt + 1})&quot;)
            continue
        else:
            raise</code></pre>
<pre><code class="language-python"># 스키마 확인 — unit, status 컬럼이 자동으로 추가되었는지 확인
result_df = spark.table(TARGET_TABLE)
print(f&quot;📊 총 레코드 수: {result_df.count()}&quot;)
print(f&quot;\n📋 현재 스키마:&quot;)
result_df.printSchema()

print(f&quot;\n💡 batch_001, batch_002의 unit, status는 null (해당 컬럼이 없었으므로)&quot;)
display(result_df.orderBy(&quot;timestamp&quot;).limit(20))</code></pre>
<p><img src="https://velog.velcdn.com/images/rudin_/post/2d38fa1c-a4ed-4809-974b-0ddaed13ebfc/image.png" alt=""></p>
<h3 id="step-6-데이터-품질-모니터링">Step 6: 데이터 품질 모니터링</h3>
<pre><code class="language-python"># 수집된 데이터 요약 통계
print(&quot;📊 센서별 통계:&quot;)
display(
    result_df
    .groupBy(&quot;sensor_id&quot;, &quot;metric_type&quot;, &quot;location&quot;)
    .agg(
        F.count(&quot;*&quot;).alias(&quot;readings&quot;),
        F.round(F.avg(&quot;value&quot;), 2).alias(&quot;avg_value&quot;),
        F.round(F.min(&quot;value&quot;), 2).alias(&quot;min_value&quot;),
        F.round(F.max(&quot;value&quot;), 2).alias(&quot;max_value&quot;),
    )
    .orderBy(&quot;location&quot;, &quot;sensor_id&quot;)
)</code></pre>
<p><img src="https://velog.velcdn.com/images/rudin_/post/ad23f6c7-bee7-4158-8b95-735cee6808a2/image.png" alt=""></p>
<pre><code class="language-python"># 수집 이력 시각화
print(&quot;📈 배치별 수집 이력:&quot;)
display(
    result_df
    .withColumn(&quot;batch&quot;, F.regexp_extract(&quot;source_file&quot;, r&quot;(batch_\d+)&quot;, 1))
    .groupBy(&quot;batch&quot;)
    .agg(
        F.count(&quot;*&quot;).alias(&quot;records&quot;),
        F.min(&quot;timestamp&quot;).alias(&quot;data_from&quot;),
        F.max(&quot;timestamp&quot;).alias(&quot;data_to&quot;),
        F.min(&quot;ingestion_timestamp&quot;).alias(&quot;ingested_at&quot;),
    )
    .orderBy(&quot;batch&quot;)
)</code></pre>
<p><img src="https://velog.velcdn.com/images/rudin_/post/20711e7e-ad73-412d-aca7-def1795d6901/image.png" alt=""></p>
<h3 id="정리-2">정리</h3>
<table>
<thead>
<tr>
<th>기능</th>
<th>코드</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Auto Loader 읽기</strong></td>
<td><code>spark.readStream.format(&quot;cloudFiles&quot;)</code></td>
<td>클라우드 스토리지의 새 파일 자동 감지</td>
</tr>
<tr>
<td><strong>파일 포맷 지정</strong></td>
<td><code>.option(&quot;cloudFiles.format&quot;, &quot;csv&quot;)</code></td>
<td>CSV, JSON, Parquet 등 지원</td>
</tr>
<tr>
<td><strong>스키마 진화</strong></td>
<td><code>.option(&quot;cloudFiles.schemaEvolutionMode&quot;, &quot;addNewColumns&quot;)</code></td>
<td>새 컬럼 자동 추가</td>
</tr>
<tr>
<td><strong>체크포인트</strong></td>
<td><code>.option(&quot;checkpointLocation&quot;, path)</code></td>
<td>처리 위치 저장, 정확히 한 번 처리 보장</td>
</tr>
<tr>
<td><strong>트리거</strong></td>
<td><code>.trigger(availableNow=True)</code></td>
<td>배치 모드 (현재 데이터만 처리 후 종료)</td>
</tr>
<tr>
<td><strong>연속 실행</strong></td>
<td><code>.trigger(processingTime=&quot;10 seconds&quot;)</code></td>
<td>10초마다 새 파일 체크 (실시간 수집)</td>
</tr>
<tr>
<td><strong>메타데이터</strong></td>
<td><code>F.input_file_name()</code></td>
<td>소스 파일 경로 추적</td>
</tr>
</tbody></table>
<hr>

<h1 id="메달리온-아키텍처-기반-데이터-파이프라인--ml">메달리온 아키텍처 기반 데이터 파이프라인 + ML</h1>
<ul>
<li><strong>Bronze Layer</strong>: Raw 데이터 수집 및 저장</li>
<li><strong>Silver Layer</strong>: 데이터 정제, 검증, 통합</li>
<li><strong>Gold Layer</strong>: 비즈니스 레벨 집계 및 Feature Store</li>
<li><strong>ML Layer</strong>: 고객 이탈(Churn) 예측 모델</li>
</ul>
<hr>
<h1 id="1-개요">1. 개요</h1>
<p>이번 실습에서는 E-commerce 데이터를 기준으로 메달리온 아키텍처를 구성했다.</p>
<ul>
<li><strong>Bronze</strong>에서는 원본 데이터를 그대로 적재하고</li>
<li><strong>Silver</strong>에서는 타입 변환, 정제, 조인 등을 수행하고</li>
<li><strong>Gold</strong>에서는 분석 및 머신러닝에 사용할 집계 테이블과 Feature를 생성했다</li>
<li>마지막으로 <strong>ML Layer</strong>에서 고객 이탈 예측 모델을 학습했다</li>
</ul>
<p>실제 노트북 실행 결과 기준으로 데이터 규모는 다음과 같았다. Bronze와 Silver는 각각 고객 10,000건, 상품 500건, 주문 39,777건, 활동 로그 523,286건이 적재되었고, Gold에는 customer_360 10,000건, product_sales 500건, monthly_revenue 47건이 생성되었다. </p>
<hr>
<h1 id="2-환경-설정-및-라이브러리">2. 환경 설정 및 라이브러리</h1>
<pre><code class="language-python">from pyspark.sql import SparkSession
from pyspark.sql.functions import *
from pyspark.sql.types import *
from pyspark.sql.window import Window
from delta.tables import DeltaTable

import pandas as pd
from datetime import datetime, timedelta
import random

# ML 라이브러리
from pyspark.ml.feature import VectorAssembler, StandardScaler, StringIndexer
from pyspark.ml.classification import RandomForestClassifier
from pyspark.ml.evaluation import BinaryClassificationEvaluator
from pyspark.ml import Pipeline

# MLflow
import mlflow
import mlflow.spark

random.seed(42)</code></pre>
<hr>
<h1 id="3-샘플-데이터-생성">3. 샘플 데이터 생성</h1>
<p>실제 프로덕션 환경에서는 Azure Event Hub, Blob Storage, CDC 등의 외부 소스에서 데이터를 가져오겠지만, 이번 실습에서는 샘플 데이터를 직접 생성했다.</p>
<p>생성한 데이터는 다음 4종이다.</p>
<ul>
<li>고객 데이터 (<code>customers</code>)</li>
<li>상품 데이터 (<code>products</code>)</li>
<li>주문 데이터 (<code>orders</code>)</li>
<li>고객 활동 로그 (<code>customer_activity</code>)</li>
</ul>
<hr>
<h2 id="31-고객-데이터-생성">3.1 고객 데이터 생성</h2>
<pre><code class="language-python">customer_data = []

for i in range(1, 10001):
    customer_data.append({
        &#39;customer_id&#39;: f&#39;C{i:06d}&#39;,
        &#39;name&#39;: f&#39;Customer_{i}&#39;,
        &#39;email&#39;: f&#39;customer{i}@email.com&#39;,
        &#39;country&#39;: random.choice([&#39;USA&#39;, &#39;UK&#39;, &#39;Germany&#39;, &#39;France&#39;, &#39;Japan&#39;, &#39;Korea&#39;, &#39;Canada&#39;]),
        &#39;registration_date&#39;: (datetime(2022, 1, 1) + timedelta(days=random.randint(0, 730))).strftime(&#39;%Y-%m-%d&#39;),
        &#39;customer_segment&#39;: random.choice([&#39;Premium&#39;, &#39;Standard&#39;, &#39;Basic&#39;]),
        &#39;age&#39;: random.randint(18, 70),
        &#39;gender&#39;: random.choice([&#39;M&#39;, &#39;F&#39;, &#39;Other&#39;])
    })

df_customers_raw = spark.createDataFrame(customer_data)
display(df_customers_raw.limit(10))</code></pre>
<h3 id="output-예시">output 예시</h3>
<pre><code class="language-text">고객 수: 10,000</code></pre>
<pre><code class="language-text">+---+-------+-----------+----------------+--------------------+------+-----------+----------+
|age|country|customer_id|customer_segment|email               |gender|name       |registration_date|
+---+-------+-----------+----------------+--------------------+------+-----------+----------+
|65 |Korea  |C000001    |Premium         |customer1@email.com |F     |Customer_1 |2022-04-25|
|55 |UK     |C000002    |Premium         |customer2@email.com |M     |Customer_2 |2022-08-17|
|55 |Korea  |C000003    |Premium         |customer3@email.com |F     |Customer_3 |2023-07-13|
...</code></pre>
<p>고객 원천 데이터는 총 <strong>10,000건</strong> 생성되었다. </p>
<hr>
<h2 id="32-상품-데이터-생성">3.2 상품 데이터 생성</h2>
<pre><code class="language-python">categories = [&#39;Electronics&#39;, &#39;Clothing&#39;, &#39;Home&#39;, &#39;Sports&#39;, &#39;Books&#39;, &#39;Toys&#39;]

product_schema = StructType([
    StructField(&quot;product_id&quot;, StringType(), False),
    StructField(&quot;product_name&quot;, StringType(), False),
    StructField(&quot;category&quot;, StringType(), False),
    StructField(&quot;price&quot;, DoubleType(), False),
    StructField(&quot;stock_quantity&quot;, IntegerType(), False),
    StructField(&quot;supplier&quot;, StringType(), False)
])

product_data = []

for i in range(1, 501):
    category = random.choice(categories)
    product_data.append({
        &#39;product_id&#39;: f&#39;P{i:04d}&#39;,
        &#39;product_name&#39;: f&#39;{category}_Product_{i}&#39;,
        &#39;category&#39;: category,
        &#39;price&#39;: float(round(random.uniform(10, 1000), 2)),
        &#39;stock_quantity&#39;: int(random.randint(0, 500)),
        &#39;supplier&#39;: f&#39;Supplier_{random.randint(1, 20)}&#39;
    })

df_products_raw = spark.createDataFrame(product_data, schema=product_schema)
display(df_products_raw.limit(10))</code></pre>
<h3 id="output-예시-1">output 예시</h3>
<pre><code class="language-text">상품 수: 500</code></pre>
<pre><code class="language-text">+----------+----------------------+-----------+------+--------------+-----------+
|product_id|product_name          |category   |price |stock_quantity|supplier   |
+----------+----------------------+-----------+------+--------------+-----------+
|P0001     |Toys_Product_1        |Toys       |238.09|440           |Supplier_14|
|P0002     |Clothing_Product_2    |Clothing   |407.88|182           |Supplier_20|
|P0003     |Electronics_Product_3 |Electronics|943.35|171           |Supplier_1 |
...</code></pre>
<p>상품 원천 데이터는 총 <strong>500건</strong> 생성되었다. </p>
<hr>
<h2 id="33-주문-데이터-생성">3.3 주문 데이터 생성</h2>
<pre><code class="language-python">order_data = []
order_id = 1

for customer in customer_data[:5000]:
    num_orders = random.randint(1, 15)
    reg_date = datetime.strptime(customer[&#39;registration_date&#39;], &#39;%Y-%m-%d&#39;)

    for _ in range(num_orders):
        order_date = reg_date + timedelta(days=random.randint(1, 700))
        product = random.choice(product_data)
        quantity = random.randint(1, 5)

        order_data.append({
            &#39;order_id&#39;: f&#39;O{order_id:08d}&#39;,
            &#39;customer_id&#39;: customer[&#39;customer_id&#39;],
            &#39;product_id&#39;: product[&#39;product_id&#39;],
            &#39;order_date&#39;: order_date.strftime(&#39;%Y-%m-%d %H:%M:%S&#39;),
            &#39;quantity&#39;: quantity,
            &#39;unit_price&#39;: product[&#39;price&#39;],
            &#39;total_amount&#39;: float(round(product[&#39;price&#39;] * quantity, 2)),
            &#39;status&#39;: random.choice([&#39;Completed&#39;, &#39;Completed&#39;, &#39;Completed&#39;, &#39;Cancelled&#39;, &#39;Returned&#39;]),
            &#39;payment_method&#39;: random.choice([&#39;Credit Card&#39;, &#39;PayPal&#39;, &#39;Bank Transfer&#39;, &#39;Cash&#39;])
        })
        order_id += 1

df_orders_raw = spark.createDataFrame(order_data)
display(df_orders_raw.limit(10))</code></pre>
<h3 id="output-예시-2">output 예시</h3>
<pre><code class="language-text">주문 수: 39,777</code></pre>
<pre><code class="language-text">+-----------+-------------------+----------+--------------+--------+---------+----------+------------+
|customer_id|order_date         |order_id  |payment_method|product_id|quantity|status    |total_amount|
+-----------+-------------------+----------+--------------+--------+---------+----------+------------+
|C000001    |2022-10-28 00:00:00|O00000001 |Cash          |P0172   |4        |Completed |1601.20     |
|C000001    |2022-10-31 00:00:00|O00000002 |PayPal        |P0194   |4        |Returned  |2668.56     |
|C000001    |2022-11-27 00:00:00|O00000003 |Cash          |P0414   |2        |Completed |1895.08     |
...</code></pre>
<p>주문 원천 데이터는 총 <strong>39,777건</strong> 생성되었다. </p>
<hr>
<h2 id="34-고객-활동-로그-생성">3.4 고객 활동 로그 생성</h2>
<pre><code class="language-python">activity_data = []
activity_id = 1

for customer in customer_data:
    num_activities = random.randint(5, 100)
    reg_date = datetime.strptime(customer[&#39;registration_date&#39;], &#39;%Y-%m-%d&#39;)

    for _ in range(num_activities):
        activity_date = reg_date + timedelta(
            days=random.randint(1, 700),
            hours=random.randint(0, 23),
            minutes=random.randint(0, 59)
        )

        activity_data.append({
            &#39;activity_id&#39;: f&#39;A{activity_id:08d}&#39;,
            &#39;customer_id&#39;: customer[&#39;customer_id&#39;],
            &#39;activity_date&#39;: activity_date.strftime(&#39;%Y-%m-%d %H:%M:%S&#39;),
            &#39;activity_type&#39;: random.choice([&#39;page_view&#39;, &#39;product_view&#39;, &#39;cart_add&#39;, &#39;search&#39;, &#39;login&#39;]),
            &#39;duration_seconds&#39;: random.randint(10, 600),
            &#39;device&#39;: random.choice([&#39;mobile&#39;, &#39;desktop&#39;, &#39;tablet&#39;])
        })
        activity_id += 1

df_activity_raw = spark.createDataFrame(activity_data)
display(df_activity_raw.limit(10))</code></pre>
<h3 id="output-예시-3">output 예시</h3>
<pre><code class="language-text">활동 로그 수: 523,286</code></pre>
<pre><code class="language-text">+-------------------+----------+-------------+-----------+--------+----------------+
|activity_date      |activity_id|activity_type|customer_id|device  |duration_seconds|
+-------------------+----------+-------------+-----------+--------+----------------+
|2023-12-31 11:38:00|A00000001 |product_view |C000001    |tablet  |137             |
|2023-12-11 04:46:00|A00000002 |product_view |C000001    |desktop |402             |
|2023-01-04 08:47:00|A00000003 |search       |C000001    |tablet  |44              |
...</code></pre>
<p>고객 활동 로그는 총 <strong>523,286건</strong> 생성되었다. </p>
<hr>
<h1 id="4-bronze-layer">4. Bronze Layer</h1>
<ul>
<li><strong>Raw 데이터 그대로 저장</strong></li>
<li><strong>스키마 최소 변경</strong></li>
<li><strong>원본 보존 목적</strong></li>
</ul>
<pre><code class="language-python">df_customers_raw.write.format(&quot;delta&quot;).mode(&quot;overwrite&quot;).saveAsTable(&quot;bronze_customers&quot;)
df_products_raw.write.format(&quot;delta&quot;).mode(&quot;overwrite&quot;).saveAsTable(&quot;bronze_products&quot;)
df_orders_raw.write.format(&quot;delta&quot;).mode(&quot;overwrite&quot;).saveAsTable(&quot;bronze_orders&quot;)
df_activity_raw.write.format(&quot;delta&quot;).mode(&quot;overwrite&quot;).saveAsTable(&quot;bronze_customer_activity&quot;)</code></pre>
<h3 id="output-예시-4">output 예시</h3>
<pre><code class="language-text">BRONZE LAYER:
- bronze_customer_activity: 523,286 rows
- bronze_customers: 10,000 rows
- bronze_orders: 39,777 rows
- bronze_products: 500 rows</code></pre>
<p>Bronze는 원본 데이터 수집 및 저장에 초점을 두므로, 이 단계에서는 정제보다 <strong>보존성</strong>이 핵심이다. </p>
<hr>
<h1 id="5-silver-layer">5. Silver Layer</h1>
<ul>
<li><strong>데이터 정제</strong></li>
<li><strong>타입 변환</strong></li>
<li><strong>중복 제거</strong></li>
<li><strong>파생 컬럼 생성</strong></li>
<li><strong>조인 및 통합</strong></li>
</ul>
<hr>
<h2 id="51-고객-데이터-정제">5.1 고객 데이터 정제</h2>
<pre><code class="language-python">df_customers_silver = spark.table(&quot;bronze_customers&quot;) \
    .withColumn(&quot;registration_date&quot;, to_date(col(&quot;registration_date&quot;))) \
    .withColumn(&quot;email_domain&quot;, split(col(&quot;email&quot;), &quot;@&quot;).getItem(1)) \
    .withColumn(
        &quot;age_group&quot;,
        when(col(&quot;age&quot;) &lt; 25, &quot;18-24&quot;)
        .when(col(&quot;age&quot;) &lt; 35, &quot;25-34&quot;)
        .when(col(&quot;age&quot;) &lt; 45, &quot;35-44&quot;)
        .when(col(&quot;age&quot;) &lt; 55, &quot;45-54&quot;)
        .otherwise(&quot;55+&quot;)
    ) \
    .withColumn(&quot;processed_date&quot;, current_timestamp()) \
    .dropDuplicates([&quot;customer_id&quot;]) \
    .filter(col(&quot;email&quot;).isNotNull())

display(df_customers_silver.limit(10))</code></pre>
<h3 id="output-예시-5">output 예시</h3>
<pre><code class="language-text">+---+-------+-----------+----------------+----------------------+--------+-----------+----------------+------------+-----------------------+
|age|country|customer_id|customer_segment|email                 |gender  |name       |registration_date|email_domain|age_group|processed_date|
+---+-------+-----------+----------------+----------------------+--------+-----------+----------------+------------+---------+-----------------------+
|43 |Korea  |C002484    |Standard        |customer2484@email.com|F       |Customer_2484|2023-01-19     |email.com   |35-44    |2026-03-26 ...|
|24 |France |C002512    |Basic           |customer2512@email.com|Other   |Customer_2512|2023-10-04     |email.com   |18-24    |2026-03-26 ...|
...</code></pre>
<p>고객 Silver 테이블은 registration_date를 date 타입으로 변환하고, email_domain과 age_group을 새로 만들었다. 실제 데이터 품질 검증 결과 <strong>중복 고객 수 0건</strong>, <strong>이메일 NULL 고객 수 0건</strong>이었다. </p>
<hr>
<h2 id="52-상품-데이터-정제">5.2 상품 데이터 정제</h2>
<pre><code class="language-python">df_products_silver = spark.table(&quot;bronze_products&quot;) \
    .withColumn(
        &quot;price_category&quot;,
        when(col(&quot;price&quot;) &lt; 50, &quot;Low&quot;)
        .when(col(&quot;price&quot;) &lt; 200, &quot;Medium&quot;)
        .when(col(&quot;price&quot;) &lt; 500, &quot;High&quot;)
        .otherwise(&quot;Premium&quot;)
    ) \
    .withColumn(&quot;in_stock&quot;, col(&quot;stock_quantity&quot;) &gt; 0) \
    .withColumn(&quot;processed_date&quot;, current_timestamp()) \
    .dropDuplicates([&quot;product_id&quot;]) \
    .filter(col(&quot;price&quot;) &gt; 0)

display(df_products_silver.limit(10))</code></pre>
<h3 id="output-예시-6">output 예시</h3>
<pre><code class="language-text">+----------+----------------------+-----------+------+--------------+-----------+--------------+--------+-----------------------+
|product_id|product_name          |category   |price |stock_quantity|supplier   |price_category|in_stock|processed_date         |
+----------+----------------------+-----------+------+--------------+-----------+--------------+--------+-----------------------+
|P0092     |Books_Product_92      |Books      |424.25|486           |Supplier_9 |High          |true    |2026-03-26 ...|
|P0112     |Sports_Product_112    |Sports     |770.13|147           |Supplier_8 |Premium       |true    |2026-03-26 ...|
...</code></pre>
<p>상품 Silver 테이블에서는 가격 구간과 재고 여부를 추가했다. 데이터 품질 검증 결과 <strong>유효하지 않은 가격 제품 수는 0건</strong>이었다. </p>
<hr>
<h2 id="53-주문-데이터-정제-및-고객상품-조인">5.3 주문 데이터 정제 및 고객/상품 조인</h2>
<pre><code class="language-python">df_orders_silver = spark.table(&quot;bronze_orders&quot;) \
    .withColumn(&quot;order_date&quot;, to_timestamp(col(&quot;order_date&quot;))) \
    .withColumn(&quot;order_year&quot;, year(col(&quot;order_date&quot;))) \
    .withColumn(&quot;order_month&quot;, month(col(&quot;order_date&quot;))) \
    .withColumn(&quot;order_quarter&quot;, quarter(col(&quot;order_date&quot;))) \
    .withColumn(&quot;order_dayofweek&quot;, dayofweek(col(&quot;order_date&quot;))) \
    .withColumn(&quot;is_weekend&quot;, col(&quot;order_dayofweek&quot;).isin([1, 7])) \
    .withColumn(&quot;revenue&quot;, when(col(&quot;status&quot;) == &quot;Completed&quot;, col(&quot;total_amount&quot;)).otherwise(0)) \
    .withColumn(&quot;processed_date&quot;, current_timestamp()) \
    .dropDuplicates([&quot;order_id&quot;]) \
    .filter(col(&quot;quantity&quot;) &gt; 0)

df_orders_silver = df_orders_silver \
    .join(
        df_customers_silver.select(&quot;customer_id&quot;, &quot;customer_segment&quot;, &quot;country&quot;, &quot;age_group&quot;),
        &quot;customer_id&quot;,
        &quot;left&quot;
    ) \
    .join(
        df_products_silver.select(&quot;product_id&quot;, &quot;category&quot;, &quot;price_category&quot;),
        &quot;product_id&quot;,
        &quot;left&quot;
    )

display(df_orders_silver.limit(10))</code></pre>
<h3 id="output-예시-7">output 예시</h3>
<pre><code class="language-text">+----------+-----------+-------------------+----------+--------------+--------+---------+----------+------------+----------+-----------+-------------+---------------+-------+---------+-----------+--------------+
|product_id|customer_id|order_date         |order_id  |payment_method|quantity|status   |total_amount|unit_price |order_year|order_month|order_quarter|order_dayofweek|is_weekend|revenue|customer_segment|category|price_category|
+----------+-----------+-------------------+----------+--------------+--------+---------+----------+------------+----------+-----------+-------------+---------------+-------+---------+-----------+--------------+
|P0334     |C002508    |2024-11-05 00:00:00|O00020014 |Credit Card   |3       |Completed|1220.58    |406.86     |2024      |11         |4            |3              |false     |1220.58|Premium        |Sports  |High|
|P0261     |C002510    |2023-04-24 00:00:00|O00020024 |Cash          |4       |Cancelled|2062.56    |515.64     |2023      |4          |2            |2              |false     |0.0    |Premium        |Books   |Premium|
...</code></pre>
<p>주문 Silver에서는 시간 파생 컬럼과 revenue 컬럼을 만들고, 고객/상품 정보까지 조인했다. 품질 검증 결과 <strong>수량 이상 주문 수는 0건</strong>이었다. </p>
<hr>
<h2 id="54-고객-활동-데이터-정제">5.4 고객 활동 데이터 정제</h2>
<pre><code class="language-python">df_activity_silver = spark.table(&quot;bronze_customer_activity&quot;) \
    .withColumn(&quot;activity_date&quot;, to_timestamp(col(&quot;activity_date&quot;))) \
    .withColumn(&quot;activity_year&quot;, year(col(&quot;activity_date&quot;))) \
    .withColumn(&quot;activity_month&quot;, month(col(&quot;activity_date&quot;))) \
    .withColumn(&quot;activity_hour&quot;, hour(col(&quot;activity_date&quot;))) \
    .withColumn(&quot;is_active_hours&quot;, col(&quot;activity_hour&quot;).between(9, 21)) \
    .withColumn(&quot;processed_date&quot;, current_timestamp()) \
    .dropDuplicates([&quot;activity_id&quot;])

display(df_activity_silver.limit(10))</code></pre>
<h3 id="output-예시-8">output 예시</h3>
<pre><code class="language-text">+-------------------+----------+-------------+-----------+--------+----------------+-------------+--------------+-------------+---------------+-----------------------+
|activity_date      |activity_id|activity_type|customer_id|device  |duration_seconds|activity_year|activity_month|activity_hour|is_active_hours|processed_date         |
+-------------------+----------+-------------+-----------+--------+----------------+-------------+--------------+-------------+---------------+-----------------------+
|2022-10-07 02:44:00|A00000061 |search       |C000002    |desktop |322             |2022         |10            |2            |false          |2026-03-26 ...|
|2024-11-25 02:19:00|A00000118 |login        |C000003    |tablet  |231             |2024         |11            |2            |false          |2026-03-26 ...|
...</code></pre>
<hr>
<h2 id="55-silver-layer-저장-결과">5.5 Silver Layer 저장 결과</h2>
<pre><code class="language-python">df_customers_silver.write.format(&quot;delta&quot;).mode(&quot;overwrite&quot;).saveAsTable(&quot;silver_customers&quot;)
df_products_silver.write.format(&quot;delta&quot;).mode(&quot;overwrite&quot;).saveAsTable(&quot;silver_products&quot;)
df_orders_silver.write.format(&quot;delta&quot;).mode(&quot;overwrite&quot;).saveAsTable(&quot;silver_orders&quot;)
df_activity_silver.write.format(&quot;delta&quot;).mode(&quot;overwrite&quot;).saveAsTable(&quot;silver_customer_activity&quot;)</code></pre>
<h3 id="output-예시-9">output 예시</h3>
<pre><code class="language-text">SILVER LAYER:
- silver_customer_activity: 523,286 rows
- silver_customers: 10,000 rows
- silver_orders: 39,777 rows
- silver_products: 500 rows</code></pre>
<p>Silver는 단순 저장 계층이 아니라, 이후 분석과 ML에 바로 활용할 수 있도록 데이터를 <strong>정제하고 의미 있게 변환하는 계층</strong>이다. </p>
<hr>
<h1 id="6-gold-layer">6. Gold Layer</h1>
<ul>
<li><strong>비즈니스 메트릭 집계</strong></li>
<li><strong>고객/상품 관점 분석</strong></li>
<li><strong>머신러닝용 Feature Table 생성</strong></li>
</ul>
<hr>
<h2 id="61-고객별-주문-통계">6.1 고객별 주문 통계</h2>
<pre><code class="language-python">df_customer_order_stats = spark.table(&quot;silver_orders&quot;) \
    .groupBy(&quot;customer_id&quot;) \
    .agg(
        count(&quot;order_id&quot;).alias(&quot;total_orders&quot;),
        sum(&quot;total_spent&quot;).alias(&quot;total_spent&quot;),
        avg(&quot;total_amount&quot;).alias(&quot;avg_order_value&quot;),
        max(&quot;order_date&quot;).alias(&quot;last_order_date&quot;),
        min(&quot;order_date&quot;).alias(&quot;first_order_date&quot;),
        sum(when(col(&quot;status&quot;) == &quot;Completed&quot;, 1).otherwise(0)).alias(&quot;completed_orders&quot;),
        sum(when(col(&quot;status&quot;) == &quot;Cancelled&quot;, 1).otherwise(0)).alias(&quot;cancelled_orders&quot;),
        sum(when(col(&quot;status&quot;) == &quot;Returned&quot;, 1).otherwise(0)).alias(&quot;returned_orders&quot;),
        countDistinct(&quot;product_id&quot;).alias(&quot;unique_products&quot;),
        countDistinct(&quot;category&quot;).alias(&quot;unique_categories&quot;)
    )

display(df_customer_order_stats.limit(10))</code></pre>
<h3 id="output-예시-10">output 예시</h3>
<pre><code class="language-text">+-----------+------------+-----------+---------------+-------------------+-------------------+----------------+----------------+---------------+---------------+-----------------+
|customer_id|total_orders |total_spent|avg_order_value|last_order_date    |first_order_date   |completed_orders|cancelled_orders|returned_orders|unique_products|unique_categories|
+-----------+------------+-----------+---------------+-------------------+-------------------+----------------+----------------+---------------+---------------+-----------------+
|C003194    |8           |5284.30    |660.5375       |2024-06-24 00:00:00|2022-09-15 00:00:00|4               |1               |3              |8              |5|
|C003128    |8           |6651.05    |831.38125      |2024-04-25 00:00:00|2022-11-17 00:00:00|8               |0               |0              |8              |5|
...</code></pre>
<p>실제 결과를 보면 고객별 주문 수, 총 결제 금액, 평균 주문 금액, 완료/취소/반품 수 등이 집계된다. </p>
<hr>
<h2 id="62-고객별-활동-통계">6.2 고객별 활동 통계</h2>
<pre><code class="language-python">df_customer_activity_stats = spark.table(&quot;silver_customer_activity&quot;) \
    .groupBy(&quot;customer_id&quot;) \
    .agg(
        count(&quot;*&quot;).alias(&quot;total_activities&quot;),
        sum(&quot;duration_seconds&quot;).alias(&quot;total_duration_seconds&quot;),
        avg(&quot;duration_seconds&quot;).alias(&quot;avg_duration_seconds&quot;),
        max(&quot;activity_date&quot;).alias(&quot;last_activity_date&quot;),
        sum(when(col(&quot;activity_type&quot;) == &quot;page_view&quot;, 1).otherwise(0)).alias(&quot;page_views&quot;),
        sum(when(col(&quot;activity_type&quot;) == &quot;product_view&quot;, 1).otherwise(0)).alias(&quot;product_views&quot;),
        sum(when(col(&quot;activity_type&quot;) == &quot;cart_add&quot;, 1).otherwise(0)).alias(&quot;cart_adds&quot;),
        sum(when(col(&quot;activity_type&quot;) == &quot;search&quot;, 1).otherwise(0)).alias(&quot;searches&quot;),
        sum(when(col(&quot;activity_type&quot;) == &quot;login&quot;, 1).otherwise(0)).alias(&quot;logins&quot;)
    )

display(df_customer_activity_stats.limit(10))</code></pre>
<h3 id="output-예시-11">output 예시</h3>
<pre><code class="language-text">+-----------+----------------+----------------------+--------------------+-------------------+----------+-------------+---------+--------+------+
|customer_id|total_activities|total_duration_seconds|avg_duration_seconds|last_activity_date |page_views|product_views|cart_adds|searches|logins|
+-----------+----------------+----------------------+--------------------+-------------------+----------+-------------+---------+--------+------+
|C000805    |50              |18044                 |360.88              |2025-09-21 15:13:00|12        |10           |12       |6       |13|
|C001875    |77              |24167                 |313.8571            |2025-09-09 07:13:00|11        |18           |14       |13      |25|
...</code></pre>
<p>고객의 행동량과 세션 지속 시간, 이벤트별 빈도를 함께 볼 수 있다. </p>
<hr>
<h2 id="63-고객-360도-뷰-생성">6.3 고객 360도 뷰 생성</h2>
<pre><code class="language-python">df_customer_360 = spark.table(&quot;silver_customers&quot;) \
    .join(df_customer_order_stats, &quot;customer_id&quot;, &quot;left&quot;) \
    .join(df_customer_activity_stats, &quot;customer_id&quot;, &quot;left&quot;) \
    .fillna(0)

display(df_customer_360.limit(10))</code></pre>
<h3 id="output-예시-12">output 예시</h3>
<pre><code class="language-text">+-----------+---+-------+----------------+----------------------+--------+-----------+----------------+------------+---------+------------+------------+-----------+---------------+-------------------+-------------------+----------------+----------------+---------------+---------------+-----------------+----------------+----------------------+--------------------+-------------------+----------+-------------+---------+--------+------+
|customer_id|age|country|customer_segment|email                 |gender  |name       |registration_date|email_domain|age_group|total_orders|total_spent |avg_order_value|last_order_date|first_order_date|completed_orders|cancelled_orders|returned_orders|unique_products|unique_categories|total_activities|total_duration_seconds|avg_duration_seconds|last_activity_date|page_views|product_views|cart_adds|searches|logins|
+-----------+---+-------+----------------+----------------------+--------+-----------+----------------+------------+---------+------------+-----------+---------------+-------------------+-------------------+----------------+----------------+---------------+---------------+-----------------+----------------+----------------------+--------------------+-------------------+----------+-------------+---------+--------+------+
|C003194    |38 |USA    |Premium         |customer3194@email.com|Other   |Customer_3194|2022-08-31    |email.com   |35-44    |8           |5284.30    |660.5375       |2024-06-24         |2022-09-15         |4               |1               |3              |8              |5                |43              |11515                 |267.79              |2024-07-30 23:00:00|8         |14           |5        |10      |12|
...</code></pre>
<p>Gold의 핵심은 이런 <strong>통합 Feature Table</strong>이다. 고객 기본 정보 + 주문 행동 + 활동 로그가 하나의 뷰로 합쳐지면서, 이후 BI나 ML에서 바로 활용 가능해진다. 실제로 이 Gold Layer에는 <strong>10,000건의 customer_360</strong> 데이터가 저장되었다. </p>
<hr>
<h2 id="64-제품-판매-분석">6.4 제품 판매 분석</h2>
<pre><code class="language-python">df_product_sales = spark.table(&quot;silver_orders&quot;) \
    .filter(col(&quot;status&quot;) == &quot;Completed&quot;) \
    .groupBy(&quot;product_id&quot;, &quot;category&quot;, &quot;price_category&quot;) \
    .agg(
        count(&quot;*&quot;).alias(&quot;total_sales&quot;),
        sum(&quot;quantity&quot;).alias(&quot;total_quantity_sold&quot;),
        sum(&quot;revenue&quot;).alias(&quot;total_revenue&quot;),
        avg(&quot;unit_price&quot;).alias(&quot;avg_selling_price&quot;),
        countDistinct(&quot;customer_id&quot;).alias(&quot;unique_customers&quot;)
    )

display(df_product_sales.limit(10))</code></pre>
<h3 id="output-예시-13">output 예시</h3>
<pre><code class="language-text">+----------+--------+--------------+----------+-------------------+-------------+-----------------+----------------+
|product_id |category|price_category|total_sales|total_quantity_sold|total_revenue|avg_selling_price|unique_customers|
+----------+--------+--------------+----------+-------------------+-------------+-----------------+----------------+
|P0081     |Books   |Premium       |62        |163                |158489.79    |972.33           |62|
|P0139     |Books   |Premium       |53        |161                |156413.11    |971.51           |53|
...</code></pre>
<p>제품별 판매량과 매출, 고객 수를 기준으로 <strong>잘 팔리는 상품</strong>을 바로 확인할 수 있다. Gold Layer에 생성된 product_sales 테이블은 총 <strong>500건</strong>이다. </p>
<hr>
<h2 id="65-월별-매출-트렌드">6.5 월별 매출 트렌드</h2>
<pre><code class="language-python">df_monthly_revenue = spark.table(&quot;silver_orders&quot;) \
    .filter(col(&quot;status&quot;) == &quot;Completed&quot;) \
    .groupBy(&quot;order_year&quot;, &quot;order_month&quot;) \
    .agg(
        count(&quot;order_id&quot;).alias(&quot;total_orders&quot;),
        sum(&quot;revenue&quot;).alias(&quot;total_revenue&quot;),
        countDistinct(&quot;customer_id&quot;).alias(&quot;unique_customers&quot;),
        avg(&quot;revenue&quot;).alias(&quot;avg_order_value&quot;)
    ) \
    .orderBy(&quot;order_year&quot;, &quot;order_month&quot;)

display(df_monthly_revenue)</code></pre>
<h3 id="output-예시-14">output 예시</h3>
<pre><code class="language-text">+----------+-----------+------------+-------------+----------------+---------------+
|order_year|order_month|total_orders|total_revenue|unique_customers|avg_order_value|
+----------+-----------+------------+-------------+----------------+---------------+
|2022      |1          |22          |32431.79     |20              |1474.17|
|2022      |2          |56          |93850.61     |54              |1675.90|
...</code></pre>
<p>월별 매출 트렌드 테이블은 총 <strong>47건</strong> 생성되었다. 이를 통해 월별 주문 수, 매출, 고객 수 변화를 시계열로 분석할 수 있다. </p>
<hr>
<h1 id="7-데이터-품질-검증">7. 데이터 품질 검증</h1>
<p>Silver와 Gold 이후에는 데이터 품질 확인도 수행했다.</p>
<pre><code class="language-python">print(&quot;중복 고객 수:&quot;, duplicate_customers)
print(&quot;이메일 NULL 고객 수:&quot;, null_emails)
print(&quot;유효하지 않은 가격 제품 수:&quot;, invalid_prices)
print(&quot;수량 이상 주문 수:&quot;, invalid_quantity_orders)</code></pre>
<h3 id="output">output</h3>
<pre><code class="language-text">중복 고객 수: 0
이메일 NULL 고객 수: 0
유효하지 않은 가격 제품 수: 0
수량 이상 주문 수: 0</code></pre>
<p>즉, 이번 파이프라인에서는 기본적인 품질 이슈 없이 데이터가 정제되었다. </p>
<hr>
<h1 id="8-ml-layer---고객-이탈-예측">8. ML Layer - 고객 이탈 예측</h1>
<p>Gold의 customer_360 데이터를 기반으로 고객 이탈 예측 모델을 학습했다.</p>
<hr>
<h2 id="81-feature-데이터-준비">8.1 Feature 데이터 준비</h2>
<pre><code class="language-python">df_ml = df_customer_360.select(
    &quot;customer_id&quot;,
    &quot;total_orders&quot;,
    &quot;total_spent&quot;,
    &quot;avg_order_value&quot;,
    &quot;completed_orders&quot;,
    &quot;cancelled_orders&quot;,
    &quot;returned_orders&quot;,
    &quot;total_activities&quot;,
    &quot;avg_duration_seconds&quot;,
    &quot;logins&quot;,
    &quot;cart_adds&quot;,
    &quot;is_churned&quot;
)</code></pre>
<h3 id="output-예시-15">output 예시</h3>
<pre><code class="language-text">전체 이탈률: 100.00%
총 고객 수: 5,000
이탈 고객 수: 5,000</code></pre>
<p>그리고 실제 label 분포를 확인해보면 다음과 같았다.</p>
<pre><code class="language-text">+----------+-----+
|is_churned|count|
+----------+-----+
|1         |5000 |
+----------+-----+</code></pre>
<p>즉, 이번 실습에서는 <strong>모든 고객이 churn=1로 라벨링되는 문제</strong>가 있었다. 이 결과는 모델 자체보다도 <strong>label 생성 로직을 다시 점검해야 한다는 신호</strong>로 보는 게 맞다. </p>
<hr>
<h2 id="82-feature-vector-생성-및-모델-학습">8.2 Feature Vector 생성 및 모델 학습</h2>
<pre><code class="language-python">feature_cols = [
    &quot;total_orders&quot;,
    &quot;total_spent&quot;,
    &quot;avg_order_value&quot;,
    &quot;completed_orders&quot;,
    &quot;cancelled_orders&quot;,
    &quot;returned_orders&quot;,
    &quot;total_activities&quot;,
    &quot;avg_duration_seconds&quot;,
    &quot;logins&quot;,
    &quot;cart_adds&quot;
]

assembler = VectorAssembler(inputCols=feature_cols, outputCol=&quot;features&quot;)

rf = RandomForestClassifier(
    featuresCol=&quot;features&quot;,
    labelCol=&quot;is_churned&quot;,
    numTrees=100,
    maxDepth=5,
    seed=42
)

pipeline = Pipeline(stages=[assembler, rf])

train_df, test_df = df_ml.randomSplit([0.8, 0.2], seed=42)
model = pipeline.fit(train_df)
predictions = model.transform(test_df)</code></pre>
<hr>
<h2 id="83-모델-평가">8.3 모델 평가</h2>
<pre><code class="language-python">evaluator = BinaryClassificationEvaluator(
    labelCol=&quot;is_churned&quot;,
    rawPredictionCol=&quot;rawPrediction&quot;,
    metricName=&quot;areaUnderROC&quot;
)

auc = evaluator.evaluate(predictions)
print(&quot;AUC:&quot;, auc)</code></pre>
<h3 id="해석">해석</h3>
<p>이번 노트북에서는 label이 전부 1로 생성되어 있어서, 모델 평가는 형식적으로는 가능하더라도 <strong>정상적인 churn 예측 실험이라고 보기 어렵다</strong>.
즉, 이 단계에서 가장 중요한 것은 모델 튜닝이 아니라 <strong>churn 정의를 현실적으로 재설계하는 것</strong>이다. 예를 들어 아래처럼 정의하는 편이 더 적절할 수 있다.</p>
<ul>
<li>최근 90일 이내 주문이 없는 고객</li>
<li>최근 30일 이내 로그인/활동이 없는 고객</li>
<li>취소/반품 비율이 높고 최근 재구매가 없는 고객</li>
</ul>
<hr>
<h2 id="9-정리">9. 정리</h2>
<p>이번 실습을 통해 메달리온 아키텍처의 흐름을 다음과 같이 확인할 수 있었다.</p>
<ul>
<li><strong>Bronze Layer</strong>: 원본 데이터를 그대로 적재하여 보존</li>
<li><strong>Silver Layer</strong>: 타입 변환, 정제, 조인, 파생 컬럼 생성</li>
<li><strong>Gold Layer</strong>: 고객 360도 뷰, 제품 판매 분석, 월별 매출 트렌드 같은 비즈니스 집계 생성</li>
<li><strong>ML Layer</strong>: Gold 데이터를 기반으로 churn 예측 모델 학습 시도</li>
</ul>
<p>특히 Gold Layer에서 생성된 <code>customer_360</code>은 분석과 머신러닝 모두에 활용할 수 있는 대표적인 Feature Store 형태의 결과물이라고 볼 수 있다. 반면 ML 단계에서는 <strong>label 설계가 결과를 크게 좌우한다는 점</strong>도 함께 확인할 수 있었다. Bronze/Silver/Gold 산출물의 실제 row 수와 데이터 품질 검증 결과, 그리고 ML label 분포는 모두 노트북 실행 결과에서 확인되었다. </p>
<h2 id="10-다음-단계">10. 다음 단계</h2>
<h3 id="실시간-파이프라인-구축">실시간 파이프라인 구축</h3>
<ul>
<li><strong>Delta Live Tables</strong>: 실시간 데이터 파이프라인 자동화</li>
<li><strong>Auto Loader</strong>: 증분 데이터 로딩</li>
<li><strong>Structured Streaming</strong>: 실시간 이벤트 처리</li>
</ul>
<h3 id="모델-운영">모델 운영</h3>
<ul>
<li><strong>MLflow Model Registry</strong>: 모델 버전 관리</li>
<li><strong>Model Serving</strong>: REST API로 모델 배포</li>
<li><strong>모니터링</strong>: 모델 성능 및 드리프트 감지</li>
</ul>
<h3 id="고급-분석">고급 분석</h3>
<ul>
<li><strong>제품 추천 시스템</strong>: 협업 필터링</li>
<li><strong>고객 세그멘테이션</strong>: 클러스터링</li>
<li><strong>수요 예측</strong>: 시계열 분석</li>
</ul>
<h3 id="거버넌스--보안">거버넌스 &amp; 보안</h3>
<ul>
<li><strong>Unity Catalog</strong>: 중앙 집중식 거버넌스</li>
<li><strong>Delta Sharing</strong>: 안전한 데이터 공유</li>
<li><strong>액세스 제어</strong>: 세밀한 권한 관리</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[MicrosoftDataSchool] 55일차 - AzureDatabricks Pyspark]]></title>
            <link>https://velog.io/@rudin_/MicrosoftDataSchool-55%EC%9D%BC%EC%B0%A8-AzureDatabricks-Pyspark</link>
            <guid>https://velog.io/@rudin_/MicrosoftDataSchool-55%EC%9D%BC%EC%B0%A8-AzureDatabricks-Pyspark</guid>
            <pubDate>Wed, 25 Mar 2026 08:16:07 GMT</pubDate>
            <description><![CDATA[<h3 id="compute-편집">compute 편집</h3>
<p><img src="https://velog.velcdn.com/images/rudin_/post/cd791d1b-4ee6-48b9-8f82-e61fa269a871/image.png" alt=""></p>
<ul>
<li>다룰 데이터가 클 수록 노드 수 늘리기</li>
<li>빠르게 대용량 작업을 해야 할 때 워커노드 자동 확장 자체보단 고정으로 노드 수를 늘리는게 당장 속도에는 좋음</li>
</ul>
<h1 id="dataengineering">DataEngineering</h1>
<p>쿼리문에 익숙하면 SparkSQL, 그게 아니라면 Pyspark를 사용하는 것이 보통임</p>
<h2 id="sparksql">SparkSQL</h2>
<p>DataFrame API를 사용하여 Spark SQL의 기본 개념을 설명</p>
<ol>
<li>SQL 쿼리 실행</li>
<li>테이블에서 DataFrame 생성</li>
<li>DataFrame 변환을 사용하여 동일한 쿼리 작성</li>
<li>DataFrame 액션을 사용하여 계산 트리거</li>
<li>DataFrame과 SQL 간 변환</li>
</ol>
<ul>
<li><a href="https://spark.apache.org/docs/latest/api/python/reference/pyspark.sql/spark_session.html" target="_blank">SparkSession</a>: <strong><code>sql</code></strong>, <strong><code>table</code></strong></li>
<li><a href="https://spark.apache.org/docs/latest/api/python/reference/pyspark.sql/dataframe.html" target="_blank">DataFrame</a>:</li>
<li>변환: <strong><code>select</code></strong>, <strong><code>where</code></strong>, <strong><code>orderBy</code></strong></li>
<li>액션: <strong><code>show</code></strong>, <strong><code>count</code></strong>, <strong><code>take</code></strong></li>
<li>기타 방법: <strong><code>printSchema</code></strong>, <strong><code>schema</code></strong>, <strong><code>createOrReplaceTempView</code></strong></li>
</ul>
<h3 id="다중-인터페이스">다중 인터페이스</h3>
<p>Spark SQL은 다중 인터페이스를 갖춘 구조화된 데이터 처리를 위한 모듈
Spark SQL은 두 가지 방법으로 상호 작용 가능</p>
<ol>
<li>SQL 쿼리 실행</li>
<li>DataFrame API 사용</li>
</ol>
<h4 id="1-sql-쿼리-실행">1. SQL 쿼리 실행</h4>
<pre><code class="language-sql">%sql
SELECT name, price
FROM products
WHERE price &lt; 200
ORDER BY price</code></pre>
<h4 id="2-dataframe-api-사용">2. Dataframe API 사용</h4>
<pre><code class="language-python">display(spark
        .table(&quot;products&quot;)
        .select(&quot;name&quot;, &quot;price&quot;)
        .where(&quot;price &lt; 200&quot;)
        .orderBy(&quot;price&quot;)
       )</code></pre>
<p>참고: <code>.</code> 입력 후 <code>ctrl</code> + <code>space</code> 하면 사용 가능한 메소드 제시됨
<img src="https://velog.velcdn.com/images/rudin_/post/55041981-743c-4c01-bb27-84f5b8ed7b36/image.png" alt=""></p>
<h2 id="쿼리-실행">쿼리 실행</h2>
<p>위와 같이 입력하면 Spark SQL 엔진은 Spark 클러스터에서 최적화하고 실행하는 데 사용되는 것과 동일한 쿼리 계획을 생성</p>
<p><strong>참고</strong></p>
<ul>
<li>복원력 있는 분산 데이터셋(RDD)은 Spark 클러스터에서 처리되는 데이터셋의 저수준 표현</li>
<li>초기 버전의 Spark에서는 <a href="https://spark.apache.org/docs/latest/rdd-programming-guide.html" target="_blank">RDD를 직접 조작하는 코드</a>를 작성해야 했음</li>
<li>최신 버전의 Spark에서는 더 높은 수준의 DataFrame API를 사용해야 함</li>
<li>Spark는 이를 자동으로 저수준 RDD 작업으로 컴파일</li>
</ul>
<h2 id="spark-api-documentation">Spark API Documentation</h2>
<p><a href="https://spark.apache.org/docs/latest/api/scala/org/apache/spark/index.html" target="_blank">Scala API</a>와 <a href="https://spark.apache.org/docs/latest/api/python/index.html" target="_blank">Python API</a>가 가장 일반적으로 사용</p>
<ul>
<li>scala 문서는 일반적으로 더 포괄적이고 Python 문서는 더 많은 코드 예제를 포함하는 경향</li>
<li>최근에는 Python이 대세</li>
<li>Scala API에서는 <strong><code>org.apache.spark.sql</code></strong></li>
<li>Python API에서는 <strong><code>pyspark.sql</code></strong></li>
</ul>
<h2 id="sparksession">SparkSession</h2>
<p><strong><code>SparkSession</code></strong> 클래스는 DataFrame API를 사용하는 Spark의 모든 기능에 대한 단일 진입점
Databricks 노트북에서는 SparkSession이 자동으로 생성되어 <strong><code>spark</code></strong>라는 변수에 저장</p>
<pre><code class="language-python">%python
spark</code></pre>
<p><img src="https://velog.velcdn.com/images/rudin_/post/6425db35-213c-49cf-9709-a7680efff4ec/image.png" alt=""></p>
<h3 id="dataframe-변수에-저장">DataFrame 변수에 저장</h3>
<p>SparkSession의 <code>table</code> 메서드를 사용하여 <code>products</code> 테이블에서 DataFrame을 생성했었음. 이 DataFrame을 <strong>products_df</strong> 변수에 저장</p>
<pre><code class="language-python">%python
products_df = spark.table(&quot;products&quot;)</code></pre>
<h3 id="sparksession-메서드"><strong>SparkSession</strong> 메서드</h3>
<table>
<thead>
<tr>
<th>메서드</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>sql</td>
<td>주어진 쿼리의 결과를 나타내는 DataFrame을 반환합니다.</td>
</tr>
<tr>
<td>table</td>
<td>지정된 테이블을 DataFrame으로 반환합니다.</td>
</tr>
<tr>
<td>read</td>
<td>DataFrame으로 데이터를 읽는 데 사용할 수 있는 DataFrameReader를 반환합니다.</td>
</tr>
<tr>
<td>range</td>
<td>시작부터 끝까지(제외) 범위에 있는 요소를 포함하는 열과 단계 값 및 파티션 개수를 가진 DataFrame을 생성합니다.</td>
</tr>
<tr>
<td>createDataFrame</td>
<td>주로 테스트용으로 사용되는 튜플 목록에서 DataFrame을 생성합니다.</td>
</tr>
</tbody></table>
<p>SparkSession 메서드로 SQL 실행도 가능</p>
<pre><code class="language-python">result_df = spark.sql(&quot;&quot;&quot;
SELECT name, price
FROM products
WHERE price &lt; 200
ORDER BY price
&quot;&quot;&quot;)

display(result_df)</code></pre>
<h2 id="dataframes">DataFrames</h2>
<p>DataFrame API의 메서드를 사용하여 쿼리를 표현하면 결과가 DataFrame으로 반환
<strong>DataFrame</strong>은 명명된 열로 그룹화된 데이터의 분산된 컬렉션</p>
<pre><code class="language-python">budget_df = (spark
             .table(&quot;products&quot;)
             .select(&quot;name&quot;, &quot;price&quot;)
             .where(&quot;price &lt; 200&quot;)
             .orderBy(&quot;price&quot;)
            )</code></pre>
<p><code>display</code>를 사용하여 데이터프레임의 결과 출력 가능</p>
<pre><code class="language-python">display(budget_df)</code></pre>
<p><strong>스키마</strong>는 데이터프레임의 열 이름과 유형을 정의
<strong><code>schema</code></strong> 속성을 사용하여 데이터프레임의 스키마에 액세스</p>
<pre><code class="language-python">budget_df.schema</code></pre>
<pre><code>Out[17]: StructType([StructField(&#39;name&#39;, StringType(), True), StructField(&#39;price&#39;, DoubleType(), True)])</code></pre><p><strong><code>printSchema()</code></strong> 메서드를 사용하여 이 스키마의 더 나은 출력을 확인</p>
<pre><code class="language-python">budget_df.printSchema()</code></pre>
<pre><code>root
 |-- name: string (nullable = true)
 |-- price: double (nullable = true)
</code></pre><h3 id="변환">변환</h3>
<p><strong><code>budget_df</code></strong>를 생성할 때 <strong><code>select</code></strong>, <strong><code>where</code></strong>, <strong><code>orderBy</code></strong>와 같은 일련의 DataFrame 변환 메서드를 사용했음</p>
<ul>
<li>변환은 DataFrame에서 동작하고 DataFrame을 반환하므로, 변환 메서드를 연결하여 새로운 DataFrame을 생성 가능</li>
<li>하지만 이러한 연산은 단독으로 실행될 수 없음 → 변환 메서드는 <strong>지연 평가</strong>되기 때문</li>
<li>다음 셀을 실행해도 계산이 트리거되지 않음</li>
</ul>
<pre><code class="language-python">(products_df
  .select(&quot;name&quot;, &quot;price&quot;)
  .where(&quot;price &lt; 200&quot;)
  .orderBy(&quot;price&quot;))</code></pre>
<h3 id="동작">동작</h3>
<p>반대로 DataFrame 동작은 계산을 트리거하는 메서드
<strong><code>show</code></strong> 동작은 다음 셀에서 변환을 실행하도록 함</p>
<pre><code class="language-python">(products_df
  .select(&quot;name&quot;, &quot;price&quot;)
  .where(&quot;price &lt; 200&quot;)
  .orderBy(&quot;price&quot;)
  .show())</code></pre>
<pre><code>+--------------------+-----+
|                name|price|
+--------------------+-----+
|Standard Foam Pillow| 59.0|
|    King Foam Pillow| 79.0|
|Standard Down Pillow|119.0|
|    King Down Pillow|159.0|
+--------------------+-----+</code></pre><h3 id="dataframe-작업-메서드">DataFrame 작업 메서드</h3>
<table>
<thead>
<tr>
<th>메서드</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>show</td>
<td>DataFrame의 상위 n개 행을 표 형식으로 표시합니다.</td>
</tr>
<tr>
<td>count</td>
<td>DataFrame의 행 수를 반환합니다.</td>
</tr>
<tr>
<td>describe, summary</td>
<td>숫자 및 문자열 열에 대한 기본 통계를 계산합니다.</td>
</tr>
<tr>
<td>first, head</td>
<td>첫 번째 행을 반환합니다.</td>
</tr>
<tr>
<td>collect</td>
<td>이 DataFrame의 모든 행을 포함하는 배열을 반환합니다.</td>
</tr>
<tr>
<td>take</td>
<td>DataFrame의 처음 n개 행을 포함하는 배열을 반환합니다.</td>
</tr>
</tbody></table>
<h3 id="dataframe과-sql-간-변환">DataFrame과 SQL 간 변환</h3>
<p><strong><code>createOrReplaceTempView</code></strong>는 DataFrame을 기반으로 임시 뷰를 생성
임시 뷰의 수명은 DataFrame을 생성하는 데 사용된 SparkSession에 연결</p>
<pre><code class="language-python">budget_df.createOrReplaceTempView(&quot;budget&quot;)
display(spark.sql(&quot;SELECT * FROM budget&quot;))</code></pre>
<h1 id="spark-sql-실습">Spark SQL 실습</h1>
<h4 id="작업">작업</h4>
<ol>
<li><strong><code>events</code></strong> 테이블에서 DataFrame 생성</li>
<li>DataFrame 표시 및 스키마 검사</li>
<li><strong><code>macOS</code></strong> 이벤트 필터링 및 정렬에 변환 적용</li>
<li>결과 개수 계산 및 처음 5개 행 가져오기</li>
<li>SQL 쿼리를 사용하여 동일한 DataFrame 생성<h4 id="메서드">메서드</h4>
</li>
</ol>
<ul>
<li><a href="https://spark.apache.org/docs/latest/api/python/reference/pyspark.sql/spark_session.html" target="_blank">SparkSession</a>: <strong><code>sql</code></strong>, <strong><code>table</code></strong></li>
<li><a href="https://spark.apache.org/docs/latest/api/python/reference/pyspark.sql/dataframe.html" target="_blank">DataFrame</a> 변환: <strong><code>select</code></strong>, <strong><code>where</code></strong>, <strong><code>orderBy</code></strong></li>
<li><a href="https://spark.apache.org/docs/latest/api/python/reference/api/pyspark.sql.DataFrame.html" target="_blank">DataFrame</a> 작업: <strong><code>select</code></strong>, <strong><code>count</code></strong>, <strong><code>take</code></strong></li>
<li>기타 <a href="https://spark.apache.org/docs/latest/api/python/reference/pyspark.sql/dataframe.html" target="_blank">DataFrame</a> 메서드: <strong><code>printSchema</code></strong>, <strong><code>schema</code></strong>, <strong><code>createOrReplaceTempView</code></strong></li>
</ul>
<h3 id="1-events-테이블에서-dataframe-생성">1. <strong><code>events</code></strong> 테이블에서 DataFrame 생성</h3>
<ul>
<li>SparkSession을 사용하여 <strong><code>events</code></strong> 테이블에서 DataFrame을 생성합니다.</li>
</ul>
<pre><code class="language-python"># TODO
events_df = (spark
             .table(&quot;events&quot;)
            )</code></pre>
<h3 id="2-dataframe-표시-및-스키마-검사">2. DataFrame 표시 및 스키마 검사</h3>
<ul>
<li>위의 메서드를 사용하여 DataFrame 내용과 스키마를 검사합니다.</li>
</ul>
<pre><code class="language-python"># TODO
events_df.printSchema()</code></pre>
<h3 id="3-macos-이벤트-필터링-및-정렬에-변환-적용">3. <strong><code>macOS</code></strong> 이벤트 필터링 및 정렬에 변환 적용</h3>
<ul>
<li><strong><code>device</code></strong>가 <strong><code>macOS</code></strong>인 행 필터링</li>
<li><strong><code>event_timestamp</code></strong>로 행 정렬</li>
</ul>
<pre><code class="language-python"># TODO
mac_df = (events_df
          .where(&quot;device = &#39;macOS&#39;&quot;)
          .orderBy(&quot;event_timestamp&quot;)
         )</code></pre>
<h3 id="4-결과-개수를-세고-처음-5개-행-가져오기">4. 결과 개수를 세고 처음 5개 행 가져오기</h3>
<ul>
<li>DataFrame 액션을 사용하여 행 개수를 세고 가져오기</li>
</ul>
<pre><code class="language-python"># TODO
num_rows = mac_df.count()
rows = mac_df.take(5)</code></pre>
<h3 id="5-sql-쿼리를-사용하여-동일한-dataframe-생성">5. SQL 쿼리를 사용하여 동일한 DataFrame 생성</h3>
<ul>
<li>SparkSession을 사용하여 <strong><code>events</code></strong> 테이블에 SQL 쿼리 실행</li>
<li>SQL 명령을 사용하여 이전에 사용한 것과 동일한 필터 및 정렬 쿼리 작성</li>
</ul>
<pre><code class="language-python"># TODO
mac_sql_df = spark.sql(
  &quot;&quot;&quot;
  SELECT * FROM events WHERE device = &#39;macOS&#39; ORDER BY event_timestamp
  &quot;&quot;&quot;
)

display(mac_sql_df)</code></pre>
<h2 id="dataframe--column">DataFrame &amp; Column</h2>
<h4 id="목표">목표</h4>
<ol>
<li>열 생성</li>
<li>열 부분 집합 생성</li>
<li>열 추가 또는 교체</li>
<li>행 부분 집합 생성</li>
<li>행 정렬</li>
</ol>
<ul>
<li><a href="https://spark.apache.org/docs/latest/api/python/reference/pyspark.sql/dataframe.html" target="_blank">DataFrame</a>: <strong><code>select</code></strong>, <strong><code>selectExpr</code></strong>, <strong><code>drop</code></strong>, <strong><code>withColumn</code></strong>, <strong><code>withColumnRenamed</code></strong>, <strong><code>filter</code></strong>, <strong><code>distinct</code></strong>, <strong><code>limit</code></strong>, <strong><code>sort</code></strong></li>
<li><a href="https://spark.apache.org/docs/latest/api/python/reference/pyspark.sql/column.html" target="_blank">Column</a>: <strong><code>alias</code></strong>, <strong><code>isin</code></strong>, <strong><code>cast</code></strong>, <strong><code>isNotNull</code></strong>, <strong><code>desc</code></strong>, <strong><code>operators</code></strong></li>
</ul>
<h3 id="열-표현식">열 표현식</h3>
<pre><code class="language-python">from pyspark.sql.functions import col

print(events_df.device)
print(events_df[&quot;device&quot;])
print(col(&quot;device&quot;))</code></pre>
<pre><code>Column&lt;&#39;device&#39;&gt;
Column&lt;&#39;device&#39;&gt;
Column&lt;&#39;device&#39;&gt;</code></pre><p>Scala는 DataFrame의 기존 열을 기반으로 새 열을 생성하는 추가 구문을 지원</p>
<pre><code class="language-java">%scala
$&quot;device&quot;</code></pre>
<pre><code>res0: org.apache.spark.sql.ColumnName = device</code></pre><h3 id="열-연산자-및-메서드">열 연산자 및 메서드</h3>
<table>
<thead>
<tr>
<th>메서드</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>*, + , &lt;, &gt;=</td>
<td>수학 및 비교 연산자</td>
</tr>
<tr>
<td>==, !=</td>
<td>같음 및 같지 않음 테스트(Scala 연산자는 <strong><code>===</code></strong> 및 <strong><code>=!=</code></strong>입니다)</td>
</tr>
<tr>
<td>alias</td>
<td>열에 별칭을 지정합니다</td>
</tr>
<tr>
<td>cast, astype</td>
<td>열을 다른 데이터 유형으로 변환합니다</td>
</tr>
<tr>
<td>isNull, isNotNull, isNan</td>
<td>null인지, null이 아닌지, NaN인지</td>
</tr>
<tr>
<td>asc, desc</td>
<td>열의 오름차순/내림차순 정렬 표현식을 반환합니다</td>
</tr>
</tbody></table>
<pre><code class="language-python">col(&quot;ecommerce.purchase_revenue_in_usd&quot;) + col(&quot;ecommerce.total_item_quantity&quot;)
col(&quot;event_timestamp&quot;).desc()
(col(&quot;ecommerce.purchase_revenue_in_usd&quot;) * 100).cast(&quot;int&quot;)</code></pre>
<pre><code>Out[10]: Column&lt;&#39;CAST((ecommerce.purchase_revenue_in_usd * 100) AS INT)&#39;&gt;</code></pre><p>실제 사용</p>
<pre><code class="language-python">rev_df = (events_df
         .filter(col(&quot;ecommerce.purchase_revenue_in_usd&quot;).isNotNull())
         .withColumn(&quot;purchase_revenue&quot;, (col(&quot;ecommerce.purchase_revenue_in_usd&quot;) * 100).cast(&quot;int&quot;))
         .withColumn(&quot;avg_purchase_revenue&quot;, col(&quot;ecommerce.purchase_revenue_in_usd&quot;) / col(&quot;ecommerce.total_item_quantity&quot;))
         .sort(col(&quot;avg_purchase_revenue&quot;).desc())
        )

display(rev_df)</code></pre>
<h3 id="dataframe-변환-메서드">DataFrame 변환 메서드</h3>
<table>
<thead>
<tr>
<th>메서드</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><strong><code>select</code></strong></td>
<td>각 요소에 대해 주어진 표현식을 계산하여 새 DataFrame을 반환합니다.</td>
</tr>
<tr>
<td><strong><code>drop</code></strong></td>
<td>열을 삭제한 새 DataFrame을 반환합니다.</td>
</tr>
<tr>
<td><strong><code>withColumnRenamed</code></strong></td>
<td>열 이름이 변경된 새 DataFrame을 반환합니다.</td>
</tr>
<tr>
<td><strong><code>withColumn</code></strong></td>
<td>열을 추가하거나 이름이 같은 기존 열을 대체하여 새 DataFrame을 반환합니다.</td>
</tr>
<tr>
<td><strong><code>filter</code></strong>, <strong><code>where</code></strong></td>
<td>주어진 조건을 사용하여 행을 필터링합니다.</td>
</tr>
<tr>
<td><strong><code>sort</code></strong>, <strong><code>orderBy</code></strong></td>
<td>주어진 표현식으로 정렬된 새 DataFrame을 반환합니다.</td>
</tr>
<tr>
<td><strong><code>dropDuplicates</code></strong>, <strong><code>distinct</code></strong></td>
<td>중복 행을 제거한 새 DataFrame을 반환합니다.</td>
</tr>
<tr>
<td><strong><code>limit</code></strong></td>
<td>처음 n개 행을 가져와 새 DataFrame을 반환합니다.</td>
</tr>
<tr>
<td><strong><code>groupBy</code></strong></td>
<td>지정된 열을 사용하여 DataFrame을 그룹화하여 해당 열에 대한 집계를 실행할 수 있습니다.</td>
</tr>
</tbody></table>
<h3 id="열-부분-집합">열 부분 집합</h3>
<p>DataFrame 변환을 사용하여 열 부분 집합 만들기</p>
<h4 id="select"><strong><code>select()</code></strong></h4>
<p>열 목록 또는 열 기반 표현식을 선택</p>
<pre><code class="language-python">devices_df = events_df.select(&quot;user_id&quot;, &quot;device&quot;)
display(devices_df)</code></pre>
<pre><code class="language-python">from pyspark.sql.functions import col

locations_df = events_df.select(
    &quot;user_id&quot;, 
    col(&quot;geo.city&quot;).alias(&quot;city&quot;), 
    col(&quot;geo.state&quot;).alias(&quot;state&quot;)
)
display(locations_df)</code></pre>
<h4 id="selectexpr"><strong><code>selectExpr()</code></strong></h4>
<p>SQL 표현식 목록을 선택</p>
<pre><code class="language-python">apple_df = events_df.selectExpr(&quot;user_id&quot;, &quot;device in (&#39;macOS&#39;, &#39;iOS&#39;) as apple_user&quot;)
display(apple_df)</code></pre>
<h4 id="drop"><strong><code>drop()</code></strong></h4>
<p>주어진 열을 삭제한 후 새 DataFrame을 반환. 
문자열 또는 Column 객체로 지정됨.
문자열을 사용하여 여러 열을 지정.</p>
<pre><code class="language-python">anonymous_df = events_df.drop(&quot;user_id&quot;, &quot;geo&quot;, &quot;device&quot;)
display(anonymous_df)</code></pre>
<pre><code class="language-python">no_sales_df = events_df.drop(col(&quot;ecommerce&quot;))
display(no_sales_df)</code></pre>
<h3 id="열-추가-또는-바꾸기">열 추가 또는 바꾸기</h3>
<p>DataFrame 변환을 사용하여 열을 추가하거나 바꿈</p>
<h4 id="withcolumn"><strong><code>withColumn()</code></strong></h4>
<p>같은 이름의 열을 추가하거나 기존 열을 대체하여 새 DataFrame을 반환</p>
<pre><code class="language-python">mobile_df = events_df.withColumn(&quot;mobile&quot;, col(&quot;device&quot;).isin(&quot;iOS&quot;, &quot;Android&quot;))
display(mobile_df)</code></pre>
<p><img src="https://velog.velcdn.com/images/rudin_/post/26d276e4-398e-4ed4-8c8e-bf783a2cd115/image.png" alt=""></p>
<pre><code class="language-python">purchase_quantity_df = events_df.withColumn(&quot;purchase_quantity&quot;, col(&quot;ecommerce.total_item_quantity&quot;).cast(&quot;int&quot;))
purchase_quantity_df.printSchema()</code></pre>
<p><img src="https://velog.velcdn.com/images/rudin_/post/e1544c8e-360b-4b04-8ee8-15ce679fce02/image.png" alt=""></p>
<h4 id="withcolumnrenamed"><strong><code>withColumnRenamed()</code></strong></h4>
<p>열 이름이 변경된 새 DataFrame을 반환</p>
<pre><code class="language-python">location_df = events_df.withColumnRenamed(&quot;geo&quot;, &quot;location&quot;)
display(location_df)</code></pre>
<p><img src="https://velog.velcdn.com/images/rudin_/post/a86c05cc-4c03-4c7f-95bc-3b4a60b8429b/image.png" alt=""></p>
<h3 id="행-부분-집합">행 부분 집합</h3>
<p>DataFrame 변환을 사용하여 행 부분 집합을 만듦</p>
<h4 id="filter"><strong><code>filter()</code></strong></h4>
<p>주어진 SQL 표현식 또는 열 기반 조건을 사용하여 행을 필터링합니다.</p>
<h5 id="별칭-where">별칭: <strong><code>where</code></strong></h5>
<pre><code class="language-python">purchases_df = events_df.filter(&quot;ecommerce.total_item_quantity &gt; 0&quot;)
display(purchases_df)</code></pre>
<p><img src="https://velog.velcdn.com/images/rudin_/post/04bc10c8-474c-419f-b016-f417569fd9cb/image.png" alt=""></p>
<pre><code class="language-python">revenue_df = events_df.filter(col(&quot;ecommerce.purchase_revenue_in_usd&quot;).isNotNull())
display(revenue_df)</code></pre>
<p><img src="https://velog.velcdn.com/images/rudin_/post/b6188805-ad88-446e-a8b8-2c7207667f0e/image.png" alt=""></p>
<pre><code class="language-python">android_df = events_df.filter((col(&quot;traffic_source&quot;) != &quot;direct&quot;) &amp; (col(&quot;device&quot;) == &quot;Android&quot;))
display(android_df)</code></pre>
<p><img src="https://velog.velcdn.com/images/rudin_/post/9c90ff55-4e5f-4edf-8efd-4f3b5e3eb1be/image.png" alt=""></p>
<h4 id="dropduplicates"><strong><code>dropDuplicates()</code></strong></h4>
<p>중복 행을 제거한 새 DataFrame을 반환합니다. 선택적으로 일부 열만 고려합니다.</p>
<h5 id="별칭-distinct">별칭: <strong><code>distinct</code></strong></h5>
<pre><code class="language-python">display(events_df.distinct())</code></pre>
<pre><code class="language-python">distinct_users_df = events_df.dropDuplicates([&quot;user_id&quot;])
display(distinct_users_df)</code></pre>
<h4 id="limit"><strong><code>limit()</code></strong></h4>
<p>처음 n개 행을 가져와 새 DataFrame을 반환</p>
<pre><code class="language-python">limit_df = events_df.limit(100)
display(limit_df)</code></pre>
<p><img src="https://velog.velcdn.com/images/rudin_/post/9c40aafd-44c5-4bb7-b8fa-a2583c5a53fd/image.png" alt=""></p>
<h3 id="행-정렬">행 정렬</h3>
<h4 id="sort"><strong><code>sort()</code></strong></h4>
<p>주어진 열 또는 표현식을 기준으로 정렬된 새 DataFrame을 반환</p>
<h5 id="별칭-orderby">별칭: <strong><code>orderBy</code></strong></h5>
<pre><code class="language-python">increase_timestamps_df = events_df.sort(&quot;event_timestamp&quot;)
display(increase_timestamps_df)</code></pre>
<pre><code class="language-python">decrease_timestamp_df = events_df.sort(col(&quot;event_timestamp&quot;).desc())
display(decrease_timestamp_df)</code></pre>
<pre><code class="language-python">increase_sessions_df = events_df.orderBy([&quot;user_first_touch_timestamp&quot;, &quot;event_timestamp&quot;])
display(increase_sessions_df)</code></pre>
<pre><code class="language-python">decrease_sessions_df = events_df.sort(col(&quot;user_first_touch_timestamp&quot;).desc(), col(&quot;event_timestamp&quot;))
display(decrease_sessions_df)</code></pre>
<hr>

<h2 id="구매-수익-실습">구매 수익 실습</h2>
<h5 id="작업-1">작업</h5>
<ol>
<li>각 이벤트의 구매 수익 추출</li>
<li>수익이 null이 아닌 이벤트 필터링</li>
<li>수익이 있는 이벤트 유형 확인</li>
<li>불필요한 열 삭제</li>
</ol>
<h5 id="메서드-1">메서드</h5>
<ul>
<li>DataFrame: <strong><code>select</code></strong>, <strong><code>drop</code></strong>, <strong><code>withColumn</code></strong>, <strong><code>filter</code></strong>, <strong><code>dropDuplicates</code></strong></li>
<li>Column: <strong><code>isNotNull</code></strong></li>
</ul>
<pre><code class="language-python">events_df = spark.table(&quot;events&quot;)
display(events_df)

from pyspark.sql.functions import col

# 각 이벤트에 대한 구매 수익을 추출
revenue_df = events_df.withColumn(&quot;revenue&quot;, col(&quot;ecommerce.purchase_revenue_in_usd&quot;))
display(revenue_df)

# 매출이 null이 아닌 이벤트 필터링
purchases_df = revenue_df.filter(col(&quot;revenue&quot;).isNotNull())
display(purchases_df)

# 수익이 발생한 이벤트 유형 확인
distinct_df = purchases_df.dropDuplicates([&quot;event_name&quot;])
display(distinct_df)

# 불필요한 열 삭제
final_df = purchases_df.drop(&quot;event_name&quot;)
display(final_df)

# 불필요한 열 삭제를 제외한 모든 단계 한 번에 실행
final_df = (events_df
  .withColumn(&quot;revenue&quot;, col(&quot;ecommerce.purchase_revenue_in_usd&quot;))
  .filter(col(&quot;revenue&quot;).isNotNull())
  .drop(&quot;event_name&quot;)
)

display(final_df)</code></pre>
<hr>

<h2 id="집계">집계</h2>
<h4 id="목표-1">목표</h4>
<ol>
<li>지정된 열을 기준으로 데이터 그룹화</li>
<li>그룹화된 데이터 메서드를 적용하여 데이터 집계</li>
<li>내장 함수를 적용하여 데이터 집계</li>
</ol>
<h4 id="메서드-2">메서드</h4>
<ul>
<li><a href="https://spark.apache.org/docs/latest/api/python/reference/pyspark.sql/dataframe.html" target="_blank">DataFrame</a>: <strong><code>groupBy</code></strong></li>
<li><a href="https://spark.apache.org/docs/latest/api/python/reference/pyspark.sql/grouping.html" target="_blank" target="_blank">그룹화된 데이터</a>: <strong><code>agg</code></strong>, <strong><code>avg</code></strong>, <strong><code>count</code></strong>, <strong><code>max</code></strong>, <strong><code>sum</code></strong></li>
<li><a href="https://spark.apache.org/docs/latest/api/python/reference/pyspark.sql/functions.html" target="_blank">내장 함수</a>: <strong><code>approx_count_distinct</code></strong>, <strong><code>avg</code></strong>, <strong><code>sum</code></strong></li>
</ul>
<pre><code class="language-python">df = spark.table(&quot;events&quot;)
display(df)</code></pre>
<h3 id="grouping-data">Grouping data</h3>
<h4 id="groupby">groupBy</h4>
<p>DataFrame의 <strong><code>groupBy</code></strong> 메서드를 사용하여 그룹화된 데이터 객체를 생성
이 그룹화된 데이터 객체는 Scala에서는 <strong><code>RelationalGroupedDataset</code></strong>, Python에서는 <strong><code>GroupedData</code></strong>라고 함</p>
<pre><code class="language-python">df = spark.read.format(&#39;csv&#39;).option(&#39;header&#39;, &#39;true&#39;).load(&#39;path/to/data.csv&#39;)df.groupBy(&quot;event_name&quot;)</code></pre>
<pre><code class="language-python"># 여러개도 가능
df.groupBy(&quot;geo.state&quot;, &quot;geo.city&quot;)</code></pre>
<h3 id="그룹화된-데이터-메서드">그룹화된 데이터 메서드</h3>
<p><a href="https://spark.apache.org/docs/latest/api/python/reference/pyspark.sql/grouping.html" target="_blank">GroupedData</a> 객체에서 다양한 집계 메서드를 사용할 수 있습니다.</p>
<table>
<thead>
<tr>
<th>메서드</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>agg</td>
<td>일련의 집계 열을 지정하여 집계를 계산합니다.</td>
</tr>
<tr>
<td>avg</td>
<td>각 그룹의 각 숫자 열에 대한 평균값을 계산합니다.</td>
</tr>
<tr>
<td>count</td>
<td>각 그룹의 행 수를 센다.</td>
</tr>
<tr>
<td>max</td>
<td>각 그룹의 각 숫자 열에 대한 최대값을 계산합니다.</td>
</tr>
<tr>
<td>mean</td>
<td>각 그룹의 각 숫자 열에 대한 평균값을 계산합니다.</td>
</tr>
<tr>
<td>min</td>
<td>각 그룹의 각 숫자 열에 대한 최소값을 계산합니다.</td>
</tr>
<tr>
<td>pivot</td>
<td>현재 DataFrame의 열을 피벗(행의 값을 열로 바꾸기)하고 지정된 집계를 수행합니다.</td>
</tr>
<tr>
<td>sum</td>
<td>각 그룹의 각 숫자 열에 대한 합계를 계산합니다.</td>
</tr>
</tbody></table>
<pre><code class="language-python">event_counts_df = df.groupBy(&quot;event_name&quot;).count()
display(event_counts_df)</code></pre>
<pre><code class="language-python">avg_state_purchases_df = df.groupBy(&quot;geo.state&quot;).avg(&quot;ecommerce.purchase_revenue_in_usd&quot;)
display(avg_state_purchases_df)</code></pre>
<pre><code class="language-python">city_purchase_quantities_df = df.groupBy(&quot;geo.state&quot;, &quot;geo.city&quot;).sum(&quot;ecommerce.total_item_quantity&quot;, &quot;ecommerce.purchase_revenue_in_usd&quot;)
display(city_purchase_quantities_df)</code></pre>
<h3 id="내장-함수">내장 함수</h3>
<p>DataFrame 및 Column 변환 메서드 외에도 Spark의 내장 <a href="https://docs.databricks.com/spark/latest/spark-sql/language-manual/sql-ref-functions-builtin.html" target="_blank">SQL 함수</a> 모듈</p>
<p>Scala에서는 <a href="https://spark.apache.org/docs/latest/api/scala/org/apache/spark/sql/functions$.html" target="_blank"><strong><code>org.apache.spark.sql.functions</code></strong></a>이고, Python에서는 <a href="https://spark.apache.org/docs/latest/api/python/reference/pyspark.sql.html#functions" target="_blank"><strong><code>pyspark.sql.functions</code></strong></a></p>
<h3 id="집계-함수">집계 함수</h3>
<table>
<thead>
<tr>
<th>메서드</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>approx_count_distinct</td>
<td>그룹 내 고유 항목의 대략적인 개수를 반환합니다.</td>
</tr>
<tr>
<td>avg</td>
<td>그룹 내 값의 평균을 반환합니다.</td>
</tr>
<tr>
<td>collect_list</td>
<td>중복된 객체 목록을 반환합니다.</td>
</tr>
<tr>
<td>corr</td>
<td>두 숫자형 열의 상관계수를 반환합니다.</td>
</tr>
<tr>
<td>max</td>
<td>각 그룹의 각 숫자 열에 대한 최댓값을 계산합니다.</td>
</tr>
<tr>
<td>mean</td>
<td>각 그룹의 각 숫자 열에 대한 평균값을 계산합니다.</td>
</tr>
<tr>
<td>stddev_samp</td>
<td>그룹 내 표현식의 표본 표준 편차를 반환합니다.</td>
</tr>
<tr>
<td>sumDistinct</td>
<td>표현식 내 고유 값의 합계를 반환합니다.</td>
</tr>
<tr>
<td>var_pop</td>
<td>그룹 내 값의 모분산을 반환합니다.</td>
</tr>
</tbody></table>
<p>그룹화된 데이터 메서드 <a href="https://spark.apache.org/docs/latest/api/python/reference/api/pyspark.sql.GroupedData.agg.html#pyspark.sql.GroupedData.agg" target="_blank"><strong><code>agg</code></strong></a>를 사용하여 내장 집계 함수를 적용</p>
<p>이렇게 하면 결과 열에 <a href="https://spark.apache.org/docs/latest/api/python/reference/api/pyspark.sql.Column.alias.html" target="_blank"><strong><code>alias</code></strong></a>와 같은 다른 변환을 적용 가능 </p>
<pre><code class="language-python">from pyspark.sql.functions import sum

state_purchases_df = df.groupBy(&quot;geo.state&quot;).agg(sum(&quot;ecommerce.total_item_quantity&quot;).alias(&quot;total_purchases&quot;))
display(state_purchases_df)</code></pre>
<p>그룹화된 데이터에 여러 집계 함수 적용</p>
<pre><code class="language-python">from pyspark.sql.functions import avg, approx_count_distinct

state_aggregates_df = (df
                       .groupBy(&quot;geo.state&quot;)
                       .agg(avg(&quot;ecommerce.total_item_quantity&quot;).alias(&quot;avg_quantity&quot;),
                            approx_count_distinct(&quot;user_id&quot;).alias(&quot;distinct_users&quot;))
                      ) #큰 데이터에서는 count 후 distinct 시 부하 발생. 그럴 때 사용(보통 빅데이터에서는 정확한 값을 요구치 않기 떄문)

display(state_aggregates_df)</code></pre>
<h3 id="수학-함수">수학 함수</h3>
<table>
<thead>
<tr>
<th>메서드</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>ceil</td>
<td>주어진 열의 상한값을 계산합니다.</td>
</tr>
<tr>
<td>cos</td>
<td>주어진 값의 코사인을 계산합니다.</td>
</tr>
<tr>
<td>log</td>
<td>주어진 값의 자연 로그를 계산합니다.</td>
</tr>
<tr>
<td>round</td>
<td>HALF_UP 반올림 모드를 사용하여 e 열의 값을 소수점 이하 0자리로 반올림하여 반환합니다.</td>
</tr>
<tr>
<td>sqrt</td>
<td>지정된 부동 소수점 값의 제곱근을 계산합니다.</td>
</tr>
</tbody></table>
<pre><code class="language-python">from pyspark.sql.functions import cos, sqrt

display(spark.range(10)  # Create a DataFrame with a single column called &quot;id&quot; with a range of integer values
        .withColumn(&quot;sqrt&quot;, sqrt(&quot;id&quot;))
        .withColumn(&quot;cos&quot;, cos(&quot;id&quot;))
       )</code></pre>
<hr>

<h2 id="가장-높은-총-수익-실습">가장 높은 총 수익 실습</h2>
<ol>
<li>트래픽 소스별 매출 집계</li>
<li>총 매출 기준 상위 3개 트래픽 소스 가져오기</li>
<li>매출 열을 소수점 둘째 자리까지 정리</li>
</ol>
<h4 id="메서드-3">메서드</h4>
<ul>
<li><a href="https://spark.apache.org/docs/latest/api/python/reference/pyspark.sql/dataframe.html" target="_blank">DataFrame</a>: <strong><code>groupBy</code></strong>, <strong><code>sort</code></strong>, <strong><code>limit</code></strong></li>
<li><a href="https://spark.apache.org/docs/latest/api/python/reference/pyspark.sql/column.html" target="_blank">Column</a>: <strong><code>alias</code></strong>, <strong><code>desc</code></strong>, <strong><code>cast</code></strong>, <strong><code>operators</code></strong></li>
<li><a href="https://spark.apache.org/docs/latest/api/python/reference/pyspark.sql/functions.html" target="_blank">내장 함수</a>: <strong><code>avg</code></strong>, <strong><code>sum</code></strong></li>
</ul>
<pre><code class="language-python"># df 생성
from pyspark.sql.functions import col

# Purchase events logged on the BedBricks website
df = (spark.table(&quot;events&quot;)
      .withColumn(&quot;revenue&quot;, col(&quot;ecommerce.purchase_revenue_in_usd&quot;))
      .filter(col(&quot;revenue&quot;).isNotNull())
      .drop(&quot;event_name&quot;)
     )

display(df)

# 트래픽 소스별 총 수익
from pyspark.sql.functions import sum, avg, round, col

traffic_df = (df
              .groupBy(&quot;traffic_source&quot;)
              .agg(
                  round(sum(&quot;revenue&quot;), 1).alias(&quot;total_rev&quot;),
                  avg(&quot;revenue&quot;).alias(&quot;avg_rev&quot;)
              )
             )

display(traffic_df)

# 총 수익 기준 상위 3개 트래픽 소스 가져오기
from pyspark.sql.functions import desc
top_traffic_df = traffic_df.orderBy(desc(&quot;total_rev&quot;)).limit(3)


display(top_traffic_df)

# 매출 열의 소수점 이하 두자리까지 제한
final_df = (top_traffic_df
            .withColumn(&quot;avg_rev&quot;, (col(&quot;avg_rev&quot;) * 100).cast(&quot;long&quot;) / 100)
            .withColumn(&quot;total_rev&quot;, (col(&quot;total_rev&quot;) * 100).cast(&quot;long&quot;) / 100)
)

display(final_df)

# 내장 수학 함수를 사용하여 재작성
bonus_df = (top_traffic_df
            .withColumn(&quot;avg_rev&quot;, round(col(&quot;avg_rev&quot;), 2))
            .withColumn(&quot;total_rev&quot;, round(col(&quot;total_rev&quot;), 2))
)

display(bonus_df)

# 한 번에 체이닝으로 처리
chain_df = (df
            .groupBy(&quot;traffic_source&quot;)
            .agg(
                round(sum(&quot;revenue&quot;), 2).alias(&quot;total_rev&quot;),
                round(avg(&quot;revenue&quot;), 2).alias(&quot;avg_rev&quot;)
            )
            .orderBy(col(&quot;total_rev&quot;).desc()).limit(3)
)

display(chain_df)</code></pre>
<p><img src="https://velog.velcdn.com/images/rudin_/post/4cd18337-b19a-468c-93f6-3a849a45d150/image.png" alt=""></p>
<hr>

<h2 id="reader--writer">Reader &amp; Writer</h2>
<h5 id="목표-2">목표</h5>
<ol>
<li>CSV 파일에서 읽기</li>
<li>JSON 파일에서 읽기</li>
<li>DataFrame을 파일에 쓰기</li>
<li>DataFrame을 테이블에 쓰기</li>
<li>DataFrame을 Delta 테이블에 쓰기</li>
</ol>
<h5 id="방법">방법</h5>
<ul>
<li><a href="https://spark.apache.org/docs/latest/api/python/reference/pyspark.sql.html#input-and-output" target="_blank">DataFrameReader</a>: <strong><code>csv</code></strong>, <strong><code>json</code></strong>, <strong><code>option</code></strong>, <strong><code>schema</code></strong></li>
<li><a href="https://spark.apache.org/docs/latest/api/python/reference/pyspark.sql.html#input-and-output" target="_blank">DataFrameWriter</a>: <strong><code>mode</code></strong>, <strong><code>option</code></strong>, <strong><code>parquet</code></strong>, <strong><code>format</code></strong>, <strong><code>saveAsTable</code></strong></li>
<li><a href="https://spark.apache.org/docs/latest/api/python/reference/api/pyspark.sql.types.StructType.html#pyspark.sql.types.StructType" target="_blank">구조체 유형</a>: <strong><code>toDDL</code></strong></li>
</ul>
<h5 id="spark-유형">Spark 유형</h5>
<ul>
<li><a href="https://spark.apache.org/docs/latest/api/python/reference/pyspark.sql.html#data-types" target="_blank">유형</a>: <strong><code>ArrayType</code></strong>, <strong><code>DoubleType</code></strong>, <strong><code>IntegerType</code></strong>, <strong><code>LongType</code></strong>, <strong><code>StringType</code></strong>, <strong><code>StructType</code></strong>, <strong><code>StructField</code></strong></li>
</ul>
<h3 id="dataframereader">DataFrameReader</h3>
<p>외부 저장소 시스템에서 DataFrame을 로드하는 데 사용되는 인터페이스</p>
<p><strong><code>spark.read.parquet(&quot;path/to/files&quot;)</code></strong></p>
<p>DataFrameReader는 SparkSession 속성인 <strong><code>read</code></strong>를 통해 액세스 가능. </p>
<h4 id="csv-파일에서-읽기">CSV 파일에서 읽기</h4>
<p>DataFrameReader의 <strong><code>csv</code></strong> 메서드와 다음 옵션을 사용하여 CSV에서 읽기:</p>
<p>Tab separator, use first line as header, infer schema</p>
<pre><code class="language-python">users_df = (spark
           .read
           .option(&quot;sep&quot;, &quot;\t&quot;)
           .option(&quot;header&quot;, True)
           .option(&quot;inferSchema&quot;, True)
           .csv(DA.paths.users_csv)
          )

users_df.printSchema()</code></pre>
<p>Spark의 Python API를 사용하면 DataFrameReader 옵션을 csv 메서드의 매개변수로 지정 가능</p>
<pre><code class="language-python">users_df = (spark
           .read
           .csv(DA.paths.users_csv, sep=&quot;\t&quot;, header=True, inferSchema=True)
          )

users_df.printSchema()</code></pre>
<p>열 이름과 데이터 유형을 사용하여 <strong>StructType</strong>을 생성하여 스키마를 수동으로 정의</p>
<pre><code class="language-python">from pyspark.sql.types import LongType, StringType, StructType, StructField

user_defined_schema = StructType([
    StructField(&quot;user_id&quot;, StringType(), True),
    StructField(&quot;user_first_touch_timestamp&quot;, LongType(), True),
    StructField(&quot;email&quot;, StringType(), True)
])</code></pre>
<pre><code class="language-python">users_df = (spark
           .read
           .option(&quot;sep&quot;, &quot;\t&quot;)
           .option(&quot;header&quot;, True)
           .schema(user_defined_schema)
           .csv(DA.paths.users_csv)
          )</code></pre>
<p>또는 <a href="https://en.wikipedia.org/wiki/Data_definition_language" target="_blank">데이터 정의 언어(DDL)</a> 구문을 사용하여 스키마를 정의</p>
<pre><code class="language-python">ddl_schema = &quot;user_id string, user_first_touch_timestamp long, email string&quot;

users_df = (spark
           .read
           .option(&quot;sep&quot;, &quot;\t&quot;)
           .option(&quot;header&quot;, True)
           .schema(ddl_schema)
           .csv(DA.paths.users_csv)
          )</code></pre>
<h3 id="json-파일에서-읽기">JSON 파일에서 읽기</h3>
<p>DataFrameReader의 <strong><code>json</code></strong> 메서드와 infer schema 옵션을 사용하여 JSON에서 읽기</p>
<pre><code class="language-python">events_df = (spark
            .read
            .option(&quot;inferSchema&quot;, True)
            .json(DA.paths.events_json)
           )

events_df.printSchema()</code></pre>
<p>스키마 이름과 데이터 유형을 사용하여 <strong><code>StructType</code></strong>을 생성하여 데이터를 더 빠르게 읽기</p>
<pre><code class="language-python">from pyspark.sql.types import ArrayType, DoubleType, IntegerType, LongType, StringType, StructType, StructField

user_defined_schema = StructType([
    StructField(&quot;device&quot;, StringType(), True),
    StructField(&quot;ecommerce&quot;, StructType([
        StructField(&quot;purchaseRevenue&quot;, DoubleType(), True),
        StructField(&quot;total_item_quantity&quot;, LongType(), True),
        StructField(&quot;unique_items&quot;, LongType(), True)
    ]), True),
    StructField(&quot;event_name&quot;, StringType(), True),
    StructField(&quot;event_previous_timestamp&quot;, LongType(), True),
    StructField(&quot;event_timestamp&quot;, LongType(), True),
    StructField(&quot;geo&quot;, StructType([
        StructField(&quot;city&quot;, StringType(), True),
        StructField(&quot;state&quot;, StringType(), True)
    ]), True),
    StructField(&quot;items&quot;, ArrayType(
        StructType([
            StructField(&quot;coupon&quot;, StringType(), True),
            StructField(&quot;item_id&quot;, StringType(), True),
            StructField(&quot;item_name&quot;, StringType(), True),
            StructField(&quot;item_revenue_in_usd&quot;, DoubleType(), True),
            StructField(&quot;price_in_usd&quot;, DoubleType(), True),
            StructField(&quot;quantity&quot;, LongType(), True)
        ])
    ), True),
    StructField(&quot;traffic_source&quot;, StringType(), True),
    StructField(&quot;user_first_touch_timestamp&quot;, LongType(), True),
    StructField(&quot;user_id&quot;, StringType(), True)
])

events_df = (spark
            .read
            .schema(user_defined_schema)
            .json(DA.paths.events_json)
           )</code></pre>
<p>스칼라의 <strong><code>StructType</code></strong> 메서드인 <strong><code>toDDL</code></strong>을 사용하면 DDL 형식의 문자열을 자동으로 생성 가능</p>
<p>이 기능은 CSV 및 JSON 데이터를 처리하기 위해 DDL 형식의 문자열을 가져와야 하지만 문자열을 직접 작성하거나 스키마의 <strong><code>StructType</code></strong> 변형을 원하지 않을 때 편리</p>
<p>Python에서는 이 기능을 사용할 수 없지만, 노트북의 기능을 통해 두 언어를 모두 사용 가능</p>
<pre><code class="language-python">spark.conf.set(&quot;com.whatever.your_scope.events_path&quot;, DA.paths.events_json)</code></pre>
<p>Python 노트북에서 Scala 셀을 생성하여 데이터를 삽입하고 DDL 형식의 스키마를 생성</p>
<pre><code class="language-java">%scala
// Step 2 - config에서 값을 끌어오거나 복사하여 붙여넣습니다.
val eventsJsonPath = spark.conf.get(&quot;com.whatever.your_scope.events_path&quot;)

// Step 3 - JSON을 읽지만 스키마를 추론하게 합니다.
val eventsSchema = spark.read
                        .option(&quot;inferSchema&quot;, true)
                        .json(eventsJsonPath)
                        .schema.toDDL

// Step 4 - 스키마를 print하고, 선택한 후 복사합니다.
println(&quot;=&quot;*80)
println(eventsSchema)
println(&quot;=&quot;*80)</code></pre>
<pre><code class="language-python"># Step 5 - 위의 스키마를 붙여넣고 여기에서 볼 수 있듯이 변수에 할당합니다.
events_schema = &quot;`device` STRING,`ecommerce` STRUCT&lt;`purchase_revenue_in_usd`: DOUBLE, `total_item_quantity`: BIGINT, `unique_items`: BIGINT&gt;,`event_name` STRING,`event_previous_timestamp` BIGINT,`event_timestamp` BIGINT,`geo` STRUCT&lt;`city`: STRING, `state`: STRING&gt;,`items` ARRAY&lt;STRUCT&lt;`coupon`: STRING, `item_id`: STRING, `item_name`: STRING, `item_revenue_in_usd`: DOUBLE, `price_in_usd`: DOUBLE, `quantity`: BIGINT&gt;&gt;,`traffic_source` STRING,`user_first_touch_timestamp` BIGINT,`user_id` STRING&quot;

# Step 6 - 새로운 DDL 형식 문자열을 사용하여 JSON 데이터를 읽습니다.
events_df = (spark.read
                 .schema(events_schema)
                 .json(DA.paths.events_json))

display(events_df)</code></pre>
<p>경고: 운영 환경에서는 이 트릭 사용X
스키마 추론은 스키마를 추론하기 위해 소스 데이터 세트를 모두 읽어야 하므로 매우 느릴 수 있음</p>
<h3 id="파일에-dataframe-쓰기">파일에 DataFrame 쓰기</h3>
<p>ataFrameWriter의 <strong><code>parquet</code></strong> 메서드와 다음 구성을 사용하여 <strong><code>users_df</code></strong>를 parquet에 쓰기:</p>
<p>Snappy compression, overwrite mode</p>
<pre><code class="language-python">users_output_dir = DA.paths.working_dir + &quot;/users.parquet&quot;

(users_df
 .write
 .option(&quot;compression&quot;, &quot;snappy&quot;)
 .mode(&quot;overwrite&quot;)
 .parquet(users_output_dir)
)</code></pre>
<pre><code class="language-python">display(
    dbutils.fs.ls(users_output_dir)
)</code></pre>
<p>DataFrameReader와 마찬가지로 Spark의 Python API를 사용하면 <strong>parquet</strong> 메서드의 매개변수로 DataFrameWriter 옵션을 지정</p>
<pre><code class="language-python">(users_df
 .write
 .parquet(users_output_dir, compression=&quot;snappy&quot;, mode=&quot;overwrite&quot;)
)</code></pre>
<h4 id="테이블에-dataframe-쓰기">테이블에 DataFrame 쓰기</h4>
<p>DataFrameWriter 메서드인 <strong>saveAsTable`</strong>을 사용하여 테이블에 <strong><code>events_df</code></strong>를 쓰기</p>
<p><img src="https://files.training.databricks.com/images/icon_note_32.png" alt="참고"> 이 메서드는 DataFrame 메서드인 <strong><code>createOrReplaceTempView</code></strong>로 생성되는 로컬 뷰와 달리 전역 테이블을 생성</p>
<pre><code class="language-python">events_df.write.mode(&quot;overwrite&quot;).saveAsTable(&quot;events&quot;)</code></pre>
<pre><code class="language-python">print(DA.schema_name)</code></pre>
<h3 id="delta-lake">Delta Lake</h3>
<p>거의 모든 경우, 특히 Databricks 작업 공간에서 데이터를 참조할 경우 Delta Lake 형식을 사용하는 것이 가장 좋음</p>
<p><a href="https://delta.io/" target="_blank">Delta Lake</a>는 Spark와 함께 작동하여 데이터 레이크의 안정성을 높이도록 설계된 오픈 소스 기술</p>
<h4 id="delta-lake의-주요-기능">Delta Lake의 주요 기능</h4>
<ul>
<li>ACID 트랜잭션</li>
<li>확장 가능한 메타데이터 처리</li>
<li>통합 스트리밍 및 일괄 처리</li>
<li>시간 이동(데이터 버전 관리)</li>
<li>스키마 적용 및 진화</li>
<li>감사 기록</li>
<li>Parquet 형식</li>
<li>Apache Spark API와 호환</li>
</ul>
<h3 id="델타-테이블에-결과-쓰기">델타 테이블에 결과 쓰기</h3>
<p>DataFrameWriter의 <strong><code>save</code></strong> 메서드와 다음 구성을 사용하여 <strong><code>events_df</code></strong>를 작성합니다. 델타 형식 및 덮어쓰기 모드.</p>
<pre><code class="language-python">events_output_path = DA.paths.working_dir + &quot;/delta/events&quot;

(events_df
 .write
 .format(&quot;delta&quot;)
 .mode(&quot;overwrite&quot;)
 .save(events_output_path)
)</code></pre>
<hr>

<h2 id="데이터수집-실습">데이터수집 실습</h2>
<p>제품 데이터가 포함된 CSV 파일을 읽어옵니다.</p>
<h5 id="작업-2">작업</h5>
<ol>
<li>스키마 추론을 사용하여 읽기</li>
<li>사용자 정의 스키마를 사용하여 읽기</li>
<li>스키마를 DDL 형식 문자열로 사용하여 읽기</li>
<li>델타 형식을 사용하여 쓰기</li>
</ol>
<pre><code class="language-python"># 스키마 추론을 사용한 읽기
single_product_csv_file_path = f&quot;{DA.paths.products_csv}/part-00000-tid-1663954264736839188-daf30e86-5967-4173-b9ae-d1481d3506db-2367-1-c000.csv&quot;
print(dbutils.fs.head(single_product_csv_file_path))

products_csv_path = DA.paths.products_csv
products_df = spark.read.csv(products_csv_path,
                             header=True,
                             inferSchema=True)

products_df.printSchema()

# 사용자 정의 스키마로 읽기
from pyspark.sql.types import DoubleType, StringType, StructField, StructType

user_defined_schema = StructType([
    StructField(&quot;item_id&quot;, StringType(), True),
    StructField(&quot;name&quot;, StringType(), True),
    StructField(&quot;price&quot;, DoubleType())
])

products_df2 = spark.read.csv(products_csv_path,
                              header=True,
                              schema=user_defined_schema)

# DDL 형식 문자열로 읽기
ddl_schema = &quot;item_id string, name string, price double&quot;

products_df3 = spark.read.csv(products_csv_path,
                              header=True,
                              schema=ddl_schema)

# Delta에 쓰기
products_output_path = DA.paths.working_dir + &quot;/delta/products&quot;
(products_df
.write
.format(&quot;delta&quot;)
.mode(&quot;overwrite&quot;)
.save(products_output_path)
)</code></pre>
<hr>

<h2 id="datetimes">Datetimes</h2>
<h5 id="목표-3">목표</h5>
<ol>
<li>타임스탬프로 변환</li>
<li>날짜/시간 형식 지정</li>
<li>타임스탬프에서 추출</li>
<li>날짜/시간으로 변환</li>
<li>날짜/시간 조작</li>
</ol>
<h5 id="methods">Methods</h5>
<ul>
<li><a href="https://spark.apache.org/docs/latest/api/python/reference/pyspark.sql/column.html" target="_blank">Column</a>: <strong><code>cast</code></strong></li>
<li><a href="https://spark.apache.org/docs/latest/api/python/reference/pyspark.sql/functions.html#datetime-functions" target="_blank">Built-In Functions</a>: <strong><code>date_format</code></strong>, <strong><code>to_date</code></strong>, <strong><code>date_add</code></strong>, <strong><code>year</code></strong>, <strong><code>month</code></strong>, <strong><code>dayofweek</code></strong>, <strong><code>minute</code></strong>, <strong><code>second</code></strong></li>
</ul>
<pre><code class="language-python">from pyspark.sql.functions import col

df = spark.table(&quot;events&quot;).select(&quot;user_id&quot;, col(&quot;event_timestamp&quot;).alias(&quot;timestamp&quot;))
display(df)</code></pre>
<h3 id="내장-함수-날짜시간-함수">내장 함수: 날짜/시간 함수</h3>
<table>
<thead>
<tr>
<th>메서드</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><strong><code>add_months</code></strong></td>
<td>startDate로부터 numMonths 후의 날짜를 반환합니다.</td>
</tr>
<tr>
<td><strong><code>current_timestamp</code></strong></td>
<td>쿼리 실행 시작 시 현재 타임스탬프를 타임스탬프 열로 반환합니다.</td>
</tr>
<tr>
<td><strong><code>date_format</code></strong></td>
<td>날짜/타임스탬프/문자열을 두 번째 인수로 지정된 날짜 형식의 문자열 값으로 변환합니다.</td>
</tr>
<tr>
<td><strong><code>dayofweek</code></strong></td>
<td>주어진 날짜/타임스탬프/문자열에서 일자를 정수로 추출합니다.</td>
</tr>
<tr>
<td><strong><code>from_unixtime</code></strong></td>
<td>유닉스 시대(1970-01-01 00:00:00 UTC)의 초 수를 현재 시스템 시간대의 해당 시점의 타임스탬프를 yyyy-MM-dd HH:mm:ss 형식으로 나타내는 문자열로 변환합니다.</td>
</tr>
<tr>
<td><strong><code>minute</code></strong></td>
<td>주어진 날짜/타임스탬프/문자열에서 분을 정수로 추출합니다.</td>
</tr>
<tr>
<td><strong><code>unix_timestamp</code></strong></td>
<td>주어진 패턴을 갖는 시간 문자열을 유닉스 타임스탬프(초)로 변환합니다.</td>
</tr>
</tbody></table>
<h3 id="타임스탬프로-변환">타임스탬프로 변환</h3>
<h4 id="cast"><strong><code>cast()</code></strong></h4>
<p>문자열 표현이나 DataType을 사용하여 지정된 다른 데이터 유형으로 열을 변환합니다.</p>
<pre><code class="language-python">#1e6 = 1,000,000
timestamp_df = df.withColumn(&quot;timestamp&quot;, (col(&quot;timestamp&quot;) / 1e6).cast(&quot;timestamp&quot;))
display(timestamp_df)</code></pre>
<pre><code class="language-python">from pyspark.sql.types import TimestampType

timestamp_df = df.withColumn(&quot;timestamp&quot;, (col(&quot;timestamp&quot;) / 1e6).cast(TimestampType()))
display(timestamp_df)</code></pre>
<h3 id="날짜시간">날짜/시간</h3>
<ul>
<li>CSV/JSON 데이터 소스는 날짜/시간 콘텐츠의 구문 분석 및 형식 지정에 패턴 문자열을 사용</li>
<li>StringType과 DateType 또는 TimestampType 간의 변환과 관련된 날짜/시간 함수(예: <strong><code>unix_timestamp</code></strong>, <strong><code>date_format</code></strong>, <strong><code>from_unixtime</code></strong>, <strong><code>to_date</code></strong>, <strong><code>to_timestamp</code></strong> 등)</li>
</ul>
<h4 id="형식-지정-및-구문-분석을-위한-날짜시간-패턴">형식 지정 및 구문 분석을 위한 날짜/시간 패턴</h4>
<p>Spark는 <a href="https://spark.apache.org/docs/latest/sql-ref-datetime-pattern.html" target="_blank">날짜 및 타임스탬프 구문 분석 및 형식 지정에 패턴 문자</a>를 사용</p>
<table>
<thead>
<tr>
<th>기호</th>
<th>의미</th>
<th>표현</th>
<th>예시</th>
</tr>
</thead>
<tbody><tr>
<td>G</td>
<td>시대</td>
<td>텍스트</td>
<td>서기; 기원후</td>
</tr>
<tr>
<td>y</td>
<td>년</td>
<td>년</td>
<td>2020; 20</td>
</tr>
<tr>
<td>D</td>
<td>일</td>
<td>숫자(3)</td>
<td>189</td>
</tr>
<tr>
<td>M/L</td>
<td>월</td>
<td>월</td>
<td>7; 07; 7월; 7월</td>
</tr>
<tr>
<td>d</td>
<td>일</td>
<td>숫자(3)</td>
<td>28</td>
</tr>
<tr>
<td>Q/q</td>
<td>분기</td>
<td>숫자/텍스트</td>
<td>3; 03; 3분기</td>
</tr>
<tr>
<td>E</td>
<td>요일</td>
<td>텍스트</td>
<td>화; 화요일</td>
</tr>
</tbody></table>
<p> Spark 3.0 버전에서 날짜 및 타임스탬프 처리 방식이 변경되었으며, 이러한 값을 구문 분석하고 서식을 지정하는 데 사용되는 패턴도 변경됨.
<a href="https://databricks.com/blog/2020/07/22/a-comprehensive-look-at-dates-and-timestamps-in-apache-spark-3-0.html" target="_blank">이러한 변경 사항에 대한 설명</a></p>
<h4 id="날짜-형식-지정">날짜 형식 지정</h4>
<h4 id="date_format"><strong><code>date_format()</code></strong></h4>
<p>날짜/타임스탬프/문자열을 주어진 날짜/시간 패턴으로 형식화된 문자열로 변환합니다.</p>
<pre><code class="language-python">from pyspark.sql.functions import date_format

formatted_df = (timestamp_df
                .withColumn(&quot;date string&quot;, date_format(&quot;timestamp&quot;, &quot;MMMM dd, yyyy&quot;))
                .withColumn(&quot;time string&quot;, date_format(&quot;timestamp&quot;, &quot;HH:mm:ss.SSSSSS&quot;))
               )
display(formatted_df)</code></pre>
<h4 id="타임스탬프에서-날짜시간-속성-추출">타임스탬프에서 날짜/시간 속성 추출</h4>
<p> <strong><code>year</code></strong>
주어진 날짜/타임스탬프/문자열에서 연도를 정수로 추출</p>
<h5 id="유사-메서드-month-dayofweek-minute-second-등">유사 메서드: <strong><code>month</code></strong>, <strong><code>dayofweek</code></strong>, <strong><code>minute</code></strong>, <strong><code>second</code></strong> 등</h5>
<pre><code class="language-python">from pyspark.sql.functions import year, month, dayofweek, minute, second

datetime_df = (timestamp_df
               .withColumn(&quot;year&quot;, year(col(&quot;timestamp&quot;)))
               .withColumn(&quot;month&quot;, month(col(&quot;timestamp&quot;)))
               .withColumn(&quot;dayofweek&quot;, dayofweek(col(&quot;timestamp&quot;)))
               .withColumn(&quot;minute&quot;, minute(col(&quot;timestamp&quot;)))
               .withColumn(&quot;second&quot;, second(col(&quot;timestamp&quot;)))
              )
display(datetime_df)</code></pre>
<h4 id="날짜로-변환">날짜로 변환</h4>
<p> <strong><code>to_date</code></strong>
규칙을 DateType으로 캐스팅하여 열을 DateType으로 변환</p>
<pre><code class="language-python">from pyspark.sql.functions import to_date

date_df = timestamp_df.withColumn(&quot;date&quot;, to_date(col(&quot;timestamp&quot;)))
display(date_df)</code></pre>
<h3 id="날짜시간-조작">날짜/시간 조작</h3>
<p><strong><code>date_add</code></strong>
시작일로부터 주어진 일수 후의 날짜를 반환</p>
<pre><code class="language-python">from pyspark.sql.functions import date_add

plus_2_df = timestamp_df.withColumn(&quot;plus_two_days&quot;, date_add(col(&quot;timestamp&quot;), 2))
display(plus_2_df)</code></pre>
<hr>

<h2 id="complex-types">Complex Types</h2>
<p>컬렉션 및 문자열 작업을 위한 내장 함수</p>
<ol>
<li>배열 처리에 컬렉션 함수 적용</li>
<li>DataFrames 결합</li>
</ol>
<h4 id="방법-1">방법</h4>
<ul>
<li><a href="https://spark.apache.org/docs/latest/api/python/reference/pyspark.sql/dataframe.html" target="_blank">DataFrame</a>:<strong><code>union</code></strong>, <strong><code>unionByName</code></strong></li>
<li><a href="https://spark.apache.org/docs/latest/api/python/reference/pyspark.sql/functions.html" target="_blank">내장 함수</a>:</li>
<li>집계: <strong><code>collect_set</code></strong></li>
<li>컬렉션: <strong><code>array_contains</code></strong>, <strong><code>element_at</code></strong>, <strong><code>explode</code></strong></li>
<li>문자열: <strong><code>split</code></strong></li>
</ul>
<pre><code class="language-python">details_df = (df
              .withColumn(&quot;items&quot;, explode(&quot;items&quot;)) #행변환
              .select(&quot;email&quot;, &quot;items.item_name&quot;)
              .withColumn(&quot;details&quot;, split(col(&quot;item_name&quot;), &quot; &quot;))
             )
display(details_df)</code></pre>
<h3 id="문자열-함수">문자열 함수</h3>
<p>문자열 내장 함수</p>
<table>
<thead>
<tr>
<th>메서드</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>translate</td>
<td>src의 모든 문자를 replaceString의 문자로 변환합니다.</td>
</tr>
<tr>
<td>regexp_replace</td>
<td>지정된 문자열 값에서 regexp와 일치하는 모든 부분 문자열을 rep로 바꿉니다.</td>
</tr>
<tr>
<td>regexp_extract</td>
<td>지정된 문자열 열에서 Java 정규식과 일치하는 특정 그룹을 추출합니다.</td>
</tr>
<tr>
<td>ltrim</td>
<td>지정된 문자열 열에서 선행 공백 문자를 제거합니다.</td>
</tr>
<tr>
<td>lower</td>
<td>문자열 열을 소문자로 변환합니다.</td>
</tr>
<tr>
<td>split</td>
<td>주어진 패턴과 일치하는 문자열을 중심으로 str을 나눕니다.</td>
</tr>
</tbody></table>
<p><strong><code>email</code></strong> 열을 구문 분석 → <strong><code>split</code></strong> 함수를 사용하여 도메인을 분할하고 처리</p>
<pre><code class="language-python">from pyspark.sql.functions import split

display(df.select(split(df.email, &#39;@&#39;, 0).alias(&#39;email_handle&#39;)))</code></pre>
<h3 id="컬렉션-함수">컬렉션 함수</h3>
<p>배열 내장 함수</p>
<table>
<thead>
<tr>
<th>메서드</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>array_contains</td>
<td>배열이 null이면 null을 반환하고, 배열에 값이 있으면 true를 반환하고, 그렇지 않으면 false를 반환합니다.</td>
</tr>
<tr>
<td>element_at</td>
<td>주어진 인덱스에 있는 배열의 요소를 반환합니다. 배열 요소는 <strong>1</strong>부터 번호가 매겨집니다.</td>
</tr>
<tr>
<td>explode</td>
<td>주어진 배열 또는 맵 열의 각 요소에 대해 새 행을 생성합니다.</td>
</tr>
<tr>
<td>collect_set</td>
<td>중복 요소가 제거된 객체 집합을 반환합니다.</td>
</tr>
</tbody></table>
<pre><code class="language-python">mattress_df = (details_df
               .filter(array_contains(col(&quot;details&quot;), &quot;Mattress&quot;))
               .withColumn(&quot;size&quot;, element_at(col(&quot;details&quot;), 2)))
display(mattress_df)</code></pre>
<h3 id="집계-함수-1">집계 함수</h3>
<p>GroupedData에서 배열을 생성하는 데 사용</p>
<table>
<thead>
<tr>
<th>메서드</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>collect_list</td>
<td>그룹 내의 모든 값으로 구성된 배열을 반환합니다.</td>
</tr>
<tr>
<td>collect_set</td>
<td>그룹 내의 모든 고유 값으로 구성된 배열을 반환합니다.</td>
</tr>
</tbody></table>
<pre><code class="language-python"># 이메일 주소별로 주문된 매트리스 크기 확인
size_df = mattress_df.groupBy(&quot;email&quot;).agg(collect_set(&quot;size&quot;).alias(&quot;size options&quot;))

display(size_df)</code></pre>
<h3 id="union-및-unionbyname">Union 및 unionByName</h3>
<ul>
<li>DataFrame <a href="https://spark.apache.org/docs/latest/api/python/reference/api/pyspark.sql.DataFrame.union.html" target="_blank"><strong><code>union</code></strong></a> 메서드는 표준 SQL처럼 <strong>위치</strong>를 기준으로 열을 확인
두 DataFrame의 스키마가 열 순서를 포함하여 정확히 동일한 경우에만 사용해야 함</li>
<li>반면, DataFrame <a href="https://spark.apache.org/docs/latest/api/python/reference/api/pyspark.sql.DataFrame.unionByName.html" target="_blank"><strong><code>unionByName</code></strong></a> 메서드는 <strong>이름</strong>을 기준으로 열을 확인 
이는 SQL의 UNION ALL과 동일. </li>
<li>둘 다 중복 제거  X</li>
</ul>
<pre><code class="language-python"># 두 데이터프레임에 union이 적합한 일치하는 스키마가 있는지 확인
mattress_df.schema==size_df.schema #false</code></pre>
<pre><code class="language-python">union_count = mattress_df.select(&quot;email&quot;).union(size_df.select(&quot;email&quot;)).count()

mattress_count = mattress_df.count()
size_count = size_df.count()

mattress_count + size_count == union_count #true</code></pre>
<hr>

<h2 id="additional-functions">Additional Functions</h2>
<ol>
<li>내장 함수를 적용하여 새 열에 대한 데이터 생성</li>
<li>DataFrame NA 함수를 적용하여 Null 값 처리</li>
<li>DataFrame 조인</li>
</ol>
<h5 id="메서드-4">메서드</h5>
<ul>
<li><a href="https://spark.apache.org/docs/latest/api/python/reference/pyspark.sql/api/pyspark.sql.DataFrame.join.html#pyspark.sql.DataFrame.join" target="_blank">DataFrame 메서드</a>: <strong><code>join</code></strong></li>
<li><a href="https://spark.apache.org/docs/latest/api/python/reference/pyspark.sql/api/pyspark.sql.DataFrameNaFunctions.html#pyspark.sql.DataFrameNaFunctions" target="_blank">DataFrameNaFunctions</a>: <strong><code>fill</code></strong>, <strong><code>drop</code></strong></li>
<li><a href="https://spark.apache.org/docs/latest/api/python/reference/pyspark.sql/functions.html" target="_blank">내장 함수</a>:</li>
<li>집계: <strong><code>collect_set</code></strong></li>
<li>컬렉션: <strong><code>explode</code></strong></li>
<li>비집계 및 기타: <strong><code>col</code></strong>, <strong><code>lit</code></strong></li>
</ul>
<h3 id="비집계-함수-및-기타-함수">비집계 함수 및 기타 함수</h3>
<table>
<thead>
<tr>
<th>메서드</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>col / column</td>
<td>주어진 열 이름을 기반으로 열을 반환합니다.</td>
</tr>
<tr>
<td>lit</td>
<td>리터럴 값의 열을 생성합니다.</td>
</tr>
<tr>
<td>isnull</td>
<td>열이 null이면 true를 반환합니다.</td>
</tr>
<tr>
<td>rand</td>
<td>[0.0, 1.0]에 균등하게 분포하는 독립적이고 동일 분포(i.i.d.) 샘플을 갖는 난수 열을 생성합니다.</td>
</tr>
</tbody></table>
<p><code>col</code> 함수를 사용하여 특정 열 선택</p>
<pre><code class="language-python">gmail_accounts = sales_df.filter(col(&quot;email&quot;).endswith(&quot;gmail.com&quot;))

display(gmail_accounts)</code></pre>
<p><code>lit</code>은 값으로 열을 생성하는데 사용, 열을 추가할 때 유용
모든 행에 똑같은 값을 입력할 때 유용</p>
<pre><code class="language-python">display(gmail_accounts.select(&quot;email&quot;, lit(True).alias(&quot;gmail user&quot;)))</code></pre>
<h3 id="dataframenafunctions">DataFrameNaFunctions</h3>
<p><a href="https://spark.apache.org/docs/latest/api/python/reference/pyspark.sql/api/pyspark.sql.DataFrameNaFunctions.html#pyspark.sql.DataFrameNaFunctions" target="_blank">DataFrameNaFunctions</a>는 null 값을 처리하는 메서드를 포함하는 DataFrame 하위 모듈
DataFrame의 <strong><code>na</code></strong> 속성에 접근하여 DataFrameNaFunctions의 인스턴스를 가져옴</p>
<table>
<thead>
<tr>
<th>메서드</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>drop</td>
<td>열의 선택적 하위 집합을 고려하여 null 값이 있는 행을 일부, 전체 또는 지정된 개수만큼 제외하고 새 DataFrame을 반환합니다.</td>
</tr>
<tr>
<td>fill</td>
<td>열의 선택적 하위 집합에 대해 null 값을 지정된 값으로 바꿉니다.</td>
</tr>
<tr>
<td>replace</td>
<td>열의 선택적 하위 집합을 고려하여 값을 다른 값으로 바꾸고 새 DataFrame을 반환합니다.</td>
</tr>
</tbody></table>
<pre><code class="language-python"># null/NA 값이 있는 행을 삭제하기 전과 삭제한 후의 행 수 확인
print(sales_df.count())
print(sales_df.na.drop().count())</code></pre>
<pre><code class="language-python"># 10510으로 위에서 행 개수가 같으므로 null이 없는 열이 있음
# items.coupon과 같은 열에서 null을 찾기 위해 항목 분리
sales_exploded_df = sales_df.withColumn(&quot;items&quot;, explode(col(&quot;items&quot;)))
display(sales_exploded_df.select(&quot;items.coupon&quot;))
print(sales_exploded_df.select(&quot;items.coupon&quot;).count())
print(sales_exploded_df.select(&quot;items.coupon&quot;).na.drop().count())</code></pre>
<pre><code class="language-python"># 누락된 쿠폰 코드는 **`na.fill`**을 사용하여 채움
display(sales_exploded_df.select(&quot;items.coupon&quot;).na.fill(&quot;NO COUPON&quot;))</code></pre>
<h3 id="dataframe-결합">DataFrame 결합</h3>
<p>DataFrame의 <a href="https://spark.apache.org/docs/latest/api/python/reference/api/pyspark.sql.DataFrame.join.html?highlight=join#pyspark.sql.DataFrame.join" target="_blank"><strong><code>join</code></strong></a> 메서드는 주어진 조인 표현식을 기반으로 두 DataFrame을 결합</p>
<p>&quot;name&quot;이라는 공유 열의 값이 같은 경우를 기준으로 하는 내부 조인(즉, 동등 조인)<br/>
<strong><code>df1.join(df2, &quot;name&quot;)</code></strong></p>
<p>&quot;name&quot;과 &quot;age&quot;라는 공유 열의 값이 같은 경우를 기준으로 하는 내부 조인<br/>
<strong><code>df1.join(df2, [&quot;name&quot;, &quot;age&quot;])</code></strong></p>
<p>&quot;name&quot;이라는 공유 열의 값이 같은 경우를 기준으로 하는 전체 외부 조인<br/>
<strong><code>df1.join(df2, &quot;name&quot;, &quot;outer&quot;)</code></strong></p>
<p>명시적 열 표현식을 기준으로 하는 왼쪽 외부 조인<br/>
<strong><code>df1.join(df2, df1[&quot;customer_name&quot;] == df2[&quot;account_name&quot;], &quot;left_outer&quot;)</code></strong></p>
<pre><code class="language-python">users_df = spark.table(&quot;users&quot;)
display(users_df)

joined_df = gmail_accounts.join(other=users_df, on=&#39;email&#39;, how = &quot;inner&quot;)
display(joined_df)</code></pre>
<hr>

<h2 id="버려진-장바구니-실습">버려진 장바구니 실습</h2>
<p>구매하지 않고 버려진 장바구니 항목을 이메일로 받아보기</p>
<ol>
<li>거래에서 전환된 사용자의 이메일 가져오기</li>
<li>사용자 ID로 이메일 병합</li>
<li>각 사용자의 장바구니 항목 내역 가져오기</li>
<li>이메일로 장바구니 항목 내역 병합</li>
<li>장바구니에서 버려진 항목이 있는 이메일 필터링</li>
</ol>
<h5 id="방법-2">방법</h5>
<ul>
<li><a href="https://spark.apache.org/docs/latest/api/python/reference/pyspark.sql/api/pyspark.sql.DataFrame.join.html#pyspark.sql.DataFrame.join" target="_blank">DataFrame</a>: <strong><code>join</code></strong></li>
<li><a href="https://spark.apache.org/docs/latest/api/python/reference/pyspark.sql/functions.html" target="_blank">내장 함수</a>: <strong><code>collect_set</code></strong>, <strong><code>explode</code></strong>, <strong><code>lit</code></strong></li>
<li><a href="https://spark.apache.org/docs/latest/api/python/reference/pyspark.sql/api/pyspark.sql.DataFrameNaFunctions.html#pyspark.sql.DataFrameNaFunctions" target="_blank">DataFrameNaFunctions</a>: <strong><code>fill</code></strong></li>
</ul>
<pre><code class="language-python">sales_df = spark.table(&quot;sales&quot;)
display(sales_df)

users_df = spark.table(&quot;users&quot;)
display(users_df)

events_df = spark.table(&quot;events&quot;)
display(events_df)

# 거래에서 전환된 사용자의 이메일 가져오기
from pyspark.sql.functions import *
converted_users_df = sales_df.select(&quot;email&quot;, lit(True).alias(&quot;converted&quot;)).distinct()

# 사용자 ID로 이메일을 조인
conversions_df = (users_df.join(converted_users_df, on = &quot;email&quot;, how = &quot;leftouter&quot;)).filter(&quot;email IS NOT NULL&quot;).fillna(False)
display(conversions_df)

# 긱 사용자의 장바구니 항목 내역 가져오기
carts_df = (events_df.withColumn(&quot;items&quot;, explode(col(&quot;items&quot;)))
            .groupBy(&quot;user_id&quot;)
            .agg(collect_set(&quot;items.item_id&quot;).alias(&quot;cart&quot;))
)
display(carts_df)

# 장바구니 항목 내역을 이메일과 연결
email_carts_df = conversions_df.join(carts_df, how = &quot;left&quot;, on = &quot;user_id&quot;)
display(email_carts_df)

# 장바구니에 버려진 상품이 있는 이메일 필터링
abandoned_carts_df = (email_carts_df.filter(&quot;converted == False&quot;).filter(&quot;cart IS NOT NULL&quot;))
display(abandoned_carts_df)

# 제품별 장바구니 포기 항목 수 표시
abandoned_items_df = (abandoned_carts_df.withColumn(&quot;items&quot;, explode(&quot;cart&quot;)).groupBy(&quot;items&quot;).count())
display(abandoned_items_df)</code></pre>
<h2 id="query-optimization">Query Optimization</h2>
<pre><code class="language-python">df = spark.read.table(&quot;events&quot;)
display(df)</code></pre>
<h3 id="논리적-최적화">논리적 최적화</h3>
<p><strong><code>explain(..)</code></strong>은 쿼리 계획을 출력하며, 선택적으로 지정된 설명 모드에 따라 형식이 지정</p>
<pre><code class="language-python">from pyspark.sql.functions import col

limit_events_df = (df
                   .filter(col(&quot;event_name&quot;) != &quot;reviews&quot;)
                   .filter(col(&quot;event_name&quot;) != &quot;checkout&quot;)
                   .filter(col(&quot;event_name&quot;) != &quot;register&quot;)
                   .filter(col(&quot;event_name&quot;) != &quot;email_coupon&quot;)
                   .filter(col(&quot;event_name&quot;) != &quot;cc_info&quot;)
                   .filter(col(&quot;event_name&quot;) != &quot;delivery&quot;)
                   .filter(col(&quot;event_name&quot;) != &quot;shipping_info&quot;)
                   .filter(col(&quot;event_name&quot;) != &quot;press&quot;)
                  )

limit_events_df.explain(True)</code></pre>
<p><img src="https://velog.velcdn.com/images/rudin_/post/ccb25346-6ebb-44e8-8a45-1b056ac4835c/image.png" alt=""></p>
<pre><code class="language-python">better_df = (df
             .filter((col(&quot;event_name&quot;).isNotNull()) &amp;
                     (col(&quot;event_name&quot;) != &quot;reviews&quot;) &amp;
                     (col(&quot;event_name&quot;) != &quot;checkout&quot;) &amp;
                     (col(&quot;event_name&quot;) != &quot;register&quot;) &amp;
                     (col(&quot;event_name&quot;) != &quot;email_coupon&quot;) &amp;
                     (col(&quot;event_name&quot;) != &quot;cc_info&quot;) &amp;
                     (col(&quot;event_name&quot;) != &quot;delivery&quot;) &amp;
                     (col(&quot;event_name&quot;) != &quot;shipping_info&quot;) &amp;
                     (col(&quot;event_name&quot;) != &quot;press&quot;))
            )

better_df.explain(True)</code></pre>
<p><img src="https://velog.velcdn.com/images/rudin_/post/80308159-d54f-4ad5-91fa-5180e4a98734/image.png" alt=""></p>
<pre><code class="language-python">stupid_df = (df
             .filter(col(&quot;event_name&quot;) != &quot;finalize&quot;)
             .filter(col(&quot;event_name&quot;) != &quot;finalize&quot;)
             .filter(col(&quot;event_name&quot;) != &quot;finalize&quot;)
             .filter(col(&quot;event_name&quot;) != &quot;finalize&quot;)
             .filter(col(&quot;event_name&quot;) != &quot;finalize&quot;)
            )

stupid_df.explain(True)</code></pre>
<p><img src="https://velog.velcdn.com/images/rudin_/post/f1a1ed8c-a301-4d0c-9fde-f60d0cb83df2/image.png" alt=""></p>
]]></description>
        </item>
    </channel>
</rss>