Selenium Implicitly wait vs Explicitly wait

좀 더 보기 편한 깃북 버전의 나만의 웹 크롤러 만들기가 나왔습니다!

들어가며

Selenium WebDriver를 이용해 실제 브라우저를 동작시켜 크롤링을 진행할 때 가끔가다보면 NoSuchElementException라는 에러가 나는 경우를 볼 수 있습니다.

가장 대표적인 사례가 바로 JS를 통해 동적으로 HTML 구조가 변하는 경우인데요, 만약 사이트를 로딩한 직후에(JS처리가 끝나지 않은 상태에서) JS로 그려지는 HTML 엘리먼트를 가져오려고 하는 경우가 대표적인 사례입니다. (즉, 아직 그리지도 않은 요소를 가져오려고 했기 때문에 생기는 문제인 것이죠.)

그래서 크롤링 코드를 작성할 때 크게 두가지 방법으로 브라우저가 HTML Element를 기다리도록 만들어 줄 수 있습니다.

Implicitly wait

Selenium에서 브라우저 자체가 웹 요소들을 기다리도록 만들어주는 옵션이 Implicitly Wait입니다.

아래와 같은 형태로 카카오뱅크 타이틀을 한번 가져와 봅시다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
from selenium import webdriver

driver = webdriver.Chrome('chromedriver')

# driver를 만든 후 implicitly_wait 값(초단위)을 넣어주세요.
driver.implicitly_wait(3)

driver.get('https://www.kakaobank.com/')

# 하나만 찾기
title = driver.find_element_by_css_selector('div.intro_main > h3')
# 여러개 찾기
small_titles = driver.find_elements_by_css_selector('div.cont_txt > h3')

print(title.text)

for t in small_titles:
print(t.text)

driver.quit()

위 코드를 실행하면 여러분이 .get()으로 지정해준 URL을 가져올 때 각 HTML요소(Element)가 나타날 때 까지 최대 3초까지 ‘관용있게’ 기다려 줍니다.

즉, 여러분이 find_element_by_css_selector와 같은 방식으로 HTML엘리먼트를 찾을 때 만약 요소가 없다면 요소가 없다는 No Such Element와 같은 Exception을 발생시키기 전 모든 시도에서 3초를 기다려 주는 것이죠.

하지만 이런 방식은 만약 여러분이 크롤링하려는 웹이 ajax를 통해 HTML 구조를 동적으로 바꾸고 있다면 과연 ‘3초’가 적절한 값일지에 대해 고민을 하게 만듭니다.(모든 ajax가 진짜로 3초 안에 이루어질까요?)

그래서 우리는 조금 더 발전된 기다리는 방식인 Explicitly wait을 사용하게 됩니다.

NOTE: 기본적으로 Implicitly wait의 값은 0초입니다. 즉, 요소를 찾는 코드를 실행시킨 때 요소가 없다면 전혀 기다리지 않고 Exception을 raise하는 것이죠.

Explicitly wait

자, 여러분이 인터넷 웹 사이트를 크롤링하는데 ajax를 통해 HTML 구조가 변하는 상황이고, 각 요소가 들어오는 시간은 몇 초가 될지는 예상할 수 없다고 가정해 봅시다.

위에서 설정해 준 대로 implicitly_wait을 이용했다면 어떤 특정한 상황(인터넷이 유독 느렸음)으로 인해 느려진 경우 우리가 평소에 기대했던 3초(n초)를 넘어간 경우 Exception이 발생할 것이고 이로 인해 반복적인 크롤링 작업을 진행할 때 문제가 생길 수 있습니다.

따라서 우리는 명확하게 특정 Element가 나타날 때 까지 기다려주는 방식인 Explicitly Wait을 사용할 수 있습니다.

아래 코드는 위에서 Implicitly wait을 통해 사용했던 암묵적 대기(get_element_by_id 등)을 사용한 대신 명시적으로 div.intro_main > h3라는 CSS Selector로 가져오는 부분입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
from selenium import webdriver
# 아래 코드들을 import 해 줍시다.
from selenium.webdriver.common.by import By
# WebDriverWait는 Selenium 2.4.0 이후 부터 사용 가능합니다.
from selenium.webdriver.support.ui import WebDriverWait
# expected_conditions는 Selenium 2.26.0 이후 부터 사용 가능합니다.
from selenium.webdriver.support import expected_conditions as EC

driver = webdriver.Chrome('chromedriver')

driver.get('https://www.kakaobank.com/')

try:
# WebDriverWait와 .until 옵션을 통해 우리가 찾고자 하는 HTML 요소를
# 기다려 줄 수 있습니다.
title = WebDriverWait(driver, 10) \
.until(EC.presence_of_element_located((By.CSS_SELECTOR, "div.intro_main > h3")))
print(title.text)
finally:
driver.quit()

위 코드를 사용하면 우리가 찾으려는 대상을 driver가 명시적으로 ‘10초’를 기다리도록 만들어 줄 수 있습니다.

마치며

만약 여러분이 ajax를 사용하지 않는 웹 사이트에서 단순하게 DOM구조만 변경되는 상황이라면 사실 Explicitly wait을 사용하지 않아도 괜찮을 가능성이 높습니다. (DOM API처리속도는 굉장히 빠릅니다.)

하지만 최신 웹 사이트들은 대부분 ajax요청을 통해 웹 구조를 바꾸는 SPA(Single Page App)이 많기 때문에 크롤링을 진행할 때 Explicitly wait을 이용하는 것이 좋습니다.

SQLAlchemy Query를 Pandas DataFrame로 만들기

이번 글은 기존 DB를 Flask-SQLAlchemy ORM Model로 사용하기를 보고 오시면 좀더 빠르게 실 프로젝트에 적용이 가능합니다.

들어가며

전체 예시를 보시려면 TL;DR를 참고하세요.

DB에 있는 정보를 파이썬 코드 속에서 SQL raw Query를 통해 정보를 가져오는 아래와 같은 코드의 형태는 대다수의 언어에서 지원합니다.

1
2
3
4
5
6
7
import sqlite3

# 굳이 sqlite3이 아닌 다른 MySQL와 같은 DB의 connect를 이뤄도 상관없습니다.
# 여기서는 파이썬 파일과 같은 위치에 blog.sqlite3 파일이 있다고 가정합니다.
conn = sqlite3.connect("blog.sqlite3")
cur = conn.cursor()
cur.execute("select * from post where id < 10;")

위와 같은 형식으로 코드를 사용할 경우 웹이 이루어지는 과정 중 2~3번째 과정인 “SQL쿼리 요청하기”와 “데이터 받기”라는 부분을 수동으로 처리해 줘야 하는 부분이 있습니다.

이런 경우 파이썬 파일이더라도 한 파일 안에 두개의 언어를 사용하게 되는 셈입니다. (python와 SQL)

만약 여러분이 Pandas DataFrame객체를 DB에서 가져와 만들려면 이런 문제가 생깁니다.

  • DB에 연결을 구성해야 함
  • 가져온 데이터를 데이터 타입에 맞춰 파이썬이 이해하는 형태로 변환
  • 정리한 데이터를 Pandas로 불러오기

음, 보기만 해도 상당히 귀찮네요.

설치하기

우선 필요한 패키지들을 먼저 설치해 줍시다.

1
2
3
pip install flask
pip install Flask-SQLAlchemy
pip install pandas

Pandas로 SQL요청하기

Pandas에서는 이런 귀찮은 점을 보완해 주기 위해 read_sql_query라는 함수를 제공합니다. 위 코드를 조금 바꿔봅시다.

1
2
3
4
5
6
7
8
9
10
11
import sqlite3
import pandas as pd # NoQA

conn = sqlite3.connect("blog.sqlite3")
# 이 부분을 삭제
# cur = conn.cursor()
# cur.execute("select * from post where id < 10;")

# 아래 부분을 추가
df = pd.read_sql_query("select * from post where id < 10;", conn)
# df는 이제 Pandas Dataframe 객체

단순하게 DB 커넥션, 그리고 read_sql_query만으로 SQL Query를 바로 Pandas DataFrame 객체로 받아왔습니다. 이제 데이터를 수정하고 가공하는 처리는 Pandas에게 맡기면 되겠군요!

하지만, 여전히 우리는 SQL을 짜고있어요. 복잡한 쿼리라면 몰라도, 단순한 쿼리를 이렇게까지 할 필요가 있을까요?

SQLAlchemy 모델 이용하기

Flask를 사용할때 많이 쓰는 SQLAlchemy는 ORM으로 수많은 DB를 파이썬만으로 제어하도록 도와줍니다. 그리고 이 점이 우리가 SQL을 SQLAlchemy를 통해 바로 만들 수 있도록 도와줍니다.

NOTE: 이번 글에서는 Flask-SQLAlchemy 패키지를 사용합니다. SQLAlchemy와는 약간 다르게 동작할 수도 있습니다.

모델 클래스 만들기

모델 클래스를 기존 DB를 참조해 만드는 것은 기존 DB를 Flask-SQLAlchemy ORM Model로 사용하기 를 참고하세요.

예제 모델: Post

블로그에서 자주 쓸 법한 Post라는 이름의 모델 클래스를 하나 만들어 봅시다.

우선 SQLAlchemyflask_sqlalchemy에서 import 해옵시다. 그리고 Flask도 가져와 app을 만들어 줍시다. 그리고 db객체를 만들어줍시다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from datetime import datetime
from flask import Flask, jsonify
from flask_sqlalchemy import SQLAlchemy
import pandas as pd

app = Flask(__name__)
# 현재 경로의 blog.sqlite3을 불러오기
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///blog.sqlite3'
db = SQLAlchemy(app)

class Post(db.Model):
__tablename__ = 'post'

id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(100))
content = db.Column(db.Text)
pub_date = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)

자, 이제 여러분은 blog.sqlite3파일 안에 post라는 테이블에 값들을 넣거나 뺄 수 있게 되었습니다.

루트 View 만들기

여러분이 app.run( ) 으로 Flask 개발 서버를 띄웠을 때 첫 화면(‘/‘ URL에서) 실행될 View 함수(post_all)를 만들어줍시다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
from datetime import datetime
from flask import Flask, jsonify
from flask_sqlalchemy import SQLAlchemy
import pandas as pd
import json

app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///blog.sqlite3'
db = SQLAlchemy(app)

class Post(db.Model):
__tablename__ = 'post'

id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(100))
content = db.Column(db.Text)
pub_date = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)

# 아래 줄을 추가해 줍시다.
# List post which id is less then 10
@app.route('/')
def post_all():
df = pd.read_sql_query("select * from post where id < 10;", db.session.bind).to_json()
return jsonify(json.loads(df))

자, 분명히 ORM을 쓰는데도 아직 SQL 쿼리를 쓰고있네요! SQL쿼리문을 지워버립시다!

queryset 객체를 만들기

우리는 Post라는 모델을 만들어줬으니 이제 Post객체의 .query.filter()를 통해 객체들을 가져와 봅시다.

우선 queryset라는 이름에 넣어줍시다. 그리고 Pandas의 read_sql(유의: read_sql_query가 아닙니다.)에 queryset의 내용과 세션을 넘겨줘 DataFrame 객체로 만들어줍시다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
from datetime import datetime
from flask import Flask, jsonify
from flask_sqlalchemy import SQLAlchemy
import pandas as pd
import json

app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///blog.sqlite3'
db = SQLAlchemy(app)

class Post(db.Model):
__tablename__ = 'post'

id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(100))
content = db.Column(db.Text)
pub_date = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)

# List post which id is less then 10
@app.route('/')
def post_all():
# 이 줄은 지우고,
# df = pd.read_sql_query("select * from post where id < 10;", db.session.bind).to_json()
# 아래 두줄을 추가해주세요.
queryset = Post.query.filter(Post.id < 10) # SQLAlchemy가 만들어준 쿼리, 하지만 .all()이 없어 실행되지는 않음
df = pd.read_sql(queryset.statement, queryset.session.bind) # 진짜로 쿼리가 실행되고 DataFrame이 만들어짐
return jsonify(json.loads(df).to_json())

자, 위와 같이 코드를 짜 주면 이제 SQLAlchemy ORM와 Pandas의 read_sql을 통해 df이 DataFrame 객체로 자연스럽게 가져오게 됩니다.

정리하기

여러분이 Pandas를 사용해 데이터를 분석하거나 정제하려 할 때 웹앱으로 Flask를 사용하고 ORM을 이용한다면, 굳이 SQL Query를 직접 만드는 대신 이처럼 Pandas와 SQLAlchemy의 강력한 조합을 이용해 보세요. 조금 더 효율적인 시스템 활용을 고려한 파이썬 프로그램이 나올거에요!

TL;DR

아래 코드와 같이 모델을 만들고 db 객체를 만든 뒤 pandas의 read_sql을 사용하면 됩니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
from datetime import datetime
from flask import Flask, jsonify
from flask_sqlalchemy import SQLAlchemy
import pandas as pd
import json

app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///blog.sqlite3'
db = SQLAlchemy(app)

class Post(db.Model):
__tablename__ = 'post'

id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(100))
content = db.Column(db.Text)
pub_date = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)

# List post which id is less then 10
@app.route('/')
def post_all():
queryset = Post.query.filter(Post.id < 10) # SQLAlchemy가 만들어준 쿼리, 하지만 .all()이 없어 실행되지는 않음
df = pd.read_sql(queryset.statement, queryset.session.bind) # 진짜로 쿼리가 실행되고 DataFrame이 만들어짐
return jsonify(json.loads(df).to_json())

기존 DB를 Flask-SQLAlchemy ORM Model로 사용하기

본 게시글에서는 MySQL/Sqlite을 예제로 하고있지만, Flask-SQLAlchemy가 지원하는 다른 DB에서도 사용 가능합니다.

들어가며

Flask로 웹 개발 진행 시 SQLAlchemy(Flask-SQLAlchemy)를 사용해 ORM구조를 구성할 때 데이터를 저장할 DB의 구조를 직접 확인하며 진행하는 것은 상당히 귀찮고 어려운 일입니다.

Django에는 내장된 inspectdb라는 명령어를 통해 Django와 일치하는 DB Model구조를 만들어주지만 SQLAlchemy 자체에 내장된 automap은 우리가 상상하는 모델 구조를 바로 만들어주지는 않습니다.

따라서 다른 패키지를 고려해볼 필요가 있습니다.

flask-sqlacodegen

flask-sqlacodegen은 기존 DB를 Flask-SQLAlchemy에서 사용하는 Model 형식으로 변환해 보여주는 패키지입니다. 기존 sqlacodegen에서 포크해 Flask-SQLAlchemy에 맞게 기본 설정이 갖추어져있어 편리합니다.

설치하기

설치는 pip로 간단하게 진행해 주세요.

글쓰는 시점 최신버전은 1.1.6.1입니다.

글쓴것과 같은 버전으로 설치하려면 flask-sqlacodegen==1.1.6.1 로 설치해 주세요.

1
2
3
4
# 최신 버전 설치하기
pip install flask-sqlacodegen
# 글쓴 시점과 같게 설치하려면
# pip install flask-sqlacodegen==1.1.6.1

설치가 완료되면 명령줄에서 flask-sqlacodegen라는 명령어를 사용할 수 있습니다.

주의: sqlacodegen이 이미 깔려있다면 다른 가상환경(virtuale / venv)를 만드시고 진행해 주세요. sqlacodegen이 깔려있으면 --flask이 동작하지 않습니다.

DB 구조 뜯어내기

flask-sqlacodegensqlacodegen과 거의 동일한 문법을 사용합니다.(포크를 뜬 프로젝트니까요!)

flask-sqlacodegen 명령어로 DB를 지정하면 구조를 알 수 있습니다.

SQLite의 경우

1
flask-sqlacodegen "sqlite:///db.sqlite3" --flask > models.py # 상대경로, 현재 위치의 db.sqlite3파일

SQLite는 로컬에 있는 DB의 위치를 지정하면 됩니다.

위 명령어를 실행하면 models.py파일 안에 db.sqlite3 DB의 모델이 정리됩니다.

NOTE: Sqlite의 파일을 지정할 경우 “sqlite://“가 아닌 “sqlite:///“ 로 /를 3번 써주셔야 상대경로로 지정 가능하며, “sqlite:////“로 /를 4번 써주셔야 절대경로로 지정이 가능합니다.

mysql 서버의 경우

1
flask-sqlacodegen "mysql://username:password@DB_IP/DB_NAME" --flask > models.py

MySQL의 경우 mysql에 접속하는 방식 그대로 사용자 이름, 비밀번호, IP(혹은 HOST도메인), DB이름을 넣어준 뒤 진행해주면 됩니다.

NOTE: mysql은 “mydql://“ 로 /가 2번입니다.

NOTE: mysql에 연결하려면 pip패키지 중 mysqlclient가 설치되어있어야 합니다.
설치가 되어있지 않으면 아래와 같이 ModuleNotFoundError가 발생합니다.

MAC에서 진행 중 혹시 mysqlclient설치 중 아래와 같은 에러가 발생한다면

아래 명령어를 실행해 xcode cli developer toolopenssl을 설치해주신 후 mysqlclient를 설치해 주세요.

1
2
3
4
xcode-select --install
brew install openssl
export LIBRARY_PATH=$LIBRARY_PATH:/usr/local/opt/openssl/lib/
pip install mysqlclient

실행결과

아래 결과는 장고 프로젝트를 생성하고 첫 migrate를 진행할 때 생기는 예시 db.sqlite3파일을 flask-sqlacodegen을 사용한 결과입니다.

Index, PK등을 잘 잡아주고 있는 모습을 볼 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
# models.py 파일
# coding: utf-8
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Index, Integer, String, Table, Text
from sqlalchemy.orm import relationship
from sqlalchemy.sql.sqltypes import NullType
from flask_sqlalchemy import SQLAlchemy

db = SQLAlchemy()

class AuthGroup(db.Model):
__tablename__ = 'auth_group'

id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(80), nullable=False)


class AuthGroupPermission(db.Model):
__tablename__ = 'auth_group_permissions'
__table_args__ = (
db.Index('auth_group_permissions_group_id_permission_id_0cd325b0_uniq', 'group_id', 'permission_id'),
)

id = db.Column(db.Integer, primary_key=True)
group_id = db.Column(db.ForeignKey('auth_group.id'), nullable=False, index=True)
permission_id = db.Column(db.ForeignKey('auth_permission.id'), nullable=False, index=True)

group = db.relationship('AuthGroup', primaryjoin='AuthGroupPermission.group_id == AuthGroup.id', backref='auth_group_permissions')
permission = db.relationship('AuthPermission', primaryjoin='AuthGroupPermission.permission_id == AuthPermission.id', backref='auth_group_permissions')


class AuthPermission(db.Model):
__tablename__ = 'auth_permission'
__table_args__ = (
db.Index('auth_permission_content_type_id_codename_01ab375a_uniq', 'content_type_id', 'codename'),
)

id = db.Column(db.Integer, primary_key=True)
content_type_id = db.Column(db.ForeignKey('django_content_type.id'), nullable=False, index=True)
codename = db.Column(db.String(100), nullable=False)
name = db.Column(db.String(255), nullable=False)

content_type = db.relationship('DjangoContentType', primaryjoin='AuthPermission.content_type_id == DjangoContentType.id', backref='auth_permissions')


class AuthUser(db.Model):
__tablename__ = 'auth_user'

id = db.Column(db.Integer, primary_key=True)
password = db.Column(db.String(128), nullable=False)
last_login = db.Column(db.DateTime)
is_superuser = db.Column(db.Boolean, nullable=False)
first_name = db.Column(db.String(30), nullable=False)
last_name = db.Column(db.String(30), nullable=False)
email = db.Column(db.String(254), nullable=False)
is_staff = db.Column(db.Boolean, nullable=False)
is_active = db.Column(db.Boolean, nullable=False)
date_joined = db.Column(db.DateTime, nullable=False)
username = db.Column(db.String(150), nullable=False)


class AuthUserGroup(db.Model):
__tablename__ = 'auth_user_groups'
__table_args__ = (
db.Index('auth_user_groups_user_id_group_id_94350c0c_uniq', 'user_id', 'group_id'),
)

id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.ForeignKey('auth_user.id'), nullable=False, index=True)
group_id = db.Column(db.ForeignKey('auth_group.id'), nullable=False, index=True)

group = db.relationship('AuthGroup', primaryjoin='AuthUserGroup.group_id == AuthGroup.id', backref='auth_user_groups')
user = db.relationship('AuthUser', primaryjoin='AuthUserGroup.user_id == AuthUser.id', backref='auth_user_groups')


class AuthUserUserPermission(db.Model):
__tablename__ = 'auth_user_user_permissions'
__table_args__ = (
db.Index('auth_user_user_permissions_user_id_permission_id_14a6b632_uniq', 'user_id', 'permission_id'),
)

id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.ForeignKey('auth_user.id'), nullable=False, index=True)
permission_id = db.Column(db.ForeignKey('auth_permission.id'), nullable=False, index=True)

permission = db.relationship('AuthPermission', primaryjoin='AuthUserUserPermission.permission_id == AuthPermission.id', backref='auth_user_user_permissions')
user = db.relationship('AuthUser', primaryjoin='AuthUserUserPermission.user_id == AuthUser.id', backref='auth_user_user_permissions')


class DjangoAdminLog(db.Model):
__tablename__ = 'django_admin_log'

id = db.Column(db.Integer, primary_key=True)
object_id = db.Column(db.Text)
object_repr = db.Column(db.String(200), nullable=False)
action_flag = db.Column(db.Integer, nullable=False)
change_message = db.Column(db.Text, nullable=False)
content_type_id = db.Column(db.ForeignKey('django_content_type.id'), index=True)
user_id = db.Column(db.ForeignKey('auth_user.id'), nullable=False, index=True)
action_time = db.Column(db.DateTime, nullable=False)

content_type = db.relationship('DjangoContentType', primaryjoin='DjangoAdminLog.content_type_id == DjangoContentType.id', backref='django_admin_logs')
user = db.relationship('AuthUser', primaryjoin='DjangoAdminLog.user_id == AuthUser.id', backref='django_admin_logs')


class DjangoContentType(db.Model):
__tablename__ = 'django_content_type'
__table_args__ = (
db.Index('django_content_type_app_label_model_76bd3d3b_uniq', 'app_label', 'model'),
)

id = db.Column(db.Integer, primary_key=True)
app_label = db.Column(db.String(100), nullable=False)
model = db.Column(db.String(100), nullable=False)


class DjangoMigration(db.Model):
__tablename__ = 'django_migrations'

id = db.Column(db.Integer, primary_key=True)
app = db.Column(db.String(255), nullable=False)
name = db.Column(db.String(255), nullable=False)
applied = db.Column(db.DateTime, nullable=False)


class DjangoSession(db.Model):
__tablename__ = 'django_session'

session_key = db.Column(db.String(40), primary_key=True)
session_data = db.Column(db.Text, nullable=False)
expire_date = db.Column(db.DateTime, nullable=False, index=True)


t_sqlite_sequence = db.Table(
'sqlite_sequence',
db.Column('name', db.NullType),
db.Column('seq', db.NullType)
)

Flask의 app에 덧붙이기

이렇게 만들어진 model은 다른 Extension과 동일하게 Flask app에 붙일 수 있습니다.

app.py라는 파일을 하나 만들고 아래 내용으로 채워주세요.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# app.py (models.py와 같은 위치)
from flask import Flask

import models # models.py파일을 가져옵시다.

def create_app():
app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = "mysql://username:password@DB_IP/DB_NAME"
models.db.init_app(app)
return app

if __name__=='__main__':
app = create_app()
app.run()

앞서 만들어준 models.py파일을 가져와 create_app 함수를 통해 app을 lazy_loading해주는 과정을 통해 진행해 줄 수 있습니다.

마치며

기존에 사용하던 DB를 Flask와 SqlAlchemy를 통해 ORM으로 이용해 좀 더 빠른 개발이 가능하다는 것은 큰 이점입니다. ORM에서 DB 생성을 하지 않더라도 이미 있는 DB를 ORM으로 관리하고 Flask 프로젝트에 바로 가져다 쓸 수 있다는 점이 좀 더 빠른 프로젝트 진행에 도움이 될거랍니다.

Webpack과 Babel로 최신 JavaScript 웹프론트 개발환경 만들기

이번 포스팅에서는 nodejs8.5.0, npm5.3.0 버전을 사용합니다.

들어가며

파이썬의 버전 2와 3이 다른 것은 누구나 알고 2017년인 오늘은 대부분 Python3버전을 이용해 프로젝트를 진행합니다. 하지만 자바스크립트에 버전이 있고 새로운 기능이 나온다 하더라도 이 기능을 바로 사용하는 경우는 드뭅니다. 물론 node.js를 이용한다면 자바스크립트의 새로운 버전의 기능을 바로바로 이용해볼 수 있지만 프론트엔드 웹 개발을 할 경우 새로 만들어진 자바스크립트의 기능을 사용하는 것은 상당히 어렵습니다.

1
2
3
4
5
6
// 이런 문법은 사용하지 못합니다.
const hello = 'world'
const printHelloWorld = (e) => {
console.log(e)
}
printHelloWorld(hello)

가장 큰 차이는 실행 환경의 문제인데요, 우리가 자주 사용하는 크롬브라우저의 경우에는 자동업데이트 기능이 내장되어있어 일반 사용자가 크롬브라우저를 실행만 해도 최신 버전을 이용하지만, 인터넷 익스플로러나 사파리와 같은 경우에는 많은 사용자가 OS에 설치되어있던 버전 그대로를 이용합니다. 물론 이렇게 사용하는 것도 심각한 문제를 가져오지는 않지만, 구형 브라우저들은 새로운 자바스크립트를 이해하지 못하기 때문에 이 브라우저를 사용하는 사용자들은 새로운 자바스크립트로 개발된 웹 사이트를 접속할 경우 전혀 다르게 혹은 완전히 동작하지 않는 페이지를 볼 수 있기 때문에 많은 일반 사용자를 대상으로 하는 서비스의 경우 새 버전의 자바스크립트를 사용해 개발한다는 것이 상당히 모험적인 성향이 강합니다.

es2017

글쓴 시점인 2017년 10월 최신 자바스크립트 버전은 ES2017ES8이라 불리는 버전입니다. 하지만 이건 정말 최신 버전의 자바스크립트이고, 중요한 변화가 등장한 버전이 2015년도에 발표된 ES2015, 다른 말로는 ES6이라고 불리는 자바스크립트입니다. 하지만 인터넷익스플로러를 포함한 대부분의 브라우저들이 지원하는 자바스크립트의 버전은 ES5로 이보다 한단계 낮은 버전을 사용합니다. 따라서 우리는 ES6혹은 그 이상 버전의 자바스크립트 코드들을 ES5의 아래 버전 자바스크립트로 변환해 사용하는 방법을 사용할 수 있습니다.

Babel

여기서 바로 Babel이 등장합니다. Babel은 최신 자바스크립트를 ES5버전에서도 돌아갈 수 있도록 변환(Transpiling)해줍니다. 우리가 자바스크립트 최신 버전의 멋진 기능을 이용하는 동안, Babel이 다른 브라우저에서도 돌아갈 수 있도록 처리를 모두 해주는 것이죠!

물론, Babel이 마법의 요술도구처럼 모든 최신 기능을 변환해주지는 못합니다. 하지만 아래 사진처럼 다양한 브라우저에 따라 최신 JavaScript문법 중 어떠 부분까지가 실행 가능한 범위인지 알려줍니다. Babel coverage

Webpack

ES6에서 새로 등장한 것 중 유용한 문법이 바로 import .. from ..구문입니다. 다른 언어에서의 import와 유사하게 경로(상대경로 혹은 절대경로)에서 js파일을 불러오는 방식으로 동작합니다.

예를들어 어떤 폴더 안에 Profile.jsindex.js파일이 있다고 생각해 봅시다.

1
2
3
4
5
6
7
8
9
10
11
// Profile.js
export class Profile {
constructor(name, email) {
this.name = name
this.email = email
}

hello() {
return `Hello, ${this.name}(${this.email})`
}
}

하는일이라고는 name, email을 받는 것, 그리고 hello하는 함수밖에 없지만 우선 Profile이라는 class를 하나 만들었습니다.

여기서 Profile 클래스 앞에 export를 해 주었는데, export를 해 줘야 다른 파일에서 import가 가능합니다.

자, 아래와 같이 index.js파일을 하나 만들어 봅시다.

1
2
3
4
5
// index.js
import { Profile } from './Profile'

const pf = new Profile('Beomi', 'jun@beomi.net')
console.log(pf.hello())

이 파일은 현재 경로의 Profile.js파일 중 Profile 클래스를 import해와 새로운 인스턴스를 만들어 사용합니다.

하지만 안타깝게도 이 index.js파일은 실행되지 않습니다. 아직 webpack으로 처리를 해주지 않았기 때문이죠!

webpack-dev-server

webpack은 파일을 모아 하나의 js파일로 만들어줍니다.(보통 bundle.js라는 이름을 많이 씁니다.) 하지만 실제 개발중 js파일을 수정할 때마다 Webpack을 실행해 번들작업을 해준다면 시간도 많이 걸리고 매우 귀찮습니다. 이를 보완해 주는 패키지가 바로 webpack-dev-server 인데요, 이 패키지를 사용하면 여러분이 실제 빌드를 해 bundle.js파일을 만들지 않아도 메모리 상에 가상의 bundle.js파일을 만들어 여러분이 웹 사이트를 띄울때 자동으로 번들된 js파일을 띄워줍니다. 그리고 소스가 수정될 때 마다 업데이트된(번들링된) bundle.js파일로 띄워주고 화면도 새로고침해줍니다!

NOTE: webpack-dev-server는 build를 자동으로 해주는 것은 아닙니다. 단지 미리 지정해둔 경로로 접근할 경우 (실제로는 파일이 없지만) bundle.js파일이 있는 것처럼 파일을 보내주는 역할을 맡습니다. 개발이 끝나고 실제 서버에 배포할때는 이 패키지 대신 실제 webpack을 통해 빌드 작업을 거친 최종 결과물을 서버에 올려야 합니다.

설치하기

우선 npm프로젝트를 생성해야 합니다. index.js파일을 만든 곳(어떤 폴더) 안에서 다음 명령어로 “이 폴더는 npm프로젝트를 이용하는 프로젝트다” 라는걸 알려주세요.

1
2
# -y 인자를 붙이면 모든 설정이 기본값으로 됩니다.
npm init -y

이 명령어를 치면 폴더 안에 package.json파일이 생성되었을 거에요.

이제 다음 명령어로 Babel과 webpack등을 설치해 봅시다.

1
2
3
# babel과 webpack은 개발환경에서 필요하기 때문에 --save-dev로 사용합니다.
npm install --save-dev babel-loader babel-core babel-preset-env
npm install --save-dev webpack webpack-dev-server

babel-loaderwebpack이 .js 파일들에 대해 babel을 실행하도록 만들어주고, babel-core는 babel이 실제 동작하는 코드이고, babel-preset-env는 babel이 동작할 때 지원범위가 어느정도까지 되어야 하는지에 대해 지정하도록 만들어주는 패키지입니다.

이렇게 설치를 진행하고 나면 Babel과 Webpack을 사용할 준비를 마친셈입니다.

NOTE: package.json뿐 아니라 package-lock.json파일도 함께 생길수 있습니다. 이 파일은 npm패키지들이 각각 수많은 의존성을 가지고 있기 때문에 의존성 패키지들을 다운받는 URL을 미리 모아둬 다른 컴퓨터에서 package.json을 통해 npm install로 패키지들을 설치시 훨씬 빠른 속도로 패키지를 받을 수 있도록 도와줍니다.

이제 설정파일 몇개를 만들고 수정해줘야 해요.

설정파일 건드리기

package.json

package.json파일은 파이썬 pip의 requirements.txt처럼 패키지버전 관리만 해주는 것이 아니라 npm와 결합해 특정 명령어를 실행하거나 npm 프로젝트의 환경을 담는 파일입니다.

1
npm run 명령어이름

위와 같은 명령어를 사용할 수 있도록 만들어 주기도 합니다.

현재 package.json파일은 아래와 같은 형태로 되어있을거에요.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{
"name": "npm_blog",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"babel-core": "^6.26.0",
"babel-loader": "^7.1.2",
"babel-preset-env": "^1.6.1",
"webpack": "^3.8.1",
"webpack-dev-server": "^2.9.2"
}
}

이제 package.json파일을 열어 "scripts"부분을 다음과 같이 builddevserver명령어를 추가해 줍시다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{
"name": "npm_blog",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"build": "webpack",
"devserver": "webpack-dev-server --open --progress"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"babel-core": "^6.26.0",
"babel-loader": "^7.1.2",
"babel-preset-env": "^1.6.1",
"webpack": "^3.8.1",
"webpack-dev-server": "^2.9.2"
}
}

이제 여러분이 npm run build를 할 때는 webpack이 실행되고, npm run devserver를 할 때는 개발용 서버가 띄워질거에요.

webpack.config.js

webpack.config.js 파일은 앞서 설치해준 webpack을 실행 시 어떤 옵션을 사용할지 지정해주는 js파일입니다.

우리 프로젝트 폴더에는 아직 webpack.config.js 파일이 없을거에요. package.json와 같은 위치에 webpack.config.js파일을 새로 만들어 아래 내용으로 채워줍시다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
const webpack = require('webpack');
const path = require('path');

module.exports = {
entry: './index.js',
output: {
path: path.resolve(__dirname, 'dist'),
publicPath: '/dist/',
filename: 'bundle.js'
},
module: {
rules: [
{
test: /\.js$/,
include: path.join(__dirname),
exclude: /(node_modules)|(dist)/,
use: {
loader: 'babel-loader',
options: {
presets: ['env']
}
}
}
]
}
};

위 파일은 entry에 현재 위치의 index.js파일을 들어가 모든 import를 찾아오고, module -> rules -> include에 있는 .js로 된 모든 파일을 babel로 처리해줍니다.(exclue에 있는 부분인 node_modules폴더와 dist폴더는 제외합니다.)

index.html

사실 우리는 아직 번들링된 js파일을 보여줄 HTML파일이 없습니다! 우선 bundle.js를 보여주기만 할 단순한 HTML파일을 하나 만들어 봅시다.(index.js와 같은 위치)

1
2
3
4
5
6
7
8
9
10
11
<!-- index.html -->
<!DOCTYPE html>
<html>
<head>
<title>NPM Webpack</title>
</head>
<body>
Webpack용 HTML
<script type="text/javascript" src="/dist/bundle.js"></script>
</body>
</html>

webpack을 사용하지 않았다면 HTML파일 아래 script태그의 src에 index.js를 넣어야 하지만, 우리는 webpack과 webpack-dev-server를 사용하기때문에 번들링된 파일의 위치인 /dist/bundle.js을 넣어줍니다.

devserver 띄우기

자, 이제 아래 명령어로 devserver를 띄워봅시다!

1
npm run devserver

브라우저의 개발자 도구를 열어보면 아래와 같이 로그가 잘 찍힌걸 확인해 볼 수 있을거에요.

이제 여러분이 index.js파일이나 Profile.js등을 수정하면 곧바로 새로고침되고 새로운 bundle.js를 라이브로 불러올거에요.

배포용으로 만들기

여러분이 프로젝트 개발을 끝내고 실제 서버에 배포할 때는 devserver가 아니라 실제로 번들링된 파일인 bundle.js를 만들어야 합니다.

아래 명령어로 현재 위치의 dist폴더 안에 bundle.js 파일을 만들어 줍시다.

1
npm run build

위와 같이 나온다면 성공적으로 webpack이 마쳐진 것이랍니다! 그리고 여러분 프로젝트 폴더 안에 dist폴더가 생기고 그 안에 bundle.js파일이 생겼을 거에요.

이제 여러분은 index.html파일과 dist폴더를 묶어 서버에 올리면 페이지가 잘 동작하는것을 확인할 수 있을거에요!

Fabric으로 Flask 자동 배포하기

이번 글은 Ubuntu16.04 LTS / Python3 / Apache2.4 서버 환경으로 진행합니다.

들어가며

플라스크를 서버에 배포하는 것은 장고 배포와는 약간 다릅니다. 기본적으로 Apache2를 사용하기 때문에 mod_wsgi를 사용하는 것은 동일하지만, 그 외 다른 점이 조금 있습니다.

우선 간단한 플라스크 앱 하나가 있다고 생각을 해봅시다. 가장 단순한 형태는 아래와 같이 루트로 접속시 Hello world!를 보여주는 것이죠.

1
2
3
4
5
6
7
8
# app.py
from flask import Flask

app = Flask(__file__)

@app.route('/')
def hello():
return "Hello world!"

물론 여러분이 실제로 만들고 썼을 프로젝트는 이것보다 훨씬 복잡하겠지만, 일단은 이걸로 시작은 할 수 있답니다.

wsgi.py 파일 만들기

로컬에서 app.run() 을 통해 실행했던 테스트서버와는 달리 실 배포 상황에서는 Apache나 NginX와 같은 웹서버를 거쳐 웹을 구동하고, 따라서 app.run()의 방식은 더이상 사용할 수 없습니다. 대신 여러가지 웹서버와 Flask를 연결시켜주는 방법이 있는데, 이번엔 그 중 wsgi를 통해 Apache서버가 Flask 앱을 실행하도록 만들어줄 것이랍니다.

우선 wsgi.py파일을 하나 만들어야 합니다. 이 파일은 나중에 Apache서버가 이 파일을 실행시켜 Flask서버를 구동하게 됩니다. 그리고 이 파일은 위에서 만든 변수인 app = Flask(__file__), 즉 app변수를 import할 수 있는 위치에 있어야 합니다. (app.py파일과 동일한 위치에 두면 무방합니다.)

1
2
3
4
5
6
7
8
9
10
# wsgi.py # app.py와 같은 위치
import sys
import os

CURRENT_DIR = os.getcwd()

sys.stdout = sys.stderr
sys.path.insert(0, CURRENT_DIR)

from app import app as application

우리가 wsgi를 통해 실행할 경우 프로그램은 application이라는 변수를 찾아 run()와 비슷한 명령을 실행해 서버를 구동합니다. 따라서 우리는 wsgi.py파일 내 application이라는 변수를 만들어줘야 하는데, 이 변수는 바로 app.py내의 app변수입니다.

위 코드를 보시면 sys모듈과 os모듈을 사용합니다. os모듈의 getcwd()함수를 통해 현재 파일의 위치를 시스템의 PATH 경로에 넣어줍니다. 이 줄을 통해 바로 아래에 있는 from app import app이라는 구문에서 from app 부분이 현재 wsgi.py파일의 경로에서 app.py를 import할 수 있게 되는 것이죠. 만약 이 줄이 빠져있다면 ImportError가 발생하며 app이라는 모듈을 찾을 수 없다는 익셉션이 발생합니다.

Fabric3 설치하기

Fabric3은 Python2만 지원하던 fabric프로젝트를 포크해 Python3을 지원하도록 업데이트한 패키지입니다. 우선 pip로 패키지를 설치해 줍시다.

1
2
pip install fabric3
# 맥/리눅스라면 pip3 install fabric3

이제 우리는 fab이라는 명령어를 사용할 수 있습니다. 이 명령어를 통해 fabfile.py 파일 내의 함수를 실행할 수 있게 됩니다.

fabfile.py 파일 만들기

Fabric은 그 자체로는 하는 일이 없습니다. 사실 fabric은 우리가 서버에 들어가서 ‘Git으로 소스를 받고’, ‘DB를 업데이트하고’, ‘Static파일을 정리하며’, ‘웹서버 설정을 업데이트’해주는 일들을 하나의 마치 배치파일처럼 자동으로 실행할 수 있도록 도와주는 도구입니다.

하지만 이 도구를 사용하려면 우선 fabfile.py라는 파일이 있어야 fabric이 이 파일을 읽고 파일 속의 함수를 실행할 수 있게 됩니다.

fabfile을 만들기 전 deploy.json이라는 이름의 json파일을 만들어 아래와 같이 설정을 담아줍시다.

우선 REPO_URL을 적어줍시다. 이 REPO에서 소스코드를 받아 처리해줄 예정이기 때문이죠. 그리고 PROJECT_NAME을 설정해 주세요. 일반적인 상황이라면 REPO의 이름과 같에 넣어주면 됩니다. 그리고 REMOTE_HOST는 서버의 주소가 됩니다. http등을 제외한 ‘도메인’부분만 넣어주세요. 그리고 서버에 SSH로 접속할 수 있는 IP를 REMOTE_HOST_SSH에 넣어주고, 마지막으로 sudo권한을 가진 유저이름을 REMOTE_USER에 넣어주세요.

1
2
3
4
5
6
7
{
"REPO_URL": "https://github.com/Beomi/our_project",
"PROJECT_NAME": "our_project",
"REMOTE_HOST": "our_project.com",
"REMOTE_HOST_SSH": "123.32.1.4",
"REMOTE_USER": "sudouser"
}

자, 이제 아래 코드를 통해 fabfile.py파일을 만들어 줍시다. (이것도 app.py와 같은 위치에 두면 관리하기가 편합니다.)

이부분은 파일에 설명을 담을 예정이니 코드의 주석을 참고해주세요.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
# fabfile.py
from fabric.contrib.files import append, exists, sed, put
from fabric.api import env, local, run, sudo
import os
import json

# 현재 fabfile.py가 있는 폴더의 경로
PROJECT_DIR = os.path.dirname(os.path.abspath(__file__))

# deploy.json이라는 파일을 열어 아래의 변수들에 담아줍니다.
envs = json.load(open(os.path.join(PROJECT_DIR, "deploy.json")))

REPO_URL = envs['REPO_URL']
PROJECT_NAME = envs['PROJECT_NAME']
REMOTE_HOST = envs['REMOTE_HOST']
REMOTE_HOST_SSH = envs['REMOTE_HOST_SSH']
REMOTE_USER = envs['REMOTE_USER']

# SSH에 접속할 유저를 지정하고,
env.user = REMOTE_USER
# SSH로 접속할 서버주소를 넣어주고,
env.hosts = [
REMOTE_HOST_SSH,
]
# 원격 서버중 어디에 프로젝트를 저장할지 지정해준 뒤,
project_folder = '/home/{}/{}'.format(env.user, PROJECT_NAME)
# 우리 프로젝트에 필요한 apt 패키지들을 적어줍니다.
apt_requirements = [
'curl',
'git',
'python3-dev',
'python3-pip',
'build-essential',
'apache2',
'libapache2-mod-wsgi-py3',
'python3-setuptools',
'libssl-dev',
'libffi-dev',
]

# _로 시작하지 않는 함수들은 fab new_server 처럼 명령줄에서 바로 실행이 가능합니다.
def new_server():
setup()
deploy()


def setup():
_get_latest_apt()
_install_apt_requirements(apt_requirements)
_make_virtualenv()


def deploy():
_get_latest_source()
_put_envs()
_update_virtualenv()
_make_virtualhost()
_grant_apache2()
_restart_apache2()

# put이라는 방식으로 로컬의 파일을 원격지로 업로드할 수 있습니다.
def _put_envs():
pass # activate for envs.json file
# put('envs.json', '~/{}/envs.json'.format(PROJECT_NAME))

# apt 패키지를 업데이트 할 지 결정합니다.
def _get_latest_apt():
update_or_not = input('would you update?: [y/n]')
if update_or_not == 'y':
sudo('apt-get update && apt-get -y upgrade')

# 필요한 apt 패키지를 설치합니다.
def _install_apt_requirements(apt_requirements):
reqs = ''
for req in apt_requirements:
reqs += (' ' + req)
sudo('apt-get -y install {}'.format(reqs))

# virtualenv와 virtualenvwrapper를 받아 설정합니다.
def _make_virtualenv():
if not exists('~/.virtualenvs'):
script = '''"# python virtualenv settings
export WORKON_HOME=~/.virtualenvs
export VIRTUALENVWRAPPER_PYTHON="$(command \which python3)" # location of python3
source /usr/local/bin/virtualenvwrapper.sh"'''
run('mkdir ~/.virtualenvs')
sudo('pip3 install virtualenv virtualenvwrapper')
run('echo {} >> ~/.bashrc'.format(script))

# Git Repo에서 최신 소스를 받아옵니다.
# 깃이 있다면 fetch를, 없다면 clone을 진행합니다.
def _get_latest_source():
if exists(project_folder + '/.git'):
run('cd %s && git fetch' % (project_folder,))
else:
run('git clone %s %s' % (REPO_URL, project_folder))
current_commit = local("git log -n 1 --format=%H", capture=True)
run('cd %s && git reset --hard %s' % (project_folder, current_commit))

# Repo에서 받아온 requirements.txt를 통해 pip 패키지를 virtualenv에 설치해줍니다.
def _update_virtualenv():
virtualenv_folder = project_folder + '/../.virtualenvs/{}'.format(PROJECT_NAME)
if not exists(virtualenv_folder + '/bin/pip'):
run('cd /home/%s/.virtualenvs && virtualenv %s' % (env.user, PROJECT_NAME))
run('%s/bin/pip install -r %s/requirements.txt' % (
virtualenv_folder, project_folder
))

# (optional) UFW에서 80번/tcp포트를 열어줍니다.
def _ufw_allow():
sudo("ufw allow 'Apache Full'")
sudo("ufw reload")

# Apache2의 Virtualhost를 설정해 줍니다.
# 이 부분에서 wsgi.py와의 통신, 그리고 virtualenv 내의 파이썬 경로를 지정해 줍니다.
def _make_virtualhost():
script = """'<VirtualHost *:80>
ServerName {servername}
<Directory /home/{username}/{project_name}>
<Files wsgi.py>
Require all granted
</Files>
</Directory>
WSGIDaemonProcess {project_name} python-home=/home/{username}/.virtualenvs/{project_name} python-path=/home/{username}/{project_name}
WSGIProcessGroup {project_name}
WSGIScriptAlias / /home/{username}/{project_name}/wsgi.py
{% raw %}
ErrorLog ${{APACHE_LOG_DIR}}/error.log
CustomLog ${{APACHE_LOG_DIR}}/access.log combined
{% endraw %}
</VirtualHost>'""".format(
username=REMOTE_USER,
project_name=PROJECT_NAME,
servername=REMOTE_HOST,
)
sudo('echo {} > /etc/apache2/sites-available/{}.conf'.format(script, PROJECT_NAME))
sudo('a2ensite {}.conf'.format(PROJECT_NAME))

# Apache2가 프로젝트 파일을 읽을 수 있도록 권한을 부여합니다.
def _grant_apache2():
sudo('chown -R :www-data ~/{}'.format(PROJECT_NAME))
sudo('chmod -R 775 ~/{}'.format(PROJECT_NAME))

# 마지막으로 Apache2를 재시작합니다.
def _restart_apache2():
sudo('sudo service apache2 restart')

위 코드를 fabfile.py에 넣어주고 나서

첫 실행시에는 fab new_server

코드를 수정하고 push한 뒤 서버에 배포시에는 fab deploy

명령을 실행해 주면 됩니다.

NOTE: _ 로 시작하는 함수는 fab 함수이름으로 실행하지 못합니다.

자, 이제 서버에 올릴 준비가 되었습니다.

서버에 올리기

우분투 서버를 만들고 첫 배포라면 new_server를, 한번 new_server를 했다면 deploy로 배포를 진행합니다.

1
2
fab new_server # 첫 배포
fab deploy # 첫 배포를 제외한 나머지

끝났습니다!

여러분의 사이트는 이제 http://REMOTE_HOST 으로 접속 가능할거에요!

유의할 점

fabfile내의 apt_requirements 리스트에는 프로젝트마다 필요한 다른 패키지들을 적어줘야 합니다.

만약 여러분의 프로젝트에서 mysqlclient패키지등을 사용한다면 libmysqlclient-devapt_requirements리스트에 추가해줘야 합니다. 혹은 PostgreSQL을 사용한다면 libpq-dev가 필요할 수도 있습니다. 그리고 여러분이 이미지 처리를 하는 pillow패키지를 사용한다면 libjpeg62-devapt_requirements에 추가해야 할 수도 있습니다.

이처럼 여러분이 파이썬 패키지에서 어떤 상황이냐에 따라 다른 apt패키지 리스트를 넣어줘야 합니다.

이 부분만 유의해 넣어준다면 Fabric으로 한번에 배포에 성공할 수 있을거랍니다! :)

PDF 책 구글 번역가 도구에서 번역하기

시작하며

번역을 할 때 대상이 이미 doc파일같은 것이라면 사실 이 부분은 걱정하지 않아도 괜찮습니다. 하지만 만약 여러분이 책 번역등 의뢰를 받아 진행한다면 PDF로 책을 받을 가능성이 꽤 높습니다.
물론 PDF를 켜놓고 word창을 하나 옆으로 두 창을 띄워두며 한글로 번역해도 일이 가능하기는 합니다.
하지만 최근 React문서를 번역하며 사용했던 도구인 crowdin이나 Django문서 번역할때 사용하는 transifex를 떠올려보면 이게 무슨 삽질인가, 하는 생각이 듭니다.

그래서 여러분이 번역을 하기 위해 crowdin이나 transifex를 사용하려 사이트에 들어가보면,

월 단위 pricing인 것을 넘어 가격대가 상당히 높게 형성되어있는 것을 볼 수 있습니다. (ㅠㅠ) 저 두 서비스는 분명히 멋지고 좋은 서비스이지만 매달 가격을 지불하기에는 애매한 측면이 있어 다른 방법을 찾아보았습니다.

그러다 찾게 된 것이 바로 구글 번역가 도구였습니다.

이 구글 번역가 도구를 사용하면 상당히 다양한 형식의 문서를 번역할 수 있습니다.

문서

  • HTML(.HTML)
  • Microsoft Word (.DOC/.DOCX)
  • 일반 텍스트(.TXT)
  • 서식 있는 텍스트(.RTF)
  • 위키백과 URL

광고

  • 애드워즈 에디터 보관 파일 (.AEA)
  • 애드워즈 에디터 공유 파일(.AES)

동영상

  • YouTube 캡션
  • SubRip(.SRT)
  • SubViewer(.SUB)

기타

  • 자바 애플리케이션 (.PROPERTIES)
  • 애플리케이션 리소스 번들(.ARB)
  • Chrome 확장 프로그램(.JSON)
  • Apple iOS 애플리케이션(.STRINGS)

하지만 잘 보시면 우리가 받은 PDF파일을 바로 올릴수는 없게 되어있습니다.

자, 이제 ‘구글 번역가 도구에 올릴’ 파일을 만들기 위한 여정을 시작해봅시다.

PDF를 쪼개기

우리가 구글 번역가 도구에 올릴 최종 파일은 “1MB 이내의 .docx파일” 입니다. 왜 1MB냐고요? 구글이 그렇게 제한을 걸어서 그렇습니다 (ㅠㅠ)

만약 여러분이 1MB가 넘는 파일을 올리려 하시면…..

아래와 같이 에러가 납니다.

하지만 우리가 만약 PDF를 워드로 바꿔주고 나서 쪼개주려고 하면 상당히 귀찮습니다. 그래서 PDF를 먼저 목차대로 쪼개주는 것이 좋습니다.

우리는 https://www.sejda.com/split-pdf-by-outline라는 사이트를 이용할거에요.

사이트를 들어가주시면 아래와 같이 PDF를 올리라고 하는 부분이 나옵니다.

약간 아쉬운점은 무료는 200페이지 이내의 pdf로 제한이 걸린다는 점입니다. 그래서 저는 5달러를 지불하고 5일치 정액권을 구매해 이용했습니다.

무료는 속도 제한이 있기도 하고, 여기에서 여러 서비스를 사용할 것이기 때문에 5일권을 사서 진행하시는 것도 좋은 방법입니다.

물론 무료로 진행할 수 있는 곳도 있지만, 나중에 PDF Crop을 할 때 이 사이트를 또 사용하기 때문에 정액권을 사는 것을 추천합니다.(커피한잔값에 여러분의 정신건강을 지킬 수 있습니다.)

PDF를 올려주면 얼만큼 자세하게 쪼갤지 물어봅니다. Bookmark level이 바로 그 옵션인데요, 저는 대제목(챕터)로 자를 예정이라 1을 선택했습니다. 만약 좀 더 자세하게 소제목으로 잘라주고 싶다면 2정도를 선택해주시면 됩니다.

이제 Split by bookmarks를 클릭해주면 아래와 같이 다운로드 버튼이 나옵니다!

다운로드 받은 zip파일을 풀어주면 다음과 같이 챕터별로 잘 쪼개졌다는 것을 확인할 수 있습니다.

하지만 이 상태는 책 각 페이지에 머리말과 꼬리말이 들어가 있어 이 파일을 바로 워드파일로 변환해주면 머리말과 꼬리말이 같이 들어가 번역하기 귀찮은 상태가 됩니다. 그래서 이 부분을 제거해주어야 합니다.

머리말/꼬리말 제거해주기

이번에는 https://www.sejda.com/crop-pdf에서 진행합니다. 위에서 정액권을 구매했다면 여러개 파일을 동시에 넣어 crop을 돌릴 수 있습니다. (무료는 하나하나 넣어야 합니다)

위 사진의 Upload PDF Files를 눌러 파일 여러개를 동시에 crop할 수 있습니다. 우리가 위에서 목차대로 잘라준 경우처럼 책 사이즈가 같은 경우 굉장히 유용합니다.

우선 본문인 11~19번 파일만 업로드를 해보았습니다.

업로드가 완료되면 다음과 같이 Crop할 부분을 선택하라고 나옵니다. 문서 일부분이 화면에 겹쳐 나오기때문에 예상치 못하게 버려지는 부분이 생기는 것을 방지할 수 있습니다 :)

위 사진처럼 텍스트 부분만 선택하고 화면 아래의 CropPDF를 눌러주면 위에서와 같이 처리가 끝난 파일의 모음 zip을 받을 수 있습니다.

다운을 받아주고 확인해 봅시다. 글자 부분만 깔끔하게 잘 잘라준 것을 확인해 볼 수 있습니다!

글 일부분이 안보이는 것은 책이라 일부러 잘라 보이지 않는 부분입니다. 파일은 잘 처리되었다는걸 썸네일에서 확인할 수 있죠!

PDF를 워드파일(.docx)로 바꿔주기

이번에는 pdf2docx라는 서비스를 이용합니다. (무료입니다!)

우리가 방금 만들어준 ‘cropped_어쩌구.pdf’파일들을 업로드 해 주면 됩니다. 여러개 파일을 한번에 올릴 수 있어 편리합니다 ;)

업로드가 끝나고 변환작업이 완료되면 아래와 같이 Download All버튼이 활성화됩니다.

버튼을 누르면 pdf2docx.zip파일이 받아지고, 이 압축 파일을 풀어주면 다음과 같이 .docx파일로 변환된 파일들이 잘 들어오는 것을 확인할 수 있습니다.

하지만 잘 보시면 크기가 1MB를 넘는 파일이 보입니다. 저 파일들은 구글 번역가 도구에 올릴수 없습니다. 보통 문서가 1MB를 넘는 경우는 이미지의 크기가 큰 것이기 때문에, 이미지의 ppi를 조절해 파일 크기를 줄일 수 있습니다.

.docx파일 크기 줄이기(이미지 ppi줄이기)

1MB가 넘는 한 문서를 열어보니 이미지가 많아 보입니다. 하지만 이미지를 지우면 번역할때 어떤 내용을 다루는지 알아보기 어렵기 때문에 이미지의 해상도(ppi)만 낮춰주도록 하겠습니다.

우선 아래 스샷처럼 아무 이미지나 클릭해주고 나서 화면 위에 뜨는 ‘그림 서식’을 눌러주신 뒤, 핑크색으로 네모 표시 된 버튼을 눌러주세요.

그러면 ‘그림압축’ 메뉴가 뜨고 아래와 같이 그림 품질을 고를 수 있습니다.

최저 ppi인 96ppi로 맞춰주고 ‘잘려진 그림 영역 삭제’에 체크를 눌러주고 ‘이 파일의 모든 그림’으로 맞춰준 후 확인을 눌러주세요. 그리고 저장을 해주시면, 아래와 같이 파일 사이즈가 줄어든 것을 볼 수 있습니다. (기존 1.5MB -> 현재 1MB 조금 덜 됨)

구글 번역가 도구에 업로드하기

구글 번역가 도구 업로드에 다시 들어가 작아진 .docx파일을 올려줍시다.

언어 선택에 한국어는 기본적으로 없기 때문에 ‘ko’를 검색해 한국어를 추가하고 선택해줍시다.

이제 업로드가 끝나면 번역 업체를 누르라고 하는데, ‘아니오’를 눌러주면 됩니다.

업로드가 완료되면 아래와 같이 번역 목록에 뜹니다!

링크를 클릭해 들어가면 이제 아래처럼 번역 작업을 시작할 수 있습니다.

끝!

자, 이제 PDF파일로 된 책을 구글 번역가 도구에서 번역할 수 있도록 하는 작업이 모두 끝났습니다.

하지만 이 방식으로는 아쉬운 것이 세가지가 있습니다.

  • 책의 포맷을 맞춰주세요:
    우리가 책을 crop했기 때문에 어렵습니다.
  • 코드가 Indent가 제대로 되지 않아요:
    pdf to docx는 코드도 일반 문서로 해석합니다. (ㅠㅠ)
  • TM(Translation memory)이 완벽하지 않아요:
    구글 번역가 도구가 TM관리가 약간 기능이 부족합니다. 그래도 무료잖아요!

하지만 이 세가지를 감안한다면 이 가이드가 유용하실 것이라 생각합니다. 번역하시는 모든 분들 화이팅!

나만의 웹 크롤러 만들기(7): 창없는 크롬으로 크롤링하기

좀 더 보기 편한 깃북 버전의 나만의 웹 크롤러 만들기가 나왔습니다!

이번 가이드는 가이드 3편(Selenium으로 무적 크롤러 만들기)의 확장편입니다. 아직 selenium을 이용해보지 않은 분이라면 먼저 저 가이드를 보고 오시는걸 추천합니다.

HeadLess Chrome? 머리없는 크롬?

HeadLess란?

Headless라는 용어는 ‘창이 없는’과 같다고 이해하시면 됩니다. 여러분이 브라우저(크롬 등)을 이용해 인터넷을 브라우징 할 때 기본적으로 창이 뜨고 HTML파일을 불러오고, CSS파일을 불러와 어떤 내용을 화면에 그러야 할지 계산을 하는 작업을 브라우저가 자동으로 진행해줍니다.

하지만 이와같은 방식을 사용할 경우 사용하는 운영체제에 따라 크롬이 실행이 될 수도, 실행이 되지 않을 수도 있습니다. 예를들어 우분투 서버와 같은 OS에서는 ‘화면’ 자체가 존재하지 않기 때문에 일반적인 방식으로는 크롬을 사용할 수 없습니다. 이를 해결해 주는 방식이 바로 Headless 모드입니다. 브라우저 창을 실제로 운영체제의 ‘창’으로 띄우지 않고 대신 화면을 그려주는 작업(렌더링)을 가상으로 진행해주는 방법으로 실제 브라우저와 동일하게 동작하지만 창은 뜨지 않는 방식으로 동작할 수 있습니다.

그러면 왜 크롬?

일전 가이드에서 PhantomJS(팬텀)라는 브라우저를 이용하는 방법에 대해 다룬적이 있습니다. 팬텀은 브라우저와 유사하게 동작하고 Javascript를 동작시켜주지만 성능상의 문제점과 크롬과 완전히 동일하게 동작하지는 않는다는 문제점이 있습니다. 우리가 크롤러를 만드는 상황이 대부분 크롬에서 진행하고, 크롬의 결과물 그대로 가져오기 위해서는 브라우저도 크롬을 사용하는 것이 좋습니다.

하지만 여전히 팬텀이 가지는 장점이 있습니다. WebDriver Binary만으로 추가적인 설치 없이 환경을 만들 수 있다는 장점이 있습니다.

윈도우 기준 크롬 59, 맥/리눅스 기준 크롬 60버전부터 크롬에 Headless Mode가 정식으로 추가되어서 만약 여러분의 브라우저가 최신이라면 크롬의 Headless모드를 쉽게 이용할 수 있습니다.

크롬 버전 확인하기

크롬 버전 확인은 크롬 브라우저에서 chrome://version/로 들어가 확인할 수 있습니다.

이와 같이 크롬 버전이 60버전 이상인 크롬에서는 ‘Headless’모드를 사용할 수 있습니다.

크롬드라이버(chromedriver) 업데이트

크롬 버전이 올라감에 따라 크롬을 조작하도록 도와주는 chromedriver 역시 함께 업데이트를 진행해야 합니다.

https://sites.google.com/a/chromium.org/chromedriver/downloads

위 링크에서 Latest Release 옆 크롬드라이버를 선택해 OS별로 알맞은 zip파일을 받아 압축을 풀어줍시다.

기존 코드 수정하기

크롬의 헤드리스 모드를 사용하는 방식은 기존 selenium을 이용한 코드와 거의 동일합니다만, 몇가지 옵션을 추가해줘야합니다.

기존에 webdriver를 사용해 크롬을 동작한 경우 아래와 같은 코드를 사용할 수 있었습니다.

1
2
3
4
5
6
7
8
9
10
11
from selenium import webdriver

# 유의: chromedriver를 위에서 받아준
# chromdriver(windows는 chromedriver.exe)의 절대경로로 바꿔주세요!
driver = webdriver.Chrome('chromedriver')

driver.get('http://naver.com')
driver.implicitly_wait(3)
driver.get_screenshot_as_file('naver_main.png')

driver.quit()

위 코드를 동작시키면 크롬이 켜지고 파이썬 파일 옆에 naver_main.png라는 스크린샷 하나가 생기게 됩니다.
이 코드는 지금까지 우리가 만들었던 코드와 큰 차이가 없는걸 확인해 보세요.

하지만 이 코드를 몇가지 옵션만 추가해주면 바로 Headless모드로 동작하게 만들어줄 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from selenium import webdriver

options = webdriver.ChromeOptions()
options.add_argument('headless')
options.add_argument('window-size=1920x1080')
options.add_argument("disable-gpu")
# 혹은 options.add_argument("--disable-gpu")

driver = webdriver.Chrome('chromedriver', chrome_options=options)

driver.get('http://naver.com')
driver.implicitly_wait(3)
driver.get_screenshot_as_file('naver_main_headless.png')

driver.quit()

위 코드를 보시면 ChromeOptions()를 만들어 add_argument를 통해 Headless모드인 것과, 크롬 창의 크기, 그리고 gpu(그래픽카드 가속)를 사용하지 않는 옵션을 넣어준 것을 볼 수 있습니다.

제일 중요한 부분은 바로 options.add_argument('headless')라는 부분입니다. 크롬이 Headless모드로 동작하도록 만들어주는 키워드에요. 그리고 크롬 창의 크기를 직접 지정해 준 이유는, 여러분이 일반적으로 노트북이나 데스크탑에서 사용하는 모니터의 해상도가 1920x1080이기 때문입니다. 즉, 여러분이 일상적으로 보는 것 그대로 크롬이 동작할거라는 기대를 해볼수 있습니다!

마지막으로는 disable-gpu인데요, 만약 위 코드를 실행했을때 GPU에러~가 난다면 --disable-gpu로 앞에 dash(-)를 두개 더 붙여보세요. 이 버그는 크롬 자체에 있는 문제점입니다. 브라우저들은 CPU의 부담을 줄이고 좀더 빠른 화면 렌더링을 위해 GPU를 통해 그래픽 가속을 사용하는데, 이 부분이 크롬에서 버그를 일으키는 현상을 보이고 있습니다. (윈도우 크롬 61버전까지는 아직 업데이트 되지 않았습니다. 맥 61버전에는 해결된 이슈입니다.)

그리고 driver 변수를 만들 때 단순하게 chromedriver의 위치만 적어주는 것이 아니라 chrome_options라는 이름의 인자를 함께 넘겨줘야 합니다.

chrome_options는 Chrome을 이용할때만 사용하는 인자인데요, 이 인자값을 통해 headless등의 추가적인 인자를 넘겨준답니다.

자, 이제 그러면 한번 실행해 보세요. 크롬 창이 뜨지 않았는데도 naver_main_headless.png파일이 생겼다면 여러분 컴퓨터에서 크롬이 Headless모드로 성공적으로 실행된 것이랍니다!

Headless브라우저임을 숨기기

Headless모드는 CLI기반의 서버 OS에서도 Selenium을 통한 크롤링/테스트를 가능하게 만드는 멋진 모드지만, 어떤 서버들에서는 이런 Headless모드를 감지하는 여러가지 방법을 쓸 수 있습니다.

아래 글에서는 Headless모드를 탐지하는 방법과 탐지를 ‘막는’방법을 다룹니다.(창과 방패, 또 새로운 창!)

아래 코드의 TEST_URL은 https://intoli.com/blog/making-chrome-headless-undetectable/chrome-headless-test.html 인데요, 이곳에서 Headless모드가 감춰졌는지 아닌지 확인해 볼 수 있습니다.

User Agent 확인하기

Headless 탐지하기

가장 쉬운 방법은 User-Agent값을 확인하는 방법입니다.

일반적인 크롬 브라우저는 아래와 같은 User-Agent값을 가지고 있습니다.

1
Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/61.0.3163.100 Safari/537.36

하지만 Headless브라우저는 아래와 같은 User-Agent값을 가지고 있습니다.

잘 보시면 ‘HeadlessChrome/~~’와 같이 ‘Headless’라는 단어가 들어가있는걸 확인할 수 있습니다!

1
Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/60.0.3112.50 Safari/537.36

Headless 탐지 막기

따라서 기본적으로 갖고있는 User-Agent값을 변경해줘야합니다.

이것도 위에서 사용한 chrome_options에 추가적으로 인자를 전달해주면 됩니다. 위코드를 약간 바꿔 아래와 같이 만들어보세요.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
from selenium import webdriver

TEST_URL = 'https://intoli.com/blog/making-chrome-headless-undetectable/chrome-headless-test.html'

options = webdriver.ChromeOptions()
options.add_argument('headless')
options.add_argument('window-size=1920x1080')
options.add_argument("disable-gpu")

# UserAgent값을 바꿔줍시다!
options.add_argument("user-agent=Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/61.0.3163.100 Safari/537.36")

driver = webdriver.Chrome('chromedriver', chrome_options=options)

driver.get(TEST_URL)

user_agent = driver.find_element_by_css_selector('#user-agent').text

print('User-Agent: ', user_agent)

driver.quit()

이제 여러분의 Headless크롬은 일반적인 크롬으로 보일거랍니다.

플러그인 개수 확인하기

Headless 탐지하기

크롬에는 여러분이 따로 설치하지 않아도 추가적으로 플러그인 몇개가 설치되어있답니다. PDF 내장 리더기같은 것들이죠.

하지만 Headless모드에서는 플러그인이 하나도 로딩되지 않아 개수가 0개가 됩니다. 이를 통해 Headless모드라고 추측할 수 있답니다.

아래 자바스크립트 코드를 통해 플러그인의 개수를 알아낼 수 있습니다.

1
2
3
if(navigator.plugins.length === 0) {
console.log("Headless 크롬이 아닐까??");
}

Headless 탐지 막기

물론 이 탐지를 막는 방법도 있습니다. 바로 브라우저에 ‘가짜 플러그인’ 리스트를 넣어주는 것이죠!

아래 코드와 같이 JavaScript를 실행해 플러그인 리스트를 가짜로 만들어 넣어줍시다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from selenium import webdriver

TEST_URL = 'https://intoli.com/blog/making-chrome-headless-undetectable/chrome-headless-test.html'

options = webdriver.ChromeOptions()
options.add_argument('headless')
options.add_argument('window-size=1920x1080')
options.add_argument("disable-gpu")
options.add_argument("user-agent=Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/61.0.3163.100 Safari/537.36")
options.add_argument("lang=ko_KR") # 한국어!
driver = webdriver.Chrome('chromedriver', chrome_options=options)

driver.get('about:blank')
driver.execute_script("Object.defineProperty(navigator, 'plugins', {get: function() {return[1, 2, 3, 4, 5];},});")
driver.get(TEST_URL)

user_agent = driver.find_element_by_css_selector('#user-agent').text
plugins_length = driver.find_element_by_css_selector('#plugins-length').text

print('User-Agent: ', user_agent)
print('Plugin length: ', plugins_length)

driver.quit()

위와 같이 JS로 navigator 객체의 plugins속성 자체를 오버라이딩 해 임의의 배열을 반환하도록 만들어주면 개수를 속일 수 있습니다.

단, 출력물에서는 Plugin length가 여전히 0으로 나올거에요. 왜냐하면 사이트가 로딩 될때 이미 저 속성이 들어가있기 때문이죠 :’( 그래서 우리는 좀 더 다른방법을 뒤에서 써볼거에요.

언어 설정

Headless 탐지하기

여러분이 인터넷을 사용할때 어떤 사이트를 들어가면 다국어 사이트인데도 여러분의 언어에 맞게 화면에 나오는 경우를 종종 보고, 구글 크롬을 써서 외국 사이트를 돌아다니면 ‘번역해줄까?’ 하는 친절한 질문을 종종 봅니다.

이 설정이 바로 브라우저의 언어 설정이랍니다. 즉, 여러분이 선호하는 언어가 이미 등록되어있는 것이죠.

Headless모드에는 이런 언어 설정이 되어있지 않아서 이를 통해 Headless모드가 아닐까 ‘추측’할 수 있습니다.

Headless 탐지 막기

Headless모드인 것을 감추기 위해 언어 설정을 넣어줍시다. 바로 add_argument를 통해 크롬에 전달해 줄 수 있답니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
from selenium import webdriver

TEST_URL = 'https://intoli.com/blog/making-chrome-headless-undetectable/chrome-headless-test.html'

options = webdriver.ChromeOptions()
options.add_argument('headless')
options.add_argument('window-size=1920x1080')
options.add_argument("disable-gpu")
options.add_argument("user-agent=Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/61.0.3163.100 Safari/537.36")
options.add_argument("lang=ko_KR") # 한국어!
driver = webdriver.Chrome('chromedriver', chrome_options=options)

driver.get(TEST_URL)
driver.execute_script("Object.defineProperty(navigator, 'plugins', {get: function() {return[1, 2, 3, 4, 5]}})")
# lanuages 속성을 업데이트해주기
driver.execute_script("Object.defineProperty(navigator, 'languages', {get: function() {return ['ko-KR', 'ko']}})")

user_agent = driver.find_element_by_css_selector('#user-agent').text
plugins_length = driver.find_element_by_css_selector('#plugins-length').text
languages = driver.find_element_by_css_selector('#languages').text

print('User-Agent: ', user_agent)
print('Plugin length: ', plugins_length)
print('languages: ', languages)

driver.quit()

단, 출력물에서는 language가 빈칸으로 나올거에요. 왜냐하면 사이트가 로딩 될때 이미 저 속성이 들어가있기 때문이죠 :’( 그래서 우리는 좀 더 다른방법을 뒤에서 써볼거에요.

WebGL 벤더와 렌더러

Headless 탐지하기

여러분이 브라우저를 사용할때 WebGL이라는 방법으로 그래픽카드를 통해 그려지는 방법을 가속을 한답니다. 즉, 실제로 디바이스에서 돌아간다면 대부분은 그래픽 가속을 사용한다는 가정이 기반인 셈이죠.

사실 이 방법으로 차단하는 웹사이트는 거의 없을거에요. 혹여나 GPU가속을 꺼둔 브라우저라면 구별할 수 없기 때문이죠.

위 코드에서 사용해준 disable-gpu옵션은 사실 이 그래픽 가속을 꺼주는 것이에요. 따라서 이부분을 보완해 줄 필요가 있습니다.

Headless 탐지 막기

가장 쉬운 방법은 크롬이 업데이트되길 기대하고 disable-gpu옵션을 꺼버리는 것이지만, 우선은 이 옵션을 함께 사용하는 방법을 알려드릴게요.

위에서 사용한 script실행방법을 또 써 볼 것이랍니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
from selenium import webdriver

TEST_URL = 'https://intoli.com/blog/making-chrome-headless-undetectable/chrome-headless-test.html'

options = webdriver.ChromeOptions()
options.add_argument('headless')
options.add_argument('window-size=1920x1080')
options.add_argument("disable-gpu")
options.add_argument("user-agent=Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/61.0.3163.100 Safari/537.36")
options.add_argument("lang=ko_KR") # 한국어!
driver = webdriver.Chrome('chromedriver', chrome_options=options)

driver.get(TEST_URL)
driver.execute_script("Object.defineProperty(navigator, 'plugins', {get: function() {return[1, 2, 3, 4, 5]}})")
driver.execute_script("Object.defineProperty(navigator, 'languages', {get: function() {return ['ko-KR', 'ko']}})")
driver.execute_script("const getParameter = WebGLRenderingContext.getParameter;WebGLRenderingContext.prototype.getParameter = function(parameter) {if (parameter === 37445) {return 'NVIDIA Corporation'} if (parameter === 37446) {return 'NVIDIA GeForce GTX 980 Ti OpenGL Engine';}return getParameter(parameter);};")

user_agent = driver.find_element_by_css_selector('#user-agent').text
plugins_length = driver.find_element_by_css_selector('#plugins-length').text
languages = driver.find_element_by_css_selector('#languages').text
webgl_vendor = driver.find_element_by_css_selector('#webgl-vendor').text
webgl_renderer = driver.find_element_by_css_selector('#webgl-renderer').text

print('User-Agent: ', user_agent)
print('Plugin length: ', plugins_length)
print('languages: ', languages)
print('WebGL Vendor: ', webgl_vendor)
print('WebGL Renderer: ', webgl_renderer)

driver.quit()

위 코드에서는 WebGL렌더러를 Nvidia회사와 GTX980Ti엔진인 ‘척’ 하고 있는 방법입니다.

하지만 WebGL print 구문에서는 여전히 빈칸일거에요. 이 역시 이미 사이트 로딩시 속성이 들어가있기 때문이에요.

Headless 브라우저 숨기는 방법 다함께 쓰기

위에서 사용한 방법 중 User-Agent를 바꾸는 방법 외에는 사실 모두 Javascript를 이용해 값을 추출하고 오버라이딩 하는 방식으로 바꿔보았습니다.

하지만 번번히 결과물이 빈칸으로 나오는 이유는 driver.execute_script라는 함수 자체가 사이트가 로딩이 끝난 후 (onload()이후) 실행되기 때문입니다.

즉, 우리는 우리가 써준 저 JS코드가 사이트가 로딩 되기 전 실행되어야 한다는 것이죠!

사실 기본 크롬이라면 사이트가 로딩 되기전 JS를 실행하는 Extension들을 사용할 수 있어요. 하지만 Headless크롬에서는 아직 Extension을 지원하지 않습니다 :’(

그래서 차선책으로 mitmproxy라는 Proxy 프로그램을 사용해볼거에요.

mitmproxy 사용하기

진행하기 전 앞서 만들었던 모든 JS코드가 들어있는 content.js를 아래처럼 만들어주세요.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
const CDP = require('chrome-remote-interface');

const userAgent = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.50 Safari/537.36'

CDP(async function(client) {
const {Network, Page} = client;
await Page.enable();
await Network.enable();
await Network.setUserAgentOverride({userAgent});

// user-agent is now set
});

// overwrite the `languages` property to use a custom getter
Object.defineProperty(navigator, 'languages', {
get: function() {
return ['ko-KR', 'ko'];
},
});

// overwrite the `plugins` property to use a custom getter
Object.defineProperty(navigator, 'plugins', {
get: function() {
return [1, 2, 3, 4, 5];
}
});

const getParameter = WebGLRenderingContext.getParameter;
WebGLRenderingContext.prototype.getParameter = function(parameter) {
// UNMASKED_VENDOR_WEBGL
if (parameter === 37445) {
return 'Intel Open Source Technology Center';
}
// UNMASKED_RENDERER_WEBGL
if (parameter === 37446) {
return 'Mesa DRI Intel(R) Ivybridge Mobile ';
}

return getParameter(parameter);
};

['height', 'width'].forEach(property => {
// store the existing descriptor
const imageDescriptor = Object.getOwnPropertyDescriptor(HTMLImageElement.prototype, property);

// redefine the property with a patched descriptor
Object.defineProperty(HTMLImageElement.prototype, property, {
...imageDescriptor,
get: function() {
// return an arbitrary non-zero dimension if the image failed to load
if (this.complete && this.naturalHeight == 0) {
return 20;
}
// otherwise, return the actual dimension
return imageDescriptor.get.apply(this);
},
});
});

// store the existing descriptor
const elementDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetHeight');

// redefine the property with a patched descriptor
Object.defineProperty(HTMLDivElement.prototype, 'offsetHeight', {
...elementDescriptor,
get: function() {
if (this.id === 'modernizr') {
return 1;
}
return elementDescriptor.get.apply(this);
},
});

우선 Mitmproxy를 pip로 설치해주세요.

1
pip install mitmproxy

그리고 proxy 처리를 해 줄 파일인 inject.py파일을 만들어주세요.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# inject.py
from bs4 import BeautifulSoup
from mitmproxy import ctx

# load in the javascript to inject
with open('content.js', 'r') as f:
content_js = f.read()

def response(flow):
# only process 200 responses of html content
if flow.response.headers['Content-Type'] != 'text/html':
return
if not flow.response.status_code == 200:
return

# inject the script tag
html = BeautifulSoup(flow.response.text, 'lxml')
container = html.head or html.body
if container:
script = html.new_tag('script', type='text/javascript')
script.string = content_js
container.insert(0, script)
flow.response.text = str(html)

ctx.log.info('Successfully injected the content.js script.')

이제 터미널에서 아래 명령어로 mitmproxy 서버를 띄워주세요.

1
mitmdump -p 8080 -s "inject.py"

이 서버는 크롤링 코드를 실행 할 때 항상 켜져있어야 해요!

이제 우리 크롤링 코드에 add_argument로 Proxy옵션을 추가해 주세요.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
from selenium import webdriver

TEST_URL = 'https://intoli.com/blog/making-chrome-headless-undetectable/chrome-headless-test.html'

options = webdriver.ChromeOptions()
options.add_argument('headless')
options.add_argument('window-size=1920x1080')
options.add_argument("disable-gpu")
options.add_argument("user-agent=Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/61.0.3163.100 Safari/537.36")
options.add_argument("proxy-server=localhost:8080")
driver = webdriver.Chrome('chromedriver', chrome_options=options)

driver.get(TEST_URL)
print(driver.page_source)

user_agent = driver.find_element_by_css_selector('#user-agent').text
plugins_length = driver.find_element_by_css_selector('#plugins-length').text
languages = driver.find_element_by_css_selector('#languages').text
webgl_vendor = driver.find_element_by_css_selector('#webgl-vendor').text
webgl_renderer = driver.find_element_by_css_selector('#webgl-renderer').text

print('User-Agent: ', user_agent)
print('Plugin length: ', plugins_length)
print('languages: ', languages)
print('WebGL Vendor: ', webgl_vendor)
print('WebGL Renderer: ', webgl_renderer)
driver.quit()

하지만 사실 이 코드는 정상적으로 동작하지 않을거에요. 헤드리스모드를 끄면 잘 돌아가지만 헤드리스모드를 켜면 정상적으로 동작하지 않아요. 바로 SSL오류 때문입니다.

크롬에서 SSL을 무시하도록 만들수 있고, 로컬의 HTTP를 신뢰 가능하도록 만들 수도 있지만 아직 크롬 Headless모드에서는 지원하지 않습니다.

정확히는 아직 webdriver에서 지원하지 않습니다.

결론

아직까지는 크롬 Headless모드에서 HTTPS 사이트를 ‘완전히 사람처럼’보이게 한뒤 크롤링 하는 것은 어렵습니다. 하지만 곧 업데이트 될 크롬에서는 익스텐션 사용 기능이 추가될 예정이기 때문에 이 기능이 추가되면 복잡한 과정 없이 JS를 바로 추가해 진짜 일반적인 크롬처럼 동작하도록 만들 수 있으리라 생각합니다.

사실 서버 입장에서 위와 같은 요청을 보내는 경우 처리를 할 수 있는 방법은 JS로 헤드리스 유무를 확인하는 방법이 전부입니다. 즉, 서버 입장에서도 ‘식별’은 가능하지만 이로 인해 유의미한 차단은 하기 어렵습니다. 현재로서는 UserAgent 값만 변경해주어도 대부분의 사이트에서는 자연스럽게 크롤링을 진행할 수 있으리라 생각합니다.

Reference

로컬 개발서버를 HTTPS로 세상에 띄우기(like ngork)

이번 가이드를 따라가기 위해서는 HTTP(80/tcp) 포트가 열려있는 서버와 개인 도메인이 필요합니다.

들어가기 전

django, node.js, react, vue와 같은 웹 개발(Backend & Frontend)을 진행하다보면 모바일 디바이스나 타 디바이스에서 로컬 서버에 접근해야하는 경우가 있습니다.

하지만 보통 개발환경에서는 개발기기가 공인 IP를 갖고 있는것이 아니라 내부 NAT에서 개발이 이루어지고, 웹과 내부 개발기기 사이에는 방화벽이 있습니다. 집에서 개발한다면 공유기가, 회사에서 개발한다면 회사의 라우터 정책 기준이 있습니다.

일반적인 경우 네트워크 정책은 나가는(Outbound) 트래픽은 대부분의 포트가 열려있는 한편 들어오는(Inbound) 트래픽에는 극소수의 포트만 열려있습니다.

만약 로컬 서버에서 일반적으로 HTTP가 사용하는 80/tcp 포트로 서버를 띄어놓았다면 대부분의 경우 이 포트는 막혀있습니다. (개발용 서버인 8000/8080/4000/3000등도 마찬가지입니다. 극소수 빼고는 기본적으로 다 막아둡니다.)

이렇게 포트가 막혀있다면 우리가 로컬에 띄어둔 서버가 아무리 모든 IP에서의 접근을 허용한다고 해도 중간에 있는 라우터에서 막아버리기 때문에 LTE등의 모바일 셀룰러같은 외부에서의 접속은 사실상 불가능합니다.

따라서 이를 해결하기 위해 ngrok와 같은 SSH 터널링을 이용합니다. 하지만 ngrok 서비스 서버는 기본적으로 해외에 있고, 무료 Plan의 경우 분당 connection의 개수를 40개로 제한하고 있습니다. 만약 CSS나 JS, 이미지같은 static파일 요청 하나하나가 각각 connection을 사용한다면, 짧은 시간 내 여러번 새로고침은 수십개의 connection을 만들어버리고 ngrok은 요청을 즉시 차단해버립니다.

물론 keep-alive를 지원하는 클라이언트/서버 설정이 이루어지면 connection은 새로고침을 해도 늘어나지 않습니다. 하지만 모든 클라이언트가 keep-alive를 지원하지는 않습니다.

하지만 유료 플랜이라고 해서 무제한 connection을 지원하지는 않기 때문에 마음놓고 새로고침을 하기는 어렵습니다.

이번 가이드에서는 ngrok같이 로컬 개발 서버(장고의 runserver, webpack의 webpack-dev-server)를 다른 서버에 SSH Proxy를 통해 전달하는 법, 그리고 CloudFlare를 통해 HTTPS서버로 만드는 것까지를 다룹니다.

재료준비

80/tcp가 열린 서버가 있어야 합니다

이번 가이드에서는 80/tcp 포트가 열려있는 서버가 “꼭” 있어야 합니다. 물론 서버에는 공인 IP가 할당되어야 합니다. 그래야 나중에 CloudFlare에서 DNS설정을 해줄 수 있습니다.

만약 집에 이런 서버를 둔다면 포트포워딩을 통해 80/tcp만 열어줘도 됩니다.

한국서버가 가장 좋지만(물리적으로 가까우니까) 일본 VPS도 속도면에서 큰 손해를 보지는 않습니다. (물론 게임서버라면 약간 이야기가 다르지만, 웹 서버용으로는 충분합니다.)

이번엔 ubuntu server os를 세팅하는 방법으로 진행합니다. (ubuntu 14.04, 16.04 모두 가능합니다.)

(HTTPS를 쓰려면) 도메인이 있어야 합니다

개인 도메인이 있어야 CloudFlare라는 DNS서비스에 등록을 하고 HTTPS를 이용할 수 있습니다. 도메인이 없거나 HTTPS를 사용하지 않아도 되는 상황이라면 공인 IP만 있어도 무방합니다.

만들어보기

ubuntu 서버와 도메인이 준비되었다면 이제 시작해봅시다!

서버 세팅하기

서버 세팅은 크게 어렵지 않습니다. ssh로 서버에 접속해 아래 명령어를 그대로 입력해보세요.

1
sshd -T | grep -E 'gatewayports|allowtcpforwarding'

위 명령어는 sshd의 gatewayports속성과 allowtcpforwarding속성값을 가져옵니다. 만약 여러분이 ubuntu를 설치하고 아무런 설정을 건드리지 않았다면 다음과 같이 뜰거에요.

1
2
gatewayports no
allowtcpforwarding yes

우리는 저 두개를 모두 yes로 만들어야 합니다. 아래 명령어를 ssh에 그대로 입력해주세요.

1
sudo echo "gatewayports yes\nallowtcpforwarding yes" >> /etc/ssh/sshd_config

물론 /etc/ssh/sshd_config 파일에서 직접 수정해주셔도 됩니다.

유의: 이와같이 사용하면 서버의 모든 유저가 SSH Proxy를 사용할수 있게 됩니다. 이를 막으려면 아래와 같이 Match User 유저이름을 넣고 진행해주세요.

1
2
3
Match User beomi
AllowTcpForwarding yes
GatewayPorts yes

정말 간단하게 서버 설정이 끝났습니다 :)

로컬 8000포트를 원격 80포트로 연결하기

로컬 터미널에서 아래와 같이 명령어를 입력하면 설정이 끝납니다.

1
2
# ssh 원격서버유저이름@서버ip -N -R 서버포트:localhost:로컬포트 
ssh beomi@47.156.24.36 -N -R 80:localhost:8000

위 명령어는 47.156.24.36라는 ip를 가진 서버에 beomi라는 사용자로 ssh접속을 하고, 로컬의 8000번 포트를 원격 서버의 80포트로 연결하는 명령어입니다.

즉, localhost:800047.156.24.36:80와 같아진거죠!

이제 모바일 디바이스에서도 http://47.156.24.36라고 입력하면 개발 서버에 들어올 수 있어요.

CloudFlare로 SSL 붙이기

만약 서버주소를 외우는게 불편하지 않으시고 & HTTPS가 필요하지 않으시다면, 아래부분은 진행하지 않아도 괜찮습니다.

이 챕터에서는 CloudFlare에 도메인을 연결할 때 제공받을 수 있는 SSL서비스를 통해 HTTP로 서빙되는 우리 서비스를 ‘안전한’ HTTPS로 서빙하도록 도와줍니다.

CloudFlare의 Flex SSL을 사용하면 우리 서버가 HTTPS가 아닌 HTTP로 서빙되더라도 클라우드 플레어에서 HTTPS로 만들어줍니다.

사실 이 기능은 보안을 위해서 있는 서비스라고 보기는 어렵습니다. 물론 브라우저/클라이언트와 CloudFlare 간 통신에서는 좀 더 안전한 통신이 가능하지만, 도메인별로 다른 SSL 인증서를 사용하지 않고 여러 도메인을 그룹핑한 인증서를 사용하고 있는 문제가 있고, 결국 CloudFlare와 우리 서버간에는 HTTP로 통신이 이루어지기 때문에 CloudFlare와 우리 서버 사이 Node에서 이루어지는 공격은 막기 어렵습니다. 따라서 이런 경우는 Geolocation와 같은 HTTPS 위에서만 사용할 수 있는 기능등을 테스트 서버를 통해 구동할 경우 유용합니다.

우선 CloudFlare에 가입하고 도메인을 CloudFlare에 등록해주세요.

도메인을 등록하고 DNS 탭에 들어가서 다음과 같이 서브 도메인(혹은 루트 도메인)을 서버 ip에 연결한 후 우측 하단의 구름모양을 켜 주세요. 이 구름모양을 켜 주면 이 도메인으로 온 요청은 CloudFlare의 CDN망을 통해 전달됩니다. (CSS/JS캐싱도 해줍니다!)

도메인을 등록했으면 아래와 같이 Crypto탭에서 SSL을 Flexible로 바꿔주세요.

  • off: 말 그대로 HTTPS를 끕니다.
  • flexible: 우리 서버가 HTTP라도 클라우드플레어로 온 HTTPS요청을 우리서버에 HTTP로 바꿔서 보내줍니다.
  • full: 우리 서버도 HTTPS가 지원되어야 하지만, 꼭 CA에게 인증된 ‘안전한’ 인증서일 필요는 없습니다. 자체서명 인증서라도 괜찮아요.
  • full (strict): 우리 서버가 CA에게 인증된 ‘안전한’ 인증서를 통해 HTTPS로 서빙을 해야만 합니다. 자체서명 인증서는 쓸 수 없어요.

이 설정은 off에서 다른 옵션으로 바꿔주면 약간의 시간이 걸리지만 안전한 SSL 인증서를 CloudFlare에서 만들어줍니다.

proxy 명령어에 연결하기

보통 runserver와 같은 개발 서버를 띄우는 명령은 자주 사용하지만 우리가 사용하는 긴 명령어는 한번에 치기도 어렵고 옵션 기억하기도 귀찮은 경우가 많습니다. 쉘에서 지원하는 alias를 통해 아래와 같이 만들어줍시다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# .zshrc / .bashrc / .bash_profile 와 같이 쉘이 켜질때 실행되는 부분에 넣어주세요

alias proxy="ssh beomi@47.156.24.36 -N -R 80:localhost:8000"
# alias proxy="ssh 원격서버유저이름@서버ip -N -R 서버포트:localhost:로컬포트"
```

이와 같이 입력하고 저장한 후 터미널을 다시 켜주면 이제 `proxy`라는 명령어를 치면 로컬 개발 서버가 HTTPS로 세상에 오픈되는 것을 볼 수 있습니다 :)

## 마치며

ngrok는 아주 간편하고 좋은 서비스입니다. 하지만 모바일과 PC 웹을 동시에 테스트 하는 경우 connection개수를 금방 넘어버리고 ngrok를 새로 실행할 때마다 도메인 이름이 바뀌는점이 불편해 위와 같이 Proxy서버를 만들어 개발하는데 사용합니다.

다만 CloudFlare의 CSS/JS캐싱 전략에 의해 변경된 파일이 가져와지지 않는 점은 있는데, 이때는 Apache등의 웹서버에서 제공하는 virtualhost기능과 let's encrypt의 무료 SSL 서비스를 조합해 사용하면 CloudFlare없이도 동일하게 환경을 만들어 줄 수 있습니다. 하지만 웹서버 자체에 대한 이해가 필요하며 SSL을 붙이는 일도 상당히 귀찮기때문에 단순하게 CloudFlare에서 도에인 모드를 아래와 같이 'Development Mode'로 설정해 주면 캐싱 하는 것을 방지할 수 있습니다.

![](https://d1sr4ybm5bj1wl.cloudfront.net/img/dropbox/Screenshot%202017-08-27%2014.42.36.png?dl=1)

### 여담

django의 경우에는 `settings.py`파일의 `ALLOWED_HOSTS`에 우리가 지정한 도메인 (ex: shop.testi.kr)을 추가해줘야 합니다.

```python
# settings.py

ALLOWED_HOSTS = ['*'] # 모든 Host에서의 접근을 허용
# ALLOWED_HOSTS = ['shop.testi.kr'] # shop.testi.kr 도메인 host를 통한 접근을 허용

webpack의 webpack-dev-server에서 위와같이 사용하려면 webpack.config.js파일을 아래와 같이 만들어주면 됩니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
// webpack.config.js
const path = require('path');

module.exports = {
entry: './src/index.js',
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'bundle.js'
},
devServer: {
host: "0.0.0.0", // 모든 host에서의 접근을 허용
disableHostCheck: true // Host Check를 끕니다
}

Django CBV: queryset vs get_queryset() 삽질기

요약: queryset은 request 발생시 한번만 쿼리셋이 동작하고, get_queryset()은 매 request마다 쿼리를 발생시킨다. 조건이 걸린 쿼리셋을 쓸때는 get_queryset()을 오버라이딩하자.

사건의 발단

ListView안에서 체크박스로 ForeignKey로 연결된 장고 모델 인스턴스를 저장(.save()를 호출)하는데 저장 후 모델 인스턴스의 값을 확인하는 뷰에서는 결과값이 저장 전의 데이터로 나타났었다.

1
2
3
4
5
6
7
8
9
# 문제의 코드..
class OrderMatchingList(ListView):
class Meta:
model = Order

queryset_list = Order.objects.filter(status__gte=5) \
.select_related('education', 'region') \
.prefetch_related('orderdetail_set')
queryset = sorted(queryset_list, key=lambda x: x.start_date())

사실 지금은 코드를 보면 queryset에서 sorted된 값을 반환하고, 이경우에는 쿼리셋 자체가 저 변수로 할당되어버려 다음 request에서 쿼리가 돌지 않는다는 것을 쉽게 찾을 수 있다. 하지만 원래 한번 안보이면 잘 안보이는 법.. 심지어 이 경우에는 Exception이 나는 것도 아니기 때문에 더 찾기 어려웠다.. (ㅠㅠ)

삽질의 시작

여러가지 가정을 할 수 있는 상황이었다.

  • 혹시 브라우저가 리스트를 캐싱하고 있던건 아닐까? (브라우저 캐시)
  • 장고가 View의 Response를 캐싱하고 있는걸까? (장고 캐시)
  • 혹시 DB에 save()가 안된(아예 DB가 업데이트가 되지 않은) 것은 아닐까?
  • 장고 queryset에 캐싱이 되어있었을까?
  • AJAX call이 비정상적으로 이루어진 것은 아닐까?
  • 아니면, 아예 내 View 로직이 잘못된 것은 아닐까? (CBV인데?)
  • select_relatedprefetch_related에서 캐싱이 발생하는걸까?

이런저런 가정을 하고 하나씩 체크를 해보기로 했다.

아래부분에서는 django 로직과 관련된 삽질만 다뤘습니다. JS쪽은 문제가 없었거든요.

widgets:

첫번째 삽질: “브라우저가 캐싱을 하고 있는건 아닐까?”

만약 브라우저가 HTML파일을 캐싱하고 있다면

  • 캐시 삭제후 강력 새로고침을 하거나,
  • 다른 브라우저로 접근하면

정상적인 화면이 나와야 했다.

그러나… “#망했어요”

브라우저가 캐싱하고 있는게 아니었고, 다른 브라우저에서도 기존(업데이트 전)값을 가져왔다.

widgets:

두번째 삽질: “장고가 template 렌더링 된것을 캐싱하는게 아닐까?”

사실 장고에서 response는 따로 캐싱을 명시적으로 하지 않으면 쿼리가 새로 발생해야 하는 경우에는 캐싱을 하지 않는다.

하지만 일단 template을 재 렌더링 하지 않는게 아닐까… 하는 생각에 아래와 같은 부분을 추가해 보았다.

1
2
3
{% raw %}{% for object in object_list %}
{{ object }} 이건 object다
{% endfor %}{% endraw %}

역시 .. “응 아니야~ 장고 일 잘하고 있음”

템플릿은 렌더링이 충분히 잘 되고 있었다.

뭐가 문제일까?

widgets:

세번째 삽질: “.save() 메소드의 사용을 잘못한게 아닐까?”

아예 다음번에는 DB에 저장이 되지 않고 있는게 아닌가.. 하는 생각에 save()update()의 사용법을 찾고, force_insert=True와 같은 옵션을 넣어보기도 했다.

1
2
3
4
5
6
7
8
9
# view.py 파일에서...
# ...
for m_pay in mentor_payment_list:
if str(m_pay.pk) in cleaned_keys:
m_pay.status = 1
else:
m_pay.status = 0
m_pay.save(update_fields=['status'])
# ...

.save()는 모델 인스턴스에 적용하는 케이스이고, .update()는 쿼리셋에 적용하는 방법이다. save()의 경우 모델 인스턴스를 가져오기 위해 SELECT 쿼리를 한번 날리고 값을 변경 후 UPDATE를 해주는 방법이라면, update()는 쿼리 자체를 SELECT쿼리로 날리는 방식이다. 따라서 만약의 경우 .update()를 실행 중 다른 요청에서 값이 변경되었다면 그 Transaction이 손실될 수 있고, 모델 인스턴스의 값 자체를 이용해 업데이트하는 방법은 사용하기 어렵다. (물론 사용은 가능하지만 SELECT쿼리같이 .get()으로 한번 가져와야 하기때문에 큰 의미는 없습니다. 여전히 중간에 값이 변경되었을 경우에 기존 값(get)에 대한 불가능하고요.)

m_pay.save(update_fields=['status'])에서는 save()update_fields 리스트를 넣어주었다. 일반적인 save()함수가 인스턴스 전체를 변경하는 UPDATE문을 사용하지만 update_fields가 있는 경우에는 force_insert가 자동으로 True가 되며 동시에 해당되는 Column만 update가 일어난다.

게다가 update_fields를 넣기 전에도 이미 잘 작동하던 코드.

무엇이 문제일까? 문제는 미궁속으로..

widgets:

네번째 삽질: “select_relatedprefetch_related에서 캐싱이 발생하는건 아닐까?”

장고에서 select_relatedprefetch_related는 기본적으로 한번에 데이터를 가져와 queryset 자체에 캐싱을 하는 전략인데.. 혹시 여기에서 ‘과도한 캐싱’이 발생하고 있는건 아닐까?

그렇다면 장고의 캐싱을 강제로 없애는 never_cache 데코레이터를 사용하면 어떨까? 하지만 지금 뷰는 CBV니까.. @method_decoratornever_cache를 전달해 주면 되겠다!

1
2
3
4
5
from django.views.decorators.cache import never_cache

@method_decorator(never_cache, name='dispatch')
class OrderMatchingList(SuperuserRequiredMixin, LoginRequiredMixin, ListView):
# ...

물론, 당연히, 캐시 문제가 아니었기 때문에 안되는 것은 당연했다.

widgets:

다섯번째 삽질, 여섯번째, 일곱 … 그리고 더 많은 삽질 끝에서의 허무

도대체 뭐가 문제인거지? ListView가 아예 문제인가? 이런 고민을 하다가 결국 django의 ListView자체를 뜯어보는데 눈에 들어오는 MultipleObjectMixin.

1
2
3
4
5
6
7
class MultipleObjectMixin(ContextMixin):
# ...
queryset = None
# ...

def get_queryset(self):
# ...

헐. querysetget_queryset은 다른데.

widgets:

해결 & 평화

사실 이 문제가 생긴건 DB에서 정렬하는 대신 파이썬 View에서 쿼리셋을 정렬하는 방식으로 사용하려다보니 생긴 문제였다.

모델 내부의 start_date()에 따라 정렬하는 방식을 쿼리셋 내부에서 구현이 어려워 파이썬의 sorted를 이용했는데, 이 sorted된 결과물이 queryset 변수에 담겨 새 request에도 같은 결과를 반환하게 된 것.

따라서 다음과 같이 get_queryset으로 변환해주어서 깔끔하게 해결되었다.

1
2
3
4
5
6
7
8
9
class OrderMatchingList(ListView):
class Meta:
model = Order

def get_queryset(self):
queryset_list = Order.objects.filter(status__gte=5) \
.select_related('education', 'region') \
.prefetch_related('orderdetail_set')
return sorted(queryset_list, key=lambda x: x.start_date())

사실 DJDT(Django Debug Toolbar)를 사용하며 쿼리의 개수를 확인해보는데 첫 요청시에는 6개의 쿼리가 가는데 비해 두번째 요청부터는 3개의 쿼리만이 실행되고, 그마저도 데이터를 가져오는 쿼리는 없고 세션/로그인등의 비교만 쿼리를 실행하고 있다는 것을 발견해 쿼리셋쪽의 문제라는 것을 알 수 있었다.

여담

문제의 코드 부분(아래)에서 select_relatedprefetch_related를 제거하면 쿼리수는 몇십개로 증가하지만 데이터 자체는 정상적으로 가져왔다. 이건 또 왜그랬을까?

1
2
3
4
5
6
7
8
9
# 문제의 코드..
class OrderMatchingList(ListView):
class Meta:
model = Order

queryset_list = Order.objects.filter(status__gte=5) \
.select_related('education', 'region') \
.prefetch_related('orderdetail_set')
queryset = sorted(queryset_list, key=lambda x: x.start_date())

Autoenv로 편리한 개발하기

이번 가이드는 macOS를 대상으로 합니다.

프로젝트를 여러가지를 동시에 진행하고 프로젝트에서 사용하는 개발환경이 다양해지다 보니 사용하게 되는 도구들이 많습니다.

Python에서는 virtualenv, pyenv등이 대표적이고 Node.js에서는 nvm이나 n등이 대표적인 사례입니다.

즉 시스템에 전역으로 설치되어있는 것과 다른 버전 혹은 다른 패키지들이 설치된 가상환경에서 개발을 진행해 각 프로젝트별로 다른 환경에서 개발을 진행합니다.

하지만 이러한 도구들을 사용하기 위해서는 프로젝트를 실행하기 전 특별한 명령어들(ex: workon venv_name등)을 사용해야 합니다.

Autoenv는 이러한 명령어들을 각 프로젝트 폴더 진입시 자동으로 실행할 수 있도록 도와줍니다.

Autoenv가 작동하는 방법

Autoenv는 시스템의 cd명령어를 바꿔, 폴더 안에 진입한 후 폴더 안에 .env파일이 있는지를 탐색하고 만약 .env파일이 있으면 그 파일을 한줄한줄 사람이 터미널에 치듯 실행새줍니다.

예를 들어, hello라는 폴더 안의 .env에 아래와 같이 되어있다고 가정해 봅시다.

1
2
# .env파일
echo "Hello World!"

이후 이 hello폴더에 진입할 때마다 Hello World!가 출력됩니다.

1
2
3
~ $ cd hello
Hello World!
~/hello $

이처럼 여러가지 방법으로 이용할 수 있습니다.

Autoenv 설치하기

Autoenv는 다음 두 절차를 통해 쉽게 설치할 수 있습니다.

우선 brew로 설치해 줍시다. (HomeBrew는 brew.sh에서 설치할 수 있습니다.)

1
brew install autoenv

다음으로는 autoenv 실행 스크립트를 .zshrc.bash_profile 파일의 끝부분에 적어줍시다.

1
2
# .zshrc 나 .bash_profile 의 파일 가장 끝
source /usr/local/opt/autoenv/activate.sh

만약 아직 ZSH을 설치하지 않았다면 멋진 Terminal 만들기을 읽어보세요!

유의사항

.env파일 설정 후 첫 폴더 진입시 .env파일을 신뢰하고 실행할지 않을 지에 대한 동의가 나타납니다. 이 부분은 .env파일이 악의적으로 변경되었을때 사용자에게 알리기 위해서 있기 때문에 즐거운 마음으로 Y를 눌러줍시다.

SSH키파일 등록하기

SSH키파일을 .bash_profile등에 등록해 터미널이 켜질때마다 불러오는 방법도 있지만, 그 대신 ssh-add명령어를 통해 직접 현재 터미널에만 제한적으로 불러오는 방법이 있습니다.

만약 ~/.ssh폴더 안에 my_key_file.pem이라는 키 파일들이 있다면 아래와 같이 .env를 구성할 수 있습니다.

1
2
# .env파일
ssh-add ~/.ssh/my_key_file.pem

이와 같이 구성하면 폴더에 진입시마다 아래와 같이 키 파일이 등록된다는 것을 확인할 수 있습니다.

1
2
3
~ $ cd project
Identity added: /Users/beomi/.ssh/my_key_file.pem (/Users/beomi/.ssh/my_key_file.pem)
~/project $

Python 가상환경 관리하기

venv를 사용할 경우

파이썬 3.4이후부터 내장된 venv를 이용한 경우 다음과 같이 .env를 구성할 수 있습니다.

1
2
# .env파일
source ./가상환경폴더이름/bin/activate

virtualenv를 이용할 경우

venv와 동일합니다. 아래와 같이 .env를 구성해 주세요.

1
2
# .env파일
source ./가상환경폴더이름/bin/activate

virtualenv-wrapper를 이용중인 경우

workon명령어를 그대로 사용할 수 있습니다. 아래와 같이 .env를 설정해 주세요. (저는 이 방법을 사용하고 있습니다.)

1
2
# .env파일
workon 가상환경이름

Pyenv를 이용중인 경우

pyenv에서는 local이라는 명령어를 통해 기본적으로 폴더별 Python 버전을 관리해 줍니다. 따라서 .env를 통해 Global설정을 하는 경우를 제외하면 사용하지 않는 것을 추천합니다.

Node.js 개발환경 관리하기

n을 사용할 경우

node버전을 관리해 주는 nsudo권한을 필요로 합니다. 시스템 전역에서 사용하는 node의 버전을 변경하기 때문입니다. 그래서 패스워드를 입력해 주는 과정이 필요할 수 있습니다. .env파일을 아래와 같이 만들어 주세요.

1
sudo n latest # 버전은 사용 환경에 맞게 입력해 주세요.

마무리

사실 Python을 주력 언어로 사용하다 보니 다른 언어들에 대해 언급은 적은 측면이 있습니다. 하지만 Autoenv 자체가 굉장히 심플한 스크립트로 이루어져 있기 때문에 필요에 맞춰 바꾸어 사용하는 것도 방법중 하나라고 생각합니다.

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×