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

달력 만들기

by 경자꿈사 2024. 9. 25.

오늘은 화면에 달력을 표시해 주는 프로그램을 만들어 보겠다. 우선 테스트를 위한 프로그램 코드와 실행한 화면을 보자.

require './calendar'

cal = Calendar.new

cal.prev_month.print
puts
cal.print
puts
cal.next_month.print

Calendar 객체를 생성해서 print 메서드를 호출하면 화면에 달력을 표시해 주는데, prev_month 메서드와 next_month 메서드를 사용하면 쉽게 지난달과 다음달 정보를 갖는 Calendar 객체를 가져올 수 있다.
이제 아래 Calendar 클래스의 소스 코드를 하나씩 살펴보도록 하자.

require 'date'

class Calendar
  attr_reader :year, :month
  attr_writer :prev_month, :next_month  
  protected :prev_month=, :next_month=

  def initialize(date = Date.today)
    @first_date = Date.new(date.year, date.month, 1)
    @last_date = Date.new(date.year, date.month, -1)

    @year = @first_date.year
    @month = @first_date.month
  end

  def prev_month
    if @prev_month.nil?
      @prev_month = Calendar.new(@first_date - 1)
      @prev_month.next_month = self
    end
    @prev_month
  end

  def next_month
    if @next_month.nil?
      @next_month = Calendar.new(@last_date + 1)
      @next_month.prev_month = self
    end
    @next_month
  end

  def print
    puts "#{year}년 #{month}월".center(20)
    puts "일 월 화 수 목 금 토"  
  
    arr = [""] * @first_date.wday
    arr.concat((1..@last_date.day).to_a)

    arr.each_slice(7) do |week|
      puts week.map { |day| day.to_s.rjust(2) }.join(" ")
    end
  end
end


우선 날짜 처리를 위해 Date 클래스를 포함함 루비 라이브러리를 로드했다.
Calendar 클래스의 initialize 메서드는 인수로 Date 클래스의 객체를 받는데, 기본값으로 Date 클래스의 클래스 메서드인 today를 호출한 결괏값을 주도록 했다.
today 메서드는 이름 그대로 오늘 날짜에 대한 정보를 갖고 있는 Date 객체를 반환한다.
그리고 initialize 메서드에서는 인수로 받은 날짜에 해당 하는 월의 첫째 날과 마지막 날을 구해 인스턴스 변수에 담아 놓는다.
마지막 날을 구할 때는 Date 객체를 생성할 때 일(Day)에 해당하는 인수에 -1 을 주면 된다. 배열에서 마지막 요소를 가리킬 때 인덱스에 -1을 줬던 걸 생각하면 일관성이 있어서 좋은 것 같다.
다음으로 지난달과 다음달을 구하는 prev_month 메서드와 next_month 메서드를 보면 코드 형태가 거의 비슷한 걸 알 수 있다.
먼저 지난달 또는 다음달에 해당하는 Date 객체를 구해야 하는데 Date 객체에서 정의해 놓은 + 또는 - 연산자 메서드를 사용하면 Date 객체가 가리키는 날짜에서 원하는 이전 이후 날짜의 Date 객체를 쉽게 구할 수 있다.
즉 지난달의 날짜는 현재 월의 첫째 날에서 하루를 빼면 되고 다음달의 날짜는 현재 월의 마지막 날에서 하루를 더하면 된다.
그렇게 구한 Date 객체를 인수로 하여 지난달 및 다음달에 해당하는 Calendar 객체를 생성한다. 
그리고 생성한 Calendar 객체를 현재 Calendar 객체(self)와 연결을 해주는데, 지난달 Calendar 객체에서 다음달을 구하거나 다음달 Calendar 객체에서 지난달을 구할 때, 객체를 다시 생성할 필요 없이 바로 해당 Calendar 객체를 반환하기 위해서이다.
여기서 Calendar 객체 간 연결을 위해 attr_writer 메서드를 사용하여 prev_month 속성과 next_month 속성에 대해 setter 메서드를 만들었다.
그런데, 이 두 setter 메서드는 외부에서는 호출할 필요가 없고 Calendar 클래스 내부에서 다른 Calendar 객체에 대해 호출을 해야 하므로 protected로 지정하였다.
참고로, private으로 지정하면 Calendar 클래스 내부라 할지라도 다른 Calendar 객체에 대해서는 호출할 수 없고 오직 현재 객체인 자신에 대해서만 호출이 가능하다.
그리고 protected 지정을 위해 메서드의 이름을 인수로 줘야 하는데, attr_writer를 사용하여 생성한 메서드가 prev_month= 와 next_month= 이므로 해당 메서드의 이름을 심볼로 주었다.
또한 현재 Calendar 객체에 대한 지난달과 다음달의 Calendar 객체를 prev_month 메서드와 next_month 메서드가 처음 호출되었을 때 한 번만 생성하기 위해 인스턴스 변수 @prev_month와 @next_month가 nil일 경우에만 객체를 생성하도록 했다.
초기화되지 않은 인스턴스 변수는 해당 객체에 존재하지 않게 되고 참조시 nil을 반환한다.
참고로, 객체를 단 한 번만 생성하기 위해 이런식으로 작성을 하게 되면 스레드 안전성을 보장하지는 않는다. 
즉 여러 스레드가 동시에 prev_month 메서드나 next_month 메서드를 호출하게 되면, 그중 다수의 스레드가 if 조건문의 nil 체크를 통과하여 if 문의 내용이 여러 번 실행될 수도 있다.

아래 여러 스레드가 함께 실행될 경우 발생할 수 있는 문제를 보여주기 위해 간단한 예제를 하나 만들어 보았다.
foo 라는 메서드는 스레드 두 개를 생성하여 arr1 배열의 데이터를 arr2 빈 배열로 옮기는 작업을 수행한다.
모든 데이터를 옮기면 스레드는 종료되고 원래 arr1 배열의 크기와 데이터를 모두 옮긴 후의 arr2 배열의 크기를 비교하여 출력하고, 크기가 같으면 true를 다르면 false를 반환한다.
그리고 크기가 다를 때 왜 다른지 확인하기 위해 arr2의 마지막 5개 요소를 화면에 출력하도록 했다.
끝으로 foo 메서드를 반복해서 호출하기 위해 Integer 클래스의 times 인스턴스 메서드를 사용하였는데, foo 메서드의 결괏값이 true일 동안 최대 1000번 호출하도록 하였다.
아래 코드를 D:/blog/ruby/calendar 폴더 아래 test_thread.rb 파일에 작성하여 저장한 후 실행해 보자.

def foo
  arr1 = (1..10000).to_a
  arr2 = []

  org_size = arr1.size

  thread1 = Thread.new do
    while arr1.size > 0
      arr2 << arr1.pop
    end
  end

  thread2 = Thread.new do
    while arr1.size > 0
      arr2 << arr1.pop
    end
  end

  thread1.join
  thread2.join
  
  puts "#{org_size} vs #{arr2.size}"
  if org_size == arr2.size
    true
  else
    p arr2[-5..-1]
    false
  end
end

1000.times do |n|
  print "#{n + 1} : "
  break if !foo
end

아마 foo 메서드가 1000번 호출되기 전에 프로그램이 종료될 텐데, 프로그램을 여러 번 반복해서 실행해 보면 그때마다 종료되는 시점도 다르다는 것을 알 수 있다.
실행 결과를 보면 원래 arr1 배열에는 10,000 개의 데이터가 있었는데 arr2 배열로 옮긴 후에는 (나의 경우엔) 10,001개로 1개가 늘어났다!
아마도 arr1 배열에 요소가 하나 남은 상태에서 pop메서드가 두 번 호출되어 arr2 배열에 nil이 하나 들어가게 된 것 같다.
이렇게 여러 스레드가 동일한 객체에 아무 제한 없이 접근하게 되면 언제 어떤 문제가 발생할지 모른다.
이러한 문제를 해결하기 위해서는 동시에 접근했을 때 문제가 될 수 있는 객체에 대해서는 특정 시점에 오직 하나의 스레드만 접근할 수 있도록 제한하는 것이다.
이 부분에 대해서는 나중에 기회가 될 때 조금 더 살펴보도록 하겠다.

어떤 작업을 여러 명(멀티 스레드)이 같이 하면 혼자(싱글 스레드)할 때 보다 더 빨리 끝낼 수도 있겠지만 서로 간의 정해진 규칙 없이 일을 하다보면 이미 누군가가 완료한 일(지난달 또는 다음달의 Calendar 객체 생성)을 다른 누군가가 또다시 중복해서 하는 경우가 생길 수 있다.
규칙을 잘 정해서 여러 명이 함께 문제없이 일을 잘할 수 있도록 하는 것은 쉽지 않은 일이다. 

마찬가지로 어떤 프로그램을 멀티 스레드 환경에서 문제 없이 잘 돌아가도록 만드는 일 또한 상당히 어려운 일이다.
내가 작성하는 글들에서 따로 언급하지 않는 한, 싱글 스레드 환경에서 프로그램을 실행한다고 생각하자.

이제 달력을 화면에 표시해 주는 print 메서드를 살펴볼 차례이다.
먼저 현재 표시할 달력의 년도와 월 그리고 요일을 출력한다. 다음 코드를 보면 [""] * @first_date.wday 이런 게 보이는데, 이것은 해당 월 1일의 요일(wday) 이전의 요일 수만큼 배열에 공백 문자열을 채워 넣는 것이다.
그래야 달력을 화면에 표시할 때 1일이 제 위치에 잘 표시될 것이기 때문이다. Date 객체의 wday 메서드는 요일을 정수로 반환해 주는데 일요일이 0이고 토요일이 6이다.
예를들어, 특정 월의 1일이 수요일이라면 wday 메서드는 3을 돌려주게 되고 수요일 앞에 일,월,화 세 개의 요일 수만큼의 공백 문자열이 포함된 ["", "", ""] 배열이 만들어지게 된다.
이렇게 배열이 만들어지면, 그 다음은 이 배열에 해당 월의 첫 째날부터 마지막 날까지를 문자열로 변환해서 채워넣는다.
이제 남은 건 해당 배열의 데이터를 화면에 출력해 주면 되는데, 한 주씩 출력해 주기 위해 배열의 each_slice 메서드를 사용하였다.
each_slice 메서드는 인수로 준 개수만큼 배열로 묶어서 블록에 넘겨준다.
이전 글에서 MyEnumerable 모듈을 만들 때 each_slice 메서드는 만들지 않았었는데, 아래처럼 간단히 만들어 볼 수 있겠다.
주의할 점은 배열의 크기가 each_slice 메서드의 인수 값으로 딱 나누어 떨어지지 않을 수 있는데, 이때는 마지막으로 나머지 요소들로 채워진 배열을 넘겨 블록을 실행해 줘야 한다.
each_slice 메서드에서 마지막 라인에 yield로 블록을 한 번 더 실행시켜주고 있는 걸 볼 수 있다.

module MyEnumerable
  생략...
  
  def each_slice(num)
    raise ArgumentError.new("invalid slice size") if num <= 0
    arr = []
    each do |e|
      arr << e
      if arr.size == num
        yield(arr)
        arr = []
      end
    end
    yield(arr) if arr.size > 0
  end
end

테스트를 위해 D:/blog/ruby/my_enumerable 폴더 아래 test_each_slice.rb 파일에 아래 코드를 작성하여 저장한 후 실행해 보자.

require './my_enumerable'

class MyArray
  include MyEnumerable

  def initialize(from, to, inc = 1)
    @from, @to, @inc = from, to, inc
  end

  def each
    num = @from
    while num <= @to
       yield(num)
       num += @inc
    end
  end
end

my_arr = MyArray.new(1, 10)
my_arr.each_slice(3) do |e|
  p e
end

1부터 10까지 3개씩 배열에 담아 블록을 실행시켜 주는데, 마지막은 배열 안에 숫자 10 하나만 들어 있는 걸 볼 수 있다.

오늘은 달력을 화면에 표시해 주는 프로그램을 간단히 만들어 보았다.
이전 글에서 rainbow gem을 사용해서 텍스트에 색깔을 입혀 출력하는 걸 해봤었는데, 토요일과 일요일에 해당하는 날짜가 빨간색으로 출력되도록 Calendar 클래스의 코드를 여러분이 직접 수정해 보면 좋을 것 같다.
See you again~~