만든 앱을 동생에게 알려주니 필요로 했던 수준정도로 완성된 것 같긴 하다. 하지만 보이는 화면이 텍스트뿐이었기 때문에 사용함에 불편을 느끼는것 같았다. 제대로 구별 되지 않으니 헷갈려 하는것 같았다. 기능 만큼이나 사용자에게 보여주는 면이 중요하다는걸 느끼게 된다.


  다음 페이지를 통해 내 앱에 Bootstrap관련 설정을 해준다. http://blog.ableit.co.kr/?p=744

  Bootstrap을 사용하려면 이 페이지를 참고하자. 기본적인 사용법이 설명되어있다. 정말 쉽다. http://bootstrapk.com/css/


  Bootstrap이나 디자인에 대한 제대로된 경험이 없어 디자인이 조금 삐뚤빼뚤하지만 사용하기에 지장이 없는 수준으로 만들어보았다. 이 글에서는 rails에 Bootstrap 설정법만 간단히 설명하려 했는데 위 링크를 통해 쉽게 해소가 되므로 적용 결과만 기록하려 한다.


(아래는 예시를 올려놓은 인스턴스이다. 실제 사용되는 서버는 따로 구성하였다.)

http://ec2-52-79-125-65.ap-northeast-2.compute.amazonaws.com

github: https://github.com/Stardust-kr/charmbitHair.git

  기능을 만들고 디자인 하고 서버를 구성하여 배포하는 작업까지 마무리 하였다. 아직 많은 버그와 추가 요구사항이 있으니 지속적으로 수정이 필요할 것이다. Rails를 거의 모를 뿐 아니라 웹 개발도 많이 해보지 않았는데도 이렇게 짧은 시간에 꽤 쓸만한 앱을 만들 수 있었다는건 조금 놀랍다. 이게 rails의 힘이 아닐까 하는 생각이 든다.

  해당 앱은 실제로 사용하기 때문에 지속적으로 수정 하면서 rails를 익혀나갈 것이다. 그리고 다른 개발방법으로 또 다른 앱을 만들어 볼까 하는 새로운 호기심도 든다.

0. 서버 사양

CPU: Intel G530
Ram: 2GB
OS: Ubuntu Server 16.04.1 LTS, https://www.ubuntu.com/download/server
Web Server: Nginx
App Server: Unicorn


1. 서버 환경 구성

  먼저 Ubuntu 설치를 위한 부팅USB를 만들었다. Win32 Disk Manager 라는 프로그램을 이용하여 부팅USB를 만들었다. 

  (참고: http://sourceforge.net/projects/win32diskimager/)

  Ubuntu 설치 과정은 다음의 블로그를 참고하였다. http://goproprada.tistory.com/260 마지막 과정에서 추가 소프트웨어 설치는 Standard system utilities, PostgreSQL database, OpenSSH server를 선택하였다.


  설치가 끝난 후 자신이 생성한 계정으로 접속하고 IP주소를 확인해보자.

$ ifconfig


  이제 동일한 내부 네트워크에 있는 개발환경의 PC에서 접속하자. 윈도우즈 OS라면 putty를 설치해서 ssh 연결을 하자. 리눅스와 맥은 터미널을 열어 ssh 연결을 한다.

$ ssh [사용자 계정명]@[IP 주소]


  이제 웹 서버 배포를 위해 필요한 패키지를 설치한다. 초보자를 위한 레일즈가이드의 글과 여러 블로그의 글을 참고하여 패키지를 설치하고 설정하였다.

 - 우분투 16.04 서버 세팅하기: https://rorlab.gitbooks.io/railsguidebook/content/appendices/ubuntu16server.html

 - 운영서버 환경구축: https://rorlab.gitbooks.io/railsguidebook/content/contents/pro_env.html

 - 우분투 MySQL 설정: http://webdir.tistory.com/217

 - 우분투 방화벽(UFW) 설정: http://webdir.tistory.com/206


#  배포용 계정 생성

$ sudo adduser deployer

$ sudo addgroup admin

$ sudo usermod -aG admin deployer

$ sudo visudo

...

# Members of the admin group may gain root privileges

%admin ALL=(ALL) NOPASSWD: ALL

...


# build-essential 설치

$ sudo apt-get -y install git curl build-essential openssl libssl-dev libreadline-dev python-software-properties python g++ make


# Nginx 서버 설치

$ sudo apt-get install -y nginx


# mysql 설치

$ sudo apt-get install -y mysql-server mysql-client libmysqlclient-dev

$ sudo vi /etc/mysql/my.cnf

[client]

default-character-set = utf8


[mysqld]

character-set-client-handshake=FALSE

init_connect="SET collation_connection = utf8_general_ci"

init_connect="SET NAMES utf8"

character-set-server = utf8 

collation-server = utf8_general_ci


[mysqldump]

default-character-set = utf8


[mysql]

default-character-set = utf8

$ mysql -u root -p

mysql> grant all privileges on *.* to deployer@localhost identified by 'password';  # deployer 계정 생성

$ sudo service mysql restart


# ImageMagick 설치

$ sudo apt-get -y install libmagickwand-dev imagemagick


# Nodejs 설치
$ sudo apt-get -y install nodejs

# sqlite3 설치
$ sudo apt-get install sqlite3 libsqlite-dev

# 방화벽 설정
$ sudo ufw enable
$ less /etc/services  # 미리 정의된 포트 목록이 출력된다. 아래 목록 외에 필요한 것을 찾아서 추가하자.
$ sudo ufw dhcpv6-client
$ sudo ufw ssh
$ sudo ufw http
$ sudo ufw https


2. 앱 설정

  앱 설정을 시작하기 전에 서버에 ssh 연결을 시도할 때 매번 비밀번호를 입력하는 번거로움을 없애기 위해 다음과 같은 과정을 수행한다.

$ ssh-copy-id -i ~/.ssh/id_rsa [사용자 계정명]@[ip 주소]


  서버 설정을 끝내고 이제 앱에서 필요한 설정을 해야한다. gemfile과 capfile을 수정하고, 배포 관련 설정을 해야한다. 관련 설정은 아래의 사이트를 참고하였다.

 - Capistrano 3로 배포하기 – 2015 업데이트: https://withrails.com/2015/05/25/capistrano-3로-배포하기-2015-업데이트/


Gemfile

# 추가
gem 'mysql2'
gem 'unicorn'

gem 'capistrano-rails', group: :development
gem 'capistrano-rbenv' # required
gem 'capistrano-rbenv-install'
gem 'capistrano-unicorn-nginx'
gem 'capistrano-upload-config'
gem 'capistrano-safe-deploy-to'
gem 'capistrano-ssh-doctor'
gem 'capistrano-rails-console'
gem 'capistrano-rails-collection'
gem 'capistrano-rails-tail-log'
gem 'capistrano-faster-assets'

$ bundle install

$ cap install

# 터미널에 출력되는 내용

mkdir -p config/deploy

create config/deploy.rb

create config/deploy/staging.rb

create config/deploy/production.rb

mkdir -p lib/capistrano/tasks

create Capfile

Capified


  capistrano를 설치하면 위와 같이 배포와 관련된 파일들이 프로젝트의 config 폴더 안에 생성된다. staging 서버 없이 바로 운영서버에 배포할 것이므로 관련 파일들만 설정한다.

 

Capfile

# 추가
require 'capistrano/bundler' # Rails needs Bundler, right?
require 'capistrano/rails/assets'
require 'capistrano/rails/migrations'
 
require 'capistrano/rbenv'
require 'capistrano/rbenv_install'
require 'capistrano/unicorn_nginx'
require 'capistrano/faster_assets'
require 'capistrano/upload-config'
require 'capistrano/safe_deploy_to'
require 'capistrano/ssh_doctor'
require 'capistrano/rails/console'
require 'capistrano/rails/collection'
require 'capistrano/rails_tail_log'


config/deploy.rb

# 대괄호로 써있는 내용은 자신의 설정에 따라 바꿔준다.
lock "3.7.1"
 
set :application, '[application-name]'
set :repo_url, "git@github.com:[user-name]/#{fetch(:application)}.git"
set :deploy_to, "/home/[deployer-account ex: deployer]/apps/#{fetch(:application)}"
 
set :rbenv_type, :user # or :system, depends on your rbenv setup
set :rbenv_ruby, '[ruby-version]'
set :rbenv_prefix, "RBENV_ROOT=#{fetch(:rbenv_path)} RBENV_VERSION=#{fetch(:rbenv_ruby)} #{fetch(:rbenv_path)}/bin/rbenv exec"
set :rbenv_map_bins, %w{rake gem bundle ruby rails}
set :rbenv_roles, :all # default values
 
# Default value for :linked_files is []
set :linked_files, fetch(:linked_files, []).push('config/database.yml', 'config/secrets.yml')
set :config_files, fetch(:linked_files)
set :pty, true
set :keep_releases, 5
 
before 'deploy:check:linked_files', 'config:push'
 
namespace :deploy do
  after :restart, :clear_cache do
    on roles(:web), in: :groups, limit: 3, wait: 10 do
      # Here we can do anything such as:
      # within release_path do
      #   execute :rake, 'cache:clear'
      # end
    end
  end
end


config/deploy/production.rb

server '[server-ip]', user: '[deploy-username]', roles: %w{web app db}
 
set :nginx_server_name, '[domain-name]'
set :unicorn_workers, 4


  버전관리에 포함되어서는 안되는 database.yml, secrets.ymlgitignore에 추가한다.

.gitignore

...
# Ignore config/database.yml, secrets.yml
config/database.yml
config/secrets.yml


$ cap production config:init

00:00 config:init

      Created: config/database.production.yml as empty file

      Created: config/secrets.production.yml as empty file


  위에서 추가된 파일은 버전관리에 포함되는 파일이기에 중요한 민감정보를 포함하지 않고 대신 시스템 환경변수로 대체한다. 시스템 환경변수를 서버에 직접 등록해 주어야 한다.

# 서버에 ssh 연결 후

$ sudo vi /etc/environment

...

DEPLOY_USERNAME=[deploy-username]

DEPLOY_PASSWORD=[deploy_password]

SECRET_KEY_BASE=[secret_key_base]

 다시 로컬로 돌아와 위에서 생성된 두 파일을 작성한다.


config/database.production.yml

# 추가
production:
  adapter: mysql2
  encoding: utf8
  reconnect: false
  database: [database_name]
  pool: 5
  username: <%= ENV['DEPLOY_USERNAME'] %>
  password: <%= ENV['DEPLOY_PASSWORD'] %>
  host: localhost


config/secrets.production.yml

# 추가
production:
  secret_key_base: <%= ENV["SECRET_KEY_BASE"] %>

git에 현재 수정된 내용을 커밋한다. 그리고 배포 명령을 수행한다.

$ git add .

$ git commit -m 'capistrano 배포를 위한 설정'

$ git push origin master


$ cap production setup

$ cap production rails:rake:db:setup

$ cap production deploy


  이제 배포가 완료되었다. 해당 ip로 접속해서 확인해보자.

 외부에서도 접속할 수 있도록 공유기 설정을 바꿔준다. 192.168.0.1로 접속해서 설정을 바꾼다. 필자의 경우에는 iptime공유기를 예로 들겠다. [관리도구] - [고급 설정] -  [NAT/라우터 관리] - [포트포워드 설정]에 들어가서 아래 그림과 같이 현재 사용하는 인터넷 ip의 80번 포트로 들어오는 요청을 현재 서버의 내부 ip로 포워딩 될 수 있도록 설정한다.

  [기본 설정] - [시스템 요약 정보] 에서 [외부 IP 주소]를 확인하고 브라우저에서 이 주소로 접속해보자.

  드디어 배포 끝!


* 여담: 서버를 처음 구성하고 배포하면서 많은 시행착오를 겪었다. 같은 리눅스인데도 데비안 계열의 ubuntu와 redhat 계열의 centos에서 환경 차이는 존재했다. 처음에는 centos를 사용했으나 ubuntu가 설정에서 좀 더 쉽다는 느낌을 받았고, 결정적으로 rails gem중에서 unicorn 앱 서버와 nginx 웹 서버를 설정해주는 'capistrano-unicorn-nginx' 젬이 ubuntu에서만 작동하는게 컸다.

  패키지를 설치하고, 내가 만든 앱에 배포 설정하는 작업까지는 무리없이 진행되나 처음 deploy 과정에서 엄청난 오류를 맞는다. mysql의 권한 설정이 문제가 되기도 하고, 시스템 환경변수가 제대로 설정되지 않았다거나, git push를 하지 않아서 오류를 내거나 하는 사소하면서도 정말 잘 찾아보지 않으면 스트레스 무지하게 받을만 한 문제를 발생시켰다.

  또한 deploy 과정이 문제없이 끝나도 막상 해당 ip로 접속해도 여전히 'Welcome to nginx' 화면만 띄우고 내가 원하는 페이지 화면을 보여주지 않아 당황하기도 한다. 자동으로 nginx와 앱 서버간의 설정을 해주는 gem의 도움을 받아 설정 할 수도 있지만, 그렇지 않은경우에는 직접 nginx.conf 파일을 작성하고 /etc/nginx/sites-enabled에 추가 시켜줘야 한다. We're sorry, but something went wrong. 을 띄운다면 다양한 문제가 존재하기에 특정할 수 없지만 나의 경우에는 SECRET_KEY_BASE 설정 실수로 발생한 것이었다.

  영어도 잘 못하는데 구글링하며 많이 애먹었다. 여전히 명확하게 어떻게 동작하는가 의문인 부분이 수도 없이 많지만 그럼에도 이 과정을 통해 약간이나마 서버에 대한 이해가 늘은 것 같다.

  커피스크립트로 다음과 같은 작업을 하였다.

 - fields_for 헬퍼에 하위 객체를 생성하기 위한 스크립트 코드를 생성하였다.

 - 회원이름 검색을 위한 ajax 쿼리

  필터를 이용하여 시술정보 생성후 포인트 갱신을 하였다.

  json builder를 이용하여 ajax 요청에 대한 데이터를 전송하였다.



1. 커피스크립트

  커피스크립트는 자바스크립트로 컴파일되는 스크립트 언어로, 자바스크립트의 단점을 보완하는 언어이다. 자바스크립트와 제이쿼리에 대한 약간의 지식만 있으면 쉽게 사용할 수 있다.

  레일즈에서 처음 스크립트를 적용하면서 당황한 것은 페이지를 넘어갈 때마다 스크립트의 작동이 멈춰버리는 것이다. 기본 gem으로 'turbolink'가 적용되어 있기에 발생하는 문제였다. 여러 페이지를 하나의 페이지처럼 동작하게 하는 gem으로, 아마도 하나의 페이지에서 동적으로 엘리먼트가 생성되고 삭제되기 때문에 bind된 메서드가 반응하지 않는것으로 보인다.

  때문에 제이쿼리를 작성할 때는 .ready()에서 작성하지 말고 .on('turbolink:load')에서 작성하여야 페이지 이동을 하더라도 스크립트가 작동한다. 이러한 형태의 페이지를 원하지 않는다면 젬을 주석처리 해버려야 할 듯 하다.

$(document).on('turbolinks:load', ->
  # 코드 입력
  return
)

  엘리먼트 동적 생성과 ajax를 위한 코드를 member.coffee 파일과 category.coffee 파일에 작성하였다. rails와 관련된 특별한 코드는 없으므로 넘어가도록 하겠다.


2. 필터

  필터는 여러개의 액션 메서드 실행 이전 또는 이후에 공통된 작업을 수행하는 것을 말한다. 자바 스프링을 배울때 기억하기로는 aspect oriented programming, 관점 지향 프로그래밍의 일환으로 설명되었던 개념으로 기억하고 있다. 위키피디아에서는 관점지향 프로그래밍을 '컴퓨팅에서 메인 프로그램의 비즈니스 로직으로부터 2차적 또는 보조 기능들을 고립시키는 프로그램 패러다임'이라 설명한다(https://ko.wikipedia.org/wiki/관점_지향_프로그래밍). 주요 비즈니스 로직과는 관련없는 로그, 보안/인증, 트랜잭션, 리소스 풀링, 에러검사, 정책 적용, 멀티쓰레드 관리, 데이터 영속성을 주요 로직과 격리된 곳에서 수행하는 것을 뜻한다고 한다(http://www.zdnet.co.kr/news/news_view.asp?artice_id=00000039147106&type=det&re=).

  여기서는 단순히 여러개의 액션 메서드에 공통된 기능을 수행하기 위해 사용하였다. 시술내역이 업데이트 될 때마다 자동으로 회원의 누적, 사용, 잔여 포인트 점수가 갱신되도록 한 것이다.


app/controllers/history_controller.rb

class HistoriesController < ApplicationController
  before_action :set_history, only: [:show, :edit, :update, :destroy]
  after_action :update_point, only: [:create, :update, :destroy]

  # 생략

  # GET /histories/1
  # GET /histories/1.json
  def show
  end

  # GET /histories/1/edit
  def edit
    @categories = Category.all
  end

  # POST /histories
  # POST /histories.json
  def create
    @history = History.new(history_params)

    respond_to do |format|
      if @history.save
        format.html { redirect_to @history.member, notice: '기록이 저장되었습니다.' }
        format.json { render :show, status: :created, location: @history }
      else
        format.html { render 'hello/show' }
        format.json { render json: @history.errors, status: :unprocessable_entity }
      end
    end
  end

  # PATCH/PUT /histories/1
  # PATCH/PUT /histories/1.json
  def update
    DetailHistory.where(history_id: @history.id).destroy_all

    respond_to do |format|
      if @history.update(history_params)
        format.html { redirect_to @history.member, notice: 'History was successfully updated.' }
        format.json { render :show, status: :ok, location: @history }
      else
        format.html { render :edit }
        format.json { render json: @history.errors, status: :unprocessable_entity }
      end
    end
  end

  # DELETE /histories/1
  # DELETE /histories/1.json
  def destroy
    DetailHistory.where(history_id: @history.id).destroy_all

    @history.destroy
    respond_to do |format|
      format.html { redirect_to @history.member, notice: 'History was successfully destroyed.' }
      format.json { head :no_content }
    end
  end

  private
    # Use callbacks to share common setup or constraints between actions.
    def set_history
      @history = History.find(params[:id])
    end

    def update_point
      histories = History.where(member_id: @history.member_id)
      acc_point = histories.sum(:point_accumulated)
      use_point = histories.sum(:point_used)
      rem_point = acc_point - use_point

      @history.member.update(acc_point: acc_point, use_point: use_point, rem_point: rem_point)
    end

    # 생략
end

  scaffold로 CRUD기능이 정의된 모델을 생성하면 기본적으로 before_action으로 필터가 생성되어 있다. show, edit, update, destroy 메서드를 요청하면 자동으로 private에 있는 set_history 메서드가 먼저 실행하도록 한다. set_history 메서드는 사용자가 요청한 id에 해당하는 history 액티브 레코드를 반환한다. show 메서드를 보면 알겠지만 어떠한 내용도 없지만 실제로는 show.html.erb 파일에 set_history에서 생성한 @history 액티브 레코드가 전달된다.

  나는 히스토리가 생성되거나 수정, 삭제된 후 마다 member 객체의 포인트가 자동으로 계산 될 수 있는 코드를 작성하기 위해 after_action 필터를 사용하였다. update_point 메서드를 새로 정의하고 위와 같이 코드를 작성하였다. 해당 회원의 id에 해당하는 history를 모두 가져와서 새로 갱신하는 계산을 한다.


3. json builder

  레일즈에는 사용자 요청에 따른 http뿐 아니라 json이나 xml로 반환할 수 있다. scaffold로 생성하면 기본적으로 해당 모델의 모든 리스트를 반환하는 index.json.jbuilder 파일과 하나의 모델의 정보를 반환하는 show.json.jbuilder 파일을 포함하고 있다.


app/views/histories/_history.json.builder

json.extract! history, :id, :member_id, :date, :content_id, :total_price, :point_used, :point_accumulated, :is_credit, :created_at, :updated_at
json.url history_url(history, format: :json)

app/views/histories/index.json.builder

json.array! @histories, partial: 'histories/history', as: :history

app/views/histories/show.json.builder

json.partial! "histories/history", history: @history

  show.json.builder는 하나의 객체를 json문서로 만들기 때문에 json.array! 라는 반복 코드 없이 _history.json.builder 파일을 그대로 호출한다. index.json.builder는 여러개의 객체정보를 json문서로 바꿔야 하므로 json.array로 반복작업을 요청한다. _history.json.builder는 json.extract! 메소드를 통해 해당 필드에 대한 정보를 자동으로 json 형태로 만들어준다. 또한 json.url로 해당 객체의 페이지로 이동하는 url까지 함께 저장할 수 있다.

  _history.json.builder와 index.json.builder를 합친 코드는 아래와 같다고 볼 수 있다.

json.array!(@histories) do |history|
  json.extract! history, :id, :member_id, :date, :content_id, :total_price, :point_used, :point_accumulated, :is_credit, :created_at,
    :updated_at
  json.url history_url(history, format: :json)
end

  이번 애플리케이션에서는 기존에 정의된 json파일과 회원 검색을 위한 목록 생성을 위해 search.json.jbuilder 파일을 생성하였다.


app/assets/javascripts/member.coffee

$(document).on('turbolinks:load', ->
  # 생략

  # select 선택자가 바뀔때
  changeListener = ->
    content_id = $(this).val()
    priceObject = $(this).closest('tr').find('input')

    $.ajax
      url: '/contents/'+content_id
      type: 'GET'
      dataType: 'json'
      success: (result) ->
        priceObject.val(result['price'])
        getTotalPrice()
        return
    return

  # 생략

  # 시술목록 '추가하기' 눌렀을 때
  $('button#add_detail').click ->
    size = $('#detail_list').children().size()
    detail = $('#detail_list tr:first').clone()

    if size == 0
      select = $('<select>', {
          id: 'history_detail_histories_attributes_0_content_id',
          name: 'history[detail_histories_attributes][0][content_id]',
          change: changeListener
        })

      $.ajax
        url: '/categories',
        type: 'GET',
        dataType: 'json',
        success: (results) ->
          for result in results
            select.append($('<optgroup>', {
                label: result['name']
              }))
            for content in result['contents']
              select.find('optgroup').last().append($('<option>', {
                  value: content['id'],
                  text: content['name']
                }))
          # select.val(results[0]['contents'][0]['id'])
          return

      # 생략

  # 검색 요청
  $('input#keywd').keyup ->
    keyword = $(this).val()

    $.ajax
      url: '/members/search/',
      type: 'GET',
      data: { keyword: keyword },
      dataType: 'json',
      success: (results) ->
        $('#member_list').empty()
        for result in results
          $('#member_list').append(build_member_list(result['id'], result['name'], result['phone'], result['designer_name'],
                                                      result['gender'], result['ages'], result['acc_point'], result['use_point'],
                                                      result['rem_point'], result['note']))
        return
    return

  return
)

  첫번째 ajax요청은 select 엘리멘트가 바뀔때 해당 content_id에 대한 price를 얻기 위한 것이다. /contents/content_id 로 요청을 하였으므로 서버에서는 show.json.jbuilder를 통해 생성된 json을 전송 해 줄 것이다.

  


  두번째는 아래와 같이 새로 객체 추가 시에 복사할 객체가 없으면 새로운 select 엘리멘트를 생성하기 위한 정보를 category와 content로 부터 가져오기 위한 요청이다.



  하나의 객체 내용을 가져오는 것이 아니라 하위 객체의 내용도 가져와야 하기 때문에 jbuilder내용의 수정이 필요하다. '/categories'로 요청을 보냈으므로 모든 category객체 내용을 담고 액티브 레코드가 반환된다.


app/views/categories/index.json.jbuilder

json.array! @categories do |category|
  json.extract! category, :id, :name, :created_at, :updated_at
  json.contents do
    json.array! category.contents do |content|
      json.extract! content, :id, :name, :price, :note
    end
  end
  json.url category_url(category, format: :json)
end

  category 객체에 대한 내용은 기존 그대로 생성하고, 하위 객체의 내용은 json.contents do ... end 메서드로 다시 정의할 수 있다. 그 안에서는 content 객체에 대한 내용을 다시 생성한다. 위의 코드는 아래처럼 json문서로 반환된다.

[
  {
    "id":1,
    "name":"컷트",
    "created_at":"2016-12-19T07:44:40.518Z",
    "updated_at":"2016-12-19T07:44:40.518Z",
    "contents":[
      {
        "id":1,
        "name":"남",
        "price":10000,
        "note":""
      },
      {
        "id":2,
        "name":"학생",
        "price":8000,
        "note":""
      }
    ],
    "url":"http://localhost:3000/categories/1.json"
  },
  {
    "id":2,
    "name":"드라이",
    "created_at":"2016-12-19T07:44:46.374Z",
    "updated_at":"2016-12-19T07:44:46.374Z",
    "contents":[
      {
        "id":3,
        "name":"일반",
        "price":12000,
        "note":""},
      {
        "id":4,
        "name":"아이롱",
        "price":15000,
        "note":""
      }
    ],
    "url":"http://localhost:3000/categories/2.json"
  },
  {
    "id":3,
    "name":"크리닉",
    "created_at":"2016-12-19T07:44:53.720Z",
    "updated_at":"2016-12-19T07:44:53.720Z",
    "contents":[
      {"id":5,"name":"두피","price":30000,"note":""},
      {"id":6,"name":"모발A","price":30000,"note":""}
    ],
    "url":"http://localhost:3000/categories/3.json"
  },
  {
    "id":4,
    "name":"펌",
    "created_at":"2016-12-23T04:32:15.431Z",
    "updated_at":"2016-12-23T04:32:15.431Z",
    "contents":[
      {
        "id":7,
        "name":"일반",
        "price":35000,
        "note":""
      },
      {
        "id":8,
        "name":"특수",
        "price":45000,
        "note":""
      },
      {
        "id":9,
        "name":"앰플",
        "price":10000,
        "note":""
      }
    ],
    "url":"http://localhost:3000/categories/4.json"
  },
  {
    "id":5,
    "name":"열펌",
    "created_at":"2016-12-23T04:38:22.368Z",
    "updated_at":"2016-12-23T04:38:22.368Z",
    "contents":[
      {
        "id":10,
        "name":"볼매B",
        "price":70000,
        "note":""
      }
    ],
    "url":"http://localhost:3000/categories/5.json"
  },
  {
    "id":6,
    "name":"염색",
    "created_at":"2016-12-23T04:39:32.901Z",
    "updated_at":"2016-12-23T04:39:32.901Z",
    "contents":[
      {
        "id":11,
        "name":"저렴뿌염",
        "price":25000,
        "note":""
      },
      {
        “id":12,
        "name":"기본뿌염",
        "price":30000,
        "note":""
      },
      {
        "id":13,
        "name":"전체염색",
        "price":50000,
        "note":""
      },
      {
        "id":14,
        "name":"남)헤나",
        "price":40000,
        "note":""
      }
    ],
    "url":"http://localhost:3000/categories/6.json"
  }
]

  위처럼 contents 키 안에 다시 객체가 배열형태로 들어가 있음을 알 수 있다. select 엘리멘트에서 아래와 같이 표시된다.

  


  마지막으로 회원 검색을 위해 사용하였다. 회원 검색에는 search.json.jbuilder파일을 만들고 member 컨트롤러에도 search 메소드를 생성하였다.


app/views/members/search.json.jbuilder

json.array!(@members) do |member|
  json.extract! member, :id, :name, :phone, :gender, :ages, :acc_point, :use_point, :rem_point, :note, :created_at, :updated_at
  json.designer_name member.designer.name
  json.url member_url(member, format: :json)
end

app/controllers/members_controller.rb

  # GET /members/search/keyword.json
  def search
    @members = Member.where("members.name LIKE '%#{params[:keyword]}%' OR members.phone LIKE '%#{params[:keyword]}'").order('members.name')
  end

  컨트롤러의 search 메서드에서 이름 또는 전화번호로 검색한 회원의 목록을 @members 인스턴스 변수에 저장한다. search.json.jbuilder 에서 member객체의 내용을 json형태로 바꾸고, member객체에 없는 designer_name은 member.designer.name으로 접근한다.

  search 메서드는 라우팅 되어있지 않으므로 routes.rb에 추가해준다.


config/routes.rb

Rails.application.routes.draw do
  resources :histories
  resources :contents
  resources :categories
  resources :members do
    get :search, on: :collection
  end
  resources :designers

  get "home/index"
  root "home#index"
end

  Member 리소스 밑에 GET으로 search 메소드를 추가한다. 여러 객체를 다루는 액션이므로 on: :collection으로 설정한다.


  json 형태로 받은 객체 정보로 회원 정보를 검색할 수 있게 되었다.


  이 외에도 단순한 마이너한 수정들이 있었다. github에서 확인 할 수 있다(https://github.com/Stardust-kr/charmbitHair). 대략적으로 중요한 기능은 모두 작성한 듯 하다. 앞으로 크게 3가지 문제를 해결해 나갈 것이다.

1. 배포

  집에서 안쓰던 노트북으로 서버를 구성해서 배포하려 했으나, 노트북을 가게로 보내고 가게에서 사용하던 저사양 pc를 서버로 사용하기로 했다. CPU는 G530, 메모리는 2GB정도 였던 것으로 기억한다. CentOS를 올리고 nginx 웹 서버에 passenger, DB로는 mysql을 사용 할 것이다. Capistrano를 이용해서 처음으로 배포 해볼 것이다.

2. 디자인

  기본적인 디자인을 위한 레이아웃 조차도 제대로 갖춰져 있지 않다. 프론트엔드 개발이라 해봐야 자바스크립트와 제이쿼리로 DOM과 ajax를 쓰는 것만 자주 했을 뿐 디자인에는 거의 손 대지 않았기에 이번 기회에 부트스트랩을 시작으로 차근차근 알아갈 것이다.

3. 추가기능 - 통계, 스케줄

  디자이너 별 매출, 기간당 매출과 같은 기본적인 통계 뿐 아니라 성별, 연령대 별 매출, 요일, 시간, 날씨별 매출 등 다양한 통계를 작성해 볼 수 있을 것이다. 또한 예약자 관리를 위한 스케줄 기능도 필요로 했기 때문에 해당 기능도 추가할 것이다. 외부 API를 적용해 보는것도 좋을 것 같다.

  지난 글에서 수정 할 것이라 언급했던 내용을 분류하여 간단히 정리하면 다음과 같다.

1. 마이그레이션
 History 모델 변경 - Detail_history 모델 생성
 회원 성별, 나이
 DB 제약조건

2. 커피스크립트
 자바스크립트 코드 버그
 포인트 사용 폼
 회원 검색

3. 레이아웃
 공통 레이아웃

  가장 먼저 마이그레이션 수정을 진행하였다. 수정 전 ERD는 다음과 같다.

 먼저 History 모델을 세부적으로 바꾸기 위해 다음과 같은 작업을 하였다.

 - 필드명 변경

    price → total_price

    point → point_used

    is_cash → is_credit

- 필드 추가

    point_accumulated :integer

- 필드 삭제

    note :text

    content_id :integer

- 하위 테이블 생성

    detail_histories history:references content:references price:integer note:text


  위의 작업을 db에서 직접 ddl을 통해 변경할 수 있지만 rails의 마이그레이션을 통해 루비 코드 형태로 정의된 데이터베이스 스키마를 만들 수 있다.

  먼저 마이그레이션 파일을 만든다.

$ rails g migration AddDetailsToHistory

/db/migrate 폴더에 새로운 마이그레이션 파일이 생성된다.


db/migrate/20161216164045_add_details_to_history.rb

class AddDetailsToHistory < ActiveRecord::Migration[5.0]
  def change
    add_column :histories, :point_used, :integer
    rename_column :histories, :point, :point_accumulated
    rename_column :histories, :is_cash, :is_credit
    rename_column :histories, :price, :total_price
    remove_column :histories, :note, :text
    remove_column :histories, :content_id, :integer
  end
end

  필드 추가는 add_column 메소드를, 필드명 변경은 rename_column, 필드 삭제는 remove_column 메소드를 사용한다.


  다음으로 detail_history 모델을 생성하기 위해 아래의 명령어를 입력한다.

$ rails g model detail_history history:references content:references price:integer note:text

  모델을 생성하면 데이터베이스에 해당 모델 형식을 갖는 테이블을 생성하기 위한 마이그레이션 파일이 /db/migrate에 자동으로 생성된다.


  지난 시간에 association관계 설정을 위해 모델 클래스에 belongs_to, has_many 등의 관계를 설정해 두었다. 아래와 같이 수정하였다.


app/models/content.rb

class Content < ApplicationRecord
  belongs_to :category
  has_many :detail_histories

  # 생략
end

app/models/history.rb

class History < ApplicationRecord
  belongs_to :member
  has_many :details_histories
end

app/models/detail_history.rb

class DetailHistory < ApplicationRecord
  belongs_to :history
  belongs_to :content
end

같은 방법으로 Member 모델에 성별과 연령대를 추가한다.

$ rails g migration AddGenderAndAgesToMembers gender:string ages:string

마이그레이션명 뒤에 직접 추가할 필드명과 타입을 적어 자동으로 마이그레이션 파일에 추가시킨다.


이제 migrate를 하고 적용이 되었는지 확인해보자.
$ rake db:migrate


rails erd 플러그인을 이용하여 erd를 확인해 본다.

플러그인: http://rails-erd.rubyforge.org

$ rake erd


  현재 테이블에는 not null이나 default 제약조건이 전혀 설정되어있지 않다. 모든 테이블에 필요한 제약조건을 추가하였다. member테이블의 예는 다음과 같다.

$ rails g migration AlterConstraintToMembers


db/migrate/20161216182645_alter_constraint_to_members.rb

class AlterConstraintToMembers < ActiveRecord::Migration[5.0]
  def change
    change_column_null :members, :name, false
    change_column_default :members, :ages, 'unknown'
    change_column_null :members, :gender, false
    change_column_default :members, :acc_point, 0
    change_column_default :members, :use_point, 0
    change_column_default :members, :rem_point, 0    
  end
end

  포인트의 기본값을 0으로 설정했으므로 앞선 개발과정에서 컨트롤러에서 기본값을 추가 했던 코드는 삭제해도 좋을 것이다.


  마지막으로 외래키로 연결된 데이터가 삭제 될 때 CASCADE 형태로 삭제 될 것인지, NOT NULL 형태로 유지할 것인지 조건을 설정하려 했다. 그러나 rails는 references 헬퍼로 외래키를 연결하는 듯 하지만 실제로는 외래키 제약조건을 설정하지 않는다. dbconsole을 열어서 contents 테이블을 확인해 보면 다음과 같다.

$ rails dbconsole

> .schema contents


 어디에도 외래키 제약조건은 없고, category_id와 관련된 인덱스가 생성되는 코드가 있음을 알 수 있다. 따라서 DDL로 CASCADE나 NOT NULL 제약조건을 추가 할 수 없는 듯 하다. 만약 이렇게 하고 싶다면 외래키 제약조건을 직접 걸어야 한다.



참고: http://rubykr.github.io/rails_guides/migrations.html


  references 헬퍼를 없애고 외래키 제약조건을 설정하기 보다 컨트롤러를 통해 유사한 기능을 추가할 것이다.


  위의 제약조건대로 설정하고 rails db:migrate를 하면 에러가 발생한다. 기존의 데이터들이 gender를 가지고 있지 않은데 not null로 설정되어 있기에 에러가 발생하는 듯 하다. db를 초기화 하고 다시 마이그레이션 한다.

$ rake db:reset

$ rake db:migrate


  이제 수정된 모델 형태로 뷰와 컨트롤러를 수정하자. members에는 gender와 ages가 추가된 필드를 모두 추가해 주고, history에도 변경된 필드를 추가해 주었다. 그리고 회원의 시술이력에서 큰 History객체와 여러개의 Detail_history 객체를 동시에 생성하는 코드를 작성하였다. fields_for 폼 헬퍼를 이용하여 부모와 자식 객체를 동시에 생성 할 수 있었다.

참고: https://withrails.com/2016/01/09/1187/


app/models/history.rb

class History < ApplicationRecord
  belongs_to :member
  has_many :detail_histories
  accepts_nested_attributes_for :detail_histories, reject_if: :all_blank
end

  하나의 history객체 밑에 여러 개의 detail_history객체를 가지므로 1:n 관계가 형성된다. has_many 관계를 설정하고 nested attributes로 detail_history의 필드를 가져온다. 모든 필드가 비어있으면 자식 객체를 생성하지 않도록 reject_if: :all_blank를 추가한다.


app/models/detail_history.rb

class DetailHistory < ApplicationRecord
  belongs_to :history, optional: true
  belongs_to :content
end

  현재 개발하는 rails의 버전이 5버전이므로 optional: true를 꼭 설정해 주어야 한다. 그렇지 않으면 아래 사진처럼 뷰에서 컨트롤러로 파라미터는 정확히 전달되나 rollback transaction을 수행하면서 부모와 자식객체를 동시에 생성하여 저장하지 않는다. 

app/controllers/histories_controller.rb

class HistoriesController < ApplicationController
# 생략

  private
    # 생략

    # Never trust parameters from the scary internet, only allow the white list through.
    def history_params
      params.require(:history).permit(:member_id, :date, :total_price, :point_used, :point_accumulated, :is_credit,
        detail_histories_attributes: [:content_id, :price, :note])  # fields_for 메서드를 사용하기 위해 nested attribute 추가
    end
end

  위 모델에서 nested_attributes를 선언한 것 처럼 컨트롤러에서도 뷰에서 전달되는 파라미터를 history_params 메서드를 통해 접근할 수 있도록 detail_histories_attributes를 추가해준다.


app/controllers/members_controller.rb

class MembersController < ApplicationController
  # 생략

  # GET /members/1
  # GET /members/1.json
  def show
    # 생략

    @history = History.new
    2.times do
      @history.detail_histories.build
    end

    # 생략
  end

  # 생략
end
  현재 애플리케이션에서는 history 생성폼이 members/show에 있기 때문에 해당 메서드에서 새로운 @history 액티브레코드를 생성해준다. @history.detail_histories.build 메서드를 통해 새로운 객체를 생성한다. 현재는 두개의 자식 객체를 저장할 수 있도록 설정되어있다.

app/views/members/show.html.erb
<!-- 생략 -->

<h2>시술 기록</h2>
<%= form_for @history do |f| %>
  <!-- 생략 -->

  <table>
    <thead>
      <tr>
        <th>시술종류</th>
        <th>시술별 가격</th>
        <th>비고</th>
        <th></th>
      </tr>
    </thead>
    <tbody>
      <%= f.fields_for :detail_histories do |df| %>
      <tr>
        <td><%= df.grouped_collection_select :content_id, @categories, :contents, :name, :id, :name %></td>
        <td><%= df.number_field :price %></td>
        <td><%= df.text_area :note %></td>
        <td><!-- 추가 버튼 --></td>
      </tr>
      <% end %>
    </tbody>
  </table>

  <!-- 생략 -->
<% end %>

<!-- 생략 -->

  history 객체 생성을 위한 form_for 헬퍼 안에 fields_for 헬퍼를 통해 하위 객체를 함께 생성 할 수 있다. 컨트롤러에서 두개의 객체를 생성했으므로 여기서도 두개의 하위 객체에 대한 필드가 생성된다.



  위와 같이 동시에 저장 되는걸 확인 할 수 있다.



* Scaffold를 원하는 기능에 맞춰 수정하다 보니 특별한 중단점을 찾을 수 없어서 어느정도 기능을 만들어 놓고 리뷰 해본다.


메인 페이지 home/index.html

회원 관리 members/index.html

회원 보기 members/show.html

디자이너 관리 designers/index.html

시술목록 관리 contents/index.html

  Scaffold를 다섯개 만들긴 했으나 하나의 페이지로 기능을 합치거나 모델만 사용해서 세개의 주요 페이지만 남았다.

  앞서 요구사항을 적용하기 위해는 미리 회원정보와 디자이너 정보, 시술 목록이 저장되어 있어야 한다. Scaffold로 생성된 기본적인 CRUD에서 필요한 부분만 수정을 가했다.


app/views/member/_form.html.erb

  <!-- 생략 -->

  <div class="field">
    <%= f.label "전화번호" %>
    <%= f.telephone_field :phone, size: 20, maxlength: 11 %>
  </div>

  <div class="field">
    <%= f.label "담당 디자이너" %>
    <%= f.collection_select :designer_id, designers, :id, :name %>
  </div>

  <!-- 생략 -->


 _form.html.erb 파일은 Create와 Edit을 위한 공통 폼이다. 하지만 이 앱에서는 포인트입력을 받지 않는다. 초기 값을 입력거나 수정할 수 없다. 다만 컨트롤러에서 0으로 초기값을 설정해 주고, 회원의 이력에 따라 포인트가 자동으로 누적되게 할 것이다.

  html5 필드에 맞게 전화번호는 telephone_field로 설정하고, 휴대전화 번호는 최대 11자리 이므로 maxlength를 11로 설정한다.

  담당 디자이너는 직접 입력하지 않고 select폼으로 입력 받을 수 있게 디자이너 이름을 designers 테이블에서 가져올 수 있도록 collection_select 메서드를 사용하였다.


app/controllers/members_controller.rb

class MembersController < ApplicationController
  # 생략

  # POST /members
  # POST /members.json
  def create
    @member = Member.new(member_params)
    @member.acc_point = 0    # 이하 포인트 초기화
    @member.use_point = 0
    @member.rem_point = 0

    respond_to do |format|
      if @member.save
        format.html { redirect_to @member, notice: '회원이 추가되었습니다.' }
        format.json { render :show, status: :created, location: @member }
      else
        @designers = Designer.all
        format.html { render :new }
        format.json { render json: @member.errors, status: :unprocessable_entity }
      end
    end
  end
  
  # 생략
end

  members/index.html.erb와 show.html.erb에서는 designer 이름을 가져오는 것이 아니라 객체를 그대로 출력하므로 아래와 같이 액티브레코드의 주소를 표시한다. 이름을 출력하기 위해 컨트롤러에서 designers 테이블에서 이름을 조인한 레코드를 가져오게 하였다.

app/controllers/members_controller.rb

  # 생략

  # GET /members
  # GET /members.json
  def index
    @members = Member.joins(:designer).order('members.name').select('members.*, designers.name as designer_name')
  end

  # GET /members/1
  # GET /members/1.json
  def show
    @member = Member.joins(:designer).select('members.*, designers.name as designer_name').find(params[:id])

  # 생략

  디자이너 이름을 designer_name으로 접근할 수 있다.


app/view/members/index.html.erb

  <!-- 생략 -->

  <tbody>
    <% @members.each do |member| %>
      <tr>
        <td><%= link_to member.name, member %></td>
        <td><%= member.phone.insert(3, '-').insert(-5, '-') %></td>
        <td><%= member.designer_name %></td>
        <td><%= number_with_delimiter(member.acc_point) %></td>
        <td><%= number_with_delimiter(member.use_point) %></td>
        <td><%= number_with_delimiter(member.rem_point) %></td>
        <td><%= member.note %></td>
      </tr>
    <% end %>
  </tbody>

 <!-- 생략 -->

  디자이너 이름을 member_name으로 접근한다.

  그 외에 전화번호 형식을 표시하기 위해 문자열 처리를 하였다. 또한 포인트는 1,000 단위에 콤마를 넣기 위해 number_with_delimiter 메소드를 사용하였다.

  아래와 같이 표시된다.



  시술정보에는 시술의 큰 범주에 속하는 category 모델과 세부 범주의 content 모델이 동시에 표시된다. 카테고리와 상세정보 생성은 위와 비슷하다. category와 content를 동시에 표시하기 위해 contents_controller에서 두개의 객체를 동시에 가져왔다.


app/controllers/contents_controller.rb

  # 생략

  # GET /contents
  # GET /contents.json
  def index
    @contents = Content.all
    @categories = Category.all
  end

  # 생략

app/views/contents/index.html.erb

  <!-- 생략 -->

  <tbody>
    <% @categories.each do |category| %>
      <tr>
        <td><%= link_to category.name, category %></td>
      </tr>

      <% contents = @contents.select{|c| c.category_id == category.id} %>
      <% if contents.empty? %>
        <tr>
          <td></td>
          <td colspan="3">[ 비 어 있 음 ]</td>
        </tr>
      <% else %>
        <% contents.each do |content| %>
        <tr>
          <td></td>
          <td><%= link_to content.name, content %></td>
          <td><%= number_to_currency(content.price, unit: '원', precision: 0, format: '%n%u') %></td>
          <td><%= content.note %></td>
        </tr>
        <% end %>
      <% end %>
    <% end %>
  </tbody>

  <!-- 생략 -->

  카테고리를 탐색해서 같은 category_id 를 가진  content 인스턴스가 있으면 출력하고 아니면 [ 비 어 있 음 ] 이라는 문자를 표시한다.

  가격을 통화단위를 포함해서 출력하기 위해 number_to_currency 메서드를 활용하였다.

  이제 시술기록이다. Scaffold로는 History 컨트롤러가 관리하는 뷰에서 생성해야 하지만 회원 정보에서 모든 이력을 표시하고 생성하기 위해 member/show.html.erb 안에서 이력을 생성하고 출력한다. 


app/views/members/show.html.erb

<!-- 생략 -->

<h2>시술 기록</h2>
<%= form_for(@history) do |f| %>
  <% if @history.errors.any? %>
    <div id="error_explanation">
      <h2><%= pluralize(@history.errors.count, "error") %> prohibited this history from being saved:</h2>

      <ul>
      <% @history.errors.full_messages.each do |message| %>
        <li><%= message %></li>
      <% end %>
      </ul>
    </div>
  <% end %>

  <%= f.hidden_field :member_id, value: @member.id %>
  <%= f.hidden_field :date, value: Time.now %>

  <div class="field">
    <%= f.label '시술종류' %>
    <%= f.grouped_collection_select :content_id, @categories, :contents, :name, :id, :name %>
  </div>

  <div class="field">
    <%= f.label '가격' %>
    <%= f.number_field :price %>
  </div>

  <div class="field">
    <%= f.label '포인트' %>
    <%= f.number_field :point, readonly: true %>
  </div>

  <div class="field">
    <%= f.label '비고' %>
    <%= f.text_area :note %>
  </div>

  <div class="field">
    <%= f.label '현금결제' %>
    <%= f.check_box :is_cash %>
  </div>

  <div class="actions">
    <%= f.submit %>
  </div>
<% end %>

<table>
  <thead>
    <tr>
      <th>일자</th>
      <th>시술</th>
      <th>가격</th>
      <th>포인트</th>
      <th>비고</th>
    </tr>
  </thead>
  <tbody>
    <% @histories.each do |history| %>
      <tr>
        <td><%= history.date.strftime('%Y년 %m월 %d일') %></td>
        <td><%= history.category_name + ' - ' + history.content_name %></td>
        <td><%= number_with_delimiter(history.price) %></td>
        <td><%= number_with_delimiter(history.point) %></td>
        <td><%= number_with_delimiter(history.note) %></td>
      </tr>
    <% end %>
  </tbody>
</table>

<!-- 생략 -->

  아래와 같은 그룹화 된 select 메서드를 사용하기 위해 grouped_collection_select 메서드를 사용하였다. 이 메서드를 사용하기 위해서는 모델간 association 관계를 정의해야한다.


app/models/category.rb

class Category < ApplicationRecord
  has_many :contents

  # 생략
end

  Category와 Content는 1:n 관계를 가지므로 'Category has many contents.'라고 말할 수 있을 것이다. 모델에서도 이와 같은 영문법처럼 1:n 관계를 정의해준다.


app/models/content.rb

class Content < ApplicationRecord
  belongs_to :category
  has_many :history

  # 생략
end

  마찬가지로 Content는 하나의 카테고리만 가지므로 'Content belongs to category.'라 볼 수 있다. 또한 Content 하나에 여러개의 History가 있을 수 있다.


app/models/history.rb

class History < ApplicationRecord
  belongs_to :member
  belongs_to :content
end

  History는 각각 Member와 Content에 n:1 관계를 가지므로 두 곳에 모두 belongs_to 설정되어있다. 

  기본적으로 Model 생성시 필드가 references 속성을 가지면 관련된 모델에 자동으로 belongs_to 설정이 된다. 다만 위와 같이 has_many나 has_one등은 직접 입력해 주여야 하는듯 하다.

  이제 아래와 같이 그룹이 생성된 select 메소드를 볼 수 있다.


  시술 기록 생성을 위한 폼은 _form.html.erb의 내용을 사용하였으나 일부 변경해서 사용하기 위해 render하지 않고 코드를 긁어서 붙여넣었다. 생성시간과 member_id는 히든 폼으로 컨트롤러에 넘겨준다.


  시술 종류를 선택하면 가격을 아래에 표시해줘야 하는데 이 과정은 자바스크립트를 통해 넣어줘야 한다. 또한 가격의 10%는 포인트로 누적되며 현금결제에 체크 되어있을 때만 포인트가 누적되게 하는 것도 스크립트로 작성하였다. erb안의 루비 코드는 서버 html 코드를 생성해서 클라이언트에게 제공하므로 클라이언트에서 능동적인 변화를 만들어 내지 않는다. 자바스크립트를 간단히 구현하기 위해 커피스크립트를 사용하였다.


app/javascripts/members.coffee

$(document).ready ->
  $('#history_content_id').change ->
    content_id = $(this).val()

    $.ajax
      url: '/contents/'+content_id
      type: 'GET'
      dataType: 'json'
      success: (result) ->
        $('#history_price').val(result['price'])
        if $('#history_is_cash').is(":checked")
          $('#history_point').val($('#history_price').val()/10)
        else
          $('#history_point').val(0)

        return
    return
  .change()

  $('#history_is_cash').click ->
    if $('#history_is_cash').is(":checked")
      $('#history_point').val($('#history_price').val()/10)
    else
      $('#history_point').val(0)
    return
  return

  jQuery로 select폼에 접근하여 내용이 변할 때 마다 ajax로 해당 시술의 가격을 받아온다. 체크박스가 선택되어있는지 여부를 확인하여 포인트 입력폼에 값을 추가시킨다.


  History가 저장될 때마다 회원의 포인트 정보가 업데이트 되어야 한다. History저장은 histories_controller에서 일어나므로 해당 코드에 업데이트 구문까지 추가한다.


app/controllers/histories_controllers.rb

  # 생략

  # POST /histories
  # POST /histories.json
  def create
    @history = History.new(history_params)
    member = Member.find(@history.member_id)

    respond_to do |format|
      if @history.save and member.update(acc_point: member.acc_point + @history.point, rem_point: member.rem_point + @history.point)
        format.html { redirect_to Member.find(@history.member_id), notice: '기록이 저장되었습니다.' }
        format.json { render :show, status: :created, location: @history }
      else
        format.html { render :new }
        format.json { render json: @history.errors, status: :unprocessable_entity }
      end
    end
  end

  # 생략

  history에 있는 member_id로 member객체를 찾아서 point를 업데이트 시켜주는 코드이다. 물론 포인트 사용시 차감되는 코드도 추가될 것이다.

  

  기능을 구현하기 위한 주요 코드는 위와 같다.

  이 외에도 유효성 검사를 위한 validate 코드를 모델에 추가하였다.


  아직 완성된 상황은 아니지만 피드백을 받기 위해 실제 이 애플리케이션을 사용할 사용자인 동생에게 중간결과를 보여줬다. 여러가지 문제점을 발견 할 수 있었다. 특히 실제 사용시 고객들이 이발만 하고 돌아가는게 아니라 같이 염색을 하는 경우가 있는데, 위 애플리케이션은 history가 하나가 아닌 두개로 저장 되기 때문에 방문 횟수를 카운트 하거나 포인트 누적에서 오류를 발생시킬 수 있다는 문제가 있었다. 또한 회원 정보에 나이와 성별 필드를 추가하는 문제, 현금결제 체크가 아니라 카드체크 버튼으로 바꿔달라는 요구를 받았다.

  이 외에도 포인트를 통한 결제금액 차감 기능 구현을 위한 모델 수정이 필요하며 자바스크립트 코드가 제대로 적용되지 않는 문제가 있으며, 포인트 관리를 위한 정규화 문제, 공통 레이아웃을 설정하는 문제등을 해결해야 할 것으로 보인다. 또한 데이터 삭제시 관련 데이터들을 삭제 시키거나 유지 시킬지 결정할 제약조건을 설정해야 하는 문제가 있다. rails의 마이그레이션은 db에 독립적으로 동작하나, db마다 상이한 제약조건은 설정하지 않으므로 직접 설정해줘야 한다.

  다음에는 이 부분을 수정하도록 하겠다.


GitHub: https://github.com/Stardust-kr/charmbitHair

어머니와 동생이 함께 미용실을 운영하게 되었는데 여기서 쓸 프로그램을 하나 만들어 달라고 한다. 회원을 관리하는 아주 간단한 프로그램이라하기에 흔쾌히 수락했다. 물론 누군가가 사용하는 프로그램을 만들어 본 적은 없으나 이번 기회에 실력을 쌓을겸 해서 Rails를 통해 애플리케이션을 만들기 시작했다.


설계에 앞서 동생을 통해 어떠한 형태의 동작을 원하는지 간단한 요구분석과정을 거쳤다.



(- _-;)


어차피 간단한 프로그램을 원하는 듯 하니 적당히 필요한 구성만 짜서 포함시키려 한다.

이를 바탕으로 대략적인 데이터베이스를 구성해 보았다.



이제 이 데이터베이스 구성을 위한 Rails를 생성하였다.

개발환경은 다음과 같다.


개발OS: macOS Sierra

개발도구: Ruby on Rails

데이터베이스: sqlite3


$ rails new charmbitHair

$ cd charmbitHair

$ rails generate scaffold designer name:string phone:string

$ rails generate scaffold member name:string phone:string designer:references

$ rails generate scaffold category name:string

$ rails generate scaffold content name:string price:integer category:references note:text

$ rails generate scaffold history member:references date:date content:references price:integer point:integer note:text is_cash:boolean

$ rake db:migrate


기본적인 scaffold 생성을 마쳤다. 서버를 구동시키고 테스트를 해본다.

$ rails s


아래와 같은 주소로 모델에 접근할 수 있다.

http://localhost:3000/designers


메인 페이지가 없으니 간단히 만들어 본다.

$ rails generate controller welcome


app/views/welcome/welcome_page.html.erb 파일을 만들고 원하는 내용을 아무거나 적는다.

config/routes.rb에 아래와 같은 내용을 추가하자.

Rails.application.routes.draw do
  ...
  get "welcome/welcome_page"
  root "welcome#welcome_page"
  ...
end


메인 페이지에 접근하자.

http://localhost:3000/


시작이 반이라 했던가. 일단 간단한 Scaffold만 생성했는데도 뭔가 다 한것 같은 느낌이다. 겨우 몇줄로 이정도 소프트웨어가 작성되는건 놀랍긴 하다.

다음에는 메인 페이지에 출력한 내용을 링크로 연결 시키고, 공통 레이아웃을 작성해 볼 것이다.

* 글 작성에 앞서, 이 글의 목적은 숙달자의 정보전달이 아니라 초심자의 기록 남기기에 가깝다. 이해가 부족한 상태에서 경험의 과정을 적으니, 미숙한 점이 많이 있을 것이므로 양해 부탁드린다. 또, 미숙함에 대한 지적은 대환영이지만 기본적으로 이 포스팅은 대부분 일방적인 기록 남기기에 가까우니 피드백이 적을 수 있다. 다만 필자뿐 아니라 같은 글을 보는 다른 독자들을 위해 또 하나의 필자가 되어 부연설명을 해 주신다면 감사할 따름이다.


0. Homebrew 설치하기

루비 버전 관리자인 rbenv를 설치하기 위해 맥의 패키지 관리자인 Homebrew를 먼저 설치한다. Redhat 계열의 패키지 관리자인 yum이나 Debian 계열의 패키지 관리자인 apt-get과 비슷한 역할을 한다 보면 된다.


아래 홈페이지에서 터미널에 입력할 명령어를 확인하자.

Homebrew 홈페이지: http://brew.sh/index_ko.html


1. rbenv 설치하기

아래 명령을 터미널에서 수행하자

$ brew update

$ brew install rbenv ruby-build


다음과 같은 화면이 나타날 것이다.



2. ruby 설치하기

기본적으로 맥에는 ruby가 설치되어 있다.

$ ruby -v

위 명령어를 수행하면 2.0.0p648 버전이 설치되어 있음을 알 수 있다.

rbenv를 이용하여 최신 안정버전을 설치해 보겠다.


$ rbenv install -l

$ rbenv install 2.3.1

$ rbenv rehash

위 명령어를 실행하면 설치 가능한 버전 목록이 표시된다. 현재 가장 최신 안정버전인 2.3.1을 설치한다.

새로 루비를 설치하거나 루비 젬을 설치하면 반드시 rbenv rehash를 해주자. 새로운 환경을 재설정 하는 옵션이다.



$ rbenv versions

위의 명령어를 실행하면 현재 시스템에 설치된 루비 버전 목록이 표시된다.

현재 system 버전과 사용자가 설치한 2.3.1 버전이 표시된다. 앞의 * 기호가 현재 활성화된 루비 버전을 의미한다.

$ rbenv global 2.3.1

$ ruby -v

루비 버전을 2.3.1로 활성화 시킨다. 허나 이 상태에서 ruby -v를 실행해보아도 system의 구 버전이 남아있게 된다.


이 문제는 환경변수에 rbenv가 추가되어 있지 않아 발생한다.

.bash_profile에 환경변수에 추가하는 export 명령어를 추가하여 터미널 수행시 추가될 수 있게 설정을 바꿔주자.

$ vi .bash_profile

맥의 초기 상태에서는 .bash_profile이 없기 때문에 새로운 파일을 생성한다. vi에서 다음과 같은 내용을 추가하고 저장하자.

export PATH="$HOME/.rbenv/bin:$PATH"

eval "$(rbenv init -)"


그리고 다시 루비 버전을 확인하면 정상적으로 새로운 버전이 적용되는 것을 확인 할 수 있다.


(http://seonhokim.net/2013/10/30/mac-os-x-%EC%97%90%EC%84%9C-path-%EC%84%B8%ED%8C%85-%EB%B0%A9%EB%B2%95/)

(http://stackoverflow.com/questions/10940736/rbenv-not-changing-ruby-version)


루비 젬을 관리하는 젬 관리자인 bundler를 설치하자.


$ gem install bundler

$ rbenv rehash


3. rails 설치하기


$ gem install rails

$ rbenv rehash

$ rails -v



4. rails 앱 만들어 테스트 하기

원하는 폴더에 rails 앱을 만들어보자.

필자는 documents 경로 아래에 myFirstRailsApplication이라는 앱을 만들었다.

$ cd documents

$ rails new myFirstRailsApplication


생성된 rails 앱의 폴더로 들어가서 작동여부를 확인해보자.

$ cd myFirstRailsApplication

$ rails server -d

$ curl http://localhost:3000


Safari를 통해 위의 경로로 접속하면 실제로 앱이 실행 됨을 확인할 수 있다.



참고자료

1. https://www.gitbook.com/book/rorlab/railsguidebook/details

+ Recent posts