Skip to content

Instantly share code, notes, and snippets.

@mu-hun
Last active October 24, 2025 08:43
Show Gist options
  • Select an option

  • Save mu-hun/11dfa2f12beab5bbe6b0989fb3732af9 to your computer and use it in GitHub Desktop.

Select an option

Save mu-hun/11dfa2f12beab5bbe6b0989fb3732af9 to your computer and use it in GitHub Desktop.
마이크로소프트웨어 399호 “자동화의 광시곡” — “학교 생활 속 불편함 줄이기”, 2020.01, 개정 2판 → https://www.frontend.moe/posts/maso-399/

학교생활 속 불편함 줄이기

유저 스크립트에서 서비스 제작까지

주니어 인터렉션 마법사. 적당한 버전 관리 프로그램을 찾다가 깃(Git)을 다루게 되면서 프로그래밍을 필연적으로 시작했다.

새 학기마다 어떤 사이드 프로젝트를 진행할 지 심각하게 고민을 하고 있다.

유저 스크립트 소개

유저 스크립트는 모던 브라우저 확장 기능과 비슷하다. 인터넷 브라우징을 확장하기 위해 웹 페이지를 수정할 수 있는 스크립트다. 확장 기능보다 가볍게 특정 웹 페이지 기능을 코드로 수정하거나 자동화하는 용도로 많이 쓴다. 유저 스크립트를 사용하려면 특정 브라우저를 지원하는 유저 스크립트 매니저를 선택해야 한다.

유저 스크립트 매니저

  • Chrome: Tampermonkey, Violentmonkey
  • Firefox: Greasemonkey, Tampermonkey, Violentmonkey
  • Safari: Tampermonkey
  • Microsoft Edge: Tampermonkey
  • Opera: Tampermonkey, Violentmonkey
  • Maxthon: Violentmonkey
  • Dolphin: Tampermonkey
  • UC: Tampermonkey

유저 스크립트 저장소

  • userscript.org
  • OpenUserJS
  • Greasy Fork
  • Github Gist 검색

역사

영문 위키백과에 따르면, 가장 처음 발표된 유저 스크립트 관리 프로그램은 2004년 11월 28일 등장한 그리스몽키Greasemonkey다. 에런 부드먼Aaron Boodman은 올뮤직AllMusic이라는 웹 서비스 UI를 변경하는 확장 기능에서 영감을 받아, 수많은 사이트의 UI를 수정하고 싶었다. 하지만 확장 기능을 사용하기에는 목적에 맞지 않게 너무 무거웠다. 그는 단지 여러 웹 페이지를 수정하는 도구를 만들고 싶었다. 그래서 그리스몽키를 만들었다. 2005년에는 여러 사용자에 의해 115개 사이트에 대응하는 유저 스크립트가 만들어졌다.

보통 어떤 작업을 하는가

유저 스크립트는 특정 사이트에서 UI를 수정하기 위해 만들어졌고, 지금도 UI를 수정하기 위해 가장 많이 사용된다. 문서 객체 모델Document Object Model에 직접 접근하는 것을 응용해, 웹 브라우징 자동화도 할 수 있다.

just-news: 뉴스 페이지 본문만 보고 싶다.

적용 이전 적용 이후
1_left 1_right

<그림1> ‘just-news’ 유저 스크립트 프로젝트

‘just-news’는 국내 오픈소스 커뮤니티에서 가장 많은 기여자를 보유한 유저 스크립트 프로젝트다. (나도 기여했다!) 국내 인터넷 뉴스 사이트에서 불필요한 정보는 제거하고 기사 본문으로만 이뤄진 페이지를 재구성한다. 페이지 로딩이 덜 돼도, 기사 전문을 다 읽어 들이면 전용 리더 뷰로 대체하는 강력한 본문 파서를 가진 것이 특징이다.

반복하는 입력을 한번만 수행하기 : 카카오페이 PC 결제

QR코드로 결제 카톡 메세지로 결제
2_left 1_right

<그림2> 데스크톱 카카오페이 결제 시스템

카카오페이 결제를 데스크톱에서 이용하려면 아래 4단계 절차를 매번 거쳐야 한다.

  1. QR코드를 스캔하거나 탭을 전환해, 전화번호와 생년월일 기재를 유도한다.
  2. 카카오페이 앱 내부 QR 코드 타입과 결제 페이지 QR 코드 타입이 다르다는 것을 깨닫는다. (사용자가 당황한다)
  3. QR 코드 리더 앱을 통해(안드로이드 사용자는 별도의 앱 설치 시간이 소요된다) 1~2초간 기다려 URL 정보를 얻는다. 혹은 상단 오른쪽 탭으로 전환해 전화번호와 생년월일을 입력하고, 카카오톡 메시지로 링크를 받는다.
  4. 결제 비밀번호나 지문 인식을 통해 결제를 마무리한다.

안드로이드 사용자는 QR 코드 스캔을 다른 앱으로 해야 하는 것에 불편함을 느낄 법 했다. 전화번호와 생년월일을 매번 입력하는 대안책도 번거로워 보였다.

다행히 해당 웹 서비스는 SPA(Single Page Application)로 만들어져 있었다. 숨겨진 입력 폼 엘리먼트 값을 <코드1> 유저스크립트로 채우면 번거롭게 입력하는 일을 더는 하지 않아도 된다.

// ==UserScript==
// @name   kakao-pg
// @version 1
// @match https://pg-web.kakao.com/v1/*/info
// @match https://pg-web.kakao.com/v1/*/info#none
// ==/UserScript==

window.onload = () => {
  document.querySelector('#userPhone').value = '0101234567';
  document.querySelector('#userBirth').value = 840301;
  document.querySelector('.btn_payask').click();
};

<코드1> kakao-pg.user.js


제주대 학생과 교수진은 ‘하영드리미(dreamy.jejunu.ac.kr)’라는 서비스로 학적을 관리한다. 온라인 강의와 출석은 ‘이러닝 웹 사이트(elearning.jejunu.ac.kr)’를 이용한다. 여느 공기관 웹 서비스처럼 사용자에게 많은 책임을 떠넘기고 있다. 나뿐 아니라 다른 학생도 큰 불편함을 느끼고 있다.

하영드리미 로그인 개선

하영드리미는 교내 공용 컴퓨터에서도 접속할 수 있어, 시간제한을 두고자 세션 인증 방식을 쓴다. 세션 인증 방식은 접속 시간에 제한을 두어 일정 시간 응답이 없으면 세션을 끊도록 설정하는 방식이다. 그래서 잠시 다른 탭을 보다가 돌아오면 로그인이 풀려 다시 학번과 비밀번호를 수기로 입력해야 했다. 나는 개인 컴퓨터를 사용하고 있어 너무 불편했다.

6

<그림3> 하영드리미 세션 만료

로그인 페이지는 /frame/index.do라는 라우터를 사용하고, 메인 페이지는 /frame/main.do 라는 라우터를 사용하고 있었다. 나는 이 구조를 이용해 스크립트를 작성했다. 세션이 만료돼 /frame/index.do 페이지로 리디렉션Redirection될 때, 로그인 버튼을 클릭하는 <코드 2>가 실행된다.

// ==UserScript==
// @name   dreamy
// @match https://dreamy.jejunu.ac.kr/frame/index.do*
// ==/UserScript==
window.onload = () => {
  if (document.getElementsByName('frmLogin').length) {
    document.querySelector('#userid').value = 1234567890;
    document.querySelector('#password').value = 'password';
    document.querySelector('#act_lgn').click();
  }
};

<코드2> dreamy.user.js

온라인 강의 접속 개선

이러닝 강의를 수강하려면, 일부러 보안 인증서가 없는 HTTP 프로토콜 주소를 사용해야 했다. 강의 콘텐츠는 HTTP로 배포되고, 이러닝 사이트는 TLSTransport Layer Security 프로토콜을 사용하는 환경이기 때문이다. 모던 브라우저는 HTTP와 TLS 프로토콜이 섞인 브라우징을 허용하지 않는다.

Mixed active content 차단 정책

mdn.io/Mixed_content

<그림4> 보안 정책으로 차단된 임베딩 콘텐츠

나는 강의보다 출석 기능을 활용하려고 이러닝 사이트에 HTTPS로 접속하는 빈도가 높았다. 온라인 강의를 수강할 때마다 평소 사용하던 HTTPS에서 ‘S’를 직접 빼내야 해서 번거로웠다. 이런 수고를 줄이기 위해, 차단되는 비디오 소스를 새 창을 열어 보여주는 유저 스크립트를 <코드3>과 같이 작성했다.

const regex = RegExp('^http://common.jejunu.ac.kr/em/[a-zA-Z0-9]*$');
const frame = document.querySelector('iframe#bodyFrame');

const INTERVAL = 100;
const trigger = setInterval(async () => {
  if (regex.test(frame.src)) {
    open(
      frame.src,
      '_blank',
      'width=' + window.innerWidth + ', height=' + window.innerHeight
    );
    clearInterval(trigger);
  }
}, INTERVAL);

<코드3> elearning.user.js

<그림5> 비보안 임베딩 콘텐츠를 <코드3>의 도움을 받아 새 창에서 연 모습

정규식으로 캡처한 common.jejunu.ac.kr/em/\* 패턴이 iframe#body 선택자의 src 값과 일치하면 새 창으로 연다. 동기적인 흐름으로는 src 값 캡처가 안 돼서, 비동기 구문을 사용했다. 다른 학생도 같은 문제를 겪고 있을 것 같아 확장 기능으로 만들어 교내 커뮤니티에 공유했다. 문제를 해결하고 배포까지 해보는 값진 경험이었다.

학식 데이터를 수집하기

6

<그림6> 주간 학식 메뉴

우리 학교의 주간 학식 메뉴는 ‘대학생활 > 학생생활안내 > 금주의 메뉴’ 페이지에서 볼 수 있다. 나는 매일 표 안에서 세로 방향 속성 중 특정 요일 메뉴를 찾는 일이 번거로웠다. 무의미한 반복을 줄이기 위해 파이썬 뷰티플수프BeautifulSoup 모듈을 활용해 파서를 작성하고, 그 결과를 매주 AWS 람다Lambda로 S3에 올려 AWS 게이트웨이Gateway를 통해 API로 배포했다.

7

<그림7> PWA 앱 - bob.muhun.kim

제주대학교 학식 조회 모듈: pypi.org/project/jejunuMeals

학교 홈페이지 학식 정보는 매주 갱신된다. 웹 앱을 열 때마다 매번 요청하는 것은 자원 낭비다. 그래서 서비스 워커 기술을 이용한 버전 관리 로직을 설계했다. 서비스 워커(Service Worker)는 일종의 프록시 역할을 한다. 요청에 대한 응답 데이터를 중간에 가로채서 보관하거나, 이미 보관된 정보를 응답할 수 있다. API 응답 정보는 한번 저장되면 오프라인에서 조회할 수 있다.

이를 이용해 서비스 워커에서 매주 월요일 10시 이후 데이터를 갱신하도록 캐시 버전 관리를 설계했다. <코드4>의 서비스 워커는 getCacheVersion으로 받은 문자열과 같은 이름을 가진 캐시 저장소가 없으면 새로 저장소를 생성하고, 전에 있던 저장소는 제거하도록 했다.

const getCacheVersion = () =>
  `api_${(() => {
    const DATE = new Date()
    const weekday = Math.floor(DATE.getDate() / 7)
    const dayOfWeek = DATE.getDay()
    
    const isWeekend = dayOfWeek === 0 || dayOfWeek === 6
    const notYet = dayOfWeek === 1 && DATE.getHours() < 10
    
    return isWeekend || notYet ? weekday - 1 : weekday
  })()}`

// ...(중략)

self.addEventListener('install', (evt) => {
  evt.waitUntil(
    caches
      .open(getCacheVersion())
      .then((cache) => cache.addAll([API_URL]))
      .catch((evt) => console.log(evt))
  );
});

self.addEventListener('activate', (evt) => {
  const cacheWhitelist = [CACHE_NAME, getCacheVersion()];
  evt.waitUntil(
    caches.keys().then((names) =>
      Promise.all(
        names.map((name) => {
          if (cacheWhitelist.indexOf(name) === -1) return caches.delete(name);
        })
      ).catch((err) => console.log(err))
    )
  );
});

<코드4> public/sw.js

교내 학적 관리 웹 서비스를 개선하기

사용자가 서버 상황을 신경 쓰지 않도록 하는 것이 올바른 추상화 설계다. 하지만 우리 학교 UX는 컴퓨터 전공자가 아닌 사람도 데이터 필드가 명확하지 않은 것을 느낄 만큼 부실했다.

<표1> 전체 성적 요청 페이지에서 사용하는 AJAX 요청 중 하나

요청 메서드 URL 폼 데이터 쿠키
POST https://dreamy.jejunu.ac.kr/susj/com/com_su.jejunu {"mode":"doSchd","job_id":"SEONGJEOG_TOT"}} JSESSIONID, WMONID

<표1>은 전체 성적을 불러오는 로직이 사용하는 요청 정보이다. 폼 데이터의 개별 값이 무엇을 의미하는지 유추하기 어려웠다. 나중에서야 SEONGJEOG_TOT가 ‘모든 성적을 조회하는 것’을 의미하는 로마 표기법에서 따온 은어로 알아챘지만, 응답 데이터에는 그에 관련한 정보를 찾을 수 없었다. 아마 과거에 썼던 레거시 코드가 방치된 게 아닌가 싶다.

{
  "SCHEDULE_VALUE": {
    "b_ipp": "",
    "b_page": "",
    "b_rnum": "",
    "b_tot_cnt": "",
    "ballCheck": "",
    "chkSessionId": "2018567890",
    "chkUserGb": "111",
    "cls_cd": "",
    "cnt": "",
    "com_cd": "",
    "com_cd_div": "",
    "com_cd_nm": "",
    "create_dt": "",
    "create_no": "",
    "dat_flag": "",
    "del_flag": "",
    "dept_cd": "",
    "entry_dt": "",
    "entry_no": "",
    "evl_yn": "",
    "from_time": "",
    "group_gb": "",
    "job_id": "SEONGJEOG_TOT",
    "job_value": "N",
    "log_gb": "",
    "login_no": "",
    "maj_cd": "",
    "prog_id": "",
    "prog_nm": "",
    "rownum": "0",
    "search": "",
    "student_no": "",
    "subject_cd": "",
    "sys_time": "20191118205412",
    "term_gb": "0",
    "to_time": "",
    "univ_cd": "",
    "user_ip": "",
    "year": "2010"
  }
}

<코드6> 전체 성적 조회 요청의 응답 데이터

어떤 요청이든 응답 데이터를 열어보면, <코드 6>처럼 키의 의미나 데이터 타입(숫자, 문자)을 유추하기 어려웠다. 가장 심각한 문제는 모든 컴포넌트가 아이프레임iframe으로 분리됐던 탓에, 페이지 로딩이 꽤 느렸다.

8

<그림8> 시간 초과로 로딩에 실패한 아이프레임 콘텐츠

이런 환경을 개선하기 위해, 꼭 필요한 데이터만 요청하고 프리액트Preact 컴포넌트를 사용하는 유저 스크립트를 시험적으로 만들었다. 프리엑트란 리엑트React의 경량화된 UI 라이브러리로, 가벼운 프로토타이핑을 위해 리엑트의 대체품으로 사용했다. 앞에서 소개한 ‘just-news’ 코드를 참고해 별 어려움 없이 만들 수 있었다

시험적으로 만든 유저 스크립트 링크: github.com/reflation/prototype

9

<그림9> 프로토타입 스크린샷

기본 인터페이스로 로그인/로그아웃을 구현했다. 로그인에 실패한 횟수를 세는 기능, 세션이 만료되지 않았다면 메인 페이지로 리디렉션하는 기능, 세션 만료 알림 기능, 원본 페이지를 보는 기능까지만 구현하고 중단했다. 기존 웹 페이지에 제 3자 컴포넌트를 집어넣고 관리하는 것에 어려움을 겪었기 때문이다.

MVP 실현하기

부딪힌 어려움에 대한 돌파구를 찾고자 멘토를 구해 조언을 얻었다.

: 기존 UI를 리액트 컴포넌트로 전부 대체하는 작업이 어렵네요..

멘토: 일단 목업(Mock-up) 개발 환경을 만들어 작업해보면 어떨까요? 그리고 모든 학적 데이터를 처리하기에는 양이 많은데, 대학생이 가장 관심 있어 할 정보가 뭘까요?

: 졸업이죠.

멘토: 그럼 학점 시각화를 목표로 일주일 안에 MVP를 만들어보세요.

MVP란 최소 기능 제품Minimum Viable Product의 약자로, 고객 피드백을 받아 최소 기능을 구현한 제품이다. ‘나는 언제 졸업할 수 있을까?’라는 주제로 MVP를 매주 개발하기로 했다. 독립적인 프론트엔드 환경에 맞는 실제 API와 유사한 목업 API를 구현했다.

실제 URL 목업 URL mode year term_gb = 학기 구분 outside_seq = 교류 수학 여부
모든 학기 리스트 조회 /susj/sj/sta_sj_3230q.jejunu / doSearch
각 학기의 성적 데이터 조회 /susj/sj/sta_sj_3220q.jejunu / doList 2018 10 or 11 or 20 or 21 0 or 1

<표2> 목업 API 응답 데이터 일부 ‌

응답 데이터 내부에 필요 없는 속성이 많아 일부만 설명하겠다. 모든 학기 항목을 조회해 받은 year, term_gb, outside_seq 속성은 개별 학기 학점 데이터를 요청할 때 쓴다. 모든 학기 항목을 조회하는 이름으로 doSearch 보다 doList가 적절해 보이지만, 실제 API를 본따서 만드는 목업 API였기에 어쩔 수 없었다.

10

<그림10> 첫번째 MVP

우선 학점 조회 기능만 구현해 일주일 만에 첫 MVP를 주변 지인에게 선보였다. 받은 피드백은 다음과 같았다.

1차 피드백

  • 최대로 취득 가능한 평점
  • 졸업 가능한 최소한의 점수 제시
  • 성적 관리의 부담을 줄이기
  • 필수로 이수해야할 교양 강의 달성률

일반적인 모던 웹 서비스는 API 서버가 잘 정돈된 JSON 문서를 제공하고, 이를 받은 프론트엔드는 응답 데이터를 분석하는 데에 큰 비용을 들이지 않는다. 하지만 우리 학교 프론트엔드 환경은 앞에서 이미 얘기했듯이 정반대 상황에 놓여 있다. 기존 서비스는 웹 컴포넌트 대용으로 아이프레임을 사용해 부담을 줬다면, 이제는 확장 기능을 만들며 데이터를 정규화하는 작업 비용이 복잡해졌다.

이 문제를 풀어야 피드백을 반영해 추가 기능을 만들 수 있었다. 주기적으로 데이터를 크롤링해 정규화 작업을 하는 서드파티 전적 서비스(온라인 게임이나 온라인 저지 같은) 사례가 생각났다. 해당 비즈니스 로직을 차용해, 별도 서버에서 데이터 정제 및 저장을 부담하고, 프론트엔드에서는 그 결과만 보여주기로 했다.

두번째 MVP

클린 코드

교내 성적 데이터를 가져오고 정규화하는 fetchAndParse 함수 작성을 시작으로 API 서버를 구현하기 시작했다. 이 당시 클린 코드(로버트 C. 마틴)라는 책의 함수 챕터를 읽고 있었는데, 저자의 조언을 받아 <코드 7처럼> 한 함수(function) 하나당 기능(function) 하나를 수행하도록 정리했다.

export const fetchAndParse = async (account: UserNoPw) => {
  const {
    data: { semestersReqParams, ...user },
    cookie,
  } = await fetchList(account);
  const rawSemester = await Promise.all(
    semestersReqParams.map((params) => fetchSemester({ cookie, params }))
  );
  return { ...user, semesters: postSemesters(rawSemester) };
};

<코드7> 함수 정리, src/fetch/index.ts

타입 안전한 코드 작성하기

<코드7>은 타입스크립트로 작성되었다. 타입스크립트는 자바스크립트의 상위확장된SuperSet 언어로, 정적 타이핑을 지원하며. 다른 정적 타이핑 언어와 비슷하게 리터럴의 타입을 보장받을 수 있다.

11

<그림11> 타입 힌트 기능(src/fetch/postProcesser.ts)

학교 메일 기반으로 인증 체계를 설계하고, 데이터를 동기화하는 절차를 빠르게 구현했다.

12

<그림12> 메일로 받은 로그인 링크

13

<그림13> 성적 조회 페이지

<그림13>을 보면, 왼쪽에 학기 항목이 적힌 메뉴가 있다. 특정 학기를 선택하면 해당 학기에 이수한 과목 리스트를 표로 볼 수 있다. 해당 학기 데이터를 차트 데이터로도 볼 수 있도록 기능 추가에 초점을 뒀다. 다만, 여름과 겨울 학기는 차트로 보여주기엔 데이터 양이 적어서 표만 나오도록 작업했다.

가짜 기민함에서 벗어나기

두 번째 MVP를 만들며 뭔가 잘못돼가고 있는 느낌을 받았다. 왜냐하면 수고스러운 가입 절차를 거쳐 제 3자에게 개인 학적을 맡기는 걸 이해할 학생이 별로 없을 것 같았다. 돌이켜 보면 테크 데모에 그쳐도 좋다는 가짜 기민함으로 인해, 잘못 설계한 MVP를 밀고 나갔던 것 같다. 리스크를 멈추기 위해 가장 처음으로 사용한 프로토타입 환경을 다시 고려해, 재시작하며 두 가지 원칙을 만들었다.

두 가지 원칙
  1. 해킹(삽질)을 어떻게 줄이는가?

    빠르게 만들고 검증해 실패율을 낮춘다. 첫 시도부터 어렵게 접근하기보다는 기존에 익혔던 패턴을 사용한다.

  2. 시스템 제작과 사용을 분리하자.

    시스템 제작과 사용은 분명한 경계를 두고 사용해야 한다. 비즈니스 로직에서 감춰도 될 부분은 외부 API로 추상화한다.

14

<그림14> 확장 기능을 통해 구현한 UI

빠르게 시작하기 위해 다른 사람이 만든 ‘프리액트 + 웹팩Webpack + 타입스크립트’ 템플릿을 확장 기능에서 동작하도록 수정했다. 기존 로그인 페이지에서 로그인 유지 기능을 붙인 제품을 MVP로 삼았다. 한두 번 확인할 성적 데이터를 제공하는 기능보다는, 매번 마주하는 로그인 화면을 건너뛰는 기능이 더 유용하기 때문이다.

두 번째는 메인 페이지 작업에 기반하는 규칙이다. 분량이 긴 응답 데이터를 정제하는 작업을 외부 모듈로 분리하도록 방향을 정했다.

{
  "compilerOptions": {
    "paths": {
      "react": ["node_modules/preact/compat"],
      "react-dom": ["node_modules/preact/compat"],
      "linaria/react": ["src/utils/styled.ts"]
    }
  }
}

프리액트에서 ‘CSS in JS’를 사용하고 싶어, 리나리아(Linaria)라는 경량 스타일 도구에 있는 ‘Styled API’를 프리액트에 맞게 덮어씌웠다. 원래부터 이에 능숙했던 것은 아니다. 처음에는 프리액트를 지원하는 방법을 찾지 못해 인라인 스타일로 ‘CSS in JS’를 흉내내서 UI를 빠르게 만들어봤다. 새로운 시도에 대한 위험 부담이 적다고 판단되면, 다음 작업 큐에 넣는 식으로 작업을 진행했다.

하영드리미 확장 기능 깃허브 저장소: github.com/reflation/extension

마무리

2019년 12월 1일, 평가원 사이트의 허점으로 수험생 312명의 수능 성적표가 예상보다 일찍 공개됐다. 2주가 채 지나지 않아 소스 보기 기능을 차단한 웹 브라우저를 국내 기업에서 개발한다는 소식을 들었다. (프로그래머의 실수를 보이지 않게 하기 위해서라고...?) 나는 차의 보닛을 용접하고 출고하는 것과 같은 발상이라 생각해서 전혀 납득할 수 없었다.

두 번 정도 프로젝트 방향을 바꾸면서 생각보다 오랜 시간이 걸렸지만, 다양한 관점에서 문제를 바라본 덕에 실력이 늘었다. 중간에 MVP를 잘못 설계했지만, 그 덕에 기술을 만들 때 타인을 관찰하는 것이 중요함을 몸소 체감했다.

사용자에게 필요 이상으로 책임을 넘기는 설계를 개발자가 의식하고 점차 줄여간다면, 줄인 시간에 최소 몇십 배를 곱한 만큼 사용자가 소모하는 시간을 아낄 수 있다. 더는 자동화를 신경쓰지 않고 중요한 일에 제대로 집중 할 수 있는 날이 왔으면 좋겠다.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment