매일 자동으로 DB 파일을 백업하기 위해 backupdb.sh이란 쉘 스크립트를 작성하고, 이를 매일 실행함과 동시에 로깅까지 처리하도록 다음과 같이 크론탭에 스크립트를 작성했다.

 

0 4 * * * source /home/user/backupdb.sh >> /home/user/db_backupdb.log 2>&1

 매일 오전 4시에 backupdb.sh를 실행하고, 이를 db_backupdb.log에 로깅한다는 간단한 내용이다.

 

 

 이렇게 작성하고 자고 일어나보니 아무 일도 없었다... 해결하면서 여러 가지 문제들을 겪었는데, 여기서 겪었던 크론탭을 사용하면서 겪었던 관련 문제점들과 해결책을 소개한다. 

 

 

0. 크론탭 로그 확인

 일단 당연히 문제가 일어났으면 기본적으로 로그를 확인해야 한다.  ubuntu 18.04 기준으로 크론 관련 로깅은 기본적으로 /var/log/syslog에 기록되니 여기를 뒤져보면 되는데, 문제는 여긴 시스템 관련 로깅들이 다 같이 작성되는 곳이다 보니 AWS EC2에선 워낙 별별 로그가 많다. 그래서 아래의 명령어로 뒤져보면 크론 관련 메세지만 나와서 보기 편하다. 여기서부터 원인을 찾기 쉬울 것이다.

 

cat /var/log/syslog | grep CRON

 

 

 

 

1. (CRON) info (No MTA installed, discarding output)

 여기서 말하는 MTA는 Mail Transfer Agent로 메일 에이전트이다. 메일 에이전트가 없다니까 그냥 메일 에이전트를 깔면 해결되고, sudo apt-get install postfix 한 방이면 이 오류는 사라질 것이다. 문제는 이 오류가 왜 생기냐는 건데, 바로 내가 추가했던 로그 파일 때문이다.

 

 크론 데몬의 로깅은 다른 데몬들처럼 로그 파일을 /var/log 이런데 작성하는 게 아니라, 메일 에이전트를 통해서 해당 크론탭을 소유한 각 유저에게 로그 내용이 전송된다. 나는 이 내용을 파일에 기록하도록 했으니 그 내용이 파일로 작성되어서, 다른 로깅들처럼 파일로 작성되는 것처럼 보이는 것이다. 왜 이런 식으로 처리되냐면, 리눅스는 여러 명의 유저들이 동시에 사용하는 것을 전제로 계정 시스템이 구현되어 있고, 크론탭 역시 각 유저별로 따로 작성되기 때문에 각 유저별로 따로 로그를 관리할 필요가 있기 때문이다. 만약 /var/log 아래에 로그가 한꺼번에 기록된다면, 루트 계정의 크론 로그는 일반 계정이 보면 안되니 읽기 권한에 제한을 줘야 하는 일이 발생한다. 그러면 당연히 일반 유저들은 자신의 로그를 확인할 수 없는 모순이 생긴다. 

 

 위 내용은 여기를 참고하였다. 

 

 

 

 

 

2. /bin/sh: 1: source: not found

 일단 이 에러는 다른 의미로 삽질을 많이 하게 만들었다. 그 이유인즉슨,

 

 로그 파일을 열면 이렇게 뜨기 때문이다. 나는 cat 명령어로 이 파일을 여는데 문제가 생긴 줄 알았다. 그래서 혹시 로그 생성중에 뭔가 문제가 생겨서 깨진 파일이 생성됐나 확인해 보려고 크론 관련 로그를 뒤지고, 기본 쉘이 /bin/bash에서 /bin/sh로 바뀌었나 점검해 봤는데 아니었고 그냥 로그 파일이 저래 나온 거였다. vim db_backupdb.log로 열어보면 정상적으로 열리고 내용도 저거 한줄이다. ㅡㅡ;

 

 

  아무튼 이 오류는 왜 생기냐면 쉘 스크립트를 실행하기 위해 쓴 명령어 source가 문제였다. 크론탭에서 사용하는 기본 쉘이 /bin/sh인데, 이 쉘은 /bin/bash와 달리 source 명령어를 지원하지 않는다. 그래서 source 명령어를 찾지 못해서 스크립트를 실행조차 못하고 끝났던 것이다. 그래서 1. 크론탭에서 사용하는 쉘을 바꾸거나 2. /bin/sh에서 사용할 수 있는 명령어를 사용하면 해결된다. 즉, 아래의 두 가지 방법중 하나만 하면 된다.

 

(1) 사용하는 쉘 바꾸기

 

 크론탭에 한 줄만 추가하면 된다.

SHELL=/bin/bash
0 4 * * * source /home/user/backupdb.sh >> /home/user/db_backupdb.log 2>&1

 

(2) 명령어 바꾸기

 

 source  명령어를 . 으로 바꾸면 된다.

0 4 * * * . /home/user/backupdb.sh >> /home/user/db_backupdb.log 2>&1

 

   파이썬에서 패키지 의존성을 공유할 때 가장 범용적으로 사용되는 게 requirements.txt일 것이다. 현재 파이썬 환경에서 설치된 패키지들을 정리할땐 아래의 명령어를 사용한다.

pip freeze > requirements.txt

  이 명령어를 통해 설치된 패키지들이 requirements.txt에 나열된다. 이 파일을 이용하여 패키지들을 설치하고자 할 때는, 다음과 같은 명령어를 사용한다.

pip install -r requirements.txt

 

 

  보통은 이 두가지 명령어 정도면 프로젝트를 관리하는 데는 지장이 없을 것이다. 하지만, 상황별로 패키지를 다르게 관리해야 할 때도 있을 것이다. 나의 경우엔, 윈도우즈 PC에서 개발을 하고 우분투 기반의 서버에서 배포를 하는데, 서버에서 위 명령어를 이용하여 패키지들을 설치할 때 오류가 생겼었다. 그래서 오류가 나는 패키지를 찾아봤는데, pywin32라는 라이브러리였고, 이 라이브러리는 파이썬에서 윈도우즈 API들을 사용할 때 쓰는 라이브러리라 당연히 에러가 날 수 밖에 없었다. 그래서, 상황에 의존성을 따로 관리해야 할 필요성을 느꼈다.

 

 

 

1. 무식한 방법

 

2개의 requirements.txt를 사용한다.

# requirements-develop.txt
Django==3.0.8
django-import-export==2.3.0
django-sass-processor==0.8
django-suit==2.0a1
et-xmlfile==1.0.1
Flask==1.1.2
Flask-BasicAuth==0.2.0
pycparser==2.20
pytz==2020.1
pywin32==300 - 문제가 되는 라이브러리

# requirements-production.txt
Django==3.0.8
django-import-export==2.3.0
django-sass-processor==0.8
django-suit==2.0a1
et-xmlfile==1.0.1
Flask==1.1.2
Flask-BasicAuth==0.2.0
pycparser==2.20
pytz==2020.1

  물론 일회성이고, 문제가 되는 패키지가 한두개라면 그냥 복붙으로 파일을 새로 만들고 위처럼 해도 된다. 하지만 관리해야 할 패키지가 수십 개라면 저 방법은 너무 귀찮고 헷갈릴 것이다. 그래서 조금 더 우아한 방법이 있다.

 

 

 

 

2. 조건에 따른 패키지 설치

 

# requirements.txt
Django==3.0.8
django-import-export==2.3.0
django-sass-processor==0.8
django-suit==2.0a1
et-xmlfile==1.0.1
Flask==1.1.2
Flask-BasicAuth==0.2.0
pycparser==2.20
pytz==2020.1
pywin32==300; sys_platform == 'win32' - 문제가 되는 라이브러리

  조건을 붙이길 원하는 패키지의 뒤에 ;를 붙이고, 조건을 넣는다. 나는 윈도우즈 플랫폼에서만 해당 패키지를 설치하길 원해서 윈도우즈 플랫폼에서만 해당 라이브러리를 설치하도록 설정하였다. 만약 리눅스에서만 설치하기 원하는 패키지라면 sys_platform == 'linux' (python2는 linux2)로 설정하면 된다.

 

  위 내용에 대한 정보는 여기를 참조하면 된다. 해당 문서에서 소개하는 주요 마커들은 다음과 같다.

  예를 들어, 파이썬의 버젼(2.7, 3.5, 3.6, ...)에 따라서도 다르게 설정하길 원한다면 python_version == "원하는 버젼"을 붙이면 될 것이다.

 

 

 

 

3. 파일을 아예 분리하기

 

  위에 제시된 마커들로는 구분할 수 없는 정보가 있거나, 분리해야 하는 패키지가 너무 많다면 아예 파일을 나눠버릴 수 있다.

# requirements.txt
-- 공통적으로 필요한 패키지들 --
Django==3.0.8
django-import-export==2.3.0
django-sass-processor==0.8
django-suit==2.0a1
et-xmlfile==1.0.1
Flask==1.1.2
Flask-BasicAuth==0.2.0
pycparser==2.20
pytz==2020.1
.
.
.

--------------------------------------------------

# requirements-develop.txt
-r requirements.txt
pywin32==300
-- 개발에만 필요하고 배포엔 필요없는 패키지들 --
.
.
.

---------------------------------------------------

# requirements-production.txt
-r requirements.txt
-- 배포에만 필요하고 개발엔 필요없는 패키지들 --
.
.
.

 

  이런 식으로, 다른 파일을 읽어오도록 설정할 수 있다. 예를 들어, 배포 환경에선 `pip install -r requirements-production.txt` 명령어를 실행한다. 그러면 우선 requirements.txt를 읽어와서 그 안의 패키지를 설치하고, 그 다음엔 requirements-production.txt 에 적혀 있는 라이브러리들을 설치하게 된다. gunicorn, uwsgi와 같은 배포에만 필요한 라이브러리들이 여기에 들어가면 될 것이다.

 

 

  관리해야 하는 패키지들이 간단하다면 역시 파일 하나로 모든 게 가능한 2번 방법, 조금 구조가 복잡하다면 3번 방법이 가장 적절할 것이다. 자세한 정보는 여기서 확인할 수 있다.

  Mocha와 Should.js를 이용해 테스트 코드를 작성하던 중, 콜백 함수 내에선 assert 기능이 정상적으로 작동하지 않는 것을 발견했다. 처음엔 Should.js 자체의 문제인 줄 알고 assert 라이브러리로 바꿔서 시도했지만 똑같은 현상이 발생했다. 아래와 같이 명백히 오류가 나야 하는 부분도 문제 없이 통과가 되었다.

 

const tempAndHum = require('tempAndHum')

it('현재 온습도 출력 테스트', () => {
    tempAndHum.getNowTah((data) => {
        (0).should.be.equal(1)
    })
})

 

 찾아본 결과, mocha에서 비동기 함수 내에서 테스트를 진행할 때는 done을 이용하여, mocha가 해당 테스트 함수가 모두 실행될 때까지 기다리도록 해야 한다. 그래서 다음과 같이 바꿨다. 여기까지는 공식 문서에도 설명이 잘 되어 있는 부분이다.

it('현재 온습도 출력 테스트', (done) => {
    tempAndHum.getNowTah((data) => {
        (0).should.be.equal(1)
        done()
    })
})

 

 

 이렇게 하고 테스트를 진행하니, 이제는 다음과 같이 엉뚱한 부분에서 에러가 떴다.

Error: Timeout of 2000ms exceeded. For async tests and hooks, ensure "done()" is called; if returning a Promise, ensure it resolves.

   

  분명 나는 done()을 넣어 놨는데, 테스트 모듈에선 못 찾고 있었다. 그래서 다시 찾아본 결과, (0).should.be.equal(1)의 assert 문에서 이미 Error가 던져져서 코드 실행이 중단되고, 따라서 done()까지 코드가 가지 못하고 있는 상태였다. Promise를 이용한 비동기 함수라면 .catch로 해결하면 될 것이고, 콜백 함수를 이용한 비동기 문일 경우 다음과 같이 try ~ catch 문으로 테스트 함수를 감싸면 된다. 최종적으로 아래와 같다.

 

it('현재 온습도 출력 테스트', (done) => {
    tempAndHum.getNowTah((data) => { // 테스트를 원하는 함수
        try {
            // 원하는 테스트 코드 입력
            done()
        }
        catch (err){
            done(err)
        }
    })
})

 

 

 

 Express.js를 이용하여 개발을 하면서 ORM 라이브러리로 Sequelize를 사용하고 있는데, 내 예상과 다르게 작동하는 부분이 많아서 조금 애를 먹고 있었다. 그 중에서 특히 내가 가장 삽질했던 부분을 공유하고자 한다.

 

 

Sequelize 모델 불러오기

 우선 가장 먼저 ORM을 이용하려면 내 코드에 해당 모델을 불러와야 하는데, 대부분의 문서나 글에선 이미 모델을 불러온 걸 전제로 CRUD를 설명하는데, 이 모델을 가져오는 부분에서부터 막혔었다.

 

 

 

 Sequelize-cli를 이용하여 다음 명령어를 이용하여 기본적인 파일을 생성했다.

 

sequelize init

 

 이렇게 하면, models/index.js에서 실제 DB와 시퀄라이저가 연결된다. 그런데, index.js에는 모델을 불러오는 형식이 이미 지정되어 있고, 따라서 각 모델 파일들의 형식도 다음과 같이 지정되어 있다. 물론, 이러한 형태에서는 require(user.js) 형태로 불러와도 당연히 해당 모델을 쓸 수 없다. 그렇다고 다시 싹 다 Sequelize 모듈을 임포트 하기엔 지저분하고 비효율적이다. 분명 이렇게 쓰라고 만든 기능이 아닐 것이다...

// models/user.js

module.exports = function (sequelize, DataTypes) {
    return sequelize.define(
        // ...
    )
}

 

 

 

 

 여기서, models/index.js 파일을 다시 보면 재미있는 사실을 찾을수 있다.

 

'use strict';

const fs = require('fs');
const path = require('path');
const Sequelize = require('sequelize');
const basename = path.basename(__filename);
const env = process.env.NODE_ENV || 'development';
const config = require(__dirname + '/../config/config.json')[env];
const db = {};

// DBMS와 연결
let sequelize;
if (config.use_env_variable) {
  sequelize = new Sequelize(process.env[config.use_env_variable], config);
} else {
  sequelize = new Sequelize(config.database, config.username, config.password, config);
}


// 각 모델을 불러옴.
fs
  .readdirSync(__dirname)
  .filter(file => {
    return (file.indexOf('.') !== 0) && (file !== basename) && (file.slice(-3) === '.js');
  })
  .forEach(file => {
    const model = require(path.join(__dirname, file))(sequelize, Sequelize.DataTypes);
    db[model.name] = model;
  });

Object.keys(db).forEach(modelName => {
  if (db[modelName].associate) {
    db[modelName].associate(db);
  }
});

db.sequelize = sequelize;
db.Sequelize = Sequelize;

module.exports = db;

 "각 모델을 불러옴"이라고 주석을 넣은 부분의 코드를 읽어 보면, 현재 index.js가 있는 폴더 내의 파일을 모두 읽고(자기 자신 제외), 이를 전부 연동(associate)하여 db라는 객체 안에 넣는다. 그리고 이 db를 익스포트한다. 그렇다면, 당연히 익스포트된 db 객체만 가져와도 DB의 모든 기능을 이용할 수 있을 것이다.

 

 

 

 따라서, 다른 파일에서 모델을 사용한다면...

const db = require('../models')
const tah = db["TempAndHum"] // 내가 만든 DB 이름

 

 이런 식으로 DB 이름을 이용하여 불러올 수 있다. models/index.js 안에서 DBMS 연결과 모델 연결을 모두 해 주기 때문에, 다른 데에선 그냥 DB 이름만 갖고 불러오면 된다. DB 이름은, 

module.exports = function (sequelize, DataTypes) {
    return sequelize.define(
        "TempAndHum", {
        id: { ...
        

 이렇게 각 모델 파일에서 정의한 이름을 따른다.

 

 

서브쿼리를 이용하여 조건을 걸고 DELETE 사용하기

 

  나는 특정 테이블을 생성 시간순으로 정렬하여, 거기서 특정 몇 개의 항만 삭제하는 함수를 구현하고자 했다. 일반적인 SQL 문에서는 서브 쿼리를 이용하여 구현할 수 있는데, 시퀄라이저에서는 서브쿼리를 다루는 방법이 좀 복잡하고 직관적이지 않았다. 그래서 그냥 직접 SQL 쿼리를 박아넣었다.

 

const db = require('../models')
const tah = db["TempAndHum"]

tah.destroy({
        where: {
            id: [Sequelize.literal(`SELECT * FROM (SELECT id FROM tempandhum ORDER BY createdAt DESC LIMIT 3) AS tmp`)]
        }
    })

 

 Sequelize.literal 함수를 통해 썡 SQL 쿼리를 넣었고, 내가 원하는 조건의 id들을 추출한 뒤 where 조건문에 넣어 조건에 따른 삭제를 수행한다. 쿼리를 SELECT * FROM ~~ AS tmp와 같이 래핑했는데, mariaDB에서는 서브쿼리에 limit가 적용이 안되서 위와 같이 편법으로 우회했다. 위 쿼리가 실제도 수행될 땐 다음과 같이 수행된다.

 

DELETE FROM `TempAndHum` WHERE `id` IN 
(SELECT * FROM (SELECT id FROM tempandhum ORDER BY createdAt DESC LIMIT 3) AS tmp)

 

'프로그래밍 > Node.js' 카테고리의 다른 글

Mocha에서 콜백 함수 테스트하기  (0) 2021.01.28

  Django에서 아임포트를 이용한 결제 시스템을 만들던 중, 문제가 있었다. 결제를 완료한 후, 결제 정보를 POST method를 통해 서버로 보내야 하는데, 서버로 보내도 항상 받은 데이터가 empty로 표시가 되었다. 처음에는 프론트엔드 쪽에서 문제가 있는줄 알았는데, 알고 보니 Django의 view에서 POST를 받을 때 모든 데이터를 받을 수 있는 게 아니었다.

 

  아래는 장고 공식 문서에서 Request.POST의 설명 중 일부이다.

https://docs.djangoproject.com/en/3.1/ref/request-response/#django.http.HttpRequest.POST

 

A dictionary-like object containing all given HTTP POST parameters, providing that the request contains form data. See the QueryDict documentation below. If you need to access raw or non-form data posted in the request, access this through the HttpRequest.body attribute instead.

 

Request and response objects | Django documentation | Django

Django The web framework for perfectionists with deadlines. Overview Download Documentation News Community Code Issues About ♥ Donate

docs.djangoproject.com

 

  쉽게 말해, POST를 통해 form 데이터 이외의 데이터들은 request.POST.('요소') 와 같은 방식으로 받을 수 없고 request.body와 같이 바이트 스트림으로 직접 받아와서 처리해야 한다. 다행히, 생각보다 어렵진 않았다.

 


 

  나는 딱히 이미지와 같은 바이너리 형식의 데이터를 전송하는 게 아니므로, json 형태로 데이터를 주고받기로 약속했다. 보내는 쪽에서 json 형태로 보내도록 아래와 같이 코딩하자.

 

var paymentdata = {
    // 서버로 보낼 데이터(dictionary 형태)
}

jQuery.ajax({
      url: "{% url 'destination' %}",
      type: "POST",
      dataType: "json",
      data: JSON.stringify(paymentdata)
}).done(function (data) {
      alert("결제가 성공적으로 진행되었습니다.");
}).fail(function (data) {
      alert("결제중 서버와의 통신에 문제가 발생하였습니다.\n원인: " + data.reason);
})

 

 보내는 코드는 데이터만 json으로 확실하게 보낸다면 어떤 형태이든 상관없다.

받는 view 부분 역시 이 두줄이면 충분하다.

    import json
    
    # 생략
    
    if request.method == 'POST':
        payment_data = json.loads(request.body)

 

json으로 보내고 json으로 받는 점만 기억하면 큰 문제가 없을 것이다.

  위와 같은 스트링 데이터 이외의 이미지 등의 바이너리 데이터들을 주고받을 때에도 똑같은 방식으로 request.body를 이용해 처리하면 큰 문제 없을 것이다.

 


 

  추가로, 장고 기본 form을 보면 항상 {% csrf_token %} 이 있었던 걸 기억할 것이다.

아마 위 상태 그대로 form을 보낸다면 csrf 때문에 에러가 날 것이다.

이건 말 그대로 프론트엔드에서 보낼때 해당 토큰을 추가하면 되고,

 

jinmay.github.io/2019/04/09/django/django-ajax-csrf/

 

Django에서 ajax post 요청시 csrf token 문제 해결하기

Django에서 ajax 사용할때 발생하는 csrf 문제장고에서 ajax를 조금씩 섞어서 사용하다보면 POST 요청을 보낼때 문제가 발생하게 된다. POST 요청에서는 보안상의 문제로 csrf token을 필요로하게 되는게 �

jinmay.github.io

  이 블로그에 코드를 비롯하여 해결책이 명확하게 나와 있으니 참고하면 되겠다.

참고로 1번은 저 블로그 주인장분도 설명하셨지만 보안상으로 매우 안좋은 방법이니까 2번 방법을 강력히 권장한다.

문 열기 귀찮다고 현관문을 없애버리는 사람은 없을 것이다.

 

 

 

 

 

 django에는 기본적으로 email 발송 기능이 내장되어 있다. 그런데, 인터넷에 관련 예제를 찾아 보면 전부 gmail 발송이고, daum 메일(hanmail.net / daum.net) 발송에 대한 예제는 거의 없어서 정리하게 되었다.

 

 이미 gmail로 발송을 해 봤다면 기본적인 맥락은 거의 유사하다.

 

 

 

1. Daum 메일 설정

 

daum 메일 -> 환경설정에 들어가자.

 

 

 웹서버를 통한 메일 발송은 SMTP라는 프로토콜을 사용하게 되는데, 그러려면 다음과 같이 사용 설정을 해야 한다. 기본값으로 '사용 안 함'으로 되어있으니, 꼭 잊지 말고 바꿔야 한다.

 

 

 

 

2. django의 settings.py 변경

 

EMAIL_HOST = 'smtp.daum.net' 		 # 메일 호스트 서버
EMAIL_PORT = 465 			 # 서버 포트
EMAIL_HOST_USER =  '사용할 이메일'
EMAIL_HOST_PASSWORD = '이메일 비밀번호'
EMAIL_USE_SSL = True
DEFAULT_FROM_EMAIL = EMAIL_HOST_USER	 # 기본 발신자
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'

 

 

SMTP 설정 페이지 바로 밑에 나온 안내문이다. 이대로 설정하되, 이미 gmail로 구현해 본 적이 있다면 다른 점이 몇개 있다.

 

  • Port : gmail은 587 포트를 사용하는데 여기서는 465 포트를 사용해야 한다.
  • EMAIL_USE_SSL : 내가 이 글을 쓰게 된 이유이다. 처음에 gmail을 쓰다가 다음 메일로 교체했는데, 어떤 오류도 없이 그냥 이메일 발송 페이지에서 웹서버가 멈춰 버리는 일이 있었다. 값을 쭉 점검해본 결과, 이 설정값에 문제가 있었다. 이전엔 EMAIL_USE_TLS = True 를 설정값으로 이용했다. TLS와 SSL은 유사하지만 엄연히 다른 프로토콜이었고, 이 값을 EMAIL_USE_SSL로 바꾸니까 정상적으로 작동되었다. 참고로, EMAIL_USE_SSL 과 EMAIL_USE_TLS를 동시에 넣으면 하나만 쓰라고 에러가 난다. 이 부분 반드시 체크하자.

 

 발송 과정은 django.core.mail을 활용하면 되고 워낙 예제가 많아서 생략한다.

  Django에서 기본적으로 제공해주는 로그인 관련 뷰와 템플릿은 매우 편하다. 대략적으론 다음과 같이 사용할 수 있다.

 

yourapp/urls.py

from django.contrib.auth import views as auth_view

urlpatterns = [
    path('login/',
    auth_view.LoginView.as_view(template_name='registration/login.html'), name='login')
    ]

 

templates/registration/login.html

        <div class="alert alert-info">로그인</div>
        <form action="" method="post">
            {% csrf_token %}
            {{form.as_p}}
            <input class="btn btn-primary" type="submit" value="Login">
        </form>

 

 

   그런데, 이 상태에선 기능적으로는 문제가 없지만, 사용자 경험면에서는 문제가 좀 있다.

 

  위의 코드로 만들어진 로그인 화면이고, 위의 Please ~ 문구는 비밀번호를 틀리면 나오는 문구이다.

나는 템플릿엔 어떠한 텍스트도 넣지 않았으니 아마 form에서 자체적으로 산출된 텍스트들일 것으로 추정할 수 있다.

 

  이 글을 보는 사람들은 거의 개발자일 것이니 이 정도 영어는 신경쓰이지 않을 수도 있겠지만,

일반 사용자들은 그렇지 않을 것이다. 그래서, 이 영어 범벅 폼을 개조해서 한글화를 해 보기로 했다.

오류 문구는 물론, Email과 Password 역시 내가 DB에 저장해 놓은 이름이 그대로 출력되고 있기 때문에 이 역시 바꿔보기로 했다.

 


 

  우선, LoginView부터 살펴보도록 하자. 파이참을 사용한다면 해당 클래스에 커서를 대고 Ctrl+B로 해당 클래스의 정의로 바로 넘어갈 수 있다.

 

  밑에 자잘한 함수들이 있으니 이 뷰가 어떻게 동작하는 지 관심이 있다면 읽어보자.

form_class = AuthenticationForm

에만 주목하면 해당 이름의 Form을 로그인에 활용하는 것으로 추정할 수 있고, 밑에서 get_form_class() 라는 함수를 통해 해당 form을 사용하도록 설정하는 코드가 있다.

  그러므로 우리가 수정해야 할 건 이 Form일 것이다.

 

 

 

  아래의 error_messages를 보면 위에서 봤던 익숙한 문구들이 보인다. 이제 이 부분을 전부 수정하면 될 것이다.

수정해야 할 부분을 찾았으니, 이제 코드로 작성해보자.

 

 

 

yourapp/forms.py

from django.contrib.auth.forms import AuthenticationForm

class CustomAuthenticationForm(AuthenticationForm):
    error_messages = {
        'invalid_login': (
            "비밀번호나 이메일이 올바르지 않습니다. 다시 확인해 주세요."
        ),
        'inactive': ("이 계정은 인증되지 않았습니다. 인증을 먼저 진행해 주세요."),
    }

 

 

에러 메세지들은 수정했으니, 이제 필드 이름을 수정할 차례이다. 해당 클래스의 __init__ 함수를 보자.

 

 

 유저네임 필드의 최대 길이와 라벨명 등을 설정하고 있다. 해당 부분을 오버라이딩 하면 될 것이다.

 

 

* 글쓴이는 username 대신 email을 쓰고 있는데? 

 

  맞다. 나는 User 클래스를 아예 새로 만들어서 쓰고 있다. 그래서 로그인에서 ID 없이 이메일과 비밀번호만으로 로그인을 하고 있고, username이라는 필드는 아예 모델에 없다. 그래도 지금까지 위 코드가 정상 작동 했다. 왜냐하면,

self.username_field = UserModel._meta.get_field(UserModel.USERNAME_FIELD)

이 부분 때문이다. 장고의 기본 User 모델에 username이라는 필드가 있고 해당 필드를 로그인시에 활용하는 건 맞다.

  일단, 이 Form에서의 username이라는 이름은 그냥 필드 이름 그 이상도 이하도 아니다.

두 이름이 겹치는 건 일단 그냥 우연이라고 생각하면 된다. 중요한 건, 이 Form에서의 username 필드를 정의할 때, Usermodel의 USERNAME_FIELD를 이용해 정의를 한다. 즉 로그인 인증에서 사용하는 유저 모델의 설정을 따라간다.

그리고 나는 커스터마이징한 유저 모델에서 USERNAME_FILED를 email이라고 정의해 두었기에, 맨 위의 로그인 창에서 Email이라고 표시된 것이다.

 


 

위의 관찰 내용을 이용하여 커스터마이징을 계속해 보자.

 

yourapp/forms.py

from django.contrib.auth.forms import AuthenticationForm

class CustomAuthenticationForm(AuthenticationForm):
    error_messages = {
        'invalid_login': (
            "비밀번호나 이메일이 올바르지 않습니다. 다시 확인해 주세요."
        ),
        'inactive': ("이 계정은 인증되지 않았습니다. 인증을 먼저 진행해 주세요."),
    }

    def __init__(self, request=None, *args, **kwargs):
        super(CustomAuthenticationForm, self).__init__(*args, **kwargs) # 꼭 있어야 한다!
        self.fields['username'].label = '이메일'
        self.fields['password'].label = '비밀번호'

 

 

  원한다면 길이 제한 등도 건들 수 있지만 여기서는 필요하지 않아서 따로 수정하지 않았다.

이대로 하기 전에, 다시 한번 원본 코드를 보고 이 부분을 주목해보자.

        if self.fields['username'].label is None:
            self.fields['username'].label = capfirst(self.username_field.verbose_name)

username 필드의 라벨이 지정되어 있지 않으면, 해당 username 필드의 verbose_name을 가져온다고 되어 있다.

그렇다면, 내가 커스터마이징했던 모델에서 verbose_name을 지정한다면 최소한 이메일 부분은 한글화가 되지 않을까?

 

 

class User(AbstractBaseUser):
    email = models.EmailField(
        verbose_name='이메일',
        max_length=255,
        unique=True,
    )
    
    USERNAME_FIELD = 'email'
    # ...

 

이렇게 지정하고, migration을 진행한다면...

 

 

  잘 적용 되었다. 이렇게 된다면 username 필드는 따로 건들 필요가 없게 되었다.

이런 식으로 매번 form class나 템플릿을 만지며 라벨을 일일이 달아 주는 건 비효율적일 것이다. 그래서 직접 form의 label을 수정하는 것보다, 가능하다면 위와 같이 verbose_name을 적용하는게 제일 좋다.

 

 

 

코드를 수정하자.

 

yourapp/forms.py

from django.contrib.auth.forms import AuthenticationForm

class CustomAuthenticationForm(AuthenticationForm):
    error_messages = {
        'invalid_login': (
            "비밀번호나 이메일이 올바르지 않습니다. 다시 확인해 주세요."
        ),
        'inactive': ("이 계정은 인증되지 않았습니다. 인증을 먼저 진행해 주세요."),
    }

    def __init__(self, request=None, *args, **kwargs):
        super(CustomAuthenticationForm, self).__init__(*args, **kwargs) # 꼭 있어야 한다!
        self.fields['password'].label = '비밀번호'

 

 

폼은 완성되었다. 이제 뷰를 수정하자.

 

yourapp/views.py

 

from django.contrib.auth import views as auth_view
from .forms import CustomAuthenticationForm

class CustomLoginView(auth_view.LoginView):
    form_class = CustomAuthenticationForm

 

Form까지 적용이 완료되었다. 이제 뷰를 교체하자.

 

 

yourapp/urls.py

 

from .views import CustomLoginView

urlpatterns = [
    path('login/',
    CustomLoginView.as_view(template_name='registration/login.html'), name='login')
    ]

 

 

이제 결과를 보자.

 

 

  모두 한글화가 되었다. 

맨 아래의 Login 버튼은 Form에 포함된 게 아니라 템플릿에 들어 있는 요소여서 바로 수정할 수 있을 것이다. 

 

 

  이외에도 커스터마이징 할 수 있는 요소가 많이 있다.

여기서 다루지 않은 속성이나 함수들도 적절히 오버라이딩 한다면 훨씬 많은 걸 할 수 있을 것이다.

 

 1. 사용 배경

  기술적인 구현 내용을 알고 싶다면 바로 2번으로 건너뛰면 된다. 

 

 인턴으로 다니던 회사에서 데이터를 주기적으로 크롤링하고, 크롤링한 자료를 이용자들에게 제공해주는 웹 서버를 개발했다. 이 서버를 운영해야 하는데, 이 모든 작업이 정상 작동하는지 주기적으로 확인을 해야 한다. 서버는 사실 바로 안다. 그냥 주소를 입력했는데 안 들어가지면 문제가 있다. 내가 확인하기도 전에 아마 사용자 측에서 뭔가 피드백이 들어올 것이다.

 

 하지만, 크롤링이 잘못되었을 경우엔 이 크롤링이 잘못되었다는 사실을 깨닫는 건 상대적으로 까다롭다. 그냥 데이터가 몇 개 비거나, 혹은 아예 안 보일 뿐이지 다른 모든 건 정상작동 할 것이다. (서버와 크롤링 모듈을 합쳐놓지 않은 이상) 물론, 서버 로그를 확인하는 방법도 있다. 하지만 AWS를 사용하는 지금으로선 putty를 켜고 들어가는 거 자체도 너무 귀찮았다. 그래서 그냥 이 과정을 자동화 시켜서, 나는 그냥 앉아서 정상 작동 여부를 알도록 했다.

 

 

 

 해당 크롤링이 성공이든, 실패든 일단 무조건 메세지를 보내서 성공/실패를 보고하도록 하고 싶었다. 일단 언어는 크롤링 모듈이 python으로 만들어 졌기에 python으로 고정하고, 이제 이 메세지를 보낼 매체를 선정하는데, 선정 결과는 다음과 같았다.

 

- 이메일 : 프로토콜도 있고 다 있는데, 이미 내 네이버 메일함은 999+라 메일 공해를 더 일으키고 싶진 않다.  그렇다고  업무용 메일을 또 만들기엔 솔직히 내가 확인을 자주 안 할거같다.

 

- 카카오톡 : 접근성도 최고고, 다 좋은데 사적 메신저를 업무용으로 쓰고 싶진 않았다. 결정적으로, 현재 메세지 보내는 API 서비스가 종료하고 새로운 챗봇 서비스를 준비하는 모양인데, 이게 2020년 2월 기준으로 베타 테스트 중이라 사유를 적어서 신청해야 쓸 수 있다.

 

- 텔레그램 : telegram이라는 모듈이 있어서 코딩하기엔 편리한데, 이것도 사실 업무용보다는 사적인 메신저라 업무용으로 쓰고 싶진 않았다.

 

- Slack : slacker라는 쓰기 쉬운 모듈도 있고, 원래 업무용으로 나온 메신저다. 

 

 그래서 Slack bot을 만들기로 했다.

 

 

2. Slack bot 추가하기

 

 일단 당연히 Slack 워크스페이스를 만들어야 한다. 다행히 만드는 건 무료다. 무료 기능은 검색 기능이 15000 메세지로 제한되는 점을 빼면 개인적으로 사용하는 수준에선 딱히 지장이 없을 것이다. 사실 실무에서 slack을 사용하고 있었다면 이미 유료 에디션일 것이다.

 

 

 일단, 봇은 여기서 app이라고 부르는 데 이 app을 만들어야 한다. 

https://api.slack.com/ 여기로 본인 아이디로 로그인하고, 화면 중앙의 Start building -> Create New App을 클릭한다.

 

 

 

본인이 쓸 앱 이름과 이 앱이 적용될 워크스페이스를 지정한다. 아까 만든 워크스페이스를 지정하면 될 것이다.

 

 

 

 이제 앱은 생성되었고, 설정을 할 시간이다. 보면 bots와 같이 사용자와 인터렉션을 할 수 있도록 다양한 옵션이 준비되어 있는데, 지금 만드는 것은 양방향 소통이 아니라 일방적으로 정보를 전송해 주는 구독형 봇이라 Permissions만 손대보자.

 

 

  

 

 

 

들어가면 우선 Install App to Workspace라고 이름만 보면 반드시 눌러야 할 거 같이 생겼는데 안 눌리는 버튼이 있다. 당황하지 말고 지금은 권한이 아무것도 지정이 안 되어서 설치를 못 하는 것이다. 스크롤을 내리자.

 

 

 

 

밑에 Scopes에서, 우리는 Bot으로 쓰는 거니 Bot Token Scope에서 지정해보자. 여러 가지 권한들이 있는데, 내가 사용할 것은 chat:write 다. chat:write는 당연히 글을 써야 하니 주는 기능이다. 클릭하면 바로 적용이 된다.

 

 

 이외에도 사용 가능 IP 제한 설정이나 리디렉션 설정 등이 있다. 필요한 설정이 있다면 하면 되고, 이제 다시 보면...

 

 

 

 이제야 인스톨 앱 버튼이 활성화가 됐다. 다음엔 이런 권한을 준다고 확인창이 뜨는데, 읽어보고 자기가 준 권한이 맞다면 승인을 누르자.

 

 

 

 

 

 Bot 토큰을 받는데, 이 토큰으로 봇을 조종하니 유출되지 않게 하자.

그리고 이 봇이 들어갈 채널을 만들자. 테스트인데 그냥 #test면 심심하니까 #testtest로 했다.

 

 

 

눌러야 하는 버튼이 보인다. Add an app을 누르자.

 

 

 

 아까 만든 TestService가 보인다. Add를 하면 채널에 초대가 된다.

그런데, 처음 만들 땐 Add가 아니라 그냥 View로 보이면서 초대가 안 될 때가 있다. 이 경우는 그냥 기다리니까 다 해결이 됐는데, 내 추측으로는 앱이 만들어졌다는 사실 자체는 바로 전송되는데, 앱이 해당 워크스페이스에 적용되는 데는 시간이 걸리는 것 같다. 

 

 

 

 

초대가 됐다. 참고로 아이콘이나 이름을 편집하고 싶다면, 앱 관리 페이지의 Basic Information에서 이름이나 아이콘 등을 바꿀 수 있다.

 

 

 이걸로 모든 준비가 끝났다.

 

 

 

3. Python 코드 작성

 

 pip install slacker를 통해 라이브러리를 설치한다.

 

사용은 놀랍도록 간단하다.

 

# Slackbot.py

from slacker import Slacker
from datetime import datetime

token = '아까 받은 토큰을 이곳에 삽입'


def send_slack_message(msg, error=''):
    full_msg = msg
    if error:
        full_msg = msg + '\n에러 내용:\n' + str(error)
    today = datetime.now().strftime('%Y-%m-%d %H:%M:%S ')
    slack = Slacker(token)
    slack.chat.post_message('#testtest', today + full_msg, as_user=True)

if __name__ == "__main__":
    # 일부러 에러가 발생하도록 함.
    try:
        a = 1/0
    except Exception as e:
        send_slack_message('테스트', e)

 사실 메세지 전송 자체는 딱 import 한 줄, token 한 줄, slacker 두 줄로 총 네 줄이면 충분한데, 나는 해당 에러 발생 시간도 알기 위해 시간 정보 역시 삽입했다. 아래의 __main__ 코드를 통해 에러를 발생시켜보자.

 

 

 다음과 같이 메세지가 보내어진다. 기본적으론 이게 끝이다!

 

4. 프로젝트에서의 활용

 

 이번엔 내가 사용했던 용례를 소개하고자 한다. 우선, 가장 기본적으론 이렇게 사용할 수 있겠다.

 

def run():
    # 여기에서 크롤링 진행


if __name__ == "__main__":
    try:
        run()
        send_slack_message('크롤링이 완료되었습니다.')
    except Exception as e:
        send_slack_message('크롤링 작업 중 오류가 발생했습니다!', e)

 

 이러면 run()이 오류 없이 진행되었을 시, 크롤링이 완료되었습니다 라는 메세지가 보내지고, 에러가 나면 에러내용과 함께 에러가 발생했다는 메세지가 전송된다. 성공을 하든 실패를 하든 일단 메세지를 보내게 된다. 만약 정해진 시간에 아무런 메세지도 안 온다? 이러면 아예 실행 자체가 안 된 것이니 역시 서버를 체크하면 된다. 성공의 경우도 메세지를 보내야 하는 이유가, 서버가 OS 다운 등의 모종의 사유로 아예 스크립트를 못 돌리는 상황도 체크하기 위함이다.

 

 

 

 하지만 이 경우는 단순히 크롤러의 성공/실패 여부만 따질 뿐, 문제가 있다. 말 그대로 프로그램을 돌릴 수 없는 '에러' 만 잡는 상황이고, 의도하지 않는 값이 나오는 경우는 잡을 수 없다. 즉 함수에 들어가야 할 값이 안 들어가고 None이 들어가서 None이 나오는 경우, 우리가 의도한 상황이 아니지만 아무튼 에러는 안 난다. 이런 경우를 위해, 사용자 정의 에러를 사용하자. 

 

from Slackbot import send_slack_message

class LoginError(Exception):
    def __init__(self, target_name):
        self.msg = '{target} 로그인 양식이 맞지 않습니다.'.format(target=target_name)
        send_slack_message(self.msg)
    def __str__(self):
        return self.msg


class DBError(Exception):
    def __init__(self, name, length, error):
        self.msg = '{name} 에 대한 {len} 행의 데이터 입력 작업 중 오류가 발생했습니다.\n{error}'\
            .format(name=name, error=error, len=length)
        send_slack_message(self.msg)
    def __str__(self):
        return self.msg

 

 코드 내에서 값의 유효성을 검증하는 부분을 추가하고, 유효하지 않을 경우 위와 같은 에러들을 raise 한다. 이 에러들은 raise 됨과 동시에 __init__ 에 의해 슬랙으로 에러 내용에 대해 정해진 양식의 메세지를 보내게 된다. 나는 실제 코드에선 Slack API가 작동하지 않을 것을 대비하여, 위의 코드에선 표시하지 않았지만 logging 모듈을 이용해 로컬 파일로 에러 로그를 작성하는 부분도 매 에러마다 추가해 두었다. 

 

 

 

 이외에도, 메세지는 보내되 크롤링은 그대로 진행해야 하는 경우도 있다. 1000개 중 하나만 오류가 난다면, 그 하나 때문에 크롤링이 완전히 중단되는 것보단 999개는 진행하고 1개는 나중에 처리하는 것이 합리적일 것이다. 그런 경우에도 간단히 이런 식으로 작성하면 될 것이다.

 

for name in name_list:
    try:
        crawl_something(name)
    except:
        send_slack_message('크롤링 중 {name} 를 처리하는 데 오류가 발생했습니다.'.format(name=name))

 개인적으론 이 방식은 항목 하나라도 크롤링이 안되면 치명적이거나, 데이터 수가 매우 적은 경우를 제외하곤 추천하지 않는다. 천개짜리 name_list를 크롤링을 진행하는데 모두 오류가 나서 천 개의 메세지가 왔다고 가정하면... 끔찍하다. 차라리 실패한 갯수(except 블록이 실행된 횟수)를 카운팅해서 그 갯수를 보내주는 게 합리적일 것이다. 내 경우엔 일단 몇 개 정도는 당장 크롤링에 실패해도 서비스에 큰 지장은 없어서, except 블록에서 해당 에러를 로그 파일에 기록만 하도록 작성해 두었다.

 

 

 

5. 주의사항

 위의 방법은 오류가 났다는 걸 능동적으로 알려주기만 할 뿐, 절대 오류를 해결해 주는 과정이 아님을 명심하자. 따라서 위에서는 가독성을 위해 생략했지만 문제 원인 파악과 해결을 위해선 logging 등의 기본적인 로그 작성은 필수다. 그리고, 유효성 검증을 적절하게 할 수 있도록 코드를 짜는 것 역시 중요하다.

 

 

 

 

 

+ Recent posts