Python으로 웹 프론트엔드 개발하기

예전에 장난삼아 훗날엔 파이썬으로 프론트 개발하는 날이 올거라서 자바스크립트를 깊게 하지 않겠다고 말했던 적이 있었다. 파이썬을 사랑하는 마음에 던진 말이었는데 정말 그런날이 올지도 모른다는 생각이 들었다. Brython이라는 존재가 그러한 확신을 심어주었다.

Brython은 사실 꽤나 오래전부터 존재한 것으로 보여지나 필자는 2021년 7월 즈음 처음 존재를 알게 되었다. 이 글에는 Brython을 사용하면서 느낀점과 사용법에 대해서 다루고자 한다. 우선 여기서는 파이썬이라는 점에서 자바스크립트와 용어를 다소 다르게 사용한다.

  • document를 제외한 빌트인 객체 = 패키지
  • 빌트인 객체 메서드 = 함수

Brython


공식 홈페이지에서는 자바스크립트를 대신해 파이썬3를 이용하여 웹 클라이언트 사이드 개발을 진행할 수 있다고 설명하고 있다. 실제로 자바스크립트에서 사용할 수 있는 브라우저 API 객체를 동일하게 제공하며, 파이썬에서 사용하는 모든 문법을 사용할 수 있었다.

속도는 CPython과 거의 동일하다고 언급하고 있다. 다만 Brython의 초기 실행 시간이 상당히 길며, 파이썬 스크립트를 외부에서 호출하는 경우 DOM 로딩이 완료된 후 AJAX를 이용하여 호출하는 것인지 속도가 상당히 느렸다. 하지만 로딩이 완료된 후 실행 속도는 크게 문제되지 않았다.

Hello, Brython!

<html>
    <head>
        <meta charset="utf-8">
        <script type="text/javascript" src="https://cdn.jsdelivr.net/npm/brython@3.10.0/brython.min.js"></script>
        <script type="text/javascript" src="https://cdn.jsdelivr.net/npm/brython@3.10.0/brython_stdlib.js"></script>
    </head>
    <body onload="brython()">
        <script type="text/python">
            from browser import document
            document <= "Hello, Brython!"
        </script>
    </body>
</html>

위와같이 단 2개의 스크립트와 body onload 이벤트에서 brython 함수를 호출하는 것으로 브라이썬을 즐길 모든 준비가 끝난다. 위 코드를 그대로 붙여서 실행한 후 페이지에 접속하면 Hello, Brython!문장이 뜨는 것을 확인할 수 있다.

가볍게 엘리먼트를 생성하여 DOM에 추가해 보자.

from browser import document

fruits = ['apple', 'banana', 'grape']

ul = document.createElement('ul')
for fruit in fruits:
    li = document.createElement('li')
    li.textContent = fruit
    ul.appendChild(li)
document.body.appendChild(ul)

필자는 사실 document 객체에게 불만이 있다. document 객체는 스네이크 표기가 아닌 카멜 케이스 표기를 따르고 있다. 깐깐하게 표기법 가지고 따져들고 싶진 않지만 코드의 가독성을 중요시 생각하는 필자에게 기본적인 문법이 통일되지 않은 것이 안타까울 따름이다. 다만 이는 document 보단 브라이썬에서 제공하는 문법 및 함수를 사용하면 어느정도 해소된다.

from browser import document, html

fruits = ['apple', 'banana', 'grape']

ul = html.UL()
for fruit in fruits:
    ul <= html.LI(fruit)
document.body <= ul

위에서 본 코드와 같은 결과를 출력하지만 훨씬 간결해진 것을 볼 수 있다. createElement 대신 html 패키지를 사용하여 원하는 엘리먼트를 생성할 수 있고, 엘리먼트의 appendChild 대신 화살표를 사용하여 엘리먼트를 자식 노드로 추가할 수 있다. 간결하지만 다소 난해하다.

Selector

브라이썬에서는 자바스크립트의 getElementById와 querySelector 메서드를 좀 더 간결한 문법으로 제공하고 있다. getElementById는 document에서 딕셔너리처럼 접근할 수 있다.

from browser import document, html

document.body <= html.DIV(id='root')
document['root'] <= html.P('Hello, Brython!')

querySelector는 document.select 메서드로 접근할 수 있다. css 구분자를 인자로 받는다.

from browser import document, html

ul = html.UL()
for fruit in ['apple', 'banana', 'grape']:
    ul <= html.LI(fruit, Class='fruit')
document.body <= ul

fruits = document.select('.fruit')
for fruit in fruits:
    fruit.style.color = '#f00'

Event

이벤트는 엘리먼트에 bind 메서드를 사용하면 추가할 수 있다.

from browser import document, html

def on_click_ul(e):
    if e.target.nodeName == 'LI':
        e.target.style.color = '#f00'

ul = html.UL()
ul.style.cursor = 'pointer'
ul.bind('click', on_click_ul)

for fruit in ['apple', 'banana', 'grape']:
    ul <= html.LI(fruit, Class='fruit')

document.body <= ul

첫 인자에 이벤트의 종류, 두번째 인자에 콜백 함수를 전달한다.

Timer

set_timeout

set_timeout 함수는 timer 패키지에서 제공하고 있다. 자바스크립트의 setTimeout 메서드와 동일하게 첫번째 인자로 콜백 함수, 두번째 인자로 ms 단위의 시간을 받는다.

from browser import timer

timer.set_timeout(lambda: print('Hello, World!'), 3000)

위 경우 3초후 Hello, World!가 콘솔에 표시된다. 타이머를 중단시키려면 clear_timeout 함수를 사용하여 중지키실 수 있다.

from browser import timer

timeout_event = timer.set_timeout(lambda: print('Hello, World!'), 3000)
timer.clear_timeout(timeout_event)
set_interval

set_interval 함수 역시 set_timeout 과 같다. 첫번째 인자로 콜백 함수, 두번째 인자로 ms 단위의 시간을 받는다.

from browser import timer

timer.set_interval(lambda: print('Hello, World!'), 3000)

위 경우 3초마다 Hello, World!가 콘솔에 표시된다. 종료하려면 clear_interval을 호출하면 된다.

from browser import timer

interval_event = timer.set_interval(lambda: print('Hello, World!'), 3000)
timer.clear_interval(interval_event)

Network

브라이썬은 네트워크 API로 ajax 라는 패캐지를 제공하고 있다. 동일한 서버에 다음과 같은 파일을 준비했다.

[
    {
        "id": "1",
        "title": "now, brython better than javascript",
        "author": "baealex"
    },
    {
        "id": "2",
        "title": "not yet, javascript better than brython",
        "author": "baealex"
    }
]
from browser import ajax

def oncomplete(res):
    print(res.text)

ajax.get('posts.json', oncomplete=oncomplete)

아주 간결하다. 단, 이 경우 res.text가 텍스트로 출력된다.

ajax.get('posts.json', mode='json', oncomplete=oncomplete)

위와같이 mode에서 타입을 지정하면 적절한 형태로 가져올 수 있다. 이제 res.text는 텍스트가 아닌 배열 객체로 출력된다. 하지만 데이터를 딕셔너리 형태로 관리하면 실수할 여지가 많으므로 객체로 래핑하였다.

class Posts:
    def __init__(self, item):
        self.id = item['id']
        self.title = item['title']
        self.author = item['author']
    
    def from_json(items: list):
        return [ Posts(item) for item in items ]

이제 위 클래스의 정적 메서드를 이용하여 요청 결과를 즉시 포스트 객체로 변환시킬 수 있다.

from browser import ajax, document, html

class Posts:
    def __init__(self, item):
        self.id = item['id']
        self.title = item['title']
        self.author = item['author']
    
    def from_json(items: list):
        return [ Posts(item) for item in items ]

def oncomplete(res):
    posts = Posts.from_json(res.text)
    for item in posts:
        text = f'{item.title} write by {item.author}'
        document['root'] <= html.LI(text)
ajax.get('posts.json', mode='json', oncomplete=oncomplete)

document.body <= html.UL(id='root')

비동기 프로그래밍

브라이썬은 자바스크립트와 마찬가지로 기본적으로 비동기 프로그래밍을 기반으로 한다, 브라이썬은 파이썬에서 사용하는 async, await 예약어를 지원하며, 추가적인 비동기 라이브러리를 제공한다. 다만 제공하는 비동기 라이브러리가 많지 않아 파이썬 수준의 활용은 어렵다.

파이썬에서는 동기를 비동기로 변환하는 작업이 많은데 브라이썬에서는 비동기를 동기로 변환하는 작업이 많다. 즉, 자바스크립트에서 async, await 키워드를 사용하는 것처럼 브라이썬 역시 비동기 프로그래밍의 문법적 개선을 위하여 사용하는 경향이 크다는 점에서 파이썬과 다소 차이를 가진다.

위에서 언급했듯 비동기 라이브러리에 구현된 기능이 많지 않다. 게중에 가장 활용하기 좋은 건 Future 객체지만 이 또한 일부 메서드만 구현되었다. 아래는 Future 객체와 timer 패키지를 활용하여 sleep 과 같은 기능을 하는 rest 함수를 구현한 코드이다.

from browser import timer, aio

async def rest(ms):
    future = aio.Future()
    timer.set_timeout(lambda: future.set_result(True), ms)
    await future

async def main():
    print('start...')
    await rest(2000)
    print('after 2 seconds...')
    await rest(2000)
    print('after 4 seconds...')

aio.run(main())

위에서 네트워크 API를 이용해 포스트를 불러왔던 코드를 수정해보자.

from browser import aio, document, html
from javascript import JSON

class Posts:
    def __init__(self, item):
        self.id = item['id']
        self.title = item['title']
        self.author = item['author']
    
    def from_json(items: list):
        return [ Posts(item) for item in items ]

async def main():
    res = await aio.get('posts.json', format='text')
    posts = Posts.from_json(JSON.parse(res.data))
    for item in posts:
        text = f'{item.title} write by {item.author}'
        document['root'] <= html.LI(text)

aio.run(main())

document.body <= html.UL(id='root')

소스코드가 작성한 순서대로 동작하여 가동성 면에서는 콜백 패턴에 비해 훌륭한 듯 보인다. 단, 기본 네트워크 API와 다소 차이가 있는 부분에 주의하자.

  • 파일 타입 지정하는 인수가 mode가 아닌 format이다.
  • format에는 json이 존재하지 않는다. (별도로 parse를 해줘야 한다.)
  • 응답 객체에 text 대신 data가 존재한다.

문법을 통일시키려면 기존 ajax 패키지의 함수를 코루틴으로 래핑하는 것이 나을 것 같다.

주의사항

반복문에서 콜백함수를 전달할 때 주의해야 할 점이 있다.

from browser import timer

fruits = ['apple', 'banana', 'grape']
for fruit in fruits:
    timer.set_timeout(lambda: print(fruit), 3000)

위 코드를 실행하면 대부분은 다음과 같은 결과를 기대한다.

apple
banana
grape

하지만 안타깝게도 결과는 아래와 같다.

grape
grape
grape

파이썬에 대해서 알고 있는 사람이라면 이해가 가능한 문제다. 파이썬 관점에서 생각해보면 콜백 함수가 실행될 시점에 fruit 변수에는 'grape'가 할당되어 있으므로 이러한 결과가 발생한 것을 알 수 있다. 하지만 일반적인 관점에서는 이해하기 어려운 동작이다. 정상적인 결과를 얻고자 한다면 다음과 같이 값의 복사가 이뤄질 수 있도록 하여 함수가 실행 될 시점에 복사한 값을 넣어 실행한다.

from browser import timer

def timer_callback(value, func):
    return lambda: func(value)

fruits = ['apple', 'banana', 'grape']
for fruit in fruits:
    timer.set_timeout(timer_callback(fruit, print), 3000)

이 경우 기대한 apple, banana, grape가 순서대로 출력된다. 이벤트에서도 마찬가지다. 예를들어

from browser import document, html

ul = html.UL()
for fruit in ['apple', 'banana', 'grape']:
    ul <= html.LI(fruit, Class='fruit')
document.body <= ul

fruits = document.select('.fruit')
for fruit in fruits:
    def on_click(e):
        fruit.style.color = '#f00'
    fruit.bind('click', on_click) 

위와같이 이벤트를 바인딩 할 때 fruit으로 엘리먼트를 접근하는 경우 문제가 된다. 이 경우 어떤 엘리먼트를 클릭해도 마지막 엘리먼트만 빨간색으로 변한다. 이때는, 엘리먼트를 e.target으로 접근하거나 timer에서 한 것과 동일한 처리가 필요하다. 전자의 방법을 권장한다.

fruits = document.select('.fruit')
for fruit in fruits:
    def on_click(e):
        e.target.style.color = '#f00'
    fruit.bind('click', on_click)

또는

def event_bind(target, func):
    return lambda e: func(e, target)

fruits = document.select('.fruit')
for fruit in fruits:
    def on_click(e, fruit):
        fruit.style.color = '#f00'
    fruit.bind('click', event_bind(fruit, on_click)) 

반복문의 변수를 콜백 함수로 전달한다면 꼭 주의하자.

마치며


사용해보니 파이썬의 문법으로 프론트엔드를 만든다는 것이 굉장히 이색적이고 신선하다는 생각이 들었다. 좀만 더 가다듬어지면 정말 좋을 것 같은데 지금은 자바스크립트의 생산성이나 편리성이 지대하여 프론트엔드는 자바스크립트로 개발하는 것이 정신건강에 이로울 것으로 보인다.

앞으로 브라이썬으로 이것저것 해보다 추가로 드는 생각이 있다면 이곳에 내용을 추가할 예정이다.

이 글이 도움이 되었나요?
0 minutes ago
작성된 댓글이 없습니다. 첫 댓글을 달아보세요!
    댓글을 작성하려면 로그인이 필요합니다.