안녕하세요. 데이터를 활용한 재테크!. D-Tech입니다.

 

오늘(2021년 1월 4일) 코스피는 사상 최고치인 2,944.45 포인트에 도달했습니다.

여기저기서 3,000 갈거라고 많이들 말씀하셔서 가려나 싶었는데 이렇게 빨리 갈줄은 생각도 못했네요..

주식을 많이 담아 놓지 않았던거에..한숨이 절로 나옵니다...

 

주가도 사상 최고치인만큼 인버스를 사용할까?? 에 대해 데이터를 한번 살펴 볼까 합니다. 엑셀만 잘 사용하면 저보다 더 유용한 분석을 하실 수 있으리라 생각합니다.

본격적인 설명에 앞서, 절대 이 글은 인버스를 추천하는 글도 아니고, 주가가 하락하거나 상승할거라 예측하는 글도 아닙니다. 인버스 ETF와 KODEX ETF 비교를 통한 흐름을 탐구하는 정도의 글입니다.

 

먼저 인버스와 지수를 비교하기 위해서 아래 두 개의 ETF를 사용하기로 했습니다.

 - KODEX 인버스(114800)

 - KODEX 200(069500)

 

두 ETF의 가격 흐름을 비교하기 위해서는 두 종목의 일별 종가가 필요한데 파이썬을 이용해 크롤링했습니다. 파이썬은 아직 접해보지 못하신 분들이 많을 수 있으니, 아래와 같이 결과를 엑셀로 첨부드립니다.

 

KODEX_200_069500.KS_20210104160647.xls
0.28MB
KODEX_인버스_114800.KS_20210104160711.xls
0.20MB

 

두 개의 엑셀 파일을 결합해 같은 일자의 종가와 거래량을 붙입니다.(VLOOKUP만 잘 사용하면 됩니다.)

VLOOKUP은 아래 글 참고해주세요.

sweetquant.tistory.com/207

 

EXCEL 기본편 - VLOOKUP

오늘은 엑셀 함수인 VLOOKUP을 다뤄볼까 합니다. D-Tech 관련된 글을 적으면서 엑셀의 VLOOKUP을 사용하는 경우가 많기 때문에 VLOOKUP만 별도로 한번 다루는 것이 좋다고 생각했습니다. 먼저 엑셀의 VLOO

sweetquant.tistory.com

 

VLOOKUP도 귀찮으신 분은 KODEX와 인버스를 일자별로 결합한 결과를 아래 파일로 올려드리니 바로 보셔도 됩니다.

 

인버스_VS_코덱스.xlsx
0.75MB

 

KODEX와 인버스를 결합한 결과는 아래 그림과 같습니다. 결과를 보면 거래 금액도 비교하기 위해 인버스 거래금액(억)과 코덱스 거래금액(억) 컬럼을 추가해 놓았습니다.

 

결과를 보면 2009년 9월부터 2021년 1월 4일까지의 데이터가 있습니다. 이제 위 내용을 가지고 각종 차트를 뚝딱뚝닥 만들어 보면 됩니다. 엑셀의 차트 버튼만 눌러주면 됩니다. 차트 만드는 과정은 생략하고 결과만 올려드립니다.

 

1. 인버스 VS 코덱스 종가

너무 많은 일수 데이터를 사용해 그림이 잘 보일지 모르겠습니다. 빨간색이 코덱스이고 파란색이 인버스입니다.

 

코덱스(빨간색)의 흐름을 읽어보면 아래와 같습니다.

  - 2013년전에는 푹푹 꺼지는 경우가 있는데, 이 부분은 제외하고 분석해야 할거 같습니다.

  - 2013년부터 2015년까지는 거의 횡보인거 같습니다.

  - 2015초에는 코덱스가 좀씩 빠더니 2016년부터 상승을 하는거 같습니다.

  - 2018년부터 하락, 아마 미중 무역 전쟁 타이밍 아닌가 싶습니다.

  - 2020년초, 코로나 발생, 깊은 하락후 하늘을 뚫어버렸습니다.

 

인버스(파란색)의 흐름은 읽어볼게 없습니다. 코덱스에 비해 정말 잔잔합니다. 다만 코로나 쯤에 한번, 잠깐 솟아 오른 부분만 눈에 뜁니다.

차트를 보고 제가 내린 결론은, 역시 인버스를 롱으로 가져간다는 것은 위험하다. 여러 증권 관련 프로에서도 들었던 말인거 같습니다. 차트를 통해 다시 한번 확인했습니다.

 

 

2. 인버스(주축) VS 코덱스(보조축)

앞에서 살펴본 차트에서는 인버스 ETF와 코덱스의 ETF의 가격대가 다르다 보니, 인버스의 경우 오르고 내리는게 눈에 뛰지 않는 단점이 있습니다. 그래서 인덱스를 주축(왼쪽)으로 코덱스를 보조축(오른쪽)으로 만들어서 비교해봤습니다. 아래와 같습니다.

 

마찬가지로 인버스는 파란색, 코덱스는 빨간색입니다. 인버스와 코덱스가 서로 역으로 움직인다는 것이 너무나 잘 보입니다.

이 차트를 통해 고민하는 것은 하나입니다. 지수는 너무 올랐고, 인버스는 너무 빠졌으니.. 인버스를 함 들여다 봐야 하나라는 것입니다. 하지만!!! 그럼에도 불구하고 인버스는 단발성 상승 가능성이 높기 때문에 손 대기에 매우 위험합니다.

그뿐만 아니라,  이처럼 단순한 비교만 가지고 '어디에 투자하자' 라고 절대 결론 지을수는 없습니다.

 

역시 부정보다는 긍정에 투자하자.. 이게 맞지 않나 생각합니다. 그럼에도 불구하고 주가가 높으니 어떤 종목에 들어갈지는 고민되는 상황인거 같습니다.

여러분들도 첨부 엑셀을 가지고 두들겨 보면서 각자 고민해보시는 시간을 가져보시기 바랍니다~! 좀더 시간을 나눠서 분석하고 그때의 상황까지 고려해본다면 무슨 답이 나올지도 모르겠네요. 역시.. 좀 더 세밀하게 분석하려면 SQL과 파이썬 공부를 추천드립니다.!

 

이와 같은 분석은 데이터베이스를 구축해 SQL을 활용한다면 훨씬 더 쉽고 강력하게 처리할 수 있습니다.

SQL과 주식에 관심이 있다면 아래 책을 참고해주세요.

※ 주의 사항

  ▶ 절대 특정 종목을 추천하거나 투자를 권유하는 책이 아닙니다.

  ▶ 데이터 분석을 공부하기 위한 MySQL 책입니다.

  ▶ 책의 내용을 통해 얻은 종목에 투자해 발생한 손해는, 저를 비롯한 책 관계자 누구도 책임지지 않습니다.

※ 책 소개: https://sweetquant.tistory.com/243

※ 책 미리보기: https://sweetquant.tistory.com/257

※ 완전판 E-Book

  ▶ 유페이퍼: https://www.upaper.net/ryu1hwan/1142997

  ▶ 알라딘: https://www.aladin.co.kr/shop/wproduct.aspx?ItemId=273701425 

  ▶ Yes24: http://www.yes24.com/Product/Goods/102264444?OzSrank=1 

  ▶ 교보문고: https://digital.kyobobook.co.kr/digital/ebook/ebookDetail.ink?selectedLargeCategory=001&barcode=4801167630019&orderClick=LAG&Kc=

 

 

 

 

 

 

오늘은 엑셀 함수인 VLOOKUP을 다뤄볼까 합니다. D-Tech 관련된 글을 적으면서 엑셀의 VLOOKUP을 사용하는 경우가 많기 때문에 VLOOKUP만 별도로 한번 다루는 것이 좋다고 생각했습니다.

 

먼저 엑셀의 VLOOKUP은 Vertical Lookup의 줄임말입니다. Lookup은 검색의 뜻을 가지고 있습니다. 즉, VLOOKUP은 세로 검색을 뜻합니다.

여기서는 예제를 다루기 위해 주식 데이터를 사용합니다.

일부 종목에 대한 2020년3월20일 종가와 2020년 12월30일 종가를 찾는 연습을 할 예정입니다. 아래는 연습에 사용할 파일입니다.

 

VLOOKUP_연습데이터.xlsx
0.27MB

 

 

먼저 엑셀에 아래와 같이 특정 종목 몇개를 적어줍니다.

 

반드시 위의 종목들이 아니어도 됩니다. 자신이 알고 싶은 종목을 넣으셔도 됩니다.

이번에는 2020년 3월 20일 종가와 2020년 12월 30일 종가를 구해야 합니다. KRX사이트에서 손쉽게 구할 수 있습니다.

KRX에서 데이터를 다운 받는 방법은 아래를 참고해주세요.(아래 글에도 VLOOKUP을 사용합니다. 아래 글에 비해 본 글은 VLOOKUP만 깊게 설명합니다.)

sweetquant.tistory.com/206 

 

코로나 이후 가장 많이 오른 업종은?(Feat. KRX산업분류)

데이터로 하는 재테크, D-Tech! 오늘은 코로나 이후 가장 많이 오른 업종을 찾아보려고 합니다. 업종별로 오른 등락률을 통해 앞으로 어떤 업종을 투자해야 할지 각자 고민해볼 수 있겠죠. 업종별

sweetquant.tistory.com

 

KRX에서 2020년 3월 20일 데이터와 2020년 12월 30일 데이터를 다운 받았다면, 엑셀을 아래와 같이 구성합니다.

여기서는 2020년3월20일 주가 데이터는 F열부터 시작이고 2020년 12월 30일 주가 데이터는 N열부터 시작입니다. 이 위치에 따라 VLOOKUP에서 처리할 값이 달라지므로 본 문서와 똑같이 하려면 시작 위치를 맞쳐주셔야 합니다.

 

VLOOKUP을 처리하기 위해서는 위와 같이 한 시트에 필요한 데이터를 모아 놓는 것이 편합니다.

VLOOKUP은 총 4개의 값이 필요합니다. 정리하면 아래와 같습니다.

  - VLOOKUP(lookup_value, table_array, col_index_num, [range_lookup])

   - lookup_value : 검색할 값(검색어)

   - table_array : 검색을 수행할 데이터

      : lookup_value는 무조건 table_array의 첫 번째 열과 검색을 수행합니다.

      : 그러므로 table_array를 정할 때 시작 위치를 잘 정해야 합니다.

   - col_index_num : table_array에서 검색한 후에 돌려줄 값의 위치를 정합니다.

      : 만약에 table_array의 첫 번째 열이 종목명, 두 번째 열이 산업분류, 세번째 열이 현재가라면,

      : col_index_num을 1을 입력하면 lookup_value에 해당하는 데이터를 찾 은 후 종목명 값을 돌려줍니다.

      : col_index_num을 2를 입력하면 lookup_value에 해당하는 데이터를 찾 은 후 산업분류 값을 돌려줍니다.

   - range_lookup : 정확히 일치하는 데이터만 찾을지 정도를 설정합니다. 

      : false를 입력해야 정확히 일치하는 데이터만 찾습니다. 대부분 false로 처리합니다.

 

이제 본격적으로 VLOOKUP을 적용해볼 차례입니다.

아래와 같은 과정으로 2020년 3월 30일 종가를 VLOOKUP으로 찾아냅니다.

1. B2셀에 아래의 내용을 입력합니다.

    =VLOOKUP(A2,H:J,3,false)

2. 첫 번째 파라미터에 검색할 값을 입력합니다. 여기서는 A2셀이므로 A2를 입력합니다.

3. 검색을 처리할 대상 테이블배열을 입력합니다. 여기서는 H열부터 J열까지이므로 H:J 라고 입력합니다.

4. 검색해서 찾아낼 값은 종가입니다. (3)번에서 입력한 대상 테이블 범위인 H~J에서 세 번째 열입니다.

    : H는 첫 번째 열, I는 두 번째 열, J는 세번째 열입니다.

 

위에서 입력한 '=VLOOKUP(A2,H:J,3,false)'의 내용을 풀어쓰면 'A2에 있는 값을 H~J의 첫 번째 열인 H열에서 검색해서 H열에서 A2와 같은 데이터가 나오면, 해당 데이터의 3번째 열(J열, 현재가(종가))의 값을 보여주세요'와 같습니다.

 

처리된 VLOOKUP의 결과를 아래 행으로 모두 카피해줍니다. 아래 그림과 같이 모서리 부분을 더블클릭하면 됩니다.

 

이제 C열에 2020년 12월 30일 종가를 가져와 볼까요? 아래와 같이 C2셀에 '=VLOOKUP(A2,P:R,3,FALSE)'를 입력하면 됩니다. VLOOKUP을 입력한 후에 바로 위에서 한 것처럼 아래 행으로 모두 카피해주세요.

기왕 VLOOKUP까지 한 거, 2020년 03월 20일부터 2020년 12월 30일 사이에 등락률까지 구해볼까요. 아래와 같이 D2셀에 '=(C2-B2)/B2'를 입력한 후에 상단 메뉴에 '%'를 클릭해 백분율로 표시되도록 합니다.

등락률을 구한후 아래 행까지 모두 카피하면 최종 아래와 같은 결과를 얻을 수 있습니다.

 

데이터를 분석할 때 VLOOKUP은 엑셀에서 가장 많이 쓰는 기능중에 하나입니다. 여러번 사용하면서 완전히 자신의 것으로 만드시길 바랍니다.~ 오늘은 여기까지입니다. 감사합니다.

 

이와 같은 분석은 데이터베이스를 구축해 SQL을 활용한다면 훨씬 더 쉽고 강력하게 처리할 수 있습니다.

SQL과 주식에 관심이 있다면 아래 책을 참고해주세요.

※ 주의 사항

  ▶ 절대 특정 종목을 추천하거나 투자를 권유하는 책이 아닙니다.

  ▶ 데이터 분석을 공부하기 위한 MySQL 책입니다.

  ▶ 책의 내용을 통해 얻은 종목에 투자해 발생한 손해는, 저를 비롯한 책 관계자 누구도 책임지지 않습니다.

※ 책 소개: https://sweetquant.tistory.com/243

※ 책 미리보기: https://sweetquant.tistory.com/257

※ 완전판 E-Book

  ▶ 유페이퍼: https://www.upaper.net/ryu1hwan/1142997

  ▶ 알라딘: https://www.aladin.co.kr/shop/wproduct.aspx?ItemId=273701425 

  ▶ Yes24: http://www.yes24.com/Product/Goods/102264444?OzSrank=1 

  ▶ 교보문고: https://digital.kyobobook.co.kr/digital/ebook/ebookDetail.ink?selectedLargeCategory=001&barcode=4801167630019&orderClick=LAG&Kc=

 

 

 

 

안녕하세요. 데이터를 분석해 재테크하기, 바로 D-Tech! 입니다.

 

오늘은 DART의 3분기 손익계산서(2016~2020)를 하나의 엑셀로 정리한 파일을 무료 나눔하려 합니다. 아무래도 DART에서 일괄다운로드한 엑셀에는 항목이 너무 많고 년도별로 다 쪼개져 있어서 분석하기 어려운 점이 있습니다.

 

DART(dart.fss.or.kr/) 에서 2016부터 2020까지 3분기 손익계산서만 사용했습니다. DART는 개인 투자자들에게 반드시 꼭 항상 옆에 두어야 하는 사이트인거 같습니다. (언제나 종목 매매전에 DART 들어가서 사업보고서 챙겨보는 습관이 필요합니다.!) 개인적으로 2016년 이전의 재무제표 데이터도 모두 얻어서 정리해서 공유하고 싶지만 쉽지 않네요.

제가 알기에는 DART 데이터만이 무료로 공유가 가능한거 같습니다.

 

개인 투자자분들의 분석에 용이하도록 다음과 같이 정리했습니다.

  - 매출액, 영업이익, 당기순이익, 금융수익, 주당순이익 항목만 정리

  - 원래 DART 에서 받은 파일은 항목별로 로우가 분리되어 있습니다. 이를 결산기준일+종목별로 컬럼으로 처리

    (매출액, 영업이익, 당기순이익, 금융수익, 주당순이익이 컬럼으로 표시됩니다.)

  - 주가 변동을 같이 분석할 수 있도록 결산기준일에 해당하는 년도의 월별 종가를 추가

  - 포괄손익계산서_연결 파일만 사용(일부 종목이 없을 수 있습니다.)

  - 개인 참고용으로 만든 데이터이기 때문에, 데이터의 정확성을 책임지지는 않습니다.

 

아래는 샘플 그림입니다.

 

 

이와 같은 파일을 만들기 위해서 MySQL로 DART 보고서를 업로드 한 후에 데이터를 재가공했습니다. 월별 주가 같은 경우는 제가 꾸준히 API로 수집하고 있던 데이터입니다.(액분등의 이벤트로 주가가 수정된 경우 약간 안맞을수도 있겠습니다. 대부분은 맞습니다.)

 

엑셀 파일입니다.~

 

데이터나눔_20201204_DART기준_3분기포괄손익_연결_20201208.xlsx
2.06MB

 

이 파일로 해볼만한 것들은 다음과 같은 것들이 있습니다.

  - 영업이익은 급등했으나 주가는 내려간 주식 찾기

  - 반대로, 영업이익 급락했으나 주가는 올라간 주식 찾기

  - 매출 줄었지만 영업이익은 늘어난 주식

  - 영업이익과 당기순이익, 주가가 모두 올라간 주식

 

물론 이런 종목을 찾아서 투자했을때 수익이 날 수 있는지도 엑셀을 이용해 시뮬레이션 해보실수 있습니다.

2016년 데이터부터 있으니 과거 데이터랑 주가를 결합해보시면 됩니다.

 

분석 방법까지는 여기서 다루지는 않습니다. 기존의 제가 올렸던 글들을 참고해주세요.

sweetquant.tistory.com/174?category=1163308

 

영업이익과 주가 변화로 포트폴리오 만들어보기

데이터로 하는 재테크, D-Tech! 오늘은 영업이익과 주가 변화를 활용해 포트폴리오를 만들어 보겠습니다. 간단하게 '영업이익은 올랐지만, 주가는 오히려 떨어진 주식이라면 매수 찬스?'라는 아이

sweetquant.tistory.com

 

그리고... 이와 같은 작업은 엑셀보다 데이터베이스화해서 SQL로 작업하는 것이 훨씬 더 쉽습니다. 생각보다 SQL을 배우기는 어렵지 않습니다. 개인 업무와 직장 경력에도 큰 도움이 되는 것이 데이터와 SQL입니다. 여유가 있으시면 한 번 공부해 보시기 바랍니다. 이것도 제 블록그에서 주식 데이터와 연계해 최대한 쉽게 쓰려고 하고 있습니다.

4차 산업 혁명의 중심. 그리고 미래의 원유인 데이터. 데이터를 다루기에 가장 적합한 언어 SQL.. 반드시 배워두시는 것이 좋습니다.

이와 같은 분석은 데이터베이스를 구축해 SQL을 활용한다면 훨씬 더 쉽고 강력하게 처리할 수 있습니다.

SQL과 주식에 관심이 있다면 아래 책을 참고해주세요.

※ 주의 사항

  ▶ 절대 특정 종목을 추천하거나 투자를 권유하는 책이 아닙니다.

  ▶ 데이터 분석을 공부하기 위한 MySQL 책입니다.

  ▶ 책의 내용을 통해 얻은 종목에 투자해 발생한 손해는, 저를 비롯한 책 관계자 누구도 책임지지 않습니다.

※ 책 소개: https://sweetquant.tistory.com/243

※ 책 미리보기: https://sweetquant.tistory.com/257

※ 완전판 E-Book

  ▶ 유페이퍼: https://www.upaper.net/ryu1hwan/1142997

  ▶ 알라딘: https://www.aladin.co.kr/shop/wproduct.aspx?ItemId=273701425 

  ▶ Yes24: http://www.yes24.com/Product/Goods/102264444?OzSrank=1 

  ▶ 교보문고: https://digital.kyobobook.co.kr/digital/ebook/ebookDetail.ink?selectedLargeCategory=001&barcode=4801167630019&orderClick=LAG&Kc=

 

 

 

 

 

언제나 강조드리고 싶은 말씀은, 이 글과 자료는 투자를 권유하거나 특정 종목을 추천하려는 글이 아닙니다. 데이터를 이와 같이 분석할 수 있음을 알려드리고 싶은 글입니다. 또한 이 글과 자료에 나온 종목에 투자해 발생한 손해는 투자자 본인의 책임임을 명심하시기 바랍니다.

 

읽어주셔서 감사합니다.! 언제나 개인투자자들의 성투를 기원합니다. (저역시..ㅠㅠ)

요즘 주가가 너무 올라서... 새로 들어가기 어려운 구간이네요.ㅜㅜ

 

데이터로 하는 재테크 D-Tech

 

안녕하세요. 오늘은 트레이딩뷰(kr.tradingview.com)를 활용해 손쉽게(?) 투자 종목을 찾는 과정을 설명드리려 합니다.

여기서는 베트남 주식시장을 사용하지만, 한국, 미국, 일본, 중국 모두에 적용하실 수 있습니다.

오늘 필요한 IT 기술은 단지 '인터넷'과 '키보드&마우스'입니다.

 

투자 포트폴리오 구성 시나리오

a. 트레이딩뷰를 활용

b. 스탁 스크리너 메뉴를 사용합니다.

c. 베트남 주식시장에서 시가총액이 크면서 배당금이 높은 종목을 찾아내 투자 전략을 만듭니다.

 

본격적인 설명에 앞서 늘 드리는 말씀입니다.

이 글은 투자를 권유하거나 특정 종목을 추천하려는 글이 아닙니다. 데이터를 이와 같이 분석할 수 있음을 알려드리고 싶은 글입니다. 또한 이 글에 나온 종목에 투자해 발생한 손해는 투자자 본인의 책임임을 명심하시기 바랍니다.

 

1. 트레이딩뷰에 접속

트레이딩뷰는 전세계 주식 뿐 아니라, 가상화폐, 채권 정보까지 제공하는 사이트입니다.

무료로 사용해도 인베스팅닷컴처럼 광고가 여기저기 붙지 않아 보기 편한 점도 있습니다.

유료 기능도 있지만 무료 사용만 해도 기본적으로 투자 종목을 찾는데 큰 도움이 되는 사이트입니다.

아래 URL로 트레이딩뷰에 접속합니다.

https://kr.tradingview.com/

 

무료 스탁 차트, 스탁 쿼트 및 트레이드 아이디어

라이브 호가, 무료 차트 및 전문 트레이딩 아이디어. TradingView 는 주식, 선물 및 포렉스 마켓에서 트레이더와 인베스터를 서로 이어주는 소셜 네트워크입니다.

kr.tradingview.com

 

트레이딩뷰에 접속하면 나오는 상단 메뉴중에 스크리너를 선택합니다. [그림 1]과 같습니다.

[그림 1]

 

2. 베트남 주식 시장을 선택하고 컬럼 설정하기

[그림 2]와 같이 스탁 스크리너 화면에서 주식 시장을 선택하고 필요한 컬럼들을 선택합니다.

[그림 2]

  1. 베트남 주식 시장을 선택합니다.

  2. 해당 부분을 클릭하면 보고 싶은 컬럼들을 선택할 수 있습니다.

  3. 저는 시가총액이 크면서, 배당을 많이 주는 주식에 투자하고자 [그림 2]의 3과 같은 항목을 선택했습니다.

     - 마켓 캪 = 시가총액입니다.

     - 지불 배당금(FY)가 두 개 있는데, 하나는 금액이고 하나는 배당률입니다.

     - 각자 찾고 싶은 종목, 투자하고 싶은 전략에 따라 필요한 컬럼들을 선택하시면 됩니다.

  4. 조회된 데이터에서 시가총액 항목을 클릭해 시가총액이 높은 데이터 먼저 나오게 합니다.

 

위의 작업을 완료하면 [그림 3]과 같은 결과를 볼 수 있습니다.

[그림 3]

 

저는 시가총액 순으로 조회하면서 그 중에 배당금이 높은 종목을 찾아서, 어떤 기업인지 살펴보고 투자를 결정했습니다.

시가총액 상위에도 배당 수익이 10%가 있는데... 실화인가요..ㅡㅡ;;; 아직 받아보지 않아 잘 모르겠습니다.

 

 

오늘 설명은 여기까지입니다. 읽어주셔서 감사합니다.

이와 같은 분석은 데이터베이스를 구축해 SQL을 활용한다면 훨씬 더 쉽고 강력하게 처리할 수 있습니다.

SQL과 주식에 관심이 있다면 아래 책을 참고해주세요.

※ 주의 사항

  ▶ 절대 특정 종목을 추천하거나 투자를 권유하는 책이 아닙니다.

  ▶ 데이터 분석을 공부하기 위한 MySQL 책입니다.

  ▶ 책의 내용을 통해 얻은 종목에 투자해 발생한 손해는, 저를 비롯한 책 관계자 누구도 책임지지 않습니다.

※ 책 소개: https://sweetquant.tistory.com/243

※ 책 미리보기: https://sweetquant.tistory.com/257

※ 완전판 E-Book

  ▶ 유페이퍼: https://www.upaper.net/ryu1hwan/1142997

  ▶ 알라딘: https://www.aladin.co.kr/shop/wproduct.aspx?ItemId=273701425 

  ▶ Yes24: http://www.yes24.com/Product/Goods/102264444?OzSrank=1 

  ▶ 교보문고: https://digital.kyobobook.co.kr/digital/ebook/ebookDetail.ink?selectedLargeCategory=001&barcode=4801167630019&orderClick=LAG&Kc=

 

 

 

데이터로 하는 재테크 D-Tech.

 

오늘은 제목을 좀 재미있게(?) 뽑아봤습니다.

 

이 글은 테마주에 투자를 추천하는 글이 절대 아닙니다. 여기서 이야기하는 테마는 갑작스러운 또는 너무도 불확실한 그런 테마가 아닙니다. 어느 정도 실적이 나오면서 전체적으로 뉴스에서도 많이 다루는 그런 테마를 뜻합니다. 산업의 큰 변화 경제적인 큰 변화를 만드는 테마를 뜻합니다. 그런 테마가 만들어졌을 때(대표적으로 2차 전지, 반도체) 어떤 종목을 어떻게 선별할지를 다루고자 합니다. 그럴 때 팍스넷을 적절하게 사용하면 매우 유용하기에 이렇게 소개하게 되었습니다.( 절대 팍스넷 관련자도 아닙니다! )

그리고.. 저만 알고 싶은 방법입니다. 좀 아꿉네요. ㅜ.ㅜ

 

 - 필요 IT 기술 : 엑셀과 웹서핑 능력, 그리고 Ctlr+C와 Ctrl+V 능력

 

제 입으로 이런 말씀드리기 그렇지만... 사실 저는 DB(데이터베이스) 전문가입니다. SQL 관련 책도 한 권 집필했지요.(부끄럽습니다) 그러므로 이러한 주식 데이터는 DB화 해서 분석하는 것이 저에게는 더 쉽습니다. 하지만 많은 분들이 DB를 접하기에는 아직 거리가 먼 거 같아.. 우선은 쉽게 데이터에 접할 수 있도록 엑셀만 사용하는 선에서 주식 데이터를 분석하는 과정을 설명하고 있습니다.

 

포트폴리오 구성 시나리오

a. 팍스넷 접속

   - 2차 전지 테마주 관련 종목을 엑셀로 카피한다.

b. 엑셀에서 시가총액, 목표주가 괴리율 등에 순위를 부여한다.

c. 부여된 순위의 합이 가장 좋은 종목들을 추려 투자하거나 분석한다.

 

여기서는 팍스넷을 사용해 2차 전지 테마주 들 중에 옥석(?)을 가리는 작업을 해보도록 하겠습니다. 이미 2차 전지에 대한 테마는 형성된 지가 제법 되었습니다. 지금 뛰어든다는 것은 위험할 수도 있습니다. 그러므로 여기서 소개하는 글은 과정에 대한 참고로 생각해 주시기 바랍니다.

 

본격적인 설명에 앞서 늘 드리는 말씀 적어 드리고 시작하겠습니다.

이 글은 투자를 권유하거나 특정 종목을 추천하려는 글이 아닙니다. 데이터를 이와 같이 분석할 수 있음을 알려드리고 싶은 글입니다. 또한 이 글에 나온 종목에 투자해 발생한 손해는 투자자 본인의 책임임을 명심하시기 바랍니다.

 

 

1. 팍스넷 접속

팍스넷(http://www.paxnet.co.kr/)에 접속해 상승테마 메뉴로 이동합니다. [그림. 1]과 같습니다.

 

[그림. 1]

 

  1. www.paxnet.co.kr  로 접속합니다. 상단에 '종목입체분석'을 선택

  2. 나온 서브 메뉴들 중에 '상승테마'를 선택

 

[그림. 2]와 같이 상승테마 메뉴로 이동하면, 가나다순으로 정렬해 2차전지를 선택합니다. 나중에는 각자 원하는 테마로 작업을 하셔도 되겠죠.

[그림. 2]

  1. 가나다순을 선택합니다.

  2. 첫 페이지에 나온 2차 전지를 선택합니다.

 

2차 전지 관련 종목들이 나오면, [그림. 3]과 같이 필요한 항목들을 변경해서 다시 검색을 합니다. 제가 임의로 정한 분석에 필요한 항목입니다. 나중에는 각자 필요한 항목을 사용하시면 됩니다.

[그림. 3]

  1. '조건검색' 부분을 클릭

  2. '현재가, 시가총액(억), PER(배), PBR(배), ROE(%), 목표주가괴리율'만 선택합니다.

  3. 선택 조건 검색 클릭

 

위와 같은 작업을 하면, 선택한 항목의 내용들만 추려서 조회가 다시 진행됩니다. 결과가 조회되면 기간을 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 책입니다.

  ▶ 책의 내용을 통해 얻은 종목에 투자해 발생한 손해는, 저를 비롯한 책 관계자 누구도 책임지지 않습니다.

※ 책 소개: https://sweetquant.tistory.com/243

※ 책 미리보기: https://sweetquant.tistory.com/257

※ 완전판 E-Book

  ▶ 유페이퍼: https://www.upaper.net/ryu1hwan/1142997

  ▶ 알라딘: https://www.aladin.co.kr/shop/wproduct.aspx?ItemId=273701425 

  ▶ Yes24: http://www.yes24.com/Product/Goods/102264444?OzSrank=1 

  ▶ 교보문고: https://digital.kyobobook.co.kr/digital/ebook/ebookDetail.ink?selectedLargeCategory=001&barcode=4801167630019&orderClick=LAG&Kc=

 

 

 

Part.06 기본적 분석 투자 전략

기본적 분석 투자는 기업의 내재 가치(재무제표나 각종 2차 지표)를 분석해 투자하는 전략입니다.

기업의 가치를 분석하는 것을 기본적 분석이라고 합니다. 기업의 가치는 재무제표의 수치나 PER, PBR, ROE와 같은 각종 2차 지표를 통해 결정됩니다.

 

이 책에서 구현한 퀀트 SQL들은 최소한의 위험도를 피하면서(예를 들어, 당기순이익이 500억 이상인 종목만 투자합니다.) 지표의 순위를 사용해 좋은 종목들을 선별해 투자하는 전략을 사용합니다.

 

앞에서 설명한 퀀트 SQL 기본 패턴만 정확히 이해하고 있다면 전혀 어렵지 않의 차분하게 따라서 실습해보시기 바랍니다.

 

데이터로 하는 재테크, D-Tech!

 

오늘은 영업이익과 주가 변화를 활용해 포트폴리오를 만들어 보겠습니다. 간단하게 '영업이익은 올랐지만, 주가는 오히려 떨어진 주식이라면 매수 찬스?'라는 아이디어로 포트폴리오를 구성해서 시뮬레이션하는 과정입니다.

 

결론부터 말씀드리면, 이 포트폴리오는 보유기간 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분기 영업이익 좋은 종목 찾기'를 먼저 따라 해 주세요.

sweetquant.tistory.com/150

 

DART로 20년 3분기 영업이익 좋은 종목 찾기

DART로 20년 3분기 영업이익 좋은 종목 찾기 데이터를 활용하는 재테크, D-Tech입니다. 이름만 너무 거창한 거 아닌지 모르겠습니다. 민망함을 무릅쓰고 D-Tech란 주제 아래 글을 하나씩 채워보도

sweetquant.tistory.com

 

1. DART에서 2018년 사업 보고서 다운로드하기.

[그림. 1]을 참고해 DART(http://dart.fss.or.kr/)에서 2018년의 사업보고서를 다운로드합니다.

[그림. 1]

 

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]를 참고해주세요.

[그림. 3]

  1. 엑셀 최상위 메뉴에서 데이터 > 필터를 선택

  2. 항목코드 부분의 화살표를 클릭

  3. dart_OperatingIncomeLoss를 입력(해당 항목코드가 영업이익입니다.)

  4. 확인을 클릭 ( 항목코드가 영업이익인 데이터만 조회됩니다.)

  5. 결산기준일 부분의 화살표를 클릭

  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월의 종가가 있습니다.

 

종목별_특정월종가.xlsx
0.08MB

다운로드한 파일의 내용을 지금 작업하고 있는 엑셀의 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]

 

데이터가 '당기/전기' 순서로 내림차순 되어 있습니다. 그러므로 '당기/전기 순위'를 만들어 보이는 순서대로 순위를 추가합니다.

[그림. 12]

  1. O열 1행을 '당기/전기 순위'로 하고, O2(O열 2행)에 1, O3에 2, O4에 3을 입력합니다.

  2. O2부터 O4까지 세 개 셀을 마우스로 선택한 후에, 우측 아래 부분을 더블클릭합니다.

      - 세 개 행 정도가 연속된 숫자인 상태에서 아랫부분을 더블클릭하면, 나머지 행에 순차적 번호가 자동 생성됩니다.

 

당기/전기 순위까지 만들어졌습니다. 순위가 작은 종목이 좋은 종목이겠죠.(영업이익이 많이 오른 종목)

 

9. 19년4월 / 18년4월 종가 수식 구하기

영업이익만이 아니라, 주가가 많이 떨어진 것까지 고려해서 포트폴리오를 만들 예정입니다. [그림. 13]과 같이 18년 4월 대비 19년 4월의 종가 비율을 계산합니다.

[그림. 13]

 

  1. R열 1행에는 '19년/18년'으로 헤더 명을 입력, R열 2행에는 '=N2/M2' 수식을 입력합니다.

 

만들어진 수식을 아래 행에 모두 카피를 해주시면 됩니다.(앞에서도 반복 설명했기 때문에, 설명은 생략합니다.)

 

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 책입니다.

  ▶ 책의 내용을 통해 얻은 종목에 투자해 발생한 손해는, 저를 비롯한 책 관계자 누구도 책임지지 않습니다.

※ 책 소개: https://sweetquant.tistory.com/243

※ 책 미리보기: https://sweetquant.tistory.com/257

※ 완전판 E-Book

  ▶ 유페이퍼: https://www.upaper.net/ryu1hwan/1142997

  ▶ 알라딘: https://www.aladin.co.kr/shop/wproduct.aspx?ItemId=273701425 

  ▶ Yes24: http://www.yes24.com/Product/Goods/102264444?OzSrank=1 

  ▶ 교보문고: https://digital.kyobobook.co.kr/digital/ebook/ebookDetail.ink?selectedLargeCategory=001&barcode=4801167630019&orderClick=LAG&Kc=

 

 

길고 따분한 글 읽어주셔서 감사합니다. 마지막으로 거듭 말씀드리지만, 종목을 추천하기 위해 쓴 글이 아니며, 투자는 절대 투자자 본인의 책임임을 명심해주시기 바랍니다.

 

이상입니다. 감사합니다. 모두 성투하시길~!

 

DART로 20년 3분기 영업이익 좋은 종목 찾기

데이터를 활용하는 재테크, D-Tech입니다. 이름만 너무 거창한 거 아닌지 모르겠습니다. 민망함을 무릅쓰고 D-Tech란 주제 아래 글을 하나씩 채워보도록 하겠습니다. 이 글을 읽고 따라 해 보기 위해 필요한 기술은 오직 엑셀(Excel)입니다.

 

DART라는 전자공시 사이트가 있습니다. 주식 투자를 심도 있게 해 보신 분들은 한 번씩은 들어 보셨을 겁니다.

DART만 잘 확인하고, DART의 데이터만 잘 사용하면... 폭망(?)은 피해 갈 수 있지 않을까 생각해 봅니다.

DART에는 기업의  재무정보가 분기별로 올라옵니다. 그리고 친절하게도 이 재무정보를 모두 일괄 다운로드할 수 있습니다.

오늘은 DART에서 따끈따끈한 2020년 3분기 보고서를 다운로드해서 3분기 영업이익 비중이 좋은 종목을 찾는 과정을 살펴보까 합니다.

 

설명에 앞서 강조드리고 싶은 말은, 이 글은 투자를 권유하거나 특정 종목을 추천하려는 글이 아닙니다. 데이터를 이와 같이 분석할 수 있음을 알려드리고 싶은 글입니다. 또한 이 글에 나온 종목에 투자해 발생한 손해는 투자자 본인의 책임임을 명심하시기 바랍니다.

 

 - 사용 데이터 : DART 20년 3분기 재무정보(포괄손익계산서_연결)

 - 필요 IT 기술 : 엑셀

 - 투자 시나리오 : 20년 전체 영업이익 중에 3분기 영업이익의 비중이 높은 종목 찾아내기

 

 

1. DART 접속해서 20년 3분기 재무정보 다운로드하기

아래는 DART의 URL입니다. 

  - http://dart.fss.or.kr/

 

DART에 접속해 [그림. 1]과 같은 과정으로 재무정보를 일괄 다운로드합니다.

[그림. 1]

  1. 상단 메뉴에서 '공시정보 활용마당' 선택

  2. 좌측 메뉴에서 재무정보 '일괄 다운로드' 선택

  3. 우측에서 2020년 3분기 보고서 '손익계산서'를 다운로드

 

다운로드할 수 있는 재무정보에는 재무상태표, 손익계산서, 현금흐름표, 자본변동표가 있습니다. 여기서는 손익계산서를 사용합니다. 이 글을 쓰는 시점으로 다운로드한 손익계산서의 파일 명은 '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 책입니다.

  ▶ 책의 내용을 통해 얻은 종목에 투자해 발생한 손해는, 저를 비롯한 책 관계자 누구도 책임지지 않습니다.

※ 책 소개: https://sweetquant.tistory.com/243

※ 책 미리보기: https://sweetquant.tistory.com/257

※ 완전판 E-Book

  ▶ 유페이퍼: https://www.upaper.net/ryu1hwan/1142997

  ▶ 알라딘: https://www.aladin.co.kr/shop/wproduct.aspx?ItemId=273701425 

  ▶ Yes24: http://www.yes24.com/Product/Goods/102264444?OzSrank=1 

  ▶ 교보문고: https://digital.kyobobook.co.kr/digital/ebook/ebookDetail.ink?selectedLargeCategory=001&barcode=4801167630019&orderClick=LAG&Kc=

 

 

마지막으로 거듭 말씀드리지만, 종목을 추천하기 위해 쓴 글이 아니며, 투자는 절대 투자자 본인의 책임임을 명심해주시기 바랍니다.

이상입니다. 감사합니다. 모두 성투하시길~!

 

 

 

 

MySQL을 설치하고 새로운 사용자를 만들어 접속을 하려 할 때 접속이 안될 때가 있습니다. 보통 이런 경우, Windows의 방화벽 설정에 문제가 있을 수 있습니다.

 

ROOT가 아닌 일반 사용자가 접속이 안된다면 아래 과정을 참고해 방화벽 포트 허용 설정을 추가합니다. [그림. 1]과 [그림. 2]를 참고해서 진행해 주세요.

 1. Windows 메뉴를 누른 후 '고급 보안'을 검색해 '고급 보안이 포함된 Windows Defender'를 실행합니다.

 2. 인바운드 규칙을 선택합니다.

 3. 새 규칙을 선택합니다.

 4. 포트를 선택 > 포트에 3306 입력 > 연결 허용 선택 > 규칙 시기, 모두 체크 > 규칙명 임의로 입력

 

[그림. 1]

 

 

 

[그림. 2]

 

이와 같은 설정이 끝난 후에, 만들어진 일반 사용자로 다시 접속해보시기 바랍니다.

 

RANK와 GROUP BY

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에서는 에러를 발생시키고 실행조차 되지 않습니다.

 

오늘도 커피에 과소비 한번 하러 신용산역 지하에 다녀왔슴다. 많은 커피샵들이 있는데, 그 중에 비엔나커피를 가봤슴욥. 넓고 인테리어가 으리으리 멋지네욤
난중에 우리 와이프랑 가야겠어욤.
라떼를 시켰는데, 맛도 괜춚네요. 우유의 텁텁함도 별로 없고 라떼보다는 카푸치노에 좀 가깝네요. 커피도 막 진하진 않고 좋네요. 담에는 아메리카노로 함 마셔봐야겠어요.

제목 : 배달의 민족, 주문 총금액 어떻게 구현하지?

원본 위치 : DB 전문가 네트워크 디비안 (https://cafe.naver.com/dbian)

작성자 : SweetBoss

작성일 : 2019.11.05

 

1. 목적 및 환경

이 글은 누구라도 데이터를 활용 할 수 있게 하는데 목적이 있습니다. 기본적으로 SELECT, UPDATE등의 SQL은 사용할 줄 아는 사람들을 대상으로 합니다. 이 글을 통해 SQL 실력도 향상할 수 있습니다. 이번 글은 특히 MySQL의 성능을 다양하게 측정해보는 과정에 목적이 있습니다.

 

사용 DBMS : MySQL 5.7 Windows

사용 Tool : MySQL Workbench, MySQL Command Line Client

활용 주제 : MySQL의 성능, 나의 총 주문금액

선행 주제 : 차례대로입니다.

1.     https://cafe.naver.com/dbian/2331

2.     https://cafe.naver.com/dbian/2335

3.     https://cafe.naver.com/dbian/2337

4.     https://cafe.naver.com/dbian/2338

5.     https://cafe.naver.com/dbian/2341

6.     https://cafe.naver.com/dbian/2391

 

 

2. ‘배달의 민족나의 총 주문금액

오늘은 데이터 활용과는 약간 어긋난 주제가 될지도 모르겠습니다. 최근에 이슈가 된 배달의 민족나의 총 주문금액을 구하는 과정을 구현해보려고 합니다. 어쩌면 뻔한 이야기가 될 수도 있습니다. 아직은 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
    ;

SQL8번과 11번 라인의 @RNO를 이용해서 순번을 부여하는 패턴은 유용하니 익혀두도록 합니다. 하지만 해당 패턴은 절대 남발하지 않습니다. 임시성이나 일회성 SQL에만 적절한 패턴입니다. 실제 서비스되는 SQL에서는 사용하지 않기를 가이드합니다. (이유는 성능상, 불리할 수 있기 때문입니다.)

 

 

(3) 상장매핑 임시 테이블 생성

M_ShopShopNo 1부터 시작하지 않습니다. 주문 데이터를 생성할 때, 실제 M_ShopShopNo를 물리기 위해서, ShopNo1부터 시퀀스한 숫자를 매핑해서 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) 주문 테이블 생성 및 데이터 생성

아래 SQLT_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
;

 

주문을 만들 때, UserNo30 미만인 경우는 매일 주문을 한 우수(?) 사용자처럼 주문을 만들어 줍니다. 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;

 

아래와 같은 실행계획이 나옵니다. TypeALL이고 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 인덱스 사용

이번에는 UserNoOrderAMT에 인덱스를 만들어서 테스트해보도록 합니다.

인덱스를 만들기 전에, EXPLAIN FORMAT=JSON을 사용해, 10번 사용자의 실행계획을 JSON형태로 조회해봅니다. EXPLAIN보다 자세한 정보들이 있습니다.

# 10번 사용자 주문금액 확인 - UserNo인덱스 – 실행계획 얻기
EXPLAIN FORMAT = JSON
SELECT SUM(T1.OrderAMT) , COUNT(*) FROM T_Order T1 WHERE T1.UserNo = 10;

 

 

아래와 같은 JSON형태의 실행계획을 얻을 수 있습니다.

# 10번 사용자 주문금액 확인 - UserNo인덱스 – 실행계획 결과
{
   "query_block": {
     "select_id": 1,
     "cost_info": {
       "query_cost": "2118.00"
     },
     "table": {
       "table_name": "T1",
       "access_type": "ref",
       "possible_keys": [
         "X_T_Order_1"
       ],
       "key": "X_T_Order_1",
       "used_key_parts": [
         "UserNo"
       ],
       "key_length": "4",
       "ref": [
         "const"
       ],
       "rows_examined_per_scan": 1765,
       "rows_produced_per_join": 1765,
       "filtered": "100.00",
       "cost_info": {
         "read_cost": "1765.00",
         "eval_cost": "353.00",
         "prefix_cost": "2118.00",
         "data_read_per_join": "96K"
       },
       "used_columns": [
         "OrderAMT",
         "UserNo"
       ]
     }
   }
 }

아래와 같이 UserNo+OrderAMT 복합 인덱스를 만든 후에, 동일한 10번 사용자의 총 주문 금액의 실행계획을 확인해 봅니다.

# 10번 사용자 주문금액 확인 – UserNo+OrderAMT인덱스 – 실행계획 얻기
CREATE INDEX X_T_Order_2 ON T_Order(UserNo, OrderAmt);

EXPLAIN FORMAT = JSON
SELECT SUM(T1.OrderAMT) , COUNT(*) FROM T_Order T1 WHERE T1.UserNo = 10;

{
   "query_block": {
     "select_id": 1,
     "cost_info": {
       "query_cost": "358.73"
     },
     "table": {
       "table_name": "T1",
       "access_type": "ref",
       "possible_keys": [
         "X_T_Order_1",
         "X_T_Order_2"
       ],
       "key": "X_T_Order_2",
       "used_key_parts": [
         "UserNo"
       ],
       "key_length": "4",
       "ref": [
         "const"
       ],
       "rows_examined_per_scan": 1765,
       "rows_produced_per_join": 1765,
       "filtered": "100.00",
       "using_index": true,
       "cost_info": {
         "read_cost": "5.73",
         "eval_cost": "353.00",
         "prefix_cost": "358.73",
         "data_read_per_join": "96K"
       },
       "used_columns": [
         "OrderAMT",
         "UserNo"
       ]
     }
   }
 }

이전 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를 조회합니다. 첫 번쨰 SQLUserNo+OrderAMT 인덱스를 사용하게 했고, 두 번째 SQLUserNo 인덱스를 사용하도록 힌트를 주었습니다.

SHOW PROFILES로 실행 성능을 확인해봅니다.

SHOW PROFILES;

저의 환경에서는 UserNo+OrderAMT 인덱스를 사용한 경우에는 0.008초로 측정되었고, UserNo 인덱스를 사용한 경우에는 0.2초로 측정되었습니다. 실제 시간으로도 UserNo+OrderAMT가 월등한걸 알 수 있습니다. (너무 당연한걸 길게 설명하고 있습니다..)

추가로.. 개인적으로 OrderAMT와 같은 금액, 단가와 같은 컬럼이 인덱스에 들어가는 것을 별로 추천하지는 않습니다. 꼭 어쩔 수 없을 때만 사용합니다.

 

 

 

6. 총 주문 금액 집계 테이블 전략

UserNo+OrderAMT 인덱스를 만들었어도, 10번 사용자의 총 주문 금액을 조회하려면 1,765건의 데이터를 읽어야 하는 것은 피할 수 없습니다. (물론, 인덱스의 리프 블록만 읽으므로 매우 빠릅니다.) 한 명이 사용하는 거라면 큰 이슈가 없겠지만, 배달의 민족처럼 매우 많은 사용자가 한 순간에 조회를 요청하면 이 또한 심한 부하가 발생될 수 있습니다. 이를 해결하기 위해 집계(배치) 테이블 전략을 시도해보겠습니다. 사실 집계 테이블을 하면 성능이 월등히 좋을거야란 추측으로 시도했으나 현실은 그렇지 않네요!!!!!. 어쨌든, SQL에 대한 공부로 생각해주시면 감사하겠습니다.

아래와 같은 집계 테이블을 설계합니다.

 

테이블을 보면, 누적주문금액 컬럼을 가지고 있습니다. 사용자의 주문년월까지의 누적주문금액을 저장합니다. 예를 들어 20181월이라면 최초부터 20181월까지의 주문금액 합계가, 20192월이라면 최초부터 20192월까지의 주문금액을 누적해서 저장합니다. 마지막 월의 해당 값만 읽어와서 총주문금액을 처리하는 전략입니다.

아래 스크립트로 테이블을 생성합니다.

# 사용자월별주문 집계 테이블 생성
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를 제외한 20199월까지의 주문을 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;

 

비교 결과는 아래와 같습니다.

비교해보면, ‘집계 테이블과 조인은 두 번의 executingsending data가 발생합니다. 아마도 조인을 위해 스토리지 엔진을 나누어서 다녀오기 때문인 듯 합니다.

지금의 상황으로는 집계 테이블이 유리한 상황은 아닌 거 같습니다. (하지만 데이터가 더 많고, 많은 사용자가 몰린다면 집계 전략이 훨씬 유리할거라 여전히 추측은 됩니다.) 추가로 해보고 싶은 전략도 많고, 데이터 분포 변경에 따른 테스트도 해보고 싶지만 여기까지 쓰고 줄이도록 하겠습니다.

감사합니다.

 

이처럼, 데이터를 분석하는 과정을 공부해보고 싶으신 분은 아래의 '평생 필요한 데이터 분석'의 교육 과정을 추천합니다. 교육을 통해 SQL을 배운다면, 위 내용을 좀 더 보강할 수도 있고, 자신만의 스타일로 분석을 할 수 있습니다. SQL을 완전히 자신의 것으로 만들 수 있는 교육이니 관심 가져보시기 바랍니다. 감사합니다.~!

 

https://cafe.naver.com/dbian/5259

 

「평생 필요한 데이터 분석(MySQL 과정)」 수강자 모집

MySQL 사용자를 위한 SQL 교육 과정을 모집합니다. 2021년 4월에 첫 강의를 성공리에 잘 마친 이후, 강사님의 프로젝트 일정이 너무 바쁜 탓에, 그리고 코로나 탓에 ...

cafe.naver.com

 

아래에서 이어지는 글입니다.

sweetquant.tistory.com/71

 

5. 부동산 분석 테이블 만들기

실제 사용할 테이블은 아래와 같습니다.

 

아래 스크립트로 테이블을 생성합니다.

# 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)가 많은지 분석해 보도록 하겠습니다. 이 내용이 진짜 부동산 시세와 상관이 있는지는 알 수 없습니다.(상관도는 나중에 파이썬으로 한 번 돌려보면 재미 있을거 같습니다.) 그냥 뭐가 있을까? 란 호기심의 분석입니다.

먼저 법정동별로 분석을 하면 데이터 종류가 너무 많아 질거 같습니다. ‘시군구단위로 분석을 해보겠습니다. 부동산 데이터는 가장 최근의 20199월 데이터만 사용하고, 매매(AMTDiv=Buy) 데이터만 사용합니다. 분석 전에 M_ShopXiGoonGuCode, 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;

 

아래와 같은 결과가 나옵니다.

# 결과
MktSDivCode   MktSDivCodeName        ShopCnt   
===========   ===============        =======   
Q01A01        한식/백반/한정식        13455     
Q12A01        커피전문점/카페/다방     9680      
L01A01        부동산중개               8001      
F01A01        여성미용실               7514      
D05A01        일반의류                 4790

 

7. 그럼, 어디가 좋을까?

절대!!! 부동산 권유의 글이 아닙니다. 데이터 분석을 설명하는 글입니다.

위의 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

여기까지입니다. 재미로 분석한 내용입니다. 절대!!! 투자 권유가 아닙니다.!!

제목 : 부동산 분석, 비싼 동네에는 뭐가 많지?

원본 위치 : DB 전문가 네트워크 디비안 (https://cafe.naver.com/dbian)

작성자 : SweetBoss

작성일 : 2019.10.23

 

1. 목적 및 환경

이 글은 누구라도 데이터를 활용 할 수 있게 하는데 목적이 있습니다. 기본적으로 SELECT, UPDATE등의 SQL은 사용할 줄 아는 사람들을 대상으로 합니다. 이 글을 통해 SQL 실력도 향상할 수 있습니다.

사용 DBMS : MySQL 5.7 Windows

사용 Tool : MySQL Workbench, MySQL Command Line Client

활용 주제 : 비싼 동네에는 뭐가 많지?

선행 주제 : 차례대로입니다.

1.     https://cafe.naver.com/dbian/2331

2.     https://cafe.naver.com/dbian/2335

3.     https://cafe.naver.com/dbian/2337

4.     https://cafe.naver.com/dbian/2338

 

 

2. KB부동산 데이터 수집

이 글의 주제는 비싼 동네(?)에는 어떤 상가가 많은지 분석해 보는 것입니다. 본격적으로 설명을 하기에 앞서, 이 글은 절대 투자 권유 글이 아니라는 것을 명시해주시기 바랍니다. 데이터를 분석하고 SQL 실력을 향상하기 위한 글입니다.

‘KB의 월간 주택가격 동향데이터를 사용합니다. 아래 사이트의 메뉴에서 데이터를 얻을 수 있습니다.

- https://onland.kbstar.com/

- 메뉴: 뉴스/자료실 > 월간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_RegionMap2BZDongNameXiGoonGuName 컬럼에 인덱스도 만들어 놓도록 합니다. 업데이트 성능을 위해서입니다.)

# 법정동코드 추가
ALTER TABLE U_KBLiivHouse ADD BZDongCode VARCHAR(100);

CREATE INDEX X_M_RegionMap2_1 ON M_RegionMap2(BZDongName, XiGoonGuName);

 

아래 SQLU_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인 데이터를 조회해봅니다. 여전히 충청북도 덕산읍이 업데이트 되지 못했습니다. 이런 경우 수작업으로 찾아서 업데이트를 해야 하지만, 단 두 건이므로 무시하고 진행하도록 하겠습니다.

BZDongCode가 있는 데이터만 실제 분석용 테이블에 올린 후 사용하도록 하겠습니다

제목 : 인구데이터 수집 어디에 치킨집을?

원본 위치 : DB 전문가 네트워크 디비안 (https://cafe.naver.com/dbian)

작성자 : SweetBoss

작성일 : 2019.10.22

 

1. 목적 및 환경

이 글은 누구라도 데이터를 활용 할 수 있게 하는데 목적이 있습니다. 기본적으로 SELECT, UPDATE등의 SQL은 사용할 줄 아는 사람들을 대상으로 합니다.

사용 DBMS : MySQL 5.7 Windows

사용 Tool : MySQL Workbench, MySQL Command Line Client

활용 주제 : 어디에 치킨 집을 차릴까??

선행 주제 : 차례대로입니다.

1.     https://cafe.naver.com/dbian/2331

2.     https://cafe.naver.com/dbian/2335

3.     https://cafe.naver.com/dbian/2337

 

 

 

2. 서울시 인구 데이터 수집

이 글의 주제는 치킨집을 차릴 곳을 찾는 것입니다. 시나리오는 어린 친구들(?)이 많으면서 치킨집이 적은 동네를 찾는 것입니다.

서울시 열린 데이터의 동별 인구 정보를 사용합니다. 아래 경로에서 받을 수 있습니다.

http://data.seoul.go.kr/dataList/datasetView.do?infId=10727&srvType=S&serviceKind=2¤tPageNo=2&searchValue=&searchKey=null

인구 정보에는 연령대별 인구수가 있습니다.

 

CSV 파일을 다운 받습니다. 파일을 열어보니 아래와 같습니다.

 

3. 업로드용 테이블 생성 및 업로드

아래 스크립트로 업로드용 테이블을 만듭니다

업로드 테이블 생성
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_PopuINSERT합니다. 아래 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;

 

아래와 같은 결과를 얻었습니다.

# 결과
HZDongCode   HZDongCodeName   XiGoonGuCodeName   PopuCnt   ShopCount   
==========   ==============   ================   =======   =========   
1150053500   등촌3동           강서구             2835      24          
1165051000   서초1동           서초구             2645      19          
1135057000   월계2동           노원구             2762      15          
1121577000   중곡4동           광진구             2669      15          
1132051500   창5동             도봉구             2655      15          
1159056000   상도4동           동작구             2955      14          
1171061000   삼전동            송파구             2931      11          
1171062000   가락본동          송파구             2636      10          
1132052200   도봉2동           도봉구             2726      8           
1138063100   신사1동           은평구             2842      6

신사1동이 15살 이하 어린이들이 많으면서 집계된 치킨집은 6개 밖에 없습니다. 물론 이 데이터가 백퍼센트 정확하다고 생각되지는 않습니다. 그리고 다양하게 다른 것도 고민해봐야겠죠.

데이터를 이와 같이 분석할 수 있다는 것에 가치를 두셨으면 합니다!~

 

이상입니다. 감사합니다.!

MySQL에서 실행계획을 확인하기 위해서는 EXPLAIN을 사용합니다.

 

아래와 같이 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로 한 건만 조회 하는 경우입니다. (오라클의 INDEX UNIQUE 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 형태로도 출력이 가능합니다. 아래와 같습니다.

EXPLAIN FORMAT = JSON
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;

{
   "query_block": {
     "select_id": 1,
     "cost_info": {
       "query_cost": "478369.15"
     },
     "grouping_operation": {
       "using_temporary_table": true,
       "using_filesort": false,
       "nested_loop": [
         {
           "table": {
             "table_name": "T1",
             "access_type": "range",
             "possible_keys": [
               "X_T_ORD_BIG_1",
               "X_T_ORD_BIG_3",
               "X_T_ORD_BIG_4",
               "X_T_ORD_BIG_5"
             ],
             "key": "X_T_ORD_BIG_5",
             "used_key_parts": [
               "ORD_YMD"
             ],
             "key_length": "35",
             "rows_examined_per_scan": 360858,
             "rows_produced_per_join": 360858,
             "filtered": "100.00",
             "using_index": true,
             "cost_info": {
               "read_cost": "45339.55",
               "eval_cost": "36085.80",
               "prefix_cost": "81425.35",
               "data_read_per_join": "195M"
             },
             "used_columns": [
               "ORD_SEQ",
               "CUS_ID",
               "ORD_YMD"
             ],
             "attached_condition": "(`db_mysqlbooster`.`t1`.`ORD_YMD` like '201703%')"
           }
         },
         {
           "table": {
             "table_name": "T2",
             "access_type": "eq_ref",
             "possible_keys": [
               "PRIMARY"
             ],
             "key": "PRIMARY",
             "used_key_parts": [
               "CUS_ID"
             ],
             "key_length": "162",
             "ref": [
               "db_mysqlbooster.T1.CUS_ID"
             ],
             "rows_examined_per_scan": 1,
             "rows_produced_per_join": 360858,
             "filtered": "100.00",
             "cost_info": {
               "read_cost": "360858.00",
               "eval_cost": "36085.80",
               "prefix_cost": "478369.15",
               "data_read_per_join": "1G"
             },
             "used_columns": [
               "CUS_ID",
               "CUS_GD"
             ]
           }
         }
       ]
     }
   }
 }

 

아래와 같이 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를 통해 트리 형태로 실제 실행된 실행계획도 확인할 수 있습니다.

sweetquant.tistory.com/29

 

MySQL 실제 실행계획

오라클은 SQL 성능에 대해서 다양한 방법으로 측정이 가능합니다. 이로 인해, 튜닝도 비교적 쉽게 할 수 있습니다. 반면에 MySQL은 성능 측정이 쉽지 않습니다. 지금까지 MySQL의 실행계획을 확인하

sweetquant.tistory.com

이상입니다~!

아래 지난 글에 이어지는 내용입니다.

 

sweetquant.tistory.com/24

 

5. 기준코드 테이블 설계

데이터를 조회해보면 아래와 같습니다. 데이터를 보니, 코드화할 컬럼들이 보입니다.

아래와 같이 기준 코드(공통 코드) 테이블을 설계합니다.

 

아래는 기준 코드 테이블을 생성하는 스크립트입니다.

-- 기준 코드 테이블 생성
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;

 

아래와 같은 결과가 나옵니다. ..!! 그렇습니다. 아래 업종들은 저희 동네에서 쉽게 접근하면 안되겠습니다.

-- 우리 동네에서 가장 많은 업종은
BZDongCodeName   MktMDivCode   MktMDivCodeName      COUNT(*)   
==============   ===========   ===============      ========   
중계동              F01           이/미용/건강           257        
중계동              D05           의복의류               205        
중계동              Q01           한식                   199        
중계동              R01           학원-보습교습입시      195        
중계동              D03           종합소매점             125        
중계동              Q12           커피점/카페            111        
중계동              Q04           분식                   108        
중계동              S01           병원                   107        
중계동              R04           학원-어학               92         
중계동              Q05           닭/오리요리             82

 

8. 위치를 이용한 분석

데이터는 합쳐서(?) 분석했을 때 더욱 빛이 납니다.

저번글에서 올렸던 지하철 승하차 정보와, 지하철 역의 위치(경도, 위도) 정보를 구축했습니다. 그리고 오늘은 상가 정보를 올렸습니다.

하차 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)

 

결과는 아래와 같습니다.

-- 우리 동네에서 가장 많은 업종은 - 결과
StationNo   UseCount   StationName     PosX                 PosY                 
=========   ========   ===========     ==================   ==================   
222         3432246    강남            127.02830790088069   37.498164651039694   
239         2659233    홍대입구        126.92367442251489   37.556888697557625   
216         2609272    잠실(송파구청)  127.10031473197408   37.513319997745555   
230         2138072    신림            126.92980421555579   37.48421761465397    
219         2055866    삼성(무역센터)  127.06307527215017   37.50886235664123    
232         2014006    구로디지털단지  126.9014956225072    37.48531465452861    
234         1802572    신도림          126.89108197001643   37.508771317645575   
329         1775417    고속터미널      127.00444243570533   37.50464799363718    
221         1735554    역삼            127.03661876920279   37.50071762069987    
150         1655682    서울역          126.97255256952047   37.557158852433425

 

이제, 위치 정보를 이용해, 상가 정보와 조인을 합니다. 위치 정보 조인을 위해서, 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;

 

결과는 아래와 같습니다.

-- 최종 결과
MktMDivCode   MktMDivCodeName   COUNT(*)   
===========   ===============   ========   
D03           종합소매점        3073       
Q01           한식              2795       
D05           의복의류          1844       
Q12           커피점/카페       1787       
F01           이/미용/건강      1523       
Q09           유흥주점          1362       
S01           병원              1136       
L01           부동산중개        1009       
Q06           양식              838        
Q03           일식/수산물       793

 

오늘은 여기까지입니다!

제목 : 상가데이터 수집

원본 위치 : DB 전문가 네트워크 디비안 (https://cafe.naver.com/dbian)

작성자 : SweetBoss

작성일 : 2019.10.21

 

1. 목적 및 환경

이 글은 누구라도 데이터를 활용 할 수 있게 하는데 목적이 있습니다. 기본적으로 SELECT, UPDATE등의 SQL은 사용할 줄 아는 사람들을 대상으로 합니다.

사용 DBMS : MySQL 5.7 Windows

사용 Tool : MySQL Workbench, MySQL Command Line Client

활용 주제 : 상가 정보 확보 및 분석

선행 주제 : 차례대로입니다.

1.     https://cafe.naver.com/dbian/2331

2.     https://cafe.naver.com/dbian/2335

 

 

 

2. 상가(상권) 데이터 수집

공공 데이터 포털에서 상가 정보를 다운 합니다. 아래 경로입니다.

- https://www.data.go.kr/dataset/15012005/fileData.do

아래 그림과 같이 상가(상권)정보_201909’를 다운합니다.

파일이 제법 큽니다. 압축을 풀어보면, 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 조건으로 필요한 정보를 몇 건 찾아야 할 필요가 있을거 같습니다.

제법 오래 걸립니다.

 

-- U_StationUse 업로드
ALTER TABLE U_Shop ADD CONSTRAINT PK_U_Shop PRIMARY KEY(ShopNo);

 

오늘은 여기까지입니다.

다음 글은 아래에서 이어집니다.

sweetquant.tistory.com/25

 

 

오라클은 SQL 성능에 대해서 다양한 방법으로 측정이 가능합니다. 이로 인해, 튜닝도 비교적 쉽게 할 수 있습니다. 반면에 MySQL은 성능 측정이 쉽지 않습니다.

 

지금까지 MySQL의 실행계획을 확인하기 위해서는 EXPLAIN 과 함께 SQL을 실행하면 됩니다. 하지만 EXPLAIN으로 확인할 수 있는 정보는 너무 제한적입니다.

MySQL8 에서는 EXPLAIN에 ANALYZE 옵션을 지정할 수 있습니다. 

dev.mysql.com/doc/refman/8.0/en/explain.html

 

MySQL :: MySQL 8.0 Reference Manual :: 13.8.2 EXPLAIN Statement

13.8.2 EXPLAIN Statement {EXPLAIN | DESCRIBE | DESC} tbl_name [col_name | wild] {EXPLAIN | DESCRIBE | DESC} [explain_type] {explainable_stmt | FOR CONNECTION connection_id} {EXPLAIN | DESCRIBE | DESC} ANALYZE [FORMAT = TREE] select_statement explain_type:

dev.mysql.com

 

제가 가지고 있는 MySQL 테스트 DB에서 기존 방법으로 EXPLAIN 만 사용해서 실행계획을 확인해 보겠습니다.

(테스트 DB는 오라클 기준으로 쓰여진 책 'SQL BOOSTER'의 DB를 MySQL로 컨버젼한 DB입니다.)

 

EXPLAIN
SELECT  COUNT(*)
FROM    T_ORD_BIG T1
        INNER JOIN M_CUS T2
          ON  (T2.CUS_ID = T1.CUS_ID)
WHERE   T1.ORD_YMD LIKE '201703%';

# 결과
id   select_type   table   partitions   type    possible_keys                                             key             key_len   ref                         rows    filtered   Extra                      
==== ============= ======= ============ ======= ========================================================= =============== ========= =========================== ======= ========== ========================== 
1    SIMPLE        T2      None         index   PRIMARY                                                   FK_CUS_1        163       None                        90      100.0      Using index                
1    SIMPLE        T1      None         ref     X_T_ORD_BIG_1,X_T_ORD_BIG_3,X_T_ORD_BIG_4,X_T_ORD_BIG_5   X_T_ORD_BIG_4   162       db_mysqlbooster.T2.CUS_ID   34394   13.33      Using where; Using index   

 

오라클만 사용했던 사람이라면, 이해가 쉽지 않은 구조의 실행계획입니다. 이번에는 EXPLAIN ANALYZE 로 실행계획을 확인해 보겠습니다.

EXPLAIN ANALYZE
SELECT  COUNT(*)
FROM    T_ORD_BIG T1
        INNER JOIN M_CUS T2
          ON  (T2.CUS_ID = T1.CUS_ID)
WHERE   T1.ORD_YMD LIKE '201703%';

# 결과
-> Aggregate: count(0)  (actual time=5384.902..5384.902 rows=1 loops=1)
     -> Nested loop inner join  (cost=444232.85 rows=412658) (actual time=21.379..5358.471 rows=185000 loops=1)
         -> Index scan on T2 using FK_CUS_1  (cost=10.00 rows=90) (actual time=1.522..3.185 rows=90 loops=1)
         -> Filter: (t1.ORD_YMD like '201703%')  (cost=1501.46 rows=4585) (actual time=23.675..59.104 rows=2056 loops=90)
             -> Index lookup on T1 using X_T_ORD_BIG_4 (CUS_ID=t2.CUS_ID)  (cost=1501.46 rows=34394) (actual time=0.437..50.395 rows=33856 loops=90)
 

마치 오라클의 실행계획처럼 자세하고, Tree형태로 실행계획이 나오고 있습니다.! 앞으로는 이 방법을 사용해 MySQL 튜닝을 진행해야 겠네요. 물론 MySQL 버젼에 따라 지원되지 않을 수 있습니다.

 

앞의 글은 아래에 있습니다.

sweetquant.tistory.com/20

 

4. 경도(X), 위도(Y) 값 구하기

경도 위도 값을 구할 차례입니다. 저는 카카오 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이어야 합니다. 아래와 같이 PosXYNOT NULL로 변경하고 인덱스를 만듭니다.

-- SPATIAL INDEX 생성
ALTER TABLE M_Station MODIFY PosXY POINT NOT NULL;
CREATE SPATIAL INDEX SX_M_Station_1 ON M_Station(PosXY);

경도와, 위도, 거리(미터)를 변수로 받아서 범위를 리턴해주는 함수를 추가합니다. (함수 관련 로직과 검색에 대해서는 https://purumae.tistory.com/198 블로그 내용을 참고해주시면 감사하겠습니다.)

!! mysql8 은 루트로 SET GLOBAL log_bin_trust_function_creators = 1; 실행 필요함.

 

-- 범위 값을 구하는 함수 생성
DELIMITER $$
CREATE FUNCTION UFN_GetDiagonal(
lon decimal(30,18)
    ,lat decimal(30,18)
    ,MBR_length decimal(30,18)
) RETURNS VARCHAR(500)
BEGIN
DECLARE lon_diff decimal(30,18);
DECLARE lat_diff decimal(30,18);
DECLARE diagonal varchar(500);
    
SET lon_diff = MBR_length / 2 / ST_DISTANCE_SPHERE(POINT(lon, lat), POINT(lon + IF(lon < 0, 1, -1), lat));
SET lat_diff = MBR_length / 2 / ST_DISTANCE_SPHERE(POINT(lon, lat), POINT(lon, lat + IF(lat < 0, 1, -1)));
SET diagonal = CONCAT('LINESTRING(', lon -  IF(lon < 0, 1, -1) * lon_diff, ' ', lat -  IF(lon < 0, 1, -1) * lat_diff, ',', lon +  IF(lon < 0, 1, -1) * lon_diff, ' ', lat +  IF(lon < 0, 1, -1) * lat_diff, ')');

RETURN diagonal;
END$$
DELIMITER ;

 

이제, 검색을 해보겠습니다. 제 지금 위치(경도=127.0624762, 위도=37.6387354)를 이용합니다. 반경 1키로 이내 지하철 역을 조회합니다.

-- 위치 조회
SELECT	*
FROM	M_Station T1
WHERE	MBRCONTAINS(ST_LINESTRINGFROMTEXT(UFN_GetDiagonal(127.0624762,37.6387354,1000)), T1.PosXY);

아래와 같이 잘 조회됩니다.

 

제목 : 지하철역 경도(Longtitude) 위도(Latitude) 만들기.

원본 위치 : DB 전문가 네트워크 디비안 (https://cafe.naver.com/dbian)

작성자 : SweetBoss

 

1. 목적 및 환경

이 글은 누구라도 데이터를 활용 할 수 있게 하는데 목적이 있습니다. 기본적으로 SELECT, UPDATE등의 SQL은 사용할 줄 아는 사람들을 대상으로 합니다.

사용 DBMS : MySQL 5.7 Windows

사용 Tool : MySQL Workbench, MySQL Command Line Client

추가 Tool (있으면 좋고, 없어도 가능) : Anaconda3, Pycharm

활용 주제 : 서울시 지하철 역에 경도, 위도 정보를 설정합니다. 향후 더 강력한 분석이 가능합니다.

선행 주제 : 서울지하철승하차분석(해당 주제의 역(M_Station) 테이블을 사용합니다.)

           (https://cafe.naver.com/dbian/2331)

 

2. (M_Station) 테이블 변경

역 테이블에 주소와, 전화번호 경도, 위도를 추가합니다.

경도와 위도 위치 정보입니다. 위치 정보를 저장하고 관리하기 위해서는 MySQLPOINT 자료형을 사용합니다.

(보통은 경도, 위도 보다는 위도, 경도(위경도) 순으로 말하는 것이 익숙합니다. 그런데 경도는 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;

3. 주소, 전화번호 가져오기

주소와 전화번호는 아래 공공데이터 포털에서 얻을 수 있습니다.

- https://www.data.go.kr/dataset/15003124/fileData.do

서울교통공사 역별 주소 및 전화번호를 다운합니다

 

건수가 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호선의 경우에는 영등포역, 창동역 등 많이 누락되어 있습니다. 어떤 이유인지 모르겠습니다. 정확한 분석을 위해서 마스터는 완벽하게 맞추어져 있어야 하는데.. 그렇지가 않네요. 그래도 감사하고 일단은 쓰도록 합니다. 혹시 모르니 공공 데이터 포털에 오류신고를 해났습니다.!!!

이젠, 모든 지하철역의 주소가 들어가 있을 겁니다.

 

나머지는 아래 글에서 이어집니다~

sweetquant.tistory.com/21

<업로드 마스터 테이블 >

 

지난 이어지는 이야기 .11’에서는 엑셀 업로드 테이블을 만들어서 사용하는 방법을 살펴보았다. 지난 이야기의 핵심은 데이터베이스에 반복적인 작업을 줄이기 위해 업로드 테이블을 사용해 성능을 향상할 수 있다는 점이었다.

 


SQL BOOSTER 에 이어지는 이야기들입니다.~!
SQL BOOSTER 를 보신 분들께 좀 더 도움을 드리고자 추가로 작성한 내용입니다.

www.aladin.co.kr/shop/wproduct.aspx?ItemId=216383877

 

SQL BOOSTER

프로젝트 성공을 위한 SQL 필독서. 프로젝트를 진행하는 순서처럼 구성되어 있다. 프로젝트 투입을 위해 필요한 SQL 기술을 설명하고, 성능 테스트를 위해 필요한 기술을 설명한 뒤에 마지막으로

www.aladin.co.kr


설명의 편의상 반말체로 작성한 점 양해바랍니다.  pdf 파일도 첨부드리니 다운 받아 보셔도 됩니다.

 

오늘은 통합된 업로드 마스터 테이블을 간단히 살펴보도록 하겠다.(정말 간단히 살펴볼 것이다.) ‘업로드 마스터테이블이다. 다양한 업로드 양식을 통합한 테이블이 아니다.

결과부터 보면 아래와 같이 업로드 마스터 테이블을 설계할 수 있다.

ERD의 가운데 있는 테이블이 바로 통합된 업로드 마스터 테이블이다. 엑셀이나 파일의 데이터를 시스템에 올리기 위해서는 공통적으로 관리해야 하는 컬럼들이 있다. 바로 그러한 컬럼을 모아서 업로드 마스터를 만든다. 특별히 설명하지 않아도 쉽게 이해할 수 있겠지만 특징 있는 컬럼 몇 개만 설명하고 넘어가도록 하겠다.

- 업로드시퀀스: 업로드를 실행하면 부여되는 시퀀스 값이다.
-
파일명/파일경로: 업로드 작업자에게 어떤 파일을 올렸는지 정보를 제공하기 위해서 관리한다.
-
업로드건수/에러건수: 처리된 건수를 저장한다. 업로드 목록을 보여줄 때 처리 건수를 보여달라는 업무 요건이 있을 때, 실제 업로드를 수행한 테이블을 접근해서 보여주기에는 성능 이슈가 있다.
-
업로드테이블: 업로드 파일의 내용이 실제 저장된 테이블명을 보관한다. 그래야만, 테이블을 보고 실제 업로드한 데이터가 어느 테이블에 있는지 쉽게 찾을 수 있다.

 

작업 프로세스는 간단하다. 다음과 같다.

이와 같이 통합된 형태의 테이블은 다양한 업무에 사용된다. 기업에는 각종 결제가 있다. 다양한 결제가 있지만 프로세스는 거의 동일하다. 그러므로 결제 마스터 역시 통합된 테이블 구조로 설계가 가능하다.

각종 인터페이스도 이와 같이 설계가 가능하다. 다양한 시스템에서 다양한 데이터를 인터페이스 받는 시스템이라면 이와 같이 통합된 인터페이스 마스터 테이블을 설계해 유용하게 사용할 수 있다. 인터페이스에 맞는 From시스템, To시스템, 인터페이스 유형 등을 추가로 관리하기만 하면 된다.

 

오늘은 여기까지입니다. 정말 짧게 살펴보고 마무리했습니다. 필요한 업무에 적절히 통합된 마스터 테이블을 활용할 수 있으시기 바랍니다.

 

<엑셀 업로드 테이블의 설계와 사용>

 

시스템을 개발해 보면, 사용자의 편의를 위해 엑셀 업로드 기능을 개발해야 할 때가 있다. 이때, 소량의 데이터를 올리는 경우라면 큰 문제가 없지만, 대량의 데이터를 업로드 해야 한다면 정교하게 프로그램을 개발해야 한다. 프로그램 코드 부분에서도 성능을 고려해야 하지만 데이터베이스에 던지는 SQL의 부하를 줄이는 것이 매우 중요하다.

엑셀 업로드를 구현할 때, 빠질 수 없는 것이 바로 데이터의 정확성을 확인하는 로직이다. 중복된 데이터가 있거나, 잘 못된 코드 값들이 입력되는 경우를 확인해서 업로드 되지 않도록 해야 한다. 이러한 데이터 체크 로직을 데이터베이스를 거치지 않고 확인할 수 있다면 좋겠지만 절대 그럴 수가 없다.

 


SQL BOOSTER 에 이어지는 이야기들입니다.~!
SQL BOOSTER 를 보신 분들께 좀 더 도움을 드리고자 추가로 작성한 내용입니다.

www.aladin.co.kr/shop/wproduct.aspx?ItemId=216383877

 

SQL BOOSTER

프로젝트 성공을 위한 SQL 필독서. 프로젝트를 진행하는 순서처럼 구성되어 있다. 프로젝트 투입을 위해 필요한 SQL 기술을 설명하고, 성능 테스트를 위해 필요한 기술을 설명한 뒤에 마지막으로

www.aladin.co.kr

설명의 편의상 반말체로 작성한 점 양해바랍니다.  pdf 파일도 첨부드리니 다운 받아 보셔도 됩니다.

SQL_Booster_이어지는이야기11.pdf
0.41MB

여기서는 이어지는 이야기 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_UP102건의 데이터가 만들어진다.

[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_NO1부터 102까지의 숫자를 갖는다. 실제 엑셀 파일을 이용했다면 엑셀의 줄번호가 여기에 해당한다. ROW_NO1~10번째 데이터는 생산일자를 일부로 230일로 설정했다. 에러 데이터로 만든 것이다. 마찬가지로 11~20번째 데이터는 아이템IDITMXXX, 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=1102건의 데이터를 모두 한 번에 처리했다. 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_IDNULL인 데이터만 대상으로 해야 한다. 이미 이전 과정에서 에러로 처리된 데이터를 다시 점검할 필요가 없기 때문이다.

 

 

(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;

 

여기서 살펴본 방법은 여러 건을 한 번에 처리하기 때문에, 루프 방식으로 한 건씩 실행하는 것보다 틀림없이 성능이 좋다고 장담한다. (물론 처리하려는 건수가 매우 적다면 이 같은 방법이 큰 이득은 없다.)

오늘은 여기까지입니다. 감사합니다.

<잘 되던 LIKE도 다시 보자.>

 

LIKE는 문자열 중에, 일부 문자가 같은 데이터를 검색할 수 있는 매우 유용한 조건자다. 하지만 LIKE는 인덱스를 설계할 때 고민을 많이 하게 만든다. ‘같다(=)’ 조건을 사용해도 되는 SQL이라면 LIKE보다는 같다(=) 조건을 사용해야 한다.

 

SQL BOOSTER 에 이어지는 이야기들입니다.~!
SQL BOOSTER 를 보신 분들께 좀 더 도움을 드리고자 추가로 작성한 내용입니다.

www.aladin.co.kr/shop/wproduct.aspx?ItemId=216383877

 

SQL BOOSTER

프로젝트 성공을 위한 SQL 필독서. 프로젝트를 진행하는 순서처럼 구성되어 있다. 프로젝트 투입을 위해 필요한 SQL 기술을 설명하고, 성능 테스트를 위해 필요한 기술을 설명한 뒤에 마지막으로

www.aladin.co.kr

설명의 편의상 반말체로 작성한 점 양해바랍니다.  pdf 파일도 첨부드리니 다운 받아 보셔도 됩니다.

SQL_Booster_이어지는이야기10.pdf
0.29MB

 

아래 SQL은 고객이 접속해서 자신의 주문 건수를 조회하는 SQL이다.

[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_IDORD_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||'%';

SQLCUS_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(아이템유형)ELEC16자리, 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을 사용하면, ELECPC 아이템 유형의 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';

SQLP08로 시작하는 모든 시리얼번호를 조회하게 된다. 성능에 문제가 있을 수 밖에 없다. 성능 부하를 고려해 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 개발 가이드 어떻게 지킬 것인가?>

 

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.211.3.3 SQL 개발 가이드에 포함 할지 많은 고민이 있었다. 별도의 Chapter로 구성하기에는 내용이 짧고 가이드에 포함시켜도 큰 무리가 없다 생각되어 가이드에 포함했다.)

 

 


SQL BOOSTER 에 이어지는 이야기들입니다.~!
SQL BOOSTER 를 보신 분들께 좀 더 도움을 드리고자 추가로 작성한 내용입니다.

www.aladin.co.kr/shop/wproduct.aspx?ItemId=216383877

 

SQL BOOSTER

프로젝트 성공을 위한 SQL 필독서. 프로젝트를 진행하는 순서처럼 구성되어 있다. 프로젝트 투입을 위해 필요한 SQL 기술을 설명하고, 성능 테스트를 위해 필요한 기술을 설명한 뒤에 마지막으로

www.aladin.co.kr

설명의 편의상 반말체로 작성한 점 양해바랍니다.  pdf 파일도 첨부드리니 다운 받아 보셔도 됩니다.

SQL_Booster_이어지는이야기09.pdf
0.62MB

이와 같은 ‘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 사고는 조건절을 빼먹고 급하게 실행할 때 발생한다. 한 명이 단계별로 실행하고, 실행 과정을 누군가가 지켜보면서 조건절에 실수는 없는지 다시 한번 확인하도록 하는 것이다. 또한 DBMSDBMS 툴에 따라 AUTOCOMMIT을 사용하는 경우에는 각별한 주의가 필요하다. VOC UPDATE, DELETE는 무조건 AUTOCOMMITOFF하고 사용하도록 해야 한다. 그러므로 필자가 운영팀에서 사용했던 SQL 가이드에는 아래와 같은 내용도 있었다. 참고하기 바란다.

 

대상을 백업 할 때는, VOC를 처리한 개발자 이름의 약자(LTW) VOC ID를 사용하도록 한다. 이 후 추적이 용이하다. 최종, COMMIT을 하기 전에는 백업한 건수와 UPDATEDELETE 된 건수가 같을 때만 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를 간단하게 만들어 올려보도록 하겠습니다. 작은 개발 사이트들에 도움이 되지 않을까 생각이 드네요. 감사합니다.

 

<고객별 마지막 주문을 구하는 SQL>

특정 그룹(GROUP BY)별로 마지막 데이터를 구하는 SQL을 알아보자. SQL에 따라 성능이 어떻게 변하는지도 살펴보도록 하자.

여기서 살펴보는 방법은 다음과 같다.

- WHERE절 서브쿼리

- 인라인-(GROUP BY, 조인)

- 분석함수와 ROW_NUMBER

- 마스터 테이블과 스칼라 서브쿼리를 활용

- 인라인-뷰의 KEEP함수(ROWID를 활용)

 


SQL BOOSTER 에 이어지는 이야기들입니다.~!
SQL BOOSTER 를 보신 분들께 좀 더 도움을 드리고자 추가로 작성한 내용입니다.

www.aladin.co.kr/shop/wproduct.aspx?ItemId=216383877

 

SQL BOOSTER

프로젝트 성공을 위한 SQL 필독서. 프로젝트를 진행하는 순서처럼 구성되어 있다. 프로젝트 투입을 위해 필요한 SQL 기술을 설명하고, 성능 테스트를 위해 필요한 기술을 설명한 뒤에 마지막으로

www.aladin.co.kr

설명의 편의상 반말체로 작성한 점 양해바랍니다.  pdf 파일도 첨부드리니 다운 받아 보셔도 됩니다.

SQL_Booster_이어지는이야기08.pdf
0.46MB

 

여기서 살펴보는 방법은 다음과 같다.

- WHERE절 서브쿼리

- 인라인-(GROUP BY, 조인)

- 분석함수와 ROW_NUMBER

- 마스터 테이블과 스칼라 서브쿼리를 활용

- 인라인-뷰의 KEEP함수(ROWID를 활용)

 

상황과 방법에 따라 다양한 결과가 나온다. 결과는 마지막에 있으니 살펴보기 바란다.

 

 

(1) 고객별 마지막 주문 데이터 가져오기.

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 작성 방법은 좋지 않다.

 

 

실제 실행된 실행계획을 살펴보면 아래와 같다.

[실행계획-1] 고객별 마지막 주문 가져오기
---------------------------------------------------------------------------------------------------
| Id  | Operation                    | Name              | Starts | A-Rows |   A-Time   | Buffers |
---------------------------------------------------------------------------------------------------
|   0 | SELECT STATEMENT             |                   |      1 |     90 |00:00:00.81 |   11867 |
|   1 |  NESTED LOOPS                |                   |      1 |     90 |00:00:00.81 |   11867 |
|   2 |   NESTED LOOPS               |                   |      1 |     90 |00:00:00.81 |   11777 |
|   3 |    VIEW                      | VW_SQ_1           |      1 |     90 |00:00:00.81 |   11594 |
|   4 |     HASH GROUP BY            |                   |      1 |     90 |00:00:00.81 |   11594 |
|   5 |      INDEX FAST FULL SCAN    | X_T_ORD_JOIN_TEST |      1 |   3224K|00:00:00.59 |   11594 |
|*  6 |    INDEX UNIQUE SCAN         | PK_T_ORD_JOIN     |     90 |     90 |00:00:00.01 |     183 |
|*  7 |   TABLE ACCESS BY INDEX ROWID| T_ORD_JOIN        |     90 |     90 |00:00:00.01 |      90 |
---------------------------------------------------------------------------------------------------
 
Predicate Information (identified by operation id):
---------------------------------------------------
   6 - access("T1"."ORD_SEQ"="MAX(A.ORD_SEQ)")
   7 - filter("ITEM_1"="T1"."CUS_ID")

실행계획에서는 서브쿼리를 먼저 처리하고 있다. 실행계획의 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,488Buffers가 발생한다.

 

지금 상황에서 필자가 추천하는 방법은 다음과 같다.

[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과 조인 처리하는 방법이다. 실행계획을 살펴보면 다음과 같다.

[실행계획-3] 고객별 마지막 주문 가져오기 – M_CUS를 사용
------------------------------------------------------------------------------------------------------
| Id  | Operation                       | Name              | Starts | A-Rows |   A-Time   | Buffers |
------------------------------------------------------------------------------------------------------
|   0 | SELECT STATEMENT                |                   |      1 |     90 |00:00:00.01 |     458 |
|   1 |  NESTED LOOPS                   |                   |      1 |     90 |00:00:00.01 |     458 |
|   2 |   NESTED LOOPS                  |                   |      1 |     90 |00:00:00.01 |     368 |
|   3 |    INDEX FULL SCAN              | PK_M_CUS          |      1 |     90 |00:00:00.01 |       2 |
|*  4 |    INDEX UNIQUE SCAN            | PK_T_ORD_JOIN     |     90 |     90 |00:00:00.01 |     366 |
|   5 |     SORT AGGREGATE              |                   |     90 |     90 |00:00:00.01 |     183 |
|   6 |      FIRST ROW                  |                   |     90 |     90 |00:00:00.01 |     183 |
|*  7 |       INDEX RANGE SCAN (MIN/MAX)| X_T_ORD_JOIN_TEST |     90 |     90 |00:00:00.01 |     183 |
|   8 |   TABLE ACCESS BY INDEX ROWID   | T_ORD_JOIN        |     90 |     90 |00:00:00.01 |      90 |

Predicate Information (identified by operation id):
---------------------------------------------------
   4 - access("T2"."ORD_SEQ"=)
   7 - access("B"."CUS_ID"=:B1)

Buffers458로 획기적으로 줄어들었다.

필자는 여기서 이 방법을 추천했다. 하지만, 항상 이 방법이 좋은 것은 아니다. 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과 조인하는 방법이다. 실행계획은 다음과 같다.

[실행계획-5] 고객별 월별 마지막 주문 가져오기 – 인라인-뷰
-------------------------------------------------------------------------------------------------
| Id  | Operation                | Name                | Starts | A-Rows |   A-Time   | Buffers |
-------------------------------------------------------------------------------------------------
|   0 | SELECT STATEMENT         |                     |      1 |     50 |00:00:01.64 |   42125 |
|   1 |  SORT ORDER BY           |                     |      1 |     50 |00:00:01.64 |   42125 |
|*  2 |   HASH JOIN              |                     |      1 |   1000 |00:00:02.94 |   42125 |
|   3 |    VIEW                  |                     |      1 |   1000 |00:00:01.11 |   15668 |
|   4 |     HASH GROUP BY        |                     |      1 |   1000 |00:00:01.11 |   15668 |
|   5 |      INDEX FAST FULL SCAN| X_T_ORD_JOIN_TEST_2 |      1 |   3224K|00:00:00.35 |   15668 |
|   6 |    TABLE ACCESS FULL     | T_ORD_JOIN          |      1 |   3224K|00:00:00.34 |   26457 |
-------------------------------------------------------------------------------------------------
 
Predicate Information (identified by operation id):
---------------------------------------------------
   2 - access("T1"."ORD_SEQ"="T0"."MAX_ORD_SEQ")

인라인-뷰를 사용한 방법의 총 Buffers42,125. 인라인-뷰 결과와 T_ORD_JOINN을 해시-조인 처리하고 있다. 필자 생각에는 인라인-뷰 결과와 T_ORD_JOINNL 조인 하는 것이 성능이 더 좋을 것이라 생각된다. 인라인-뷰의 결과가 천 건밖에 안되기 때문이다. (직접 힌트를 사용해 NL 조인으로 테스트해보기 바란다.)

 

고객별 월별 마지막 주문을 구하기 위해서도 ROW_NUMBER 분석함수를 사용할 수 있다. 아래와 같이 PARTITION BYSUBSTR(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,490Buffers가 발생한다.

‘(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과 조인하는 방법이다. 실행계획은 다음과 같다.

[실행계획-6] 고객별 월별 마지막 주문 가져오기 – M_CUS와 C_BAS_YMD
-------------------------------------------------------------------------------------------------------
| Id  | Operation                         | Name               | Starts |A-Rows |   A-Time   |Buffers |
-------------------------------------------------------------------------------------------------------
|   0 | SELECT STATEMENT                  |                    |      1 |  1000 |00:00:01.48 |  21226 |
|   1 |  SORT ORDER BY                    |                    |      1 |  1000 |00:00:01.48 |  21226 |
|   2 |   NESTED LOOPS                    |                    |      1 |  1000 |00:00:01.09 |  21226 |
|   3 |    NESTED LOOPS                   |                    |      1 |  1000 |00:00:00.96 |  20226 |
|   4 |     MERGE JOIN CARTESIAN          |                    |      1 |  1080 |00:00:00.01 |    472 |
|   5 |      VIEW                         |                    |      1 |    12 |00:00:00.01 |    469 |
|   6 |       HASH UNIQUE                 |                    |      1 |    12 |00:00:00.01 |    469 |
|   7 |        TABLE ACCESS BY INDEX ROWID| C_BAS_YMD          |      1 |   365 |00:00:00.01 |    469 |
|*  8 |         INDEX RANGE SCAN          | PK_C_BAS_YMD       |      1 |   365 |00:00:00.01 |      3 |
|   9 |      BUFFER SORT                  |                    |     12 |  1080 |00:00:00.01 |      3 |
|  10 |       INDEX FAST FULL SCAN        | PK_M_CUS           |      1 |    90 |00:00:00.01 |      3 |
|* 11 |     INDEX UNIQUE SCAN             | PK_T_ORD_JOIN      |   1080 |  1000 |00:00:01.29 |  19754 |
|  12 |      SORT AGGREGATE               |                    |   1080 |  1080 |00:00:01.11 |  17752 |
|* 13 |       INDEX RANGE SCAN            | X_T_ORD_JOIN_TEST_2|   1080 |  3224K|00:00:01.25 |  17752 |
|  14 |    TABLE ACCESS BY INDEX ROWID    | T_ORD_JOIN         |   1000 |  1000 |00:00:00.18 |   1000 |
-------------------------------------------------------------------------------------------------------
 
Predicate Information (identified by operation id):
---------------------------------------------------
   8 - access("C"."BAS_YMD" LIKE '2017%')
       filter("C"."BAS_YMD" LIKE '2017%')
  11 - access("T2"."ORD_SEQ"=)
  13 - access("B"."CUS_ID"=:B1 AND "B"."ORD_YMD" LIKE :B2||'%')
       filter("B"."ORD_YMD" LIKE :B1||'%')

Buffers21,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;

실행계획을 확인해 보면 다음과 같다.

[실행계획-7] 고객별 일자별 마지막 주문 가져오기 – 인라인-뷰
------------------------------------------------------------------------------------------------------
| Id  | Operation                     | Name                | Starts | A-Rows |   A-Time   | Buffers |
------------------------------------------------------------------------------------------------------
|   0 | SELECT STATEMENT              |                     |      1 |   2594 |00:00:00.79 |   23452 |
|   1 |  SORT ORDER BY                |                     |      1 |   2594 |00:00:00.79 |   23452 |
|   2 |   NESTED LOOPS                |                     |      1 |   2594 |00:00:00.79 |   23452 |
|   3 |    NESTED LOOPS               |                     |      1 |   2594 |00:00:00.78 |   20858 |
|   4 |     VIEW                      |                     |      1 |   2594 |00:00:00.78 |   15668 |
|   5 |      HASH GROUP BY            |                     |      1 |   2594 |00:00:00.78 |   15668 |
|   6 |       INDEX FAST FULL SCAN    | X_T_ORD_JOIN_TEST_2 |      1 |   3224K|00:00:00.36 |   15668 |
|*  7 |     INDEX UNIQUE SCAN         | PK_T_ORD_JOIN       |   2594 |   2594 |00:00:00.01 |    5190 |
|   8 |    TABLE ACCESS BY INDEX ROWID| T_ORD_JOIN          |   2594 |   2594 |00:00:00.01 |    2594 |
------------------------------------------------------------------------------------------------------
 
Predicate Information (identified by operation id):
---------------------------------------------------
   7 - access("T1"."ORD_SEQ"="T0"."MAX_ORD_SEQ")

이번에는 인라인-뷰의 결과와 T_ORD_JOINNL 조인으로 처리되고 있다. (‘(2)’에서 인라인-뷰를 사용한 방법은 해시-조인으로 처리되었었다.) NL 조인이면서 총 Buffers23,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;

실행계획을 확인해보면 아래와 같다.

[실행계획-8] 고객별 일자별 마지막 주문 가져오기 – M_CUS와 C_BAS_YM
------------------------------------------------------------------------------------------------------
| Id  | Operation                        | Name               |Starts | A-Rows |   A-Time   |Buffers |
------------------------------------------------------------------------------------------------------
|   0 | SELECT STATEMENT                 |                    |     1 |   2594 |00:00:00.13 |  36526 |
|   1 |  SORT ORDER BY                   |                    |     1 |   2594 |00:00:00.13 |  36526 |
|   2 |   NESTED LOOPS                   |                    |     1 |   2594 |00:00:00.15 |  36526 |
|   3 |    NESTED LOOPS                  |                    |     1 |   2594 |00:00:00.15 |  33932 |
|   4 |     MERGE JOIN CARTESIAN         |                    |     1 |  32850 |00:00:00.01 |      4 |
|   5 |      INDEX FULL SCAN             | PK_M_CUS           |     1 |     90 |00:00:00.01 |      1 |
|   6 |      BUFFER SORT                 |                    |    90 |  32850 |00:00:00.01 |      3 |
|*  7 |       INDEX RANGE SCAN           | PK_C_BAS_YMD       |     1 |    365 |00:00:00.01 |      3 |
|*  8 |     INDEX UNIQUE SCAN            | PK_T_ORD_JOIN      | 32850 |   2594 |00:00:00.11 |  33928 |
|   9 |      SORT AGGREGATE              |                    | 32850 |  32850 |00:00:00.08 |  28739 |
|  10 |       FIRST ROW                  |                    | 32850 |   2594 |00:00:00.06 |  28739 |
|* 11 |        INDEX RANGE SCAN (MIN/MAX)| X_T_ORD_JOIN_TEST_2| 32850 |   2594 |00:00:00.06 |  28739 |
|  12 |    TABLE ACCESS BY INDEX ROWID   | T_ORD_JOIN         |  2594 |   2594 |00:00:00.01 |   2594 |
------------------------------------------------------------------------------------------------------

Buffers36,526이다. 인라인-뷰를 사용한 방법보다 IO가 좋지 못하다.

그렇다면 인라인-뷰를 사용한 방법을 좀 더 향상시킬 수는 없을까? 다음과 같이 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을 이용해 고객, 일별 마지막 주문SEQROWID를 가져와서, ROWIDT_ORD_JOIN에 바로 접근하는 방법이다. PK_T_ORD_JOIN 인덱스를 경유하지 않아 성능에 이득이 있다. 실행계획은 다음과 같다.

[실행계획-9] 고객별 일자별 마지막 주문 가져오기 – 인라인-뷰와 KEEP
----------------------------------------------------------------------------------------------------
| Id  | Operation                   | Name                | Starts | A-Rows |   A-Time   | Buffers |
----------------------------------------------------------------------------------------------------
|   0 | SELECT STATEMENT            |                     |      1 |   2594 |00:00:01.50 |   18262 |
|   1 |  NESTED LOOPS               |                     |      1 |   2594 |00:00:01.50 |   18262 |
|   2 |   VIEW                      |                     |      1 |   2594 |00:00:01.50 |   15668 |
|   3 |    SORT GROUP BY            |                     |      1 |   2594 |00:00:01.50 |   15668 |
|   4 |     INDEX FAST FULL SCAN    | X_T_ORD_JOIN_TEST_2 |      1 |   3224K|00:00:00.63 |   15668 |
|   5 |   TABLE ACCESS BY USER ROWID| T_ORD_JOIN          |   2594 |   2594 |00:00:00.01 |    2594 |
----------------------------------------------------------------------------------------------------

18,262Buffers가 줄어들었다.

마무리하기 전에, 여기서 만들었던 테스트 인덱스를 모두 제거하도록 하자.

테스트 인덱스 제거
DROP INDEX X_T_ORD_JOIN_TEST;
DROP INDEX X_T_ORD_JOIN_TEST_2;

지금까지 살펴본 내용을 종합해 보면 아래와 같다.

상황과 방법에 따라 다양한 성능이 나오고 있다. 하나의 규칙에 얽매이지 않고 항상 고민하고 연구해보기 바란다.

 

 

오늘은 여기까지입니다. 감사합니다.

<쓸데 없는 MINUS???>

 

UNIONUNION ALL은 두 데이터 집합을 상하로 결합시킨다. 아마도 이를 모르는 개발자는 없을 것이다. 반면에 MINUS 구문은 사용해 본적이 없거나 처음 접하는 개발자도 있을 것이다. MINUS는 상하의 두 데이터 집합간의 차집합을 구한다. MINUS 연산은 도통 쓸데가 없다. 일반적인 조회 화면에서 MINUS가 포함된 SQL이 사용되는 경우는 거의 없기 때문이다. 하지만 MINUS는 데이터 검증 작업을 할 때 매우 유용하다. 필자는 MINUS 구문을 성능 개선 작업할 때 많이 사용한다.

 


SQL BOOSTER 에 이어지는 이야기들입니다.~!
SQL BOOSTER 를 보신 분들께 좀 더 도움을 드리고자 추가로 작성한 내용입니다.

www.aladin.co.kr/shop/wproduct.aspx?ItemId=216383877

 

SQL BOOSTER

프로젝트 성공을 위한 SQL 필독서. 프로젝트를 진행하는 순서처럼 구성되어 있다. 프로젝트 투입을 위해 필요한 SQL 기술을 설명하고, 성능 테스트를 위해 필요한 기술을 설명한 뒤에 마지막으로

www.aladin.co.kr


설명의 편의상 반말체로 작성한 점 양해바랍니다.  pdf 파일도 첨부드리니 다운 받아 보셔도 됩니다.

SQL_Booster_이어지는이야기07.pdf
0.18MB

먼저 간단한 SQL을 통해 MINUS를 이해해보자.

[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

COL1B면서 COL23인 데이터는 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')

위와 같은 SQLROLLUP으로 변경하는 것이 성능에 유리할 수 있다. 실행계획까지 확인해 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을 변경한 성능 개선 .03>

 

SQL을 성능 개선 할 때, 가장 손쉬운 방법은 인덱스를 추가하는 것이다. 물론 인덱스로 성능이 개선될 수 있다면 말이다. 하지만, 그런 식으로 인덱스를 만들다 보면 데이터베이스에는 인덱스가 테이블보다 더 많은 용량을 차지하기 시작한다. SQL BOOSTER 본서 183페이지, ‘6.4.3 너무 많은 인덱스의 위험성에서 설명했던 내용이다.

손쉬운 인덱스 추가보다는, 주어진 인덱스에서 성능을 개선할 방법을 찾는 것이 SQL 성능 개선의 첫 단계다. 인덱스는 그 다음 단계다.

 

SQL BOOSTER 에 이어지는 이야기들입니다.~!
SQL BOOSTER 를 보신 분들께 좀 더 도움을 드리고자 추가로 작성한 내용입니다.

www.aladin.co.kr/shop/wproduct.aspx?ItemId=216383877

 

SQL BOOSTER

프로젝트 성공을 위한 SQL 필독서. 프로젝트를 진행하는 순서처럼 구성되어 있다. 프로젝트 투입을 위해 필요한 SQL 기술을 설명하고, 성능 테스트를 위해 필요한 기술을 설명한 뒤에 마지막으로

www.aladin.co.kr


설명의 편의상 반말체로 작성한 점 양해바랍니다.  pdf 파일도 첨부드리니 다운 받아 보셔도 됩니다.

SQL_Booster_이어지는이야기06.pdf
0.26MB

 

아래 SQL을 살펴보자.

[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;

특정 고객의, 특정 아이템에 대해 11일부터 930일까지의 판매금액을 주문상태(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_JOIN19,000번 접근했지만, 그 중에 17,000번이 불필요한 접근인 것이다. 만약에 CUS_ID, ORD_YMD의 인덱스에 ITM_ID 컬럼도 있었다면 테이블에 접근한 후 버려지는 비효율이 발생하지 않았을 것이다. 그리고 CUS_ID, ITM_ID, ORD_YMD, ORD_ST 순서의 인덱스가 있었다면, 테이블을 접근하는 비효율 자체가 없었을 것이다. 하지만, 현재 가진 인덱스에서 해결해야 한다면 어떻게 해야 할까? 우선 T_ORD_JOIN에 어떤 인덱스가 있는지 살펴보자.

T_ORD_JOIN의 인덱스
INDEX_OWNER         TABLE_NAME         INDEX_NAME       IND_COLS             
============        ==========         =============    =====================
ORA_SQL_TEST	    T_ORD_JOIN	       PK_T_ORD_JOIN	ORD_SEQ
ORA_SQL_TEST	    T_ORD_JOIN	       X_T_ORD_JOIN_1	CUS_ID
ORA_SQL_TEST	    T_ORD_JOIN	       X_T_ORD_JOIN_2	CUS_ID,ORD_YMD
ORA_SQL_TEST	    T_ORD_JOIN	       X_T_ORD_JOIN_3	ORD_YMD
ORA_SQL_TEST	    T_ORD_JOIN	       X_T_ORD_JOIN_4	ITM_ID,ORD_YMD

그리고 다시 한번 [SQL-1]을 살펴보면서 어떻게 성능을 좀 더 개선 할 수 있을지 고민해보자. 실력을 키우고 싶은 독자라면 스스로 고민해보고 SQL을 작성해보기 바란다.

 

 

고민이 끝났다면, 필자의 생각과 같은지 다음 내용들을 읽어보기 바란다. 먼저 [SQL-1]WHERE절 조건들과 사용 가능한 인덱스를 정리해보면 아래와 같다.

- X_T_ORD_JOIN_1 : CUS_ID(=), ITM_ID(=), ORD_YMD(><)

- X_T_ORD_JOIN_2 : CUS_ID(=), ITM_ID(=), ORD_YMD(><)

- X_T_ORD_JOIN_3 : CUS_ID(=), ITM_ID(=), ORD_YMD(><)

- X_T_ORD_JOIN_4 : CUS_ID(=), ITM_ID(=), ORD_YMD(><)

 

노란색으로 표시된 부분은 해당 인덱스에서 사용할 수 있는 컬럼이다. [SQL-1]을 처리하기에 가장 좋은 인덱스는 X_T_ORD_JOIN_2X_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을 작성해 성능을 개선할 수 있다.

SQLX_T_ORD_JOIN_2 인덱스로 CUS_IDORD_YMD 조건이 맞는 ROWID를 찾아내고, X_T_ORD_JOIN_4 인덱스로 ITM_IDORD_YMD 조건에 맞는 ROWID를 찾아내 해시 조인 처리하고 있다. 해시 조인으로 얻은 두 인덱스간에 공통된 ROWIDT_ORD_JOIN(T3)에 공급해 최종 결과를 얻어내는 방법이다. 실행계획을 살펴보자. 논리IO257에서 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.ROWIDTRIM 처리하기도 했다. 실행계획이 원하는 대로 만들어지지 않아 강제 처리한 것이다.

오라클에는 INDEX_JOIN이나 INDEX_COMBINE 힌트가 있다. [SQL-2]처럼 복잡하게 SQL을 변경하지 않아도 해당 힌트를 사용할 수 있다. 하지만, 힌트가 먹지 않는 경우가 있다. 또는 작동하던 힌트가 어느 순간부터 작동하지 않을 수도 있다. 그리고, 힌트를 사용할 수 없는 DBMS도 있다. 그러므로 이와 같이 SQL을 변경할 수 있다면, 힌트가 작동하지 않아도, 힌트가 없어도 성능 개선을 할 수 있다.

 

 

오늘은 여기까지입니다. 감사합니다.

지난 글에 이어지는 내용입니다.

sweetquant.tistory.com/18

 

김밥 말러 갑시다 1/2

1. 목적 및 환경 이 글은 누구라도 데이터를 활용 할 수 있게 하는데 목적이 있습니다. 기본적으로 SELECT, UPDATE등의 SQL은 사용할 줄 아는 사람들을 대상으로 합니다. 사용 DBMS : MySQL 5.7 Windows 사용 T

sweetquant.tistory.com

 

5. 테이블 업로드

CSV 파일을 업로드용 테이블에 업로드할 차례입니다.

먼저 다운 받은 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_StationUseCROSS 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(승차) 71일 전체 승차 값을 보면 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)

SQLT_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
;

아래와 같은 결과를 얻었습니다. 결과로는 저녁에 신림역에서 김밥을 말면 될거 같습니다. 그런데 신림역은.. 술집도 많아서 회식하러 사람들이 많이 모이는거 아닐까 생각이 듭니다. 그렇다면, 아래 데이터에 생활권을 좀 고려해서 김밥 말 곳을 정하면 되지 않을까요?. 여기에.. 역 근처 식당, 술집 정보까지 얹는다면 쓸만하지 않을까 싶습니다.

 

 

끝입니다!!! 감사합니다~!

 

원본은 pdf및 첨부 파일은 아래 주소에서 받을 수 있습니다.

원본 위치 : DB 전문가 네트워크 디비안 (https://cafe.naver.com/dbian)

 

1. 목적 및 환경

이 글은 누구라도 데이터를 활용 할 수 있게 하는데 목적이 있습니다. 기본적으로 SELECT, UPDATE등의 SQL은 사용할 줄 아는 사람들을 대상으로 합니다.

사용 DBMS : MySQL 5.7 Windows

사용 Tool : MySQL Workbench, MySQL Command Line Client

활용 주제 : 지하철 승하차 정보를 통한 상권 분석.

 

2. 데이터 다운로드

공공데이터 포털에서 서울교통공사_일별 역별 시간대별 승하차인원를 다운 받습니다.

https://www.data.go.kr/dataset/15024829/fileData.do

아래 그림을 참고해서 다운합니다.

 

다운한 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);

 

오늘은 여기까지입니다. 

 

이어지는 글은 아래에 있습니다.

sweetquant.tistory.com/19

 

김밥 말러 갑시다 2/2

지난 글에 이어지는 내용입니다. sweetquant.tistory.com/18 김밥 말러 갑시다 1/2 1. 목적 및 환경 이 글은 누구라도 데이터를 활용 할 수 있게 하는데 목적이 있습니다. 기본적으로 SELECT, UPDATE등의 SQL은

sweetquant.tistory.com

 

+ Recent posts