본문 바로가기

Information/Corona19(COVID19)

코로나19(Corona19, COVID19) 엑셀 데이터 얻기/가공하기(2/2)

반응형

이 페이지에서는 웹에서 다운로드한 원시 코로나 데이터 파일을 이용해서, 유용한 데이터로 가공하는 엑셀 매크로 프로그램에 대해서 설명합니다.

 


모두 8 단계의 단계를 거치면 원하는 데이터로 가공되게 했습니다.

 

이처럼 단계를 나누고 단계별로 다른 프로시저로 구성하는 것이 엑셀 VBA 프로그래밍에서 매우 중요합니다. 그래야 코드가 깔끔해지고 디버깅이 쉽습니다.

 

메인 프로시저로 ConvertSourceData를 만들었습니다. 여기서 각 단계별로 해당 프로시저들을 호출합니다.

 

Sub ConvertSourceData()
    Dim wb As Workbook
    Dim src_sheet As Worksheet, tgt_sheet As Worksheet
    Dim prg_sheet As Worksheet
    
    Dim src_file_name As String, src_sheet_name
        
    Set prg_sheet = Workbooks("program.xlsm").Sheets("program")
    src_file_name = prg_sheet.Cells(12, 12).Value
    src_sheet_name = prg_sheet.Cells(13, 12).Value
        
    Set wb = Workbooks(src_file_name)
    Set src_sheet = wb.Sheets(src_sheet_name)
    
    '1. Create target sheet as "data"
    src_sheet.Activate
    Sheets.Add.Name = "data"
    Set tgt_sheet = Sheets("data")
        
    '2. Get unique date and countries
    Dim dates As Variant, countries As Variant
    dates = GetUniqueData(src_sheet, 1)
    countries = GetUniqueData(src_sheet, 7)
    
    '3. make format
    Call MakeFormat(tgt_sheet, dates, countries)
    
    '4 create dictionary for cases and deaths
    Dim cases_deaths As Object
    Set cases_deaths = CreateObject("Scripting.Dictionary")
    Call MakeCasesDict(cases_deaths, src_sheet)
    
    
    '5 write case and death value
    Call WriteCaseDeath(tgt_sheet, cases_deaths)
    
    '6. Calculate Cumulated data
    Call CalculateCumul(tgt_sheet)
    
    '7. Calculate rank
    Call CalculateRank(tgt_sheet)
    
    '8. Create rank sheet
    src_sheet.Activate
    Sheets.Add.Name = "rank"
    
    Dim rank_sheet As Worksheet
    Set rank_sheet = Sheets("rank")
    Call RankCountryByCumulData(tgt_sheet, rank_sheet)
End Sub

 

소스코드 전체에 대해서 하나하나 설명하진 않고 핵심 되는 부분만 설명하겠습니다.

 

전체 소스코드는 이 글의 하단부에 기재할 것이고, 프로그램 코드가 들어 있는 엑셀 파일도 첨부해놨습니다.

 

소스파일 이름 및 시트명 입력받는 기법

가변 되는 데이터를 어떻게 입력받을 것인가는 항상 고민되는 항목입니다.

 

웹에서 다운로드한 데이터 파일명을 알아야, 그 파일에서 데이터를 가지고 올 텐데, 사용자마다 다운로드한 폴더 및 파일명이 다르기에, 사용자가 값을 바꿔줘야 합니다.

 

가장 간단한 방법은 프로그램 코드 내에서 상수로 값을 넣어두고, 사용자가 그 값을 직접 바꾼 후 프로그램을 실행하게 하는 것.

 

그러나 이 방법은 프로그램 코드를 사용자가 직접 손을 대야 하기에, 별롭니다.

 

제가 가장 많이 사용하는 방법이, 엑셀 시트에 사용자가 입력할 수 있는 셀을 만들어 두고, 사용자가 그 셀의 내용을 바꾼 후 실행 버튼을 누르게 하는 것.

 

엑셀 데이터에서 고유한 값만 추려내기

다운로드한 코로나 데이터를 보면 한 행에 하나씩 날자/국가/신규 감염자/신규 사망자 데이터가 있습니다. 

 

여기서 날자 데이터에 대해서 중복된 항목을 제거해서 유니크한 날자 집합만 얻어내야 하고, 또한 국가도 유니크한 국가 리스트를 얻어내야 합니다.   --> GetUniqueData 함수를 만듦

 

엑셀 기능을 사용한다면 쉽습니다. 엑셀 기능에 '중복된 항목 제거'란게 있어서, 선택된 영역에 있는 데이터에서 중복된 항목을 쉽게 제거할 수 있습니다.

 

근데 VBA에서는 중복된 항목을 제거하는 것이 꽤나 까다로운 문제입니다. 

 

물론, 해당 데이터를 임시 시트에 카피하고, VBA에서 그 영역을 선택하고 엑셀의 worksheet 함수를 호출해서 고윳값만 남게 해서 사용할 수도 있습니다.

 

VBA에서 이것을 쉽게 할 수 있는 방법을 찾았습니다. Dictionary를 이용한 꼼수입니다. ^^

 

    For r = 1 To UBound(data)
        dict(data(r, 1)) = Empty
    Next r
    data = dict.keys

 

위에서 data는 해당 영역의 값을 배열로 읽어 들인 값이고, 이 배열에 있는 모든 것을 딕셔너리에 key 항목으로 모두 꾸겨넣고, 딕셔너리의 키 값만 읽어내면 되는 것입니다.

 

딕셔너리의 특성상 키 데이터는 유일해야 하기에, 중복된 항목들을 집어넣으면 딕셔너리 내부에서 알아서 중복된 것들은 없애주게 됩니다.

 

위에서 data(r,1)처럼 한 것은, 엑셀 시트의 column 데이터를 배열로 읽었기 때문에, 데이터가 2차원 배열이 되었기 때문입니다.  

 

 

정렬 함수

엑셀 VBA에는 정렬 함수가 없습니다.!!!

 

해서 저는 퀵 소트를 구현해놓고 필요할 때마다 쓰고 있습니다.  --> program 파일의 Common 모듈 내 있음

 

'usage: QuickSort(arr,LBound(arr), UBound(arr))
Sub QuickSort(ByRef arr As Variant, Lo As Long, Hi As Long)
  Dim varPivot As Variant, varTmp As Variant
  Dim tmpLow As Long, tmpHi As Long
  
  tmpLow = Lo
  tmpHi = Hi
  varPivot = arr((Lo + Hi) / 2)
  Do While tmpLow <= tmpHi
    Do While arr(tmpLow) < varPivot And tmpLow < Hi
      tmpLow = tmpLow + 1
    Loop
    Do While varPivot < arr(tmpHi) And tmpHi > Lo
      tmpHi = tmpHi - 1
    Loop
    If tmpLow <= tmpHi Then
      varTmp = arr(tmpLow)
      arr(tmpLow) = arr(tmpHi)
      arr(tmpHi) = varTmp
      tmpLow = tmpLow + 1
      tmpHi = tmpHi - 1
    End If
  Loop
  If Lo < tmpHi Then QuickSort arr, Lo, tmpHi
  If tmpLow < Hi Then QuickSort arr, tmpLow, Hi
End Sub

 

실행 속도 빠르게 하기

엑셀 VBA 프로그래밍을 하는 개발자들의 공통적인 얘기가 속도가 느리다는 것일 겁니다.

 

그런데, 몇 가지 기법을 쓰면, 꽤 쓸만한 속도를 낼 수 있습니다.  심지어 이 기법을 쓰지 않으면 10분 정도 걸리는 작업을 몇 초안에 할 수 있습니다. 

 

이러한 기법을 안 쓰면, 데이터 크기가 만 개 이상만 되어도, 그 데이터들을 엑셀 시트에 읽고 쓰고 하는 작업이 몇십 초 혹은 몇 분 걸립니다. 그래서 느려서 못쓰겠다 하는데, 여기서 소개하는 기법을 적용하면 극적으로 빨라질 것입니다.

 

기법 1. 셀(cell) 쓰기 횟수를 최대한 줄인다.

for나 while루프를 이용해서 row를 1에서 10000까지 증가시키면서 뭔가 쓴다. 

 

이렇게 하면 안 됩니다. 쓸 내용을 배열에다 집어넣고, row 1~10000까지를 Range로 만든 후, 배열 값을 Range에 한 번에 넣어야 합니다.

 

엑셀 VBA에서 가장 많이 시간을 잡아먹는 것이 cell 쓰기 작업이기 때문입니다.

 

여기서 소개하는 program.xlsm에서도, 대부분의 값을 배열로 다 처리하고 난 후 그 값을 엑셀 시트에 한 번에 쓰기 하고 있습니다.  

 

1000개의 셀에 하나씩 쓰기 작업하면 약 20초 소요. 이걸 배열 처리해서 한 번에 쓰면 약 0.2초

 

기법 2. 엑셀의 속도 관련 속성 값을 조절

program.xlsm 코드 안에 보면 SetTurbo 프로시저를 찾을 수 있을 겁니다. 

 

Sub SetTurbo(is_on As Boolean)
    If is_on Then
        Application.ScreenUpdating = False
        Application.EnableEvents = False
        Application.Calculation = xlCalculationManual  '매크로 속도를 엄청 빠르게 만듬
    Else
        Application.ScreenUpdating = True
        Application.EnableEvents = True
        Application.Calculation = xlCalculationAutomatic
    End If
    
End Sub

 

SetTurbo(True)로 하면 Applicaton관련 3개의 속성값이 바뀝니다. 전부 수행 속도에 영향을 미치는 것으로, 특히 대규모의 데이터를 다룰때는 Application.Calculaton=xlCalculationManual이 수행 속도 절감에 큰 영향을 끼집니다.

 

이 SetTurbo 함수를, for나 while루프 들어가기 전에 True로 하고, 루프를 빠져 나왔을 때 False해주면 되는데, 이 함수만 사용해줘도 엄청난 속도 향상을 보일 수 있을 것입니다.

 

 

데이터의 크기 순위(rank) 알아내기

program.xlsm의 코드 중에 CalculateRank 프로시저에서, 누적 감염자 순에 따라 각 날자별 국가 순위를 계산합니다.

 

엑셀 데이터에서 순위를 알아내는 것은 WorksheetFunction.Rank_Eq를 쓰면 간단합니다. 

 

해서, 별도로 순위를 뽑아내는 VBA 코드를 만들까 하다가, 그냥 엑셀 워크시트 함수를 사용했습니다. 

 

문제는 워크시트 함수의 경우 그 입력값이 Range라야한다는 것. 그래서 해당 데이터를 뽑아낸 후 다시 그 데이터를 시트의 특정 영역에다가 쓰고, 그 영역을 Range로 잡아서 Rank_Eq에 넘겨주는 형태로 처리했습니다.

 

	'CalculateRank 프로시저 내부
    
    For r = sr To er
        k = 0
        For c = 3 To ec Step 6
            buff_case(k) = Cells(r, c).Value
            buff_death(k) = Cells(r, c + 3).Value
            k = k + 1
        Next c
        
        Set rng_case = Range(Cells(r, ec + 1), Cells(r, ec + 1 + country_cnt - 1))
        Set rng_death = Range(Cells(r + 1, ec + 1), Cells(r + 1, ec + 1 + country_cnt - 1))
        
        rng_case.Value = buff_case
        rng_death.Value = buff_death
        
        For c = 3 To ec Step 6
            tgt_val = Cells(r, c).Value 'case_cumul
            Cells(r, c + 1).Value = WorksheetFunction.Rank_Eq(tgt_val, rng_case, 0)
            
            tgt_val = Cells(r, c + 3).Value 'death_cumul
            Cells(r, c + 4).Value = WorksheetFunction.Rank_Eq(tgt_val, rng_death, 0)
        Next c
    Next r

 


나머지 코드들은 실제 소스를 보면 이해가 될거라고 봅니다. 

 

전체 소스는 아래와 같고, 소스가 들어 있는 엑셀 파일도 페이지 맨 밑에 다운로드할 수 있게 해놨습니다.

 

'Program 모듈
Option Explicit

Sub SetTurbo(is_on As Boolean)
    If is_on Then
        Application.ScreenUpdating = False
        Application.EnableEvents = False
        Application.Calculation = xlCalculationManual  '매크로 속도를 엄청 빠르게 만듬
    Else
        Application.ScreenUpdating = True
        Application.EnableEvents = True
        Application.Calculation = xlCalculationAutomatic
    End If
    
End Sub

'주어진 시트의 칼럼에 대해 유니크한 데이터만 추려서 소팅 후 리턴
Function GetUniqueData(src_sheet As Worksheet, col_num As Long) As Variant
    Dim sr As Long, sc As Long
    Dim er As Long, ec As Long
    Dim data_rng As Range
    
    sr = 2
    sc = col_num
    src_sheet.Activate
    Cells(sr, sc).Select
    er = Selection.End(xlDown).Row
    Set data_rng = Range(Cells(sr, sc), Cells(er, sc))
    
    Dim data(), dict As Object, r As Long
    Set dict = CreateObject("Scripting.Dictionary")
    
    data = data_rng.Value
    
    For r = 1 To UBound(data)
        dict(data(r, 1)) = Empty
    Next r
    data = dict.keys
    
    Call Common.QuickSort(data, LBound(data), UBound(data))
    
    GetUniqueData = data
End Function

Sub MakeFormat(tgt_sheet As Worksheet, dates As Variant, countries As Variant)
    
    
    Dim i As Long, j As Long
    Dim dates_len As Long, countries_len As Long
    
    dates_len = UBound(dates) - LBound(dates) + 1
    countries_len = UBound(countries) - LBound(countries) + 1
    
    tgt_sheet.Activate
    Cells(1, 1).Value = "Date"
    Range(Cells(4, 1), Cells(4 + dates_len - 1, 1)).Value = Application.Transpose(dates)
    
    Call SetTurbo(True)
    For j = 0 To countries_len - 1
        Cells(1, j * 6 + 2).Value = countries(j)
        Cells(2, j * 6 + 2).Value = "Case"
        Cells(2, j * 6 + 5).Value = "Death"
        
        Cells(3, j * 6 + 2).Value = "new"
        Cells(3, j * 6 + 3).Value = "cumul"
        Cells(3, j * 6 + 4).Value = "rank"
        
        Cells(3, j * 6 + 5).Value = "new"
        Cells(3, j * 6 + 6).Value = "cumul"
        Cells(3, j * 6 + 7).Value = "rank"
    Next j
    Call SetTurbo(False)
End Sub

Sub MakeCasesDict(dict As Object, sheet As Worksheet)
    Dim sr As Long, sc As Long
    Dim er As Long
    
    sheet.Activate
    sr = 2
    Cells(sr, 1).Select
    er = Selection.End(xlDown).Row
    
    Dim dates(), countries(), cases_deaths()
    dates = Range(Cells(sr, 1), Cells(er, 1)).Value
    countries = Range(Cells(sr, 7), Cells(er, 7)).Value
    cases_deaths = Range(Cells(sr, 5), Cells(er, 6)).Value
    
    Dim i As Long
    Dim key As String
    For i = 1 To (er - sr) + 1
        key = dates(i, 1) & countries(i, 1)
        dict(key) = Array(cases_deaths(i, 1), cases_deaths(i, 2))
    Next i
End Sub

Sub WriteCaseDeath(sheet As Worksheet, dict As Object)
    Dim sr As Long, sc As Long
    Dim er As Long, ec As Long
    
    sheet.Activate
    Cells(4, 1).Select
    er = Selection.End(xlDown).Row
    
    Cells(3, 2).Select
    ec = Selection.End(xlToRight).Column
        
    Dim r As Long, c As Long
    Dim key As String
    Dim case_val, death_val
    
    Call SetTurbo(True)
    Range(Cells(4, 2), Cells(er, ec)).NumberFormat = "0"
    For r = 4 To er
        For c = 2 To ec Step 6
            key = Cells(r, 1).Value & Cells(1, c).Value
            If dict.Exists(key) Then
                case_val = dict(key)(0)
                death_val = dict(key)(1)
            Else
                case_val = 0
                death_val = 0
            End If
            
            Cells(r, c).Value = case_val
            Cells(r, c + 3).Value = death_val
        Next c
    Next r
    Call SetTurbo(False)
    
End Sub

Sub CalculateCumul(sheet As Worksheet)
    Dim sr As Long, sc As Long
    Dim er As Long, ec As Long
    
    sheet.Activate
    sr = 4
    Cells(sr, 1).Select
    er = Selection.End(xlDown).Row
    
    Cells(3, 2).Select
    ec = Selection.End(xlToRight).Column
    
    'calculate cumulated number of case and death
    Call SetTurbo(True)
    
    Dim new_data(), cumul_data() As Long
    Dim arr_len As Long
    
    arr_len = er - sr + 1
    ReDim cumul_data(1 To arr_len) As Long
    Dim c As Long, i As Long
    For c = 2 To ec Step 6
        '(1)for case data
        new_data = Range(Cells(4, c), Cells(er, c)).Value
        cumul_data(1) = new_data(1, 1)
        For i = 2 To arr_len
            cumul_data(i) = cumul_data(i - 1) + new_data(i, 1)
        Next i
        
        Range(Cells(4, c + 1), Cells(er, c + 1)).Value = Application.Transpose(cumul_data)
        
        '(2)for death data
        new_data = Range(Cells(4, c + 3), Cells(er, c + 3)).Value
        cumul_data(1) = new_data(1, 1)
        For i = 2 To arr_len
            cumul_data(i) = cumul_data(i - 1) + new_data(i, 1)
        Next i
        
        Range(Cells(4, c + 4), Cells(er, c + 4)).Value = Application.Transpose(cumul_data)
    Next c
    Call SetTurbo(False)
End Sub

Sub CalculateRank(sheet As Worksheet)
    Dim sr As Long, sc As Long
    Dim er As Long, ec As Long
    
    sheet.Activate
    sr = 4
    Cells(sr, 1).Select
    er = Selection.End(xlDown).Row
    
    
    sc = 2
    Cells(3, sc).Select
    ec = Selection.End(xlToRight).Column
    
    
    'calculate cumulated number of case and death
    Call SetTurbo(True)
    Dim r As Long, c As Long
    Dim i As Long, j As Long, k As Long
    Dim country_cnt As Long
    Dim tgt_val As Long
    
    Dim rng_case As Range, rng_death As Range
    
    
    Dim buff_case() As Long, buff_death() As Long
    country_cnt = (ec - sc + 1) / 6
    ReDim buff_case(0 To country_cnt - 1) As Long
    ReDim buff_death(0 To country_cnt - 1) As Long
    
    
    For r = sr To er
        k = 0
        For c = 3 To ec Step 6
            buff_case(k) = Cells(r, c).Value
            buff_death(k) = Cells(r, c + 3).Value
            k = k + 1
        Next c
        
        Set rng_case = Range(Cells(r, ec + 1), Cells(r, ec + 1 + country_cnt - 1))
        Set rng_death = Range(Cells(r + 1, ec + 1), Cells(r + 1, ec + 1 + country_cnt - 1))
        
        rng_case.Value = buff_case
        rng_death.Value = buff_death
        
        For c = 3 To ec Step 6
            tgt_val = Cells(r, c).Value 'case_cumul
            Cells(r, c + 1).Value = WorksheetFunction.Rank_Eq(tgt_val, rng_case, 0)
            
            tgt_val = Cells(r, c + 3).Value 'death_cumul
            Cells(r, c + 4).Value = WorksheetFunction.Rank_Eq(tgt_val, rng_death, 0)
        Next c
    Next r
    Call SetTurbo(False)
End Sub

Sub RankCountryByCumulData(data_sheet As Worksheet, rank_sheet As Worksheet)
    Dim i As Long, j As Long, r As Long, c As Long, k As Long
    Dim sr As Long, sc As Long, er As Long, ec As Long
    Dim country_cnt As Long
        
    data_sheet.Activate
    sr = 4
    Cells(sr, 1).Select
    er = Selection.End(xlDown).Row
    
    sc = 2
    Cells(3, sc).Select
    ec = Selection.End(xlToRight).Column
    
    country_cnt = (ec - sc + 1) / 6
    Dim date_str As String, country As String
    Dim cumul As Long
    
    Call SetTurbo(True)
    For r = 4 To er
        
        date_str = data_sheet.Cells(r, 1).Value
        rank_sheet.Cells(1, (r - 3) * 2).Value = date_str
        k = 2
        For c = 2 To ec Step 6
            country = data_sheet.Cells(1, c).Value
            cumul = data_sheet.Cells(r, c + 1).Value
            If cumul > 0 Then
                rank_sheet.Cells(k, (r - 3) * 2).Value = country
                rank_sheet.Cells(k, (r - 3) * 2 + 1).Value = cumul
                k = k + 1
            End If
        Next c
        If k > 1 Then
            Call Common.RangeSorting(rank_sheet, 2, (r - 3) * 2, 1 + k, (r - 3) * 2 + 1)
        End If
    Next r
    Call SetTurbo(False)
    
End Sub


'''''''''''''''''''''''''''''''''''''''''''

Sub ConvertSourceData()
    Dim wb As Workbook
    Dim src_sheet As Worksheet, tgt_sheet As Worksheet
    Dim prg_sheet As Worksheet
    
    Dim src_file_name As String, src_sheet_name
        
    Set prg_sheet = Workbooks("program.xlsm").Sheets("program")
    src_file_name = prg_sheet.Cells(12, 12).Value
    src_sheet_name = prg_sheet.Cells(13, 12).Value
        
    Set wb = Workbooks(src_file_name)
    Set src_sheet = wb.Sheets(src_sheet_name)
    
    '1. Create target sheet as "data"
    src_sheet.Activate
    Sheets.Add.Name = "data"
    Set tgt_sheet = Sheets("data")
        
    '2. Get unique date and countries
    Dim dates As Variant, countries As Variant
    dates = GetUniqueData(src_sheet, 1)
    countries = GetUniqueData(src_sheet, 7)
    
    '3. make format
    Call MakeFormat(tgt_sheet, dates, countries)
    
    '4 create dictionary for cases and deaths
    Dim cases_deaths As Object
    Set cases_deaths = CreateObject("Scripting.Dictionary")
    Call MakeCasesDict(cases_deaths, src_sheet)
    
    
    '5 write case and death value
    Call WriteCaseDeath(tgt_sheet, cases_deaths)
    
    '6. Calculate Cumulated data
    Call CalculateCumul(tgt_sheet)
    
    '7. Calculate rank
    Call CalculateRank(tgt_sheet)
    
    '8. Create rank sheet
    src_sheet.Activate
    Sheets.Add.Name = "rank"
    
    Dim rank_sheet As Worksheet
    Set rank_sheet = Sheets("rank")
    Call RankCountryByCumulData(tgt_sheet, rank_sheet)
End Sub


 

'Common 모듈
Option Explicit

'usage: QuickSort(arr,LBound(arr), UBound(arr))
Sub QuickSort(ByRef arr As Variant, Lo As Long, Hi As Long)
  Dim varPivot As Variant, varTmp As Variant
  Dim tmpLow As Long, tmpHi As Long
  
  tmpLow = Lo
  tmpHi = Hi
  varPivot = arr((Lo + Hi) / 2)
  Do While tmpLow <= tmpHi
    Do While arr(tmpLow) < varPivot And tmpLow < Hi
      tmpLow = tmpLow + 1
    Loop
    Do While varPivot < arr(tmpHi) And tmpHi > Lo
      tmpHi = tmpHi - 1
    Loop
    If tmpLow <= tmpHi Then
      varTmp = arr(tmpLow)
      arr(tmpLow) = arr(tmpHi)
      arr(tmpHi) = varTmp
      tmpLow = tmpLow + 1
      tmpHi = tmpHi - 1
    End If
  Loop
  If Lo < tmpHi Then QuickSort arr, Lo, tmpHi
  If tmpLow < Hi Then QuickSort arr, tmpLow, Hi
End Sub

Sub RangeSorting(sheet As Worksheet, sr As Long, sc As Long, er As Long, ec As Long)
    sheet.Activate
    
    Dim rng As Range, key_rng As Range
    Set rng = Range(Cells(sr, sc), Cells(er, ec))
    Set key_rng = Range(Cells(sr, ec), Cells(er, ec))

    sheet.Sort.SortFields.Clear
    sheet.Sort.SortFields.Add key:=key_rng, _
        SortOn:=xlSortOnValues, Order:=xlDescending, DataOption:=xlSortNormal
        
    With sheet.Sort
        .SetRange rng
        .Header = xlNo
        .MatchCase = False
        .Orientation = xlTopToBottom
        .SortMethod = xlPinYin
        .Apply
    End With
End Sub

 

위 소스코드가 들어 있는 엑셀파일은,

program_v1.0.xlsm
0.04MB

 

-끝-

반응형