Windows Batch Script

이 블로그에는 세 개의 카테고리가 있다. ‘개(Dog)’, ‘발(Foot)’, ‘인생(Life)’이 그것인데, 순서대로 ‘어떠한 것에 대한 설명’, ‘실수한 경험’, ‘코드가 없는 이야기’라는 의미를 담아두었다. 대충 그렇다.

이번에 쓰려던 것은 사실, ‘개(Dog)’카테고리에 넣으려고 했던 것이다. 이것이 ‘발(Foot)’카테고리에 들어가게 된 데에는 아래와 같은 사연이 있다.

회사에는 수십 대의 서버가 있고, 거기에는 수시로 로그가 쌓이게 된다. 그 양이 꽤 많아서, 몇 달 지나면 금새 하드디스크가 다 차버리게 되는데, 어찌된 영문인지 이를 자동으로 지워주는 기능이 없었다.

서버는 윈도 서버였으며, 로그는 최근 세 달의 것만 유지하도록 하고자 했다. 어려울 거 없다. 세달전의 로그를 지우는 스크립트 혹은 프로그램을 주기적으로 실행해주기만 하면 된다.

윈도에는 리눅스의 crontab과 비슷한 Task Scheduler가 있으며, crontab과 비슷한 형태로 실행하려는 명령을 등록할 수 있다. Manager프로그램을 이용해서 Task를 등록하려면, 이를 실행할 계정정보를 명시해야 한다. 개발자가 공통으로 사용하는 계정은 몇 달 간격으로 비밀번호를 변경하는데, 이것이 Task Scheduler가 등록한 Task를 실행하지 못하는 원인이 되므로, 다른 개발자가 주기적으로 명령어를 실행하는 프로그램을 윈도 서비스로 만들어 놨다. 그리곤, 그걸 써보랜다.

‘MS가 바보도 아니고, 그런걸 생각하지 못했을 리 없잖아!. 게다가, 당신이 만든 그 서비스를 등록하는 것도 귀찮다고!’라고 속으로만 생각했다. 왜냐면, 그 사람이 한참 상사였으니깐.

AT라는 Command Line명령으로 Task Scheduler에 Task를 등록하면, 기본적으로(Manager프로그램에서 변경할 수 있다) System계정이 사용된다. 즉, 그냥 AT명령어 사용하면 된다. 몇 번만 구글링해보면 나온다. 한번은 부족할 수 있다.

로그를 지우는 스크립트는 Windows Batch파일로 작성했다. 이 과정이 길었다. Batch파일의 문법은 제약이 많고, 표현도 풍부하지 못하다. 결정적으로, 난 거의 모른다. 그래도 굳이 Batch파일을 사용한 이유는, 누구나 소스를 보고 수정할 수 있고, 서버에 설치해야 할 프로그램이 없다는 점 때문이었다. 어줍잖은 오픈 마인드.

도저히 익숙해지지 않을법한 문법도 문제였지만, 더 큰 문제는 날짜간 연산을 지원하지 않는 관계로, 대학교 숙제 생각하며 윤년 계산하며 이를 직접 구현해야 했다는데 있다. 그래서…, Ruby로 했으면 30분이면 끝났을 일을 하루 종일 했다.

최종적으로 스크립트를 복사하고 Task Scheduler에 등록하는 등의 일련의 과정은 Ruby를 이용해서 만들고, 이를 OCRA로 exe 변환, 서버에서 실행/설치하는 것으로 마무리 지었다.

당연히 테스트 했고, 날짜계산도 잘 되고…, 뭐 암튼 다 잘 되었다. 처음 작성한 Batch파일에 문제가 없음에 스스로 대견스러웠다.

여기까지였으면 ‘개(Dog)’카테고리에 이 글을 넣었을 거다.

문제는 정확히 2013년 2월 8일 발생했다. 스크립트는 1월 25일부터 매일 문제없이 돌고 있었다. 2월 8일까지는 그랬던 거다. 2월 8일 새벽에…, 스크립트는 모든 로그를 지워버렸다. 젠장! 이 한마디는 결국, 이 포스트가 ‘발(Foot)’카테고리에 들어가 되었음을 의미한다.

난, 이런 경우 심한 자괴감에 빠져든다. 물론, 남들 모르게.

역시나 날짜를 계산하는 부분이 썩어있었다. 날짜는 연월일을 포함한 8자리 문자열로 구성되는데, 90일 이전의 날짜를 구하기 위해서 이를 연, 월, 일로 분리한다. 이를 Batch파일에서는 다음과 같이 처리한다.

SET /A "year=%from:~0,4%"
SET /A "month=%from:~4,2"
SET /A "day=%from:~6,2"

짐작하듯이, 8자리 문자열을 4/2/2 분할하여 이를 숫자로 각 변수에 저장한다. /A옵션은 문자열을 숫자로 인식하도록 한다. 예를 들어 문자열 04는 숫자로 인식되어 4로 저장되게 된다. 물론, Batch에는 타입이 존재하지 않으므로, 4는 다시금 문자열로 다룰 수 있다.

이때, 모르고 넘어가면 안될 결정적인 부분이 있는데, 내가 놓친 것이 이것이다.

Batch파일에서 문자열을 숫자로 변환할 때, 문자열이 0으로 시작하면 이는 무조건 8진수로 인식한다. 따라서, 문자열 08은 유효하지 않은 숫자가 된다. 8진수는 0~7의 숫자만 사용하니깐! 그래서 2월 8일 문제가 생긴 거다. 중간에 에러가 난 거지. 그래서 잘못된 날짜를 갖고 현재날짜와 비교해서 로그를 지우는 바람에, 모든 로그를 몽땅 지워버린 거라고.

수정 후 위 코드는 다음과 같이 바뀌었다.

SET /A "year=%from% / 10000"
SET /A "month=%from% %% 10000 / 100"
SET /A "day=%from% %% 100"

%%는 mod연산이다. 원 날짜 값을 문자열로써 파싱하지 않고, 그냥 산술 식으로 계산해서 변환시켰다. 진작 이럴걸 후회해도 이미 로그는 다 지워졌고, 고객문의는 처리되지 못할 뿐이었다.

덕분에 사건 다음 출근 일에는 하루 종일 입이 부르트도록 로그파일에 인공 호흡했다. 다행히 대부분은 살아나서 우울증은 걸리지 않았다. 그리고 결심했다. Windows Batch파일은 더 이상 사용하지 않으리!

Reading Excel using Ruby

There’re some libraries dealing with Excel, but they didn’t work in some conditions. So I’ve just written the code for this.

ExcelData uses win32ole module. It means this work only on Windows and Excel needs to be installed.
There’s only a public class method Load returns row array. Each row consists of hash with column name and value string pair.
Load method requires minimum three parameters. Excel filename(excelFilename), sheet name(worksheetName) and first header column name(firstHeaderColumnName). ExcelData finds first row with first header column name and read cell from there. If empty cell is found(both row and column), it stops reading the file.

require 'win32ole'

class ExcelData
public
  def	ExcelData.Load(excelFilename, worksheetName, firstHeaderColumnName, keySearcher = nil)
    xls       = nil
		ws        = nil
    excelData = []

    begin
      xls         = WIN32OLE.new('Excel.Application')
      xls.visible = false
      ws          = xls.Workbooks.Open(excelFilename).Worksheets(worksheetName)

      excelData   = _CollectData(ws, firstHeaderColumnName, keySearcher)
    rescue
      puts "Failed to open excel file : #{excelFilename}"
    ensure
      xls.quit if xls != nil
    end

    return excelData
  end

private
  def	ExcelData._FindFirstDataRowNum(worksheet, firstHeaderColumnName)
    rowNum  = 1

    while (true)
      row = worksheet.Range("a#{rowNum}")
      break if (row['Value'] != nil && row['Value'] == firstHeaderColumnName)
      rowNum  += 1
    end

    return rowNum + 1
  end
	
  def	ExcelData._FindLastDataRowNum(worksheet, firstDataRowNum)
    rowNum  = firstDataRowNum

    while (true)
      row = worksheet.Range("a#{rowNum}")
      break if (row['Value'] == nil || row['Value'] == "")
      rowNum  += 1
    end

    return rowNum - 1
  end

  def	ExcelData._FindLastDataColChar(worksheet, headerDataRowNum)
    colChar	= 'a'

    while (true)
      row = worksheet.Range("#{colChar}#{headerDataRowNum}")
      break if (colChar == 'z' || row['Value'] == nil || row['Value'] == "")
      colChar.succ!
    end

    return colChar
  end

  def ExcelData._CollectData(worksheet, firstHeaderColumnName, keySearcher)

    firstDataRowNum = _FindFirstDataRowNum(worksheet, firstHeaderColumnName)		
    lastDataRowNum  = _FindLastDataRowNum(worksheet, firstDataRowNum)
    lastDataColChar = _FindLastDataColChar(worksheet, firstDataRowNum - 1)

    puts "Collecting excel data : Total #{lastDataRowNum - firstDataRowNum + 1} rows ..."

    # Column Names
    colNames	= []
    row = worksheet.Range("a#{firstDataRowNum - 1}:#{lastDataColChar}#{firstDataRowNum - 1}")
    row.each do |cell| colNames << cell['Value'] end

    # Collect excel data
    excelData	= []
    for rowNum in firstDataRowNum..lastDataRowNum
      colIndex  = 0
      rowHash   = {}

      row = worksheet.Range("a#{rowNum}:#{lastDataColChar}#{rowNum}")

      row.each do |cell|
        if (!cell['Value'].to_s.empty? && cell['Value'].to_s =~ /^[0-9.]+/)
          rowHash.store(colNames[colIndex], cell['Value'].to_i.to_s)
        elsif
          rowHash.store(colNames[colIndex], cell['Value'].to_s)
        end

        colIndex	+= 1
      end

      excelData << rowHash

      keySearcher.store(rowHash[colNames[0]].to_s, excelData.length - 1) if keySearcher != nil

      print "." if excelData.length % 10 == 0
    end

    puts 

    return excelData
  end
end

You can simply use this code like bellow.

xls = ExcelData.Load('myfile.xls', 'sheet1', 'ITEMID')