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

도서 관리 프로그램 만들기2

by 경자꿈사 2024. 8. 5.

오늘은 지난번에 만들었던 도서 관리 프로그램에서 데이터를 메모리가 아닌 파일에 저장하도록 프로그램을 수정해 보자.

아래처럼 BookDb 클래스의 코드만 수정하면 된다. 먼저 데이터를 저장할 파일 정보가 필요한데 initialize 메서드에서 인자로 받도록 했다. 그리고 해당 파일에는 이미 도서 데이터가 저장되어 있을 수 있으므로 파일에서 해당 데이터를 읽어와 메모리에 저장해야 한다. 그래야 search_book 메서드와 같은 기존 코드를 수정없이 그대로 이용하여 파일에 저장되어 있던 데이터에서 도서 검색이 가능하다. 지금처럼 학습이 주 목적인 프로그램에서는 괜찮겠지만 실전에서는 데이터 파일의 크기 등 여러 가지를 고려해야 할 수도 있다. 처음 실행할 경우 데이터 파일이 아직 생성되기 전이라 데이터를 읽기 위해 파일을 열려고 하면 예외가 발생하므로 해당 데이터 파일이 존재하는지 먼저 검사해야 한다. 

class BookDb
  def initialize(file)
    @file = file
    @books = []
    load_data if File.exist?(@file)
  end
  
  def load_data
    File.foreach(@file) do |str|
      title, author, pub_year = str.split("/")
      md = title.match(/\[(\d+)\](.+)/)
      no = md[1]
      title = md[2]
      @books << Book.new(no.strip, title.strip, author.strip, pub_year.strip)
    end
  end
  
  def add_book(title, author, pub_year)
    book = Book.new(@books.size + 1, title, author, pub_year)  
    File.open(@file, "a") { |f| f.puts book }
    @books << book
  end
  
  def search_book(condition)
    @books.select do |b| 
      b.title == condition[:title] || b.author == condition[:author] || b.pub_year == condition[:pub_year]
    end
  end
  
  private :load_data
end

우선 add_book 메서드를 보면 Book 객체를 생성한 후 @books 배열에 추가하기 전에 파일에 도서 정보에 대한 데이터를 쓰로독 수정하였다. File 클래스의 open 메서드로 파일의 끝에 추가(append 모드: "a")하기 위해 파일(@file)을 열고 블록을 통해 실제 작업을 전달하였다. 블록 안에서는 대상 파일(@file)에 대한 파일 객체를 참조하는 블록 변수 f를 이용하여 도서 관련 데이터를 파일에 썼다. puts 메서드는 내부에서 book 객체의 to_s 메서드를 호출하여 받은 문자열을 파일에 쓴다. 따라서 아래와 같이 도서 정보를 등록하게 되면 데이터 파일의 내용은 그 아래에 보이는 것과 같은 형식으로 저장된다.

이제 load_data 메서드의 코드를 보자. 파일의 내용을 한 라인씩 읽어와 블록을 통해 처리할 수 있게 해주는 File 클래스의 'foreach' 메서드를 사용하고 있다. 블록 내용을 보면 읽어온 문자열을 먼저 "/" 로 구분하여 나누고 세 개의 변수에 순서대로 나눠 담고 있다. 그런데 첫 번째 변수 title 의 문자열에는 도서번호 정보가 포함되어 있으므로 이 도서번호 정보를 꺼내야 title 변수에는 제대로 된 도서 이름 데이터만 남게된다. 이를 위해 코드를 보면 정규 표현식을 인자로하여 문자열의 match 메서드를 호출하고 있다. title 변수가 가리키는 문자열이 "[1] 망원동 브라더스 " 라고 할 때 이 문자열과 정규 표현식

\[(\d+)\](.+) 을 비교해 보자. 이해를 위해 정규 표현식의 리터럴 표기를 위한 시작과 끝의 '/' 은 빼고 보도록 하자.

정규 표현식이 뭔가 문자열의 형식을 나타내는 것 처럼 보이지 않는가? '\d' 를 0부터 9까지의 숫자 하나라고 생각하고 '.' 을 임의의 한 문자라고 생각하면 조금 더 뚜렷하게 보일 것이다. '[' 와 ']' 는 정규 표현식 안에서 특수한 의미로 사용되는 문자이기 때문에 앞에 '\' 을 붙여줘서 특수한 의미가 아닌 원래 문자 그대로를 나타내도록 했다. '+' 는 하나 이상을 의미하는데 도서 번호는 최소 한 자리 이상의 숫자이기 때문이다. 같은 이유로 도서 제목을 매칭시키는 부분에도 '+' 를 붙였다.

정규 표현식에서 일부 표현식을 괄호로 묶어 주면 괄호 안의 표현식과 매칭 되는 부분만 별도로 참조할 수 있게 된다.

여기서도 title 변수가 가리키는 문자열에서 '도서 번호' 와 '도서 제목' 두 개의 문자열을 뽑아내야 하므로 '도서 번호' 에 대한 정규 표현식과 '문자열' 에 대한 정규 표현식 각각을 괄호로 묶었다. match 메서드는 대상 문자열과 정규 표현식을 매칭한 결괏값을 담고 있는 MatchData 객체를 반환하는데 코드에서 보이는 것처럼 배열과 같이 []을 사용하여 실제 원하는 값을 꺼낼 수 있다. md[0] 은 대상 문자열 안에서 전체 정규 표현식과 매칭되는 문자열을, md[1] 은 정규 표현식 내 첫 번째 괄호와 매칭되는 문자열을, md[2] 는 두 번째 괄호와 매칭되는 문자열을 돌려준다. 우리에게 필요한 건 '도서 번호' 와 '도서 이름' 문자열을 돌려줄 m[1] 과 m[2] 이다. 끝으로 Book 객체를 생성하기 전에 문자열의 'strip' 메서드를 이용하여 각 속성 정보의 앞 또는 뒤에 붙어 있는 공백 문자들을 제거하였다.

 

현재 기능으로는 데이터 파일로부터 전체 도서 정보를 다 가져왔는지 확인하기가 쉽지 않다. 그래서 전체 도서 정보를 한 번에 볼 수 있는 기능을 추가해 보려고 한다. 아래와 같이 search_book 메서드를 수정하는 것이 좋은 선택일 것 같다.

class BookDb
...생략

  def search_book(condition)
    @books.select do |b|
      (!condition.key?(:title) || condition[:title] == b.title) and
      (!condition.key?(:author) || condition[:author] == b.author) and
      (!condition.key?(:pub_year) || condition[:pub_year] == b.pub_year)
    end
  end

...생략
end

search_book 메서드를 이렇게 수정하게 되면 condition 이 빈 해시일 경우 블록 안에서 모든 book 객체에 대한 조건식 결과가 true 가 되어 @books 배열과 동일한 요소를 갖는 새로운 배열이 반환된다. 또한 현재 메뉴에는 없지만 두 가지 이상의 조건을 모두 만족시키는 도서를 검색하는 메뉴를 만들 수도 있다.

전체 도서를 검색하는 메뉴를 만들기 전에 "동일한 요소를 갖는 새로운 배열을 반환한다"는 게 무슨 의미인지 아래 그림을 보자. [1,2,3] 배열에서 0 보다 큰 요소들을 찾으니 당연히 [1,2,3] 배열을 돌려 받았다. 그런데 두 배열이 포함하는 요소들은 순서까지 서로 같지만 실제는 서로 다른 객체임을 object_id 값의 차이로 알 수 있다. 그래서 arr2 (가 참조하는) 배열을 clear해도 원래의 arr (가 참조하는) 배열은 그대로이다.

>> arr = [1,2,3]
=> [1, 2, 3]
>> arr2 = arr.select { |e| e > 0 }
=> [1, 2, 3]
>> arr.object_id
=> 260
>> arr2.object_id
=> 280
>> arr2.clear
=> []
>> arr
=> [1, 2, 3]

하지만 똑같은 상황에서 두 배열이 가지고 있는 요소는 서로 동일한 객체이므로 하나의 배열에서 요소 자체의 내용을 변경하면 또 다른 배열의 해당 요소 역시 내용이 변경된다. 아래 그림을 보면 arr2 배열의 첫 번째 요소가 가리키는 문자열에 concat 메서드로 "!" 문자열을 추가하니 arr 배열의 첫 번째 요소가 가리키는 문자열에도 "!" 가 붙어 있는 것을 볼 수 있다.

두 요소가 동일한 객체이기 때문이다. 여러분이 object_id 로 직접 확인해 보면 더 좋을 것이다.

>> arr = ["a", "b", "c"]
=> ["a", "b", "c"]
>> arr2 = arr.select { true }
=> ["a", "b", "c"]
>> arr2[0].concat("!")
=> "a!"
>> arr
=> ["a!", "b", "c"]
>> arr2
=> ["a!", "b", "c"]
>> arr2.clear
=> []
>> arr
=> ["a!", "b", "c"]

이제 도서 정보 전체 목록을 보여주는 메뉴를 추가해 보자.

require './menu'

class BookMenu
  def initialize(book_db)
    @book_db = book_db
    build
  end

  def build
    book_search_menu = MenuList.new("도서 조회")
    book_search_menu.add(Menu.new("전체 도서 조회") { search_book })
    book_search_menu.add(Menu.new("도서 이름 검색") { search_book_title })
    book_search_menu.add(Menu.new("작가 이름 검색") { search_book_author })
    book_search_menu.add(Menu.new("출판 연도 검색") { search_book_pub_year })

    @menu = MenuList.new("도서 관리 메뉴")
    @menu.add(book_search_menu)
    @menu.add(Menu.new("도서 등록") { add_book } )
    @menu.add(ExitMenu.new("종료"))
  end

  def add_book
    title = Menu.get_input("도서 이름 > ")
    author = Menu.get_input("작가 이름 > ")
    pub_year = Menu.get_input("출판 연도 > ")
    @book_db.add_book(title, author, pub_year)
  end

  def search_book
    puts @book_db.search_book({})
  end

  def search_book_title
    title = Menu.get_input("도서 이름 > ")
    puts @book_db.search_book(title: title)
  end
  
  def search_book_author
    author = Menu.get_input("작가 이름 > ")
    puts @book_db.search_book(author: author)
  end  

  def search_book_pub_year
    pub_year = Menu.get_input("출판 연도 > ")
    puts @book_db.search_book(pub_year: pub_year)
  end  

  def show
    @menu.select
  end

  private :build, :add_book, :search_book, :search_book_title, :search_book_author, :search_book_pub_year
end

'도서 조회' 하위 메뉴로 '전체 도서 조회' 메뉴를 추가했고 메뉴 기능 처리를 위해 새로 만든 search_book 메서드를 블록에서 호출하도록 하였다. 테스트해 보면 아래와 같이 전체 도서 목록을 편하게 볼 수 있게 되었다.

마직막으로 메뉴 구성 코드에서 반복되는 코드를 정리해 보자. 코드를 보면 도서 등록이나 조회를 위해 도서 관련 정보를 입력 받는 부분이 있는데 사용자에게 보여주는 프롬프트만 다를뿐 동일한 패턴의 코드로 되어 있다. 그리고 도서 조회 관련 메서드들의 코드도 사용자 입력을 받고 그 입력 데이터를 인자로 하여 BookDb 객체의 search_book 메서드를 호출하는 동일한 패턴을 보이고 있다. 프롬프트의 내용이 결국 도서 객체의 속성에 대한 한글 이름이고 코드에서는 그에 대응하는 영문 이름을 사용하니 해시를 사용하여 한글 속성 이름과 영문 속성 이름을 서로 짝지어 주자. 이때 해시의 키는 영문 속성 이름을 나타내는 심볼로 하고 값은 한글 속성 이름으로 하자. 이렇게 만든 해시를 사용하는 새로운 get_input 메서드를 만들었다. 새로 만든 get_input 메서드는 인자로 받은 속성 이름 심볼을 통해 해시에서 한글 속성 이름을 찾아 사용자에게 보여줄 프롬프트 문자열을 구성한다. 그리고 실제 사용자 입력을 받기 위해 원래 사용하던 Menu 클래스의 get_input 메서드를 그대로 이용한다. 다음으로 정리한 메서드는 도서 조회 관련 메서드인데 기존에 검색 조건별로 있던 'search_book_xxx' 메서드를 모두 없애고 search_book 메서드 하나로 모든 검색을 처리할 수 있도록 수정했다.

파라미터 attr 에 기본값으로 nil 을 지정하여 인자 없이 호출할 경우 전체 도서 목록을 조회할 수 있게 했다.

인자가 있으면 해당 인자에 대한 값 즉, '도서 이름', '작가 이름', '출판 연도' 중 하나를 사용자로부터 입력 받아 조건에 맞는 도서를 검색한다.

아래 최종적으로 정리한 book_menu.rb 소스의 코드와 실행을 위한 메인 소스인 book_manager.rb 소스의 코드를 보여주면서 이번 시리즈를 마치도록 하겠다.

require './menu'

class BookMenu
  def initialize(book_db)
    @book_db = book_db
    build
  end

  def build
    book_search_menu = MenuList.new("도서 조회")
    book_search_menu.add(Menu.new("전체 도서 조회") { search_book })
    book_search_menu.add(Menu.new("도서 이름 검색") { search_book(:title) })
    book_search_menu.add(Menu.new("작가 이름 검색") { search_book(:author) })
    book_search_menu.add(Menu.new("출판 연도 검색") { search_book(:pub_year) })

    @menu = MenuList.new("도서 관리 메뉴")
    @menu.add(book_search_menu)
    @menu.add(Menu.new("도서 등록") { add_book } )
    @menu.add(ExitMenu.new("종료"))
  end

  def get_input(attr)
    attr_h = { title: "도서 이름", author: "작가 이름", pub_year: "출판 연도" }
    Menu.get_input("#{attr_h[attr]} > ")
  end

  def add_book
    title = get_input(:title)
    author = get_input(:author)
    pub_year = get_input(:pub_year)
    @book_db.add_book(title, author, pub_year)
  end

  def search_book(attr = nil)
    condition = {}
    condition[attr] = get_input(attr) if attr
    puts @book_db.search_book(condition)
  end

  def show
    @menu.select
  end

  private :build, :get_input, :add_book, :search_book
end

 

See you again~~