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


  다음 페이지를 통해 내 앱에 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를 익혀나갈 것이다. 그리고 다른 개발방법으로 또 다른 앱을 만들어 볼까 하는 새로운 호기심도 든다.


<코끼리 공장의 해피엔드> 무라카미 하루키 | 김난주 옮김 | 안자이 미즈마루 그림 | 문학동네

<발렌타인데이의 무말랭이> 무라카미 하루키 | 김난주 옮김 | 안자이 미즈마루 그림 | 문학동네

<세일러복을 입은 연필> 무라카미 하루키 | 김난주 옮김 | 안자이 미즈마루 그림 | 문학동네

<쿨하고 와일드한 백일몽> 무라카미 하루키 | 김난주 옮김 | 안자이 미즈마루 그림 | 문학동네

<해뜨는 나라의 공장> 무라카미 하루키 | 김난주 옮김 | 안자이 미즈마루 그림 | 문학동네

    (이상 5권은 문학동네에서 출간한 무라카미 하루키 에세이 걸작선 세트)

<라오스에 대체 뭐가 있는데요?> 무라카미 하루키 | 이영미 옮김 | 문학동네


<후와후와> 무라카미 하루키 | 권남희 옮김 | 안자이 미즈마루 그림 | 비채

<무라카미 하루키의 위스키 성지여행> 무라카미 하루키 | 이윤정 옮김 | 문학사상사

    (이상 2권은 서점에서 읽은 책)

 

나는 무라카미 하루키의 에세이를 추천한다.

 

왜 하루키의 에세이인가?

하루키가 세계적인 인물임을 모르는 사람은 없을 것이다. 하루키 신드롬은 이제 한물간 유행으로 여겨질 정도로 많은 독자의 사랑을 받는 작가이다. 단순히 하루키를 추천하는 것은 마치 웃긴 예능인을 추천해준다며 유재석을 언급하는 것과 다를 게 없는 시시한 이야기일 것이다.



교보문고에 등록된 <노르웨이의 숲> 상품정보에 있는 저자 소개 부분이다. 에세이에 대한 짤막한 소개가 들어있긴 하나 전반적으로 그를 대표하는 건 소설이다. 아마 그를 추천하는 대부분 사람도 소설을 두고 한 말일 것이다. 하지만 나는 그의 소설을 잘 이해하지 못한다. 내가 처음이자 마지막으로 읽은 그의 소설은 <1Q84>이다. 당시 하루키의 신작 발표로 나라가 떠들썩했기에 왜 그렇게 인기가 많은지 직접 알아보고자 읽었다. 세 권을 내리읽으면서도 그가 말하고자 하는 바를 쉽게 이해하지 못했고 난해한 예술영화를 보는듯한 느낌을 받았었다. 그 이후로 그의 책을 찾아보지 않았다. 그래서 많은 사람의 생각과 달리 나는 그의 소설을 추천하진 않는다.

하지만 그는 저자 소개에 언급된 소설 만큼 많은 에세이도 발표했다. 여행을 좋아하는 나는 서점에서 우연히 여행에 관한 하루키의 책 <라오스에 대체 뭐가 있는데요?>를 접하고 그의 에세이에 푹 빠져들었다. 그리고 지난해 그의 에세이만 6권을 더 읽었다. 그의 에세이에는 소설에서 느꼈던 것과는 전혀 다른 분위기를 풍겼다. 하루키 팬에게는 딱히 새로운 이야기가 아닐 수 있다. 나와 같이 하루키의 소설만 알고 있었을 뿐 그의 에세이에 특별한 관심이 없던 사람에게는 신선한 경험이 될 것으로 생각한다. 그래서 나는 하루키라는 작가가 아닌 하루키의 에세이를 추천한다.

 

어떤 매력을 가지고 있는가?

하루키 에세이의 가장 큰 매력은

재미라고 생각한다. 그냥 소소한 재미가 아니라 말 그대로 미소 짓게 되고, 가끔은 소리 내서 깔깔거릴 수도 있는 재미를 그의 에세이에서 만날 수 있다. 예능이나 드라마로만 재미를 얻을 수 있는 것이 아니라 책에서도 즐거움을 느낄 수 있다는 것을 깨닫게 된다.

또 다른 매력은 여행이다. 하루키 에세이를 세 가지 테마로 분류한다면 일상, 재즈, 그리고 여행이라고 말할 수 있을 것 같다. 그중에서 여행에 관한 테마를 이야기하는 이유는 하루키가 속된말로 프로여행러라고 불릴 수 있을 정도로 엄청난 여행 경험을 자랑하고 있기 때문이다. 한 해 절반 이상을 해외에서 체류하고 유럽과 미국에 몇 년간 거주하면서 경험한 다양한 이야기들, 그리고 그만의 여행 방법은 나와 같은 여행을 좋아하는 독자들의 마음을 흔들기에 충분한 매력을 가지고 있다. 소설가라는 어딘가에 얽매이지 않은 직업을 가지고 이리저리 자유롭게 유랑하는 모습은 내가 하고 싶은 삶의 모습일 뿐만 아니라 수많은 사람의 꿈이기도 할 것이다. 나의 꿈을 누군가가 대신 실현해주는 모습을 보는 재미도 쏠쏠하다.

마지막 매력은 긍정적이고 밝은 분위기다. 최근 다양한 힐링에세이류가 서점에 쏟아지고 있는데 오히려 직접적인 위로의 메시지를 담고 있지 않은 하루키의 에세이를 보면서 더 많은 위안을 느낄 수 있을 것으로 생각한다. 여행을 통해 많은 문화를 경험한 덕분인지, 그의 에세이에서는 다양한 생각에 대한 넓은 관용을 보여주며 곤란해 보이는 상황에도 긍정적인 마인드를 잃지 않는다. 그의 여행에세이에서는 현지에서 렌터카를 사용하는 이야기가 종종 나오는데, 자신이 원했던 것과는 다른 차를 받았음에도 끝에는 이러저러한 이유로 나름대로 색다른 재미를 느낄 수 있었다로 마무리 지어지는 모습을 보면 그가 얼마나 긍정적인 사람인지 가늠할 수 있게 된다(혹은 둔감한 바보가 아닐까 하는 생각이 들 정도). 그런 긍정적이고 넓은 이해심을 보여주는 이야기를 통해 세상을 다르게 바라볼 수 있게 되는듯하고, 나의 사소한 고민이 아닌 범인간적인 위로를 받을 수 있다.

 

어떻게 읽을까?

정말 읽기 쉬운 책이라고 생각한다. 위에서 언급한 하루키 에세이 걸작선 세트에 있는 책은 그가 잡지에 연재하면서 쓴 글을 책으로 엮어낸 것이다. 따라서 두-세장 정도에 하나의 이야기가 쓰여 있다. 그와 단짝인 것처럼 보이는 안자이 미즈마루의 유쾌한 삽화도 이야기마다 꼭 하나씩 들어가 있다. 세계적인 소설가다운 간결하면서도 몰입력 있는 문체는 이 짧은 글을 더더욱 쉽게 만들어 준다.

특별한 깊은 교훈을 담는 책도 아니고 중요한 정보를 전달하는 책도 아니다. 가볍게 읽으면 좋은 책이다. 머리 쓰지 않고 여유로운 휴식을 취하고 싶을 때 TV나 인터넷이 아니라 독서를 하고 싶다면 이만큼 좋은 책이 없다. , 깊게 집중하기 어려운 환경에서 시간을 보내야 할 경우, 예를 들면 소란스러운 카페나 버스 또는 기차에서도 집중하려는 노력 없이도 쉽게 몰입해서 읽을 수 있다. 마지막으로는 나의 경우인데 어려운 책을 읽을 때 해독의 방법으로 하루키의 에세이를 사용했다. 책으로 인해 얻은 피로를 다른 활동으로 푸는 게 아니라 다시 책으로 해독하는 것이다. 나처럼 책을 많이 읽고 싶어 하면서도, 막상 책을 펴면 피로가 느껴지는 사람들에게 이렇게 번갈아 읽기는 절대적인 독서량을 늘리기에 좋은 방법이라 생각한다.

 

책에 흥미를 붙이고 싶은 사람, 여행 방법이나 그곳의 문화를 나열한 에세이가 아니라, 여행을 통해 생각하고 느끼는 것에 공감하고 싶은 여행광, 하루키의 소설이 아닌 색다른 모습을 보고 싶어 하는 독서광에게 하루키의 에세이를 추천한다.

 * 시작에 앞서.

  본래는 이보다 일찍, 더 여유롭게 글을 쓰려했다. 하지만 어머니와 동생이 같이 운영하는 미용실을 개업하면서, 그 가게에서 사용할 소프트웨어를 만들어 달라는 부탁이 있었기에 그동안 이 글쓰기 계획을 미룰 수밖에 없었다. 간단한 기능을 가진 소프트웨어지만 공부를 겸하면서 개발했기에 시간이 오래 걸렸고 어제나 되어서야 완성 할 수 있었다.

  한해의 독서 생활을 정리하고 내년의 독서 계획과 다짐을 세우겠다는 목표로 시작한 글이기에 꼭 마무리해야 한다고 생각하기에 짧게나마 리뷰를 해보려 한다.



<어떻게 살 것인가> 유시민 | 생각의길

<유시민의 글쓰기특강> 유시민 | 생각의길

<대통령의 글쓰기> 강원국 | 메디치미디어

<대통령의 말하기> 윤태영 | 위즈덤하우스


  올해 주요 독서 테마 중 하나가 글쓰기가 된 이유는 하나의 책 때문이다. 그 책은 유시민 작가의 <어떻게 살 것인가>이다. 토론 프로그램에서 보여주는 논리적인 유시민 작가의 모습을 보며 그 논리의 바탕이 되는 생각은 무엇인지 궁금했기에 이 책을 찾아보게 되었다.

  책의 주제는 단순하다. ‘자신이 삶의 주체가 되어 하고 싶은 놀이와 일, 사랑, 그리고 연대하며 살아라’ 하는 것이다. 죽음에 대한 두려움이나 망각으로 ‘죽지 못해 사는 삶’을 살기보다, 죽음을 직시하며 삶의 의미를 찾으라 이야기한다. 유시민 작가는 이 책에서 삶의 중요한 요소에 놀이, 일, 사랑에 연대를 더하며 진보적 가치에 대한 예찬도 동시에 하고 있다.

  이 책의 주제가 나에게 특별하게 다가오진 않았다. 평소에 내가 가지고 있는 생각과도 큰 차이가 없었기에 무덤덤했다. 단지 인상 깊었던 것은 그러한 생각을 표현하기 위해 유시민 작가가 보여주는 글쓰기 방식이다. 나는 생각만 가지고 있을 뿐 <어떻게 살 것인가>처럼 말끔하고 또렷하게 생각을 전달하지는 못한다. 자신의 경험은 물론 철학과 인문학, 통계 뿐 아니라 과학적 연구 결과를 동원하여 자신의 생각을 뒷받침 하며 뚜렷한 주제를 전달하는 글쓰기 방식에 매혹되었다. 이런 이유로 <유시민의 글쓰기특강>을 읽어 보게 되었고, 연말에는 대한민국의 리더십 실종을 목격하며 자신의 뚜렷한 생각을 갖고 표현했던 전임 대통령들의 글쓰기 방법을 이해하고자 <대통령의 말하기>, <대통령의 글쓰기>를 읽었다.


  이 세 권의 책을 읽으면서 공통적으로 이야기 하는 좋은 글쓰기 방법을 발견 할 수 있었다. 크게 보면 네 가지로 나눌 수 있을 것 같다.

  첫 번째로는 생각이 중요하다는 것이다. 자신만의 정확하고 논리적인 생각을 가지고 있어야 글을 쓸 수 있다. 글쓰기라는 것은 결국 내 생각을 전달하는 도구일 뿐이다. 아주 뚜렷하고 명확한 생각을 가지고 있지 못하면 아무리 멋있는 글을 써도 겉만 번지르르할 뿐이다.

  두 번째로는 많이 읽고 많이 써야 한다는 것이다. 무슨 일을 하든 결국 노력할수록 느는 법이다. 많이 읽는 것은 자신의 생각을 발전시키고, 독해력을 키우고, 논리적 사고를 할 수 있게 한다. 많이 쓰는 것은 주제에 알맞은 군더더기 없는 글을 쓸 수 있게 한다. 이를 위해 메모하는 습관을 가지는 것도 좋다.

  세 번째로는 뚜렷한 주제를 가지고 논증하는 습관을 가지라는 것이다. 주제와 동떨어진 이야기를 지양하고, 자신의 주제를 타인에게 설득시키기 위해 충분한 논증을 하라는 것이다.

  마지막으로 독자를 고려하여 써야 한다는 것이다. 좋은 생각과 뚜렷한 주제를 가지고 있어도 쉽게 읽히지 않으면 무용지물이다. 글도 결국 말에서 나오는 것이므로, 말로서 표현해도 막힘없이 읽을 수 있는 글을 만들어야 한다. 가급적 단문으로 표현하며, 한국어로 글을 작성한다면 외국어 번역투를 지양해야 한다. 중학생 수준의 독자가 읽는다고 가정하고 쉬운 문장을 써야 한다.

  위에 언급한 네가지 이 외에도 다양한 조언이 있으니 찾아 읽어보면 도움이 될 것이다.


  물론 세 책은 다른 작가가 조금씩 다른 의도를 가지고 쓴 글이기에 차이도 있다. <유시민의 글쓰기특강>은 일반적인 논리적 글쓰기에 대한 이야기를 한다. 때문에 다양한 상황에서의 글쓰기에 대한 조언과 마음가짐도 이야기 하고, 논리적 글쓰기를 위한 추천도서도 포함하고 있다. 반면 뒤의 두 책은 대통령의 글쓰기, 즉 리더라는 특정 인물의 글쓰기에 대해 이야기 하고 있다. 내가 쓰는 블로그의 글은 취향이 맞지 않는 누군가를 고려하며 써야할 의무를 갖진 않는다. 하지만 대통령의 글쓰기라면 상황은 다르다. 리더 자신에게 관심이 없거나 혹은 반대적 입장을 가진 사람도 자신에게 주의 집중 시키고, 자신의 생각을 또렷하게 전달해야 한다. 때문에 뒤의 두 책에서는 그러한 방법과 관련된 노하우를 이야기한다. 이러한 차이에 유의하여 책을 선택하면 좋을 듯하다. 그리고 두 책 모두 시나 일상에서 느끼는 감정을 실감나게 전하는 감수성 있는 글쓰기와는 거리가 멀다.


  이 네 권의 책을 읽으면서 많이 읽는 것 뿐 아니라 많이 써야겠다는 다짐을 하게 되었다. 블로그를 시작하게 된 계기도 이 책들 덕분이라 할 수 있겠다. 평소에 그리 많은 대화를 하는 성격이 아니라 정작 중요할 때 표현력의 부족을 많이 느끼곤 한다. 내 생각이 다른 사람에게 효과적으로 전달되지 못하는 경험을 종종 한다. 좋은 글을 쓰기 위해, 즉 나의 생각을 정확히 전달하기 위해 이 블로그에서 많은 글을 써나갈 것이다. 일 년 뒤 이맘때 지금 내가 쓴 글을 돌아보며 성장해 있음을 느끼게 된다면 더할 나위 없이 기쁘지 않을까 상상해본다.

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

  2년 차 백수인 덕에 책을 꽤 많이 읽을 수 있었다. 작년에는 어영부영 취업 준비하다 책 몇 권 읽지 못했다. 올해 상반기에는 취업 활동대신 국비 교육과정에 들어갔었기 때문에 남는 시간에는 책을 읽을 수 있었다. 특히 학원이 꽤 먼 곳에 떨어져 있었기 때문에 통학 시간동안 책을 오래 읽을 수 있었다. 후반기에는 취업활동을 하긴 했으나 계속되는 탈락 소식에 자괴감에 빠졌다. 그리고 책으로만 마음을 다잡을 수 있었다. 그 좋아하는 게임도 손에 잘 안 잡히고 거실에 누워 멍하니 있을 때마다 유일하게 하고 싶은 일은 독서밖에 없었다. 그 정도로 올해는 책을 꽤 많이 읽은 듯하다. 물론 시간이 아주 많이 여유로웠던 것 치고는 많이 못 읽었다고 해도 할 말이 없지만, 그냥 평소보다 많이 읽었다고 해두자.

 

  올해 읽은 책들은 아래와 같다.


<어떻게 살 것인가> 유시민 | 생각의길

<유시민의 글쓰기특강> 유시민 | 생각의길

<대통령의 말하기> 윤태영 | 위즈덤하우스

<대통령의 글쓰기> 강원국 | 메디치미디어

<라오스에 대체 뭐가 있는데요?> 무라카미 하루키 | 이영미 옮김 | 문학동네

<코끼리 공장의 해피엔드> 무라카미 하루키 | 김난주 옮김 | 안자이 미즈마루 그림 | 문학동네

<발렌타인데이의 무말랭이> 무라카미 하루키 | 김난주 옮김 | 안자이 미즈마루 그림 | 문학동네

<세일러복을 입은 연필> 무라카미 하루키 | 김난주 옮김 | 안자이 미즈마루 그림 | 문학동네

<쿨하고 와일드한 백일몽> 무라카미 하루키 | 김난주 옮김 | 안자이 미즈마루 그림 | 문학동네

<해뜨는 나라의 공장> 무라카미 하루키 | 김난주 옮김 | 안자이 미즈마루 그림 | 문학동네

     (이상 5권은 문학동네에서 출간한 무라카미 하루키 에세이 걸작선 세트)

<취미는 전시회 관람> 한정희 | 중앙북스

<미학 오디세이 세트> 진중권 | 휴머니스트

<진중권의 서양미술사 세트> 진중권 | 휴머니스트

<CODE: 하드웨어와 소프트웨어에 숨어 있는 언어> 찰스 펫졸드 | 김현규 옮김 | 인사이트

<군주론> 니콜로 마키아벨리 | 강정인, 김경희 옮김 | 까치

<코스모스> 칼 세이건 | 홍승수 옮김 | 사이언스북스

<순수의 시대> 이디스 워튼 | 고정아 옮김 | 열린책들

<나는 고양이로소이다> 나쓰메 소세키 | 김난주 옮김 | 열린책들

<장미의 이름> 움베르토 에코 | 이윤기 옮김 | 열린책들


  구매하지 않고 서점에서 읽은 책들

<후와후와> 무라카미 하루키 | 권남희 옮김 | 안자이 미즈마루 그림 | 비채

<무라카미 하루키의 위스키 성지여행> 무라카미 하루키 | 이윤정 옮김 | 문학사상사


  총 26권을 읽었다. 현재 읽고 있는 소설을 포함하면 12월이 끝나기 전에 한, 두 권은 더 읽을 수 있지 않을까 싶다. 다음 글에는 ‘글쓰기, 하루키 에세이, 고전과 인문 교양, 소설’을 분류로 간단한 이야기를 해볼까 한다.

어머니와 동생이 함께 미용실을 운영하게 되었는데 여기서 쓸 프로그램을 하나 만들어 달라고 한다. 회원을 관리하는 아주 간단한 프로그램이라하기에 흔쾌히 수락했다. 물론 누군가가 사용하는 프로그램을 만들어 본 적은 없으나 이번 기회에 실력을 쌓을겸 해서 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만 생성했는데도 뭔가 다 한것 같은 느낌이다. 겨우 몇줄로 이정도 소프트웨어가 작성되는건 놀랍긴 하다.

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

    사실 몇 주 전까지 아무런 준비도 하지 않았다. 유럽 여행에 대한 기대는 가득하지만, 막상 준비하려니 너무나 귀찮았다. 그전까지 나에게 여행은 그저 출발 전날 기차 예약하고 버스카드 하나만 잘 챙기면 대충 옷가지 쑤셔 넣고 떠나도 아무 문제가 없는 그런 것이었다. 국내 여행이 전부였기에 나올만한 당연한 불평이었다.

    준비 안 하냐는 어머니의 닦달에 못 이겨 하나하나씩 조사하기 시작했다. 너무나 다른 교통 환경과 숙박시설, 그리고 수많은 관광지가 눈앞을 캄캄하게 만들었다. 어디서부터 시작해야 할지 감을 잡을 수 없었다. 속된말로 멘탈이 와르르 무너졌다. ‘, 괜히 혼자 유럽 여행한다고 깝댔나’, ‘영어도 제대로 못 하는 게, 가서 미아 되고 영영 못 오는 거 아닐까?’ 하는 오만 망상이 들었다. 정말로 유럽은(혹은 여행은) 그 분위기를 한껏 느낌 있게 담은 사진을 통해 감상하는 것이 직접 가는 것보다 더 나은 게 아닐까 하는 생각을 하기에 이르렀다. 사진은 여태껏 내가 가졌던 유럽에 대한 기대를 저버리지도 않을 것이고, 움직여야 하는 수고를 덜어주며, 그 나라에 대한 동경과 환상을 깨지 않는다는 점에서 더 나은 것이 아닌가 싶기도 했다. 정말로 알랭 드 보통의 에세이 <여행의 기술>에 언급된 위스망스처럼상상력이 실제의 천박한 경험보다 더 나은 대체물을 제공한다는 의견에 순간 동의하기도 했다.

    이런 두려움과 긴장 탓에 여행 준비에 온 정신을 쏟기 어려웠다. 그저 필요한 짐을 챙기고 간략한 동선만 만든 것이 준비의 전부였다. 정작 여행에서 보게 될 것이 무엇이고, 지나가는 모든 것에 대한 더 많은 호기심을 제공할 유럽의 문화에 관한 공부는 거의 하지 못했다. 긴장이 미리 오는지 이상하게 밥맛이 떨어졌다. 소화도 잘 안 되고 머리도 아파지고 그랬다. 간밤에는 비행기를 타고 먼 곳으로 떠나야 한다는 두려움이 온 정신을 마비시켰다. 그 때문에 머릿속은 뒤죽박죽이 되어 쉬이 잠을 이룰 수 없었다. 새벽 즈음이 되어서야 얕은 잠을 잘 수 있었다.

    아침에 일어나니 이상하게도 어제의 두려움과 긴장감은 전혀 느껴지지 않았다. 몇 시간 잠을 자지 못했는데도 왠지 모르게 개운했고, 몇 주 내내 번갈아 날뛰었던 신경들도 잠잠해진 듯했다. 자포자기의 마음으로돈 아까우니 일단 가고 보자는 생각 때문에 그런가 싶었다. 하지만 배낭을 메고, 집을 나와 택시를 타고, 여객 터미널을 거치고, 인천공항으로 향하는 버스에 타고 이동하는 과정에서 느껴지는 행복감은 그런 어떻게든 되겠지 하는 생각에서 나오는 게 아니라는 것을 깨달았다. 그동안 나에게 두려움을 안겨줬던 것들 - 즉 내가 도달하게 될 곳이 어떤 장소인지, 그리고 어떤 아름다움을 가지고 있고 내가 가지고 있던 기대에 부응하는지는 중요한 것이 아니었다. 어디론가 미지의 곳으로 떠나는 행동 그 자체가 나를 들뜨게 하고 있었다. 몸으로나 마음으로나 정말 추웠던 지난 몇 달의 기억이 있는 이곳을 떠날 수만 있다면, 그 어느 곳으로든 도달한다는 것이 행복이었다. 일상의 따분한 대학 생활에서 내가 타고 있는 버스가 경로를 이탈하여 어디론가 모르는 곳으로 도달한다면 즐겁지 않을까 하는 쾌감이 실현되는 순간이다. 도착하는 그곳이 나에게 어떠한 행복과 실망을 안겨주든지 상관없다. 그곳으로 향하게 하는 버스와 비행기, 그리고 머무는 여객터미널과 공항에서의 기억이 어쩌면 여행 중에 가장 행복한 기억이 될 수도 있지 않을까.

----

* 2014년 3월에 유럽여행을 떠나며 느낀 것을 다시 이곳에 옮겨 적는다. 볼 때마다 그때의 즐거운 긴장감이 느껴지는 듯하다.

아래는 그 후 여행하며 떠나간 흔적들.




+ Recent posts