본문 바로가기
카테고리 없음

파일 및 디렉터리 수 집계하기2

by 경자꿈사 2024. 11. 12.

지난번 글에서 특정 디렉터리 안의 모든 항목들을 집계하기 위해 재귀적 탐색 패턴과 함께 Dir 클래스의 glob 메서드를 사용하여 한 번에 전체 항목 결과를 받아와서 집계를 했었다.
해당 작업을 쉽고 간단한 코드로 처리할 수는 있었지만, 항목이 많은 큰 디렉터리를 대상으로 집계 작업을 수행할 경우에는 프로그램이 사용하는 메모리의 양이 glob 메서드가 실행되는 동안 계속적으로 늘어난다는 것을 실제 테스트를 통해 확인해 보았다.

 

오늘은 반복문을 사용해서 직접 하위 디렉터리를 하나씩 탐색하는 방법으로 항목을 집계해 보려고 한다.
아래 코드를 보면 FileCounter 클래스에 count_by_recursive 메서드를 하나 추가했는데, 메서드 이름처럼 코드를 재귀 호출 방식으로 작성하였다.
디렉터리 안의 항목들을 하나씩 직접 처리하기 위해 Dir 클래스의 foreach 메서드를 사용하였다.
foreach 메서드는 블록 파라미터를 통해 항목들의 이름을 하나씩 넘겨 주는데, 이름이 '.'으로 시작하는 것도 넘겨 주므로 현재 디렉터리를 의미하는 '.'와 상위 디렉터리를 의미하는 '..'은 집계에서 제외했다.
그리고 블록 파라미터를 통해 받는 항목의 이름은 경로가 빠진 순수한 이름만 넘어오기 때문에 해당 항목이 파일인지 디렉터리인지 판별하기 위해서는 디렉터리 경로와 해당 항목의 이름을 연결한 전체 경로를 사용해야 한다.
결과적으로 파일이냐 디렉터리냐에 따라 해당 집계 변수의 값을 1만큼 증가시켰고, 해당 항목이 디렉터리일 경우에는 그 경로의 값을 인수로 하여 메서드를 재귀 호출한 후 결괏값을 집계 결과에 누적시켰다.

class FileCounter
  def count_by_glob(dir)
    dir_cnt, f_cnt = 0, 0
    Dir.glob(File.join(dir, "**/{*,.*}")).each { |f| if File.file?(f) then f_cnt += 1 else dir_cnt += 1 end }    
    {dir: dir_cnt, file: f_cnt}
  end
  
  def count_by_recursive(dir)
    dir_cnt, f_cnt = 0, 0
    Dir.foreach(dir) do |f|
      next if f == "." || f == ".."
      f = File.join(dir,f)
      if File.file?(f)
        f_cnt += 1
      else
        dir_cnt += 1
        h = count_by_recursive(f)
        f_cnt += h[:file]
        dir_cnt += h[:dir]
      end
    end
    {dir: dir_cnt, file: f_cnt}
  end
end

 

이제 새로 만든 count_by_recursive 메서드를 기존 count_by_glob 메서드와 동일한 방법으로 테스트를 진행해 보자.
먼저 D:/blog/ruby/file_counter 폴더 아래에 있는 file_counter.rb 파일에 수정한 내용을 반영하고, 해당 폴더의 위치에서 irb를 실행한 후 다음 그림처럼 코드를 입력해 보자.

이전 글에서 만들었던 count_by_glob 메서드와 결과가 동일하게 나오는 것을 볼 수 있다.
이번에는 크기가 큰 D:/blog/ruby/file_counter/logs 디렉터리를 count_by_recursive 메서드로 집계해 보자.

이미 테스트를 위한 logs 디렉터리는 지난번 글에서 아래 코드를 사용하여 생성했었는데, 테스트 후에 logs 디렉터리를 삭제했거나 아직 생성하지 않았다면 
다시 생성하길 바란다. 아래 코드를 D:/blog/ruby/file_counter 폴더 아래 make_dummy_files.rb 파일로 저장한 후 해당 폴더에서 cmd 창을 열어 실행하면 된다.

require 'date'
require 'fileutils'

logs_dir = "D:/blog/ruby/file_counter/logs"

FileUtils.mkdir(logs_dir)
today = Date.today

1000.times do |n|
  date = (today - n).strftime("%Y-%m-%d")
  dir = File.join(logs_dir, date)
  FileUtils.mkdir(dir)
  1.upto(100) do |n|
    file = File.join(dir, "#{date}_#{n.to_s.rjust(3, "0")}.log")
    FileUtils.touch(file)
  end
end

 

이제 테스트를 위해 아래 코드를 D:/blog/ruby/file_counter 폴더 아래 test_count_by_recursive.rb 파일로 저장하자.
count_by_glob 메서드 호출을 count_by_recursive 메서드 호출로 변경한 것 말고는 지난번 글에서 만들었던 test_count_by_glob.rb 파일의 코드와 동일하다.

require './file_counter'

def memory_usage
  `tasklist /fi "pid eq #{Process.pid}"`.encode("utf-8", "cp949").match(/[\d,]* K/)[0]  
end

completed = false

Thread.new do
  while !completed
    puts memory_usage
    sleep 1
  end
end

fc = FileCounter.new

start_time = Time.now
puts fc.count_by_recursive("logs")
completed = true
puts Time.now - start_time

 

이제 D:/blog/ruby/file_counter 폴더의 위치에서 cmd 창을 띄워 test_count_by_recursive.rb 파일을 실행해 보자.


실행 결과를 보면 count_by_glob 메서드로 집계할 때와 달리 메모리 사용량이 거의 늘지 않는 상태로 유지되며 집계에 걸린 시간도 조금은 줄어든 것을 볼 수 있다.
Dir 클래스의 glob 메서드를 사용할 때보다 코드의 길이는 조금 길어졌지만 메모리 사용에 있어 더 안정적이고 항목을 하나씩 처리할 수 있기 때문에 유연성은 더 커졌다.
그러나 이미 알고 있겠지만 재귀 호출이 갖는 기본적인 한계가 있다. 재귀 호출이 너무 깊어지면 스택 오버플로 에러가 발생한다.
아래 그림처럼 테스트를 해보면 재귀 호출의 깊이가 너무 깊어지면 SystemStackError(프로그래밍 언어마다 부르는 이름은 다를 수 있다.) 예외가 발생하는 것을 볼 수 있다.

프로그램이 실행되는 환경이나 상황에 따라 스택 오버플로 에러가 발생하는 시점(깊이)이 다를 수 있으므로, 중요한 건 처음부터 그러한 문제가 발생할 수 있는 가능성이 높다면 재귀 호출이 아닌 다른 방법으로 문제를 해결해야 한다는 것이다.
그러면 반복을 통해 처리를 하되, 재귀 호출을 사용하지 않으려면 어떻게 해야 할까?
'배열과 친해지기' 첫 번째 글을 보면 스택과 큐에 대해 잠깐 설명을 한 부분이 나오는데, 스택은 가장 마지막에 넣은 데이터가 가장 먼저 나오는 '후입선출' 구조이고, 큐는 가장 먼저 넣은 데이터가 가장 먼저 나오는 '선입선출' 구조라고 했다. 

그리고 루비에서 배열은 스택과 큐 두 가지 방식으로 모두 사용이 가능하다고 했다.
재귀 호출 방식을 보면 특정 디렉터리에 대한 집계를 현재 호출 중인 메서드에게 다시 맡기는 구조인데, 메서드의 주요 로직을 반복문으로 감싸고 집계 대상 경로를 인수가 아닌 다른 '어딘가'에서 가져와 반목문을 돌리는 구조로 변경한다면 재귀 호출을 대체할 수 있을 것이다. 이럴 때 그 '어딘가'에 적합한 데이터 구조가 큐와 스택이다.
즉 하위 항목 중 디렉터리를 발견했다면 디렉터리 카운트를 1만큼 증가시킨 후 해당 디렉터리의 경로를 큐 또는 스택에 넣어 놓고 그냥 다음 항목으로 넘어가면 그뿐이다.
그렇게 현재 디렉터리의 항목들에 대한 반복문의 처리가 끝이 났다면, 큐 또는 스택에서 디렉터리 경로를 하나 가져와서 다시 반복문을 시작한다. 즉, 하위 디렉터리에 대한 집계가 시작되는 것이다.
이제 재귀 호출 없이 큐 또는 스택을 사용해서 디렉터리의 항목을 집계하는 방법에 대해 이해가 됐을테니 아래 작성한 코드를 보자.
FileCounter 클래스에 count_by_queue 메서드가 하나 추가되었는데, count_by_recursive 메서드와 유사하지만 앞서 설명한 대로, 재귀 호출을 제거하였고 그 대신에 기존 로직을 본문으로 하는 while 반복문을 하나 두었다.
그 while 반복문은 dir_queue 배열 안에 처리할 디렉터리 경로가 남아 있을 때까지 계속해서 디렉터리 경로를 하나씩 꺼내와 그 디렉터리에 대한 집계 로직을 실행한다.
count_by_recursive 메서드에서 재귀 호출을 하고 그 결괏값을 집계 변수에 반영하는 부분이 count_by_queue 메서드에서는 단순히 dir_queue 배열에 해당 경로의 값을 밀어 넣는 것으로 변경되었다.
그리고 count_by_queue 메서드에서 shift 메서드가 아닌 pop 메서드를 사용하면 배열을 스택 구조로 사용하게 되는데, 디렉터리의 항목을 집계하는 작업의 경우 큐와 스택 중 어느 것을 사용해도 정상적으로 잘 동작한다.

class FileCounter
  def count_by_glob(dir)
    dir_cnt, f_cnt = 0, 0
    Dir.glob(File.join(dir, "**/{*,.*}")).each { |f| if File.file?(f) then f_cnt += 1 else dir_cnt += 1 end }    
    {dir: dir_cnt, file: f_cnt}
  end
  
  def count_by_recursive(dir)
    dir_cnt, f_cnt = 0, 0
    Dir.foreach(dir) do |f|
      next if f == "." || f == ".."
      f = File.join(dir,f)
      if File.file?(f)
        f_cnt += 1
      else
        dir_cnt += 1
        h = count_by_recursive(f)
        f_cnt += h[:file]
        dir_cnt += h[:dir]
      end
    end
    {dir: dir_cnt, file: f_cnt}
  end
  
  def count_by_queue(dir)
    dir_cnt, f_cnt = 0, 0
    dir_queue = [dir]
    
    while dir = dir_queue.shift    
      Dir.foreach(dir) do |f|
        next if f == "." || f == ".."
        f = File.join(dir,f)
        if File.file?(f)
          f_cnt += 1
        else
          dir_cnt += 1
          dir_queue.push(f)
        end
      end
    end
    {dir: dir_cnt, file: f_cnt}  
  end
end

 

이제 count_by_queue 메서드 역시 기존과 동일한 방법으로 테스트를 진행해 보자.
먼저 D:/blog/ruby/file_counter 폴더 아래에 있는 file_counter.rb 파일에 수정한 내용을 반영하고, 해당 폴더의 위치에서 irb를 실행한 후 다음 그림처럼 코드를 입력해 보자.
세 가지 메서드 모두 결과가 동일하게 나오는 것을 볼 수 있다.

다음으로 count_by_queue 메서드로 D:/blog/ruby/file_counter/logs 디렉터리의 항목 수를 집계해 보자. 
먼저 아래의 코드를 D:/blog/ruby/file_counter 폴더 아래에 test_count_by_queue.rb 파일에 저장하자. 기존 코드에서 메서드의 이름만 count_by_queue으로 변경하였다.

require './file_counter'

def memory_usage
  `tasklist /fi "pid eq #{Process.pid}"`.encode("utf-8", "cp949").match(/[\d,]* K/)[0]  
end

completed = false

Thread.new do
  while !completed
    puts memory_usage
    sleep 1
  end
end

fc = FileCounter.new

start_time = Time.now
puts fc.count_by_queue("logs")
completed = true
puts Time.now - start_time

이제 해당 폴더의 위치에서 cmd 창을 띄워 test_count_by_queue.rb 파일을 실행해 보자.

실행 결과를 보면 count_by_recursive 메서드처럼 메모리 사용량에 큰 변화가 없고 집계에 걸린 시간도 count_by_glob 메서드보다 조금은 줄어든 것을 볼 수 있다.

이번 글에서는 세 가지 방법을 사용하여 디렉터리 안에 포함된 파일과 디렉터리의 수를 집계해 보았다.
Dir 클래스의 glob 메서드를 사용하는 방법이 가장 짧은 코드로 원하는 결과를 쉽게 얻을 수 있었지만, 집계 대상 디렉터리에 포함된 항목의 수가 많을 경우 메모리 사용량이 계속적으로 늘어나고 다른 방법보다 시간이 좀 더 걸리는 것을 보았다.
그리고 glob 메서드로부터 결괏값을 받아야 실제 집계를 할 수 있기 때문에 패턴으로 처리를 할 수 없는 경우에 대해서는 (일단 결괏값으로는 받아야 하므로) 컴퓨터 자원의 낭비가 발생할 수 있다.
재귀 호출과 큐(또는 스택)를 사용한 방법은 직접 대상 디렉터리의 항목을 순회하면서 집계하기 때문에 glob 메서드를 사용할 때보다 유연성이 좋고 메모리 사용량도 안정적이다.
그러나 재귀 호출 방식은 스택 오버플로 문제에서 자유롭지 못하고, 큐(또는 스택)를 사용한 방식은 구현이 조금 더 복잡해진다.

glob 메서드를 사용하여 빠르고 쉽게 해결할 수 있는 경우가 대부분일 것이므로 상황에 맞게 사용한다면 glob 메서드를 사용하지 않을 이유가 없다.
그리고 대부분의 경우 스택 오버플로 에러가 발생할 만큼 디렉터리의 구조를 깊게 만들지는 않을 것이므로 재귀 호출 방식도 상황에 맞게 사용하면 된다.

See you again~~