파이썬 html.parser 로 HTML 파싱하기: 40줄 추출기

스크래핑 강좌 2. 표준 html.parser 로 상품 목록을 뽑고, urljoin 으로 상대 URL 을 고치고, 공백·엔티티를 정리하고, 정규식이 깨지는 모습을 확인. 파싱 속도도 3.9 와 3.12 로 실측.

시리즈: 웹 스크래핑 강좌 2/6 (초급). 페이지는 받아 왔는데(1강) 그 안의 데이터를 어떻게 꺼내야 할지 모르는 분을 위한 글입니다. 다 읽고 나면 파이썬 표준 html.parser 로 40줄 남짓한 추출기를 쓰고, 텍스트와 속성 중 무엇을 읽어야 하는지 판단하고, 404 가 나지 않는 링크를 만들 수 있습니다.

최저가 비교표도, 재고 알림도, 공공기관 게시판에서 공지를 모아 오는 사내 도구도, 한가운데에는 파서가 있습니다. 신입 대부분은 이걸 str.find 나 정규식으로 짜고, 첫 페이지에서는 잘 되다가 사이트 디자인이 바뀐 주에 조용히 깨집니다. 이번 강의에서는 표준 라이브러리만으로 제대로 만들고, 정규식 버전이 실제 출력에서 실패하는 모습을 보고, 속도도 잽니다. 실행 환경은 Apple Silicon Mac(M1 Pro, macOS 26.6)의 Python 3.9.6, 비교용 Python 3.12.14 이고, 상대는 1강의 연습용 쇼핑몰(PHP 8.5.6, 127.0.0.1:18160)입니다.

아직 쇼핑몰을 띄우지 않았다면 1강부터 보세요. PHP 파일 하나와 명령 한 줄이면 됩니다.

여기서 말하는 "파싱"

HTML 페이지는 태그(<li>, <a>), 태그 안의 속성(class="product", href="product/101"), 태그 사이의 텍스트(USB-C 케이블 1m)로 이뤄져 있습니다. 여는 태그부터 닫는 태그까지의 덩어리를 요소라고 부릅니다. 쇼핑몰의 상품 하나는 이렇게 생겼습니다.

  <li class="product" data-id="101">
    <a href="product/101">
      USB-C 케이블 1m
    </a>
    <span class="price">₩8,900</span>
    <span class="stock out">0</span>
  </li>

파서는 이 텍스트를 읽고 구조를 알려 주는 도구입니다. html.parser.HTMLParser 는 이벤트 방식 파서라서, 페이지를 한 번 훑으면서 만나는 것마다 메서드를 호출합니다. <li ...> 면 handle_starttag(tag, attrs), 텍스트면 handle_data(text), </li> 면 handle_endtag(tag). 나중에 검색할 수 있는 트리는 만들지 않으므로 "지금 상품 안에 있다", "다음에 올 텍스트는 가격이다" 같은 현재 위치는 내 코드가 기억해야 합니다.

BeautifulSoup 에 비하면 원시적으로 보이지만, 어느 파이썬에나 들어 있고, 지저분한 HTML 에도 너그럽고, 목록 페이지라면 대부분 이걸로 충분합니다.

40줄 추출기

쇼핑몰과 같은 폴더에 extract.py 로 저장하세요. import 와 파서 클래스가 39줄이고, main() 은 페이지를 받아 결과를 출력하는 부분입니다.

import sys
import urllib.request
from html.parser import HTMLParser
from urllib.parse import urljoin

UA = "PracticeShopBot/0.1 (+https://example.com/bot-contact)"

class ProductParser(HTMLParser):
    def __init__(self, base_url):
        super().__init__()                      # convert_charrefs=True by default
        self.base_url = base_url
        self.items, self.next_url = [], None
        self.cur, self.field = None, None

    def handle_starttag(self, tag, attrs):
        a = dict(attrs)
        classes = (a.get("class") or "").split()
        if tag == "li" and "product" in classes:
            self.cur = {"id": a.get("data-id"), "name": "", "price": "", "url": None}
        elif self.cur is not None and tag == "a":
            self.cur["url"] = urljoin(self.base_url, a.get("href", ""))
            self.field = "name"
        elif self.cur is not None and tag == "span" and "price" in classes:
            self.field = "price"
        elif tag == "a" and a.get("rel") == "next":
            self.next_url = urljoin(self.base_url, a["href"])

    def handle_endtag(self, tag):
        if tag in ("a", "span"):
            self.field = None
        elif tag == "li" and self.cur is not None:
            self.cur = {k: " ".join(v.split()) if isinstance(v, str) else v
                        for k, v in self.cur.items()}
            self.items.append(self.cur)
            self.cur = None

    def handle_data(self, data):
        if self.cur is not None and self.field:
            self.cur[self.field] += data

def main():
    url = sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:18160/ko/products"
    req = urllib.request.Request(url, headers={"User-Agent": UA})
    with urllib.request.urlopen(req, timeout=10) as resp:
        html = resp.read().decode(resp.headers.get_content_charset() or "utf-8")
    p = ProductParser(resp.url)
    p.feed(html)
    p.close()
    for item in p.items:
        print(item)
    print("next:", p.next_url)

if __name__ == "__main__":
    main()
$ python3 extract.py
{'id': '101', 'name': 'USB-C 케이블 1m', 'price': '₩8,900', 'url': 'http://127.0.0.1:18160/ko/product/101'}
{'id': '102', 'name': '무선 마우스', 'price': '₩24,500', 'url': 'http://127.0.0.1:18160/ko/product/102'}
{'id': '103', 'name': '노트북 거치대', 'price': '₩39,000', 'url': 'http://127.0.0.1:18160/ko/product/103'}
{'id': '104', 'name': '기계식 키보드', 'price': '₩89,000', 'url': 'http://127.0.0.1:18160/ko/product/104'}
next: http://127.0.0.1:18160/ko/products?page=2
$ python3 extract.py 'http://127.0.0.1:18160/ko/products?page=2'
{'id': '105', 'name': '27인치 모니터', 'price': '₩259,000', 'url': 'http://127.0.0.1:18160/ko/product/105'}
{'id': '106', 'name': '웹캠 HD', 'price': '₩45,000', 'url': 'http://127.0.0.1:18160/ko/product/106'}
{'id': '107', 'name': '스탠드 조명', 'price': '₩19,900', 'url': 'http://127.0.0.1:18160/ko/product/107'}
{'id': '108', 'name': '노이즈 캔슬링 헤드폰', 'price': '₩189,000', 'url': 'http://127.0.0.1:18160/ko/product/108'}
next: None

셸에서는 URL 을 따옴표로 감싸세요. 감싸지 않으면 ? 가 파일 이름 와일드카드로 해석돼 zsh 가 no matches found 를 돌려줬습니다.

두 줄짜리 흐름도. 윗줄: feed(html) 로 파싱이 시작되고, class 가 product 인 li 의 handle_starttag 에서 빈 현재 항목을 만들고, a 의 handle_starttag 에서 urljoin 으로 만든 절대 URL 을 저장하며 현재 필드를 name 으로 정하고, handle_data 가 그 필드에 글자를 붙인다. 아랫줄: a 의 handle_endtag 에서 필드를 비우고, class 가 price 인 span 에서 필드를 price 로 정해 handle_data 로 채우고, li 의 handle_endtag 에서 공백을 정리해 완성된 항목을 리스트에 넣는다.
html.parser 추출기가 <li class="product"> 하나를 딕셔너리로 바꾸는 흐름. 파서는 메서드를 부를 뿐이고, 지금 어디에 있는지는 내 코드가 기억한다.

패턴은 언제나 같습니다. handle_starttag 에서 "여기가 원하는 곳"이라고 판단해 표시(self.cur, self.field)를 세우고, handle_data 는 표시가 서 있는 동안만 텍스트를 모으고, handle_endtag 에서 표시를 내리고 결과를 저장합니다. 이 하나를 쓸 줄 알면 어떤 목록 페이지의 파서도 쓸 수 있습니다.

텍스트냐 속성이냐

데이터는 태그 사이 텍스트에 있기도 하고(상품명, 가격), 속성에 있기도 합니다(data-id 의 상품 ID, href 의 링크). attrs 는 쌍의 리스트로 들어오기 때문에 추출기는 먼저 딕셔너리로 바꿉니다. 직접 확인한 세부 사항 세 가지입니다.

li [('class', 'product featured'), ('data-id', '109')] {'class': 'product featured', 'data-id': '109'}
input [('disabled', None), ('value', '3')] {'disabled': None, 'value': '3'}
a [('href', 'x'), ('href', 'y')] {'href': 'y'}
  • class 는 공백으로 구분된 문자열 하나. <li class="product featured"> 는 'product featured' 가 되므로 a.get("class") == "product" 는 False 입니다. 추출기처럼 나눈 뒤 포함 여부를 봐야 합니다.
  • 값이 없는 속성은 None. 그래서 .split() 앞에 (a.get("class") or "") 를 씁니다. None.split() 은 오류가 납니다.
  • 같은 속성이 두 번 있으면 dict() 에서는 뒤의 것이 이김. 드물지만 손으로 고친 페이지에서는 생깁니다.

속성 이름은 소문자로 맞춰서 넘어오므로 HREF 도 href 도 href 가 됩니다. 값은 그대로입니다.

상대 링크와 urljoin

쇼핑몰의 링크는 href="product/101" 같은 상대 URL 로, "현재 페이지 폴더 기준의 product/101"이라는 뜻입니다. 가져오려면 절대 URL 이 필요하고, urllib.parse.urljoin 이 브라우저와 같은 규칙으로 만들어 줍니다.

http://127.0.0.1:18160/ko/products         + product/101              -> http://127.0.0.1:18160/ko/product/101
http://127.0.0.1:18160/ko/products/        + product/101              -> http://127.0.0.1:18160/ko/products/product/101
http://127.0.0.1:18160/                    + product/101              -> http://127.0.0.1:18160/product/101
http://127.0.0.1:18160/ko/products?page=1  + ?page=2                  -> http://127.0.0.1:18160/ko/products?page=2
http://127.0.0.1:18160/ko/products         + /static/101.png          -> http://127.0.0.1:18160/static/101.png
http://127.0.0.1:18160/ko/products         + //cdn.example.com/a.png  -> http://cdn.example.com/a.png
http://127.0.0.1:18160/ko/products         + https://example.com/     -> https://example.com/

URL 을 + 나 f-문자열로 이어 붙이면 안 됩니다. 두 번째 줄처럼 끝의 슬래시 하나로 결과가 달라집니다. 그리고 기준은 실제로 받은 페이지의 URL 이어야 합니다. 쇼핑몰의 / 는 /ko/products 로 리다이렉트되는데, 기준을 각각 달리해 추출기를 돌려 보면 이렇습니다.

base=http://127.0.0.1:18160/                -> http://127.0.0.1:18160/product/101  404
base=http://127.0.0.1:18160/ko/products     -> http://127.0.0.1:18160/ko/product/101  200

main() 이 요청한 URL 이 아니라 resp.url 을 넘기는 이유가 이것입니다(페이지가 <base href="..."> 를 선언하면 기준이 또 바뀝니다. 이 추출기는 무시하므로 링크가 이상하면 확인해 보세요).

공백과 문자 참조

handle_endtag 의 정리 줄이 없으면 상품명에 HTML 소스의 줄바꿈과 들여쓰기가 그대로 남습니다.

{'id': '105', 'name': '\n      27인치 모니터\n    ', 'price': '₩259,000', 'url': 'http://127.0.0.1:18160/ko/product/105'}

" ".join(v.split()) 로 고칩니다. 인자 없는 str.split() 은 유니코드 공백 전부로 나누기 때문에, 줄 바꿈 없는 공백(&nbsp;)이나 전각 공백도 함께 처리됩니다.

'27&quot; &amp; HD' -> '27" & HD' -> '27" & HD'
'¥980&nbsp;(税込)' -> '¥980\xa0(税込)' -> '¥980 (税込)'
'ノートPC スタンド' -> 'ノートPC スタンド' -> 'ノートPC スタンド'
'&#8361;8,900' -> '₩8,900' -> '₩8,900'
'caf&eacute;' -> 'café' -> 'café'

왼쪽부터 원래 문자열, html.unescape(s) 결과, 그리고 split 과 join 까지 한 결과입니다. 원화 기호를 &#8361; 로 적는 사이트도 있는데, 이것도 ₩ 로 돌아옵니다. &amp; 나 &quot; 같은 문자 참조는 HTML 안에서 그대로 쓰면 헷갈리는 글자를 나타내는 방법이고, HTMLParser 는 기본적으로 이를 원래 글자로 바꿔 줍니다. 그 설정을 끄면 어떻게 되는지 쇼핑몰의 제목과 "다음" 링크로 확인했습니다.

convert_charrefs = True
  data: 'Practice Shop & Co.'
  data: 'Next »'
convert_charrefs = False
  data: 'Practice Shop '
  entityref: 'amp'
  data: ' Co.'
  data: 'Next '
  entityref: 'raquo'

False 면 텍스트가 조각나고 문자 참조는 다른 메서드로 갑니다. handle_data 만 구현한 추출기에서는 & 가 조용히 사라집니다. 기본값을 그대로 두세요.

HTML 에 정규식을 쓰는 함정

대부분이 처음에 쓰는 버전입니다. 쇼핑몰 2페이지에 대고 실행했습니다.

import re, urllib.request

html = urllib.request.urlopen("http://127.0.0.1:18160/ko/products?page=2", timeout=10).read().decode()

# attempt 1: "simple" regex
print("1:", re.findall(r'<a href="(.*?)">(.*?)</a>', html))
# attempt 2: let . match newlines
print("2:", re.findall(r'<a href="(.*?)">(.*?)</a>', html, re.S)[:2])
# attempt 3: greedy by accident
m = re.search(r'<a href="(.*)">', html, re.S)
print("3:", len(m.group(1)), "chars captured as one href")
# attempt 4: the site adds one attribute (same data, same browser view)
v2 = html.replace('<a href=', '<a class="p-link" href=')
print("4:", re.findall(r'<a href="(.*?)">(.*?)</a>', v2, re.S))
1: []
2: [('product/105', '\n      27인치 모니터\n    '), ('product/106', '\n      웹캠 HD\n    ')]
3: 616 chars captured as one href
4: []
  1. 하나도 못 찾습니다. . 은 줄바꿈과 일치하지 않는데 상품명이 따로 한 줄에 있기 때문입니다.
  2. re.S 를 붙이면 일치하지만 들여쓰기까지 딸려 와서 결국 정리해야 합니다.
  3. ? 하나를 빼면 .* 가 첫 링크부터 마지막 링크까지 616글자를 삼킵니다.
  4. 사이트가 class="p-link" 를 하나 넣었을 뿐(화면은 그대로)인데 정규식은 링크를 0개 찾고, 오류도 나지 않습니다.

같은 v2 페이지에 html.parser 추출기를 쓰면 이렇습니다.

v2: ['27인치 모니터', '웹캠 HD', '스탠드 조명', '노이즈 캔슬링 헤드폰']

속성을 이름으로 읽기 때문에 순서가 바뀌거나 속성이 늘어나도 상관없습니다. 정규식이 빛나는 곳은 이미 뽑아낸 항목 안(₩24,500 에서 24500 꺼내기)이지, 항목을 찾는 일이 아닙니다.

흔한 실수와 실제로 벌어지는 일

실수 여기서 벌어진 일 해결
요청한 URL 로 링크를 합침 /product/101, 404 urljoin(resp.url, href)
a.get("class") == "product" class="product featured" 를 놓침 나눈 뒤 "product" in classes
convert_charrefs=False 인데 handle_data 만 구현 Practice Shop & Co. 가 쪼개지고 & 가 사라짐 기본값 유지
<li> 에 </li> 가 없는 페이지 0 items, no error 결과 수를 세고 0개면 경보(6강)
zsh 에서 ? 가 든 URL 을 따옴표 없이 no matches found URL 을 따옴표로 감싸기

네 번째 줄은 다시 볼 가치가 있습니다. 브라우저는 <li> 를 알아서 닫아 주기 때문에 깨진 페이지도 화면에서는 멀쩡해 보입니다. 하지만 html.parser 는 브라우저가 아니라 적힌 대로 태그를 알려 줄 뿐입니다. 이 추출기는 </li> 를 만나야 항목을 저장하므로 하나도 저장하지 않았고 예외도 없었습니다. 빈 리스트를 돌려주는 스크레이퍼가 가장 위험하게 고장 난 스크레이퍼입니다.

어떤 페이지에 무엇을 쓸까

페이지 쓸 것 여기서 실행했나
JSON 이 있음(공공데이터포털 같은 API, 또는 <script> 안의 JSON) json.loads 예(1강)
평범한 목록·상세 페이지 html.parser(이번 강의) 예
아주 지저분한 HTML, CSS 선택자를 쓰고 싶음 BeautifulSoup, lxml 아니요(미설치)
이미 뽑은 텍스트 안의 값 하나 re 예
로딩 후 자바스크립트가 만드는 내용 페이지가 부르는 JSON 요청 찾기(6강) 설명만

BeautifulSoup 을 쓰면 상태를 직접 관리하지 않고 soup.select("li.product") 처럼 쓸 수 있습니다. 패키지를 설치할 수 있는 환경이라면 좋은 선택이고, 텍스트와 속성 구분, urljoin, 공백 처리 같은 이번 강의의 개념은 그대로 통합니다.

실측: 페이지 크기와 파싱 속도

쇼핑몰은 ?per_page=N 으로 상품 수를 마음대로 정해 한 페이지에 담을 수 있습니다. 10~10,000개짜리 페이지를 한 번씩 받고, 각각 5번 파싱한 중앙값입니다(Apple M1 Pro, time.perf_counter, 한국어 카탈로그).

상품 수 페이지 크기 html.parser 3.9.6 html.parser 3.12.14 정규식 3.9.6
10 2,041 B 0.50 ms 0.32 ms 0.01 ms
100 19,020 B 4.29 ms 2.75 ms 0.06 ms
1,000 189,824 B 41.62 ms 27.17 ms 0.59 ms
10,000 1,922,959 B 415.05 ms 269.83 ms 6.49 ms
막대그래프. 같은 상품 1만 개 페이지에서 Python 3.9.6 의 html.parser 는 408.6 ms, Python 3.12.14 의 html.parser 는 269.3 ms, Python 3.9.6 의 정규식은 6.8 ms.
상품 1만 개짜리 페이지 1장(1.86 MB, 영어 카탈로그)을 Apple M1 Pro 에서 파싱한 시간, 5회 중앙값. 정규식은 약 60배 빠르지만, 사이트가 속성 하나를 추가했을 때 깨진 것도 정규식이었다.

그림은 영어 카탈로그 페이지로 잰 것이고, 한국어 결과도 거의 같은 모양입니다. 요점은 세 가지입니다.

  • 시간은 페이지 크기에 비례합니다. 어느 크기에서나 3.9.6 은 초당 약 24,000개(4.6 MB), 3.12.14 는 약 37,000개(7 MB)를 처리했습니다.
  • 새 파이썬은 같은 코드로 약 1.5배 빠릅니다. 코드를 고칠 필요가 없습니다.
  • 파싱은 병목이 아닙니다. 상품 10개짜리 평범한 페이지는 0.5 ms. 1강의 1초 간격을 넣으면 실행 시간 중 파싱은 0.05% 입니다. 가장 빠른 파서보다 디자인 개편에 버티는 파서를 쓰세요.

솔직한 한계와 버전 메모

  • Python 3.9.6 으로 실행하고 3.12.14 로 비교했습니다. 추출 결과는 둘이 같았습니다. convert_charrefs 의 기본값은 Python 3.5 부터 True 입니다.
  • html.parser 는 트리를 만들지 않고, 브라우저(HTML5 파싱 알고리즘)처럼 깨진 중첩을 고쳐 주지 않으며, 자바스크립트도 실행하지 않습니다.
  • BeautifulSoup, lxml, html5lib 는 설치돼 있지 않아 실행하지 않았습니다. 그에 관한 설명은 측정이 아니라 문서에 근거합니다.
  • 페이지는 연습용 쇼핑몰이 만든 것입니다. 실제 페이지는 상품당 마크업이 훨씬 많아 상품당 파싱이 느립니다. 가져갈 것은 "비례한다"는 모양입니다.
  • UTF-8 이 아닌 인코딩(EUC-KR, CP949)은 6강에서 다룹니다. 그전까지는 문자 인코딩 판별 강좌를 참고하세요.

연습 문제

1. ProductParser 를 고치지 않고 각 항목에 재고 수(<span class="stock ...">)를 추가하세요.

2. 쇼핑몰 가격 문자열을 숫자로 바꾸는 to_number(text) 를 쓰고, 할인 전후 가격이 함께 적힌 24,500원 → 19,900원 에서는 어떻게 되는지 확인하세요.

3. next_url 을 써서, 페이지 사이에 1초씩 쉬면서 모든 페이지의 상품을 모으세요.

정답

1. 하위 클래스를 만들고 재고 span 에서 필드를 세웁니다.

class StockParser(ProductParser):
    def handle_starttag(self, tag, attrs):
        super().handle_starttag(tag, attrs)
        a = dict(attrs)
        if self.cur is not None and tag == "span" and "stock" in (a.get("class") or "").split():
            self.cur.setdefault("stock", "")
            self.field = "stock"

1페이지에서 int() 로 바꾸니 [('101', 0), ('102', 5), ('103', 9), ('104', 4)] 가 나왔습니다.

2. 숫자와 소수점만 남기고 변환합니다.

import re

def to_number(text, decimal="."):
    s = re.sub(r"[^\d" + re.escape(decimal) + "]", "", text)
    return float(s.replace(decimal, ".")) if s else None

for t in ["₩24,500", "8,900원", "24,500원 → 19,900원"]:
    print(t, "->", to_number(t))
₩24,500 -> 24500.0
8,900원 -> 8900.0
24,500원 → 19,900원 -> 2450019900.0

세 번째 줄이 함정입니다. 할인 전 가격과 할인가가 한 요소 안에 있으면 숫자가 그대로 이어 붙어 약 24억 5천만 원이 됩니다. 오류는 나지 않습니다. 실제 쇼핑몰은 보통 정가를 <del> 같은 별도 태그에 넣으니, 숫자로 바꾸기 전에 파서에서 어느 요소의 텍스트인지 먼저 나눠야 합니다. 금액을 저장할 때는 float 보다 decimal.Decimal 이 안전합니다.

3.

import time, urllib.request
from extract import ProductParser, UA

url, items = "http://127.0.0.1:18160/ko/products", []
while url:
    req = urllib.request.Request(url, headers={"User-Agent": UA})
    with urllib.request.urlopen(req, timeout=10) as resp:
        p = ProductParser(resp.url)
        p.feed(resp.read().decode())
    items += p.items
    print(url, len(p.items))
    url = p.next_url
    if url:
        time.sleep(1)
print("total", len(items))
http://127.0.0.1:18160/ko/products 4
http://127.0.0.1:18160/ko/products?page=2 4
total 8

이 반복문에 빠진 것(최대 페이지 수, 429 응답 처리, 재시도)은 3강에서 채웁니다.

다음 글

자주 묻는 질문

뽑아낸 링크가 맞아 보이는데 왜 404 가 나나요?

엉뚱한 기준 URL 과 합쳤을 가능성이 큽니다. 쇼핑몰은 / 를 /ko/products 로 리다이렉트했는데, href="product/101" 을 요청한 URL 과 합치면 /product/101(404), 실제로 도착한 resp.url 과 합치면 /ko/product/101(200)이 됐습니다. urljoin 에는 항상 최종 URL 을 넘기세요.

HTML 에 정규식을 쓰는 게 정말 그렇게 나쁜가요?

특정 페이지 하나에서는 동작하고 빠릅니다. 측정해 보니 html.parser 보다 약 60배 빨랐습니다. 하지만 단순한 패턴은 re.S 를 붙이기 전까지 아무것도 못 찾았고, 붙이면 공백까지 딸려 왔으며, 사이트가 href 앞에 class="p-link" 하나를 넣자 링크를 0개 찾았습니다. html.parser 는 그 변경에도 계속 동작했습니다.

html.parser 의 convert_charrefs 는 무슨 설정인가요?

기본값 convert_charrefs=True 에서는 &amp; 나 &quot; 같은 문자 참조가 handle_data 로 넘어오는 문자열 안에서 원래 문자로 바뀝니다. False 로 하면 문자열이 쪼개지고 문자 참조는 handle_entityref 로 가기 때문에, handle_data 만 구현한 추출기에서는 글자가 조용히 사라집니다. Practice Shop &amp; Co. 가 'Practice Shop ' 과 ' Co.' 로 쪼개졌습니다.

  • #웹 스크래핑
  • #초급
  • #파이썬
  • #html.parser
  • #urljoin
  • #정규식

t.laboratory

직접 만들고 운영하는 앱·서버·서비스에서 겪은 문제와, 그 해결에 쓴 코드를 기록하는 기술 블로그입니다.

운영자 소개 · 앱

↑