오늘은 DART의 3분기 손익계산서(2016~2020)를 하나의 엑셀로 정리한 파일을 무료 나눔하려 합니다. 아무래도 DART에서 일괄다운로드한 엑셀에는 항목이 너무 많고 년도별로 다 쪼개져 있어서 분석하기 어려운 점이 있습니다.
DART(dart.fss.or.kr/) 에서 2016부터 2020까지 3분기 손익계산서만 사용했습니다. DART는 개인 투자자들에게 반드시 꼭 항상 옆에 두어야 하는 사이트인거 같습니다. (언제나 종목 매매전에 DART 들어가서 사업보고서 챙겨보는 습관이 필요합니다.!) 개인적으로 2016년 이전의 재무제표 데이터도 모두 얻어서 정리해서 공유하고 싶지만 쉽지 않네요.
제가 알기에는 DART 데이터만이 무료로 공유가 가능한거 같습니다.
개인 투자자분들의 분석에 용이하도록 다음과 같이 정리했습니다.
- 매출액, 영업이익, 당기순이익, 금융수익, 주당순이익 항목만 정리
- 원래 DART 에서 받은 파일은 항목별로 로우가 분리되어 있습니다. 이를 결산기준일+종목별로 컬럼으로 처리
(매출액, 영업이익, 당기순이익, 금융수익, 주당순이익이 컬럼으로 표시됩니다.)
- 주가 변동을 같이 분석할 수 있도록 결산기준일에 해당하는 년도의 월별 종가를 추가
- 포괄손익계산서_연결 파일만 사용(일부 종목이 없을 수 있습니다.)
- 개인 참고용으로 만든 데이터이기 때문에, 데이터의 정확성을 책임지지는 않습니다.
아래는 샘플 그림입니다.
이와 같은 파일을 만들기 위해서 MySQL로 DART 보고서를 업로드 한 후에 데이터를 재가공했습니다. 월별 주가 같은 경우는 제가 꾸준히 API로 수집하고 있던 데이터입니다.(액분등의 이벤트로 주가가 수정된 경우 약간 안맞을수도 있겠습니다. 대부분은 맞습니다.)
그리고... 이와 같은 작업은 엑셀보다 데이터베이스화해서 SQL로 작업하는 것이 훨씬 더 쉽습니다. 생각보다 SQL을 배우기는 어렵지 않습니다. 개인 업무와 직장 경력에도 큰 도움이 되는 것이 데이터와 SQL입니다. 여유가 있으시면 한 번 공부해 보시기 바랍니다. 이것도 제 블록그에서 주식 데이터와 연계해 최대한 쉽게 쓰려고 하고 있습니다.
4차 산업 혁명의 중심. 그리고 미래의 원유인 데이터. 데이터를 다루기에 가장 적합한 언어 SQL.. 반드시 배워두시는 것이 좋습니다.
이와 같은 분석은 데이터베이스를 구축해 SQL을 활용한다면 훨씬 더 쉽고 강력하게 처리할 수 있습니다.
SQL과 주식에 관심이 있다면 아래 책을 참고해주세요.
※ 주의 사항
▶ 절대 특정 종목을 추천하거나 투자를 권유하는 책이 아닙니다.
▶ 데이터 분석을 공부하기 위한 MySQL 책입니다.
▶ 책의 내용을 통해 얻은 종목에 투자해 발생한 손해는, 저를 비롯한 책 관계자 누구도 책임지지 않습니다.
이 글은 테마주에 투자를 추천하는 글이 절대 아닙니다. 여기서 이야기하는 테마는 갑작스러운 또는 너무도 불확실한 그런 테마가 아닙니다. 어느 정도 실적이 나오면서 전체적으로 뉴스에서도 많이 다루는 그런 테마를 뜻합니다. 산업의 큰 변화 경제적인 큰 변화를 만드는 테마를 뜻합니다. 그런 테마가 만들어졌을 때(대표적으로 2차 전지, 반도체) 어떤 종목을 어떻게 선별할지를 다루고자 합니다. 그럴 때 팍스넷을 적절하게 사용하면 매우 유용하기에 이렇게 소개하게 되었습니다.( 절대 팍스넷 관련자도 아닙니다! )
그리고.. 저만 알고 싶은 방법입니다. 좀 아꿉네요. ㅜ.ㅜ
- 필요 IT 기술 : 엑셀과 웹서핑 능력, 그리고 Ctlr+C와 Ctrl+V 능력
제 입으로 이런 말씀드리기 그렇지만... 사실 저는 DB(데이터베이스) 전문가입니다. SQL 관련 책도 한 권 집필했지요.(부끄럽습니다) 그러므로 이러한 주식 데이터는 DB화 해서 분석하는 것이 저에게는 더 쉽습니다. 하지만 많은 분들이 DB를 접하기에는 아직 거리가 먼 거 같아.. 우선은 쉽게 데이터에 접할 수 있도록 엑셀만 사용하는 선에서 주식 데이터를 분석하는 과정을 설명하고 있습니다.
포트폴리오 구성 시나리오
a. 팍스넷 접속
- 2차 전지 테마주 관련 종목을 엑셀로 카피한다.
b. 엑셀에서 시가총액, 목표주가 괴리율 등에 순위를 부여한다.
c. 부여된 순위의 합이 가장 좋은 종목들을 추려 투자하거나 분석한다.
여기서는 팍스넷을 사용해 2차 전지 테마주 들 중에 옥석(?)을 가리는 작업을 해보도록 하겠습니다. 이미 2차 전지에 대한 테마는 형성된 지가 제법 되었습니다. 지금 뛰어든다는 것은 위험할 수도 있습니다. 그러므로 여기서 소개하는 글은 과정에 대한 참고로 생각해 주시기 바랍니다.
본격적인 설명에 앞서 늘 드리는 말씀 적어 드리고 시작하겠습니다.
이 글은 투자를 권유하거나 특정 종목을 추천하려는 글이 아닙니다. 데이터를 이와 같이 분석할 수 있음을 알려드리고 싶은 글입니다. 또한 이 글에 나온 종목에 투자해 발생한 손해는 투자자 본인의 책임임을 명심하시기 바랍니다.
위와 같은 작업을 하면, 선택한 항목의 내용들만 추려서 조회가 다시 진행됩니다. 결과가 조회되면 기간을 120일로 설정합니다. [그림. 4]와 같습니다.
[그림. 4]
1. 120일을 클릭합니다.
- 120일을 선택하면, 기간등락률, 외인, 기관, 개인에 관한 내용이 120일로 변경됩니다.
분석할 때 120일 동안 등락률이 어떠한지도 고려하기 위해서 120일을 선택했습니다.
2. 팍스넷 조회 내용 엑셀로 옮기기
위에서 조회된 내용을 엑셀로 카피해서 옮길 차례입니다. 위에서 조회된 결과를 보면 총 세 페이지에 걸쳐 결과가 나옵니다. 우선은 첫 번째 페이지의 내용만 엑셀로 옮깁니다. 새로운 엑셀 시트를 열어 놓습니다. [그림. 5]를 참고하세요.
[그림. 5]
1. 조회된 결과를 카피하기 위해서, 정확히 '2차전지' 바로 앞 부분에서 마우스 좌클릭을 합니다.
2. 1에서 마우스 좌클릭한채로 끌어서(Drag) 리스트 마지막에 다음페이지 화살표 바로 뒤에서 좌클릭을 놓습니다.
- 그림과 같이 파란색으로 글씨들이 선택되어집니다.
Ctrl + C(복사)를 입력합니다.
3. 새 엑셀로 이동합니다. 새 엑셀의 가장 첫 번째 셀에서 Ctrl+V(붙여넣기)를 입력합니다.
팍스넷의 내용을 엑셀로 붙여 넣으면 불필요한 내용도 복사가 됩니다. 적절하게 삭제 작업을 합니다. [그림. 6]을 참고하세요.
[그림. 6]
1. 엑셀 윗부분에 불필요 부분의 행 헤더를 마우스 좌클릭으로 동시 선택 후,
- 선택된 영역에서 마우스 우클릭 후 팝업 메뉴에서 '삭제'를 선택
2. 엑셀을 아래로 내리다 보면 불필요한 부분이 또 있습니다. 1번과 마찬가지 방법으로 삭제합니다.
팍스넷에서 2차 전지를 조회했을 때 총 세 페이지의 데이터가 있었습니다. 이번에는 두 번째 페이지를 엑셀로 옮길 차례입니다. 잊지 말아야 할 것은 팍스넷에서 2 페이지로 이동후 '기간누적'을 반드시 120일로 눌러주셔야 합니다. [그림. 7]과 같습니다.
[그림. 7]
1. 팍스넷에서 2페이지를 선택합니다.
2. 상단의 기간누적을 120으로 선택합니다.
위에서 팍스넷의 1페이지를 엑셀로 옮겼던 거랑 같은 방법으로 2페이지를 기존 엑셀 아래쪽에 추가 카피합니다. [그림. 8]을 참고해주세요.
[그림. 8]
1. 2페이지 결과 중, '2차전지' 앞부분을 마우스 좌클릭으로 선택해서 유지
2. 1번에서 마우스 좌 클릭한 채로 아랫부분 다음 페이지 화살표까지 선택, Ctrl+C 입력
3. 기존 작업하던 엑셀의 마지막 행 아래에서 Ctrl+V 입력
이제, 이전에 작업한 것처럼, 엑셀에서 불필요하게 카피된 빈 행들을 제거합니다. 이와 같은 과정으로 3페이지도 엑셀로 옮겨줍니다.
3. 엑셀에서 불필요 데이터 제거
이제 팍스넷을 접속할 필요는 없습니다. 이와 같이 귀중한 정보들을 보여주는 팍스넷에 감사할 따름입니다.! (팍스넷 관련자 아닙니다.^^)
이제 팍스넷의 내용을 카피한 엑셀만 사용하면 됩니다. 엑셀에서 분석에 지장을 주는 불필요 데이터를 제거하겠습니다.
[그림. 9]와 같이 필터를 사용에 목표주가 괴리율이 0인 데이터를 제거합니다.
작업에 앞서 목표주가 괴리율에 대해 간단히 설명하도록 하겠습니다. 증권사는 종목 별로 목표주가를 제시합니다. 이 목표 주가와 현재 주가가 차이가 나는데, 이를 괴리율이라고 합니다. 만약에 괴리율이 크다면, 목표 주가를 잘 못 제시한 걸 수도 있겠지만, 그만큼 올라갈 주가가 있다고 해석할 수도 있습니다. 그런데... 이러한 목표주가를 모든 종목에 대해서 제시하지는 않습니다. 기본적으로 어느 정도 덩치가 있고, 증권사 나름 투자 가치가 있다 판단하는 종목들에 목표 주가를 제시합니다. 그러므로 여기서는 목표 주가를 제시하지 않아 괴리율이 0인 데이터는 제거하려고 합니다.
[그림. 9]
1. 데이터 > 필터 메뉴를 선택
2. 목표주가괴리율 필터를 선택
3. 0 만 체크해서 선택
4. 확인
5. 목표주가 괴리율이 0으로 불필요 데이터이므로 삭제 처리
- 조회 결과 행의 헤더를 마우스 좌클릭으로 모두 선택 후, 마우스 우클릭 -> '삭제' 메뉴 선택
불필요한 목표주가 괴리율이 0인 종목들을 모두 삭제한 후에 다시 아래와 같이 필터 버튼을 누릅니다. 그러면 필터가 사라지면서 모든 데이터가 조회됩니다.
[그림. 10]
방금 목표주가 괴리율이 0인 데이터를 삭제한 것처럼, '기간등락률'의 값이 '-'인 데이터도 삭제해주세요. 자세한 설명은 생략하겠습니다. 목표주가 괴리율 처리한 것과 똑같이만 하시면 됩니다. (필터를 사용에 기간 등락률이 - 인 데이터를 골라내고 선택 후 삭제 처리, 다시 필터를 눌러 원래대로 돌아가면 됩니다.)
4. RANK를 사용한 순위 작업
엑셀에는 RANK라는 함수가 있습니다. RANK를 사용하면 특정 항목의 값이 참고 값들 중에 몇 위에 해당하는지를 얻어 낼 수 있습니다. 전혀 어렵지 않습니다. 천천히 따라해보시죠.
[그림. 11]을 참고해 시가총액(억)에 대한 순위를 부여합니다.
[그림. 11]
1. L1 부분에 순위_시가로 헤더명을 입력합니다.(사실 헤더명은 아무거나 상관없죠)
2. L2(L열 2행)에 다음 수식을 입력 : = RANK(C2, C:C, 0)
3. L2의 우측 아래 모서리에 마우스를 대고 더블클릭
- L2의 내용이 아래 행들로 모두 자동 카피됩니다.
여기서 RANK 함수에 대해 잠깐 설명을 드리면, RANK 함수는 세 개의 값을 갖습니다. 첫 번째는 순위를 구 할 값입니다. 여기서는 C2에 해당하는 1,860이 됩니다. 두 번째 변수는 순위를 구하기 위해 참고할 리스트입니다. 여기서는 C:C로 입력되어 있습니다. 이것은 C행(시가총액(억)) 전부를 뜻합니다. 그러므로 시가총액들 중에 C2의 값이 몇 등인지를 구합니다. 마지막 세 번째 값은 0 또는 1입니다. 0은 내림차순으로 순위를 정하고 1은 오름차순으로 순위를 정합니다. 여기서는 0을 사용해 내림차순 순위를 구했습니다. 내림차순은 가장 큰 값이 1등이 되고, 작은 값일수록 뒤의 등수가 됩니다. 오름차순이라면 반대겠죠. 가장 작은 값이 1등이 됩니다. 여기서는 시가총액을 사용했고, 시가총액이 클수록 좋다가 생각해 내림차순으로 순위를 처리했습니다.
이번에는 목표주가 괴리율에 대한 순위를 구할 차례입니다. 괴리율 순위를 입력하고 아래 행으로 모두 카피합니다.
[그림. 12]와 같습니다. 앞에서 살펴본 과정과 유사합니다. M2에 넣을 수식은 '= RANK(G2, G:G, 0)'입니다. 순위에는 내림차순을 사용했습니다. 다시 말해, 괴리율이 클수록 좋은 순위를 받도록 했습니다. 목표주가 괴리율이 클수록 아직 오를 가능성이 크다고 생각했습니다. (예를 들어 LG화학은 목표주가 933,953이고 현재 주가가 798,000이므로 괴리율이 17.04입니다.)
[그림. 12]
이번에는 기간등락률에 대한 순위를 부여합니다. 모멘텀이라는 말이 요즘은 대세입니다. 오르는 주식이 계속 오른다. 그러므로 여기서는 기간등락률이 높을수록 좋은 순위를 받도록 수식을 처리합니다. [그림. 13]과 같습니다. N2에 수식을 입력하고 아래행까지 모두 카피합니다. N2의 수식 '=RANK(H2, H:H, 0)'입니다.
[그림. 13]
5. 투자 대상 살펴보기
이제 마지막 단계입니다. 지금까지 구한 순위 3개를 합해서 투자순위를 결정하고 투자순위로 정렬해서 투자할 종목들에 대해 고민해볼 차례입니다.
[그림. 14]를 참고해 투자순위를 구합니다.
[그림. 14]
1. O1에 헤더명을 입력합니다.
2. O2에 다음 수식을 입력 : = L2+M2+N2
3. O2의 수식을 아래 행으로 모두 복사(O2 우측 아래 모서리에 마우스를 대고 더블 클릭)
이제, 투자순위로 정렬해서 조회만 하면 됩니다. [그림. 15]와 같습니다.
[그림. 15]
최종 조회된 결과는 각자 확인해보시기 바랍니다~!
여기서 사용한 순위를 구한 항목들은 제가 임의로 정한 것입니다. 각자 생각하는 투자에 고려할 좋은 항목을 선택해서 투자 대상을 골라내시면 될 거 같습니다.
저는 이와 같은 방법만 이용해서 투자를 하지는 않습니다.이와 같이 종목을 우선 추려놓고 추가적인 공부(?)와 연구를 통해 투자할지를 결정하는 것이 현명하다 생각됩니다.
어떤가요? 테마주 투자... 무턱대고 깜깜이로 종목 추천받아서 투자할 필요가 없겠죠?
감사합니다.
이와 같은 분석은 데이터베이스를 구축해 SQL을 활용한다면 훨씬 더 쉽고 강력하게 처리할 수 있습니다.
SQL과 주식에 관심이 있다면 아래 책을 참고해주세요.
※ 주의 사항
▶ 절대 특정 종목을 추천하거나 투자를 권유하는 책이 아닙니다.
▶ 데이터 분석을 공부하기 위한 MySQL 책입니다.
▶ 책의 내용을 통해 얻은 종목에 투자해 발생한 손해는, 저를 비롯한 책 관계자 누구도 책임지지 않습니다.
오늘은 영업이익과 주가 변화를 활용해 포트폴리오를 만들어 보겠습니다. 간단하게 '영업이익은 올랐지만, 주가는 오히려 떨어진 주식이라면 매수 찬스?'라는 아이디어로 포트폴리오를 구성해서 시뮬레이션하는 과정입니다.
결론부터 말씀드리면, 이 포트폴리오는 보유기간 6개월에 -9% 의 손해를 보게 됩니다. 하지만 상상력을 발휘해 로직을 조금만 변경하면 6개월에 플러스 46% 수익률이 나오기도 합니다. 제가 설명드리고 싶은 건 엑셀만 활용해서 포트폴리오를 구성하고 시뮬레이션까지 해볼 수 있는 과정을 익혀 본다는 점입니다. 이 과정을 익혀서 실제 수익을 낼 수 있는 포트폴리오를 직접 찾아보시길 권유드립니다. (재미에 빠지면 밤 새 찾아 헤매실지도 모릅니다.)
내용은 좀 길 수 있는데, 따라 하는 과정은 그리 길지도 않습니다.~
- 필요 IT 기술 : 엑셀
- 필요 데이터 : DART의 2018년 사업보고서, 특정 월의 종목별 종가(엑셀로 별도 첨부해드립니다.)
포트폴리오 구성 시나리오
영업이익은 오르고, 주가는 내린 종목을 찾아서 포트폴리오를 구성하는 전략.
a. DART의 2018년 사업보고서를 사용
- 2017년 대비 2018년 영업이익이 가장 많이 오른 종목을 찾는다.
b. 2018년 4월 대비, 2019년 4월에 주가가 가장 많이 내린 종목을 찾는다.
- 왜 2018년, 2019년 4월 주가일까요?
* 2018년 사업보고서는 2019년 4월쯤 접할 수 있습니다.
* 그러므로 해당 시점과 1년 전의 주가를 비교하는 것으로 설정했습니다.
c. a와 b를 각각 순위를 매긴 후에, 두 순위 합이 가장 작은 10개 종목을 투자
- 2019년 4월에 매수해서 2019년 9월에 매도한다.
* 왜 2019년 9월 매도일까요?
* 원래는 1년 보유로 2020년 4월로 시뮬레이션을 해볼까 했습니다.
* 하지만 2020년 초에는 코로나로 인해 시뮬레이션에 적합하지 않습니다.
* 그러므로 2019년9월로 우선 잡아봤습니다.
* 이러한 매수/매도 시점을 어떻게 잡느냐에 결과는 확연히 달라질 수 있습니다.
이와 같은 시나리오로 포트폴리오를 구성하고 수익률까지 계산해 보겠습니다. 주의할 점은, 이와 같이 1년치 데이터를 가지고 시뮬레이션한 결과로 투자 전략을 확정해서는 절대 안 됩니다. 가능한 많은 데이터를 수집해 동일한 로직을 적용해보고 평균적으로 승산이 있을 때, 투자 전략을 사용해야 합니다.
설명에 앞서 항상 강조드리고 싶은 말은,이 글은 투자를 권유하거나 특정 종목을 추천하려는 글이 아닙니다. 데이터를 이와 같이 분석할 수 있음을 알려드리고 싶은 글입니다. 또한 이 글에 나온 종목에 투자해 발생한 손해는 투자자 본인의 책임임을 명심하시기 바랍니다.
이 글을 처음 접하시는 분은, 전의 글 'DART로 20년 3분기 영업이익 좋은 종목 찾기'를 먼저 따라 해 주세요.
2018년 사업보고서는 2018년 12월 31일 기준으로 결산한 보고서입니다. 저희가 실제 접할 수 있는 시점은 2019년 4월쯤입니다. 받은 파일의 압축을 풀면 네 개의 파일이 있는데 그중에 아래 파일을 엑셀에서 엽니다.
- 2019_사업보고서_03_포괄손익계산서_연결_20201117.txt
2. 불필요 컬럼 삭제
엑셀에서 분석에 용이하도록 불필요한 컬럼을 삭제합니다. [그림. 2]를 참고해주세요.
[그림. 2]
P 열 전체를 지우는 방법은 아래와 같습니다.
1. 열의 헤더인 'P'에 대고 마우스 우클릭을 합니다.
2. 삭제를 선택해 열 전체를 삭제합니다.
위 과정을 반복해, O, M, J, I, G, E, A 열을 제거합니다. (뒤쪽 열부터 제거해야 헷갈리지 않습니다.)
3. 영업이익과 2018년 12월 31일 결산만 골라내기
방금은 열(Column) 단위로 불필요한 것을 제거했습니다. 이번에는 필터 기능을 사용해 필요한 행(Row)만 골라내도록 합니다. 먼저 영업이익 항목만 골라내고, 그다음에 2018년 12월 31일 결산 데이터만 골라냅니다. 그리고 당기(2018년) 영업이익이 500억 이상만 추려내도록 하겠습니다. 아래의 [그림. 3]과 [그림. 4]를 참고해주세요.
6. 2018년 12월 31일만 선택 (종목에 따라 2018년 3월 결산된 데이터가 섞여 있기도 합니다.)
7. 확인을 클릭
이번에는 [그림. 4]와 같은 과정으로 '당기'(2018년)의 영업이익이 500억 이상인 종목만 필터 합니다. (왜 500억이냐고 물으신다면, 특별한 이유는 없습니다. 적당히 이익이 큰 회사를 투자하자 생각했습니다. 나중에는 이러한 금액도 특정한 논리나 계산을 통해 정하면 좋을 거 같습니다. 그리고 이 부분을 변경함에 따라 수익률도 변경되니 도전해 보시기 바랍니다.)
[그림. 4]
4. 작업된 데이터를 새 엑셀 파일로 카피
불필요한 열(Column)은 삭제했지만 불필요한 행(Row)은 삭제가 아닌 필터 처리했습니다. 현재 작업하고 있는 엑셀 시트에 숨겨져 있는 상황입니다. 이대로는 나머지 분석을 하기가 효율적이지 못합니다. 작업한 내용을 카피해서 새로운 엑셀 문서로 옮깁니다. [그림. 5]를 참고합니다.
[그림. 5]
1. 필터 작업을 마친 엑셀에서 Ctrl+A 입력(전체 선택), Ctrl+C 입력(카피), Ctrl+N을 입력(새 엑셀 열기)
2. 열린 새 엑셀에서 Ctrl+V 입력(붙여 넣기)
열린 새 엑셀에 작업한 내용을 붙여 넣은 다음에 '새 이름으로 저장'을 해주세요. 그리고 헷갈리지 않게 DART에서 다운로드하여 열었던 2018년 사업보고서는 닫아 주세요.
5. 특정 월의 종가 가져오기
포트폴리오를 구성하고 수익률까지 계산하기 위해서는 종목별로 특정 월의 종가가 필요합니다. 제가 보유한 데이터를 정리해서 올려드리니 아래 엑셀을 다운해 주세요. 첨부 파일에는 종목별로 18년 4월의 종가, 19년 4월 종가, 19년 9월의 종가가 있습니다.
다운로드한 파일의 내용을 지금 작업하고 있는 엑셀의 AA 열에 붙여주세요. [그림. 6]과 같습니다.
[그림. 6[
1. '종목별_특정월종가' 엑셀을 열고 Ctrl+A(전체 선택) 입력, Ctrl+C(복사) 입력
2. 작업 중인 엑셀의 AA 열로 이동, Ctrl+V(붙여 넣기) 입력
: 다음 작업에 필요한 공간을 넉넉히 비우기 위해 AA정도로 했습니다.
: 만약에 AA가 아닌 다른 곳에 붙여 넣으시면, 다음 VLOOKUP 작업에서 영역 변경이 필요합니다.
6. VLOOKUP으로 종가 붙이기
현재 가져온 월별 종가와 작업 중이던 엑셀의 종목코드는 순서가 맞지 않습니다.
다시 말해 작업 중이던 엑셀의 첫 번째 종목과 월별 종가의 첫 번째 종목은 다른 종목입니다.그러므로 작업 중이던 엑셀에, 자신의 종목코드에 맞는 종가를 찾아서 붙여주어야 합니다. 이러한 기능을 할 수 있는 것이 바로 VLOOKUP입니다.(VLOOKUP 이해를 위해서는 별도 다른 블로그 등을 참고해주시면 좋을 거 같습니다. 우선은 따라 해 보시죠.)
[그림. 7]과 같이 VLOOKUP 작업을 해서 우리가 작업하던 엑셀에 월별 종가를 추가합니다.
[그림. 7]
1. M~O 열 의 1행에 헤더명을 입력
- M1 : 18년4월종가 , N1 : 19년4월종가 , O1 : 19년9월종가
2. M2(M열 2행) : =VLOOKUP(A2,AA:AD,2,FALSE)
3. N2(N열 2행) : = VLOOKUP(A2,AA:AD,3,FALSE)
4. O2(O열 2행) : = VLOOKUP(A2,AA:AD,4,FALSE)
VLOOKUP 작업을 하실 때, 엑셀의 카피 기능을 사용하면, 자동으로 영역이 변형되어 비정상적인 수치가 나옵니다. 반드시 위와 같이 각각 직접 입력을 해주세요. 위와 같이 입력을 하면, AA부터 붙여놓은 종목별 종가에서 맞는 종가를 찾아 M2, N2, O2에 입력이 됩니다.
이제 입력한 VLOOKUP을 밑으로(행으로) 카피를 해주셔야 합니다. [그림. 8]을 참고하세요.
[그림. 8]
1. M2부터 O2를 마우스로 선택 (반드시 세 개 셀만 선택합니다.)
2. 선택된 곳에서 우측 아래 부분에 마우스를 갖다 대면, 십자가 커서가 나옵니다. 이때 더블클릭
: 더블 클릭하면 VLOOKUP 한 세 개의 셀이 아래 로우들에 모두 자동으로 카피가 됩니다.
: 각자 편한 방법으로 카피하셔도 됩니다.
7. 당기 / 전기 수식 추가하기
전기 대비 당기에 영업이익이 얼마나 늘어났는지 계산하기 위해 당기 / 전기 수식을 추가합니다. 2018년 사업보고서이기 때문에 당기는 2018년, 전기는 2017년입니다. [그림. 9]와 같이 작업합니다.
[그림. 9]
1. P열 2행에 '=J2/K2' 수식을 입력합니다.(따옴표(')는 입력하지 않습니다.)
2. 소수점을 표시하기 위해 소수점 늘이기를 세 번 정도 클릭합니다.(엑셀 상단 '홈' 메뉴에 있습니다.)
P2 셀의 수식을 아래 행에 모두 카피해줍니다. 편하신 방법을 사용하시면 됩니다. ('6. VLOOKUP으로 종가 붙이기'에서 입력한 것을 아래 행에 모두 카피한 것처럼 방금 입력한 P2 셀의 우측 하단에 마우스를 갖다 대고 더블 클릭하시면 됩니다.)
8. 당기/전기 정렬 및 순위 구하기
만들어진 당기/전기 값으로 내림차순 정렬합니다. 다시 말해 2017년 대비 2018년 영업이익이많이 늘어난 종목이 먼저 나오도록 정렬합니다. [그림. 10]과 같습니다.
[그림. 10]
1. 엑셀 최상단 메뉴에서 데이터 선택
2. 정렬 메뉴 선택
3. 그림과 같이 '당기/전기', '값', '내림차순'으로 세팅
4. 확인
정렬을 하고 나면 [그림. 11]과 같이 전기의 영업이익이 없는 데이터가 가장 위에 올라와 있습니다. '당기/전기'의 값이 '#DIV/0!'입니다. 분모의 값이 없거나 0인 경우에 이와 같은 에러 값이 나오게 됩니다. 해당 행을 삭제합니다. 분석 과정에 방해가 됩니다. 해당 행(2행) 헤더 부분에 마우스 우클릭 후에 '삭제'를 선택해 행 전체를 지웁니다.
[그림. 11]
데이터가 '당기/전기' 순서로 내림차순 되어 있습니다. 그러므로 '당기/전기 순위'를 만들어 보이는 순서대로 순위를 추가합니다.
만들어진 수식을 아래 행에 모두 카피를 해주시면 됩니다.(앞에서도 반복 설명했기 때문에, 설명은 생략합니다.)
10. 19년4월 / 18년4월 종가 순위 구하기
만들어진 '19년/18년' 값에 따라 오름차순 정렬을 하고 순위를 구합니다. '8. 당기/전기 정렬 및 순위 구하기' 단계와 같은 방법이므로 설명을 생략하고 그림만 올립니다. 주의할 점은 내림차순이 아닌 오름차순입니다. 오름차순이므로 가격이 많이 떨어진 종목부터 조회가 됩니다.
[그림. 14]
[그림. 14]와 같이 정렬을 한 후에 [그림. 15]와 같이 S열에 '19년/18년 순위'를 추가해서 순위 작업을 합니다. '8. 당기/전기 정렬 및 순위 구하기' 단계를 참고하시면 어렵지 않게 하실 수 있습니다.
[그림. 15]
11. 투자 대상 순위 구하기
'당기/전기 순위'와 '19년/18년 순위'를 구했으면 두 셀의 값을 더해서 '투자 대상 순위'를 구합니다. T열 1행에 '투자 대상 순위'라고 항목명을 정하고, T2에 '= Q2 + S2'를 입력합니다. T2의 입력한 내용을 아래 행으로 모두 카피해줍니다. [그림. 16]과 같이 투자 대상 순위가 만들어집니다.
[그림. 16]
12. 투자 대상 순위로 정렬하고 수익률 구하기
길고 긴 내용을 따라오느라 고생 많으셨습니다. 드디어 마지막 단계입니다. 만들어진 '투자 대상 순위'로 오름차순 정렬을 합니다. '투자 대상 순위'는 '당기/전기 순위'(영업이익이 늘어난 비율 순위)와 '19년/18년 순위'(18년 대비 19년에 주가가 많이 떨어진 순위'를 합한 값입니다. 즉, '투자 대상 순위'가 작을수록 영업이익은 늘어났지만 주가는 오히려 많이 떨어졌거나 그만큼 안 오른 종목이 됩니다.
[그림. 17]과 같이 '투자 대상 순위'로 정렬을 합니다.
[그림. 17]
정렬된 결과의 U2에 수익률을 구하는 공식을 입력하고, 해당 공식을 마지막 행까지 복사합니다. [그림. 18]처럼 작업하시면 됩니다.
[그림. 18]
1. U2에 수익률 공식을 입력 : =(O2-N2)/N2
2~3. 입력한 공식의 결과가 백분율로 안 나올 경우에, 홈 메뉴에 % 표시를 클릭, 백분율로 나오도록 처리.
위와 같은 작업을 한 후에 만들어진 수익률을 밑에 행까지 모두 카피해주세요.
13. 결과 마무리
'12. 투자 대상 순위로 정렬하고 수익률 구하기'까지 마무리한 결과, '투자 대상 순위' 상위 10개 종목을 2019년 4월에 매수에 2019년 9월에 매도했다면, -9%의 손해를 보게 됩니다. 절대 사용하면 안 되는 투자 전략이라 할 수 있습니다.
이를 통해 생각해 볼 문제는 이겁니다. '영업이익이 오른 것에 대비해, 주가가 덜 오른 주식은.. 위험할 수 있다.'입니다.
이익은 올랐지만 가격이 오르지 못한 것에는 다 이유가 있는 거겠죠. 그렇다면 반대로 영업이익도 오르고 주가도 오른 주식을 투자하는 역발상 투자는 어떨까요? 스스로 한 번 수익률을 계산해 보시기 바랍니다.!
그리고 이와 같이 엑셀로 데이터를 분석하는 작업에는 한계가 있습니다. 많은 데이터를 다양한 수식으로 변형하며 작업을 하기에는 너무 복잡하고 어렵습니다. 기회가 되신다면 데이터베이스와 SQL을 공부해보시기를 추천드립니다. 좀 더 쉽게 투자 전략을 세울 수 있습니다.
오늘 설명드렸던 내용은, 데이터만 갖추어져 있다면 SQL 한 문장만으로 구현이 가능합니다. 조건에 대한 변경도 매우 쉽고요.
SQL과 주식 공부에 뜻이 있으신 분은 아래 내용을 참고해주세요.
※ 주의 사항
▶ 절대 특정 종목을 추천하거나 투자를 권유하는 책이 아닙니다.
▶ 데이터 분석을 공부하기 위한 MySQL 책입니다.
▶ 책의 내용을 통해 얻은 종목에 투자해 발생한 손해는, 저를 비롯한 책 관계자 누구도 책임지지 않습니다.
다운로드할 수 있는 재무정보에는 재무상태표, 손익계산서, 현금흐름표, 자본변동표가 있습니다. 여기서는 손익계산서를 사용합니다. 이 글을 쓰는 시점으로 다운로드한 손익계산서의 파일 명은 '2020_3Q_PL_20201117041911.zip'입니다.
(재무정보에 변경이 발생하면 파일이 주기적으로 업데이트됩니다. 그러므로 받는 시점에 따라 파일명이 다를 수 있습니다.)
받은 파일의 압축을 풀어 보면 아래와 같이 4개의 파일이 있습니다.
- 2020_3분기보고서_02_손익계산서_20201117.txt
- 2020_3분기보고서_02_손익계산서_연결_20201117.txt
- 2020_3분기보고서_03_포괄손익계산서_20201117.txt
- 2020_3분기보고서_03_포괄손익계산서_연결_20201117.txt
저희는 위 파일 중에 '2020_3분기 보고서_03_포괄손익계산서_연결_20201117.txt'를 사용합니다. 손익계산서가 4개로 나누어져 있는데 일부 기업은 포괄손익계산서_연결이 아닌 다른 리포트에 있는 경우가 있습니다. 그와 같은 경우는 고려하지 않고 처리하겠습니다.
위 파일을 엑셀로 열어봅니다. 엑셀로 여는 방법은 엑셀을 실행한 후에 위 파일을 드래그해서 엑셀 위해 놓으시면 됩니다. [그림. 2]와 같이 파일을 엑셀에서 볼 수 있습니다.
[그림. 2]
2. 영업이익 500억 이상 종목만 찾아내기
손익계산서에는 다양한 항목이 있습니다. 이 중에서 저희는 영업이익만 확인해서 데이터를 분석할 예정입니다. [그림. 3]과 같이 필터를 통해 영업이익 데이터만 찾아냅니다.
[그림. 3]
1. 엑셀 최상단 메뉴에서 '데이터'를 선택
2. 엑셀 메뉴에서 '필터'를 선택
3. 항목코드(K열)에서 'dart_OperatingIncomeLoss' 를 필터로 선택
위와 같이 작업하면 손익계산서에서 영업이익 항목만 확인할 수 있습니다.
이제 엑셀에서 영업이익 수치를 담고 있는 M, N, O, P 열에 대해 알아보겠습니다. 당기는 현재 년도를 뜻합니다. 2020년이고요, 전기는 전년도인 2019년도입니다. 그리고 3분기 3개월은 3분기에 속해는 6~9월을 뜻합니다. 3분기 누적은 1월부터 9월까지(3분기까지) 누적된 값을 뜻합니다. 정리해보면 아래와 같습니다.
- M열, 당기 3분기 3개월 : 2020년 6~9월
- N열, 당기 3분기 누적 : 2020년 1월~9월
- O열, 전기 3분기 3개월 : 2019년 6~9월
- P열, 전기 3분기 누적 : 2019년 1~9월
실적이 좋지 않은 종목을 투자하는 건 리스크가 있습니다. 리스크를 줄이기 위해서 당기 3분기 누적(N열) 영업이익이 500억 이상인 종목만 대상으로 분석을 진행하겠습니다. [그림. 4]와 같이 숫자 필터를 사용해 영업이익 500억 이상 종목만 찾아냅니다.
[그림. 5]
1. 당기 3분기 누적에서 필터를 선택
2. 숫자 필터를 선택
3. 크거나 같음을 선택
4. >=(이상) 조건으로 500억을 입력
5. 확인을 클릭
위의 과정을 거치면, 3분기 누적 영업이익이 500억 이상인 종목만 조회할 수 있습니다.
3. 3분기 영업이익 비중 구하기
2020년도 누적 영업이익 안에서 특히 3분기 영업이익이 좋은 종목을 찾을 계획입니다. 그러므로 누적 영업이익 대비 3분기 영업이익의 비율을 계산식으로 추가합니다. [그림. 6]과 같습니다.
[그림. 6]
1. S열 1행에 'M/N'이라고 컬럼 명을 입력(임의로 아무 명이나 넣어도 상관없습니다.)
2. S열 2행으로 가서 '=M2/N2'을 수식 입력(입력 시 따옴표(')는 제거하고 입력합니다.)
- 수식을 넣으면 그림과 같이 계산된 값이 나옵니다.
- M2/N2 = 2행의 3분기 영업이익 / 누적 영업이익입니다. 즉, 누적 영업이익 대비 3분기의 영업이익입니다.
3. S열 2행에 입력한 수식을 엑셀의 마지막 행까지 복사를 합니다.
- 저 같은 경우 아래와 같은 방법을 사용합니다.(각자 편한 방법을 사용하세요.)
1단계: S열 2행에 커서를 위치시키고 왼쪽 Shift를 누른 채, 마지막 행까지 내려갑니다.
2단계: Shift를 놓습니다. S열 2행부터 마지막 행까지 셀이 선택되어 있습니다.
3단계: Ctrl+D를 입력합니다. S열 2행의 수식이 마지막 행까지 복사됩니다.
4. 3분기 영업 비중 순서로 정렬하기
S열에 3분기 영업 비중을 구했으면, 영업 비중 순서로 내림차순 정렬을 합니다. [그림. 7]을 참고합니다.
[그림. 7]
1. 데이터 메뉴의 '정렬'을 선택
2. 그림과 같이 'M/N' - '값' - '내림차순'을 선택
3. 확인을 클릭
위의 과정을 거치면 3분기 영업이익 비율이 가장 큰 종목들부터 정렬되어 결과가 나옵니다.
5. 결과 및 마무리
지금까지 잘 따라 하셨다면 아래와 같은 결과를 얻을 수 있습니다. 상위 다섯 개 종목만 살펴보면 롯데케미컬, 솔브레인, 한세실업, 한국전력공사, 풍산이 보입니다.
[그림. 8]
생각해볼 문제들이 있습니다. 우선 재무정보가 나오는 시점에 주가는 이미 오른 경우가 많습니다. 재무정보가 취합되어서 일반인들에게 공개되기까지는 시간이 걸립니다. 하지만 아무래도 업계 관계자나 그 기업에 관심이 많은 투자자들은 재무정보를 접하기 전에 실적이나 업황이 좋아짐을 예측해 미리 매수를 하니 당연히 주가는 선반영 된다고 생각합니다.(관계자가 재무 정보를 미리 빼돌리거나 알아냈다는 뜻은 절대 아닙니다.)
그럼에도 불구하고 이와 같이 실적이 좋은 종목들을 추려서 투자하는 것은 개인 투자자들이 가져야 할 기본적인 방향이라고 생각합니다.
그리고 더 중요한 문제가 있습니다. 이와 같이 종목을 찾아내서 투자하면 수익이 나는 것인가이죠. 이를 위해서는 과거 데이터를 더 모으고 주가와 연동해서 테스트를 해볼 필요가 있습니다. 이 부분은 SQL, 파이썬과 관련된 기술이 필요하기 때문에 여기서 다루지는 않습니다.
SQL과 주식에 관심이 있다면 아래 책을 참고해주세요.
※ 주의 사항
▶ 절대 특정 종목을 추천하거나 투자를 권유하는 책이 아닙니다.
▶ 데이터 분석을 공부하기 위한 MySQL 책입니다.
▶ 책의 내용을 통해 얻은 종목에 투자해 발생한 손해는, 저를 비롯한 책 관계자 누구도 책임지지 않습니다.
RANK와 GROUP BY가 같이 사용된 SQL에서는 GROUP BY가 모두 처리된 후에 RANK가 사용됩니다. 다시 말해 분석함수는 분석함수를 제외한 SQL의 결과가 먼저 처리된 후, 해당 결과에 분석함수가 적용됩니다.
아래는 2019년 4월의 종목별 거래금액을 억원 단위로 구한 SQL입니다. GROUP BY와 SUM을 사용합니다.
# 19년4월 종목별 거래금액(억)
SELECT T1.STK_CD
,MAX(T1.STK_NM) STK_NM
,ROUND(SUM(T2.VOL * T2.C_PRC)/1e8,1) VOL_AMT
FROM STOCK T1
INNER JOIN HISTORY_DT T2
ON (T2.STK_CD = T1.STK_CD)
WHERE T1.STK_NM IN ('NAVER','카카오','엔씨소프트')
AND T2.DT >= STR_TO_DATE('20190401','%Y%m%d')
AND T2.DT < STR_TO_DATE('20190501','%Y%m%d')
GROUP BY T1.STK_CD;
# 결과
STK_CD STK_NM VOL_AMT
======== ================= =========
035420 NAVER 15804.0
035720 카카오 10861.4
036570 엔씨소프트 7817.2
위의 SQL에 RANK를 추가합니다. 위의 결과에 대해 RANK가 처리됩니다. 아래와 같습니다.
# 19년4월 종목별 거래금액(억) – RANK 추가
SELECT T1.STK_CD
,MAX(T1.STK_NM) STK_NM
,ROUND(SUM(T2.VOL * T2.C_PRC)/1e8,1) VOL_AMT
,RANK() OVER(ORDER BY SUM(T2.VOL * T2.C_PRC)/1e8 DESC) VOL_AMT_RNK
FROM STOCK T1
INNER JOIN HISTORY_DT T2
ON (T2.STK_CD = T1.STK_CD)
WHERE T1.STK_NM IN ('NAVER','카카오','엔씨소프트')
AND T2.DT >= STR_TO_DATE('20190401','%Y%m%d')
AND T2.DT < STR_TO_DATE('20190501','%Y%m%d')
GROUP BY T1.STK_CD;
# 결과
STK_CD STK_NM VOL_AMT VOL_AMT_RNK
======== ================= ========= =============
035420 NAVER 15804.0 1
035720 카카오 10861.4 2
036570 엔씨소프트 7817.2 3
RANK와 GROUP BY - 잘 못 사용한 경우
GROUP BY 를 사용할 때, SELECT 절에는 GROUP BY에 정의한 컬럼(항목)과 집계함수를 처리한 컬럼만 사용할 수 있습니다. GROUP BY가 포함된 분석함수도 마찬가지입니다. 분석함수의 OVER 절 안에도 GROUP BY에서 정의한 컬럼(항목) 또는 집계함수를 처리한 컬럼만 사용할 수 있습니다.
아래는 제대로 RANK의 ORDER BY를 사용한 예입니다. VOL과 C_PRC를 SUM 처리해서 사용했습니다.
# 19년4월 종목별 거래금액(억) – RANK 추가
SELECT T1.STK_CD ,MAX(T1.STK_NM) STK_NM
,ROUND(SUM(T2.VOL * T2.C_PRC)/1e8,1) VOL_AMT
,RANK() OVER(ORDER BY SUM(T2.VOL * T2.C_PRC)/1e8 DESC) VOL_AMT_RNK
FROM STOCK T1
INNER JOIN HISTORY_DT T2 ON (T2.STK_CD = T1.STK_CD)
WHERE T1.STK_NM IN ('NAVER','카카오','엔씨소프트')
AND T2.DT >= STR_TO_DATE('20190401','%Y%m%d')
AND T2.DT < STR_TO_DATE('20190501','%Y%m%d')
GROUP BY T1.STK_CD;
# 결과
STK_CD STK_NM VOL_AMT VOL_AMT_RNK
======== ================= ========= =============
035420 NAVER 15804.0 1
035720 카카오 10861.4 2
036570 엔씨소프트 7817.2 3
거래금액(VOL_AMT)에 맞게 RANK가 제대로 부여된 것을 확인할 수 있습니다. 이번에는 잘 못 사용한 예입니다. RANK의 ORDER BY에서 집계함수를 처리하지 않고 VOL 과 C_PRC를 사용했습니다.
# 19년4월 종목별 거래금액(억) – RANK 추가 / 잘 못 사용한 경우
# 다른 DBMS에서는 에러가 난다.
SELECT T1.STK_CD ,MAX(T1.STK_NM) STK_NM
,ROUND(SUM(T2.VOL * T2.C_PRC)/1e8,1) VOL_AMT
,RANK() OVER(ORDER BY T2.VOL * T2.C_PRC/1e8 DESC) VOL_AMT_RNK
FROM STOCK T1
INNER JOIN HISTORY_DT T2 ON (T2.STK_CD = T1.STK_CD)
WHERE T1.STK_NM IN ('NAVER','카카오','엔씨소프트')
AND T2.DT >= STR_TO_DATE('20190401','%Y%m%d')
AND T2.DT < STR_TO_DATE('20190501','%Y%m%d')
GROUP BY T1.STK_CD;
# 결과
STK_CD STK_NM VOL_AMT VOL_AMT_RNK
======== ================= ========= =============
035420 NAVER 15804.0 1
036570 엔씨소프트 7817.2 2
035720 카카오 10861.4 3
결과를 보면 거래금액이 더 많은 카카오가 엔씨소프트보다 RANK가 낮은 것을 확인할 수 있습니다. 위와 같은 SQL은 다른 대부분 DBMS에서는 에러를 발생시키고 실행조차 되지 않습니다.
오늘도 커피에 과소비 한번 하러 신용산역 지하에 다녀왔슴다. 많은 커피샵들이 있는데, 그 중에 비엔나커피를 가봤슴욥. 넓고 인테리어가 으리으리 멋지네욤 난중에 우리 와이프랑 가야겠어욤. 라떼를 시켰는데, 맛도 괜춚네요. 우유의 텁텁함도 별로 없고 라떼보다는 카푸치노에 좀 가깝네요. 커피도 막 진하진 않고 좋네요. 담에는 아메리카노로 함 마셔봐야겠어요.
이 글은 누구라도 데이터를 활용 할 수 있게 하는데 목적이 있습니다. 기본적으로 SELECT, UPDATE등의 SQL은 사용할 줄 아는 사람들을 대상으로 합니다. 이 글을 통해 SQL 실력도 향상할 수 있습니다. 이번 글은 특히 MySQL의 성능을 다양하게 측정해보는 과정에 목적이 있습니다.
사용 DBMS : MySQL 5.7 Windows
사용 Tool : MySQL Workbench, MySQL Command Line Client
오늘은 데이터 활용과는 약간 어긋난 주제가 될지도 모르겠습니다. 최근에 이슈가 된 ‘배달의 민족’의 ‘나의 총 주문금액’을 구하는 과정을 구현해보려고 합니다. 어쩌면 뻔한 이야기가 될 수도 있습니다. 아직은 DB를 잘 모르거나, MySQL을 많이 접해보지 않은 분들을 대상으로 합니다. 디비에 능통하신 ‘디비안’ 회원님들은 스킵 부탁드립니다. 먼저, 저는 ‘배달의 민족’과 아무 관련이 없습니다.!
먼저, SQL 테스트를 위해 여기서는 사용자 테이블과 주문 테이블, 일자(C_BaseDate) 테이블을 신규로 생성합니다. 아래와 같습니다.
주문 테이블은 기존에 과정에서 만들었던 M_Shop을 참조합니다. 다만, FK를 설정하지는 않을 예정입니다.
(1) 일자 테이블 생성 및 데이터 생성
아래 SQL로 일자 테이블을 만들고 데이터를 생성합니다. 기존의 ‘데이터활용6’ 과정에서 이미 만들었던 테이블입니다. 해당 과정을 진행하신 분은 추가로 수행하실 필요 없습니다.
# 일자 테이블 생성 및 데이터 생성
# C_BaseDate
CREATE TABLE C_BaseDate
(
BaseDT DATE NOT NULL
,BaseDTSeq INT NOT NULL
, PRIMARY KEY(BaseDT)
, UNIQUE KEY(BaseDTSeq)
) ENGINE = InnoDB;
INSERT INTO C_BaseDate (BaseDT, BaseDTSeq)
SELECT '2015-01-01', 1 FROM DUAL;
-- 결과 건수가 0건이 될때까지 반복 실행한다.(2015-01-01부터 2030-12-31까지 데이터 생성)
INSERT INTO C_BaseDate (BaseDT, BaseDTSeq)
SELECT DATE_ADD(T1.BaseDT, interval T2.MAX_Seq day) BaseDT
,T1.BaseDTSeq + T2.MAX_Seq
FROM C_BaseDate T1
CROSS JOIN (SELECT MAX(BaseDT) MAX_DT, MAX(BaseDTSeq) MAX_Seq FROM C_BaseDate) T2
WHERE DATE_ADD(T1.BaseDT, interval T2.MAX_Seq day) <= '2030-12-31';
(2) 사용자 테이블 생성 및 데이터 생성
M_User 테이블을 만들고 데이터를 생성합니다. 총 10,000명의 사용자를 생성합니다. M_Shop 테이블에서 10000건의 데이터를 가져와서 사용자 데이터를 생성합니다. 만 건의 데이터가 필요해서 M_Shop을 임의로 사용했습니다.
# M_User 테이블과 데이터 생성
CREATE TABLE M_User
( UserNo INT NOT NULL
,UserName VARCHAR(100) CHARACTER SET UTF8MB4 NULL
,PRIMARY KEY(UserNo)
) ENGINE = InnoDB;
INSERT INTO M_User(UserNo, UserName)
SELECT @RNO := @RNO + 1 UserNo
,CONCAT('U_',@RNO) UserNamr
FROM (SELECT T1.ShopNo FROM M_Shop T1 ORDER BY ShopNo LIMIT 10000 ) A
CROSS JOIN (SELECT @RNO := 0 FROM DUAL) B
;
SQL의 8번과 11번 라인의 @RNO를 이용해서 순번을 부여하는 패턴은 유용하니 익혀두도록 합니다. 하지만 해당 패턴은 절대 남발하지 않습니다. 임시성이나 일회성 SQL에만 적절한 패턴입니다. 실제 서비스되는 SQL에서는 사용하지 않기를 가이드합니다. (이유는 성능상, 불리할 수 있기 때문입니다.)
(3) 상장매핑 임시 테이블 생성
M_Shop의 ShopNo는 1부터 시작하지 않습니다. 주문 데이터를 생성할 때, 실제 M_Shop의 ShopNo를 물리기 위해서, ShopNo에 1부터 시퀀스한 숫자를 매핑해서 2,000개 데이터를 만듭니다. 실전에서 ID를 완전히 새로 부여할 때 자주 사용하는 패턴입니다.
# 상장매핑 임시 테이블 생성
CREATE TEMPORARY TABLE TMP_MAPSHOP AS
SELECT @RNO := @RNO + 1 MapNo
,A.ShopNo
FROM (SELECT T1.ShopNo FROM M_Shop T1 ORDER BY ShopNo LIMIT 2000 ) A
CROSS JOIN (SELECT @RNO := 0 FROM DUAL) B
;
ALTER TABLE TMP_MAPSHOP ADD INDEX X_TMP_MAPSHOP(MapNo,ShopNo);
성능에 문제 없도록 인덱스도 잡아줍니다.
(4) 주문 테이블 생성 및 데이터 생성
아래 SQL로 T_Order를 생성하고, 가상의 주문 데이터를 만들어 냅니다.
# 주문 테이블 생성 및 데이터 생성
# ROOT 권한으로 다음 SQL을 실행해 버퍼를 늘려 놓는 것이 좋습니다.
SET GLOBAL innodb_buffer_pool_size = 402653184;
CREATE TABLE T_Order
(
OrderNo BIGINT AUTO_INCREMENT NOT NULL
,OrderDTM DATETIME NOT NULL
,OrderAMT DECIMAL(20,5) DEFAULT 0 NOT NULL
,DeliveryAMT DECIMAL(20,5) DEFAULT 0 NOT NULL
,PayAMT DECIMAL(20,5) DEFAULT 0 NOT NULL
,UserNo INT NOT NULL
,ShopNo INT NOT NULL
,PRIMARY KEY(OrderNO)
) ENGINE = InnoDB;
INSERT INTO T_Order (OrderDTM,OrderAMT,DeliveryAMT,PayAMT,UserNo,ShopNo)
SELECT T1.OrderDTM
,10000 OrderAMT
,1000 DeliveryAMT
,11000 PayAMT
,T1.UserNo
,(SELECT A.ShopNo FROM TMP_MAPSHOP A WHERE A.MapNo = T1.MapNo) ShopNo
FROM (
#일반 사용자
SELECT DATE_ADD(T1.BaseDT, interval MOD(T2.UserNo, 240) minute) OrderDTM
,T2.UserNo
,MOD(T1.BaseDTSeq + T2.UserNo ,2000) + 1 MapNo
FROM C_BaseDate T1
CROSS JOIN ( SELECT * FROM M_User A WHERE UserNo >= 30) T2
WHERE T1.BaseDT <= STR_TO_DATE('2019-10-31','%Y-%m-%d')
AND WEEKDAY(T1.BaseDT) = 6 #일요일만 배달.
UNION ALL
#우수(?) 사용자 매일 배달.
SELECT DATE_ADD(T1.BaseDT, interval MOD(T2.UserNo, 240) minute) OrderDTM
,T2.UserNo
,MOD(T1.BaseDTSeq + T2.UserNo ,2000) + 1 MapNo
FROM C_BaseDate T1
CROSS JOIN ( SELECT * FROM M_User A WHERE UserNo < 30) T2
WHERE T1.BaseDT <= STR_TO_DATE('2019-10-31','%Y-%m-%d')
) T1
ORDER BY T1.OrderDTM, T1.UserNo
;
주문을 만들 때, UserNo가 30 미만인 경우는 매일 주문을 한 우수(?) 사용자처럼 주문을 만들어 줍니다. 30 이상은 일주일에 한번 주문한 것으로 만들어줍니다. 총 2,563,877 건의 주문 데이터가 만들어 집니다.
3. 총 주문 금액 조회하기 – 테이블 전체 스캔
총 주문 금액을 조회해보도록 합니다.
SHOW STATUS를 실행하면 innodb에서 읽어들인 row 건수를 확인할 수 있습니다. 아래와 같이 100번 사용자의 총 주문금액을 조회합니다.
# 100번 사용자 주문금액 확인
SHOW STATUS LIKE 'innodb_rows_read';
SELECT SUM(T1.OrderAMT) , COUNT(*) FROM T_Order T1 WHERE T1.UserNo = 100;
SHOW STATUS LIKE 'innodb_rows_read';
제 환경에서 ‘Innodb rows’는 SQL 실행 전에 251 실행 후에 2,564,128이 나옵니다. 총 2,563,877(=2,564,128-251) 건을 읽은 것을 알 수 있습니다. 2,563,877은 테이블의 전체 건수입니다. 100번 사용자의 주문총금액을 구하기 위해 테이블의 전체 데이터를 읽은 것입니다.
이번에는 주문이 많은 10번 사용자의 총 주문금액을 조회합니다.
# 10번 사용자 주문금액 확인
EXPLAIN
SELECT SUM(T1.OrderAMT) FROM T_Order T1 WHERE T1.UserNo = 10;
아래와 같은 실행계획이 나옵니다. Type이 ALL이고 key값이 비어 있으면 테이블 전체를 읽은 것입니다. 당연히 T_Order에는 OrderNo로 구성된 PK 밖에 없으니, 원하는 데이터를 찾으려면 테이블 전체를 뒤질 수 밖에 없습니다.
4. 총 주문 금액 조회하기 – UserNo 인덱스 사용
UserNo 컬럼에 인덱스를 만들어 3번에서 수행한 테스트를 동일하게 해보도록 하겠습니다. 인덱스에 대해서는 추가적인 공부가 필요합니다. 친절한SQL튜닝책을 추천드립니다.
아래와 같이 UserNo 로 구성된 인덱스를 만듭니다.
# UserNo 인덱스 생성
CREATE INDEX X_T_Order_1 ON T_Order(UserNo);
인덱스가 잘 만들어졌는지는 아래 SQL로 확인합니다.
# 인덱스 확인
show index from T_Order;
인덱스가 만들어 졌다면 다시 innodb_rows_read를 확인하면서, 100번 사용자와 10번 사용자의 총 주문금액을 조회해봅니다.
100번 사용자의 총 주문금액을 조회합니다.
# 100번 사용자 주문금액 확인
SHOW STATUS LIKE 'innodb_rows_read';
SELECT SUM(T1.OrderAMT) , COUNT(*) FROM T_Order T1 WHERE T1.UserNo = 100;
SHOW STATUS LIKE 'innodb_rows_read';
100번 사용자 SQL 실행전과 후과 rows_read를 비교해 보면 251건의 데이터만 읽은 것을 알 수 있습니다.
이번에는 10번 사용자를 테스트해봅니다.
# 10번 사용자 주문금액 확인
SHOW STATUS LIKE 'innodb_rows_read';
SELECT SUM(T1.OrderAMT) , COUNT(*) FROM T_Order T1 WHERE T1.UserNo = 10;
SHOW STATUS LIKE 'innodb_rows_read';
10번 사용자는 1765건의 rows_read가 발생했습니다. 실제 해당 사용자가 주문한 건수만큼만 접근했습니다. UserNo에 인덱스를 만들어서 성능을 향상했습니다.
5. 총 주문 금액 조회하기 – UserNo + OrderAMT 인덱스 사용
이번에는 UserNo와 OrderAMT에 인덱스를 만들어서 테스트해보도록 합니다.
인덱스를 만들기 전에, EXPLAIN FORMAT=JSON을 사용해, 10번 사용자의 실행계획을 JSON형태로 조회해봅니다. EXPLAIN보다 자세한 정보들이 있습니다.
# 10번 사용자 주문금액 확인 - UserNo인덱스 – 실행계획 얻기
EXPLAIN FORMAT = JSON
SELECT SUM(T1.OrderAMT) , COUNT(*) FROM T_Order T1 WHERE T1.UserNo = 10;
이전 SQL과 비교해보면, cost가 현저히 낮아졌습니다. 인덱스에 OrderAMT가 추가되어서 인덱스만으로 모든 문제를 해결할 수 있기 때문이다. 사실,,, 이러한 부분이 실행계획에 명확히 표시되면 좋은데 MySQL은 그렇지가 않습니다. 그러므로 오라클을 사용하던 분들이 MySQL을 쓰면 답답해서 속이 터집니다. 그래도 현실에 적응해야 합니다. 그냥 무료로 사용할 수 있음에 감사해야겠습니다.
실제, 실행시간도 측정해보도록 합니다. 아래 SQL들을 실행해봅니다.
# 2번 사용자, 1번 사용자 주문금액 확인(각각 다른 인덱스 사용)
SET PROFILING = ON;
SELECT SQL_NO_CACHE SUM(T1.OrderAMT),COUNT(*) FROM T_Order T1 FORCE INDEX(X_T_Order_2)
WHERE T1.UserNo = 2;
SELECT SQL_NO_CACHE SUM(T1.OrderAMT),COUNT(*) FROM T_Order T1 FORCE INDEX(X_T_Order_1)
WHERE T1.UserNo = 1;
일부러, 기존의 버퍼캐시의 데이터를 읽지 않도록 다른 UserNo를 조회합니다. 첫 번쨰 SQL은 UserNo+OrderAMT 인덱스를 사용하게 했고, 두 번째 SQL은 UserNo 인덱스를 사용하도록 힌트를 주었습니다.
SHOW PROFILES로 실행 성능을 확인해봅니다.
SHOW PROFILES;
저의 환경에서는 UserNo+OrderAMT 인덱스를 사용한 경우에는 0.008초로 측정되었고, UserNo 인덱스를 사용한 경우에는 0.2초로 측정되었습니다. 실제 시간으로도 UserNo+OrderAMT가 월등한걸 알 수 있습니다. (너무 당연한걸 길게 설명하고 있습니다.ㅜ.ㅜ)
추가로.. 개인적으로 OrderAMT와 같은 금액, 단가와 같은 컬럼이 인덱스에 들어가는 것을 별로 추천하지는 않습니다. 꼭 어쩔 수 없을 때만 사용합니다.
6. 총 주문 금액 – 집계 테이블 전략
UserNo+OrderAMT 인덱스를 만들었어도, 10번 사용자의 총 주문 금액을 조회하려면 1,765건의 데이터를 읽어야 하는 것은 피할 수 없습니다. (물론, 인덱스의 리프 블록만 읽으므로 매우 빠릅니다.) 한 명이 사용하는 거라면 큰 이슈가 없겠지만, 배달의 민족처럼 매우 많은 사용자가 한 순간에 조회를 요청하면 이 또한 심한 부하가 발생될 수 있습니다. 이를 해결하기 위해 집계(배치) 테이블 전략을 시도해보겠습니다. 사실 집계 테이블을 하면 성능이 월등히 좋을거야란 추측으로 시도했으나 현실은 그렇지 않네요!!!!!. 어쨌든, SQL에 대한 공부로 생각해주시면 감사하겠습니다.
아래와 같은 집계 테이블을 설계합니다.
테이블을 보면, 누적주문금액 컬럼을 가지고 있습니다. 사용자의 주문년월까지의 누적주문금액을 저장합니다. 예를 들어 2018년1월이라면 최초부터 2018년1월까지의 주문금액 합계가, 2019년2월이라면 최초부터 2019년2월까지의 주문금액을 누적해서 저장합니다. 마지막 월의 해당 값만 읽어와서 총주문금액을 처리하는 전략입니다.
아래 스크립트로 테이블을 생성합니다.
# 사용자월별주문 집계 테이블 생성
CREATE TABLE S_UserOrderYM
(
OrderYM VARCHAR(6) CHARACTER SET UTF8MB4 NOT NULL
,UserNo INT NOT NULL
,OrderAMT DECIMAL(20,5) DEFAULT 0 NOT NULL
,DeliveryAMT DECIMAL(20,5) DEFAULT 0 NOT NULL
,PayAMT DECIMAL(20,5) DEFAULT 0 NOT NULL
,SUMOrderAMT DECIMAL(20,5) DEFAULT 0 NOT NULL
,PRIMARY KEY(OrderYM,UserNo)
) ENGINE = InnoDB;
아래 SQL로 SUMOrderAMT를 제외한 2019년9월까지의 주문을 S_UserOrderAMT에 입력합니다.
# 사용자월별주문 초기 데이터 생성
INSERT INTO S_UserOrderYM
(OrderYM,UserNo,OrderAMT,DeliveryAMT,PayAMT)
SELECT DATE_FORMAT(OrderDTM,'%Y%m')
,UserNo
,SUM(T1.OrderAMT)
,SUM(T1.DeliveryAMT)
,SUM(T1.PayAMT)
FROM T_Order T1
WHERE OrderDTM < '2019-10-01'
GROUP BY DATE_FORMAT(OrderDTM,'%Y%m'), UserNo;
아직, 10월달 데이터와 SUMOrderAMT는 입력하지 않았습니다. 입력된 9월까지의 SUMOrderAMT는 아래 업데이트 SQL로 처리합니다. MySQL 5.7은 분석함수 기능이 없습니다. 분석함수 기능이 있다면, 분석 함수 한 줄로 깔끔하게 총 주문금액을 처리할 수 있는데 안타깝습니다. 어쩔 수 없이 아래와 같이 업데이트 합니다. 업데이트 전에 루트 계정으로 버퍼풀 사이즈도 늘려주고 추가로 인덱스도 만들어줍니다. 업데이트 처리시에 성능 때문입니다.
# 사용자월별주문 누적주문금액 업데이트
SET GLOBAL innodb_buffer_pool_size = 402653184;
CREATE INDEX X_S_UserOrderYM ON S_UserOrderYM(UserNo,OrderYM,OrderAMT);
UPDATE S_UserOrderYM T1
right outer join (
SELECT A.UserNo, A.OrderYM , SUM(B.OrderAMT) SUMOrderAMT
FROM S_UserOrderYM A
INNER JOIN S_UserOrderYM B
ON (A.UserNO = B.UserNo AND A.OrderYM >= B.OrderYM)
GROUP BY A.UserNo, A.OrderYM
) T2
ON (T1.UserNo = T2.UserNo AND T1.OrderYM = T2.OrderYM)
SET T1.SUMOrderAMT = T2.SUMOrderAMT;
업데이트를 보시면 S_UserOrderYM과 인라인-뷰(7~12)의 결과를 아우터-조인(6번 라인) 하고 있습니다. 업데이트 처리가, 인라인-뷰=>S_UserOrderYM 순으로 해야 성능이 좋을거 같습니다. 그냥 INNER JOIN을 하면 이처럼 안되기 때문에 일부러 RIGHT OUTER JOIN을 사용했습니다. 아주,… 힌트가 안되니 어렵습니다. (오라클이 그립습니다.)
이제 주문총금액을 조회해봅니다. 그런데, 집계 테이블에는 9월 주문까지만 있습니다. 그러므로 10월 주문금액 정보는 T_Order에서 읽어야 합니다. T_Order의 효율적인 접근을 위해 아래와 같이 인덱스를 추가합니다.(인덱스가 계속 늘어납니다. 좋지 않습니다. 나중에 불필요한 인덱스는 제거해야 합니다.)
# 집계 테이블을 이용한 사용자 총주문금액 조회 – T_Order 인덱스 추가
CREATE INDEX X_T_Order_3 ON T_Order(UserNo,OrderDTM);
아래는 실제 조회 SQL입니다.
집계 테이블을 이용한 사용자 총주문금액 조회
SHOW STATUS LIKE 'innodb_rows_read';
SELECT MAX(T1.SUMOrderAMT) + IFNULL(SUM(T2.OrderAMT),0)
FROM (
SELECT T1.SUMOrderAMT
,DATE_ADD(STR_TO_DATE(CONCAT(T1.OrderYM,'01'),'%Y%m%d'),interval 1 month) FromDate
FROM S_UserOrderYM T1
WHERE T1.UserNo = 10
ORDER BY T1.OrderYM DESC
LIMIT 1
) T1
LEFT OUTER JOIN T_Order T2
ON (T2.UserNo = 10 AND T2.OrderDTM >= T1.FromDate);
SHOW STATUS LIKE 'innodb_rows_read';
위 SQL에서 주의 깊게 볼 만한 부분은 T1과 T2를 조인 처리하는 부분입니다.보시고 무릎을 탁 치신다면 이미 SQL은 저만큼 하시는 겁니다.
Rows_read를 확인해보면, 이전에는 1,765건을 읽었지만 이번에는 33건만 읽어서 결과를 처리했습니다. 집계 테이블이라는 불편한 작업이 있었지만, 확실한 성능 개선 효과가 있었다고 단정할 수 있습니다. JSON 실행계획도 확인해 보시기 바랍니다. 전체 실행계획은 훨씬 길고 복잡해졌지만 query cost가 44로 확실하게 좋아졌습니다.
이제 실제 실행시간도 측정해 봅니다. 실제 실행시간을 측정하기 전에는 당연히 집계 전략이 유리할거라 생각했습니다.!!!!! 그러나…
실제 실행시간을 측정해 봅니다. 각각 다른 사용자를 사용합니다.
# 집계 테이블을 이용한 사용자 총주문금액 조회 – 시간 측정
SET PROFILING = ON;
SELECT SQL_NO_CACHE
MAX(T1.SUMOrderAMT) + IFNULL(SUM(T2.OrderAMT),0)
FROM (
SELECT T1.SUMOrderAMT
,DATE_ADD(STR_TO_DATE(CONCAT(T1.OrderYM,'01'),'%Y%m%d'),interval 1 month) FromDate
FROM S_UserOrderYM T1
WHERE T1.UserNo = 23
ORDER BY T1.OrderYM DESC
LIMIT 1
) T1
LEFT OUTER JOIN T_Order T2
ON (T2.UserNo = 23
AND T2.OrderDTM >= T1.FromDate);
SELECT SQL_NO_CACHE SUM(T1.OrderAMT),COUNT(*) FROM T_Order T1 FORCE INDEX(X_T_Order_2)
WHERE T1.UserNo = 29;
SHOW PROFILES;
실제 실행시간을 확인해 보면, 사용자의 T_Order를 그냥 모두 읽은 것이 더 좋습니다. SHOW PROFILES에서 얻은 각 쿼리의 ID를 이용해 실행타임을 자세하게 분석해 봅니다.
# 쿼리ID를 이용해 자세한 단계별 실행시간 확인
#집계 테이블 사용+실적 테이블 사용
SHOW PROFILE FOR QUERY 135;
#실적 테이블만 사용
SHOW PROFILE FOR QUERY 136;
비교 결과는 아래와 같습니다.
비교해보면, ‘집계 테이블과 조인’은 두 번의 executing과 sending data가 발생합니다. 아마도 조인을 위해 스토리지 엔진을 나누어서 다녀오기 때문인 듯 합니다.
지금의 상황으로는 집계 테이블이 유리한 상황은 아닌 거 같습니다. (하지만 데이터가 더 많고, 많은 사용자가 몰린다면 집계 전략이 훨씬 유리할거라 여전히 추측은 됩니다.) 추가로 해보고 싶은 전략도 많고, 데이터 분포 변경에 따른 테스트도 해보고 싶지만 여기까지 쓰고 줄이도록 하겠습니다.
감사합니다.
이처럼, 데이터를 분석하는 과정을 공부해보고 싶으신 분은 아래의 '평생 필요한 데이터 분석'의 교육 과정을 추천합니다. 교육을 통해 SQL을 배운다면, 위 내용을 좀 더 보강할 수도 있고, 자신만의 스타일로 분석을 할 수 있습니다. SQL을 완전히 자신의 것으로 만들 수 있는 교육이니 관심 가져보시기 바랍니다. 감사합니다.~!
# T_KBLiivHouse 테이블 생성
CREATE TABLE T_KBLiivHouse
(
BaseYM VARCHAR(6) NOT NULL
,BZDongCode VARCHAR(100) NOT NULL
,AMTDiv VARCHAR(100) NOT NULL
,AMT DECIMAL(30,12) NOT NULL DEFAULT 0
,PRIMARY KEY (BaseYM ,BZDongCode ,AmtDiv)
);
이번에는 업로드 테이블의 데이터를 T_KBLiivHouse로 인서트합니다. 지난 글처럼, 업로드한 테이블은 년월이 컬럼으로 구성되어 있지만, 실제 분석에 사용할 테이블은 년월이 로우로 구성됩니다. 업로드 테이블에는 201609부터 201909까지 무려 3년치의 컬럼이 있습니다. 컬럼을 로우로 바꾸기 위해, 우선 아래 SQL을 실행해 봅니다.
# 206109부터 201909까지 로우 만들기
SELECT DATE_FORMAT(DATE_ADD(STR_TO_DATE('20160901','%Y%m%d')
, interval T1.Seq Month),'%Y%m') BaseYM
FROM C_Seq T1
WHERE DATE_ADD(STR_TO_DATE('20160901','%Y%m%d')
, interval T1.Seq Month) < STR_TO_DATE('20191001','%Y%m%d')
위 SQL을 실행하면 201609부터 201909까지 로우로 데이터가 만들어 집니다. 위 SQL의 결과와 업로드한 테이블을 크로스-조인해서 최종 분석용 테이블에 데이터를 인서트합니다. 아래와 같습니다.
# T_KBLiivHouse에 데이터 넣기
SET @@SESSION.sql_mode = REPLACE(@@SESSION.sql_mode,'STRICT_TRANS_TABLES,','');
INSERT INTO T_KBLiivHouse
(BaseYM,BZDongCode,AMTDiv,Amt)
SELECT T2.BaseYM
,T1.BZDongCode
,CASE WHEN T1.ValueDiv LIKE '%매매평균가%' THEN 'BUY' ELSE 'RENT' END AmtDiv
,CASE T2.BaseYM
WHEN '201609' THEN AMT_201609
WHEN '201610' THEN AMT_201610
WHEN '201611' THEN AMT_201611
WHEN '201612' THEN AMT_201612
WHEN '201701' THEN AMT_201701
WHEN '201702' THEN AMT_201702
WHEN '201703' THEN AMT_201703
WHEN '201704' THEN AMT_201704
WHEN '201705' THEN AMT_201705
WHEN '201706' THEN AMT_201706
WHEN '201707' THEN AMT_201707
WHEN '201708' THEN AMT_201708
WHEN '201709' THEN AMT_201709
WHEN '201710' THEN AMT_201710
WHEN '201711' THEN AMT_201711
WHEN '201712' THEN AMT_201712
WHEN '201801' THEN AMT_201801
WHEN '201802' THEN AMT_201802
WHEN '201803' THEN AMT_201803
WHEN '201804' THEN AMT_201804
WHEN '201805' THEN AMT_201805
WHEN '201806' THEN AMT_201806
WHEN '201807' THEN AMT_201807
WHEN '201808' THEN AMT_201808
WHEN '201809' THEN AMT_201809
WHEN '201810' THEN AMT_201810
WHEN '201811' THEN AMT_201811
WHEN '201812' THEN AMT_201812
WHEN '201901' THEN AMT_201901
WHEN '201902' THEN AMT_201902
WHEN '201903' THEN AMT_201903
WHEN '201904' THEN AMT_201904
WHEN '201905' THEN AMT_201905
WHEN '201906' THEN AMT_201906
WHEN '201907' THEN AMT_201907
WHEN '201908' THEN AMT_201908
WHEN '201909' THEN AMT_201909
END Amt
FROM U_KBLiivHouse T1 CROSS JOIN
(
SELECT DATE_FORMAT(DATE_ADD(STR_TO_DATE('20160901','%Y%m%d')
, interval T1.Seq Month),'%Y%m') BaseYM
FROM C_Seq T1
WHERE DATE_ADD(STR_TO_DATE('20160901','%Y%m%d')
, interval T1.Seq Month) < STR_TO_DATE('20191001','%Y%m%d')
) T2
WHERE T1.BZDongCode IS NOT NULL;
6. 비싼 동네에는 어떤 상가가 많을까?
절대!!! 부동산 권유의 글이 아닙니다. 데이터 분석을 설명하는 글입니다.
비싼 동네(?)에는 어떤 상가(M_Shop)가 많은지 분석해 보도록 하겠습니다. 이 내용이 진짜 부동산 시세와 상관이 있는지는 알 수 없습니다.(상관도는 나중에 파이썬으로 한 번 돌려보면 재미 있을거 같습니다.) 그냥 뭐가 있을까? 란 호기심의 분석입니다.
먼저 ‘법정동’ 별로 분석을 하면 데이터 종류가 너무 많아 질거 같습니다. ‘시군구’ 단위로 분석을 해보겠습니다. 부동산 데이터는 가장 최근의 2019년9월 데이터만 사용하고, 매매(AMTDiv=Buy) 데이터만 사용합니다. 분석 전에 M_Shop에 XiGoonGuCode, MktSDivCode 컬럼에 인덱스를 만듭니다. 분석 성능을 위해서죠.
# M_Shop에 인덱스 생성
CREATE INDEX X_M_Shop_2 ON M_Shop(XiGoonGuCode,MktSDivCode);
아래 SQL로 분석을 합니다. 부동산 평균 가격 Top-10 ‘시군구’에 가장 많은 상가 종류 Top-5를 찾습니다.
# Top10 시군구의 상가 종류 Top5
SELECT T2.MktSDivCode
,(SELECT A.BaseCodeName FROM C_BaseCode A
WHERE A.BaseCodeDiv = 'MktSDivCode' AND A.BaseCode = T2.MktSDivCode) MktSDivCodeName
,COUNT(*) ShopCnt
FROM (
SELECT T2.XiGoonGuCode
,MAX(T2.XiGoonGuName) XiGoonGuName
,AVG(T1.AMT)
FROM T_KBLiivHouse T1
INNER JOIN M_RegionMap2 T2
ON (T1.BZDongCode = T2.BZDongCode)
WHERE T1.BaseYM = '201909'
AND T1.AMTDiv = 'BUY'
GROUP BY T2.XiGoonGuCode
ORDER BY AVG(T1.AMT) DESC
LIMIT 10
) T1 INNER JOIN M_Shop T2
ON (T1.XiGoonGuCode = T2.XiGoonGuCode)
GROUP BY T2.MktSDivCode
ORDER BY COUNT(*) DESC
LIMIT 5;
위의 6번 절에서, 비싼 동네에는 ‘커피전문점, 부동산중개, 여성미용실’이 많았습니다. 이 정보를 이용해서 역으로 분석을 한 번 해봅니다. 가격이 낮은 ‘시군구’ Top-10 중에, ‘커피전문점, 부동산중개, 여성미용실’가 많은 동네를 찾아보는 겁니다.
아래 SQL입니다. 서울시와 다른 시는 가격차이가 크므로, 19번 라인에서 서울특별시만 조회해서 처리했습니다. 다른 지역도 필요하면 조건을 추가하시면 됩니다.
# 어디가 좋을까?
SELECT T1.XiGoonGuCode
,MAX(T1.XiDoName)
,MAX(T1.XiGoonGuName)
,COUNT(T2.ShopNo)
,COUNT(CASE WHEN T2.MktSDivCode = 'Q12A01' THEN T2.ShopNo END) CNT_COFFEE
,COUNT(CASE WHEN T2.MktSDivCode = 'L01A01' THEN T2.ShopNo END) CNT_BUDONG
,COUNT(CASE WHEN T2.MktSDivCode = 'F01A01' THEN T2.ShopNo END) CNT_BEAUTY
,ROUND(MAX(T1.AMT)) AVG_AMT
FROM (
SELECT T2.XiGoonGuCode
,MAX(T2.XiDoName) XiDoName
,MAX(T2.XiGoonGuName) XiGoonGuName
,AVG(T1.AMT) AMT
FROM T_KBLiivHouse T1
INNER JOIN M_RegionMap2 T2
ON (T1.BZDongCode = T2.BZDongCode)
WHERE T1.BaseYM = '201909'
AND T1.AMTDiv = 'BUY'
and T2.XidoName in ('서울특별시')#,'부산광역시','경기도')
GROUP BY T2.XiGoonGuCode
ORDER BY AVG(T1.AMT) ASC
LIMIT 10
) T1 INNER JOIN M_Shop T2
ON (T1.XiGoonGuCode = T2.XiGoonGuCode
AND T2.MktSDivCode IN ('Q12A01','L01A01','F01A01') )
GROUP BY T2.XiGoonGuCode
ORDER BY COUNT(T2.ShopNo) DESC;
아래와 같은 결과를 얻었습니다.
# 결과
XiGoonGuCode MAX(T1.XiDoName) MAX(T1.XiGoonGuName) COUNT(T2.ShopNo) CNT_COFFEE CNT_BUDONG CNT_BEAUTY AVG_AMT
============ ================ ==================== ================ ========== ========== ========== ===
11380 서울특별시 은평구 2004 559 493 952 515
11620 서울특별시 관악구 1952 505 600 847 570
11290 서울특별시 성북구 1792 552 480 760 573
11350 서울특별시 노원구 1724 536 386 802 517
11230 서울특별시 동대문구 1680 512 560 608 625
11530 서울특별시 구로구 1676 543 480 653 477
11260 서울특별시 중랑구 1449 308 350 791 469
11545 서울특별시 금천구 1127 364 344 419 474
11320 서울특별시 도봉구 1064 285 257 522 415
11305 서울특별시 강북구 992 252 183 557 434
- 메뉴: 뉴스/자료실 > 월간KB주택가격동향 > 리스트 제일 위에, ‘Liiv ON 아파트 시세’ 통계
- 위의 항목에 들어가서 아래 쪽에 ‘시세통계_면적당평균가_201909기준(2010버전).xlsx’
받은 엑셀 파일을 열어보면 아래와 같습니다.
위 엑셀 파일을 약간 변환합니다. 불필요한 1~4번 라인을 삭제하고 CSV 파일로 저장합니다. CSV로 저장된 파일을 메모장에서 열어보면 아래 그림과 같습니다.
데이터를 올릴 때, 한글이 깨지므로 메모장에서 UTF-8로 변환해서 저장하도록 합니다.
3. 업로드 테이블 설계 및 생성
아래와 같이 업로드 테이블을 설계합니다.
아래 SQL로 업로드용 테이블을 생성합니다.
# 업로드 테이블 생성
CREATE TABLE U_KBLiivHouse
(
XiDo VARCHAR(100) CHARACTER SET UTF8MB4 NULL,
GuXiGun VARCHAR(100) CHARACTER SET UTF8MB4 NULL,
Gu VARCHAR(100) CHARACTER SET UTF8MB4 NULL,
DongYM VARCHAR(100) CHARACTER SET UTF8MB4 NULL,
ValueDiv VARCHAR(100) CHARACTER SET UTF8MB4 NULL,
AMT_201609 VARCHAR(100) CHARACTER SET UTF8MB4 NULL,
AMT_201610 VARCHAR(100) CHARACTER SET UTF8MB4 NULL,
AMT_201611 VARCHAR(100) CHARACTER SET UTF8MB4 NULL,
AMT_201612 VARCHAR(100) CHARACTER SET UTF8MB4 NULL,
AMT_201701 VARCHAR(100) CHARACTER SET UTF8MB4 NULL,
AMT_201702 VARCHAR(100) CHARACTER SET UTF8MB4 NULL,
AMT_201703 VARCHAR(100) CHARACTER SET UTF8MB4 NULL,
AMT_201704 VARCHAR(100) CHARACTER SET UTF8MB4 NULL,
AMT_201705 VARCHAR(100) CHARACTER SET UTF8MB4 NULL,
AMT_201706 VARCHAR(100) CHARACTER SET UTF8MB4 NULL,
AMT_201707 VARCHAR(100) CHARACTER SET UTF8MB4 NULL,
AMT_201708 VARCHAR(100) CHARACTER SET UTF8MB4 NULL,
AMT_201709 VARCHAR(100) CHARACTER SET UTF8MB4 NULL,
AMT_201710 VARCHAR(100) CHARACTER SET UTF8MB4 NULL,
AMT_201711 VARCHAR(100) CHARACTER SET UTF8MB4 NULL,
AMT_201712 VARCHAR(100) CHARACTER SET UTF8MB4 NULL,
AMT_201801 VARCHAR(100) CHARACTER SET UTF8MB4 NULL,
AMT_201802 VARCHAR(100) CHARACTER SET UTF8MB4 NULL,
AMT_201803 VARCHAR(100) CHARACTER SET UTF8MB4 NULL,
AMT_201804 VARCHAR(100) CHARACTER SET UTF8MB4 NULL,
AMT_201805 VARCHAR(100) CHARACTER SET UTF8MB4 NULL,
AMT_201806 VARCHAR(100) CHARACTER SET UTF8MB4 NULL,
AMT_201807 VARCHAR(100) CHARACTER SET UTF8MB4 NULL,
AMT_201808 VARCHAR(100) CHARACTER SET UTF8MB4 NULL,
AMT_201809 VARCHAR(100) CHARACTER SET UTF8MB4 NULL,
AMT_201810 VARCHAR(100) CHARACTER SET UTF8MB4 NULL,
AMT_201811 VARCHAR(100) CHARACTER SET UTF8MB4 NULL,
AMT_201812 VARCHAR(100) CHARACTER SET UTF8MB4 NULL,
AMT_201901 VARCHAR(100) CHARACTER SET UTF8MB4 NULL,
AMT_201902 VARCHAR(100) CHARACTER SET UTF8MB4 NULL,
AMT_201903 VARCHAR(100) CHARACTER SET UTF8MB4 NULL,
AMT_201904 VARCHAR(100) CHARACTER SET UTF8MB4 NULL,
AMT_201905 VARCHAR(100) CHARACTER SET UTF8MB4 NULL,
AMT_201906 VARCHAR(100) CHARACTER SET UTF8MB4 NULL,
AMT_201907 VARCHAR(100) CHARACTER SET UTF8MB4 NULL,
AMT_201908 VARCHAR(100) CHARACTER SET UTF8MB4 NULL,
AMT_201909 VARCHAR(100) CHARACTER SET UTF8MB4 NULL
) ENGINE = InnoDB;
생성된 테이블에 CSV(UTF-8로 변환된) 파일을 올립니다. 아래와 같습니다.
# U_Population 업로드 (아래 SQL은 ROOT계정으로 MySQL Command Line Client에서 실행해야 합니다.)
USE SWEET_DATA;
SET character_set_client = utf8;
SET character_set_connection = utf8;
SET character_set_database = utf8;
SET character_set_results = utf8;
SET character_set_server = utf8;
LOAD DATA LOCAL INFILE 'C:\\upload\\up_budong_UTF8.CSV' INTO TABLE U_KBLiivHouse FIELDS TERMINATED BY ',' ENCLOSED BY '"' LINES TERMINATED BY '\r\n' IGNORE 1 LINES;
4. 법정동코드 찾아내기
업로드된 데이터를 조회해보면 아래와 같습니다.
KB Liiv의 부동산 데이터의 ‘동’ 정보는 ‘법정동’이라고 합니다. ‘동’ 정보는 ‘행정동’과 ‘법정동’이 있는거 같습니다.
법정동은 법적으로 나누어진 동이고, 행정동은 실질적인 지역을 통제(?)하는 단위인듯 합니다. (제 짧은 생각입니다. 정확한지 모르겠습니다.)
어쨌든, 저번 글에서는 행정동을 기준으로 지역매핑을 만들었는데, 이번에는 법정동을 기준으로 지역매핑을 만들어야 합니다. 아래와 같이 테이블을 설계합니다.
테이블 명을 정하는데 많은 시간을 할애하기 힘들어서 그냥 M_RegionMap2로 했습니다. (이와 같은 방법은 다양한 장단점이 있습니다.) 그리고 테이블명에 지역 명칭도 모두 같이 넣었습니다. 위의 테이블 구조는 정규화를 위배하는 모델입니다. 하지만 분석을 편하게 하기 위해서, 또는 SQL을 간단화하기 위해서 예외적으로 위와 같이 설계를 할 수 도 있습니다. (이것 역시 장단점이 다양합니다.)
아래 SQL로 테이블을 생성합니다.
# M_RegionMap2(법정동 기준) 생성
CREATE TABLE M_RegionMap2
(
BZDongCode VARCHAR(100) NOT NULL
,XiGoonGuCode VARCHAR(100) NOT NULL
,XiDoCode VARCHAR(100) NOT NULL
,BZDongName VARCHAR(100) CHARACTER SET UTF8MB4 NOT NULL
,XiGoonGuName VARCHAR(100) CHARACTER SET UTF8MB4 NOT NULL
,XiDoName VARCHAR(100) CHARACTER SET UTF8MB4 NOT NULL
,PRIMARY KEY(BZDongCode)
) ENGINE = InnoDB;
테이블을 생성한 후에는, 업로드상가(U_Shop) 테이블을 이용해 법정동 매핑 데이터를 만들어 냅니다. 실제로는 법정동 매핑이 관리되는 마스터 데이터가 별도로 필요합니다. 하지만 상가 정보를 이용해서 구성해도 큰 문제는 없어 보입니다. 아래 SQL을 이용합니다. SQL 실행전에 루트 권한으로 Buffer Pool을 늘려주셔야 합니다.(데이터활용3번 글 참고)
# 법정동 기준 데이터 생성
INSERT INTO M_RegionMap2
(BZDongCode,XiGoonGuCode,XiDoCode
,BZDongName,XiGoonGuName,XiDoName)
SELECT DISTINCT
BZDongCode,XiGoonGuCode,XiDoCode
,BZDongName,XiGoonGuName,XiDoName
FROM U_Shop
WHERE BZDongCode IS NOT NULL
AND XiGoonGuCode IS NOT NULL
AND XiDoCode IS NOT NULL;
업로드한 부동산 테이블(U_KBLiivHouse)에는 법정동명칭만 있습니다. 법정동 코드 컬럼을 추가하고 방금 생성한 M_RegionMap2를 이용해 법정동 코드를 업데이트 하도록 합니다.
아래 SQL로 법정동코드를 추가합니다. (법정동 코드를 추가하면서 M_RegionMap2에 BZDongName과 XiGoonGuName 컬럼에 인덱스도 만들어 놓도록 합니다. 업데이트 성능을 위해서입니다.)
# 법정동코드 추가
ALTER TABLE U_KBLiivHouse ADD BZDongCode VARCHAR(100);
CREATE INDEX X_M_RegionMap2_1 ON M_RegionMap2(BZDongName, XiGoonGuName);
아래 SQL로 U_KBLiivHouse테이블의 법정동코드(BZDongCode)를 업데이트합니다.
# 법정동코드 업데이트
UPDATE U_KBLiivHouse A
INNER JOIN M_RegionMap2 B
ON (A.DongYM = B.BZDongName
AND A.XiDo = B.XiDoName
AND A.GuXiGun = B.XiGoonGuName)
SET A.BZDongCode = B.BZDongCode;
위와 같이 업데이트 한 후에도, 법정동코드를 찾지 못하는 경우가 있습니다. 확인해 보면, U_KBLiivHouse의 ‘시군구’명과 M_RegionMap2의 ‘시군구’명의 구성이 차이가 있어서입니다. 아래와 같이 추가 업데이트를 합니다.(위에서 업데이트 되지 않은 대상만 업데이트 처리합니다.)
# 법정동코드 업데이트2
# 구시랑 구가 합쳐진 데이터 존재.
UPDATE U_KBLiivHouse A
INNER JOIN M_RegionMap2 B
ON (A.DongYM = B.BZDongName
AND A.XiDo = B.XiDoName
AND CONCAT(A.GuXiGun,A.Gu) = REPLACE(B.XiGoonGuName,' ',''))
SET A.BZDongCode = B.BZDongCode
WHERE A.BZDongCode IS NULL;
다시 한번 U_KBLiivHouse 에서 법정동코드(BZDongCode)가 NULL인 데이터를 조회해봅니다. 여전히 ‘충청북도 덕산읍’이 업데이트 되지 못했습니다. 이런 경우 수작업으로 찾아서 업데이트를 해야 하지만, 단 두 건이므로 무시하고 진행하도록 하겠습니다.
업로드 테이블 생성
CREATE TABLE U_Population
(
BasePeriod VARCHAR(100) CHARACTER SET UTF8MB4 NULL
,ZCGu VARCHAR(100) CHARACTER SET UTF8MB4 NULL
,HZDong VARCHAR(100) CHARACTER SET UTF8MB4 NULL
,PopuDiv VARCHAR(100) CHARACTER SET UTF8MB4 NULL
,C_Total VARCHAR(100) CHARACTER SET UTF8MB4 NULL
,C_0_4 VARCHAR(100) CHARACTER SET UTF8MB4 NULL
,C_5_9 VARCHAR(100) CHARACTER SET UTF8MB4 NULL
,C_10_14 VARCHAR(100) CHARACTER SET UTF8MB4 NULL
,C_15_19 VARCHAR(100) CHARACTER SET UTF8MB4 NULL
,C_20_24 VARCHAR(100) CHARACTER SET UTF8MB4 NULL
,C_25_29 VARCHAR(100) CHARACTER SET UTF8MB4 NULL
,C_30_34 VARCHAR(100) CHARACTER SET UTF8MB4 NULL
,C_35_39 VARCHAR(100) CHARACTER SET UTF8MB4 NULL
,C_40_44 VARCHAR(100) CHARACTER SET UTF8MB4 NULL
,C_45_49 VARCHAR(100) CHARACTER SET UTF8MB4 NULL
,C_50_54 VARCHAR(100) CHARACTER SET UTF8MB4 NULL
,C_55_59 VARCHAR(100) CHARACTER SET UTF8MB4 NULL
,C_60_64 VARCHAR(100) CHARACTER SET UTF8MB4 NULL
,C_65_69 VARCHAR(100) CHARACTER SET UTF8MB4 NULL
,C_70_74 VARCHAR(100) CHARACTER SET UTF8MB4 NULL
,C_75_79 VARCHAR(100) CHARACTER SET UTF8MB4 NULL
,C_80_84 VARCHAR(100) CHARACTER SET UTF8MB4 NULL
,C_85_89 VARCHAR(100) CHARACTER SET UTF8MB4 NULL
,C_90_94 VARCHAR(100) CHARACTER SET UTF8MB4 NULL
,C_95_99 VARCHAR(100) CHARACTER SET UTF8MB4 NULL
,C_OV_100 VARCHAR(100) CHARACTER SET UTF8MB4 NULL
) ENGINE = InnoDB
;
데이터를 올리는 방법은 저번 글들에 이미 설명했습니다.
지금 올릴 파일도 UTF-8로 이미 만들어져 있습니다. UTF-8로 변환하지 않아도 됩니다. 다만 이번 파일은 CSV의 구분자가 콤마(,)가 아니라 탭(TAB)입니다. 그러므로 아래와 같디 LOAD DATA를 할 때 Field TERMINATED를 탭으로 주셔야 합니다.
U_Population 업로드 (아래 SQL은 ROOT계정으로 MySQL Command Line Client에서 실행해야 합니다.)
USE SWEET_DATA;
SET character_set_client = utf8;
SET character_set_connection = utf8;
SET character_set_database = utf8;
SET character_set_results = utf8;
SET character_set_server = utf8;
LOAD DATA LOCAL INFILE 'C:\\upload\\popu_data.txt' INTO TABLE U_Population FIELDS TERMINATED BY '|' ENCLOSED BY '"' LINES TERMINATED BY '\r\n' IGNORE 1 LINES;
올라온 데이터 중에 불필요한 데이터(소계, 합계)를 삭제합니다.
U_Population 불필요 데이터 삭제
DELETE T1 FROM U_Population T1 WHERE HZDong in ('합계','소계');
DELETE T1 FROM U_Population T1 WHERE PopuDiv in ('계');
4. 행정동코드 찾아내기
데이터를 통합 분석하려면 연결고리가 있어야 합니다. 현재 올린 인구 데이터에는 행정동명은 있지만 행정동코드는 없습니다. 이전 글에서 상가(M_Shop)에는 행정동코드가 있습니다. 데이터를 연결하기 위해 인구 정보에도 행정동코드를 매핑할 생각입니다.
현재 시도 – 구군 – 행정동이 하이라키 구조인데, 이 구조를 관리하는 테이블이 없습니다. 아래와 같이 지역매핑 테이블을 먼저 생성해서 지역 하이라키를 관리할 예정입니다.
아래 SQL로 테이블을 생성합니다.
# M_RegionMap 생성
CREATE TABLE M_RegionMap
(
HZDongCode VARCHAR(100) NOT NULL
,XiGoonGuCode VARCHAR(100) NOT NULL
,XiDoCode VARCHAR(100) NOT NULL
,PRIMARY KEY(HZDongCode)
) ENGINE = InnoDB;
지역매핑(하이라키) 데이터는 저번 글의 U_Shop(업로드용상가정보) 테이블을 이용해 만듭니다. 데이터를 만들기 위해서는 루트 권한으로 버퍼풀을 늘려 놓아야 합니다.(저번 글 참고)
아래 SQL로 지역매핑을 만듭니다.
# M_RegionMap 데이터 생성
INSERT INTO M_RegionMap(HZDongCode, XiGoonGuCode, XiDoCode)
SELECT DISTINCT T1.HZDongCode
,T1.XiGoonGuCode
,T1.XiDoCode
FROM U_Shop T1
WHERE T1.HZDongCode IS NOT NULL
AND T1.XiGoonGuCode IS NOT NULL
AND T1.XiDoCode IS NOT NULL;
이제는 M_regionMap을 이용해서 U_Population 테이블의 행정동 코드를 업데이트합니다. 아래 SQL을 사용합니다.(서울시만 처리하면 됩니다.)
# U_Population에 행정동 코드 업데이트
ALTER TABLE U_Population ADD XiGoonGuCode VARCHAR(100);
ALTER TABLE U_Population ADD HZDongCode VARCHAR(100);
UPDATE U_Population A
INNER JOIN (
SELECT A.HZDongCode
,A.XiGoonGuCode
,C.BaseCodeName HZDongCodeName
,D.BaseCodeName XiGoonGuCodeName
FROM M_RegionMap A
INNER JOIN C_BaseCode C
ON (C.BaseCodeDiv = 'HZDongCode'
AND C.BaseCode = A.HZDongCode)
INNER JOIN C_BaseCode D
ON (D.BaseCodeDiv = 'XiGoonGuCode'
AND D.BaseCode = A.XiGoonGuCode)
WHERE A.XiDoCode
= (SELECT B.BaseCode FROM C_BaseCode B WHERE B.BaseCodeDiv = 'XiDoCode'
AND B.BaseCodeName = '서울특별시')
) B
ON (A.HZDong = B.HZDongCodeName
AND A.ZCGu = B.XiGoonGuCodeName)
SET A.XiGoonGuCode = B.XiGoonGuCode
,A.HZDongCode = B.HZDongCode;
위와 같이 업데이트를 해도, 행정동코드를 못 찾는 데이터가 있습니다. 바로 종로5,6가입니다. 상가정보의 행정동명과, 인구정보의 행정동명이 달라서 그렇습니다.
아래 SQL로 종로 5,6가는 수작업 업데이트 합니다.
# U_Population에 종로 5,6가 수작업 업데이트
UPDATE U_Population T1 SET HZDongCode = '1111063000', XiGoonGuCode = '11110' WHERE HZDong = '종로5•6가동';
5. 인구 테이블 설계
아래 구조로 인구 테이블을 설계합니다.
아래 SQL로 테이블을 생성합니다.
# M_Popu생성
CREATE TABLE M_Popu(
BaseYQ VARCHAR(5) NOT NULL
,HZDongCode VARCHAR(100) NOT NULL
,PopuDiv VARCHAR(100) NOT NULL
,FromAge INT NOT NULL
,ToAge INT NOT NULL
,PopuCnt INT NOT NULL
,PRIMARY KEY(BaseYQ, HZDongCode, PopuDiv, FromAge)
);
데이터를 넣어야 하는데, 업로드용 테이블은 나이대별 인구수가 컬럼으로 되어 있습니다. 그리고 최종 사용할 테이블은 나이대가 FromAge, ToAge로 관리가 됩니다. 그러므로 업로드용 테이블의 컬럼들을 로우로 만들어야 합니다. 아래 SQL을 먼저 실행해봅니다.
# 나이대 그룹 만들기
SELECT CEIL(T1.Seq / 5) AgeGr
,MIN(T1.Seq) - 1 FromAge
,CASE WHEN MAX(T1.Seq) - 1 >= 100 THEN 999 ELSE MAX(T1.Seq) - 1 END ToAge
FROM C_Seq T1
GROUP BY CEIL(T1.Seq / 5)
HAVING MIN(T1.Seq) - 1 <= 100;
위 SQL을 실행하면, 0~4, 5~10과 같은 나이대 그룹 데이터가 만들어 집니다. 위 결과와 업로드 테이블을 CROSS JOIN해서 M_Popu에 INSERT합니다. 아래 SQL입니다. SQL을 보면 세션의 SQL_MODE에서 STRICT_TRANS_TABLES를 제거하고 있습니다. 이와 같이 해야만 데이터를 입력할 수 있습니다.(MySQL에서 형변환이 뭔가 잘 되지 않습니다…ㅜㅜ)
# M_Popu 데이터 만들기
SET @@SESSION.sql_mode = REPLACE(@@SESSION.sql_mode,'STRICT_TRANS_TABLES,','')
INSERT INTO M_Popu
(
BaseYQ
,HZDongCode
,PopuDiv
,FromAge
,ToAge
,PopuCnt
)
SELECT T0.BaseYQ
,T0.HZDongCode
,T0.PopuDiv
,T0.FromAge
,T0.ToAge
,CASE WHEN T0.PopuCnt = '-' THEN 0 ELSE ROUND(CAST(T0.PopuCnt AS UNSIGNED INT)) END PopuCnt
FROM (
SELECT '20192' BaseYQ
,HZDongCode
,CASE WHEN T1.PopuDiv = '한국인' THEN 'L' ELSE 'F' END PopuDiv
,T2.FromAge
,T2.ToAge
,CASE T2.FromAge
WHEN 0 THEN T1.C_0_4
WHEN 5 THEN T1.C_5_9
WHEN 10 THEN T1.C_10_14
WHEN 15 THEN T1.C_15_19
WHEN 20 THEN T1.C_20_24
WHEN 25 THEN T1.C_25_29
WHEN 30 THEN T1.C_30_34
WHEN 35 THEN T1.C_35_39
WHEN 40 THEN T1.C_40_44
WHEN 45 THEN T1.C_45_49
WHEN 50 THEN T1.C_50_54
WHEN 55 THEN T1.C_55_59
WHEN 60 THEN T1.C_60_64
WHEN 65 THEN T1.C_65_69
WHEN 70 THEN T1.C_70_74
WHEN 75 THEN T1.C_75_79
WHEN 80 THEN T1.C_80_84
WHEN 85 THEN T1.C_85_89
WHEN 90 THEN T1.C_90_94
WHEN 95 THEN T1.C_95_99
WHEN 100 THEN T1.C_OV_100 END PopuCnt
FROM U_Population T1
CROSS JOIN (
SELECT CEIL(T1.Seq / 5) AgeGr
,MIN(T1.Seq) - 1 FromAge
,CASE WHEN MAX(T1.Seq) - 1 >= 100 THEN 999 ELSE MAX(T1.Seq) - 1 END ToAge
FROM C_Seq T1
GROUP BY CEIL(T1.Seq / 5)
HAVING MIN(T1.Seq) - 1 <= 100
) T2
) T0;
데이터 입력까지 완료했습니다.
6. 어디에 치킨집을 열까?
15살 이하가 많이 살면서 치킨 집이 적은 동네에 치킨 집을 열도록 하겠습니다.
먼저, 상가테이블에 인덱스를 추가합니다. 데이터를 빨리 찾기 위해서죠.
# 인덱스 추가
CREATE INDEX X_M_Shop_1 ON M_Shop(HZDongCode, StndIndDivCode);
아래는 최종 SQL입니다.
# 어디에 치킨집을 열까?
SELECT T1.HZDongCode
,MAX(T1.HZDongCodeName) HZDongCodeName
,MAX(T1.XiGoonGuCodeName) XiGoonGuCodeName
,MAX(T1.PopuCnt) PopuCnt
,COUNT(T2.ShopNo) ShopCount
FROM (
SELECT T1.HZDongCode
,MAX(T3.BaseCodeName) HZDongCodeName
,MAX(T4.BaseCodeName) XiGoonGuCodeName
,SUM(T1.PopuCnt) PopuCnt
FROM M_Popu T1
INNER JOIN M_RegionMap T2
ON (T2.HZDongCode = T1.HZDongCode)
INNER JOIN C_BaseCode T3
ON (T3.BaseCodeDiv = 'HZDongCode'
AND T3.BaseCode = T2.HZDongCode)
INNER JOIN C_BaseCode T4
ON (T4.BaseCodeDiv = 'XiGoonGuCode'
AND T4.BaseCode = T2.XiGoonGuCode)
WHERE T1.ToAge <= 15
GROUP BY T1.HZDongCode
ORDER BY SUM(T1.PopuCnt) DESC
LIMIT 10
) T1
INNER JOIN M_Shop T2
ON (T2.HZDongCode = T1.HZDongCode
AND T2.StndIndDivCode = 'I56193') -- 치킨전문점
GROUP BY T1.HZDongCode
ORDER BY COUNT(T2.ShopNo) DESC;
아래와 같이 EXPLAIN을 SELECT SQL 앞에 붙여서 실행합니다. 실행하면, SQL은 실제 실행되지 않고, 예상 실행계획이 출력됩니다.
EXPLAIN
SELECT T2.CUS_GD
,COUNT(*)
FROM T_ORD_BIG T1
INNER JOIN M_CUS T2
ON (T2.CUS_ID = T1.CUS_ID)
WHERE T1.ORD_YMD LIKE '201703%'
GROUP BY T2.CUS_GD;
# 결과
id select_type table partitions type possible_keys key key_len ref rows filtered Extra
==== ============= ======= ============ ======== ========================================================= =============== ========= =========================== ======== ========== ===========================================
1 SIMPLE T1 None range X_T_ORD_BIG_1,X_T_ORD_BIG_3,X_T_ORD_BIG_4,X_T_ORD_BIG_5 X_T_ORD_BIG_5 35 None 360858 100.0 Using where; Using index; Using temporary
1 SIMPLE T2 None eq_ref PRIMARY PRIMARY 162 db_mysqlbooster.T1.CUS_ID 1 100.0 None
오라클과 같은 Tree 형태가 아니어서 해석이 쉽지 않습니다. 아래와 같은 내용들을 기억하고 실행계획을 해석해야 합니다.
*id : 해당 단계의 ID입니다. 값이 작을 수록 먼저 실행된다고 해석할 수 있습니다.
*select_type : 해당 단계의 쿼리 유형을 나타냅니다. - SIMPLE : 단순한 SELECT를 나타냅니다. 가장 흔하게 나타는 경우입니다. - PRIMARY : UNION이나 서브쿼리 존재시에 가장 바깥쪽 쿼리를 뜻합니다. - UNION : UNION이 존재하는 쿼리에서 두 번째 UNION 이후의 쿼리 블록입니다. - SUBQUERY : SELECT 절 또는 WHERE 절의ㅣ 서브쿼리입니다. - DERVIED : FROM절의 서브쿼리입니다.(인라인-뷰)
- DEPENDEN SUBQUERY : SELECT, WHERE 절 서브쿼리가 바깥쪽 SELECT에 컬럼을 사용할 때입니다. *table : 해당 단계의 관련 테이블입니다. *type : 해당 단계의 접근 유형입니다. -const = PK, UNIQUE KEY로 한 건만 조회 하는 경우입니다. (오라클의 INDEXUNIQUE SCAN) - ref = INDEX RANGE SCAN 인데, 같다(=) 조건을 처리하는 경우입니다. - range = INDEXRANGE SCAN 인데, 범위 조건입니다. - index = INDEX FULL SCAN , 이 부분을 가장 유의해야 합니다. - ALL = TABLE ACCESS FULL(TABLE FULL SCAN)
* 위에서 type이 index나 ALL이면 성능에 문제가 있을 수 있습니다. - eq_req = 조인에서 후행 접근하는 쪽에 나타납니다. INDEX UNIQUE SCAN 정도로 이해하면 됩니다. *possible_keys : 후보 인덱스 목록입니다. *key : 실제 사용한 인덱스 * key_len : 인덱스를 몇 바이트 사용했는지 표시합니다. 바이트에 따라, 몇 개 컬럼에 인덱스가 적용되었는지 알 수 있습니다. * rows : 예측 레코드 건수입니다.
추가로, MySQL에서 실행계획을 볼때는 JSON 형태로도 출력이 가능합니다. 아래와 같습니다.
아래와 같이 FORMAT을 Tree형태로 출력도 할 수 있습니다. 오라클을 사용하는 사용자라면 훨씬 친숙합니다. (사실 이 방법이 훨씬 실행계획 이해하기에 유리합니다.)
EXPLAIN FORMAT = TREE
SELECT T2.CUS_GD
,COUNT(*)
FROM T_ORD_BIG T1
INNER JOIN M_CUS T2
ON (T2.CUS_ID = T1.CUS_ID)
WHERE T1.ORD_YMD LIKE '201703%'
GROUP BY T2.CUS_GD;
-> Table scan on <temporary>
-> Aggregate using temporary table
-> Nested loop inner join (cost=478369.15 rows=360858)
-> Filter: (t1.ORD_YMD like '201703%') (cost=81425.35 rows=360858)
-> Index range scan on T1 using X_T_ORD_BIG_5 (cost=81425.35 rows=360858)
-> Single-row index lookup on T2 using PRIMARY (CUS_ID=t1.CUS_ID) (cost=1.00 rows=1)
이 정도를 알면, 실행계획 해석에 큰 어려움은 없을거 같습니다.
참고로, MySQL 8에서는 EXPLAIN ANALYZE를 통해 트리 형태로 실제 실행된 실행계획도 확인할 수 있습니다.
-- 기준 코드 테이블 생성
CREATE TABLE C_BaseCodeDiv
( BaseCodeDiv VARCHAR(100) NOT NULL
,BaseCodeDivName VARCHAR(500) CHARACTER SET UTF8MB4 NULL
,PRIMARY KEY(BaseCodeDiv)
);
CREATE TABLE C_BaseCode
( BaseCodeDiv VARCHAR(100) NOT NULL
,BaseCode VARCHAR(100) NOT NULL
,BaseCodeName VARCHAR(500) CHARACTER SET UTF8MB4 NULL
,SortOrder INT NULL
,PRIMARY KEY(BaseCodeDiv, BaseCode)
);
ALTER TABLE C_BaseCode
ADD CONSTRAINT FK_C_BaseCode_1 FOREIGN KEY(BaseCodeDiv) REFERENCES C_BaseCodeDiv(BaseCodeDiv);
기준 코드 데이터를 만들기 전에 아래와 같이 MySQL의 버퍼풀의 크기를 늘려줍니다. 만약에 버퍼가 충분하다면 상관 없는데, 버퍼가 모자라면 많은 데이터를 읽어서 INSERT하는 중에 에러가 발생합니다.
-- ROOT계정으로 접속해서 실행, 버퍼사이즈 업
SET GLOBAL innodb_buffer_pool_size = 402653184;
버퍼를 늘렸으면, 아래 SQL로 기준코드 데이터를 만듭니다.
(타임아웃이 날 경우, My.ini에 [mysqld] skip-name-resolve 설정이 필요합니다.)
-- 기준코드 데이터 만들기
INSERT INTO C_BaseCodeDiv(BaseCodeDiv, BaseCodeDivName)
VALUES('MktLDivCode','상권업종대분류코드'),
('MktMDivCode','상권업종중분류코드'),
('MktSDivCode','상권업종소분류코드'),
('StndIndDivCode','표준산업분류코드'),
('XiDoCode','시도코드'),
('XiGoonGuCode','시군구코드'),
('HZDongCode','행정동코드'),
('BZDongCode','법정동코드'),
('DaeJiDivCode','대지구분코드'),
('DoroCode','도로명코드');
INSERT INTO C_BaseCode(BaseCodeDiv, BaseCode, BaseCodeName)
SELECT DISTINCT 'DaeJiDivCode', T1.DaeJiDivCode, T1.DaeJiDivName FROM U_Shop T1;
INSERT INTO C_BaseCode(BaseCodeDiv, BaseCode, BaseCodeName)
SELECT 'DoroCode', T1.DoroCode, MAX(T1.DoroName) FROM U_Shop T1 GROUP BY T1.DoroCode;
INSERT INTO C_BaseCode(BaseCodeDiv, BaseCode, BaseCodeName)
SELECT DISTINCT 'HZDongCode', T1.HZDongCode, T1.HZDongName FROM U_Shop T1;
INSERT INTO C_BaseCode(BaseCodeDiv, BaseCode, BaseCodeName)
SELECT DISTINCT 'MktLDivCode', T1.MktLDivCode, T1.MktLDivName FROM U_Shop T1;
INSERT INTO C_BaseCode(BaseCodeDiv, BaseCode, BaseCodeName)
SELECT DISTINCT 'MktMDivCode', T1.MktMDivCode, T1.MktMDivName FROM U_Shop T1;
INSERT INTO C_BaseCode(BaseCodeDiv, BaseCode, BaseCodeName)
SELECT DISTINCT 'MktSDivCode', T1.MktSDivCode, T1.MktSDivName FROM U_Shop T1;
INSERT INTO C_BaseCode(BaseCodeDiv, BaseCode, BaseCodeName)
SELECT DISTINCT 'StndIndDivCode', T1.StndIndDivCode, T1.StndIndDivName FROM U_Shop T1;
INSERT INTO C_BaseCode(BaseCodeDiv, BaseCode, BaseCodeName)
SELECT DISTINCT 'XiDoCode', T1.XiDoCode, T1.XiDoName FROM U_Shop T1;
INSERT INTO C_BaseCode(BaseCodeDiv, BaseCode, BaseCodeName)
SELECT DISTINCT 'XiGoonGuCode', T1.XiGoonGuCode, T1.XiGoonGuName FROM U_Shop T1;
INSERT INTO C_BaseCode(BaseCodeDiv, BaseCode, BaseCodeName)
SELECT DISTINCT 'BZDongCode', T1.BZDongCode, T1.BZDongName FROM U_Shop T1;
여기서 기준코드의 기준코드구분(BaseCodeDiv)의 값을 보면, 컬럼명을 그대로 사용합니다. 찾아보기 쉽게 하기 위해서입니다. 이와 같이 사용하는 방법에는 장단점이 있습니다.(여기서 설명하지는 않습니다.) 그리고 기준코드라고 하기에는 부적합해 보이는 코드들도 있습니다. 도로코드와 같은 데이터입니다. 별도의 지역마스터를 구성하는 것이 가장 좋다고 생각됩니다. 하지만 그렇게 하려면 공수가 많이 들어가므로 우선은 모두 기준 코드로 처리하도록 하겠습니다.
6. 상가 테이블 설계
드디어, 상가 테이블을 만들고 데이터를 넣을 차례입니다. 상가 테이블은 아래와 같은 구조입니다.
아래 SQL로 상가(M_Shop) 테이블을 생성합니다.
-- 상가 테이블 생성하기
CREATE TABLE M_Shop(
ShopNo INT NOT NULL
,ShopName VARCHAR(100) CHARACTER SET UTF8MB4 NOT NULL
,BranchName VARCHAR(100) CHARACTER SET UTF8MB4 NOT NULL
,MktLDivCode VARCHAR(100)
,MktMDivCode VARCHAR(100)
,MktSDivCode VARCHAR(100)
,StndIndDivCode VARCHAR(100)
,XiDoCode VARCHAR(100)
,XiGoonGuCode VARCHAR(100)
,HZDongCode VARCHAR(100)
,BZDongCode VARCHAR(100)
,JiBunCode VARCHAR(100)
,DaeJiDivCode VARCHAR(100)
,JiBunPrimNumber VARCHAR(100) CHARACTER SET UTF8MB4 NOT NULL
,JiBunSecnNumber VARCHAR(100) CHARACTER SET UTF8MB4 NOT NULL
,JiBunAddress VARCHAR(100) CHARACTER SET UTF8MB4 NOT NULL
,DoroCode VARCHAR(100)
,BuildingPrimNumber VARCHAR(100) CHARACTER SET UTF8MB4 NOT NULL
,BuildingSecnNumber VARCHAR(100) CHARACTER SET UTF8MB4 NOT NULL
,BuildingMngNumber VARCHAR(100) CHARACTER SET UTF8MB4 NOT NULL
,BuildingName VARCHAR(100) CHARACTER SET UTF8MB4 NOT NULL
,DoroAddress VARCHAR(100) CHARACTER SET UTF8MB4 NOT NULL
,OldZipCode VARCHAR(100) CHARACTER SET UTF8MB4 NOT NULL
,NewZipCode VARCHAR(100) CHARACTER SET UTF8MB4 NOT NULL
,DongInfo VARCHAR(100) CHARACTER SET UTF8MB4 NOT NULL
,CengInfo VARCHAR(100) CHARACTER SET UTF8MB4 NOT NULL
,HoInfo VARCHAR(100) CHARACTER SET UTF8MB4 NOT NULL
,PosXY POINT NOT NULL
,PRIMARY KEY(ShopNo)
) ENGINE = InnoDB;
아래 SQL로 상가(M_Shop) 테이블에 데이터를 생성합니다. 여기서, JiBunCode를 넣을 때, 데이터 때문에 오류가 납니다. 일단 사용안할 예정이므로 ‘’(빈값)으로 처리해서 값을 입력합니다.
-- 상가 데이터 생성
INSERT INTO M_Shop
(ShopNo,ShopName,BranchName,MktLDivCode,MktMDivCode,MktSDivCode
,StndIndDivCode,XiDoCode,XiGoonGuCode,HZDongCode,BZDongCode
,JiBunCode,DaeJiDivCode,JiBunPrimNumber,JiBunSecnNumber,JiBunAddress,DoroCode
,BuildingPrimNumber,BuildingSecnNumber,BuildingMngNumber,BuildingName,DoroAddress
,OldZipCode,NewZipCode,DongInfo,CengInfo,HoInfo,PosXY)
SELECT ShopNo,ShopName,BranchName,MktLDivCode,MktMDivCode,MktSDivCode
,StndIndDivCode,XiDoCode,XiGoonGuCode,HZDongCode,BZDongCode
,'' JiBunCode,DaeJiDivCode,JiBunPrimNumber,JiBunSecnNumber,JiBunAddress,DoroCode
,BuildingPrimNumber,BuildingSecnNumber,BuildingMngNumber,BuildingName,DoroAddress
,OldZipCode,NewZipCode,DongInfo,CengInfo,HoInfo,POINT(T1.PosX, T1.PosY)
FROM U_Shop T1;
7. 우리 동네에서 가장 많은 업종은?
간단히 몸풀기 분석을 해봅니다. 우리 동네에서 가장 많은 업종 10개를 찾아보겠습니다. 저희 동네는 중계동입니다.
-- 우리 동네에서 가장 많은 업종은?
SELECT T2.BaseCodeName BZDongCodeName
,T1.MktMDivCode
,T3.BaseCodeName MktMDivCodeName
,COUNT(*)
FROM M_Shop T1
LEFT OUTER JOIN C_BaseCode T2
ON (T2.BaseCodeDiv = 'BZDongCode' AND T2.BaseCode = T1.BZDongCode)
LEFT OUTER JOIN C_BaseCode T3
ON (T3.BaseCodeDiv = 'MktMDivCode' AND T3.BaseCode = T1.MktMDivCode)
WHERE T1.XiDoCode
= (SELECT A.BaseCode FROM C_BaseCode A WHERE A.BaseCodeDiv = 'XiDoCode' AND A.BaseCodeName = '서울특별시')
AND T1.BZDongCode
= (SELECT A.BaseCode FROM C_BaseCode A WHERE A.BaseCodeDiv = 'BZDongCode' AND A.BaseCodeName = '중계동')
GROUP BY T1.MktMDivCode
ORDER BY COUNT(*) DESC
LIMIT 10;
아래와 같은 결과가 나옵니다. 음..!! 그렇습니다. 아래 업종들은 저희 동네에서 쉽게 접근하면 안되겠습니다.
저번글에서 올렸던 지하철 승하차 정보와, 지하철 역의 위치(경도, 위도) 정보를 구축했습니다. 그리고 오늘은 상가 정보를 올렸습니다.
하차 Top 10 지하철 역 근처에 제일 많은 업종을 찾아보겠습니다.(승하차 중에 하차만 사용하겠습니다.) 먼저 하차 Top 10 지하철 역과 위치 정보를 조회해봅니다. 아래 SQL입니다.
-- 우리 동네에서 가장 많은 업종은
SELECT T1.*
,T2.StationName
,CAST(X(T2.PosXY) as CHAR) PosX
,CAST(Y(T2.PosXY) as CHAR) PosY
FROM (
SELECT T1.StationNo, SUM(UseCount) UseCount
FROM T_StationUse T1
WHERE T1.GetOnOffType = 'OFF'
GROUP BY T1.StationNo
ORDER BY SUM(UseCount) DESC
LIMIT 10
) T1
INNER JOIN M_Station T2
ON (T1.StationNo = T2.StationNo)
이제, 위치 정보를 이용해, 상가 정보와 조인을 합니다. 위치 정보 조인을 위해서, M_Shop의 위치 값에 SPATIAL INDEX인덱스를 만듭니다.
-- 인덱스 생성
CREATE SPATIAL INDEX SX_M_Shop_1 ON M_Shop(PosXY);
최종 SQL은 아래와 같습니다. (역 근처의 기준은 반경 1키로입니다.)
-- Top 10 하차 지하철 역 근처 상권 정보.
SELECT T3.MktMDivCode
,(SELECT A.BaseCodeName FROM C_BaseCode A
WHERE A.BaseCodeDiv = 'MktMDivCode' AND A.BaseCode = T3.MktMDivCode) MktMDivCodeName
,COUNT(*)
FROM (
SELECT T1.*
,T2.StationName
,ufn_getDiagonal(CAST(ST_X(T2.PosXY) as CHAR),CAST(ST_Y(T2.PosXY) as CHAR),1000) diag
FROM (
SELECT T1.StationNo, SUM(UseCount) UseCount
FROM T_StationUse T1
WHERE T1.GetOnOffType = 'OFF'
GROUP BY T1.StationNo
ORDER BY SUM(UseCount) DESC
LIMIT 10
) T1
INNER JOIN M_Station T2
ON (T1.StationNo = T2.StationNo)
LIMIT 10
) T1
INNER JOIN M_Shop T3
ON (MBRCONTAINS(ST_LINESTRINGFROMTEXT(T1.diag), T3.PosXY))
GROUP BY T3.MktMDivCode
ORDER BY COUNT(*) DESC
LIMIT 10;
파일이 제법 큽니다. 압축을 풀어보면, 4개의 파일이 있습니다. 4개의 파일을 모두 DB로 올려야 합니다. 우선은 업로드할 테이블을 정의해야 하니, 엑셀 하나를 열어봅니다. 아래와 같습니다.
3. 업로드용 테이블 설계 및 생성
업로드할 임시 테이블을 먼저 설계합니다. 앞에서 열었던 파일의 컬럼명들을 모두 나열해주면 됩니다.
컬럼이 너무 많아서 설계가 귀찮습니다. 컬럼명을 컬럼1, 2, 3, 4 처럼 하고 싶었으나… 그러면 안되니까.. 귀찮지만 적당히 컬럼명을 정했습니다. 순수 영어로 번역해서 컬럼명을 하고 싶었지만, 한국어를 100% 영문명으로 변환하기가 쉽지 않습니다. 어쩔 수 없이, DoroCode같은 한글을 발음 그대로 영문으로 사용한 컬럼들도 있습니다. 이해 부탁드립니다.
아래 스크립트로 테이블을 생성합니다.
-- 업로드 테이블 생성
CREATE TABLE U_Shop(
ShopNo VARCHAR(100) CHARACTER SET UTF8MB4 NOT NULL
,ShopName VARCHAR(100) CHARACTER SET UTF8MB4 NOT NULL
,BranchName VARCHAR(100) CHARACTER SET UTF8MB4 NOT NULL
,MktLDivCode VARCHAR(100) CHARACTER SET UTF8MB4 NOT NULL
,MktLDivName VARCHAR(100) CHARACTER SET UTF8MB4 NOT NULL
,MktMDivCode VARCHAR(100) CHARACTER SET UTF8MB4 NOT NULL
,MktMDivName VARCHAR(100) CHARACTER SET UTF8MB4 NOT NULL
,MktSDivCode VARCHAR(100) CHARACTER SET UTF8MB4 NOT NULL
,MktSDivName VARCHAR(100) CHARACTER SET UTF8MB4 NOT NULL
,StndIndDivCode VARCHAR(100) CHARACTER SET UTF8MB4 NOT NULL
,StndIndDivName VARCHAR(100) CHARACTER SET UTF8MB4 NOT NULL
,XiDoCode VARCHAR(100) CHARACTER SET UTF8MB4 NOT NULL
,XiDoName VARCHAR(100) CHARACTER SET UTF8MB4 NOT NULL
,XiGoonGuCode VARCHAR(100) CHARACTER SET UTF8MB4 NOT NULL
,XiGoonGuName VARCHAR(100) CHARACTER SET UTF8MB4 NOT NULL
,HZDongCode VARCHAR(100) CHARACTER SET UTF8MB4 NOT NULL
,HZDongName VARCHAR(100) CHARACTER SET UTF8MB4 NOT NULL
,BZDongCode VARCHAR(100) CHARACTER SET UTF8MB4 NOT NULL
,BZDongName VARCHAR(100) CHARACTER SET UTF8MB4 NOT NULL
,JiBunCode VARCHAR(100) CHARACTER SET UTF8MB4 NOT NULL
,DaeJiDivCode VARCHAR(100) CHARACTER SET UTF8MB4 NOT NULL
,DaeJiDivName VARCHAR(100) CHARACTER SET UTF8MB4 NOT NULL
,JiBunPrimNumber VARCHAR(100) CHARACTER SET UTF8MB4 NOT NULL
,JiBunSecnNumber VARCHAR(100) CHARACTER SET UTF8MB4 NOT NULL
,JiBunAddress VARCHAR(100) CHARACTER SET UTF8MB4 NOT NULL
,DoroCode VARCHAR(100) CHARACTER SET UTF8MB4 NOT NULL
,DoroName VARCHAR(100) CHARACTER SET UTF8MB4 NOT NULL
,BuildingPrimNumber VARCHAR(100) CHARACTER SET UTF8MB4 NOT NULL
,BuildingSecnNumber VARCHAR(100) CHARACTER SET UTF8MB4 NOT NULL
,BuildingMngNumber VARCHAR(100) CHARACTER SET UTF8MB4 NOT NULL
,BuildingName VARCHAR(100) CHARACTER SET UTF8MB4 NOT NULL
,DoroAddress VARCHAR(100) CHARACTER SET UTF8MB4 NOT NULL
,OldZipCode VARCHAR(100) CHARACTER SET UTF8MB4 NOT NULL
,NewZipCode VARCHAR(100) CHARACTER SET UTF8MB4 NOT NULL
,DongInfo VARCHAR(100) CHARACTER SET UTF8MB4 NOT NULL
,CengInfo VARCHAR(100) CHARACTER SET UTF8MB4 NOT NULL
,HoInfo VARCHAR(100) CHARACTER SET UTF8MB4 NOT NULL
,PosX VARCHAR(100) CHARACTER SET UTF8MB4 NOT NULL
,PosY VARCHAR(100) CHARACTER SET UTF8MB4 NOT NULL
) ENGINE = InnoDB
4. 상가정보 업로드하기.
CSV파일을 MySQL에 업로드 하기 위해서는 아래와 같은 과정이 필요합니다. 참고해주세요.
이전 글(서울지하철승하차분석)에서 ‘5. 테이블 업로드’편에서 이와 같이 작업을 했습니다. 여기서도 동일하게 작업을 할 예정입니다. 그런데 다행히도, 상가정보의 CSV파일은 이미 UTF-8로 되어 있습니다. CSV파일을 UTF-8로 변환할 필요가 없습니다. UTF-8로 올려준 공공데이터 포털에 감사합니다~!
아래 스크립트로 CSV파일 네 개를 T_Shop에 업로드합니다. 빨간색은 각자 폴더와 파일명으로 변경해 주세요. (바꿀때, 윈도우 환경에서 \\ 와 같이 \를 두개 사용하셔야 합니다.)
ð한글 파일명이 안되서, 영어로 파일명 바꾸어야 하네.. mysql 8에서는요.
ðC:\upload\ 폴더로 파일 옮겨서 했음.
-- U_StationUse 업로드 (아래 SQL은 ROOT계정으로 MySQL Command Line Client에서 실행해야 합니다.)
USE SWEET_DATA;
SET character_set_client = utf8;
SET character_set_connection = utf8;
SET character_set_database = utf8;
SET character_set_results = utf8;
SET character_set_server = utf8;
LOAD DATA LOCAL INFILE 'C:\\Users\\sweetboss\\Desktop\\데이터분석\\0030_상점데이터수집\\상가(상권)정보_201909\\소상공인시장진흥공단_상가업소정보_201909_01.CSV' INTO TABLE U_Shop FIELDS TERMINATED BY ',' ENCLOSED BY '"' LINES TERMINATED BY '\r\n' IGNORE 1 LINES;
LOAD DATA LOCAL INFILE 'C:\\Users\\sweetboss\\Desktop\\데이터분석\\0030_상점데이터수집\\상가(상권)정보_201909\\소상공인시장진흥공단_상가업소정보_201909_02.CSV' INTO TABLE U_Shop FIELDS TERMINATED BY ',' ENCLOSED BY '"' LINES TERMINATED BY '\r\n' IGNORE 1 LINES;
LOAD DATA LOCAL INFILE 'C:\\Users\\sweetboss\\Desktop\\데이터분석\\0030_상점데이터수집\\상가(상권)정보_201909\\소상공인시장진흥공단_상가업소정보_201909_03.CSV' INTO TABLE U_Shop FIELDS TERMINATED BY ',' ENCLOSED BY '"' LINES TERMINATED BY '\r\n' IGNORE 1 LINES;
LOAD DATA LOCAL INFILE 'C:\\Users\\sweetboss\\Desktop\\데이터분석\\0030_상점데이터수집\\상가(상권)정보_201909\\소상공인시장진흥공단_상가업소정보_201909_04.CSV' INTO TABLE U_Shop FIELDS TERMINATED BY ',' ENCLOSED BY '"' LINES TERMINATED BY '\r\n' IGNORE 1 LINES;
위 과정이 정상적으로 수행되면, U_Shop에는 1,090,952 건의 상가 정보가 만들어집니다. 상가가 참 많습니다.
업로드 완료한 후에는 아래와 같이 U_Shop테이블에 PK를 잡도록 하겠습니다. 아마도, PK 조건으로 필요한 정보를 몇 건 찾아야 할 필요가 있을거 같습니다.
경도 위도 값을 구할 차례입니다. 저는 카카오 API를 사용했습니다. (SQL과 데이터 분석 중심 설명이기 때문에 카카오API에 대한 자세한 설명은 제외합니다.)
카카오API를 호출해서 위도, 경도 값을 업데이트하기 위해서 파이썬을 사용합니다. 파이썬 소스코드는 아래와 같습니다.(아나콘다3, 파이참 환경입니다. 파이썬 설치가 불가능한 분은 다음 아래의 UPDATE 스크립트를 그대로 카피해서 사용하셔도 됩니다.)
-- 경도, 위도 가져오는 파이썬
import requests
import json
import pymysql
def find_gps():
#DB LINK
conn = pymysql.connect(user='ADMIN_SWEET', passwd='1q2w3e4r', host='127.0.0.1', port=13306, db='SWEET_DATA',charset='utf8')
cur = conn.cursor()
sql_text = "SELECT T1.StationNo, T1.Address FROM M_Station T1"
cur.execute(sql_text);
rows = cur.fetchall();
url = "https://dapi.kakao.com/v2/local/search/address.json";
header = {'Authorization': 'KakaoAK 각자키를 사용'}
for row in rows:
print(row)
_addr = str(row[1]);
_key = row[0]
query = "query=" + _addr;
r = requests.get(url, headers=header, params=query)
json_data = json.loads(r.text)
for i in json_data['documents'] :
# X 좌표값 => longitude
# Y 좌표값 => latitude
sql_update_text = """UPDATE M_Station SET PosXY = POINT(""" +i['x'] + """,""" + i['y'] + """) WHERE StationNo = '""" + str(_key) + """';""";
print(sql_update_text)
cur.execute(sql_update_text)
cur.execute('commit')
find_gps()
아래는 경도, 위도를 바로 업데이트하는 스크립트입니다.
-- 경도, 위도 바로 업데이트 하기 (너무 길어서 줄 번호는 생략합니다.)
UPDATE M_Station SET PosXY = POINT(126.97255256952047,37.557158852433425) WHERE StationNo = '150';
UPDATE M_Station SET PosXY = POINT(126.97698199254849,37.565438157005836) WHERE StationNo = '151';
UPDATE M_Station SET PosXY = POINT(126.98323798089602,37.57021448838956) WHERE StationNo = '152';
UPDATE M_Station SET PosXY = POINT(126.99203100001637,37.570428044296825) WHERE StationNo = '153';
UPDATE M_Station SET PosXY = POINT(127.00191528898299,37.57090762722926) WHERE StationNo = '154';
UPDATE M_Station SET PosXY = POINT(127.01116585737377,37.571779278087966) WHERE StationNo = '155';
UPDATE M_Station SET PosXY = POINT(127.02456540747554,37.57612364917105) WHERE StationNo = '156';
UPDATE M_Station SET PosXY = POINT(127.03469139400376,37.57820060253574) WHERE StationNo = '157';
UPDATE M_Station SET PosXY = POINT(127.04505339058124,37.58022614096847) WHERE StationNo = '158';
UPDATE M_Station SET PosXY = POINT(127.01674914262058,37.573371577168444) WHERE StationNo = '159';
UPDATE M_Station SET PosXY = POINT(126.97538891079977,37.563578179426656) WHERE StationNo = '201';
UPDATE M_Station SET PosXY = POINT(126.98237414667892,37.566051762475745) WHERE StationNo = '202';
UPDATE M_Station SET PosXY = POINT(126.99030643210962,37.56626531530101) WHERE StationNo = '203';
UPDATE M_Station SET PosXY = POINT(126.99785391218619,37.56663690408433) WHERE StationNo = '204';
UPDATE M_Station SET PosXY = POINT(127.0091320641583,37.56558781104745) WHERE StationNo = '205';
UPDATE M_Station SET PosXY = POINT(127.01954091431038,37.56565502277837) WHERE StationNo = '206';
UPDATE M_Station SET PosXY = POINT(127.0290302021847,37.564467359880524) WHERE StationNo = '207';
UPDATE M_Station SET PosXY = POINT(127.0366574155161,37.56122165079314) WHERE StationNo = '208';
UPDATE M_Station SET PosXY = POINT(127.04358539961328,37.55557905450867) WHERE StationNo = '209';
UPDATE M_Station SET PosXY = POINT(127.04736461242203,37.54718750384358) WHERE StationNo = '210';
UPDATE M_Station SET PosXY = POINT(127.05608956590137,37.54450230873498) WHERE StationNo = '211';
UPDATE M_Station SET PosXY = POINT(127.0691712451776,37.54041924881689) WHERE StationNo = '212';
UPDATE M_Station SET PosXY = POINT(127.08618081290898,37.53715364857336) WHERE StationNo = '213';
UPDATE M_Station SET PosXY = POINT(127.09466422420185,37.535164952269845) WHERE StationNo = '214';
UPDATE M_Station SET PosXY = POINT(127.10381323936149,37.52068718042725) WHERE StationNo = '215';
UPDATE M_Station SET PosXY = POINT(127.10031473197408,37.513319997745555) WHERE StationNo = '216';
UPDATE M_Station SET PosXY = POINT(127.0863730771204,37.51156865152352) WHERE StationNo = '217';
UPDATE M_Station SET PosXY = POINT(127.0733561309303,37.51096837383229) WHERE StationNo = '218';
UPDATE M_Station SET PosXY = POINT(127.06307527215017,37.50886235664123) WHERE StationNo = '219';
UPDATE M_Station SET PosXY = POINT(127.0489436033093,37.50450458903165) WHERE StationNo = '220';
UPDATE M_Station SET PosXY = POINT(127.03661876920279,37.50071762069987) WHERE StationNo = '221';
UPDATE M_Station SET PosXY = POINT(127.02830790088069,37.498164651039694) WHERE StationNo = '222';
UPDATE M_Station SET PosXY = POINT(127.0141576077151,37.49357566392058) WHERE StationNo = '223';
UPDATE M_Station SET PosXY = POINT(127.00758070181062,37.491824710937784) WHERE StationNo = '224';
UPDATE M_Station SET PosXY = POINT(126.9976935425379,37.481438109455894) WHERE StationNo = '225';
UPDATE M_Station SET PosXY = POINT(126.9814093612868,37.47652436655696) WHERE StationNo = '226';
UPDATE M_Station SET PosXY = POINT(126.96341086398692,37.47706437373934) WHERE StationNo = '227';
UPDATE M_Station SET PosXY = POINT(126.95264544509422,37.48115113161296) WHERE StationNo = '228';
UPDATE M_Station SET PosXY = POINT(126.94149651644463,37.48255531496967) WHERE StationNo = '229';
UPDATE M_Station SET PosXY = POINT(126.92980421555579,37.48421761465397) WHERE StationNo = '230';
UPDATE M_Station SET PosXY = POINT(126.91317513329555,37.48757633464119) WHERE StationNo = '231';
UPDATE M_Station SET PosXY = POINT(126.9014956225072,37.48531465452861) WHERE StationNo = '232';
UPDATE M_Station SET PosXY = POINT(126.89494460557201,37.49330995650912) WHERE StationNo = '233';
UPDATE M_Station SET PosXY = POINT(126.89108197001643,37.508771317645575) WHERE StationNo = '234';
UPDATE M_Station SET PosXY = POINT(126.89476532263198,37.51792164597545) WHERE StationNo = '235';
UPDATE M_Station SET PosXY = POINT(126.89662780191244,37.52570074348017) WHERE StationNo = '236';
UPDATE M_Station SET PosXY = POINT(126.90272923847847,37.534957406012765) WHERE StationNo = '237';
UPDATE M_Station SET PosXY = POINT(126.91463716307454,37.55011421506363) WHERE StationNo = '238';
UPDATE M_Station SET PosXY = POINT(126.92367442251489,37.556888697557625) WHERE StationNo = '239';
UPDATE M_Station SET PosXY = POINT(126.9361204389213,37.555440075383814) WHERE StationNo = '240';
UPDATE M_Station SET PosXY = POINT(126.94575729679175,37.55679639200007) WHERE StationNo = '241';
UPDATE M_Station SET PosXY = POINT(126.95603329132891,37.557359284038256) WHERE StationNo = '242';
UPDATE M_Station SET PosXY = POINT(126.96447506227653,37.559838065855615) WHERE StationNo = '243';
UPDATE M_Station SET PosXY = POINT(127.05072874381338,37.562005716412244) WHERE StationNo = '244';
UPDATE M_Station SET PosXY = POINT(127.04682212731294,37.56999015446607) WHERE StationNo = '245';
UPDATE M_Station SET PosXY = POINT(127.02456540747554,37.57612364917105) WHERE StationNo = '246';
UPDATE M_Station SET PosXY = POINT(126.88270348752339,37.51444322325958) WHERE StationNo = '247';
UPDATE M_Station SET PosXY = POINT(126.86606566370143,37.51269920666508) WHERE StationNo = '248';
UPDATE M_Station SET PosXY = POINT(126.85298994738748,37.51979797507366) WHERE StationNo = '249';
UPDATE M_Station SET PosXY = POINT(127.03803112066214,37.573939672123046) WHERE StationNo = '250';
UPDATE M_Station SET PosXY = POINT(126.90582369800002,37.65085626552864) WHERE StationNo = '309';
UPDATE M_Station SET PosXY = POINT(126.9187436123905,37.63684295896786) WHERE StationNo = '310';
UPDATE M_Station SET PosXY = POINT(126.9211598597772,37.618961821472446) WHERE StationNo = '311';
UPDATE M_Station SET PosXY = POINT(126.92998497034971,37.610206176235664) WHERE StationNo = '312';
UPDATE M_Station SET PosXY = POINT(126.93601831026713,37.60074200017985) WHERE StationNo = '313';
UPDATE M_Station SET PosXY = POINT(126.94424412257759,37.588708926043225) WHERE StationNo = '314';
UPDATE M_Station SET PosXY = POINT(126.95032164535576,37.58238306587019) WHERE StationNo = '315';
UPDATE M_Station SET PosXY = POINT(126.95774846781482,37.57441035829069) WHERE StationNo = '316';
UPDATE M_Station SET PosXY = POINT(126.97306191821602,37.575860041203214) WHERE StationNo = '317';
UPDATE M_Station SET PosXY = POINT(126.98582213739954,37.57672001005511) WHERE StationNo = '318';
UPDATE M_Station SET PosXY = POINT(126.99199470091352,37.571141630115804) WHERE StationNo = '319';
UPDATE M_Station SET PosXY = POINT(126.99265172209068,37.566276296521835) WHERE StationNo = '320';
UPDATE M_Station SET PosXY = POINT(127.00545974413328,37.55902878277881) WHERE StationNo = '322';
UPDATE M_Station SET PosXY = POINT(127.01082377161588,37.55455946560875) WHERE StationNo = '323';
UPDATE M_Station SET PosXY = POINT(127.01589250869206,37.54811134857417) WHERE StationNo = '324';
UPDATE M_Station SET PosXY = POINT(127.01833055740015,37.54104716078602) WHERE StationNo = '325';
UPDATE M_Station SET PosXY = POINT(127.02848148490598,37.526430913588165) WHERE StationNo = '326';
UPDATE M_Station SET PosXY = POINT(127.02032892209975,37.51643146679401) WHERE StationNo = '327';
UPDATE M_Station SET PosXY = POINT(127.01122040598548,37.51279983559945) WHERE StationNo = '328';
UPDATE M_Station SET PosXY = POINT(127.00444243570533,37.50464799363718) WHERE StationNo = '329';
UPDATE M_Station SET PosXY = POINT(127.0141576077151,37.49357566392058) WHERE StationNo = '330';
UPDATE M_Station SET PosXY = POINT(127.01620479414689,37.48514196837421) WHERE StationNo = '331';
UPDATE M_Station SET PosXY = POINT(127.03454374096688,37.48393428252617) WHERE StationNo = '332';
UPDATE M_Station SET PosXY = POINT(127.04661648311405,37.48696112783275) WHERE StationNo = '333';
UPDATE M_Station SET PosXY = POINT(127.05532122481543,37.49086414202696) WHERE StationNo = '334';
UPDATE M_Station SET PosXY = POINT(127.06342962689939,37.49456140410784) WHERE StationNo = '335';
UPDATE M_Station SET PosXY = POINT(127.07171829171698,37.49671547492159) WHERE StationNo = '336';
UPDATE M_Station SET PosXY = POINT(127.07949534292234,37.4937227596509) WHERE StationNo = '337';
UPDATE M_Station SET PosXY = POINT(127.0844509601109,37.4840749361128) WHERE StationNo = '338';
UPDATE M_Station SET PosXY = POINT(127.10185851088313,37.48738773253322) WHERE StationNo = '339';
UPDATE M_Station SET PosXY = POINT(127.11848381630814,37.49274223205612) WHERE StationNo = '340';
UPDATE M_Station SET PosXY = POINT(127.12406558337433,37.495601702768255) WHERE StationNo = '341';
UPDATE M_Station SET PosXY = POINT(127.12788105549203,37.50226507641803) WHERE StationNo = '342';
UPDATE M_Station SET PosXY = POINT(127.07901229284572,37.67029255404396) WHERE StationNo = '409';
UPDATE M_Station SET PosXY = POINT(127.07357594636086,37.66090424876206) WHERE StationNo = '410';
UPDATE M_Station SET PosXY = POINT(127.06303203591438,37.65626849637051) WHERE StationNo = '411';
UPDATE M_Station SET PosXY = POINT(127.04771291366652,37.65321595105881) WHERE StationNo = '412';
UPDATE M_Station SET PosXY = POINT(127.03488286973543,37.6488488732005) WHERE StationNo = '413';
UPDATE M_Station SET PosXY = POINT(127.02535610786357,37.63774397254115) WHERE StationNo = '414';
UPDATE M_Station SET PosXY = POINT(127.0261044533112,37.62644184231728) WHERE StationNo = '415';
UPDATE M_Station SET PosXY = POINT(127.0301002308641,37.61328646943786) WHERE StationNo = '416';
UPDATE M_Station SET PosXY = POINT(127.02482344269079,37.60318219863949) WHERE StationNo = '417';
UPDATE M_Station SET PosXY = POINT(127.0164771967841,37.592717746005185) WHERE StationNo = '418';
UPDATE M_Station SET PosXY = POINT(127.00595101268726,37.588393983179365) WHERE StationNo = '419';
UPDATE M_Station SET PosXY = POINT(127.00197896746458,37.58180245102366) WHERE StationNo = '420';
UPDATE M_Station SET PosXY = POINT(127.00954468675879,37.57041351149444) WHERE StationNo = '421';
UPDATE M_Station SET PosXY = POINT(127.00755189864863,37.56507976088089) WHERE StationNo = '422';
UPDATE M_Station SET PosXY = POINT(126.99411000772092,37.561173134797826) WHERE StationNo = '423';
UPDATE M_Station SET PosXY = POINT(126.98594737006931,37.560909354360476) WHERE StationNo = '424';
UPDATE M_Station SET PosXY = POINT(126.97823812825729,37.558536761057404) WHERE StationNo = '425';
UPDATE M_Station SET PosXY = POINT(126.97280752153232,37.5532017297038) WHERE StationNo = '426';
UPDATE M_Station SET PosXY = POINT(126.97187348361902,37.54519343558292) WHERE StationNo = '427';
UPDATE M_Station SET PosXY = POINT(126.97301340873837,37.53459433761754) WHERE StationNo = '428';
UPDATE M_Station SET PosXY = POINT(126.96821830742816,37.529616013993916) WHERE StationNo = '429';
UPDATE M_Station SET PosXY = POINT(126.97368292469619,37.52252106548174) WHERE StationNo = '430';
UPDATE M_Station SET PosXY = POINT(126.98026736778657,37.502869646133625) WHERE StationNo = '431';
UPDATE M_Station SET PosXY = POINT(126.98219812464853,37.487552836479765) WHERE StationNo = '432';
UPDATE M_Station SET PosXY = POINT(126.9815946871435,37.476863175208685) WHERE StationNo = '433';
UPDATE M_Station SET PosXY = POINT(126.98865563982616,37.46485899919096) WHERE StationNo = '434';
UPDATE M_Station SET PosXY = POINT(126.8126306092129,37.57700464071319) WHERE StationNo = '2511';
UPDATE M_Station SET PosXY = POINT(126.80599997709584,37.572269120979904) WHERE StationNo = '2512';
UPDATE M_Station SET PosXY = POINT(126.80157133547586,37.56214539460694) WHERE StationNo = '2513';
UPDATE M_Station SET PosXY = POINT(126.81105778784394,37.56147620063578) WHERE StationNo = '2514';
UPDATE M_Station SET PosXY = POINT(126.82554810761461,37.56019746267116) WHERE StationNo = '2515';
UPDATE M_Station SET PosXY = POINT(126.83731235802402,37.55902854821429) WHERE StationNo = '2516';
UPDATE M_Station SET PosXY = POINT(126.83641074062257,37.549001008797624) WHERE StationNo = '2517';
UPDATE M_Station SET PosXY = POINT(126.84049622173754,37.5414346084945) WHERE StationNo = '2518';
UPDATE M_Station SET PosXY = POINT(126.84661226903779,37.53195698666088) WHERE StationNo = '2519';
UPDATE M_Station SET PosXY = POINT(126.85729175247246,37.52520926282623) WHERE StationNo = '2520';
UPDATE M_Station SET PosXY = POINT(126.86474281159968,37.52619832069603) WHERE StationNo = '2521';
UPDATE M_Station SET PosXY = POINT(126.87535270115818,37.524451239657594) WHERE StationNo = '2522';
UPDATE M_Station SET PosXY = POINT(126.88686737317903,37.52551159691278) WHERE StationNo = '2523';
UPDATE M_Station SET PosXY = POINT(126.8952904240174,37.52422552622088) WHERE StationNo = '2524';
UPDATE M_Station SET PosXY = POINT(126.90495781244745,37.52273443371112) WHERE StationNo = '2525';
UPDATE M_Station SET PosXY = POINT(126.91433478893137,37.517667159567864) WHERE StationNo = '2526';
UPDATE M_Station SET PosXY = POINT(126.92448389420136,37.52184392588269) WHERE StationNo = '2527';
UPDATE M_Station SET PosXY = POINT(126.9328638413941,37.52708561146302) WHERE StationNo = '2528';
UPDATE M_Station SET PosXY = POINT(126.94591912537975,37.53958378800099) WHERE StationNo = '2529';
UPDATE M_Station SET PosXY = POINT(126.95136518782198,37.54452723838077) WHERE StationNo = '2530';
UPDATE M_Station SET PosXY = POINT(126.95661507072609,37.553362673930344) WHERE StationNo = '2531';
UPDATE M_Station SET PosXY = POINT(126.96337438660855,37.560994609486485) WHERE StationNo = '2532';
UPDATE M_Station SET PosXY = POINT(126.96663637960322,37.565857352826356) WHERE StationNo = '2533';
UPDATE M_Station SET PosXY = POINT(126.97678991701194,37.571622553368286) WHERE StationNo = '2534';
UPDATE M_Station SET PosXY = POINT(126.99006110477345,37.57256865945283) WHERE StationNo = '2535';
UPDATE M_Station SET PosXY = POINT(126.99805763167915,37.56752348754336) WHERE StationNo = '2536';
UPDATE M_Station SET PosXY = POINT(127.00591742504709,37.564575296239745) WHERE StationNo = '2537';
UPDATE M_Station SET PosXY = POINT(127.0138080439052,37.560293101373034) WHERE StationNo = '2538';
UPDATE M_Station SET PosXY = POINT(127.02006312784418,37.554533029207484) WHERE StationNo = '2539';
UPDATE M_Station SET PosXY = POINT(127.02923112964419,37.55727735595999) WHERE StationNo = '2540';
UPDATE M_Station SET PosXY = POINT(127.0366574155161,37.56122165079314) WHERE StationNo = '2541';
UPDATE M_Station SET PosXY = POINT(127.04316151445926,37.56628305149315) WHERE StationNo = '2542';
UPDATE M_Station SET PosXY = POINT(127.05318118517143,37.566444746492174) WHERE StationNo = '2543';
UPDATE M_Station SET PosXY = POINT(127.0644189574387,37.561501690379174) WHERE StationNo = '2544';
UPDATE M_Station SET PosXY = POINT(127.07901512810203,37.557362654799554) WHERE StationNo = '2545';
UPDATE M_Station SET PosXY = POINT(127.08937161621444,37.55239978385352) WHERE StationNo = '2546';
UPDATE M_Station SET PosXY = POINT(127.10368438183367,37.54527740465725) WHERE StationNo = '2547';
UPDATE M_Station SET PosXY = POINT(127.12374300170981,37.53857988247639) WHERE StationNo = '2548';
UPDATE M_Station SET PosXY = POINT(127.13353161821865,37.535520236233765) WHERE StationNo = '2549';
UPDATE M_Station SET PosXY = POINT(127.13999863971951,37.53781209828301) WHERE StationNo = '2550';
UPDATE M_Station SET PosXY = POINT(127.14296914789105,37.54573012263343) WHERE StationNo = '2551';
UPDATE M_Station SET PosXY = POINT(127.14400274117364,37.55126099421607) WHERE StationNo = '2552';
UPDATE M_Station SET PosXY = POINT(127.1539511120498,37.55504704640015) WHERE StationNo = '2553';
UPDATE M_Station SET PosXY = POINT(127.16582876743654,37.556663567935374) WHERE StationNo = '2554';
UPDATE M_Station SET PosXY = POINT(127.1363739058897,37.528135991320916) WHERE StationNo = '2555';
UPDATE M_Station SET PosXY = POINT(127.13060127102182,37.51608710743359) WHERE StationNo = '2556';
UPDATE M_Station SET PosXY = POINT(127.12585594283948,37.50855626435071) WHERE StationNo = '2557';
UPDATE M_Station SET PosXY = POINT(127.12788105549203,37.50226507641803) WHERE StationNo = '2558';
UPDATE M_Station SET PosXY = POINT(127.13516074116417,37.49781321258245) WHERE StationNo = '2559';
UPDATE M_Station SET PosXY = POINT(127.14403142719337,37.49320759944486) WHERE StationNo = '2560';
UPDATE M_Station SET PosXY = POINT(127.15232084931651,37.49467124867699) WHERE StationNo = '2561';
UPDATE M_Station SET PosXY = POINT(126.91551472523976,37.59874332069344) WHERE StationNo = '2611';
UPDATE M_Station SET PosXY = POINT(126.92296737989116,37.60603923327726) WHERE StationNo = '2612';
UPDATE M_Station SET PosXY = POINT(126.92998497034971,37.610206176235664) WHERE StationNo = '2613';
UPDATE M_Station SET PosXY = POINT(126.93280900514041,37.6183996051419) WHERE StationNo = '2614';
UPDATE M_Station SET PosXY = POINT(126.91718136927429,37.61121059714101) WHERE StationNo = '2616';
UPDATE M_Station SET PosXY = POINT(126.91391017297514,37.591826152095116) WHERE StationNo = '2617';
UPDATE M_Station SET PosXY = POINT(126.91005600501946,37.584193674614255) WHERE StationNo = '2618';
UPDATE M_Station SET PosXY = POINT(126.90192720232601,37.57716305593089) WHERE StationNo = '2619';
UPDATE M_Station SET PosXY = POINT(126.89903434554247,37.56994184911615) WHERE StationNo = '2620';
UPDATE M_Station SET PosXY = POINT(126.90335776672575,37.56342586335406) WHERE StationNo = '2621';
UPDATE M_Station SET PosXY = POINT(126.9100355097372,37.5560573836446) WHERE StationNo = '2622';
UPDATE M_Station SET PosXY = POINT(126.91354286903871,37.54912232079213) WHERE StationNo = '2623';
UPDATE M_Station SET PosXY = POINT(126.92241177988097,37.5477733797523) WHERE StationNo = '2624';
UPDATE M_Station SET PosXY = POINT(126.93194025779533,37.54748733268153) WHERE StationNo = '2625';
UPDATE M_Station SET PosXY = POINT(126.94246411919256,37.54766952522553) WHERE StationNo = '2626';
UPDATE M_Station SET PosXY = POINT(126.95136518782198,37.54452723838077) WHERE StationNo = '2627';
UPDATE M_Station SET PosXY = POINT(126.9613166204824,37.53933395166948) WHERE StationNo = '2628';
UPDATE M_Station SET PosXY = POINT(126.97408110462513,37.53564334162721) WHERE StationNo = '2629';
UPDATE M_Station SET PosXY = POINT(126.98702937858346,37.534830968963725) WHERE StationNo = '2630';
UPDATE M_Station SET PosXY = POINT(126.99372290017801,37.53448192611647) WHERE StationNo = '2631';
UPDATE M_Station SET PosXY = POINT(127.00174704299799,37.54029533464754) WHERE StationNo = '2632';
UPDATE M_Station SET PosXY = POINT(127.00688478144716,37.54799328551464) WHERE StationNo = '2633';
UPDATE M_Station SET PosXY = POINT(127.01012201494936,37.55389278946013) WHERE StationNo = '2634';
UPDATE M_Station SET PosXY = POINT(127.0138080439052,37.560293101373034) WHERE StationNo = '2635';
UPDATE M_Station SET PosXY = POINT(127.01615443943429,37.56627541998749) WHERE StationNo = '2636';
UPDATE M_Station SET PosXY = POINT(127.01568028457561,37.57220043049817) WHERE StationNo = '2637';
UPDATE M_Station SET PosXY = POINT(127.01521096796488,37.580017519973765) WHERE StationNo = '2638';
UPDATE M_Station SET PosXY = POINT(127.01938754998206,37.58532916538653) WHERE StationNo = '2639';
UPDATE M_Station SET PosXY = POINT(127.0362868728223,37.590648244448744) WHERE StationNo = '2641';
UPDATE M_Station SET PosXY = POINT(127.04118420104847,37.60142249412873) WHERE StationNo = '2642';
UPDATE M_Station SET PosXY = POINT(127.04832616862042,37.606263508926446) WHERE StationNo = '2643';
UPDATE M_Station SET PosXY = POINT(127.05642453926866,37.61055222857029) WHERE StationNo = '2644';
UPDATE M_Station SET PosXY = POINT(127.06611877103421,37.61504853500426) WHERE StationNo = '2645';
UPDATE M_Station SET PosXY = POINT(127.07499697577263,37.61790476490005) WHERE StationNo = '2646';
UPDATE M_Station SET PosXY = POINT(127.08374349284219,37.61989547071212) WHERE StationNo = '2647';
UPDATE M_Station SET PosXY = POINT(127.09075903378347,37.61765223007443) WHERE StationNo = '2648';
UPDATE M_Station SET PosXY = POINT(127.05313592571325,37.70011859930464) WHERE StationNo = '2711';
UPDATE M_Station SET PosXY = POINT(127.04652958563797,37.6891222729532) WHERE StationNo = '2712';
UPDATE M_Station SET PosXY = POINT(127.05537345041247,37.67762557714484) WHERE StationNo = '2713';
UPDATE M_Station SET PosXY = POINT(127.05768986266521,37.66508643213007) WHERE StationNo = '2714';
UPDATE M_Station SET PosXY = POINT(127.0605194502251,37.65469489527157) WHERE StationNo = '2715';
UPDATE M_Station SET PosXY = POINT(127.06424615612472,37.64489022881391) WHERE StationNo = '2716';
UPDATE M_Station SET PosXY = POINT(127.06798672392947,37.63645131422817) WHERE StationNo = '2717';
UPDATE M_Station SET PosXY = POINT(127.07300633481204,37.625477910674306) WHERE StationNo = '2718';
UPDATE M_Station SET PosXY = POINT(127.07499697577263,37.61790476490005) WHERE StationNo = '2719';
UPDATE M_Station SET PosXY = POINT(127.07776672574502,37.6106265895414) WHERE StationNo = '2720';
UPDATE M_Station SET PosXY = POINT(127.07946954444269,37.60157951886236) WHERE StationNo = '2721';
UPDATE M_Station SET PosXY = POINT(127.08756682062385,37.58838327587009) WHERE StationNo = '2723';
UPDATE M_Station SET PosXY = POINT(127.08844098507846,37.580796263833484) WHERE StationNo = '2724';
UPDATE M_Station SET PosXY = POINT(127.0870383398057,37.57402903933762) WHERE StationNo = '2725';
UPDATE M_Station SET PosXY = POINT(127.0840719599339,37.565525794437484) WHERE StationNo = '2726';
UPDATE M_Station SET PosXY = POINT(127.07901512810203,37.557362654799554) WHERE StationNo = '2727';
UPDATE M_Station SET PosXY = POINT(127.07455105814755,37.547872644193866) WHERE StationNo = '2728';
UPDATE M_Station SET PosXY = POINT(127.07104541845995,37.540839804010105) WHERE StationNo = '2729';
UPDATE M_Station SET PosXY = POINT(127.06671932273433,37.53158725107477) WHERE StationNo = '2730';
UPDATE M_Station SET PosXY = POINT(127.0520390352376,37.51914278253384) WHERE StationNo = '2731';
UPDATE M_Station SET PosXY = POINT(127.0413007514142,37.51719004644419) WHERE StationNo = '2732';
UPDATE M_Station SET PosXY = POINT(127.031462969986,37.51420534847362) WHERE StationNo = '2733';
UPDATE M_Station SET PosXY = POINT(127.02165763527358,37.511198199421855) WHERE StationNo = '2734';
UPDATE M_Station SET PosXY = POINT(127.0114956748719,37.50812539767412) WHERE StationNo = '2735';
UPDATE M_Station SET PosXY = POINT(127.00444243570533,37.50464799363718) WHERE StationNo = '2736';
UPDATE M_Station SET PosXY = POINT(126.99318861355258,37.487532357103085) WHERE StationNo = '2737';
UPDATE M_Station SET PosXY = POINT(126.98102282962365,37.485015414425256) WHERE StationNo = '2738';
UPDATE M_Station SET PosXY = POINT(126.97170632193176,37.48444411335394) WHERE StationNo = '2739';
UPDATE M_Station SET PosXY = POINT(126.95406103853256,37.4958128895882) WHERE StationNo = '2740';
UPDATE M_Station SET PosXY = POINT(126.94770059895008,37.503209307157675) WHERE StationNo = '2741';
UPDATE M_Station SET PosXY = POINT(126.93874232967946,37.504664630119066) WHERE StationNo = '2742';
UPDATE M_Station SET PosXY = POINT(126.92822455086642,37.499721196936385) WHERE StationNo = '2743';
UPDATE M_Station SET PosXY = POINT(126.92048905018001,37.49991086100452) WHERE StationNo = '2744';
UPDATE M_Station SET PosXY = POINT(126.90984030067348,37.50011585114009) WHERE StationNo = '2745';
UPDATE M_Station SET PosXY = POINT(126.89660991849864,37.4927419893405) WHERE StationNo = '2746';
UPDATE M_Station SET PosXY = POINT(126.8873428283549,37.48616337072886) WHERE StationNo = '2747';
UPDATE M_Station SET PosXY = POINT(126.8825603874058,37.481596008752796) WHERE StationNo = '2748';
UPDATE M_Station SET PosXY = POINT(126.86801218606675,37.47611693573971) WHERE StationNo = '2749';
UPDATE M_Station SET PosXY = POINT(126.85463844176687,37.47936649568283) WHERE StationNo = '2750';
UPDATE M_Station SET PosXY = POINT(126.83871283951193,37.48682059028633) WHERE StationNo = '2751';
UPDATE M_Station SET PosXY = POINT(126.82338161808453,37.492150645572) WHERE StationNo = '2752';
UPDATE M_Station SET PosXY = POINT(126.81139624459261,37.50619860942323) WHERE StationNo = '2753';
UPDATE M_Station SET PosXY = POINT(126.79742821318048,37.505483510808496) WHERE StationNo = '2754';
UPDATE M_Station SET PosXY = POINT(126.78686559116848,37.50364489731701) WHERE StationNo = '2755';
UPDATE M_Station SET PosXY = POINT(126.77649905200991,37.50297341171852) WHERE StationNo = '2756';
UPDATE M_Station SET PosXY = POINT(126.76406251971743,37.504643113710536) WHERE StationNo = '2757';
UPDATE M_Station SET PosXY = POINT(126.75318785519286,37.505810217122445) WHERE StationNo = '2758';
UPDATE M_Station SET PosXY = POINT(126.74190705378871,37.506582594807355) WHERE StationNo = '2759';
UPDATE M_Station SET PosXY = POINT(126.73176253798594,37.50707897171457) WHERE StationNo = '2760';
UPDATE M_Station SET PosXY = POINT(126.72085774246696,37.50760511901552) WHERE StationNo = '2761';
UPDATE M_Station SET PosXY = POINT(127.1275418321658,37.550141060814475) WHERE StationNo = '2811';
UPDATE M_Station SET PosXY = POINT(127.12374300170981,37.53857988247639) WHERE StationNo = '2812';
UPDATE M_Station SET PosXY = POINT(127.12057570947756,37.53067237616664) WHERE StationNo = '2813';
UPDATE M_Station SET PosXY = POINT(127.11242844575287,37.517623092550025) WHERE StationNo = '2814';
UPDATE M_Station SET PosXY = POINT(127.1042170589561,37.51497806972679) WHERE StationNo = '2815';
UPDATE M_Station SET PosXY = POINT(127.10634432937081,37.50594811233585) WHERE StationNo = '2816';
UPDATE M_Station SET PosXY = POINT(127.11217989255323,37.4996861342522) WHERE StationNo = '2817';
UPDATE M_Station SET PosXY = POINT(127.11848381630814,37.49274223205612) WHERE StationNo = '2818';
UPDATE M_Station SET PosXY = POINT(127.1222907853981,37.48626191297808) WHERE StationNo = '2819';
UPDATE M_Station SET PosXY = POINT(127.12642443090823,37.478119655330296) WHERE StationNo = '2820';
UPDATE M_Station SET PosXY = POINT(127.12676975735933,37.47105537138374) WHERE StationNo = '2821';
UPDATE M_Station SET PosXY = POINT(127.14994228203592,37.456525568472756) WHERE StationNo = '2822';
UPDATE M_Station SET PosXY = POINT(127.15980099129938,37.451567893801) WHERE StationNo = '2823';
UPDATE M_Station SET PosXY = POINT(127.15668641533487,37.445142434318456) WHERE StationNo = '2824';
UPDATE M_Station SET PosXY = POINT(127.14810817259715,37.44118542933686) WHERE StationNo = '2825';
UPDATE M_Station SET PosXY = POINT(127.14035387124626,37.43746108672792) WHERE StationNo = '2826';
UPDATE M_Station SET PosXY = POINT(127.12954981193997,37.4338513401459) WHERE StationNo = '2827';
카카오 API로 경도, 위도를 찾았지만, 두 군데(안암역, 상봉역)는 카카오 API로 찾을 수가 없었습니다. 아마도 주소가 약간 안 맞아서 그런 것 아닐까 생각이 듭니다. 구글 지도로 직접 경도와 위도를 찾아서 업데이트합니다.
-- 못 찾은 경도, 위도 수작업 업데이트.
UPDATE M_Station SET PosXY = POINT(127.029137,37.586296) WHERE StationNo = 2640;
UPDATE M_Station SET PosXY = POINT(127.085739,37.595627) WHERE StationNo = 2722;
5. 검색해보기
POINT 자료형에서 데이터를 빨리 찾기 위해서는 SPATIAL 인덱스를 만들어서 활용합니다. 그런데 SPATIAL 인덱스를 만들려면 해당 컬럼이 NOT NULL이어야 합니다. 아래와 같이 PosXY를 NOT NULL로 변경하고 인덱스를 만듭니다.
-- SPATIAL INDEX 생성
ALTER TABLE M_Station MODIFY PosXY POINT NOT NULL;
CREATE SPATIAL INDEX SX_M_Station_1 ON M_Station(PosXY);
경도와 위도 위치 정보입니다. 위치 정보를 저장하고 관리하기 위해서는 MySQL의 POINT 자료형을 사용합니다.
(보통은 경도, 위도 보다는 위도, 경도(위경도) 순으로 말하는 것이 익숙합니다. 그런데 경도는 X값, 위도는 Y값이므로, 이 후 사용의 통일성을 위해서 경도, 위도 순으로 쓰고 있습니다.)
아래 스크립트로 M_Station 테이블을 변경합니다.
-- 테이블 컬럼 추가
ALTER TABLE M_Station ADD COLUMN Address VARCHAR(500) CHARACTER SET UTF8MB4 NOT NULL;
ALTER TABLE M_Station ADD COLUMN PhoneNo VARCHAR(100) CHARACTER SET UTF8MB4 NOT NULL;
ALTER TABLE M_Station ADD COLUMN PosXY POINT;
건수가 279건 밖에 안됩니다. 이런 경우에는 업로드용 테이블을 만들고 따로 업로드 하는 것보다, 엑셀 수식을 이용해 바로 올리면서 테이블을 만드는 것이 좋습니다.
엑셀로 CSV 파일을 열어보면 아래와 같습니다.
엑셀의 2번 로우, H 컬럼에 아래 수식을 적어줍니다.
-- 엑셀의 1-H 셀에 수식 입력
="SELECT '"&B2&"' LINE_NO,'"&C2&"' ST_NM,'"&E2&"' ADDR, '"&F2&"' PH_NO FROM DUAL UNION ALL"
위 수식을 엑셀에 279라인까지 카피를 합니다. 카피된 내용을 SQL 창으로 옮겨서 U_StationAddInfo 테이블을 CREATE합니다. (엑셀에서 SQL창으로 카피한 다음에, 마지막 줄에 UNION ALL은 제거해야 합니다.)
-- 지하철역 추가정보 업로드 테이블 생성 U_StationAddInfo (너무 길어서 줄 번호는 생략합니다.)
CREATE TABLE U_StationAddInfo AS
SELECT '1' LINE_NO,'서울역' ST_NM,'서울특별시 중구 세종대로 지하 2 (남대문로 5가)' ADDR, '02-6110-1331' PH_NO FROM DUAL UNION ALL
SELECT '1' LINE_NO,'시청' ST_NM,'서울특별시 중구 세종대로 지하 101 (정동)' ADDR, '02-6110-1321' PH_NO FROM DUAL UNION ALL
SELECT '1' LINE_NO,'종각' ST_NM,'서울특별시 종로구 종로 지하 55 (종로1가)' ADDR, '02-6110-1311' PH_NO FROM DUAL UNION ALL
SELECT '1' LINE_NO,'종로3가' ST_NM,'서울특별시 종로구 종로 지하 129 (종로3가)' ADDR, '02-6110-1301' PH_NO FROM DUAL UNION ALL
SELECT '1' LINE_NO,'종로5가' ST_NM,'서울특별시 종로구 종로 지하 216 (종로5가)' ADDR, '02-6110-1291' PH_NO FROM DUAL UNION ALL
SELECT '1' LINE_NO,'동대문' ST_NM,'서울특별시 종로구 종로 지하 302 (창신동)' ADDR, '02-6110-1281' PH_NO FROM DUAL UNION ALL
SELECT '1' LINE_NO,'신설동' ST_NM,'서울특별시 동대문구 왕산로 지하 1 (신설동)' ADDR, '02-6110-1261' PH_NO FROM DUAL UNION ALL
SELECT '1' LINE_NO,'제기동' ST_NM,'서울특별시 동대문구 왕산로 지하 93 (제기동)' ADDR, '02-6110-1251' PH_NO FROM DUAL UNION ALL
SELECT '1' LINE_NO,'청량리(서울시립대입구)' ST_NM,'서울특별시 동대문구 왕산로 지하 205 (전농동)' ADDR, '02-6110-1241' PH_NO FROM DUAL UNION ALL
SELECT '1' LINE_NO,'동묘앞' ST_NM,'서울특별시 종로구 종로 359 (숭인동)' ADDR, '02-6110-1271' PH_NO FROM DUAL UNION ALL
SELECT '2' LINE_NO,'시청' ST_NM,'서울특별시 중구 서소문로 지하 127 (서소문동)' ADDR, '02-6110-2011' PH_NO FROM DUAL UNION ALL
SELECT '2' LINE_NO,'을지로입구' ST_NM,'서울특별시 중구 을지로 지하 42 (을지로1가)' ADDR, '02-6110-2021' PH_NO FROM DUAL UNION ALL
SELECT '2' LINE_NO,'을지로3가' ST_NM,'서울특별시 중구 을지로 지하 106 (을지로3가)' ADDR, '02-6110-2031' PH_NO FROM DUAL UNION ALL
SELECT '2' LINE_NO,'을지로4가' ST_NM,'서울특별시 중구 을지로 지하 178 (을지로4가)' ADDR, '02-6110-2041' PH_NO FROM DUAL UNION ALL
SELECT '2' LINE_NO,'동대문역사문화공원' ST_NM,'서울특별시 중구 을지로 지하 279 (을지로7가)' ADDR, '02-6110-2051' PH_NO FROM DUAL UNION ALL
SELECT '2' LINE_NO,'신당' ST_NM,'서울특별시 중구 퇴계로 지하 431-1 (신당동)' ADDR, '02-6110-2061' PH_NO FROM DUAL UNION ALL
SELECT '2' LINE_NO,'상왕십리' ST_NM,'서울특별시 성동구 왕십리로 지하 374 (하왕십리동)' ADDR, '02-6110-2071' PH_NO FROM DUAL UNION ALL
SELECT '2' LINE_NO,'왕십리(성동구청)' ST_NM,'서울특별시 성동구 왕십리로 지하300 (행당동)' ADDR, '02-6110-2081' PH_NO FROM DUAL UNION ALL
SELECT '2' LINE_NO,'한양대' ST_NM,'서울특별시 성동구 왕십리로 206 (행당동)' ADDR, '02-6110-2091' PH_NO FROM DUAL UNION ALL
SELECT '2' LINE_NO,'뚝섬' ST_NM,'서울특별시 성동구 아차산로 18 (성수동1가)' ADDR, '02-6110-2101' PH_NO FROM DUAL UNION ALL
SELECT '2' LINE_NO,'성수' ST_NM,'서울특별시 성동구 아차산로 100 (성수동2가)' ADDR, '02-6110-2111' PH_NO FROM DUAL UNION ALL
SELECT '2' LINE_NO,'건대입구' ST_NM,'서울특별시 광진구 아차산로 243 (화양동)' ADDR, '02-6110-2121' PH_NO FROM DUAL UNION ALL
SELECT '2' LINE_NO,'구의(광진구청)' ST_NM,'서울특별시 광진구 아차산로 384-1 (구의동)' ADDR, '02-6110-2131' PH_NO FROM DUAL UNION ALL
SELECT '2' LINE_NO,'강변(동서울터미널)' ST_NM,'서울특별시 광진구 강변역로 53 (구의동)' ADDR, '02-6110-2141' PH_NO FROM DUAL UNION ALL
SELECT '2' LINE_NO,'잠실나루' ST_NM,'서울특별시 송파구 오금로 20 (신천동)' ADDR, '02-6110-2151' PH_NO FROM DUAL UNION ALL
SELECT '2' LINE_NO,'잠실(송파구청)' ST_NM,'서울특별시 송파구 올림픽로 지하 265 (잠실동)' ADDR, '02-6110-2161' PH_NO FROM DUAL UNION ALL
SELECT '2' LINE_NO,'잠실새내' ST_NM,'서울특별시 송파구 올림픽로 지하 140 (잠실동)' ADDR, '02-6110-2171' PH_NO FROM DUAL UNION ALL
SELECT '2' LINE_NO,'종합운동장' ST_NM,'서울특별시 송파구 올림픽로 지하 23 (잠실동)' ADDR, '02-6110-2181' PH_NO FROM DUAL UNION ALL
SELECT '2' LINE_NO,'삼성(무역센터)' ST_NM,'서울특별시 강남구 테헤란로 지하 538 (삼성동)' ADDR, '02-6110-2191' PH_NO FROM DUAL UNION ALL
SELECT '2' LINE_NO,'선릉' ST_NM,'서울특별시 강남구 테헤란로 지하 340 (삼성동)' ADDR, '02-6110-2201' PH_NO FROM DUAL UNION ALL
SELECT '2' LINE_NO,'역삼' ST_NM,'서울특별시 강남구 테헤란로 지하 156 (역삼동)' ADDR, '02-6110-2211' PH_NO FROM DUAL UNION ALL
SELECT '2' LINE_NO,'강남' ST_NM,'서울특별시 강남구 강남대로 지하 396 (역삼동)' ADDR, '02-6110-2221' PH_NO FROM DUAL UNION ALL
SELECT '2' LINE_NO,'교대(법원•검찰청)' ST_NM,'서울특별시 서초구 서초대로 지하 294 (서초동)' ADDR, '02-6110-2231' PH_NO FROM DUAL UNION ALL
SELECT '2' LINE_NO,'서초' ST_NM,'서울특별시 서초구 서초대로 지하 233 (서초동)' ADDR, '02-6110-2241' PH_NO FROM DUAL UNION ALL
SELECT '2' LINE_NO,'방배' ST_NM,'서울특별시 서초구 방배로 지하 80 (방배동)' ADDR, '02-6110-2251' PH_NO FROM DUAL UNION ALL
SELECT '2' LINE_NO,'사당' ST_NM,'서울특별시 동작구 남부순환로 지하 2089 (사당동)' ADDR, '02-6110-2261' PH_NO FROM DUAL UNION ALL
SELECT '2' LINE_NO,'낙성대' ST_NM,'서울특별시 관악구 남부순환로 지하 1928 (봉천동)' ADDR, '02-6110-2271' PH_NO FROM DUAL UNION ALL
SELECT '2' LINE_NO,'서울대입구(관악구청)' ST_NM,'서울특별시 관악구 남부순환로 지하 1822 (봉천동)' ADDR, '02-6110-2281' PH_NO FROM DUAL UNION ALL
SELECT '2' LINE_NO,'봉천' ST_NM,'서울특별시 관악구 남부순환로 지하 1721 (봉천동)' ADDR, '02-6110-2291' PH_NO FROM DUAL UNION ALL
SELECT '2' LINE_NO,'신림' ST_NM,'서울특별시 관악구 남부순환로 지하 1614 (신림동)' ADDR, '02-6110-2301' PH_NO FROM DUAL UNION ALL
SELECT '2' LINE_NO,'신대방' ST_NM,'서울특별시 동작구 대림로 2 (신대방동)' ADDR, '02-6110-2311' PH_NO FROM DUAL UNION ALL
SELECT '2' LINE_NO,'구로디지털단지' ST_NM,'서울특별시 구로구 도림천로 477 (구로동)' ADDR, '02-6110-2321' PH_NO FROM DUAL UNION ALL
SELECT '2' LINE_NO,'대림(구로구청)' ST_NM,'서울특별시 구로구 도림천로 351 (구로동)' ADDR, '02-6110-2331' PH_NO FROM DUAL UNION ALL
SELECT '2' LINE_NO,'신도림' ST_NM,'서울특별시 구로구 새말로 지하 117-21 (신도림동)' ADDR, '02-6110-2341' PH_NO FROM DUAL UNION ALL
SELECT '2' LINE_NO,'문래' ST_NM,'서울특별시 영등포구 당산로 지하 28 (문래동3가)' ADDR, '02-6110-2351' PH_NO FROM DUAL UNION ALL
SELECT '2' LINE_NO,'영등포구청' ST_NM,'서울특별시 영등포구 당산로 지하 121 (당산동3가)' ADDR, '02-6110-2361' PH_NO FROM DUAL UNION ALL
SELECT '2' LINE_NO,'당산' ST_NM,'서울특별시 영등포구 당산로 229 (당산동 6가)' ADDR, '02-6110-2371' PH_NO FROM DUAL UNION ALL
SELECT '2' LINE_NO,'합정' ST_NM,'서울특별시 마포구 양화로 지하 55 (서교동)' ADDR, '02-6110-2381' PH_NO FROM DUAL UNION ALL
SELECT '2' LINE_NO,'홍대입구' ST_NM,'서울특별시 마포구 양화로 지하160 (동교동)' ADDR, '02-6110-2391' PH_NO FROM DUAL UNION ALL
SELECT '2' LINE_NO,'신촌' ST_NM,'서울특별시 마포구 신촌로 지하 90 (노고산동)' ADDR, '02-6110-2401' PH_NO FROM DUAL UNION ALL
SELECT '2' LINE_NO,'이대' ST_NM,'서울특별시 마포구 신촌로 지하 180 (염리동)' ADDR, '02-6110-2411' PH_NO FROM DUAL UNION ALL
SELECT '2' LINE_NO,'아현' ST_NM,'서울특별시 마포구 신촌로 지하 270 (아현동)' ADDR, '02-6110-2421' PH_NO FROM DUAL UNION ALL
SELECT '2' LINE_NO,'충정로(경기대입구)' ST_NM,'서울특별시 서대문구 서소문로 지하 17 (충정로3가)' ADDR, '02-6110-2431' PH_NO FROM DUAL UNION ALL
SELECT '2' LINE_NO,'용답' ST_NM,'서울특별시 성동구 용답길 86 (용답동)' ADDR, '02-6110-1341' PH_NO FROM DUAL UNION ALL
SELECT '2' LINE_NO,'신답' ST_NM,'서울특별시 성동구 천호대로 232 (용답동)' ADDR, '02-6110-1351' PH_NO FROM DUAL UNION ALL
SELECT '2' LINE_NO,'용두(동대문구청)' ST_NM,'서울특별시 동대문구 천호대로 지하 129 (용두동)' ADDR, '02-6110-1361' PH_NO FROM DUAL UNION ALL
SELECT '2' LINE_NO,'신설동' ST_NM,'서울특별시 동대문구 왕산로 지하 1 (신설동)' ADDR, '02-6110-1371' PH_NO FROM DUAL UNION ALL
SELECT '2' LINE_NO,'도림천' ST_NM,'서울특별시 구로구 경인로 67길 160 (신도림동)' ADDR, '02-6110-2441' PH_NO FROM DUAL UNION ALL
SELECT '2' LINE_NO,'양천구청' ST_NM,'서울특별시 양천구 목동로3길 지하 33(신정동)' ADDR, '02-6110-2451' PH_NO FROM DUAL UNION ALL
SELECT '2' LINE_NO,'신정네거리' ST_NM,'서울특별시 양천구 중앙로 지하 261 (신정동)' ADDR, '02-6110-2461' PH_NO FROM DUAL UNION ALL
SELECT '2' LINE_NO,'까치산' ST_NM,'서울특별시 강서구 강서로 지하54(화곡동)' ADDR, '02-6110-5180' PH_NO FROM DUAL UNION ALL
SELECT '3' LINE_NO,'지축' ST_NM,'경기도 고양시 덕양구 삼송로 300 (지축동)' ADDR, '02-6110-3191' PH_NO FROM DUAL UNION ALL
SELECT '3' LINE_NO,'구파발' ST_NM,'서울특별시 은평구 진관2로 지하 15-25 (진관동)' ADDR, '02-6110-3201' PH_NO FROM DUAL UNION ALL
SELECT '3' LINE_NO,'연신내' ST_NM,'서울특별시 은평구 통일로 지하 849 (갈현동)' ADDR, '02-6110-3211' PH_NO FROM DUAL UNION ALL
SELECT '3' LINE_NO,'불광' ST_NM,'서울특별시 은평구 통일로 지하 723-1 (대조동)' ADDR, '02-6110-3221' PH_NO FROM DUAL UNION ALL
SELECT '3' LINE_NO,'녹번' ST_NM,'서울특별시 은평구 통일로 지하 602-1 (녹번동)' ADDR, '02-6110-3231' PH_NO FROM DUAL UNION ALL
SELECT '3' LINE_NO,'홍제' ST_NM,'서울특별시 서대문구 통일로 지하 440-1 (홍제동)' ADDR, '02-6110-3241' PH_NO FROM DUAL UNION ALL
SELECT '3' LINE_NO,'무악재' ST_NM,'서울특별시 서대문구 통일로 지하 361 (홍제동)' ADDR, '02-6110-3251' PH_NO FROM DUAL UNION ALL
SELECT '3' LINE_NO,'독립문' ST_NM,'서울특별시 서대문구 통일로 지하 247 (현저동)' ADDR, '02-6110-3261' PH_NO FROM DUAL UNION ALL
SELECT '3' LINE_NO,'경복궁(정부서울청사)' ST_NM,'서울특별시 종로구 사직로 지하 130 (적선동)' ADDR, '02-6110-3271' PH_NO FROM DUAL UNION ALL
SELECT '3' LINE_NO,'안국' ST_NM,'서울특별시 종로구 율곡로 지하 62 (안국동)' ADDR, '02-6110-3281' PH_NO FROM DUAL UNION ALL
SELECT '3' LINE_NO,'종로3가' ST_NM,'서울특별시 종로구 돈화문로 지하 30 (묘동)' ADDR, '02-6110-3291' PH_NO FROM DUAL UNION ALL
SELECT '3' LINE_NO,'을지로3가' ST_NM,'서울특별시 중구 을지로 지하 129 (을지로3가)' ADDR, '02-6110-3301' PH_NO FROM DUAL UNION ALL
SELECT '3' LINE_NO,'충무로' ST_NM,'서울특별시 중구 퇴계로 지하 199 (필동2가)' ADDR, '02-6110-4231' PH_NO FROM DUAL UNION ALL
SELECT '3' LINE_NO,'동대입구' ST_NM,'서울특별시 중구 동호로 지하 256 (장충동2가)' ADDR, '02-6110-3321' PH_NO FROM DUAL UNION ALL
SELECT '3' LINE_NO,'약수' ST_NM,'서울특별시 중구 다산로 지하 122 (신당동)' ADDR, '02-6110-3331' PH_NO FROM DUAL UNION ALL
SELECT '3' LINE_NO,'금호' ST_NM,'서울특별시 성동구 동호로 지하 104 (금호동4가)' ADDR, '02-6110-3341' PH_NO FROM DUAL UNION ALL
SELECT '3' LINE_NO,'옥수' ST_NM,'서울특별시 성동구 동호로 21 (옥수동)' ADDR, '02-6110-3351' PH_NO FROM DUAL UNION ALL
SELECT '3' LINE_NO,'압구정' ST_NM,'서울특별시 강남구 압구정로 지하 172 (신사동)' ADDR, '02-6110-3361' PH_NO FROM DUAL UNION ALL
SELECT '3' LINE_NO,'신사' ST_NM,'서울특별시 강남구 도산대로 지하 102 (신사동)' ADDR, '02-6110-3371' PH_NO FROM DUAL UNION ALL
SELECT '3' LINE_NO,'잠원' ST_NM,'서울특별시 서초구 잠원로 4길 지하 46 (잠원동)' ADDR, '02-6110-3381' PH_NO FROM DUAL UNION ALL
SELECT '3' LINE_NO,'고속터미널' ST_NM,'서울특별시 서초구 신반포로 지하 188 (반포동)' ADDR, '02-6110-3391' PH_NO FROM DUAL UNION ALL
SELECT '3' LINE_NO,'교대(법원•검찰청)' ST_NM,'서울특별시 서초구 서초대로 지하 294 (서초동)' ADDR, '02-6110-3401' PH_NO FROM DUAL UNION ALL
SELECT '3' LINE_NO,'남부터미널(예술의전당)' ST_NM,'서울특별시 서초구 서초중앙로 지하 31 (서초동)' ADDR, '02-6110-3411' PH_NO FROM DUAL UNION ALL
SELECT '3' LINE_NO,'양재(서초구청)' ST_NM,'서울특별시 서초구 남부순환로 지하 2585 (서초동)' ADDR, '02-6110-3421' PH_NO FROM DUAL UNION ALL
SELECT '3' LINE_NO,'매봉' ST_NM,'서울특별시 강남구 남부순환로 지하 2744 (도곡동)' ADDR, '02-6110-3431' PH_NO FROM DUAL UNION ALL
SELECT '3' LINE_NO,'도곡' ST_NM,'서울특별시 강남구 남부순환로 지하 2814 (도곡동)' ADDR, '02-6110-3441' PH_NO FROM DUAL UNION ALL
SELECT '3' LINE_NO,'대치' ST_NM,'서울특별시 강남구 남부순환로 지하 2952 (대치동)' ADDR, '02-6110-3451' PH_NO FROM DUAL UNION ALL
SELECT '3' LINE_NO,'학여울' ST_NM,'서울특별시 강남구 남부순환로 지하 3104 (대치동)' ADDR, '02-6110-3461' PH_NO FROM DUAL UNION ALL
SELECT '3' LINE_NO,'대청' ST_NM,'서울특별시 강남구 일원로 지하 2 (일원동)' ADDR, '02-6110-3471' PH_NO FROM DUAL UNION ALL
SELECT '3' LINE_NO,'일원' ST_NM,'서울특별시 강남구 일원로 지하 121 (일원동)' ADDR, '02-6110-3481' PH_NO FROM DUAL UNION ALL
SELECT '3' LINE_NO,'수서' ST_NM,'서울특별시 강남구 광평로 지하 270 (수서동)' ADDR, '02-6110-3491' PH_NO FROM DUAL UNION ALL
SELECT '3' LINE_NO,'가락시장' ST_NM,'서울특별시 송파구 송파대로 지하 257 (가락동)' ADDR, '02-6110-3501' PH_NO FROM DUAL UNION ALL
SELECT '3' LINE_NO,'경찰병원' ST_NM,'서울특별시 송파구 중대로 지하 149 (가락동)' ADDR, '02-6110-3511' PH_NO FROM DUAL UNION ALL
SELECT '3' LINE_NO,'오금' ST_NM,'서울특별시 송파구 오금로 지하 321 (오금동)' ADDR, '02-6110-3521' PH_NO FROM DUAL UNION ALL
SELECT '4' LINE_NO,'당고개' ST_NM,'서울특별시 노원구 상계로 305 (상계동)' ADDR, '02-6110-4091' PH_NO FROM DUAL UNION ALL
SELECT '4' LINE_NO,'상계' ST_NM,'서울특별시 노원구 상계로 182 (상계동)' ADDR, '02-6110-4101' PH_NO FROM DUAL UNION ALL
SELECT '4' LINE_NO,'노원' ST_NM,'서울특별시 노원구 상계로 69-1 (상계동)' ADDR, '02-6110-4111' PH_NO FROM DUAL UNION ALL
SELECT '4' LINE_NO,'창동' ST_NM,'서울특별시 도봉구 마들로 11길 77 (창동)' ADDR, '02-6110-4121' PH_NO FROM DUAL UNION ALL
SELECT '4' LINE_NO,'쌍문' ST_NM,'서울특별시 도봉구 도봉로 지하 486-1 (창동)' ADDR, '02-6110-4131' PH_NO FROM DUAL UNION ALL
SELECT '4' LINE_NO,'수유(강북구청)' ST_NM,'서울특별시 강북구 도봉로 지하 338 (수유동)' ADDR, '02-6110-4141' PH_NO FROM DUAL UNION ALL
SELECT '4' LINE_NO,'미아(서울사이버대학)' ST_NM,'서울특별시 강북구 도봉로 지하 198 (미아동)' ADDR, '02-6110-4151' PH_NO FROM DUAL UNION ALL
SELECT '4' LINE_NO,'미아사거리' ST_NM,'서울특별시 강북구 도봉로 지하 50 (미아동)' ADDR, '02-6110-4161' PH_NO FROM DUAL UNION ALL
SELECT '4' LINE_NO,'길음' ST_NM,'서울특별시 성북구 동소문로 지하 248 (길음동)' ADDR, '02-6110-4171' PH_NO FROM DUAL UNION ALL
SELECT '4' LINE_NO,'성신여대입구(돈암)' ST_NM,'서울특별시 성북구 동소문로 지하 102 (동선동4가)' ADDR, '02-6110-4181' PH_NO FROM DUAL UNION ALL
SELECT '4' LINE_NO,'한성대입구(삼선교)' ST_NM,'서울특별시 성북구 삼선교로 지하 1 (삼선동1가)' ADDR, '02-6110-4191' PH_NO FROM DUAL UNION ALL
SELECT '4' LINE_NO,'혜화' ST_NM,'서울특별시 종로구 대학로 지하 120 (명륜4가)' ADDR, '02-6110-4201' PH_NO FROM DUAL UNION ALL
SELECT '4' LINE_NO,'동대문' ST_NM,'서울특별시 종로구 율곡로 지하 308 (종로6가)' ADDR, '02-6110-4211' PH_NO FROM DUAL UNION ALL
SELECT '4' LINE_NO,'동대문역사문화공원' ST_NM,'서울특별시 중구 장충단로 지하 230 (광희동2가)' ADDR, '02-6110-4221' PH_NO FROM DUAL UNION ALL
SELECT '4' LINE_NO,'충무로' ST_NM,'서울특별시 중구 퇴계로 지하 199 (필동2가)' ADDR, '02-6110-4231' PH_NO FROM DUAL UNION ALL
SELECT '4' LINE_NO,'명동' ST_NM,'서울특별시 중구 퇴계로 지하 126 (충무로2가)' ADDR, '02-6110-4241' PH_NO FROM DUAL UNION ALL
SELECT '4' LINE_NO,'회현(남대문시장)' ST_NM,'서울특별시 중구 퇴계로 지하 54 (남창동)' ADDR, '02-6110-4251' PH_NO FROM DUAL UNION ALL
SELECT '4' LINE_NO,'서울역' ST_NM,'서울특별시 용산구 한강대로 지하 392 (동자동)' ADDR, '02-6110-4261' PH_NO FROM DUAL UNION ALL
SELECT '4' LINE_NO,'숙대입구(갈월)' ST_NM,'서울특별시 용산구 한강대로 지하 306 (갈월동)' ADDR, '02-6110-4271' PH_NO FROM DUAL UNION ALL
SELECT '4' LINE_NO,'삼각지' ST_NM,'서울특별시 용산구 한강대로 지하 180 (한강로1가)' ADDR, '02-6110-4281' PH_NO FROM DUAL UNION ALL
SELECT '4' LINE_NO,'신용산' ST_NM,'서울특별시 용산구 한강대로 지하 112 (한강로2가)' ADDR, '02-6110-4291' PH_NO FROM DUAL UNION ALL
SELECT '4' LINE_NO,'이촌(국립중앙박물관)' ST_NM,'서울특별시 용산구 서빙고로 지하 83 (용산동5가)' ADDR, '02-6110-4301' PH_NO FROM DUAL UNION ALL
SELECT '4' LINE_NO,'동작(현충원)' ST_NM,'서울특별시 동작구 현충로 257 (동작동)' ADDR, '02-6110-4311' PH_NO FROM DUAL UNION ALL
SELECT '4' LINE_NO,'총신대입구(이수)' ST_NM,'서울특별시 동작구 동작대로 지하 117 (사당동)' ADDR, '02-6110-4321' PH_NO FROM DUAL UNION ALL
SELECT '4' LINE_NO,'사당' ST_NM,'서울특별시 동작구 동작대로 지하 3 (사당동)' ADDR, '02-6110-4331' PH_NO FROM DUAL UNION ALL
SELECT '4' LINE_NO,'남태령' ST_NM,'서울특별시 서초구 과천대로 지하 816 (방배동)' ADDR, '02-6110-4341' PH_NO FROM DUAL UNION ALL
SELECT '5' LINE_NO,'방화' ST_NM,'서울특별시 강서구 금낭화로 지하132 (방화동)' ADDR, '02-6311-5100' PH_NO FROM DUAL UNION ALL
SELECT '5' LINE_NO,'개화산' ST_NM,'서울특별시 강서구 양천로 22(방화동)' ADDR, '02-6311-5110' PH_NO FROM DUAL UNION ALL
SELECT '5' LINE_NO,'김포공항' ST_NM,'서울특별시 강서구 하늘길 지하77 (방화동)' ADDR, '02-6311-5120' PH_NO FROM DUAL UNION ALL
SELECT '5' LINE_NO,'송정' ST_NM,'서울특별시 강서구 공항대로 지하33 (공항동)' ADDR, '02-6311-5130' PH_NO FROM DUAL UNION ALL
SELECT '5' LINE_NO,'마곡' ST_NM,'서울특별시 강서구 공항대로 지하163 (가양동)' ADDR, '02-6311-5140' PH_NO FROM DUAL UNION ALL
SELECT '5' LINE_NO,'발산' ST_NM,'서울특별시 강서구 공항대로 지하267 (가양동)' ADDR, '02-6311-5150' PH_NO FROM DUAL UNION ALL
SELECT '5' LINE_NO,'우장산' ST_NM,'서울특별시 강서구 강서로 지하262 (화곡동)' ADDR, '02-6311-5160' PH_NO FROM DUAL UNION ALL
SELECT '5' LINE_NO,'화곡' ST_NM,'서울특별시 강서구 화곡로 지하168 (화곡동)' ADDR, '02-6311-5170' PH_NO FROM DUAL UNION ALL
SELECT '5' LINE_NO,'까치산' ST_NM,'서울특별시 강서구 강서로 지하54(화곡동)' ADDR, '02-6311-5180' PH_NO FROM DUAL UNION ALL
SELECT '5' LINE_NO,'신정(은행정)' ST_NM,'서울특별시 양천구 오목로 지하179 (신정동)' ADDR, '02-6311-5190' PH_NO FROM DUAL UNION ALL
SELECT '5' LINE_NO,'목동' ST_NM,'서울특별시 양천구 오목로 지하245 (목동)' ADDR, '02-6311-5200' PH_NO FROM DUAL UNION ALL
SELECT '5' LINE_NO,'오목교(목동운동장앞)' ST_NM,'서울특별시 양천구 오목로 지하342 (목동)' ADDR, '02-6311-5210' PH_NO FROM DUAL UNION ALL
SELECT '5' LINE_NO,'양평' ST_NM,'서울특별시 영등포구 양산로 지하21(양평동2가)' ADDR, '02-6311-5220' PH_NO FROM DUAL UNION ALL
SELECT '5' LINE_NO,'영등포구청' ST_NM,'서울특별시 영등포구 양산로 지하116(당산동3가)' ADDR, '02-6311-2361' PH_NO FROM DUAL UNION ALL
SELECT '5' LINE_NO,'영등포시장' ST_NM,'서울특별시 영등포구 양산로 지하200 (영등포동5가)' ADDR, '02-6311-5240' PH_NO FROM DUAL UNION ALL
SELECT '5' LINE_NO,'신길' ST_NM,'서울특별시 영등포구 경인로114가길 지하9 (영등포동1가)' ADDR, '02-6311-5250' PH_NO FROM DUAL UNION ALL
SELECT '5' LINE_NO,'여의도' ST_NM,'서울특별시 영등포구 여의나루로 지하40 (여의도동)' ADDR, '02-6311-5260' PH_NO FROM DUAL UNION ALL
SELECT '5' LINE_NO,'여의나루' ST_NM,'서울특별시 영등포구 여의동로 지하343 (여의도동)' ADDR, '02-6311-5270' PH_NO FROM DUAL UNION ALL
SELECT '5' LINE_NO,'마포' ST_NM,'서울특별시 마포구 마포대로 지하33 (도화동)' ADDR, '02-6311-5280' PH_NO FROM DUAL UNION ALL
SELECT '5' LINE_NO,'공덕' ST_NM,'서울특별시 마포구 마포대로 지하100(공덕동)' ADDR, '02-6311-5290' PH_NO FROM DUAL UNION ALL
SELECT '5' LINE_NO,'애오개' ST_NM,'서울특별시 마포구 마포대로 지하210 (아현동)' ADDR, '02-6311-5300' PH_NO FROM DUAL UNION ALL
SELECT '5' LINE_NO,'충정로(경기대입구)' ST_NM,'서울특별시 서대문구 충정로 지하28-1 (충정로3가)' ADDR, '02-6311-5310' PH_NO FROM DUAL UNION ALL
SELECT '5' LINE_NO,'서대문' ST_NM,'서울특별시 종로구 통일로 지하126 (평동)' ADDR, '02-6311-5320' PH_NO FROM DUAL UNION ALL
SELECT '5' LINE_NO,'광화문(세종문화회관)' ST_NM,'서울특별시 종로구 세종대로 지하172 (세종로)' ADDR, '02-6311-5330' PH_NO FROM DUAL UNION ALL
SELECT '5' LINE_NO,'종로3가(탑골공원)' ST_NM,'서울특별시 종로구 돈화문로11길 지하26 (돈의동)' ADDR, '02-6311-5340' PH_NO FROM DUAL UNION ALL
SELECT '5' LINE_NO,'을지로4가' ST_NM,'서울특별시 중구 창경궁로 지하51 (주교동)' ADDR, '02-6311-5350' PH_NO FROM DUAL UNION ALL
SELECT '5' LINE_NO,'동대문역사문화공원' ST_NM,'서울특별시 중구 마른내로 지하162 (광희동1가)' ADDR, '02-6311-5360' PH_NO FROM DUAL UNION ALL
SELECT '5' LINE_NO,'청구' ST_NM,'서울특별시 중구 청구로 지하77(신당동)' ADDR, '02-6311-5370' PH_NO FROM DUAL UNION ALL
SELECT '5' LINE_NO,'신금호' ST_NM,'서울특별시 성동구 금호로 지하154 (금호동2가)' ADDR, '02-6311-5380' PH_NO FROM DUAL UNION ALL
SELECT '5' LINE_NO,'행당' ST_NM,'서울특별시 성동구 행당로 지하89 (행당동)' ADDR, '02-6311-5390' PH_NO FROM DUAL UNION ALL
SELECT '5' LINE_NO,'왕십리(성동구청)' ST_NM,'서울특별시 성동구 왕십리로 지하300(행당동)' ADDR, '02-6311-5400' PH_NO FROM DUAL UNION ALL
SELECT '5' LINE_NO,'마장' ST_NM,'서울특별시 성동구 마장로 지하296 (마장동)' ADDR, '02-6311-5410' PH_NO FROM DUAL UNION ALL
SELECT '5' LINE_NO,'답십리' ST_NM,'서울특별시 성동구 천호대로 지하300(용답동)' ADDR, '02-6311-5420' PH_NO FROM DUAL UNION ALL
SELECT '5' LINE_NO,'장한평' ST_NM,'서울특별시 동대문구 천호대로 지하405 (장안동)' ADDR, '02-6311-5430' PH_NO FROM DUAL UNION ALL
SELECT '5' LINE_NO,'군자(능동)' ST_NM,'서울특별시 광진구 천호대로 지하550 (능동) ' ADDR, '02-6311-5440' PH_NO FROM DUAL UNION ALL
SELECT '5' LINE_NO,'아차산(어린이대공원후문)' ST_NM,'서울특별시 광진구 천호대로 지하657 (능동)' ADDR, '02-6311-5450' PH_NO FROM DUAL UNION ALL
SELECT '5' LINE_NO,'광나루(장신대)' ST_NM,'서울특별시 광진구 아차산로 지하571 (광장동)' ADDR, '02-6311-5460' PH_NO FROM DUAL UNION ALL
SELECT '5' LINE_NO,'천호(풍납토성)' ST_NM,'서울특별시 강동구 천호대로 지하997 (천호동)' ADDR, '02-6311-5470' PH_NO FROM DUAL UNION ALL
SELECT '5' LINE_NO,'강동' ST_NM,'서울특별시 강동구 천호대로 지하1097 (천호동)' ADDR, '02-6311-5480' PH_NO FROM DUAL UNION ALL
SELECT '5' LINE_NO,'길동' ST_NM,'서울특별시 강동구 양재대로 지하1480 (길동)' ADDR, '02-6311-5490' PH_NO FROM DUAL UNION ALL
SELECT '5' LINE_NO,'굽은다리(강동구민회관앞)' ST_NM,'서울특별시 강동구 양재대로 지하1572 (명일동)' ADDR, '02-6311-5500' PH_NO FROM DUAL UNION ALL
SELECT '5' LINE_NO,'명일' ST_NM,'서울특별시 강동구 양재대로 지하1632 (명일동)' ADDR, '02-6311-5510' PH_NO FROM DUAL UNION ALL
SELECT '5' LINE_NO,'고덕' ST_NM,'서울특별시 강동구 고덕로 지하253 (고덕동)' ADDR, '02-6311-5520' PH_NO FROM DUAL UNION ALL
SELECT '5' LINE_NO,'상일동' ST_NM,'서울특별시 강동구 고덕로 지하359 (상일동)' ADDR, '02-6311-5530' PH_NO FROM DUAL UNION ALL
SELECT '5' LINE_NO,'둔촌동' ST_NM,'서울특별시 강동구 양재대로 지하1369 (둔촌동)' ADDR, '02-6311-5540' PH_NO FROM DUAL UNION ALL
SELECT '5' LINE_NO,'올림픽공원(한국체대)' ST_NM,'서울특별시 송파구 양재대로 지하1233 (방이동)' ADDR, '02-6311-5550' PH_NO FROM DUAL UNION ALL
SELECT '5' LINE_NO,'방이' ST_NM,'서울특별시 송파구 양재대로 지하1127 (방이동)' ADDR, '02-6311-5560' PH_NO FROM DUAL UNION ALL
SELECT '5' LINE_NO,'오금' ST_NM,'서울특별시 송파구 오금로 지하321(오금동)' ADDR, '02-6311-3521' PH_NO FROM DUAL UNION ALL
SELECT '5' LINE_NO,'개롱' ST_NM,'서울특별시 송파구 오금로 지하402 (가락동)' ADDR, '02-6311-5580' PH_NO FROM DUAL UNION ALL
SELECT '5' LINE_NO,'거여' ST_NM,'서울특별시 송파구 오금로 지하499(거여동)' ADDR, '02-6311-5590' PH_NO FROM DUAL UNION ALL
SELECT '5' LINE_NO,'마천' ST_NM,'서울특별시 송파구 마천로57길 지하7(마천동)' ADDR, '02-6311-5600' PH_NO FROM DUAL UNION ALL
SELECT '6' LINE_NO,'응암' ST_NM,'서울특별시 은평구 증산로 지하477 (역촌동)' ADDR, '02-6311-6100' PH_NO FROM DUAL UNION ALL
SELECT '6' LINE_NO,'역촌' ST_NM,'서울특별시 은평구 서오릉로 지하63 (녹번동)' ADDR, '02-6311-6110' PH_NO FROM DUAL UNION ALL
SELECT '6' LINE_NO,'불광' ST_NM,'서울특별시 은평구 통일로 지하723-1 (대조동)' ADDR, '02-6311-6120' PH_NO FROM DUAL UNION ALL
SELECT '6' LINE_NO,'독바위' ST_NM,'서울특별시 은평구 불광로 지하129-1 (불광동)' ADDR, '02-6311-6130' PH_NO FROM DUAL UNION ALL
SELECT '6' LINE_NO,'연신내' ST_NM,'서울특별시 은평구 통일로 지하849 (갈현동)' ADDR, '02-6311-3211' PH_NO FROM DUAL UNION ALL
SELECT '6' LINE_NO,'구산' ST_NM,'서울특별시 은평구 연서로 지하137-1(구산동)' ADDR, '02-6311-6150' PH_NO FROM DUAL UNION ALL
SELECT '6' LINE_NO,'새절(신사)' ST_NM,'서울특별시 은평구 증산로 지하400 (신사동' ADDR, '02-6311-6160' PH_NO FROM DUAL UNION ALL
SELECT '6' LINE_NO,'증산(명지대앞)' ST_NM,'서울특별시 은평구 증산로 지하306(증산동)' ADDR, '02-6311-6170' PH_NO FROM DUAL UNION ALL
SELECT '6' LINE_NO,'디지털미디어시티' ST_NM,'서울특별시 은평구 수색로 지하175 (증산동)' ADDR, '02-6311-6180' PH_NO FROM DUAL UNION ALL
SELECT '6' LINE_NO,'월드컵경기장(성산)' ST_NM,'서울특별시 마포구 월드컵로 지하240 (성산동)' ADDR, '02-6311-6190' PH_NO FROM DUAL UNION ALL
SELECT '6' LINE_NO,'마포구청' ST_NM,'서울특별시 마포구 월드컵로 지하190 (성산동)' ADDR, '02-6311-6200' PH_NO FROM DUAL UNION ALL
SELECT '6' LINE_NO,'망원' ST_NM,'서울특별시 마포구 월드컵로 지하77 (망원동)' ADDR, '02-6311-6210' PH_NO FROM DUAL UNION ALL
SELECT '6' LINE_NO,'합정' ST_NM,'서울특별시 마포구 양화로 지하45 (합정동)' ADDR, '02-6311-6220' PH_NO FROM DUAL UNION ALL
SELECT '6' LINE_NO,'상수' ST_NM,'서울특별시 마포구 독막로 지하85 (상수동)' ADDR, '02-6311-6230' PH_NO FROM DUAL UNION ALL
SELECT '6' LINE_NO,'광흥창(서강)' ST_NM,'서울특별시 마포구 독막로 지하165 (창전동)' ADDR, '02-6311-6240' PH_NO FROM DUAL UNION ALL
SELECT '6' LINE_NO,'대흥(서강대앞)' ST_NM,'서울특별시 마포구 대흥로 지하85(대흥동)' ADDR, '02-6311-6250' PH_NO FROM DUAL UNION ALL
SELECT '6' LINE_NO,'공덕' ST_NM,'서울특별시 마포구 마포대로 지하100(공덕동)' ADDR, '02-6311-5290' PH_NO FROM DUAL UNION ALL
SELECT '6' LINE_NO,'효창공원앞' ST_NM,'서울특별시 용산구 백범로 지하287 (효창동)' ADDR, '02-6311-6270' PH_NO FROM DUAL UNION ALL
SELECT '6' LINE_NO,'삼각지' ST_NM,'서울특별시 용산구 한강대로 지하185 (한강로1가)' ADDR, '02-6311-6280' PH_NO FROM DUAL UNION ALL
SELECT '6' LINE_NO,'녹사평(용산구청)' ST_NM,'서울특별시 용산구 녹사평대로 지하195 (용산동4가)' ADDR, '02-6311-6290' PH_NO FROM DUAL UNION ALL
SELECT '6' LINE_NO,'이태원' ST_NM,'서울특별시 용산구 이태원로 지하177(이태원동)' ADDR, '02-6311-6300' PH_NO FROM DUAL UNION ALL
SELECT '6' LINE_NO,'한강진' ST_NM,'서울특별시 용산구 이태원로 지하287 (한남동)' ADDR, '02-6311-6310' PH_NO FROM DUAL UNION ALL
SELECT '6' LINE_NO,'버티고개' ST_NM,'서울특별시 중구 다산로 지하38 (신당동)' ADDR, '02-6311-6320' PH_NO FROM DUAL UNION ALL
SELECT '6' LINE_NO,'약수' ST_NM,'서울특별시 중구 다산로 지하115 (신당동)' ADDR, '02-6311-6330' PH_NO FROM DUAL UNION ALL
SELECT '6' LINE_NO,'청구' ST_NM,'서울특별시 중구 청구로 지하77(신당동)' ADDR, '02-6311-5370' PH_NO FROM DUAL UNION ALL
SELECT '6' LINE_NO,'신당' ST_NM,'서울특별시 중구 다산로 지하260 (흥인동)' ADDR, '02-6311-6350' PH_NO FROM DUAL UNION ALL
SELECT '6' LINE_NO,'동묘앞' ST_NM,'서울특별시 종로구 지봉로 지하24 (숭인동)' ADDR, '02-6311-6360' PH_NO FROM DUAL UNION ALL
SELECT '6' LINE_NO,'창신' ST_NM,'서울특별시 종로구 지봉로 지하112 (창신동)' ADDR, '02-6311-6370' PH_NO FROM DUAL UNION ALL
SELECT '6' LINE_NO,'보문' ST_NM,'서울특별시 성북구 보문로 지하116 (보문동1가)' ADDR, '02-6311-6380' PH_NO FROM DUAL UNION ALL
SELECT '6' LINE_NO,'안암(고대병원앞)' ST_NM,'서울특별시 성북구 인촌로 지하89 (안암동5가)' ADDR, '02-6311-6390' PH_NO FROM DUAL UNION ALL
SELECT '6' LINE_NO,'고려대(종암)' ST_NM,'서울특별시 성북구 종암로 지하1 (종암동)' ADDR, '02-6311-6400' PH_NO FROM DUAL UNION ALL
SELECT '6' LINE_NO,'월곡(동덕여대)' ST_NM,'서울특별시 성북구 월곡로 지하107 (하월곡동)' ADDR, '02-6311-6410' PH_NO FROM DUAL UNION ALL
SELECT '6' LINE_NO,'상월곡(한국과학기술연구원)' ST_NM,'서울특별시 성북구 화랑로 지하157 (상월곡동)' ADDR, '02-6311-6420' PH_NO FROM DUAL UNION ALL
SELECT '6' LINE_NO,'돌곶이' ST_NM,'서울특별시 성북구 화랑로 지하243 (석관동)' ADDR, '02-6311-6430' PH_NO FROM DUAL UNION ALL
SELECT '6' LINE_NO,'석계' ST_NM,'서울특별시 노원구 화랑로 지하347-1 (월계동)' ADDR, '02-6311-6440' PH_NO FROM DUAL UNION ALL
SELECT '6' LINE_NO,'태릉입구' ST_NM,'서울특별시 노원구 동일로 지하992-1 (공릉동)' ADDR, '02-6311-7170' PH_NO FROM DUAL UNION ALL
SELECT '6' LINE_NO,'화랑대(서울여대입구)' ST_NM,'서울특별시 노원구 화랑로 지하510 (공릉동)' ADDR, '02-6311-6460' PH_NO FROM DUAL UNION ALL
SELECT '6' LINE_NO,'봉화산(서울의료원)' ST_NM,'서울특별시 중랑구 신내로 지하232 (신내동)' ADDR, '02-6311-6470' PH_NO FROM DUAL UNION ALL
SELECT '7' LINE_NO,'장암' ST_NM,'경기도 의정부시 동일로 121 (장암동)' ADDR, '02-6311-7090' PH_NO FROM DUAL UNION ALL
SELECT '7' LINE_NO,'도봉산' ST_NM,'서울특별시 도봉구 도봉로 964-40 (도봉동)' ADDR, '02-6311-7100' PH_NO FROM DUAL UNION ALL
SELECT '7' LINE_NO,'수락산' ST_NM,'서울특별시 노원구 동일로 지하1662 (상계동)' ADDR, '02-6311-7110' PH_NO FROM DUAL UNION ALL
SELECT '7' LINE_NO,'마들' ST_NM,'서울특별시 노원구 동일로 지하1530-1 (상계동)' ADDR, '02-6311-7120' PH_NO FROM DUAL UNION ALL
SELECT '7' LINE_NO,'노원' ST_NM,'서울특별시 노원구 동일로 지하1409 (상계동)' ADDR, '02-6311-7130' PH_NO FROM DUAL UNION ALL
SELECT '7' LINE_NO,'중계' ST_NM,'서울특별시 노원구 동일로 지하1308-1 (중계동)' ADDR, '02-6311-7140' PH_NO FROM DUAL UNION ALL
SELECT '7' LINE_NO,'하계' ST_NM,'서울특별시 노원구 동일로 지하1196(하계동)' ADDR, '02-6311-7150' PH_NO FROM DUAL UNION ALL
SELECT '7' LINE_NO,'공릉(서울과학기술대)' ST_NM,'서울특별시 노원구 동일로 지하1074 (공릉동)' ADDR, '02-6311-7160' PH_NO FROM DUAL UNION ALL
SELECT '7' LINE_NO,'태릉입구' ST_NM,'서울특별시 노원구 동일로 지하992-1 (공릉동)' ADDR, '02-6311-7170' PH_NO FROM DUAL UNION ALL
SELECT '7' LINE_NO,'먹골' ST_NM,'서울특별시 중랑구 동일로 지하901(묵동)' ADDR, '02-6311-7180' PH_NO FROM DUAL UNION ALL
SELECT '7' LINE_NO,'중화' ST_NM,'서울특별시 중랑구 동일로 지하797 (중화동)' ADDR, '02-6311-7190' PH_NO FROM DUAL UNION ALL
SELECT '7' LINE_NO,'상봉(시외버스터미널)' ST_NM,'서울특별시 중랑구 망우로 지하297 (상봉동)' ADDR, '02-6311-7200' PH_NO FROM DUAL UNION ALL
SELECT '7' LINE_NO,'면목' ST_NM,'서울특별시 중랑구 면목로 지하407(면목동)' ADDR, '02-6311-7210' PH_NO FROM DUAL UNION ALL
SELECT '7' LINE_NO,'사가정' ST_NM,'서울특별시 중랑구 사가정로 지하393 (면목동)' ADDR, '02-6311-7220' PH_NO FROM DUAL UNION ALL
SELECT '7' LINE_NO,'용마산' ST_NM,'서울특별시 중랑구 용마산로 지하227(면목동)' ADDR, '02-6311-7230' PH_NO FROM DUAL UNION ALL
SELECT '7' LINE_NO,'중곡' ST_NM,'서울특별시 광진구 능동로 지하417 (중곡동)' ADDR, '02-6311-7240' PH_NO FROM DUAL UNION ALL
SELECT '7' LINE_NO,'군자(능동)' ST_NM,'서울특별시 광진구 천호대로 지하550 (능동) ' ADDR, '02-6311-5440' PH_NO FROM DUAL UNION ALL
SELECT '7' LINE_NO,'어린이대공원(세종대)' ST_NM,'서울특별시 광진구 능동로 지하210(화양동)' ADDR, '02-6311-7260' PH_NO FROM DUAL UNION ALL
SELECT '7' LINE_NO,'건대입구' ST_NM,'서울특별시 광진구 능동로 지하110 (화양동)' ADDR, '02-6311-7270' PH_NO FROM DUAL UNION ALL
SELECT '7' LINE_NO,'뚝섬유원지' ST_NM,'서울특별시 광진구 능동로 10 (자양동)' ADDR, '02-6311-7280' PH_NO FROM DUAL UNION ALL
SELECT '7' LINE_NO,'청담' ST_NM,'서울특별시 강남구 학동로 지하508 (청담동)' ADDR, '02-6311-7290' PH_NO FROM DUAL UNION ALL
SELECT '7' LINE_NO,'강남구청' ST_NM,'서울특별시 강남구 학동로 지하346 (삼성동)' ADDR, '02-6311-7300' PH_NO FROM DUAL UNION ALL
SELECT '7' LINE_NO,'학동' ST_NM,'서울특별시 강남구 학동로 지하180(논현동)' ADDR, '02-6311-7310' PH_NO FROM DUAL UNION ALL
SELECT '7' LINE_NO,'논현' ST_NM,'서울특별시 강남구 학동로 지하102(논현동)' ADDR, '02-6311-7320' PH_NO FROM DUAL UNION ALL
SELECT '7' LINE_NO,'반포' ST_NM,'서울특별시 서초구 신반포로 지하241(잠원동)' ADDR, '02-6311-7330' PH_NO FROM DUAL UNION ALL
SELECT '7' LINE_NO,'고속터미널' ST_NM,'서울특별시 서초구 신반포로 지하188 (반포동)' ADDR, '02-6311-7340' PH_NO FROM DUAL UNION ALL
SELECT '7' LINE_NO,'내방' ST_NM,'서울특별시 서초구 서초대로 지하103 (방배동)' ADDR, '02-6311-7350' PH_NO FROM DUAL UNION ALL
SELECT '7' LINE_NO,'이수' ST_NM,'서울특별시 동작구 사당로 지하310 (사당동)' ADDR, '02-6311-7360' PH_NO FROM DUAL UNION ALL
SELECT '7' LINE_NO,'남성' ST_NM,'서울특별시 동작구 사당로 지하218 (사당동)' ADDR, '02-6311-7370' PH_NO FROM DUAL UNION ALL
SELECT '7' LINE_NO,'숭실대입구(살피재)' ST_NM,'서울특별시 동작구 상도로 지하378(상도동)' ADDR, '02-6311-7380' PH_NO FROM DUAL UNION ALL
SELECT '7' LINE_NO,'상도' ST_NM,'서울특별시 동작구 상도로 지하272(상도1동)' ADDR, '02-6311-7390' PH_NO FROM DUAL UNION ALL
SELECT '7' LINE_NO,'장승배기' ST_NM,'서울특별시 동작구 상도로 지하188(상도동)' ADDR, '02-6311-7400' PH_NO FROM DUAL UNION ALL
SELECT '7' LINE_NO,'신대방삼거리' ST_NM,'서울특별시 동작구 상도로 지하76 (대방동)' ADDR, '02-6311-7410' PH_NO FROM DUAL UNION ALL
SELECT '7' LINE_NO,'보라매' ST_NM,'서울특별시 동작구 상도로 지하2(대방동)' ADDR, '02-6311-7420' PH_NO FROM DUAL UNION ALL
SELECT '7' LINE_NO,'신풍' ST_NM,'서울특별시 영등포구 신풍로 지하27(신길동)' ADDR, '02-6311-7430' PH_NO FROM DUAL UNION ALL
SELECT '7' LINE_NO,'대림(구로구청)' ST_NM,'서울특별시 영등포구 도림로 지하137(대림동)' ADDR, '02-6311-7440' PH_NO FROM DUAL UNION ALL
SELECT '7' LINE_NO,'남구로' ST_NM,'서울특별시 구로구 도림로 지하7(구로동)' ADDR, '02-6311-7450' PH_NO FROM DUAL UNION ALL
SELECT '7' LINE_NO,'가산디지털단지' ST_NM,'서울특별시 금천구 벚꽃로 309 (가산동)' ADDR, '02-6311-7460' PH_NO FROM DUAL UNION ALL
SELECT '7' LINE_NO,'철산' ST_NM,'경기도 광명시 철산로 지하13 (철산동)' ADDR, '02-6311-7470' PH_NO FROM DUAL UNION ALL
SELECT '7' LINE_NO,'광명사거리' ST_NM,'경기도 광명시 오리로 지하980(광명동)' ADDR, '02-6311-7480' PH_NO FROM DUAL UNION ALL
SELECT '7' LINE_NO,'천왕' ST_NM,'서울특별시 구로구 오리로 지하1154(오류동)' ADDR, '02-6311-7490' PH_NO FROM DUAL UNION ALL
SELECT '7' LINE_NO,'온수(성공회대입구)' ST_NM,'서울특별시 구로구 경인로3길 지하64 (온수동)' ADDR, '02-6311-7500' PH_NO FROM DUAL UNION ALL
SELECT '7' LINE_NO,'까치울' ST_NM,'경기도 부천시 원미구 길주로 지하626(춘의동)' ADDR, '02-6311-7510' PH_NO FROM DUAL UNION ALL
SELECT '7' LINE_NO,'부천종합운동장' ST_NM,'경기도 부천시 원미구 길주로 지하502(춘의동)' ADDR, '02-6311-7520' PH_NO FROM DUAL UNION ALL
SELECT '7' LINE_NO,'춘의' ST_NM,'경기도 부천시 원미구 길주로 지하406 (춘의동)' ADDR, '02-6311-7530' PH_NO FROM DUAL UNION ALL
SELECT '7' LINE_NO,'신중동' ST_NM,'경기도 부천시 원미구 길주로 지하314 (중동)' ADDR, '02-6311-7540' PH_NO FROM DUAL UNION ALL
SELECT '7' LINE_NO,'부천시청' ST_NM,'경기도 부천시 원미구 길주로 지하202(중동)' ADDR, '02-6311-7550' PH_NO FROM DUAL UNION ALL
SELECT '7' LINE_NO,'상동' ST_NM,'경기도 부천시 원미구 길주로 지하104(상동)' ADDR, '02-6311-7560' PH_NO FROM DUAL UNION ALL
SELECT '7' LINE_NO,'삼산체육관' ST_NM,'인천광역시 부평구 길주로 지하713 (삼산동)' ADDR, '02-6311-7570' PH_NO FROM DUAL UNION ALL
SELECT '7' LINE_NO,'굴포천' ST_NM,'인천광역시 부평구 길주로 지하623 (삼산동)' ADDR, '02-6311-7580' PH_NO FROM DUAL UNION ALL
SELECT '7' LINE_NO,'부평구청' ST_NM,'인천광역시 부평구 길주로 지하527(갈산동)' ADDR, '02-6311-7590' PH_NO FROM DUAL UNION ALL
SELECT '8' LINE_NO,'암사' ST_NM,'서울특별시 강동구 올림픽로 지하776 (암사동)' ADDR, '02-6311-8100' PH_NO FROM DUAL UNION ALL
SELECT '8' LINE_NO,'천호(풍납토성)' ST_NM,'서울특별시 강동구 천호대로 지하997 (천호동)' ADDR, '02-6311-5470' PH_NO FROM DUAL UNION ALL
SELECT '8' LINE_NO,'강동구청' ST_NM,'서울특별시 강동구 올림픽로 지하550 (성내동)' ADDR, '02-6311-8120' PH_NO FROM DUAL UNION ALL
SELECT '8' LINE_NO,'몽촌토성(평화의문)' ST_NM,'서울특별시 송파구 올림픽로 지하383 (신천동)' ADDR, '02-6311-8130' PH_NO FROM DUAL UNION ALL
SELECT '8' LINE_NO,'잠실(송파구청)' ST_NM,'서울특별시 송파구 올림픽로 305 (신천동)' ADDR, '02-6311-8140' PH_NO FROM DUAL UNION ALL
SELECT '8' LINE_NO,'석촌' ST_NM,'서울특별시 송파구 송파대로 지하439(석촌동)' ADDR, '02-6311-8150' PH_NO FROM DUAL UNION ALL
SELECT '8' LINE_NO,'송파' ST_NM,'서울특별시 송파구 송파대로 지하354 (가락동)' ADDR, '02-6311-8160' PH_NO FROM DUAL UNION ALL
SELECT '8' LINE_NO,'가락시장' ST_NM,'서울특별시 송파구 송파대로 지하257(가락동)' ADDR, '02-6311-8170' PH_NO FROM DUAL UNION ALL
SELECT '8' LINE_NO,'문정' ST_NM,'서울특별시 송파구 송파대로 지하179 (문정동)' ADDR, '02-6311-8180' PH_NO FROM DUAL UNION ALL
SELECT '8' LINE_NO,'장지' ST_NM,'서울특별시 송파구 송파대로 지하82(장지동)' ADDR, '02-6311-8190' PH_NO FROM DUAL UNION ALL
SELECT '8' LINE_NO,'복정' ST_NM,'서울특별시 송파구 송파대로 지하6 (장지동)' ADDR, '02-6311-8200' PH_NO FROM DUAL UNION ALL
SELECT '8' LINE_NO,'산성' ST_NM,'경기도 성남시 수정구 수정로 지하365(신흥동)' ADDR, '02-6311-8210' PH_NO FROM DUAL UNION ALL
SELECT '8' LINE_NO,'남한산성입구(성남법원,검찰청)' ST_NM,'경기도 성남시 수정구 산성대로 지하445 (단대동) ' ADDR, '02-6311-8220' PH_NO FROM DUAL UNION ALL
SELECT '8' LINE_NO,'단대오거리' ST_NM,'경기도 성남시 수정구 산성대로 지하365 (신흥동)' ADDR, '02-6311-8230' PH_NO FROM DUAL UNION ALL
SELECT '8' LINE_NO,'신흥' ST_NM,'경기도 성남시 수정구 산성대로 지하280 (신흥동)' ADDR, '02-6311-8240' PH_NO FROM DUAL UNION ALL
SELECT '8' LINE_NO,'수진' ST_NM,'경기도 성남시 수정구 산성대로 지하200 (수진동)' ADDR, '02-6311-8250' PH_NO FROM DUAL UNION ALL
SELECT '8' LINE_NO,'모란' ST_NM,'경기도 성남시 수정구 산성대로 100(수진동)' ADDR, '02-6311-8260' PH_NO FROM DUAL ;
가져온 주소, 전화번호 정보를 M_Station 테이블에 업데이트해줍니다. 그런데, 라인명(호선)이 이전에 승하차 정보에서 가져온 정보와 일치하지 않습니다. 아래와 같이 업로드한 테이블의 라인명 뒤에 ‘호선’을 붙여서 업데이트 합니다.
-- 주소, 전화번호 업데이트 1단계
UPDATE M_Station T1
INNER JOIN U_StationAddInfo T2
ON (T1.StationName = T2.ST_NM
AND T1.LineName = CONCAT(T2.LINE_NO,'호선'))
SET T1.Address = T2.ADDR
,T1.PhoneNo = T2.PH_NO;
위와 같이 업데이트를 하면 4건을 제외한 모든 지하철역의 주소가 들어갑니다. 나 머지 네 건은 지하철역 이름이 서로 같지 않기 때문입니다. 아래 SQL로 추가 업데이트를 합니다
-- 주소, 전화번호 업데이트 2단계
UPDATE M_Station T1
INNER JOIN U_StationAddInfo T2
ON (SUBSTRING(T1.StationName,1,2) = SUBSTRING(T2.ST_NM,1,2)
AND T1.LineName = CONCAT(T2.LINE_NO,'호선')
AND T1.Address = '')
SET T1.Address = T2.ADDR
,T1.PhoneNo = T2.PH_NO;
!! 데이터를 올리고 살펴보다 보니, 빠진 지하철 역이 있습니다. 1호선의 경우에는 영등포역, 창동역 등 많이 누락되어 있습니다. 어떤 이유인지 모르겠습니다. 정확한 분석을 위해서 마스터는 완벽하게 맞추어져 있어야 하는데.. 그렇지가 않네요. 그래도 감사하고 일단은 쓰도록 합니다. 혹시 모르니 공공 데이터 포털에 오류신고를 해났습니다.!!!
설명의 편의상 반말체로 작성한 점 양해바랍니다. pdf 파일도 첨부드리니 다운 받아 보셔도 됩니다.
오늘은 통합된 ‘업로드 마스터 테이블’을 간단히 살펴보도록 하겠다.(정말 간단히 살펴볼 것이다.) ‘업로드 마스터’ 테이블이다. 다양한 업로드 양식을 통합한 테이블이 아니다.
결과부터 보면 아래와 같이 업로드 마스터 테이블을 설계할 수 있다.
ERD의 가운데 있는 테이블이 바로 통합된 업로드 마스터 테이블이다. 엑셀이나 파일의 데이터를 시스템에 올리기 위해서는 공통적으로 관리해야 하는 컬럼들이 있다. 바로 그러한 컬럼을 모아서 업로드 마스터를 만든다. 특별히 설명하지 않아도 쉽게 이해할 수 있겠지만 특징 있는 컬럼 몇 개만 설명하고 넘어가도록 하겠다.
- 업로드시퀀스: 업로드를 실행하면 부여되는 시퀀스 값이다. - 파일명/파일경로: 업로드 작업자에게 어떤 파일을 올렸는지 정보를 제공하기 위해서 관리한다. - 업로드건수/에러건수: 처리된 건수를 저장한다. 업로드 목록을 보여줄 때 처리 건수를 보여달라는 업무 요건이 있을 때, 실제 업로드를 수행한 테이블을 접근해서 보여주기에는 성능 이슈가 있다. - 업로드테이블: 업로드 파일의 내용이 실제 저장된 테이블명을 보관한다. 그래야만, 테이블을 보고 실제 업로드한 데이터가 어느 테이블에 있는지 쉽게 찾을 수 있다.
작업 프로세스는 간단하다. 다음과 같다.
이와 같이 통합된 형태의 테이블은 다양한 업무에 사용된다. 기업에는 각종 결제가 있다. 다양한 결제가 있지만 프로세스는 거의 동일하다. 그러므로 결제 마스터 역시 통합된 테이블 구조로 설계가 가능하다.
각종 인터페이스도 이와 같이 설계가 가능하다. 다양한 시스템에서 다양한 데이터를 인터페이스 받는 시스템이라면 이와 같이 통합된 인터페이스 마스터 테이블을 설계해 유용하게 사용할 수 있다. 인터페이스에 맞는 From시스템, To시스템, 인터페이스 유형 등을 추가로 관리하기만 하면 된다.
오늘은 여기까지입니다. 정말 짧게 살펴보고 마무리했습니다. 필요한 업무에 적절히 통합된 마스터 테이블을 활용할 수 있으시기 바랍니다.
시스템을 개발해 보면, 사용자의 편의를 위해 엑셀 업로드 기능을 개발해야 할 때가 있다. 이때, 소량의 데이터를 올리는 경우라면 큰 문제가 없지만, 대량의 데이터를 업로드 해야 한다면 정교하게 프로그램을 개발해야 한다. 프로그램 코드 부분에서도 성능을 고려해야 하지만 데이터베이스에 던지는 SQL의 부하를 줄이는 것이 매우 중요하다.
엑셀 업로드를 구현할 때, 빠질 수 없는 것이 바로 데이터의 정확성을 확인하는 로직이다. 중복된 데이터가 있거나, 잘 못된 코드 값들이 입력되는 경우를 확인해서 업로드 되지 않도록 해야 한다. 이러한 데이터 체크 로직을 데이터베이스를 거치지 않고 확인할 수 있다면 좋겠지만 절대 그럴 수가 없다.
SQL BOOSTER 에 이어지는 이야기들입니다.~! SQL BOOSTER 를 보신 분들께 좀 더 도움을 드리고자 추가로 작성한 내용입니다.
여기서는 ‘이어지는 이야기 10’에서 새로 생성했던 M_SN(시리얼 마스터) 테이블을 사용해 엑셀 업로드 하는 과정을 설명하도록 하겠다.
먼저 최악의 방법을 살펴보자. 아래와 같다.
엑셀 데이터를 한 건씩 읽으면서 데이터베이스를 호출해 데이터 점검을 수행하는 방식이다. 엑셀 한 건을 위해 데이터베이스를 여섯 번 호출해야 한다. 한 번 호출에 0.01초가 걸린다고 가정했을 때, 엑셀 한 건 처리에 0.06초가 걸린다. 한 건 처리에 느린 속도는 아니다. 하지만 올리고자 하는 데이터가 만 건이라면 600초의 시간이 필요하다. 또한 업로드하려는 컬럼이 많아지고 체크 로직이 많아질수록 속도는 더 느려지게 될 것이다.
위의 체크 로직중에 시리얼번호 자릿수와 생산일자 포맷 등은 데이터베이스를 거치지 않고 프로그램 소스 레벨에서도 점검이 가능할 수 있다. 하지만 어떤 로직은 데이터베이스를 통해서, 어떤 로직은 프로그램 로직에서 처리하는 것은 프로그램 소스 관리에 혼란함을 더해주기도 한다.(그럼에도 불구하고 성능을 위해서라면 나누어 처리하는 것이 좋다.)
위의 방법은 올리고자 하는 데이터 중에 잘 못된 데이터가 있을 때도 문제다. 만약에 잘못된 데이터는 빼고 업로드 처리해야 한다면, 업로드에 실패한 데이터를 어떻게 보여줄지 고민해야 한다.
필자가 추천하는 엑셀 업로드의 데이터 체크 로직은 다음과 같다.
중간에 업로드용 테이블을 만들고, 업로드용 테이블을 이용해 업로드 된 단위로 한 번에 데이터를 체크하고, 체크 완료된 데이터만 M_SN에 밀어 넣는 방식이다. 만 건 이상의 데이터가 업로드 된다고 해도, ‘INSERT – 업로드용 테이블’ 시점 외에는 데이터베이스를 반복해서 콜 할 필요가 없다. 그렇기 때문에 매우 빠르게 데이터 정확성을 점검하고 업로드를 처리할 수 있다.
실습을 위해 아래와 같은 업로드용 테이블을 만들 예정이다.
아래 SQL로 테이블을 생성하자.
[SQL-1] M_SN_UP
CREATE TABLE M_SN_UP
(
UP_SEQ NUMBER(9) NOT NULL
,ROW_NO NUMBER(9) NOT NULL
,SN VARCHAR2(100) NULL
,PRD_YMD VARCHAR2(100) NULL
,ITM_ID VARCHAR2(100) NULL
,ITM_TP VARCHAR2(100) NULL
,UP_ERR_ID VARCHAR2(100) NULL
,SYS_REG_DTM DATE
,SYS_REG_UID VARCHAR2(40) NULL
,SYS_CHG_DTM DATE
,SYS_CHG_UID VARCHAR2(40) NULL
);
ALTER TABLE M_SN_UP ADD CONSTRAINT PK_M_SN_UP PRIMARY KEY(UP_SEQ, ROW_NO);
테스트를 위해 M_SN_UP에 데이터를 입력하도록 하자. 아래 SQL을 사용한다. 아래 SQL을 실행하면 M_SN_UP에 102건의 데이터가 만들어진다.
[SQL-2] M_SN_UP 테스트 데이터 생성
INSERT INTO M_SN_UP (UP_SEQ, ROW_NO, SN, PRD_YMD, ITM_ID, ITM_TP)
SELECT 1 UP_SEQ
,ROWNUM ROW_NO
,'E'||LPAD(ROWNUM,15,'X') SN
,CASE WHEN ROWNUM <= 10 THEN '20200230' ELSE '20200212' END PRD_YMD
,CASE WHEN ROWNUM BETWEEN 11 AND 20 THEN 'ITMXXX' ELSE 'ITM080' END ITM_ID
,CASE WHEN ROWNUM BETWEEN 21 AND 30 THEN '' ELSE 'ELEC' END ITM_TP
FROM DUAL
CONNECT BY ROWNUM <= 100
UNION ALL
SELECT 1 UP_SEQ ,101 ROW_NO ,'E'||LPAD(100,15,'X') SN ,'20200212' PRD_YMD,'ITM080' ITM_ID,'ELEC' ITM_TP FROM DUAL
UNION ALL
SELECT 1 UP_SEQ, 102 ROW_NO ,MIN(SN) SN ,'20200212' PRD_YMD ,'ITM080' ITM_ID ,'ELEC' ITM_TP
FROM M_SN
WHERE ITM_ID = 'ITM080'
;
COMMIT;
위 SQL에서 UP_SEQ는 모두 1로 설정했다. 102건을 한 번에 묶어 에러 체크 할 수 있도록 한 것이다. ROW_NO는 1부터 102까지의 숫자를 갖는다. 실제 엑셀 파일을 이용했다면 엑셀의 줄번호가 여기에 해당한다. ROW_NO가 1~10번째 데이터는 생산일자를 일부로 2월30일로 설정했다. 에러 데이터로 만든 것이다. 마찬가지로 11~20번째 데이터는 아이템ID를 ITMXXX로, 21~30번째 데이터는 아이템유형에 빈 값을 입력했다. 그리고 11~12 라인의 ROW_NO=101은 업로드 데이터 중 중복 SN을 만들고 있다. 마지막으로 14~16 라인은 M_SN에 이미 있는 SN을 입력하고 있다.
에러 체크하는 SQL을 만들어보자. 위 102건에 대해 단 여섯 개의 SQL을 실행하면 된다.
(1) 시리얼번호 존재 체크
[SQL-3] 시리얼번호는 존재하는가?
UPDATE M_SN_UP T1
SET T1.UP_ERR_ID = '이미존재하는SN'
WHERE T1.UP_SEQ = 1
AND T1.UP_ERR_ID IS NULL
AND EXISTS(
SELECT *
FROM M_SN A
WHERE A.SN = T1.SN);
업로드 할 SN 중에 M_SN에 존재하는지는 위 SQL로 확인 가능하다. UP_SEQ=1인 102건의 데이터를 모두 한 번에 처리했다. 위 SQL을 실행하면 102건 중의 한 건의 데이터에 업데이트가 발생한다. 이해의 편의를 위해 UP_ERR_ID의 값을 한글로 설정했다.(다국어 및 표준화를 위해서 메시지 테이블과 데이터를 설정한 후 해당 메시지ID를 사용하는 것이 좋다.) 위 SQL을 실행한 후에 COMMIT을 하지 않는다. 데이터 체크 SQL을 모두 완료하고 M_SN에 정상 데이터를 밀어 넣은 후에 COMMIT을 해야 한다.
(2) 업로드 데이터내에 중복 확인
두 번째, 업로드하려는 데이터내에 중복을 확인해 처리하는 SQL은 다음과 같다.
[SQL-4] 시리얼번호가 중복되는가?
UPDATE M_SN_UP T1
SET T1.UP_ERR_ID = '중복SN업로드'
WHERE T1.UP_SEQ = 1
AND T1.UP_ERR_ID IS NULL
AND EXISTS(
SELECT *
FROM M_SN_UP A
WHERE A.UP_SEQ = 1
AND A.UP_ERR_ID IS NULL
AND A.SN = T1.SN
AND A.ROW_NO != T1.ROW_NO);
위 SQL을 실행하면 두 건이 업데이트된다. ROW_NO 100, 101번이 같이 SN이기 때문이다. 이때, 4번, 9번 라인과 같이 UP_ERR_ID가 NULL인 데이터만 대상으로 해야 한다. 이미 이전 과정에서 에러로 처리된 데이터를 다시 점검할 필요가 없기 때문이다.
(3) 시리얼번호 자릿수가 맞는가?
[SQL-5] 시리얼번호가 번호 자릿수 점검
UPDATE M_SN_UP T1
SET T1.UP_ERR_ID = 'SN길이오류'
WHERE T1.UP_SEQ = 1
AND T1.UP_ERR_ID IS NULL
AND (
(T1.ITM_TP = 'ELEC' AND LENGTH(T1.SN) != 16)
OR
(T1.ITM_TP = 'PC' AND LENGTH(T1.SN) != 17)
);
아이템유형별로 사용하는 SN길이를 이용해 에러 점검을 한다. 위 SQL로 업데이트되는 데이터는 없다. 업로드 하려는 데이터의 SN 길이는 모두 문제가 없는 것이다.
(4) 생산일자 포맷이 맞는가?
[SQL-6] 생산일자 포맷이 맞는가?
UPDATE M_SN_UP T1
SET T1.UP_ERR_ID = '날짜형식이 맞지 않습니다.'
WHERE T1.UP_SEQ = 1
AND T1.UP_ERR_ID IS NULL
AND NOT EXISTS(
SELECT *
FROM C_BAS_YMD A
WHERE A.BAS_YMD = T1.PRD_YMD);
‘이어지는 이야기 .03’에서 만들었던 기준일자(C_BAS_YMD) 테이블을 사용하면 일자 유효성을 쉽게 체크할 수 있다.
(5) 존재하는 아이템인가?
아이템 테이블을 이용해 존재하는 아이템인지 확인하면 된다.
[SQL-7] 존재하는 아이템인가?
UPDATE M_SN_UP T1
SET T1.UP_ERR_ID = '아이템ID가 맞지 않습니다.'
WHERE T1.UP_SEQ = 1
AND T1.UP_ERR_ID IS NULL
AND NOT EXISTS(
SELECT *
FROM M_ITM A
WHERE A.ITM_ID = T1.ITM_ID);
(6) 아이템유형이 맞는가?
마찬가지로 아이템 테이블을 이용한다. 아이템ID는 갖지만 아이템유형이 다른 경우를 찾아내면 된다. NOT EXISTS가 아니라 EXISTS인 점에 유의해야 한다. 업로드한 데이터에 ITM_TP가 NULL인 데이터가 있으므로 NVL로 치환해서 비교하도록 한다.
[SQL-8] 존재하는 아이템 유형인가?
UPDATE M_SN_UP T1
SET T1.UP_ERR_ID = '아이템유형이 맞지 않습니다.'
WHERE T1.UP_SEQ = 1
AND T1.UP_ERR_ID IS NULL
AND EXISTS(
SELECT *
FROM M_ITM A
WHERE A.ITM_ID = T1.ITM_ID
AND A.ITM_TP != NVL(T1.ITM_TP,'-'));
모든 에러 점검을 맞쳤다. 102건의 데이터를 루프를 돌면서 한 건씩 에러 점검하지 않고 한 번에 모두 처리했다. 아래 SQL로 처리 결과를 알 수 있다.
[SQL-9] 처리결과 조회
SELECT T1.UP_ERR_ID, COUNT(*)
FROM M_SN_UP T1
WHERE T1.UP_SEQ = 1
GROUP BY T1.UP_ERR_ID
ORDER BY T1.UP_ERR_ID;
위 SQL을 실행하면, 에러가 없는 SQL이 총 69건이 나온다. 업무 규칙에 따라, 에러가 한 건이라도 있으면 업로드 자체를 금지할 수도 있고, 에러는 제외하고 업로드 할 수도 있다. 여기서는 에러는 제외하고 M_SN 테이블에 밀어 넣도록 하자. 아래와 같다.
[SQL-10] M_SN에 INSERT 및 COMMIT
INSERT INTO M_SN(SN ,PRD_YMD ,ITM_ID ,ITM_TP)
SELECT T1.SN ,T1.PRD_YMD ,T1.ITM_ID ,T1.ITM_TP
FROM M_SN_UP T1
WHERE T1.UP_SEQ = 1
AND T1.UP_ERR_ID IS NULL;
COMMIT;
여기서 살펴본 방법은 여러 건을 한 번에 처리하기 때문에, 루프 방식으로 한 건씩 실행하는 것보다 틀림없이 성능이 좋다고 장담한다. (물론 처리하려는 건수가 매우 적다면 이 같은 방법이 큰 이득은 없다.)
[SQL-1] 고객의 주문 건수 조회.(주문 일자는 전체일 수도 있다.)
SELECT COUNT(*) ORD_CNT
FROM T_ORD_JOIN T1
WHERE T1.CUS_ID LIKE :v_CUS_ID||'%'
AND T1.ORD_YMD LIKE :v_ORD_YMD||'%';
위 SQL에서는 CUS_ID와 ORD_YMD에 모두 LIKE 조건을 사용했다. 그러므로 인덱스 설계에 고민하게 된다. 업무를 가만히 생각해 보면, ‘고객이 접속해서 자신의 주문 건수를 조회’하는 것이다. 그러므로 고객ID(CUS_ID)는 빈 값이 절대 들어 올 수 없다. 아래와 같이 CUS_ID에는 같다(=) 조건을 사용해야 한다.
[SQL-2] 고객의 주문 건수 조회. (주문 일자는 전체일 수도 있다.) – 같다(=) 조건 사용
SELECT COUNT(*) ORD_CNT
FROM T_ORD_JOIN T1
WHERE T1.CUS_ID = :v_CUS_ID
AND T1.ORD_YMD LIKE :v_ORD_YMD||'%';
위 SQL은 CUS_ID+ORD_YMD 순서의 인덱스를 만들면 된다. 인덱스 설계에 고민이 덜하다. 부디 습관적으로 LIKE 조건을 사용하는 일은 없기 바란다.
‘LIKE 사용은 최대한 자제하자!’ 이것은 필자의 개인적인 의견이다. 인덱스를 효율적으로 설계해 SQL의 성능을 높일 수 있기 때문이다.
LIKE 사용으로 발생하는 문제점을 하나 더 살펴보자. 이번 설명을 위해서 M_SN(시리얼번호)이라는 새로운 테이블을 만들 것이다. M_SN 테이블은 아이템유형(ITM_TP)가 PC, ELEC 인 아이템의 시리얼번호를 관리한다. 이 테이블을 통해 LIKE 사용에 주의할 점을 알아볼 것이다.
M_SN 테이블은 ‘SQL BOOSTER’를 처음 작성할 때 이미 생각해서 설계했던 테이블이다. (그때는 SQL BOOSTER라는 이름이 탄생하기 전이다.) 하지만 해당 테이블을 사용한 활용 예제들을 충분히 담기가 쉽지 않아 결국 생략하게 되었다. 다행히도 이어지는 이야기를 통해 소개할 수 있게 되었다. 새로 만들 테이블은 아래와 같은 구조다.
그림 1
아래 스크립트로 M_SN 테이블을 생성하도록 하자.
[SQL-3] M_SN 테이블 만들기
CREATE TABLE M_SN
(SN VARCHAR2(40) NOT NULL
,PRD_YMD VARCHAR2(8) NULL
,SHP_YMD VARCHAR2(8) NULL
,ITM_ID VARCHAR2(40) NOT NULL
,ITM_TP VARCHAR2(40) NOT NULL);
ALTER TABLE M_SN
ADD CONSTRAINT PK_M_SN PRIMARY KEY(SN);
아래 스크립트를 이용해 M_SN 테이블에 데이터를 생성한다.
[SQL-4] M_SN 데이터 만들기
INSERT INTO M_SN (SN ,PRD_YMD ,SHP_YMD ,ITM_ID ,ITM_TP)
SELECT CASE WHEN T1.ITM_TP = 'ELEC' THEN 'E' ELSE 'P' END||LPAD(T3.RNO,3,'0')
||LPAD(T2.T_NO,4,T3.SN_ADD)
||LPAD(ROW_NUMBER() OVER(PARTITION BY T1.ITM_TP ORDER BY T2.BAS_YMD, T1.ITM_ID, T3.RNO),7,'0')
||SN_ADD
||CASE WHEN T1.ITM_TP != 'ELEC' THEN TO_CHAR(T2.YMD_NO) ELSE '' END SN
,T2.BAS_YMD PRD_YMD
,TO_CHAR(TO_DATE(T2.BAS_YMD,'YYYYMMDD')
+ MOD(TO_NUMBER(REGEXP_REPLACE(T1.ITM_ID,'([^[:digit:]])','')),8),'YYYYMMDD') SHP_YMD
,T1.ITM_ID
,T1.ITM_TP
FROM M_ITM T1
,(
SELECT A.BAS_YMD
,MOD(ROW_NUMBER() OVER(ORDER BY A.BAS_YMD ASC),4) YMD_NO
,ROW_NUMBER() OVER(ORDER BY TO_NUMBER(SUBSTR(REVERSE(A.BAS_YMD),1,5))) T_NO
FROM C_BAS_YMD A
WHERE A.BAS_YMD >= '20190101'
AND A.BAS_YMD <= '20200120'
) T2,
(
SELECT ROWNUM RNO
,CHR(MOD(ROWNUM, 8)+65) SN_ADD
FROM DUAL CONNECT BY ROWNUM <= 100
) T3
WHERE T1.ITM_TP IN ('ELEC','PC')
AND MOD(TO_NUMBER(REGEXP_REPLACE(T1.ITM_ID,'([^[:digit:]])','')),4) <= T2.YMD_NO
;
COMMIT;
위 SQL은 카테시안-조인을 이용해 총 721,500개의 시리얼 번호를 만들어낸다. 카테시안-조인을 활용해 테스트 데이터를 만드는 과정은 SQL BOOSTER에 설명되어 있다. 위 SQL에서는 REGEXP_REPLACE라는 정규식 함수를 사용하고 있다. REGEXP_REPLACE를 이용해 숫자가 아닌 데이터([^[:digit:]])는 빈 값으로 치환하고 있다.
오라클은 REGEXP_REPLACE 외에도 다양한 정규식 함수를 제공한다. 정희락님의 ‘불친절한 SQL 프로그래밍’에 설명이 되어 있으니 한 번 찾아보기 바란다. 이러한 정규식 함수는 개발자에게도 유용하지만, 데이터 클린징이나 이행을 담당해야 하는 사람에게도 매우 유용하다.
시리얼번호가 존재하는지 확인하는 SQL을 만들어보자. 아래와 같다.
[SQL-5] SN 조회 - 16자리 시리얼번호와 아이템유형을 입력 받는다.
SELECT T1.*
FROM M_SN T1
WHERE T1.SN = 'E018CC400000418C'
AND T1.ITM_TP = 'ELEC';
문제없이 시리얼번호가 조회된다. 이번에는 PC제품을 조회해보자. 아래와 같다.
[SQL-6] SN 조회 - 16자리 시리얼번호와 아이템유형을 입력 받는다.(PC제품 조회)
SELECT *
FROM M_SN T1
WHERE T1.SN = 'P080AA400000680A'
AND T1.ITM_TP = 'PC';
위 SQL을 실행하면 조회되는 데이터가 없다. 위 SQL에서 사용한 변수('P080AA400000680A')는 16자리다. 앞에서 M_SN에 데이터를 만들 때 의도적으로 ITM_TP(아이템유형)가 ELEC은 16자리, PC 는 17자리로 구성했다. 그러므로 PC아이템을 같다(=) 조건으로 조회하려면 17자리 SN을 입력해야 한다. 만약에 ‘SN 조회 화면’에서 17자리를 모두 입력 받을 수 있다면 위 SQL은 문제가 없을 것이다. 여기서는 PC 제품도 ‘SN 조회 화면’에서 16자리까지만 입력할 수 있다고 가정한다.
무조건 16자리만 입력된다고 가정했을 때, M_SN에서 PC 제품을 조회하기 위해 아래와 같은 SQL을 고민해 볼 수 있을 것이다.
[SQL-7] SN 조회 - SUBSTR 사용
SELECT *
FROM M_SN T1
WHERE SUBSTR(T1.SN,1,16) = 'P080AA400000680A'
AND T1.ITM_TP = 'PC';
위 SQL은 최악이다.SQL BOOSTER의 독자라면 절대 위와 같이 SQL을 개발하는 일은 없기 바란다. SUBSTR 대신에 아래와 같이 LIKE를 사용하면 16자리를 이용해 SN을 조회할 수 있다.
[SQL-8] SN 조회 - LIKE 사용
SELECT *
FROM M_SN T1
WHERE T1.SN LIKE 'P080AA400000680A'||'%'
AND T1.ITM_TP = 'PC';
위 SQL을 사용하면, ELEC과 PC 아이템 유형의 SN 조회에 모두 사용할 수 있다. 성능에 있어서 같다(=) 조건보다 아주 약간의 손해가 있지만, 신경 쓰지 않아도 될 정도다.
과연, 여기서 안심하고 개발을 마무리하면 될 것인가? 이 글의 제목은 “잘 되던 LIKE도 다시 보자”다. 프로그램 오류로 시리얼 번호가 16자리가 입력되지 않고, 3자리만 입력되었다고 생각해보자. 아래와 같이 말이다.
[SQL-9] SN 존재 확인 SQL LIKE – 프로그램 오류로 3자리만 입력됨
SELECT *
FROM M_SN T1
WHERE T1.SN LIKE 'P08'||'%'
AND T1.ITM_TP = 'PC';
위 SQL은 P08로 시작하는 모든 시리얼번호를 조회하게 된다. 성능에 문제가 있을 수 밖에 없다. 성능 부하를 고려해 ROWNUM = 1 조건을 추가하는 것을 고려해 볼 수 있다. 아래와 같이 말이다.
[SQL-10] SN 존재 확인 SQL LIKE, ROWNUM – 프로그램 오류로 3자리만 입력됨
SELECT *
FROM M_SN T1
WHERE T1.SN LIKE 'P08'||'%'
AND T1.ITM_TP = 'PC'
AND ROWNUM = 1;
[SQL-10]은 한 건의 데이터만 조회되므로 성능의 문제는 없겠지만, 원하는 결과가 아니다. 성능보다는 데이터의 정확성이 먼저다.
길이가 짧은 변수로 성능 저하가 발생하지 않게 하기 위해 아래와 같은 SQL을 고려할 수 있다.
[SQL-11] SN 존재 확인 SQL LIKE, LENGTH – 프로그램 오류로 3자리만 입력됨
SELECT *
FROM M_SN T1
WHERE T1.SN LIKE 'P08'||'%'
AND T1.ITM_TP = 'PC'
AND LENGTH('P08') = 16; -- 입력된 변수가 16자리일때만 SQL이 처리되도록 한다.
위와 같이 WHERE 절에 입력된 변수의 길이를 체크하는 조건을 추가하는 것이다. 이처럼 SQL을 작성하면 입력된 변수의 길이가 맞지 않으면 데이터 자체를 뒤질 일도 없다.
물론, 프로그램적으로 길이가 짧은 변수가 입력되는 일이 없도록 해주는 것이 기본이다. 하지만 개발하다 보면 어디선가 실수가 나게 된다. 프로그램과 함께 SQL에 위의 조건을 추가해 혹시 모를 오류를 한 번 더 막을 수 있다.
시리얼번호가 모두 16자리로 관리되고 있다면 이와 같은 고민을 할 필요조차 없다. 필자의 경험으로는, 업무는 계속 변하고, 만들어 놓은 규칙도 계속 변한다. 변하는 부분을 모델에 모두 반영하고 개발한 SQL에 제대로 제때 반영하는 작업은 만만하지 않다. 이러한 과정에서 실수가 발생하고, 이로 인해 성능 문제를 일으키는 SQL들이 만들어진다. 여기서 살펴본 예제를 통해 LIKE를 사용하기 전에 한 번 더 생각해 보기 바란다. 성능에 이슈가 없을지 말이다.
SQL BOOSTER 본서 Chapter. 11에는 ‘SQL 개발 가이드’가 실려있다. Chapter. 11의 목차를 살펴보면 다음과 같다.
11.1 WHERE 절 가이드
11.1.1 WHERE 절의 컬럼은 변형하지 않는다
11.1.2 날짜 조건 처리하기
11.1.3 조건 값은 컬럼과 같은 자료형을 사용한다
11.1.4 NOT IN 보다는 IN을 사용한다
11.1.5 불필요한 LIKE는 제거하자
11.2 불필요한 부분 제거하기
11.2.1 불필요한 COUNT는 하지 않는다
11.2.2 COUNT에 불필요한 부분은 제거한다
11.2.3 불필요한 컬럼은 사용하지 않는다
11.2.4 동일 테이블의 반복 서브쿼리를 제거하자
11.3 생각의 전환
11.3.1 사용자 함수 사용의 최소화
11.3.2 작업량을 줄이자
11.3.3 집계 테이블을 고민하자
실제 프로젝트에서는 이 보다 더 많은 내용이 SQL 개발 가이드로 구성된다. (위 목차 중에 11.3.2와 11.3.3은 SQL 개발 가이드에 포함 할지 많은 고민이 있었다. 별도의 Chapter로 구성하기에는 내용이 짧고 가이드에 포함시켜도 큰 무리가 없다 생각되어 가이드에 포함했다.)
SQL BOOSTER 에 이어지는 이야기들입니다.~! SQL BOOSTER 를 보신 분들께 좀 더 도움을 드리고자 추가로 작성한 내용입니다.
이와 같은 ‘SQL 개발 가이드’는 프로젝트에 따라 있기도 하고 없기도 하다. 이러한 가이드가 존재한다는 것은 개발 PM이나 운영 팀장이 SQL의 중요성을 인식하고 있다는 뜻으로 해석할 수 있다. 그리고 SQL 개발 가이드의 내용과 상세도에 따라 인식의 깊이도 다르다 해석할 수 있다.
필자 개인적으로는 ‘SQL 개발 가이드’는 매우 중요하다 생각한다. 개발의 품질을 높일 수 있으며, SQL 성능을 어느 정도 보장할 수 있기 때문이다.
오늘은 ‘SQL 개발 가이드’를 만들고 사용했던 필자의 경험을 이야기하려 한다.
(1) ‘SQL 개발 가이드’를 만드는 방법
SQL 개발 가이드는 쉽고 필수적인 내용부터 시작해서 작성한다. 필자는 보통 파워포인트를 이용해 SQL 개발 가이드를 만든다. 예를 들어 아래 그림과 같이 SQL 개발 가이드를 만든다.
왼쪽에는 잘 못된 방법, 오른쪽에는 올바른 방법을 담는다. 개발 초기에는 보통 10장 내외의 가이드를 작성한다. 자세하고 많은 내용을 담기보다는 꼭 필요하고 빠르게 개발자들에게 전달할 수 있는 내용을 담아서 작성한다.
개발 가이드에는 실제 프로젝트의 테이블을 사용하는 것이 좋다. 예를 들어, 병원 시스템 개발 프로젝트라면 진료, 접수 등의 테이블을 사용해 가이드를 작성하는 것이 효과적이다.
(2) 운영팀의 SQL 개발 가이드
필자는 중국에서 오랜 시간 운영팀으로 일을 해왔다. 운영팀이었지만 개발도 많이 하는 팀이었다. 대부분의 시스템을 우리 팀이 직접 설계하고 개발했다. 많은 사람들이 ‘운영팀이야? 개발팀이야?’ 말하기 일수였다. 2004년도에 우리 팀은 4명에 불과했다. 팀장겸 설계자 한 명, 자바 개발자 두 명, 디비를 담당하는 필자 한 명, 이렇게 4명이었다. 중국의 경제가 성장하면서 우리가 일했던 회사도 성장해 나갔으며 이에 따라 개발할 거리가 점점 늘어났다. 4명으로 시작한 우리 팀은 어느덧 28명까지 늘어났다. 팀원이 늘어나면서 SQL 작성 방법도 점점 다양해졌다. 뿐만 아니라, SQL로 인한 사고도 점점 늘어나게 되었다. 잘 못된 SQL을 반영해 시스템에 부하가 발생하거나, 잘 못된 데이터가 생성되거나 조회되기도 했다. 운영 업무 중에는 현업의 VOC(Voice of Customer)를 받아 데이터를 보정하는 일도 있다. VOC 처리 중에 SQL을 잘 못 작성해 데이터를 날리는 경우도 있었다.
이와 같은 사고와 SQL의 다양성을 방지하기 위해 SQL 개발 가이드를 만들고 개발자에게 공지했다. 하지만 가이드가 있다고 해서, 모두 가이드를 지키는 것은 아니다. 다양한 상황이 있기 때문에 가이드를 벗어나는 SQL들이 생기게 되어 있고, 개발 일정에 따라 가이드를 따르지 못하는 경우도 있다. 또한 가이드가 모든 상황을 제시해 줄 수도 없다. 실제 개발에서는 그만큼 다양한 SQL들이 만들어지기 때문이다.
가이드를 만들고 공지하는 것은 전혀 어려운 일이 아니다. 가이드를 지키는 것이 더욱 중요하고 어려운 일이다. 필자의 운영팀에서는 아래와 같은 프로세스로 가이드를 지키도록 했다.
개발자가 SQL을 개발한 후에 SQL 검수자에게 확인을 받은 후 반영하는 절차다. 검수 과정에서는 SQL 개발 가이드를 준수 했는지와 성능에 문제 없는지도 확인하도록 한다. 검수라는 말이 딱딱할 수 있다. 그냥 한 번 더 확인하는 절차 정도다. 필자의 운영팀에서는 필자가 SQL 검수를 맡아서 진행했다. 이러한 역할은 DA(Data Architecture)가 할 수도 있으며, DBA가 할 수도 있다. 또는 SQL을 제일 잘하는 개발자가 맡을 수도 있다. 팀의 상황에 따라 적절히 안배하면 된다. 위와 같은 프로세스는 개발할 양이 많지 않을 때는 큰 문제가 없다. 하지만 개발할 양이 많으면 SQL 검수 과정에 병목이 생겨 전체적으로 프로젝트를 지연 시킬 수 있다. 결과적으로 필자의 일거리가 너무 많아져 감당할 수 없는 상황이었다. 결국 아래와 같은 절차로 변경했다.
업무별로 작은 개발팀을 만들고, 그 안에서 SQL을 제일 잘 다루는 개발자를 선정해 SQL 검수를 진행하도록 했다. 상급 개발자들에게는 SQL 개발 가이드를 자세히 설명해주었다. 만약에 SQL에 성능에 이상이 있거나, 가이드 준수가 어렵다면 DA에게 검수를 추가 요청하도록 했다. 이외에도 분기별 개발자 평가에 SQL 개발 가이드에 대한 평가도 포함해서 진행했다.
운영팀의 특성상 개발 과정보다는 VOC 처리에서 더 많은 사고가 발생한다. VOC처리 SQL은 아래와 같은 절차를 사용했다.
운영 환경에서 SQL을 실행할 때는 개발자와 상급 개발자가 모두 참가해서 실행하도록 했다. 이 과정이 특히 중요하다. 대부분 VOC UPDATE, DELETE 사고는 조건절을 빼먹고 급하게 실행할 때 발생한다. 한 명이 단계별로 실행하고, 실행 과정을 누군가가 지켜보면서 조건절에 실수는 없는지 다시 한번 확인하도록 하는 것이다. 또한 DBMS나 DBMS 툴에 따라 AUTOCOMMIT을 사용하는 경우에는 각별한 주의가 필요하다. VOC UPDATE, DELETE는 무조건 AUTOCOMMIT을 OFF하고 사용하도록 해야 한다. 그러므로 필자가 운영팀에서 사용했던 SQL 가이드에는 아래와 같은 내용도 있었다. 참고하기 바란다.
대상을 백업 할 때는, VOC를 처리한 개발자 이름의 약자(LTW)와 VOC ID를 사용하도록 한다. 이 후 추적이 용이하다. 최종, COMMIT을 하기 전에는 백업한 건수와 UPDATE나 DELETE 된 건수가 같을 때만 UPDATE 처리하도록 한다.
(3) 개발팀의 SQL 개발 가이드
개발팀의 SQL 개발 가이드는 운영팀의 개발 가이드보다 좀 더 빠르게 만들어질 필요가 있다. 개발을 시작하기 전에 가이드가 만들어지고 공유되어야 한다. 개발 중간에 SQL 개발 가이드가 만들어지면 개발자들이 만들었던 SQL을 다시 손봐야 하는 공수가 추가된다.
SQL 개발 가이드를 만들 때부터 모든 내용을 담을 수는 없다. ‘SQL BOOSTER’ Chapter. 11의 목차 정도와 페이징 SQL 정도만 포함해도 된다. 그리고 프로젝트에서 중요하게 생각되는 부분 몇 가지를 추가하면 된다. 빨리 만들어 개발자들에게 공지하고 개발을 하면서 조금씩 확장해 나가도록 한다.
개발팀의 SQL 개발 가이드에는 프로젝트에서 실제 사용하는 테이블을 예제로 담는 것이 좋다. 가이드이해에도 도움이 되며, 개발자들이 잘 못된 방법과 맞는 방법을 직접 실행해보고 느낄 수 있기 때문이다.
개발 프로젝트에는 많은 인원들이 모여서, 많은 개발을 빠르게 진행하게 된다. 그러므로 SQL 가이드에 추가할 항목도 생각보다 빠르게 늘어난다. 이 때마다, 가이드를 꾸준히 업데이트하고 공지해야 한다.
개발 프로젝트에서 가장 큰 문제는 SQL 개발 가이드를 지키게 하는 것이다. 운영팀처럼 오랫동안 호흡을 맞춘 인원이 아닌, 새롭게 구성된 팀이기 때문이다. 그러므로 개발 프로젝트에서 SQL 개발 가이드를 지키게 하려면 개발 PM과 개발을 인수인계 받을 운영팀의 노력이 필요하다. 개발 PM은 SQL 개발 가이드를 꼭 준수하도록 강조를 하고, 개발 PL들을 통해 SQL 품질을 중간 중간 확인해야 한다. 그리고 운영팀은 가이드를 준수한 SQL들만 인수인계 받을 것을 처음부터 협의해 놓을 필요가 있다. 물론 모든 SQL을 표준화 시키고 가이드를 지킬 수는 없다. 최대한 지키도록 협의를 하는 것이다.
또 다른 문제는 ‘SQL 개발 가이드를 누가 만들 것인가?’이다. SQL 경험이 풍부한 사람이 만드는 것이 제일 좋다. 가능하면 튜닝을 아는 사람이 하는 것이 좋다. SQL 작성법은 성능과 밀접한 관련이 있기 때문이다. 만약에 SQL 개발 가이드를 작성할 사람이 없다면, 이 글을 읽고 있는 독자 자신이 ‘SQL BOOSTER’의 내용을 참고해서 정리해보기 바란다. 내용의 품질과 맞고 틀림보다는, SQL 개발 가이드 존재 자체가 개발 품질에 더 큰 도움이 되기 때문이다. 가이드의 품질은 점차 올리면 되고, 잘 못된 내용은 고쳐나가면 된다.
오늘 글은 여기까지입니다. 시간이 된다면, ‘SQL 개발 가이드’ PPT를 간단하게 만들어 올려보도록 하겠습니다. 작은 개발 사이트들에 도움이 되지 않을까 생각이 드네요. 감사합니다.
T_ORD_JOIN 테이블에서 고객별 마지막 주문만 가져오는 SQL을 작성해보자. 먼저 아래와 같은 인덱스를 만들도록 한다.
X_T_ORD_JOIN_TEST 인덱스 생성
CREATE INDEX X_T_ORD_JOIN_TEST ON T_ORD_JOIN(CUS_ID,ORD_SEQ);
인덱스를 만든 후에는, 아래와 같은 SQL로 고객별 마지막 주문을 가져올 수 있다.
[SQL-1] 고객별 마지막 주문 가져오기 – WHERE절 서브쿼리
SELECT *
FROM T_ORD_JOIN T1
WHERE T1.ORD_SEQ = (SELECT MAX(A.ORD_SEQ)
FROM T_ORD_JOIN A
WHERE A.CUS_ID = T1.CUS_ID);
WHERE절의 서브쿼리를 사용한 간단한 방법이다. FROM절의 T_ORD_JOIN을 순차적으로 읽어가면서 WHERE 절의 서브쿼리에 T1.CUS_ID를 공급하고, 해당 T1.CUS_ID의 마지막 ORD_SEQ를 찾아서 FROM절의 ORD_SEQ와 같으면 조회하는 방법이다. 이와 같은 SQL 작성 방법은 좋지 않다.
실행계획에서는 서브쿼리를 먼저 처리하고 있다. 실행계획의 3~5번 단계에서 X_T_ORD_JOIN_TEST 인덱스를 모두 읽어서 HASH GROUP BY 한 후에, 2번 단계에서 NL 조인 하는 것을 보면 알 수 있다. 오라클의 옵티마이져가 재치(?)를 발휘해 서브쿼리를 인라인-뷰처러 만들어 처리한 것이다. 실행계획의 총 Buffers 수치는 11,867이다. 기억하기 바란다.
고객별 마지막 주문을 구하는 또 다른 방법은 ROW_NUMBER 분석함수를 사용하는 것이다. 아래와 같다.
[SQL-2] 고객별 마지막 주문 가져오기 – ROW_NUMBER
SELECT T0.*
FROM (
SELECT T1.*
,ROW_NUMBER() OVER(PARTITION BY T1.CUS_ID ORDER BY T1.ORD_SEQ DESC) RNK
FROM T_ORD_JOIN T1
) T0
WHERE T0.RNK = 1;
분석함수의 OVER절과 PARTITION BY만 이해하고 있다면 어렵지 않게 사용할 수 있는 방법이다. (분석함수는 SQL BOOSTER 본서에서 자세하게 다루고 있다.) T_ORD_JOIN 테이블을 한 번만 접근하면 되므로 WHERE절 서브쿼리 방식보다 성능이 좋을 것 같지만, 오히려 좋지 못하다. T_ORD_JOIN 전체를 모두 읽어야 하기 때문이다. 실행계획을 확인해 보면 총 26,488의 Buffers가 발생한다.
지금 상황에서 필자가 추천하는 방법은 다음과 같다.
[SQL-3] 고객별 마지막 주문 가져오기 – M_CUS를 사용
SELECT T2.*
FROM (
SELECT (SELECT MAX(B.ORD_SEQ) FROM T_ORD_JOIN B WHERE B.CUS_ID = A.CUS_ID) ORD_SEQ
FROM M_CUS A
) T1
,T_ORD_JOIN T2
WHERE T1.ORD_SEQ = T2.ORD_SEQ
인라인-뷰에서(3~4 라인) M_CUS 테이블과 스칼라 서브쿼리로 고객별 마지막 ORD_SEQ를 구한 후에 T_ORD_JOIN과 조인 처리하는 방법이다. 실행계획을 살펴보면 다음과 같다.
필자는 여기서 이 방법을 추천했다. 하지만, 항상 이 방법이 좋은 것은 아니다. SQL BOOSTER에서 사용하고 있는 예제는 고객 수가 많지 않다. 그렇기 때문에 M_CUS를 이용해 마지막 주문을 가져오는 방법이 효율적이었던 것이다. 만약에 고객 수가 매우 많다면 이와 같은 방법은 성능이 더 나쁠 수 있다. 언제나 실행계획을 보고, 상황에 따라 좋은 방법을 찾아서 쓸 수 있어야 한다.
(2) 고객별 월별 마지막 주문 데이터 가져오기.
이번에는 고객+월별(ORD_YM=SUBSTR(T1.ORD_YMD,1,6)) 마지막 주문을 가져오는 SQL을 살펴보자.
테스트를 위해 인덱스를 먼저 만들도록 한다.
X_T_ORD_JOIN_TEST_2 인덱스 생성
CREATE INDEX X_T_ORD_JOIN_TEST_2 ON T_ORD_JOIN(CUS_ID,ORD_YMD,ORD_SEQ);
여기서 가장 안 좋은 방법은 WHERE절의 서브쿼리를 사용하는 경우다. 아래와 같이 말이다. (아래 SQL은 필자 노트북에서 실행 결과가 나오지 않았다. 독자 여러분도 마찬가지일 수 있다.)
[SQL-4] 고객별 월별 마지막 주문 가져오기 – WHERE 절 서브쿼리
SELECT *
FROM T_ORD_JOIN T1
WHERE T1.ORD_SEQ = (SELECT MAX(A.ORD_SEQ)
FROM T_ORD_JOIN A
WHERE A.CUS_ID = T1.CUS_ID
AND A.ORD_YMD LIKE SUBSTR(T1.ORD_YMD,1,6)||'%'
)
ORDER BY T1.CUS_ID, T1.ORD_YMD DESC;
위 SQL은 필자 환경에서 매우 오랜 시간이 지나도 결과가 나오지 않았다. EXPLAIN PLAN FOR를 이용해 실행계획만 확인해보니 무지막지한 방법으로 SQL이 실행되고 있었다. 실행계획은 다음과 같다.
[실행계획-4] 고객별 월별 마지막 주문 가져오기 – WHERE 절 서브쿼리
-------------------------------------------------------------------------------------------------------
| Id | Operation | Name | Rows | Bytes |TempSpc| Cost (%CPU)| Time |
-------------------------------------------------------------------------------------------------------
| 0 | SELECT STATEMENT | | 1792 | 136K| | 584K (95)| 01:56:58 |
|* 1 | FILTER | | | | | | |
| 2 | SORT GROUP BY | | 1792 | 136K| | 584K (95)| 01:56:58 |
|* 3 | HASH JOIN | | 5774M| 419G| 110M| 350K (92)| 01:10:04 |
| 4 | INDEX FAST FULL SCAN| X_T_ORD_JOIN_TEST_2 | 3224K| 73M| | 4237 (1)| 00:00:51 |
| 5 | TABLE ACCESS FULL | T_ORD_JOIN | 3224K| 166M| | 7257 (1)| 00:01:28 |
-------------------------------------------------------------------------------------------------------
Predicate Information (identified by operation id):
---------------------------------------------------
1 - filter("T1"."ORD_SEQ"=MAX("A"."ORD_SEQ"))
3 - access("A"."CUS_ID"="T1"."CUS_ID")
filter("A"."ORD_YMD" LIKE SUBSTR("T1"."ORD_YMD",1,6)||'%')
실행계획의 Predicate Information을 보면 3번 단계에서 (A.CUS_ID = T1.CUS_ID) 조건으로 해시 조인이 되고 있다. 두 개의 T_ORD_JOIN 간에 같은 CUS_ID끼리 모두 조인이 되고 있는 것이다. 마치 카테시안-조인처럼 데이터가 연결되고 있는 것이다. 이번에는 오라클이 재치를 발휘하지 못했다.
위 SQL은 아래와 같이 인라인-뷰와 조인 조합으로 변경할 수 있다.
[SQL-5] 고객별 월별 마지막 주문 가져오기 – 인라인-뷰
SELECT T1.*
FROM (
SELECT A.CUS_ID
,SUBSTR(A.ORD_YMD,1,6) ORD_YM
,MAX(A.ORD_SEQ) MAX_ORD_SEQ
FROM T_ORD_JOIN A
GROUP BY A.CUS_ID
,SUBSTR(A.ORD_YMD,1,6)
) T0
,T_ORD_JOIN T1
WHERE T1.ORD_SEQ = T0.MAX_ORD_SEQ
ORDER BY T1.CUS_ID, T1.ORD_YMD DESC;
인라인-뷰에서 고객별 월별 마지막 ORD_SEQ를 구해서 T_ORD_JOIN과 조인하는 방법이다. 실행계획은 다음과 같다.
인라인-뷰를 사용한 방법의 총 Buffers는 42,125다. 인라인-뷰 결과와 T_ORD_JOINN을 해시-조인 처리하고 있다. 필자 생각에는 인라인-뷰 결과와 T_ORD_JOIN을 NL 조인 하는 것이 성능이 더 좋을 것이라 생각된다. 인라인-뷰의 결과가 천 건밖에 안되기 때문이다. (직접 힌트를 사용해 NL 조인으로 테스트해보기 바란다.)
고객별 월별 마지막 주문을 구하기 위해서도 ROW_NUMBER 분석함수를 사용할 수 있다. 아래와 같이 PARTITION BY에 SUBSTR(T1.ORD_YMD,1,6)을 추가하면 된다.
[SQL-5] 고객별 월별 마지막 주문 가져오기 – ROW_NUMBER
SELECT T0.*
FROM (
SELECT T1.*
,ROW_NUMBER() OVER(PARTITION BY T1.CUS_ID, SUBSTR(T1.ORD_YMD,1,6)
ORDER BY T1.ORD_SEQ DESC) RNK
FROM T_ORD_JOIN T1
) T0
WHERE T0.RNK = 1
위 SQL의 실행계획을 확인해 보면, 총 26,490의 Buffers가 발생한다.
‘(1) 고객별 마지막 주문 데이터 가져오기’에서 성능이 가장 좋았던 방법은 M_CUS를 이용한 방법이었다. 하지만 여기서는 주문년월까지 포함되어야 하므로 M_CUS를 이용한 방법을 쉽게 사용할 수 없다. 하지만 일자(C_BAS_YMD) 테이블을 이용하면 이를 해결 할 수 있다. 아래와 같다.
[SQL-6] 고객별 월별 마지막 주문 가져오기 – M_CUS와 C_BAS_YMD
SELECT T2.*
FROM (
SELECT (SELECT MAX(B.ORD_SEQ)
FROM T_ORD_JOIN B
WHERE B.CUS_ID = A.CUS_ID AND B.ORD_YMD LIKE D.BAS_YM||'%') ORD_SEQ
FROM M_CUS A
,(SELECT DISTINCT C.BAS_YM FROM C_BAS_YMD C WHERE C.BAS_YMD LIKE '2017%') D
) T1
,T_ORD_JOIN T2
WHERE T1.ORD_SEQ = T2.ORD_SEQ
ORDER BY T2.CUS_ID, T2.ORD_YMD DESC;
SQL의 7번 라인을 보면 C_BAS_YMD에서 2017년에 해당하는 월(BAS_YM) 데이터 12건을 가져온 후에 M_CUS와 카테시안-조인하고 있다. 이와 같이 하면, M_CUS의 고객별로 201701부터 201712까지의 데이터가 만들어진다. (현재 T_ORD_JOIN에는 2017년 데이터만 있기 때문에 고객별 2017년 월별 데이터만 만들면 된다.) M_CUS의 고객별 월별 데이터를 이용해 마지막 ORD_SEQ를 구한 후에 다시 T_ORD_JOIN과 조인하는 방법이다. 실행계획은 다음과 같다.
총 Buffers가 21,226으로 줄어들었다. 일자 테이블까지 가져와서 카테시안-조인 처리하긴 했지만, 지금까지 SQL 중에는 IO 성능이 제일 좋다.
(3) 고객별 일자별 마지막 주문 데이터 가져오기.
이번에는 고객+일자별 마지막 주문을 가져오는 SQL을 살펴보자. WHERE 절 서브쿼리는 보나마나 성능이 좋지 못할 것이다.
고객별 일자별 마지막 주문을 조회하기 위해 아래와 같이 인라인-뷰를 사용해보자.
[SQL-7] 고객별 일자별 마지막 주문 가져오기 – 인라인-뷰
SELECT T1.*
FROM (
SELECT A.CUS_ID
,A.ORD_YMD
,MAX(A.ORD_SEQ) MAX_ORD_SEQ
FROM T_ORD_JOIN A
GROUP BY A.CUS_ID
,A.ORD_YMD
) T0
,T_ORD_JOIN T1
WHERE T1.ORD_SEQ = T0.MAX_ORD_SEQ
ORDER BY T1.CUS_ID, T1.ORD_YMD DESC;
이번에는 인라인-뷰의 결과와 T_ORD_JOIN이 NL 조인으로 처리되고 있다. (‘(2)’에서 인라인-뷰를 사용한 방법은 해시-조인으로 처리되었었다.) NL 조인이면서 총 Buffers는 23,452다.
그렇다면, (1)번에서 추천한 방법인 M_CUS를 사용한 방법은 어떨까? 고객+일자별 마지막 주문을 가져와야 하므로 그 방법은 좋지 못할 것으로 예상된다. 스칼라-서브쿼리가 반복적으로 많이 실행되기 때문이다. 실제 테스트 해보도록 하자.
[SQL-8] 고객별 일자별 마지막 주문 가져오기 – M_CUS와 C_BAS_YMD
SELECT T2.*
FROM (
SELECT (SELECT MAX(B.ORD_SEQ) FROM T_ORD_JOIN B
WHERE B.CUS_ID = A.CUS_ID AND B.ORD_YMD = D.BAS_YMD) ORD_SEQ
FROM M_CUS A
,(SELECT C.BAS_YMD FROM C_BAS_YMD C WHERE C.BAS_YMD LIKE '2017%') D
) T1
,T_ORD_JOIN T2
WHERE T1.ORD_SEQ = T2.ORD_SEQ
ORDER BY T2.CUS_ID, T2.ORD_YMD DESC;
그렇다면 인라인-뷰를 사용한 방법을 좀 더 향상시킬 수는 없을까? 다음과 같이 KEEP 분석함수와 ROWID를 사용하는 방법이 있다.
[SQL-9] 고객별 일자별 마지막 주문 가져오기 – 인라인-뷰와 KEEP
SELECT T1.*
FROM (
SELECT A.CUS_ID
,A.ORD_YMD
,MAX(A.ROWID) KEEP(DENSE_RANK FIRST ORDER BY A.ORD_SEQ DESC) RID
FROM T_ORD_JOIN A
GROUP BY A.CUS_ID
,A.ORD_YMD
) T0
,T_ORD_JOIN T1
WHERE T1.ROWID = T0.RID
KEEP을 이용해 고객, 일별 마지막 주문SEQ의 ROWID를 가져와서, ROWID로 T_ORD_JOIN에 바로 접근하는 방법이다. PK_T_ORD_JOIN 인덱스를 경유하지 않아 성능에 이득이 있다. 실행계획은 다음과 같다.
UNION과 UNION ALL은 두 데이터 집합을 상하로 결합시킨다. 아마도 이를 모르는 개발자는 없을 것이다. 반면에 MINUS 구문은 사용해 본적이 없거나 처음 접하는 개발자도 있을 것이다. MINUS는 상하의 두 데이터 집합간의 차집합을 구한다. 이 MINUS 연산은 도통 쓸데가 없다. 일반적인 조회 화면에서 MINUS가 포함된 SQL이 사용되는 경우는 거의 없기 때문이다. 하지만 MINUS는 데이터 검증 작업을 할 때 매우 유용하다. 필자는 MINUS 구문을 성능 개선 작업할 때 많이 사용한다.
SQL BOOSTER 에 이어지는 이야기들입니다.~! SQL BOOSTER 를 보신 분들께 좀 더 도움을 드리고자 추가로 작성한 내용입니다.
[SQL-1] MINUS 예제
SELECT *
FROM (
SELECT 'A' COL1, 1 COL2 FROM DUAL
UNION ALL
SELECT 'B' COL1, 3 COL2 FROM DUAL) T1
MINUS
SELECT *
FROM (SELECT 'A' COL1, 1 COL2 FROM DUAL
UNION ALL
SELECT 'B' COL1, 2 COL2 FROM DUAL
) T2
MINUS를 기준으로 위쪽 SQL(T1)을 기준 집합이라고 하자. 그리고 아래쪽 SQL(T2)을 참조 집합이라고 하자. [SQL-1]을 실행하면 기준 집합에는 있지만 참조 집합에는 없는 데이터만 조회할 수 있다. 아래와 같은 결과가 나온다.
[결과-1] MINUS 예제
COL1 COL2
======== =========
B 3
COL1이 B면서 COL2가 3인 데이터는 T1에는 있지만, T2에는 없다. 그러므로 해당 건만 결과에 나오게 된다. 이처럼 MINUS는 특정 컬럼이 아니라 SELECT절에 표시된 모든 컬럼을 비교한다.
성능 개선을 위해 SQL을 변경해야만 할 때가 있다. 힌트나 인덱스로는 성능 개선이 어려운 경우가 있기 때문이다. 간단한 변경은 문제 없지만, 약간 복잡한 변경을 하게 되면 변경 이전과 결과가 같은지 검증을 꼭 해야 한다. 이 때 MINUS를 사용 할 수 있다.
아래 SQL을 보자.(SQL BOOSTER에서 ROLLUP을 대신할 방법으로 소개했던 SQL이다.)
[SQL-2] UNION ALL을 이용한 중간합계
SELECT TO_CHAR(T1.ORD_DT,'YYYYMM') ORD_YM ,T1.CUS_ID
,SUM(T1.ORD_AMT) ORD_AMT
FROM T_ORD T1
WHERE T1.CUS_ID IN ('CUS_0001','CUS_0002')
AND T1.ORD_DT >= TO_DATE('20170301','YYYYMMDD')
AND T1.ORD_DT < TO_DATE('20170501','YYYYMMDD')
GROUP BY TO_CHAR(T1.ORD_DT,'YYYYMM') ,T1.CUS_ID
UNION ALL
SELECT TO_CHAR(T1.ORD_DT,'YYYYMM') ORD_YM ,'Total' CUS_ID
,SUM(T1.ORD_AMT) ORD_AMT
FROM T_ORD T1
WHERE T1.CUS_ID IN ('CUS_0001','CUS_0002')
AND T1.ORD_DT >= TO_DATE('20170301','YYYYMMDD')
AND T1.ORD_DT < TO_DATE('20170501','YYYYMMDD')
GROUP BY TO_CHAR(T1.ORD_DT,'YYYYMM')
UNION ALL
SELECT 'Total' ORD_YM ,'Total' CUS_ID
,SUM(T1.ORD_AMT) ORD_AMT
FROM T_ORD T1
WHERE T1.CUS_ID IN ('CUS_0001','CUS_0002')
AND T1.ORD_DT >= TO_DATE('20170301','YYYYMMDD')
AND T1.ORD_DT < TO_DATE('20170501','YYYYMMDD')
위와 같은 SQL은 ROLLUP으로 변경하는 것이 성능에 유리할 수 있다. 실행계획까지 확인해 ROLLUP이 더 좋다면 SQL을 변경하도록 한다. 이때, 성능보다 중요한 건 데이터의 정확성이다. 성능을 얻고 정확성을 잃을 수는 없다. 그러므로 반드시 변경한 SQL의 데이터가 맞는지 확인해야 한다. 다음과 같이 MINUS를 사용해 확인할 수 있다. MINUS를 기준으로 위쪽은 ROLLUP, 아래쪽은 UNION ALL이다.
[SQL-3] MINUS를 이용한 SQL 검증
--ROLLUP을 이용한 SQL
SELECT CASE WHEN GROUPING(TO_CHAR(T1.ORD_DT,'YYYYMM')) = 1 THEN 'Total'
ELSE TO_CHAR(T1.ORD_DT,'YYYYMM') END ORD_YM
,CASE WHEN GROUPING(T1.CUS_ID) = 1 THEN 'Total' ELSE T1.CUS_ID END CUS_ID
,SUM(T1.ORD_AMT) ORD_AMT
,COUNT(*) OVER() TTL_CNT
FROM T_ORD T1
WHERE T1.CUS_ID IN ('CUS_0001','CUS_0002')
AND T1.ORD_DT >= TO_DATE('20170301','YYYYMMDD')
AND T1.ORD_DT < TO_DATE('20170501','YYYYMMDD')
GROUP BY ROLLUP(TO_CHAR(T1.ORD_DT,'YYYYMM') ,T1.CUS_ID)
MINUS
-- 기존 SQL(UNION ALL)
SELECT A.*, COUNT(*) OVER() TTL_CNT
FROM (
SELECT TO_CHAR(T1.ORD_DT,'YYYYMM') ORD_YM ,T1.CUS_ID
,SUM(T1.ORD_AMT) ORD_AMT
FROM T_ORD T1
WHERE T1.CUS_ID IN ('CUS_0001','CUS_0002')
AND T1.ORD_DT >= TO_DATE('20170301','YYYYMMDD')
AND T1.ORD_DT < TO_DATE('20170501','YYYYMMDD')
GROUP BY TO_CHAR(T1.ORD_DT,'YYYYMM') ,T1.CUS_ID
UNION ALL
SELECT TO_CHAR(T1.ORD_DT,'YYYYMM') ORD_YM ,'Total' CUS_ID
,SUM(T1.ORD_AMT) ORD_AMT
FROM T_ORD T1
WHERE T1.CUS_ID IN ('CUS_0001','CUS_0002')
AND T1.ORD_DT >= TO_DATE('20170301','YYYYMMDD')
AND T1.ORD_DT < TO_DATE('20170501','YYYYMMDD')
GROUP BY TO_CHAR(T1.ORD_DT,'YYYYMM')
UNION ALL
SELECT 'Total' ORD_YM ,'Total' CUS_ID
,SUM(T1.ORD_AMT) ORD_AMT
FROM T_ORD T1
WHERE T1.CUS_ID IN ('CUS_0001','CUS_0002')
AND T1.ORD_DT >= TO_DATE('20170301','YYYYMMDD')
AND T1.ORD_DT < TO_DATE('20170501','YYYYMMDD')
) A;
위 SQL을 실행하면 조회되는 데이터가 없다. MINUS를 기준으로 위쪽 SQL 결과와 아래쪽 SQL의 결과가 완전히 같기 때문이다. 6번 라인과 14번 라인을 보면 두 데이터 집합에 COUNT(*) OVER()를 추가한 것을 볼 수 있다. 데이터 건수까지 완전히 같은지 확인하기 위해서다.
이처럼 MINUS 연산은 데이터를 검증하기 위해서 많이 사용한다. 이행한 데이터가 문제 없는지, 변경한 SQL이 문제 없는지 확인하기 위해서 말이다. 그러므로 “쓸데 없는 MINUS”는 아니다. 나름 유용하게 쓸 데가 있다. 잘 기억하고 사용할 수 있기 바란다.
SQL을 성능 개선 할 때, 가장 손쉬운 방법은 인덱스를 추가하는 것이다. 물론 인덱스로 성능이 개선될 수 있다면 말이다. 하지만, 그런 식으로 인덱스를 만들다 보면 데이터베이스에는 인덱스가 테이블보다 더 많은 용량을 차지하기 시작한다. SQL BOOSTER 본서 183페이지, ‘6.4.3 너무 많은 인덱스의 위험성’에서 설명했던 내용이다.
손쉬운 인덱스 추가보다는, 주어진 인덱스에서 성능을 개선할 방법을 찾는 것이 SQL 성능 개선의 첫 단계다. 인덱스는 그 다음 단계다.
SQL BOOSTER 에 이어지는 이야기들입니다.~! SQL BOOSTER 를 보신 분들께 좀 더 도움을 드리고자 추가로 작성한 내용입니다.
[SQL-1] T_ORD_JOIN을 집계 조회
SELECT /*+ GATHER_PLAN_STATISTICS */
T1.ORD_ST
,SUM(T1.ORD_QTY * T1.UNT_PRC) ORD_AMT
FROM T_ORD_JOIN T1
WHERE T1.ORD_YMD BETWEEN '20170101' AND '20170930'
AND T1.ITM_ID = 'ITM020'
AND T1.CUS_ID = 'CUS_0004'
GROUP BY T1.ORD_ST;
특정 고객의, 특정 아이템에 대해 1월1일부터 9월30일까지의 판매금액을 주문상태(ORD_ST)별로 집계하고 있다. 실행계획은 다음과 같다.
[실행계획-1] T_ORD_JOIN을 집계 조회
-------------------------------------------------------------------------------------------------------
| Id | Operation | Name | Starts | A-Rows | A-Time | Buffers | OMem |
-------------------------------------------------------------------------------------------------------
| 0 | SELECT STATEMENT | | 1 | 1 |00:00:00.11 | 257 | |
| 1 | HASH GROUP BY | | 1 | 1 |00:00:00.11 | 257 | 934K|
|* 2 | TABLE ACCESS BY INDEX ROWID| T_ORD_JOIN | 1 | 2000 |00:00:00.01 | 257 | |
|* 3 | INDEX RANGE SCAN | X_T_ORD_JOIN_2 | 1 | 19000 |00:00:00.03 | 80 | |
-------------------------------------------------------------------------------------------------------
Predicate Information (identified by operation id):
---------------------------------------------------
2 - filter("T1"."ITM_ID"='ITM020')
3 - access("T1"."CUS_ID"='CUS_0004' AND "T1"."ORD_YMD">='20170101' AND "T1"."ORD_YMD"<='20170931')
원하는 결과를 얻기 위해 257번의 논리IO가 발생했다. 성능이 나쁜 SQL은 아니다. 하지만, 더 성능을 개선할 수 없을까 고민해보자. 성능을 개선하려면 비효율을 먼저 찾아야 한다. 비효율은 실행계획에 빨간색으로 표시해 놓았다. 실행계획의 3번 단계에서 19,000건을 찾았고, 2번 단계에서 ITM_ID 조건이 필터 되면서 17,000건이 버려졌다. T_ORD_JOIN에 19,000번 접근했지만, 그 중에 17,000번이 불필요한 접근인 것이다. 만약에 CUS_ID, ORD_YMD의 인덱스에 ITM_ID 컬럼도 있었다면 테이블에 접근한 후 버려지는 비효율이 발생하지 않았을 것이다. 그리고 CUS_ID, ITM_ID, ORD_YMD, ORD_ST 순서의 인덱스가 있었다면, 테이블을 접근하는 비효율 자체가 없었을 것이다. 하지만, 현재 가진 인덱스에서 해결해야 한다면 어떻게 해야 할까? 우선 T_ORD_JOIN에 어떤 인덱스가 있는지 살펴보자.
노란색으로 표시된 부분은 해당 인덱스에서 사용할 수 있는 컬럼이다. [SQL-1]을 처리하기에 가장 좋은 인덱스는 X_T_ORD_JOIN_2와 X_T_ORD_JOIN_4다. 위의 실행계획에서도 X_T_ORD_JOIN_2 인덱스를 사용했다.
인덱스의 리프 블록에는 ROWID가 있다는 사실을 기억하고 있는가? 이 점을 항상 기억하기 바란다. 하나의 테이블에 종속된 인덱스들은 ROWID라는 공통 분모를 가지고 있다. 이 점을 이용하면 가지고 있는 인덱스 안에서 어느 정도의 성능 개선이 가능하다. 바로 아래와 같이 SQL을 바꿔보는 것이다.
[SQL-2] T_ORD_JOIN을 집계 조회 – 성능 개선
SELECT /*+ NO_MERGE(T0) LEADING(T0 T3) USE_NL(T3) */
T3.ORD_ST
,SUM(T3.ORD_QTY * T3.UNT_PRC) ORD_AMT
FROM (
SELECT /*+ LEADING(T2) USE_HASH(T1) INDEX(T1 X_T_ORD_JOIN_2) INDEX(T2 X_T_ORD_JOIN_4) */
T1.ROWID RID
FROM T_ORD_JOIN T1
,T_ORD_JOIN T2
WHERE T1.ORD_YMD BETWEEN '20170101' AND '20170930'
AND T1.CUS_ID = 'CUS_0004'
AND T2.ORD_YMD BETWEEN '20170101' AND '20170930'
AND T2.ITM_ID = 'ITM020'
AND T1.ROWID = TRIM(T2.ROWID)
) T0
,T_ORD_JOIN T3
WHERE T3.ROWID = T0.RID
GROUP BY T3.ORD_ST
SQL이 굉장히 길고 복잡해졌다. 주의 깊게 볼 부분을 빨간색과 노란색으로 표시해 놓았다. T_ORD_JOIN 테이블이 세 번이나 출현하고 있다. SQL 성능을 위해서는 같은 테이블을 불필요하게 반복 사용해서는 안 된다. 필자가 줄 곧 해온 이야기며, 일반적으로 맞는 말이다. 하지만 인덱스의 구조를 정확히 알면, 위와 같이 SQL을 작성해 성능을 개선할 수 있다.
위 SQL은 X_T_ORD_JOIN_2 인덱스로 CUS_ID와 ORD_YMD 조건이 맞는 ROWID를 찾아내고, X_T_ORD_JOIN_4 인덱스로 ITM_ID와 ORD_YMD 조건에 맞는 ROWID를 찾아내 해시 조인 처리하고 있다. 해시 조인으로 얻은 두 인덱스간에 공통된 ROWID를 T_ORD_JOIN(T3)에 공급해 최종 결과를 얻어내는 방법이다. 실행계획을 살펴보자. 논리IO가 257에서 188로 줄어들었다.
[실행계획-2] T_ORD_JOIN을 집계 조회 – 성능 개선
-------------------------------------------------------------------------------------------------------
| Id | Operation | Name | Starts |A-Rows | A-Time | Buffers | OMem |
-------------------------------------------------------------------------------------------------------
| 0 | SELECT STATEMENT | | 1 | 1 |00:00:00.02 | 188 | |
| 1 | HASH GROUP BY | | 1 | 1 |00:00:00.02 | 188 | 934K|
| 2 | NESTED LOOPS | | 1 | 2000 |00:00:00.02 | 188 | |
| 3 | VIEW | | 1 | 2000 |00:00:00.02 | 170 | |
|* 4 | HASH JOIN | | 1 | 2000 |00:00:00.02 | 170 | 1814K|
|* 5 | INDEX RANGE SCAN | X_T_ORD_JOIN_4 | 1 | 23000 |00:00:00.01 | 90 | |
|* 6 | INDEX RANGE SCAN | X_T_ORD_JOIN_2 | 1 | 19000 |00:00:00.01 | 80 | |
| 7 | TABLE ACCESS BY USER ROWID| T_ORD_JOIN | 2000 | 2000 |00:00:00.01 | 18 | |
-------------------------------------------------------------------------------------------------------
Predicate Information (identified by operation id):
---------------------------------------------------
4 - access("T1".ROWID=CHARTOROWID(TRIM(ROWIDTOCHAR("T2".ROWID))))
5 - access("T2"."ITM_ID"='ITM020' AND "T2"."ORD_YMD">='20170101' AND "T2"."ORD_YMD"<='20170931')
6 - access("T1"."CUS_ID"='CUS_0004' AND "T1"."ORD_YMD">='20170101' AND "T1"."ORD_YMD"<='20170931')
6 - access("T2"."ITM_ID"='ITM002' AND "T2"."ORD_YMD">='20170101' AND "T2"."ORD_YMD"<='20170228')
논리IO가 개선되었지만, 해시조인으로 인해 메모리 사용은 늘어날 수 밖에 없다. 보통은 IO 개선이 SQL 성능에 많은 도움이 되기 때문에 이와 같은 방법을 사용해야 할 때가 있다.
이와 같이 SQL을 많이 변경해야 한다면 좋은 방법이라고 말하기는 어렵다. SQL이 길어지고, 조건이 늘어났으며 힌트도 많이 사용되었다. 사실, NO_MERGE 정도의 간단한 힌트만 사용해 원하는 결과를 얻고 싶었지만, 그럴 수 없어 힌트를 많이 사용하게 되었다. SQL의 13번 라인에서 T2.ROWID를 TRIM 처리하기도 했다. 실행계획이 원하는 대로 만들어지지 않아 강제 처리한 것이다.
오라클에는 INDEX_JOIN이나 INDEX_COMBINE 힌트가 있다. [SQL-2]처럼 복잡하게 SQL을 변경하지 않아도 해당 힌트를 사용할 수 있다. 하지만, 힌트가 먹지 않는 경우가 있다. 또는 작동하던 힌트가 어느 순간부터 작동하지 않을 수도 있다. 그리고, 힌트를 사용할 수 없는 DBMS도 있다. 그러므로 이와 같이 SQL을 변경할 수 있다면, 힌트가 작동하지 않아도, 힌트가 없어도 성능 개선을 할 수 있다.
먼저 다운 받은 CSV 파일을 UTF8로 변환해서 저장해야 합니다. 그렇지 않으면 필자 환경에서는 한글이 깨져서 DB에 올라가게 됩니다. 아래와 같이 메모장으로 파일을 연 다음에 다른 이름으로 저장을 하도록 합니다. 다른 이름으로 저장할 때 아래와 같이 해야 합니다.
- 인코딩 부분을 ‘UTF-8’로 변경한다.
- 파일 형식을 ‘모든 파일’로 변경한다.
- 파일명을 UP_LOAD_GETONOFF.CSV로 변경한다.
이젠, U_StationUse에 올릴 파일명은 UP_LOAD_GETONOFF.CSV 입니다.
인코딩을 변환해서 저장했으면 파일을 올려보도록 합니다. 저는 'MySQL 5.7 Command Line Client'를 Root 계정으로 접속해서 아래 스크립트를 실행했습니다. 한글 파일이 깨지는 경우가 있어서 아래와 같이 캐릭터 셋을 변경한 후 파일을 업로드 해야 합니다.
(MySQL8 에서는 C:\ProgramData\MySQL\MySQL Server 8.0 위치의 my.ini 를 열어서 작업 필요.
[mysql] , [mysqld] 밑에 local-infile = 1을 표시. My.ini 저장할때 ansi변환필요한듯함.)
-- U_StationUse 업로드
USE SWEET_DATA;
SET character_set_client = utf8;
SET character_set_connection = utf8;
SET character_set_database = utf8;
SET character_set_results = utf8;
SET character_set_server = utf8;
LOAD DATA LOCAL INFILE 'C:\\Users\\sweetboss\\Desktop\\201910_디비안_서울지하철분석\\download\\UP_LOAD_GETONOFF.CSV' INTO TABLE U_StationUse FIELDS TERMINATED BY ',' ENCLOSED BY '"' LINES TERMINATED BY '\r\n' IGNORE 1 LINES;
LOAD DATA LOCAL INFILE 'C:\\upload\\UP_LOAD_GETONOFF.CSV' INTO TABLE U_StationUse FIELDS TERMINATED BY ',' ENCLOSED BY '"' LINES TERMINATED BY '\r\n' IGNORE 1 LINES;
실제 파일을 올리는 스크립트는 9~11번 라인의 SQL입니다. 해당 SQL이 한 줄로 실행되어야 합니다. 빨간색으로 표시된 부분은 각자 자신이 가지고 있는 파일 경로와 파일명을 사용해야 합니다.
데이터를 올린 후에 MySQL WorkBench에서 조회를 해보면 아래와 같이 데이터가 입력되어 있습니다.
6. 실제 사용할 테이블에 데이터 입력하기
아래 SQL로 M_Station 데이터를 생성합니다.
-- M_Station 업로드
INSERT INTO M_Station
(StationNo ,StationName ,LineName)
SELECT DISTINCT StationNo ,StationName ,LineName FROM U_StationUse;
이제, 승하차 정보를 만들어야 하는데, 업로드 테이블에는 시간이 컬럼으로 구성되어 있습니다. 우리가 실제 사용할 테이블은 시간이 로우로 구성되어야 합니다. 로우로 구성하기 위해 아래와 같이 C_Seq 테이블을 만들도록 합니다. C_는 Common의 약자다. C_Seq 테이블을 이용해서 컬럼을 로우로 바꿀것입니다.
-- 시퀀스 테이블 만들기
CREATE TABLE C_Seq
( Seq INT NOT NULL
,PRIMARY KEY(Seq)
);
INSERT INTO C_Seq (Seq)
SELECT (SELECT COUNT(*) FROM M_Station A WHERE A.StationNo <= T1.StationNo) Seq
FROM M_Station T1
아래 SQL로 역 승하차 정보를 만듭니다. C_Seq와 U_StationUse을 CROSS JOIN합니다.
-- T_StationUse
INSERT INTO T_StationUse
(UseDateTime
,GetOnOffType
,StationNo
,UseCount)
SELECT DATE_ADD(STR_TO_DATE(T2.UseDate, '%Y-%m-%d'), interval T1.Seq - 1 hour) UseDate
,CASE WHEN T2.GetOnOffType = '승차' THEN 'ON' ELSE 'OFF' END GetOnOffType
,T2.StationNo
,CASE T1.Seq - 1
WHEN 0 THEN T2.H00
WHEN 1 THEN T2.H01
WHEN 2 THEN T2.H02
WHEN 3 THEN T2.H03
WHEN 4 THEN T2.H04
WHEN 5 THEN T2.H05
WHEN 6 THEN T2.H06
WHEN 7 THEN T2.H07
WHEN 8 THEN T2.H08
WHEN 9 THEN T2.H09
WHEN 10 THEN T2.H10
WHEN 11 THEN T2.H11
WHEN 12 THEN T2.H12
WHEN 13 THEN T2.H13
WHEN 14 THEN T2.H14
WHEN 15 THEN T2.H15
WHEN 16 THEN T2.H16
WHEN 17 THEN T2.H17
WHEN 18 THEN T2.H18
WHEN 19 THEN T2.H19
WHEN 20 THEN T2.H20
WHEN 21 THEN T2.H21
WHEN 22 THEN T2.H22
WHEN 23 THEN T2.H23 END UseCount
FROM (SELECT * FROM C_Seq A WHERE A.Seq <= 24) T1
CROSS JOIN U_StationUse T2;
409,200 건 데이터가 입력됩니다. 제대로 데이터가 올라갔는지 확인이 필요합니다. 항상 확인이 필요하죠.
-- 적용된 데이터 검증
SELECT SUM(UseCount)
FROM T_StationUse T1
WHERE UseDateTime >= '2019-07-01'
AND UseDateTime < '2019-07-02'
AND StationNo = 150
AND GetOnOffType = 'ON';
위 SQL을 실행해보면 58,362가 나옵니다. 엑셀에서도 150번 역의 ON(승차) 7월1일 전체 승차 값을 보면 58,362 입니다. 문제 없이 승하차 정보가 반영되었습니다.
7. 데이터를 분석해보자.
매일 아침 김밥을 파는 건 너무 뻔합니다. 퇴근 시간에 지하철 역 앞에서 김밥을 팔아보시죠.
평일 저녁 19시대를 퇴근 시간으로 잡습니다. 해당 시간대에 하차가 많은 전철역을 골라 냅니다. 그리고 승차는 적어야 합니다. 하차와 승차가 같이 있다는 것은 전철을 갈아타고 있는 것으로 추측할 수 있습니다.
먼저 7월 중에 평일 19시만 조회해봅니다. 우선 C_Seq 테이블을 사용해서 아래와 같이 시간 집합을 만듭니다.
-- 7월 중에 평일 19시만 조회
SELECT DATE_ADD(DATE_ADD(STR_TO_DATE('2019-07-01','%Y-%m-%d'), interval T1.seq day)
, interval 19 hour) UseDate
,WEEKDAY(DATE_ADD(STR_TO_DATE('2019-07-01','%Y-%m-%d'), interval T1.seq day)
) WeekDay
FROM C_Seq T1
WHERE DATE_ADD(STR_TO_DATE('2019-07-01','%Y-%m-%d'), interval T1.seq day) < '2019-08-01'
AND WEEKDAY(DATE_ADD(STR_TO_DATE('2019-07-01','%Y-%m-%d'), interval T1.seq day)) NOT IN (5,6)
위 SQL과 T_UseStation을 조인해서 7월 평일 19시의 데이터만 조회하도록 합니다. 최종 분석 SQL은 아래와 같습니다.(아래 SQL은 호선이 다른 환승은 고려 안했습니다. 각자 환승을 고려해서 SQL을 개발해 봅시다.)
-- 최종 결과.
SELECT T2.StationNo
,MAX(T3.LineName)
,MAX(T3.StationName)
,SUM(CASE WHEN T2.GetOnOffTYpe = 'ON' THEN T2.UseCount END) ON_COUNT
,SUM(CASE WHEN T2.GetOnOffTYpe = 'OFF' THEN T2.UseCount END) OFF_COUNT
,SUM(CASE WHEN T2.GetOnOffTYpe = 'OFF' THEN T2.UseCount END)
- SUM(CASE WHEN T2.GetOnOffTYpe = 'ON' THEN T2.UseCount END) TTL_OFF_CNT
FROM (
SELECT DATE_ADD(DATE_ADD(STR_TO_DATE('2019-07-01','%Y-%m-%d'), interval T1.seq day)
, interval 19 hour) UseDate
,WEEKDAY(DATE_ADD(STR_TO_DATE('2019-07-01','%Y-%m-%d'), interval T1.seq day)
) WeekDay
FROM C_Seq T1
WHERE DATE_ADD(STR_TO_DATE('2019-07-01','%Y-%m-%d'), interval T1.seq day) < '2019-08-01'
AND WEEKDAY(DATE_ADD(STR_TO_DATE('2019-07-01','%Y-%m-%d'), interval T1.seq day)) NOT IN (5,6)
) T1
INNER JOIN T_StationUse T2
ON (T1.UseDate = T2.UseDateTime)
INNER JOIN M_Station T3
ON (T3.StationNo = T2.StationNo)
GROUP BY T2.StationNo
ORDER BY SUM(CASE WHEN T2.GetOnOffTYpe = 'OFF' THEN T2.UseCount END)
- SUM(CASE WHEN T2.GetOnOffTYpe = 'ON' THEN T2.UseCount END) DESC
;
아래와 같은 결과를 얻었습니다. 결과로는 저녁에 신림역에서 김밥을 말면 될거 같습니다. 그런데 신림역은.. 술집도 많아서 회식하러 사람들이 많이 모이는거 아닐까 생각이 듭니다. 그렇다면, 아래 데이터에 생활권을 좀 고려해서 김밥 말 곳을 정하면 되지 않을까요?. 여기에.. 역 근처 식당, 술집 정보까지 얹는다면 쓸만하지 않을까 싶습니다.
다운한 Zip 파일을 압축 풀어보면, CSV 파일이 있습니다. CSV파일을 엑셀에서 열어보면 아래와 같습니다.
3. 테이블 설계
위 그림을 보고 테이블을 설계합니다. 먼저 아래와 같이 업로드용 테이블을 설계합니다.
위 테이블에서 U_ 는 업로드용 테이블을 뜻하는 약어입니다. 업로드용 테이블은 보통 PK, FK와 같은 제약을 설정하지 않습니다.
업로드용 테이블에 올린 후에는 아래 테이블 구조로 데이터를 관리합니다. 지하철역과 승하차 정보를 별도 테이블로 관리합니다.
M_는 마스터 테이블을 뜻하고, T_는 실적(Transaction)을 뜻하는 약어입니다.
4. 테이블 생성
아래 스크립트로 업로드용 테이블을 생성합니다.
-- 업로드용 테이블 생성
CREATE TABLE U_StationUse(
UseDate VARCHAR(100) CHARACTER SET UTF8MB4
,LineName VARCHAR(100) CHARACTER SET UTF8MB4
,StationNo VARCHAR(100) CHARACTER SET UTF8MB4
,StationName VARCHAR(100) CHARACTER SET UTF8MB4
,GetOnOffType VARCHAR(100) CHARACTER SET UTF8MB4
,H04 VARCHAR(100)
,H05 VARCHAR(100)
,H06 VARCHAR(100)
,H07 VARCHAR(100)
,H08 VARCHAR(100)
,H09 VARCHAR(100)
,H10 VARCHAR(100)
,H11 VARCHAR(100)
,H12 VARCHAR(100)
,H13 VARCHAR(100)
,H14 VARCHAR(100)
,H15 VARCHAR(100)
,H16 VARCHAR(100)
,H17 VARCHAR(100)
,H18 VARCHAR(100)
,H19 VARCHAR(100)
,H20 VARCHAR(100)
,H21 VARCHAR(100)
,H22 VARCHAR(100)
,H23 VARCHAR(100)
,H00 VARCHAR(100)
,H01 VARCHAR(100)
,H02 VARCHAR(100)
,H03 VARCHAR(100)
,Total VARCHAR(100)
) ENGINE = InnoDB;
아래 스크립트로 실제 사용할 역(M_Station)과 역승하차정보(T_StationUse) 테이블을 만듭니다.
-- M_Station, T_StationUse 생성
CREATE TABLE M_Station
(StationNo INT NOT NULL
,StationName VARCHAR(100) CHARACTER SET UTF8MB4 NOT NULL
,LineName VARCHAR(100) CHARACTER SET UTF8MB4 NOT NULL
,PRIMARY KEY(StationNo)
,UNIQUE KEY(LineName, StationName));
CREATE TABLE T_StationUse
(UseDateTime DATETIME NOT NULL
,GetOnOffType VARCHAR(40) NOT NULL
,StationNo INT NOT NULL
,UseCount INT NOT NULL DEFAULT 0
,PRIMARY KEY(UseDateTime, GetOnOffType, StationNo)
,KEY(StationNo, UseDateTime)
);
ALTER TABLE T_StationUse
ADD CONSTRAINT FK_T_StationUse_1 FOREIGN KEY(StationNo) REFERENCES M_Station(StationNo);