오늘은 지난 번에 만든 한 줄 메모장 프로그램에 검색 기능을 추가해 보겠다.
검색 기능에 두 가지 방법을 제공하려고 한다.
하나는 커맨드라인 인수로 '-n' 과 함께 숫자를 입력하면 최근에 작성한 메모 중 해당 숫자 만큼의 메모를 찾아 화면에 표시해 주고 다른 하나는 '-d' 와 함께 날짜를 입력하면 해당 날짜에 작성한 메모를 찾아 화면에 표시해 준다.
이때 '-n' 옵션 뒤에 값이 없거나 숫자가 아닌 문자를 입력하면 기본값으로 10 개를 가져와 보여주고 '-d' 옵션 역시 뒤에 값이 없거나 형식에 맞지 않는 날짜 값을 입력하면 기본값으로 이번 달에 작성한 메모를 보여준다.
'-d' 옵션 뒤에 올 수 있는 날짜 값은 '년도4자리' 아니면 '년도4자리-월2자리' 또는 '년도4자리-월2자리-일2자리' 로 날짜 범위를 좁혀서 검색할 수도 있다.
아래 메인 프로그램 소스 코드와 실행 화면이 있다.
require 'date'
require './my_memo'
if ARGV.empty?
puts "[사용법]"
puts "메모 작성: ruby memo.rb \"메모 내용\""
puts "메모 조회(개수): ruby memo.rb -n [개수]"
puts "메모 조회(날짜): ruby memo.rb -d [yyyy | yyyy-mm | yyyy-mm-dd]"
exit
end
memo = MyMemo.new("D:/blog/ruby/memo/memo.txt")
if ARGV[0] == "-n"
puts memo.search_by_num(ARGV[1].to_i)
elsif ARGV[0] == "-d"
puts memo.search_by_date(ARGV[1])
else
memo.write(ARGV.join(" "))
end
위에 코드를 보면 먼저 커맨드라인 인수가 없을 때 메모 작성 및 조회 등 사용법을 안내하는 메시지를 보여주고 프로그램을 종료한다.
그 다음 메모 내용을 보관할 파일명을 인수로 해서 MyMemo 클래스 객체를 생성한 후 첫 번째 커맨드라인 인수 값에 따라 MyMemo 객체의 메서드를 호출하고 있다.
이제 핵심이 되는 MyMemo 클래스의 코드를 자세히 살펴 보자.
class MyMemo
def initialize(file)
@file = file
end
def search_by_num(num)
num = 10 if num <= 0
File.readlines(@file).last(num)
end
def search_by_date(date)
date = parse_date(date)
result = []
found = false
File.foreach(@file) do |str|
if str.start_with?(date)
found = true
result << str
elsif found
break
end
end
result
end
def write(str)
File.open(@file, "a") do |f|
f.puts "#{timestamp} #{str}"
end
end
def parse_date(date)
begin
if date =~ /^\d{4}$/
return Date.strptime(date, "%Y").strftime("%Y")
elsif date =~ /^\d{4}-\d{1,2}$/
return Date.strptime(date, "%Y-%m").strftime("%Y-%m")
elsif date =~ /^\d{4}-\d{1,2}-\d{1,2}$/
return Date.strptime(date, "%Y-%m-%d").strftime("%Y-%m-%d")
end
rescue Date::Error
end
timestamp[0..6]
end
def timestamp
Time.now.strftime("%Y-%m-%d %H:%M:%S")
end
private :parse_date, :timestamp
end
지난 글에서 메모 작성 시 사용했던 코드는 write 메서드로 옮겨 왔고 메모 작성 일시를 가져오는 부분을 timestamp 라는 별도의 메서드로 분리하였다.
'-n' 옵션에 대한 검색을 처리하는 search_by_num 메서드를 보면 기본값 10을 적용하는 부분이 보이는데 '-n' 옵션 다음에 정수가 아닌 문자를 입력했다면 ARGV[1].to_i 의 결과가 0이므로 인수 값이 0 이상인지를 검사했다.
그리고 실제 검색을 위해 파일 전체를 라인단위로 읽어와 배열에 담아 돌려주는 readlines 메서드를 사용했고 이어서 last 메서드를 호출하여 배열 끝에서 num 개수 만큼 가져와 반환했다.
last 메서드는 인수를 주지 않고 호출하면 마지막 요소 하나를 반환하고 인수를 주면 그 개수 만큼의 요소들을 배열에 담아 돌려준다.
이때 인수 값으로 1을 주더라도 배열에 담아서 주는데 아래 irb 테스트 화면을 보면 이해가 될 것이다.
>> arr = [1,2,3]
=> [1, 2, 3]
>> arr.last
=> 3
>> arr.last(1)
=> [3]
>> arr.last(2)
=> [2, 3]
그런데 readlines 메서드는 파일 내용 전체를 읽어오기 때문에 만약 파일의 사이즈가 크고 내가 실제 가져와야 할 내용은 적다면 비효율적인 방법이다.
이럴 경우는 파일의 끝에서부터 내가 필요한만큼만 역으로 읽어나가는 게 더 효율적이다. 하지만 여기선 프로그램을 최대한 간단히 작성하기 위해 readlines 를 사용하였다.
이제 '-d' 옵션 검색을 처리하는 search_by_date 메서드를 보자.
우선 사용자가 입력한 날짜 값을 인수로 하여 parse_date 를 호출하고 있는데 parse_date 메서드를 보면 if/elsif 조건문을 사용하여 날짜 형식에 따라 다른 처리를 해주고 있다.
날짜 형식에 대한 비교 검사는 match 연산자(=~)를 사용하고 있는데 match 연산자 좌측의 대상 문자열이 우측의 정규 표현식과 매치되는 부분이 있으면 매치가 시작되는 문자열 내 위치를 정수로 돌려주고 없으면 nil을 돌려준다.
루비에서는 false 와 nil 만 '거짓'으로 평가되고 나머지는 모두 '참'으로 평가되므로 match 연산자가 nil 이 아닌 정수를 돌려준다면 조건식이 '참'이 되어 해당 조건문 안의 코드가 실행되게 된다.
위의 정규 표현식에서 '\d' 는 0과 9 사이의 숫자 하나를 의미하고 '\d{4}' 는 숫자 4개를 '\d{1,2}' 는 숫자 하나 이상 두개 이하를 의미한다. 그리고 '^' 는 문자열의 시작을 '$' 는 문자열의 끝을 의미한다.
따라서 문자열 전체가 숫자 4개로 된 문자열이라면 첫 번째 if문의 조건식인 'date =~ /^\d{4}$/' 의 결과가 0이 되고(문자열에서 첫 문자의 위치는 0이다) 0은 '참'으로 평가되기 때문에 if문 안의 코드인
'return Date.strptime(date, "%Y").strftime("%Y")' 가 실행되게 된다.
Date 클래스의 strptime 메서드는 날짜에 해당하는 문자열을 포맷 문자열에 맞춰 분석(parse)한 후 이상이 없으면 Date 객체를 생성해 준다.
이때 해당 문자열의 값이 유효하지 않은 날짜이거나 포맷 문자열에 맞지 않으면 Date::Error 예외가 발생한다.
그리고 최종적으로 Date 객체를 다시 strftime 메서드를 사용하여 문자열로 변경하여 반환하는데 이것은 사용자가 입력한 날짜 값을 포맷에 맞게 보정해 주기 위해서이다.
예를 들어 사용자가 "2024-8-1" 을 입력했을 때 parse_date 메서드는 결과로 "2024-08-01" 을 돌려준다. 그래야 실제 검색이 제대로 될 것이다.
또한 parse_date 는 인수로 받은 날짜 문자열이 어떠한 형식에도 맞지 않거나 유효하지 않은 값이면 이번 달에 해당하는 날짜 문자열을 돌려준다.
이때 timestamp 메서드를 호출하여 받은 문자열에서 앞 7자리에 해당하는 문자열이 이번 달을 나타내는 문자열이 된다.
문자열에서 [] 는 정수 값 하나를 주면 해당 위치의 문자 하나를 돌려주고, 정수 값 두 개 또는 범위를 인수로 주면 해당하는 부분 문자열을 돌려준다. 배열의 [] 와 같다고 보면된다.
아래처럼 irb 를 실행하여 직접 테스트해 보길 바란다.
>> str = "abcde"
=> "abcde"
>> arr = ["a", "b", "c", "d", "e"]
=> ["a", "b", "c", "d", "e"]
>>
>> str[0]
=> "a"
>> arr[0]
=> "a"
>>
>> str[2]
=> "c"
>> arr[2]
=> "c"
>>
>> str[0..2]
=> "abc"
>> arr[0..2]
=> ["a", "b", "c"]
>>
>> str[1, 2]
=> "bc"
>> arr[1, 2]
=> ["b", "c"]
다시 search_by_date 메서드로 돌아가 보면 메모가 저장되어 있는 파일의 내용을 한 라인씩 읽어와 start_with? 메서드를 사용해 검색하려는 날짜 문자열로 시작하면 result 배열에 담는다.
이때 found 변수의 값을 사용하여 검색하려는 날짜를 벗어나면 더 이상 의미 없는 작업을 하지 않도록 했다.
끝으로 메모를 좀 더 빠르고 편하게 남길 수 있도록 컴퓨터 환경 변수 'Path' 를 사용해 보자.
컴퓨터 환경 변수 Path에 어떤 폴더의 경로를 추가하게 되면 cmd 창 등에서 해당 폴더 아래에 있는 실행 파일을 실행하려고 할 때 폴더의 경로를 생략하고 실행 파일명만 입력하면 된다.
즉 cmd 창 명령 프롬프트에 특정 실행 파일명을 입력하게 되면 현재 폴더에서 먼저 해당 파일을 찾고 없으면 컴퓨터 환경 변수 Path에 등록된 경로들에서 순차적으로 찾게 된다.
그러면 우선 Path에 등록할 특정 폴더를 하나 만들고 그곳에 우리가 만든 메모 프로그램을 실행시킬 수 있는 실행 파일을 만들어 넣자.
나는 Path에 등록할 D:\blog\ruby\bin 라는 폴더를 만들었고 이곳에 mm.bat 파일을 하나 만들었다.
파일명 'mm' 은 'memo' 의 약자로 내가 지은 것이고 확장자 'bat' 는 윈도우에서 실행할 수 있는 배치 스크립트 파일을 의미하는데 이 파일 안에 cmd 창에서 실행할 수 있는 명령들을 작성해 놓고 파일을 실행하면 파일 안의 명령들이 순차적으로 실행된다.
mm.bat 파일은 아래와 같이 작성하였다.
실제 실행할 루비 프로그램 파일명은 전체 경로로 지정하였고 mm.bat 를 실행할 때 함께 입력할 커맨드라인 인수를 루비 프로그램 실행 시 그대로 전달하도록 끝에 '%*' 를 추가하였다.
또한 memo.rb 파일에서 require './my_memo' 는 require 'D:/blog/ruby/memo/my_memo' 이렇게 수정해야 한다.
지금까지는 memo.rb 파일이 있는 폴더 위치에서 프로그램을 실행했지만 'Path' 환경 변수를 사용하여 다른 곳에서도 쉽게 실행할 수 있게 하려는 것이기 때문에 전체 경로를 적어줘야 하는 것이다.
Path에 경로를 추가하기 전에 테스트 삼아 mm.bat 를 실행해 보자. 마우스로 더블클릭하면 창이 떴다가 바로 닫히기 때문에 제대로 실행이 됐는지 알 수가 없으니 mm.bat 가 있는 위치에서 cmd 창을 열고 명령 프롬프트에 mm 을 입력해 보자. 아래 그림처럼 나온다면 실행이 제대로 된 것이다. 이제 'Path' 환경 변수에 등록하는 일만 남았다.
바탕화면의 내 PC 아이콘을 마우스 우클릭하면 나오는 메뉴에서 속성을 클릭하면 아래 그림과 같은 창이 뜨는데 거기서 붉은색 박스로 표시한 "고급 시스템 설정" 을 클릭하자. 그러면 다시 창이 하나 뜨고 하단에 [환경 변수] 버튼이 보일 것이다.
[환경 변수] 버튼을 클릭하면 실제 환경변수를 관리할 수 있는 창이 뜬다. 그 창의 상단 사용자 변수 중에 Path 를 클릭하고 [편집] 버튼을 눌러보자. 그러면 실제 Path 환경 변수에 등록된 경로들이 여러 개 보일텐데 우측의 [새로 만들기] 버튼을 클릭하고 위에서 만든 폴더의 경로(D:\blog\ruby\bin)를 입력하고 [확인] 버튼을 누른다. 그리고 다시 환경 변수 창의 [확인] 버튼을 누르면 등록이 완료된다.
윈도우 + R 버튼을 누르면 바로 명령을 실행할 수 있는 창이 뜨는데 아래 그림처럼 쉽고 빠르게 mm 을 실행하여 메모를 작성할 수 있다.
만약 메모 실행 후 창이 바로 닫히는 게 싫다면(메모를 조회할 때 불편하긴 하다) mm.bat 파일 가장 아래 줄에 pause 명령을 한 줄 입력해 주면 된다.
순간 순간 생각나는 아이디어나 메모 등을 빠르고 간편하게 기록할 때 사용해 보면 좋을 것 같다.
See you again~~