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

주식 가격 조회하기2

by 경자꿈사 2024. 8. 30.

오늘은 지난 시간에 만들었던 주식 가격 조회 기능을 사용하여 내 주식 종목을 관리할 수 있는 프로그램을 만들어 보겠다.
프로그램의 실행 화면은 다음과 같다.

이전 글에서 같이 개발했던 '스피드 연산 게임' 과 '도서 관리' 프로그램에서 사용했던 메뉴 처리 기능을 여기서도 사용하면 될 것 같다.
그런데 이전 두 프로그램에서는 해당 프로그램 소스 폴더에 메뉴 처리 소스도 각각 포함하고 있었는데, 앞으로도 메뉴 처리 기능은 다른 프로그램에서 자주 사용할 수 있으므로 메뉴 처리 소스를 공통 소스 폴더에 넣어 놓고 필요할 때 require로 로드해서 사용할 수 있도록 하는 게 좋을 것 같다.
나는 그러한 목적으로 사용할 common_lib 폴더를 D:\blog\ruby 폴더 아래 만들었다. 그리고 common_lib 폴더 아래 다시 ui 폴더를 만들어 이전에 만들었던 menu.rb 소스 파일을 복사해 넣었다.
그리고 menu.rb 소스 파일 내 Menu, ExitMenu, MenuList 등의 클래스가 다른 유사한 목적의 클래스나 모듈 등과 이름 충돌이 나지 않도록 UI 라는 이름의 모듈 아래 놓이도록 소스를 수정했다. 그래서 menu.rb 소스 파일을 포함한 폴더 이름을 ui 로 한 것이다.

module UI
  class Menu
    attr_reader :name
    attr_accessor :parent_menu

    def initialize(name, &block)
      @name = name
      @block = block
    end
    
    def select
      if @block
        @block.yield
      else
        puts "#{name} 메뉴를 선택하셨습니다."
      end
    end
    
    def self.get_input(prompt, check_empty = true)
        while true
          print prompt
          input = STDIN.gets.chomp
          return input if input != "" || !check_empty
        end
    end
  end

  class ExitMenu < Menu
  end

  class MenuList < Menu
    def initialize(name)
      super
      @sub_menus = []
    end
    
    def add(menu)
      menu.parent_menu = self
      @sub_menus.push(menu)
    end
    
    def display
      if parent_menu && !@sub_menus.last.instance_of?(ExitMenu)
        add(ExitMenu.new("상위 메뉴로 가기"))
      end
    
      puts "\n[ #{name} ]"
      no = 1
      @sub_menus.each do |menu|
        puts "#{no}. #{menu.name}"
        no += 1
      end
    end
    
    def select
      while true
        display
        no = Menu.get_input("선택 > ").to_i
        if no < 1 || no > @sub_menus.size
          puts "메뉴 선택이 잘못되었습니다."
        else
          menu = @sub_menus[no - 1]
          if menu.instance_of?(ExitMenu)
            break
          else
            menu.select
          end
        end
      end    
    end
    
    private :display
  end
end

그리고 원하는 주식 종목의 현재 가격을 Daum 사이트에 접속하여 조회해 올 수 있는 StockUtil 클래스를 만들었다.
아래 보이는 것처럼 대부분의 코드는 바로 이전 글의 코드와 동일하다. 다만 이전 글에서는 코드 안에 '삼성전자' 종목명이 그대로 박혀 있었는데(하드 코딩) 이번 프로그램은 원하는 종목을 조회할 수 있어야 하므로, 클래스로 만들고 get_price 메서드에 인수로 종목명을 넣을 수 있도록 했다.
get_price 메서드는 종목명이 잘못 되었거나 해당 HTML 페이지가 변경되는 등의 이유로 가격 정보를 찾지 못하면 nil 을 반환한다.

require 'cgi'
require 'net/https'

class StockUtil
  def initialize
    @http = Net::HTTP.new("m.search.daum.net", 443)
    @http.use_ssl = true
    @http.verify_mode = OpenSSL::SSL::VERIFY_NONE     
  end
  
  def get_price(name)
    query = CGI.escape("#{name} 주가")
    response = @http.get("/search?w=tot&DA=BJE&q=#{query}")

    html = response.body
    md = html.match(/<span class="current_stock">([\d,]+)<\/span>/i)

    if md
      md[1]
    end
  end
end

내가 관리하고 싶은 주식 종목을 파일에 저장하고 조회할 수 있는 기능이 필요한데 이것 역시 기존에 '도서 관리' 프로그램을 개발했을 때 만들었던 코드와 유사하다.
다만 주식 종목 정보를 담는 클래스 Stock을 직접 작성하지 않고 루비에서 기본으로 제공하는 Struct 클래스를 사용하여 만들었고 StockDb 클래스 외부에서 직접 사용할 일이 없을 듯하여 StockDb 클래스 안에 정의하였다.

class StockDb
  Stock = Struct.new(:name, :unit_price)

  def initialize(file)
    @file = file
    @stocks = []
    load_data if File.exist?(@file)
  end

  def load_data
    File.foreach(@file) do |str|
      name, unit_price = str.chomp.split("|")
      @stocks << Stock.new(name, unit_price)
    end
  end

  def add_stock(name, unit_price)
    return false if search_stock(name)
    
    File.open(@file, "a") { |f| f.puts "#{name}|#{unit_price}" }
    @stocks << Stock.new(name, unit_price)
    true
  end

  def search_stock(name = nil)
    if name.nil?
      @stocks.dup
    else
      @stocks.find { |e| e.name == name }
    end
  end

  private :load_data
end


Struct 클래스는 필요한 속성 이름만 나열해 주면 간단하게 해당 속성 정보에 대한 getter 와 setter 메서드까지 만들어 주기 때문에 단순한 데이터성 클래스를 만들 때 유용하게 사용할 수 있다.
또한 블록을 전달하여 필요한 메서드를 정의할 수도 있다. 
아래 그림을 보면 Struct 클래스를 사용하여 Person 클래스를 만들었는데 블록을 전달하여 to_s 메서드를 재정의하였다. 
ancestors 메서드 결과를 보면 Person 클래스는 Struct 클래스를 상속하고 있고, 일반적인 getter 와 setter 메서드뿐만 아니라 연산자 메서드인 [] 와 []= 를 사용해서도 속성 정보를 조회하거나 변경할 수 있음을 알 수 있다.

StockDb 클래스의 search_stock 메서드는 인수로 종목명을 받는데 name 파라미터의 기본값을 nil 로 지정하여 인수 없이 메서드를 호출했을 때 모든 종목 정보를 반환하도록 하였다.
이때 종목 정보를 담고 있는 인스턴스 변수 @stocks 를 그대로 반환하지 않고 dup 메서드를 호출한 결괏값을 반환하였다.
배열의 dup 메서드는 대상 배열과 동일한 요소를 갖는 배열을 생성하여 반환하므로 dup 메서드를 호출하여 받은 배열의 내용을 변경해도 원래의 배열은 변경되지 않는다.
즉, StockDb 객체를 사용하는 곳에서 StockDb 객체 내부의 데이터를 손상시키는 것을 방지하기 위해서 복사본을 만들어 전달한 것이다. 그러나 원래의 배열과 dup를 통해 생성한 배열의 요소들이 갖고 있는 값 자체는 같기 때문에 해당 (참조)값을 통해 객체의 내용을 변경시키는 것까지는 막지 못한다.
이러한 수준의 복사를 '얕은 복사(shallow copy)' 라고 한다. 이와 반대로 참조하는 객체 자체까지 복사해서 내용은 같지만 실제로는 서로 다른 객체를 참조하도록 복사하는 것을 '깊은 복사(deep copy)' 라고 한다.
특정 객체를 깊은 복사하는 것은 생각만큼 쉽지 않은데 여러 이유 중 하나는 객체가 갖는 속성 데이터 중에 배열이나 해시 등 또 다른 객체를 담을 수 있는 타입이 있을 수 있기 때문이다.
즉 배열이나 해시 등에 담긴 모든 객체들까지도 다시 깊은 복사를 해줘야 하는데 그 객체들의 타입 역시 고려해야 할 대상이다.
그래서 보통은 문자열이나 숫자 그리고 불린(true, false) 등의 값만 데이터로 갖는 단순한 구조의 객체가 깊은 복사에 적합하다.
아래 그림을 보면 dup 메서드로 새로 생성한 배열에 요소를 추가해도 원래의 배열에는 변함이 없지만 요소가 참조하는 객체의 내용을 변경시키면 원래의 배열에도 똑같이 영향이 있음을 알 수 있다.

아래는 간단한 깊은 복사의 예이다. 
dup 메서드가 호출될 때 내부적으로 새로 생성된 객체에 대해 initialize_copy 메서드가 호출되고 인수로는 호출 대상인 원 객체가 전달된다.
Person 클래스에서 이 메서드를 재정의하여 원래의 Person 객체가 갖는 name 속성의 값을 복사하여 새 Person 객체의 name 속성 값에 할당하도록 했다. 그래서 아래 결과를 보면 원래의 Person 객체의 name 속성의 값은 변경되지 않고 그대로인 것이 보인다.
만약 initialize_copy 메서드를 재정의하는 부분을 빼고 아래 코드를 실행하면 원래의 Person 객체의 name 속성의 값도 함께 변경되는 것을 볼 수 있다. 직접 확인해 보길 바란다.

위의 StockDb 클래스의 코드로 다시 돌아가 보자.
add_stock 메서드는 주식 종목명과 구매 단가를 인수로 받아 새로 추가해 주는 메서드인데 동일한 종목이 중복으로 추가되지 않도록 search_stock 메서드를 사용하여 먼저 검사를 수행한다.


이제 실제 메뉴를 구성하는 stock_menu.rb 소스 파일의 코드를 보자.
아래 코드의 첫 라인을 보면 위에서 만들었던 common_lib 폴더의 전체 경로를 $LOAD_PATH 전역 변수가 참조하는 배열의 맨 앞에 추가하고 있다.
전역 변수는 프로그램 전체에서 접근이 가능한 변수를 의미하는데 $LOAD_PATH 와 같이 루비는 특수한 목적을 가진 몇 가지 전역 변수를 정의해서 사용하고 있다.
개발자도 원하는 전역 변수를 정의해서 사용할 수 있는데 프로그램 전체에서 접근하고 변경할 수 있어서 편하고 좋다는 생각만으로 남용하게 되면 프로그램에 오류가 있을 때 문제를 찾거나 해결하는 것을 어렵게 만들 수도 있다.
루비에서 정의해 놓은 또 다른 전역 변수들에 대해서는 필요할 때 설명하겠다.
$LOAD_PATH 배열에는 require나 load를 사용해서 루비 파일을 로드할 때 해당 루비 파일을 찾을 디렉터리 목록이 들어있는데 첫 번째 디렉터리부터 순서대로 찾는다.
따라서 $LOAD_PATH 배열에 등록된 다른 디렉터리에 ui/menu.rb 와 동일한 경로 및 이름의 파일이 있더라도 우리가 만든 D:/blog/ruby/common_lib/ui/menu.rb 가 로드된다.
'./stock_util' 의 경우 젤 앞에 './' 은 현재 디렉터리에서 찾으라는 의미이며 이때는 $LOAD_PATH 배열에 등록된 경로에서는 찾지 않는다.
search_stock 메서드를 보면 등록한 종목들 전체를 가져와서 각 종목들의 현재가를 StockUtil 객체를 사용하여 조회한 후 화면에 표시하고 있다.
이때 등록한 종목이 하나도 없다면 '등록된 종목이 없습니다' 라는 메시지만 보여주는데 배열의 empty? 메서드를 사용하여 검사를 수행하고 있다.
문자열도 empty? 메서드를 가지고 있고 동일한 의미로 빈 문자열일 경우 true 를 반환한다. 즉 "".empty? 의 결괏값은 true 가 된다.
add_stock 메서드에서는 종목명을 입력받아 해당 주식 종목의 현재가를 먼저 조회해 보고 만약 현재가를 가져오지 못하면 종목명이 잘못되었다고 판단한다. 즉 현재가 조회로 종목명의 유효성을 검사한다.

$LOAD_PATH.unshift("D:/blog/ruby/common_lib")
require 'ui/menu'
require './stock_util'

class StockMenu
  include UI

  def initialize(stock_db)
    @stock_db = stock_db
    @stock_util = StockUtil.new
    build
  end

  def build
    @menu = MenuList.new("주식 종목 관리 메뉴")
    @menu.add(Menu.new("내 종목 조회") { search_stock } )
    @menu.add(Menu.new("종목 등록") { add_stock } )
    @menu.add(ExitMenu.new("종료"))
  end

  def search_stock
    stocks = @stock_db.search_stock
    
    if stocks.empty?
      puts "등록된 종목이 없습니다"
    else
      puts "종목명\t구매단가\t현재가"
      stocks.each do |e|
        price = @stock_util.get_price(e.name)
        puts "#{e.name}\t#{e.unit_price}\t#{price}"
      end
    end
  end

  def add_stock
    name = Menu.get_input("종목명 > ")
    price = @stock_util.get_price(name)

    if !price
      puts "종목명이 잘못되었습니다"
      return
    end

    puts "현재가 : #{price}"
    
    unit_price = Menu.get_input("구매단가 > ")
    @stock_db.add_stock(name, unit_price)
  end

  def show
    @menu.select
  end

  private :build, :search_stock, :add_stock
end

아래 메인 소스인 stock_manager.rb 의 코드를 보면 매우 간단하다.
StockDb 객체를 인수로 하여 StockMenu 객체를 생성한 후 show 메서드를 호출하는 게 다다.

require './stock_db'
require './stock_menu'

stock_db = StockDb.new("stock.txt")
menu = StockMenu.new(stock_db)

menu.show

이번 글의 마지막으로 require 와 load 의 차이점을 알아보겠다.
require 와 load 모두 특정 루비 소스 파일의 코드를 현재 소스 코드 안으로 로드하기 위해 사용하는데 require 는 동일한 파일에 대해서 최초 한 번만 로드하는 반면에 load 는 호출할 때마다 다시 로드한다.
아래 두 파일 load_test.rb 와 my_class.rb 를 같은 폴더에 두고 load_test.rb 파일을 실행해 보자.
화면에 숫자 1이 나오고 키보드 입력을 대기하는데 이때 my_class.rb 소스 안의 코드에서 'puts 1' 을 'puts 2' 로 수정하여 저장한 후에 키보드 입력을 대기하고 있는 cmd창에서 엔터를 눌러주자.
그러면 다시 숫자 1이 화면에 출력되고 프로그램이 종료된다. 즉 my_class.rb 소스를 변경한 후에 다시 require를 사용하여 my_class.rb 파일을 로드했지만 변경사항이 반영되지 않음을 알 수 있다.
이제 load 를 테스트해 보기 위해 load_test.rb 소스에서 require 부분을 모두 주석으로 막고 load 부분의 주석을 모두 제거한 후 파일을 저장하자. 그리고 my_class.rb 소스 파일의 내용도 원래대로 'puts 1' 로 수정한 후 저장하자.
준비가 끝났으니 다시 cmd 창에서 load_test.rb 를 실행하고 숫자 1이 출력되면 my_class.rb 소스 안의 코드에서 'puts 1' 을 'puts 2' 로 수정하여 저장한 후 cmd창에서 엔터를 눌러보자.
화면에 숫자 2가 출력되는 게 보일 것이다. 이로써 load 가 방금 수정한 my_class.rb 소스 파일을 다시 로드해서 실행한 것이 확인되었다.
그리고 아래 코드에서 보이는 것처럼 load 는 로드할 파일의 확장자까지 다 적어줘야 하는데 파일의 확장자가 반드시 '.rb' 일 필요는 없다. 반면에 require 는 확장자가 '.rb' 인 파일만 로드가 가능하고 코드 작성 시 확장자는 생략이 가능하다.

require './my_class'
#load './my_class.rb'

my_cls = MyClass.new
my_cls.foo

STDIN.gets

require './my_class'
#load './my_class.rb'

my_cls.foo
class MyClass
  def foo
    puts 1
  end
end

오늘 만들어 본 프로그램을 실행하여 '내 종목 조회' 메뉴의 결과 화면을 보면 헤더(종목명, 구매단가, 현재가)와 해당하는 값이 출력되는 위치가 서로 맞지 않고 틀어져 보이는데, 다음 글에서는 이렇게 여러 값을 여러 행에 걸쳐 출력할 때 헤더 및 값들이 서로 정렬되어 보기 좋게 출력되도록 해주는 유틸리티성 기능을 갖는 프로그램을 개발해 보도록 하겠다.
See you again~~