아마란스(gw.goorm.io) 근태 API를 직접 호출해 이번 달 남은 근무시간을 계산합니다.
하루 8시간제 / 유연근무 최소 6시간 / 월 단위 정산 기준.
두 가지 형태로 제공합니다.
| 위치 | 세션 | |
|---|---|---|
| Chrome 확장 | manifest.json, src/ | 브라우저 쿠키를 읽음 |
| Android 앱 | app/ — Capacitor | 앱에서 직접 로그인 |
계산 로직(lib/time.js · holidays.js · calc.js)은
플랫폼 의존성이 전혀 없어 양쪽이 같은 파일을 공유합니다 (app/sync-lib.sh 로 복사).
- 확장:
./pack-extension.sh→amaranth-extension-vX.zip - 앱:
cd app && npx cap sync android && (cd android && JAVA_HOME=/opt/homebrew/opt/openjdk@21 ./gradlew assembleDebug)→ APK - GitHub 릴리스에 두 산출물을 올리면, 확장이 src/lib/updater.js 로 최신 태그와 버전을 비교해 업데이트 알림을 띄웁니다.
chrome://extensions→ 우측 상단 개발자 모드 켜기- 압축해제된 확장 프로그램을 로드 → 이 폴더 선택
- 툴바 아이콘 클릭
gw.goorm.io에 로그인만 되어 있으면 어느 탭에서든 동작합니다. 근태 페이지를 열어둘 필요가 없습니다.
근태 페이지에 들어가면 패널이 함께 뜹니다. 헤더를 잡고 끌어서 원하는 자리에 둘 수
있고, 접으면 작은 버블로 줄여 남은 시간만 띄울 수 있습니다(설정). 팝업 설정
에서 아예 끌 수도 있고,
켜 둔다면 위치를 네 모서리 중 고를 수 있습니다(기본 우측 하단). 위치는 패널
헤더의 ⤢ 로도 바꿉니다. 어느 쪽에서 바꾸든 열려 있는 탭에 즉시 반영됩니다 —
새로고침이 필요 없습니다.
드래그한 좌표는 모서리 설정보다 우선합니다. 모서리를 다시 고르면 좌표가 지워집니다. 창 크기가 바뀌면 화면 밖으로 나간 패널을 다시 당겨 넣습니다. 몇 픽셀 이내의 움직임은 클릭으로 봅니다 — 안 그러면 접기 버튼을 누르다 흔들려도 위치가 바뀝니다.
패널을 끄면 DOM 에서 지우고 자동 갱신도 멈춥니다. 팝업은 열 때마다 따로
조회하므로 백그라운드로 API 를 두드릴 이유가 없습니다 — 근태신청서·결재처럼
window.open() 으로 열리는 별도 팝업 창에는 뜨지 않습니다.
| 항목 | 정의 |
|---|---|
| 이번 달 인정근무 | Σ appworkTm — 마감된 날의 연차·반차 인정분은 서버가 이미 반영 |
| 휴가 인정 (예정) | 아직 집계 전인 오늘·미래 휴가. 반차 = 소정의 절반 |
| 월 소정근로 | 8시간 × 근무일수 |
| 남은 근무시간 | 월 소정근로 − (인정근무 + 휴가 인정) |
| 남은 근무일 | 오늘 포함, 소정근로시간이 있고 휴가로 다 덮이지 않은 남은 날 |
| 하루 필요 | (남은 근무시간 − 이미 정해진 날의 필요분) ÷ 나머지 근무일 |
| 어제까지 누적 | Σ(인정근무 + 휴가 인정 − 8시간) (마감된 날) |
서버의 selfCommuteStandardWorkTm 은 법정근로시간이라 8시간보다 짧게 잡히는 날이 있습니다.
그 값을 기준으로 삼으면 실제로 채워야 하는 시간과 어긋나므로, 서버 값은 "그날이 근무일인가"를
판별하는 데만 쓰고 시간은 설정값(기본 8시간)으로 고정합니다.
반차가 걸린 날은 그날 필요분(8h − 4h = 4h)이 이미 정해져 있습니다. 이걸 평균에 섞으면
8시간 아래로 끌려 내려가서, 정작 평범한 날 몇 시간 일해야 하는지를 알 수 없습니다.
그래서 반차 날과 계획을 넣은 날은 분자·분모에서 빼고 나머지 날로만 평균을 냅니다.
남은 125시간 32분, 남은 근무일 17일 (반차 2일 포함)
섞어서 계산 → 125:32 ÷ 17 = 7시간 23분 ← 평범한 날 기준으로는 틀린 값
반차 분리 → (125:32 − 8h) ÷ 15 = 7시간 50분 ✓
| 칸 | 뜻 |
|---|---|
| 흰 칸 + 옅은 테두리 | 남은 근무일 |
| 회색 | 마감 (지나간 근무일) |
| 파란 칸 + 왼쪽 띠 | 휴가 |
| 흰 칸 + 파란 테두리 | 내가 정한 계획 |
| 테두리 없음 | 휴일·주말 |
지난 휴가가 휴가로 안 보이던 문제가 있었습니다. 지난 날은 조건문에서 전부
마감 으로 빠져서, 내가 쉰 날이 평범한 지난 근무일과 똑같이 보였습니다.
이제 지났든 안 지났든 휴가는 휴가입니다(지난 휴가는 조금 가라앉힙니다).
서버가 이미 반영한 휴가는 creditMin 이 0 이라 그것만으로는 못 찾습니다.
atNm 도 안 됩니다 — "휴게" 가 같이 걸립니다. atCd 앞자리로 가릅니다
(1xxx 휴가 · 5xxx 휴게).
마감 색도 바꿨습니다. #eef1f6 이라 일반 칸(#f5f7fb)과 사실상 같은 회색이었습니다.
주말·공휴일에 일한 시간은 보상휴가로 나갑니다. 이번 달 소정근로를 채우는 게
아닙니다. 그래서 인정근무 는 근무일만 셉니다.
실제로 틀렸던 값입니다 — 한글날 348분 + 토요일 408분을 일한 달에:
| 틀린 값 | 맞는 값 | |
|---|---|---|
| 인정근무 | 61:31 | 48:55 |
| 남은 시간 | 98:29 | 111:05 |
| 하루 필요 | 7:02 | 7:56 |
| 어제까지 누적 | +0:55 | +0:55 |
쉬는 날 근무 12시간 36분을 더해 버려서 이미 채운 것처럼 보였고, 남은 시간이 그만큼 적게 찍혔습니다. 그 시간은 나중에 보상휴가로 받는 것이지 이번 달 소정이 아닙니다.
한 일이 사라져 보이면 안 되므로 쉬는 날 근무 12시간 36분 (보상휴가) 로 따로
적습니다.
어제까지 누적 은 원래부터 근무일만 세고 있어서 맞는 값이었습니다. 1.8.8 에서
"두 숫자가 어긋난다" 고 보고 누적을 모든 날로 바꿨는데, 어긋난 원인은 누적이
아니라 인정근무 쪽이었습니다. 되돌렸습니다.
남은 근무일 소정 합계 − 누적 = 남은 시간
둘 다 근무일만 세므로 이 등식이 성립합니다. 날짜별로 확인하려면 tools/dump-month.js 를 팝업 콘솔에 붙여 넣으면 됩니다.
툴바 팝업에 달력이 항상 펼쳐져 있습니다. 남은 근무일을 눌러 그날 일할 시간을 정해두면, 나머지 날에 하루 몇 시간이 필요한지 즉시 다시 계산됩니다.
팝업 순서는 남은 근무시간 → 오늘 출퇴근 → 월 집계 → 달력 입니다.
오늘 정보를 보려고 달력 아래까지 스크롤할 일이 없도록 달력 위에 둡니다.
남은 144시간 / 18일
계획 없음 → 하루 8시간
9/3 하루만 6시간 → 하루 8시간 7분 (138h ÷ 17일)
9/3~9/9 다섯 날 6시간 → 하루 8시간 46분 (114h ÷ 13일)
남은 날 전부 6시간 → 계획 부족 36시간 (18일 × 6h = 108h)
달력의 색: 파란 테두리 = 계획, 하늘색 = 휴가, 회색 = 마감(실적), 주황 테두리 = 오늘.
마감된 날 칸에는 월 집계와 같은 값이 들어갑니다 — 인정근무에 휴가 크레딧을 더한
값입니다. 칸의 합이 이번 달 인정근무 와 정확히 일치합니다.
마감된 날과 휴일은 누를 수 없습니다. 계획은 브라우저에만 저장되고 그룹웨어로 전송되지 않습니다.
유연근무 하루 최소 6시간 기준으로 판정합니다.
남은 시간 ≤ 남은 일수 × 6h→ 매일 6시간이면 충족 (초록)6h × 일수 < 남은 시간 ≤ 8h × 일수→ 매일 6시간으론 부족 (주황)남은 시간 > 8h × 일수→ 8시간을 다 채워도 부족 (빨강)
오늘 출근했다면 언제 퇴근하면 되는지를 세 기준(최소 6h / 월 페이스 / 정량 8h)으로 보여줍니다.
출근시각 + 근무시간 + 휴게시간으로 계산하고, 각 퇴근 시각 옆에 더 일해야 하는 시간을
괄호로 붙입니다 — 18:00 (5시간 남음), 이미 지났으면 (충족).
경과도 남은 시간도 벽시계가 아니라 실제 일하는 시간입니다. 점심 휴게(12:00~13:00 고정)와 겹치는 만큼을 양쪽에서 뺍니다. 그래서 경과 + 남은 = 오늘 필요 시간이 항상 맞습니다.
| 09:00 출근 · 정량 8시간 | 경과 | 남은 | 합 |
|---|---|---|---|
| 11:30 | 2시간 30분 | 5시간 30분 | 8시간 |
| 12:30 | 3시간 | 5시간 | 8시간 |
| 14:00 | 4시간 | 4시간 | 8시간 |
휴가 구간이 휴게를 이미 흡수한 날은 빼지 않습니다. 오전반차 후 09:00~13:00 은 쭉 일하므로 뺄 휴게가 없습니다.
휴가를 쓴 날은 유연근무 하한(6시간)이 적용되지 않습니다. 그날 남은 소정근로가 곧 최소
근무시간입니다 — 오전반차(4시간 인정)면 최소도 정량도 8h − 4h = 4h 하나로 합쳐 보여줍니다.
휴게시간은 하루에 한 번만 적용됩니다. 오전반차 09:00~14:00 처럼 휴가 구간이 이미
휴게를 흡수했다면, 남은 근무에는 또 더하지 않습니다. 이건 60분 기본 휴게 이야기이고,
휴게·외출 신청은 다루지 않습니다 (아래 참고).
| 상황 | 출근 | 퇴근 |
|---|---|---|
오전반차 09:00~14:00 |
14:39 | 14:39 + 4h = 18:39 (휴게 추가 없음) |
오후반차 14:00~18:00 |
09:00 | 09:00 + 1h + 4h = 14:00 |
| 휴가 없음 | 10:00 | 10:00 + 1h + 8h = 19:00 |
시간연차 16:00~18:00 |
09:00 | 09:00 + 1h + 6h = 16:00 |
2026년 8월 실제 데이터로 대조했습니다.
8월 소정근로 160시간 (20일)
8월 인정근무 164시간 55분
8/31 제외 누적 +4시간 38분 ← 그룹웨어 화면의 "8월 누적 04시간 38분초과달성"과 일치
네 엔드포인트를 씁니다.
POST https://gw.goorm.io/personal/hpd0210/getWorkTimeList
Authorization : Bearer <cookie.oAuthToken>
transaction-id: 32자리 랜덤 hex
timestamp : Math.floor(Date.now()/1000)
Access-Domain : https://gw.goorm.io
menu-code : HPD0210
wehago-sign : Base64( HmacSHA256(token + tid + timestamp + pathname, cookie.signKey) )
※ pathname 만 서명한다 (쿼리스트링 제외)
{"atDtList": ["20260901", "20260902", ...]}
- WEHAGO(더존 Amaranth 10) 플랫폼의 서명 요청 방식입니다. 구현은 src/lib/gwapi.js.
- 토큰(
oAuthToken)과 서명키(signKey)는 쿠키에 있습니다 (HttpOnly 아님). coCd/empCd는 보내지 않아도 됩니다 — 서버가 토큰에서 판별합니다.atDtList에 원하는 날짜를 나열하면 되므로 월 전체를 한 번에 가져옵니다.
주요 응답 필드:
| 필드 | 의미 |
|---|---|
appworkTotalTm |
인정근무 (분). 우리가 쓰는 값. 회사가 얹어 주는 시간까지 포함 |
appworkTm |
인정근무 (분). 얹어 준 시간이 빠져 있다 |
outgoworkTm |
외출근무 = 자율휴게 신청분 (분) |
selfCommuteStandardWorkTm |
그날의 소정근로시간 (분). 휴일이면 0 |
exceptworkTm |
제외근무 = 휴게시간 (보통 60) |
comeTm / leaveTm |
실제 출퇴근 타각 (HHMM, 없으면 ----) |
appcomeTm / appleaveTm |
인정 출퇴근 |
attresultNm |
정상근무 / 휴일 / 지각 / 부족 / 이상근태 |
atNm |
오전반차 등 휴가 종류 |
worktmNm |
승인 / 대기 |
holiYn |
공휴일 여부 |
coCd / empCd |
회사·사원 코드 (아래 실시간 조회에 필요) |
회사가 근무시간을 얹어 주는 경우(스마트데이 등)가 있습니다. 그 시간은
appworkTm 에 안 들어가고 appworkTotalTm 에만 들어옵니다.
2026-09-11 (스마데) appworkTm 417 · appworkTotalTm 537 ← 120분 차이
그 외의 날 둘이 같다 (578/578 · 485/485 · 530/530 · 497/497)
appworkTotalTm 이 체류시간이 아니라는 것도 확인했습니다 — 2026-09-01 은
체류가 698분(09:00~20:38)인데 두 필드 모두 578 입니다. 즉 체류가 아니라
"인정근무 + 얹어 준 시간" 입니다.
반차·휴가 인정분은 appcomeTm 이 앞당겨지는 방식이라 양쪽에 이미 들어 있습니다
(9/1 오전반차 → comeTm 14:39 이지만 appcomeTm 09:00).
API 에 월 합계는 없습니다. getWorkTimeList 는 평평한 배열만 돌려주고,
개인근무시간현황 화면이 행을 더해서 보여줍니다. 그래서 "화면 총합을 그대로
가져오기" 는 불가능하고, 같은 필드를 더하는 것이 곧 같은 값입니다.
어느 필드인지 대조하는 도구가 tools/capture-worktime.js
의 __dumpMonth() 입니다.
POST https://gw.goorm.io/personal/hpd0120/0hp00001
menu-code: HPD0120
{"startDt": "20260901", "endDt": "20260930", "flag": "2"}
→ resultData.atPopUpDetailInfos[].attendApplications[]
{ startDt, endDt, startTm, endTm, atItemNm: "연차휴가", atNm: "오전반차(보상)",
approStateNm: "결재진행", ... }
flag가 조회 기준을 정합니다 —"1"= 신청일,"2"= 사용일(휴가일).flag=1로 9월을 조회하면 9월에 신청한 8월 휴가가 딸려 옵니다.flag=2를 씁니다.coCd는 생략 가능합니다.결재진행(승인 대기)도 포함하고, 반려·취소·취하 건은 제외합니다.- 이름으로 종류를 가릅니다 —
휴게|외출→ 휴게,휴가|연차|반차|…→ 휴가, 나머지(출장·시간외)는 다루지 않습니다.
신청 구간에는 휴게시간이 포함돼 있습니다. 오전반차는 점심을 물고 있어 구간이 5시간
(09:00~14:00)인데 오후반차는 4시간(14:00~18:00)입니다. 둘 다 실제 휴가는 4시간입니다.
그래서 구간이 4시간을 넘으면 휴게 1시간을 뺍니다.
| 신청 구간 | 길이 | 인정 |
|---|---|---|
07:30~12:30 오전반차 |
5시간 | 4시간 |
14:00~18:00 오후반차 |
4시간 | 4시간 |
09:00~18:00 종일연차 |
9시간 | 8시간 |
16:00~18:00 시간연차 |
2시간 | 2시간 |
startTm/endTm 이 없으면 atNm 이름으로 추정합니다 (반반차 = 1/4, 반차 = 1/2, 그 외 = 하루치).
하루에 오전반차 + 오후반차가 겹치면 합산하되 그날 소정근로를 넘지 않게 자릅니다.
항목 하나에 startDt~endDt 를 담습니다. 날짜마다 항목을 만들지 않습니다.
실제 신청을 캡처해 확인했습니다(tools/capture-range.js).
휴게 09-14~09-18 atDt=20260914 startDt=20260914 endDt=20260918
appDy="5" appTm=300 (하루 60분 × 5일)
휴가 09-21~09-22 atDt=20260921 startDt=20260921 endDt=20260922
appDy="2" appTm=960 ycUseCnt=2
atDt 는 시작일입니다. 일수(appDy)·시간(appTm)·연차차감(ycUseCnt)은 전부
calculateApplicationDays 가 범위로 계산해 돌려주는 값을 그대로 씁니다 —
calculateOption: HOLIDAY_EXCLUSION 이 주말·휴일을 빼 줍니다.
휴게를 여러 날로 걸면 그 기간 동안 매일 같은 시간대에 휴게가 잡힙니다 (17:00~18:00 × 5일 = 300분).
결재문서 제목도 서버(0hr00011)가 범위에 맞게 돌려줍니다 —
[SRE팀 오세준] 09-14~09-18 (17:00~18:00)(5.0일) 휴게신청서. 우리가 조립하지 않습니다.
처음엔 30분/1시간 두 가지였다가 15/30/45/60 으로 나눴는데, 그래도 성글었습니다.
지금은 숫자 입력입니다. 적은 분을 그대로 올립니다 — 37분이면 37분입니다.
상한을 두지 않았습니다. 양식 설명에는 "최대 1시간" 이라 적혀 있지만 실제로 받아 주는지는 서버가 정하고, 미리보기에 서버가 계산한 인정 시간이 그대로 나오므로 누르기 전에 확인할 수 있습니다. 1분 미만만 막고(0분짜리 신청은 의미가 없습니다) 비었거나 숫자가 아니면 60분으로 둡니다.
같은 근태(자율휴게)에 결재 양식만 다릅니다. 실제 신청을 캡처해 확인했습니다.
formId |
formNm |
formDTp |
atCd / linkAtCd |
|
|---|---|---|---|---|
| 자율휴게 | 151 |
자율휴게신청서 | HP_HPD0110_00052 |
5201 / 5110 |
| 식사 휴게 | 259 |
휴게·식사시간 반영 신청서 | HP_HPD0110_00052 |
5201 / 5110 |
근태 코드도 항목 모양도 제목 형식도 같습니다. formId 와 formNm 만 바뀝니다
(formDTp 까지 같습니다). 그래서 break.js 는 양식만 FORMS 에서
골라 쓰고 나머지는 그대로 씁니다. 서버가 돌려주는 결재문서 제목은 두 양식 모두
… 휴게신청서 입니다.
처음엔 종류(atCd)가 다를 거라 보고 근태 항목 목록을 뒤졌는데, 거기서는 답이 안
나왔습니다 — 양식은 목록 API 에 없고 SetEnageGroup 호출에만 나옵니다.
같은 엔드포인트의 atPopupCd 51 그룹으로 함께 내려오지만 계산에 넣지 않습니다.
결재가 끝나면 서버가 알아서 인정근무시간(appworkTm)에 반영하기 때문에, 결재 전
신청분을 미리 빼 봐야 이중 보정만 됩니다.
집계된 근태 데이터는 다음날 새벽 5시 배치로 채워집니다 (8/31 행의 insertDt = 2026-09-01 05:00:09 SYSTEM).
그래서 오늘과 미래 날짜는 selfCommuteStandardWorkTm 이 0으로 옵니다.
(오늘 출퇴근 타각 자체는 위 실시간 엔드포인트로 바로 읽을 수 있습니다.)
진행 중인 달의 소정근로시간을 알 수 없으면 "남은 시간"을 계산할 수 없으므로 다음과 같이 처리합니다.
- 마감된 날 → 서버 값을 그대로 사용
- 오늘·미래 → 회사 휴일 목록(아래)으로 근무일을 판별해 소정근로를 추정. 서버 목록이 없거나 빈약하면 src/lib/holidays.js 의 내장 표로 폴백
서버 배치는 결재가 완료된 휴가만 반영합니다. 결재진행 중이면 마감된 날이어도 반차가 빠진 순수 타각 시간만 들어옵니다.
9/1 오전반차(결재진행) → appworkTm 388분 (14:39~21:07)
휴게 차감도, 반차 4시간 인정도 없이 타각 그대로.
그대로 두면 누적이 388 − 480 = −1시간 32분 으로 찍힌다 (실제로는 +2시간 28분).
그래서 휴가 크레딧을 더할지는 "마감 여부"가 아니라 서버가 그 휴가를 반영했는지로
판단합니다 — 행의 atNm 에 휴가명이 찍혀 있으면 이미 반영된 것이므로 더하지 않습니다.
결재가 완료되면 서버 값으로 넘어가면서 같은 결과가 나옵니다 (이중 계상 없음).
추정한 날짜 수는 화면에 표시되고, 달이 진행되면서 서버 값으로 자동 교정됩니다. 회사 휴무일이 표와 다르면 팝업 설정 → 추가 휴무일 / 휴일 해제에서 조정하세요.
| 대상 | 대체 조건 |
|---|---|
| 3·1절, 광복절, 개천절, 한글날, 부처님오신날, 성탄절 | 토·일 겹침 |
| 어린이날 | 토·일 또는 다른 공휴일 겹침 |
| 설날·추석 연휴 | 일요일 또는 다른 공휴일 겹침 — 토요일은 대체 없음 |
| 신정, 현충일 | 대체 없음 |
흔히 틀리는 두 가지입니다.
- 추석·설 연휴가 토요일과 겹쳐도 대체휴일은 생기지 않습니다. 2026 추석은 9/24(목)~9/26(토)이고 9/28(월)은 평일입니다.
- 현충일은 어느 요일에 걸려도 대체휴일이 없습니다.
근태신청서 화면까지 엽니다. 신청·상신은 그 화면에서 직접 합니다.
| 확장 팝업 | 휴가 신청 → #/HP/HPD0110/HPD0110 |
| 앱 | 상단 + → 세션을 쿠키로 심고 같은 화면으로 이동 |
연차 신청 팝업 주소는 이렇게 생겼습니다.
/#/popup?MicroModuleCode=eap&callComp=UBAP001&formId=249
&approkey=ERP_<uuid>&popupUUID=<uuid>&…
두 번 시도했고 둘 다 실패했습니다.
- approkey 를 새로 만들어 열기 →
연동본문 데이터 조회 실패 - API 로 초안(0hr00011 + create)을 먼저 만들고 열기 → 같은 실패
이유는 approkey 가 서버에 등록되는 값이 아니기 때문입니다. 실제 흐름에서
팝업 직전에 호출되는 eap096A45·eap096A48 어느 쪽도 approkey 를 보내지 않습니다.
즉 approkey 는 같은 탭 SPA 의 메모리 상태를 가리키는 키이고, 근태신청서 화면이
그 상태를 만들어 둡니다. URL 로 냉시작하면 그 상태가 없어 연동이 본문을 못 찾습니다.
연동프로세스코드 : HP_HPD0110_00011
{"resultCode":-1,"resultMessage":"Internal Server Error"}
calculateApplicationDays(계산) → validateNew(검증) → 0hr00011(근태신청 저장)
→ create(결재문서 생성)까지는 전부 동작합니다. approLineId 를 비우면 서버가
기본 결재선을 붙여 줍니다. 페이로드도 실제 요청과 전 필드 일치를 확인했습니다
(timeCd 7730, workTp 1, appDyFg D, atCd 는 종일 1101·1105 / 오전 1102·1106 /
오후 1103·1107, linkAtCd 1010).
문제는 create 결과가 approState "2"(미상신 초안)이라는 점입니다. 결재선에 올리는
마지막 단계를 찾지 못했고, 절반만 재현하면 초안만 쌓입니다. 그래서 쓰지 않습니다.
src/icons/icon.svg 하나가 원본이고, PNG 는 전부 거기서 뽑습니다.
sh tools/make-icons.sh
src/icons/ 16 · 32 · 48 · 128 확장 (manifest 의 icons · action.default_icon)
app/www/icons/ 180 · 192 · 512 웹앱 (webmanifest · apple-touch-icon · favicon)
헤드리스 크롬으로 1024 한 판을 그리고 sips 로 줄입니다. 작은 크기를 직접 그리면
창 최소 크기에 걸려 엉뚱하게 나옵니다. 모서리가 둥글어서 배경은 투명하게 뽑습니다
(--default-background-color=00000000).
16px 에서 읽히는지가 형태를 정했습니다. 세 가지를 16·32·48·128·512 에서 눈으로 비교했습니다.
- 바늘은 12시·3시. 10시10분 같은 사선 배치는 작은 크기에서 금지 표지처럼 보입니다.
- 시계 면을 타일에 꽉 차게(
r206) 키웠습니다. 여백을 두면 16px 에서 바늘이 뭉갭니다. - 바늘 끝은 둥글게. 각지게 하면 큰 크기에서 투박합니다.
| 파일 | 역할 |
|---|---|
| src/lib/gwapi.js | WEHAGO 서명 요청 + 월 근태 / 휴가 / 타각 조회 |
| src/lib/calc.js | 월 단위 집계, 퇴근 시각 계산 |
| src/lib/holidays.js | 근무일 판정 (서버 휴일 우선, 내장 표 폴백) |
| src/lib/store.js | 월별 응답 캐시 + 설정 |
| src/popup.js | 툴바 팝업 (단독 동작) |
| src/content.js | 근태 페이지 내 패널 (메인 창에서만) |
| src/lib/team.js | 팀 근무시간 공유 클라이언트 (확장·웹앱 공용) |
| server/index.js | 공유 API (worktime.goorm.io/api) |
| src/icons/icon.svg | 아이콘 원본 (PNG 는 여기서 생성) |
팝업은 chrome.cookies로, 콘텐츠 스크립트는 document.cookie로 세션을 읽습니다.
화면에서 가장 빨리 낡는 값(경과 시간·퇴근 시각)은 new Date() 로 계산되므로 서버와 무관합니다.
반대로 월 집계는 다음날 새벽 배치라 낮에 아무리 불러도 값이 그대로입니다.
그래서 "N분마다 전부 다시 조회"는 가장 낡는 값은 그대로 두고 서버만 두드리는 방식이 됩니다.
둘로 나눴습니다.
| 무엇 | 주기 | 서버 호출 |
|---|---|---|
| 화면 다시 그리기 (경과 시간·퇴근 시각) | 30초 | 없음 |
| 오늘 타각 | 5분 — 퇴근이 찍히면 중단 | 1건 |
| 월 근태 + 휴가 | 부팅 시 1회 | 30일치 |
월 집계를 다시 부르는 경우는 세 가지뿐입니다 — 자정을 넘겼을 때, 10분 넘게 자리를 비웠다 돌아왔을 때, 그리고 수동 새로고침.
탭이 보이지 않으면 아무것도 하지 않습니다 (document.hidden). 다른 탭에서 일하는 동안에는
폴링이 완전히 멈추고, 돌아오면 그때 판단합니다.
- 세션 만료(401/403) → 자동 갱신을 중단하고 상태줄에 사유를 띄웁니다. 만료된 세션으로 계속 두드려봐야 소용없기 때문입니다. 새로고침을 누르면 재개합니다.
- 그 외 오류 → 1 → 2 → 4 → 8 → 16 → 30분으로 백오프하고, 한 번 성공하면 초기화합니다. 남은 대기 시간은 상태줄에 표시됩니다.
팝업은 열 때마다 새로 조회하므로 자동 재조회가 없습니다. 열어둔 동안 경과 시간이 낡지 않도록 30초마다 다시 그리기만 합니다.
출근 타각이 빠지면 아마란스가 attresultNm: "이상근태" 로 분류하고 인정근무를 0으로 줍니다.
실제로 몇 시간을 일했든 상관없습니다.
20260812 이상근태 comeTm=---- leaveTm=2101 appworkTm=0 std=480
그대로 두면 그날 8시간이 통째로 부족분에 빨려 들어갑니다. 확장이 계산으로 메울 수 있는 값이 아니라(근태조정을 신청해야 채워집니다) 경고로 띄웁니다 — 해당 날짜와 타각 시각, 출퇴근 타각이 둘 다 있으면 못 받은 시간까지 함께 보여줍니다.
확장은 전부 읽기 전용입니다. 휴가 신청 기능은 의도적으로 넣지 않았습니다.
- 신청 API 를 알아내려면 실제 신청서를 상신해야 합니다. 결재자에게 알림이 가고 되돌리려면 취소 절차를 밟아야 하는, 부작용이 되돌리기 어려운 캡처입니다.
- 신청서에는 결재선·사유·대결자 등 필드가 많고 검증이 서버와 화면에 흩어져 있어, 일부만 흉내내면 잘못된 문서가 결재에 올라갈 위험이 있습니다.
- 지금은 읽기만 하므로 확장이 오작동해도 인사 데이터가 바뀌지 않습니다. 쓰기가 들어가는 순간 그 보장이 사라집니다.
대신 화면으로 이동하는 버튼을 넣었습니다. 화면 코드는 메뉴 권한 조회(/gw/gw999A12)로
확인했습니다.
| 버튼 | 화면 | 경로 |
|---|---|---|
| 휴가 신청 | 부서 근태일정 | #/HP/HPD0110/HPD0110 |
| 신청 현황 | 개인근태신청현황 | #/HP/HPD0120/HPD0120 |
| 근무시간 | 개인근무시간현황 | #/HP/HPD0210/HPD0210 |
팝업에서 누르면 이미 열려 있는 gw.goorm.io 탭을 재사용하고, 없으면 새 탭을 엽니다.
페이지 안 패널에서는 해시만 바꿔 같은 탭에서 이동합니다.
신청서 화면을 열지 않고 API 로 초안을 만든 뒤 결재 화면을 새 탭으로 엽니다. 상신은 그 화면에서 사용자가 직접 누릅니다.
calculateApplicationDays 일수·인정시간·연차차감을 서버가 계산 (읽기)
validateNew 중복·잔여연차 검증 (읽기)
↓ 확인 화면
0hr00011 신청 확정. 응답이 곧 결재문서 제목이다
create 초안 생성 → appSq / appDt / coCd, approState "2"
GetLinkKey approKey 를 등록하고 linkKey 를 받는다
SetEnageGroup approKey 에 양식·제목·본문조회API 를 붙인다
saveLinkKey linkKey 를 방금 만든 초안(appSq)에 묶는다
/#popup?…&approkey=… 결재 화면. 여기서 결재상신
approkey 등록이 전부입니다. ERP_<uuid> 는 클라이언트가 만드는 난수지만
그냥 만들어 쓰는 값이 아니라 서버에 등록해야 하는 값입니다.
POST /system/apiUtilEap/GetLinkKey
{ menuCode: "HPD0110", approKey: "ERP_<uuid>", vPCoCd: coCd, coCd }
→ { approKey, linkKey: "1000_HPD0110_20260907_2023030702_0008" }
POST /system/apiUtilEap/SetEnageGroup
{ approKey, linkKey, formDTp: "HP_HPD0110_00011", formId: "249",
formNm: "연차휴가신청서", docTitle,
contentsApi: "/human/attendapplication/interlock/getInterlockFormContents",
statusApi: "/human/attendapplication/interlock/setInterlockSync",
contents: "", dummy1: "", link: "", vPCoCd: coCd, coCd }
POST /human/openapi/attendapplication/saveLinkKey
{ linkKey, appSq, coCd, appDt }
연동본문 데이터 조회 실패 / HP_HPD0110_00011은 이 세 단계를 빠뜨렸을 때 나옵니다. 서버에 등록된 적 없는 approkey 로 본문을 찾으니 당연히 없습니다.가설 두 개를 먼저 틀렸습니다 —
create의linkKey에 실어 보내는 것도 아니고 (실제 요청도""입니다),create응답에 실려 오는 것도 아니고 (linkKey: "",calLinkKey: "20260902.1"), 스토리지로 넘기는 것도 아닙니다 (ERP_키가 local·session 어디에도 없습니다). 네트워크 타임라인을 찍어create와window.open사이를 보고서야 등록 단계 셋이 드러났습니다.
approState:"2" 는 정상입니다 — 초안 상태이고 상신은 결재 화면에서 일어납니다.
(미상신 초안은 개인근태신청현황에 뜨지 않으니 목록 조회로 성공 판정을 하면 안 됩니다)
제목은 0hr00011 응답을 그대로 씁니다 — 화면도 그렇게 합니다.
[SRE팀 오세준] 09-11 (15:00~19:00)(1.0일)오후반차신청서
| 종류 | atCd | 구간 |
|---|---|---|
| 연차 | 1101 |
09:00~18:00 |
| 오전반차 | 1102 |
09:00~14:00 |
| 오후반차 | 1103 |
15:00~19:00 |
| 연차(보상) | 1105 |
09:00~18:00 |
| 오전반차(보상) | 1106 |
09:00~14:00 |
| 오후반차(보상) | 1107 |
15:00~19:00 |
점심을 물고 있는 구간에는 휴게 1시간이 끼어 있습니다 — 종류가 정하므로 고르지 않습니다. 시작시각만 바꿀 수 있습니다.
흐름을 다시 확인해야 하면 tools/capture.js 를 gw 콘솔에 붙여
실행한 뒤 실제 신청을 하고 __capDump() 를 부르면 됩니다. 결재 팝업 안의 요청까지
같이 잡힙니다.
앱도 같은 코드를 씁니다. lib/leave.js 와 lib/break.js 는
app/sync-lib.sh 로 복사되고, 앱의 GW.api.call 이 확장과 같은 계약(resultData 반환)이라 그대로 돕니다.
다른 건 결재 화면을 여는 방법뿐입니다 — 확장은 새 탭, 앱은 WebView 를 gw.goorm.io 로
이동시킵니다. iframe 은 앱 출처(localhost)에 대해 서드파티라 안드로이드가 쿠키를
막지만, 1st-party 이동이면 정상 적용됩니다.
app/www 를 정적 호스팅해 아이폰에서도 쓰려고 했다. 접었다. 이유를 남긴다.
CORS 는 열려 있다. gw.goorm.io 는 Origin 을 그대로 반사하고
Access-Control-Allow-Credentials: true 를 준다. 그래서 자체 로그인으로 API 를
부르는 것 자체는 웹에서도 된다.
막히는 건 두 가지다.
① 브라우저의 gw 세션으로 API 를 부를 수 없다 — 익스텐션 방식이 웹에서 안 되는 이유.
쿠키만 → 601 "허용된 쿠키 인증 URL이 아닙니다"
서명+쿠키 → 107 "GetToken 요청 시, 비인증 URL을 레디스에서 찾을 수 없습니다"
/human/* 는 wehago-sign(사용자 signKey 로 만드는 HMAC)을 요구하는데,
signKey 는 HttpOnly 쿠키라 웹 페이지에서 읽을 수 없다. 익스텐션은
chrome.cookies 로 HttpOnly 를 읽을 수 있어서 가능한 것이다.
② 결재 화면에 세션을 넘길 수 없다.
loginType=set-cookie 는 세션을 만드는 게 아니라 검증 없이 쿠키를 써주는 헬퍼다
— 더미 토큰(oAuthToken=DUMMY)을 보내도 그대로 Set-Cookie 로 내려준다.
브라우저는 그 쿠키를 gw.goorm.io 호스트 쿠키로 정상 저장한다(헤드리스 크롬의
쿠키 DB 로 확인). 그런데 우리 로그인 토큰은 gw 웹 세션으로는 인정되지 않아
결재 화면이 로그인을 요구한다. 게다가 이걸 부르면 사용자가 gw 에 직접 로그인해
만든 멀쩡한 세션 쿠키를 덮어써서 오히려 로그아웃시킨다.
결론: 웹앱은 자체 로그인(API용) + gw 로그인(결재 화면용)으로 로그인이 두 번 강제되고, 그마저 결재 화면 세션 이관이 되지 않는다. 아이폰은 익스텐션도 앱도 쓸 수 없으니, 지원하려면 Capacitor iOS 빌드가 유일한 길이다(유료 개발자 계정 필요).
휴게 신청 은 같은 흐름을 씁니다. 실제 신청을 캡처해 재현했고, 휴가와 다른 점은 셋뿐입니다.
| 연차휴가 | 자율휴게 · 식사 휴게 | |
|---|---|---|
atCd / linkAtCd |
1101~1107 / 1010 |
5201 / 5110 |
| 결재 양식 | 249 / HP_HPD0110_00011 |
151 또는 259 / HP_HPD0110_00052 |
validateNew |
호출함 | 호출하지 않음 (화면도 안 부름) |
ycUseCnt |
0.5 / 1.0 | 0 (연차를 쓰지 않음) |
appRmkDc |
빈 문자열 | 사유 (예: 운동) |
calculateApplicationDays → 0hr00011 → create
→ GetLinkKey → SetEnageGroup → saveLinkKey → 결재 화면
시작 시각과 길이(분), 사유를 받습니다. 양식 설명은 "점심시간 외 개인용무로 인하여 최대 1시간까지 추가 휴게신청" 이지만 상한은 두지 않았습니다 — 위 휴게 길이는 분으로 그대로 받습니다 참고.
휴게는 근로시간에서 빠지므로 퇴근이 그만큼 밀립니다. 다만 확장의 계산에는 넣지 않습니다 — 결재가 끝나면 서버가 인정근무시간에 반영합니다 (위 참고).
달력 날짜를 누르면 그날 창이 뜹니다 — 근무 계획 편집과 자리를 비우는 사람이
한 곳에 있습니다. 우리 팀 / 전체 를 고를 수 있고, 휴가 → 출장 → 외근 순으로
묶어 보여줍니다.
지난 날과 휴일도 누를 수 있습니다. 계획을 세울 수는 없지만 그날 누가 쉬었는지는 볼 수 있어야 하므로, 계획 편집 줄만 숨깁니다.
POST /schres/sc111A03 (일정 모듈의 근태캘린더)
{ companyInfo: { compSeq, groupSeq, deptSeq, emailAddr, emailDomain },
startDate, endDate, acalList: ["1"], … }
→ resultList[] : partName · createDeptName · atNm · atCd · startDate · endDate · alldayYn
화면이 날짜마다 한 번씩 부르므로 우리도 하루씩 부릅니다. 본문에 startDate/
endDate 가 있어 범위가 될 수도 있지만 확인된 바 없습니다. 날짜별로 한 번 받아
두고 우리 팀/전체 전환은 클라이언트에서 거릅니다.
종류는 atCd 앞자리로 갈립니다 — 1xxx 휴가, 2xxx 출장, 3xxx 외근.
이 API 는 본문에 groupSeq·compSeq·deptSeq·이메일을 요구합니다. 근태 API 가
쓰는 ERP 코드(empCd/coCd)와는 다른 체계라 따로 구해야 합니다.
gw.goorm.io 의 sessionStorage.userInfo 에 들어 있고, 콘텐츠 스크립트가 같은
출처라 읽을 수 있습니다(격리 세계라도 스토리지는 공유됩니다). 팝업은 다른 출처라
못 읽으므로 콘텐츠 스크립트가 캐시해 둡니다. 그래서 gw.goorm.io 탭을 한 번은
열어야 이 기능이 동작합니다.
팀원이 언제 퇴근하는지 볼 방법이 그룹웨어에 없습니다. 각자 계산한 결과를
worktime.goorm.io 한 곳에 모읍니다.
그룹웨어의 부서가 그대로 팀입니다. 만들기도 참여도 링크도 열쇠도 없고, 고를 수 있는 것도 없습니다 — 팀도 이름도 그룹웨어가 알려 주는 값으로 고정합니다.
설정 화면도 시작 버튼도 없습니다. 정할 게 없으면 물어볼 것도 없습니다 — 열면 바로 팀이 보이고, 내 것도 올릴지는 체크박스 하나로 정합니다. 읽는 것은 공유와 무관하므로 켜지 않아도 팀은 보입니다.
조직도 경로가 세션과 조직도 API 양쪽에 들어 있습니다. 모양이 다릅니다.
내 세션(userInfo) deptPath "2038|2042"
deptPathNm "주식회사 구름|주식회사 구름|인프라본부|SRE팀"
조직도 API path "1000|1000|2017|2019|2025|2255"
pathName "주식회사 구름>…>에듀그룹>에듀팀>에듀 2파트"
구분자가 | 이기도 하고 > 이기도 하며, 길이도 다릅니다 — 번호 쪽에는 회사
항목이 빠져 있습니다(이름 4개에 번호 2개). 그래서 뒤에서부터 맞춥니다.
그렇게 맞춘 뒤 뒤에서부터 찾은 첫 …팀 이 그 사람의 팀입니다
(teamFromPath).
| 부서 | 경로 | 묶이는 곳 |
|---|---|---|
| SRE팀 | …>인프라본부>SRE팀 | SRE팀 (2042) |
| SRE 1파트 | …>인프라본부>SRE팀>SRE 1파트 | SRE팀 (2042 · 같은 방) |
| 에듀 1파트 · 에듀 2파트 | …>에듀그룹>에듀팀>에듀 N파트 | 에듀팀 (2025) |
| 프로덕트디자인팀 | …>디자인그룹>프로덕트디자인팀 | 프로덕트디자인팀 |
| 사업1팀 · 사업2팀 | …>사업개발실>사업N팀 | 각각 |
| 대표이사 | …>대표이사 | 본인 (팀이라 부를 마디가 없음) |
teamId = SHA-256("worktime-team-v4:" + compSeq + ":" + 최종소속팀 deptSeq)
처음에는 본인 deptSeq 로 만들었는데 "에듀 1파트" 와 "에듀 2파트" 가 서로 안
보였습니다. 그 다음엔 부서명에서 팀·파트 같은 꼬리를 정규식으로 떼어 묶어
봤는데, 그러면 사업1팀 과 사업2팀 처럼 실제로 다른 팀이 합쳐집니다.
조직도가 이미 아는 것을 추측할 이유가 없어서 경로를 씁니다.
경로를 못 읽으면 본인 부서로 떨어집니다. 하위 조직이 갈라지지만, 아무 이름이나 합쳐 버리는 것보다는 낫습니다.
열쇠를 하나 두면 팀원마다 그걸 받아 넣어야 하고, 그 번거로움이 기능을 안 쓰게 만듭니다. 그렇다고 팀 이름을 입력칸으로 열어 두면 아무 팀이나 적어 넣고 남의 부서를 들여다볼 수 있습니다.
그래서 입력 자체를 없앴습니다. 클라이언트가 계산하는 주소는 자기 부서 하나뿐이고, 다른 부서를 지목할 경로가 UI 에도 저장값에도 없습니다.
예외는 하나입니다. 그룹웨어 응답에서 이름을 못 읽었을 때만 표시 이름 칸이 열립니다(응답 구조가 버전마다 다릅니다). 팀은 어떤 경우에도 고칠 수 없습니다.
주소 자체가 비밀은 아닙니다. 계산식이 이 저장소에 공개돼 있고 deptSeq 는 작은
정수라, 확장을 거치지 않고 API 를 직접 부르는 사람은 값을 맞춰 볼 수 있습니다.
joinKey 는 서버 형식을 맞추는 값일 뿐 보호 수단이 아닙니다. 거기까지 막으려면
API 를 internal-private ALB 로 옮기거나(집·아이폰에서 못 씀), 회사 SSO 를 앞단에
붙이거나, 서버가 그룹웨어 세션을 검증해야 합니다. 지금은 넣지 않았습니다.
로드될 때마다 켭니다. 끄는 것은 그 다음 로드까지만 유지됩니다.
| 어디 | "로드" 시점 |
|---|---|
| 툴바 팝업 | 팝업을 열 때마다 (열 때마다 새로 뜨므로) |
| gw 페이지 패널 | 페이지가 로드될 때 한 번 |
| 웹앱 | 페이지가 로드될 때 한 번 |
처음엔 꺼 두고 각자 켜게 했다가, 다음엔 설정이 없을 때만 켜지게 했습니다. 둘 다 같은 문제가 남았습니다 — 꺼 둔 채로 잊는 사람이 쌓이면 목록이 비어서 기능 자체가 안 쓰입니다. 그래서 꺼짐이 누적되지 않게 했습니다.
껐을 때 올려 둔 기록을 지우는 것은 그대로입니다. 한 번 끄면 그 로드 동안에는 꺼진 채로 있습니다 — 패널의 5분 주기도 그 설정을 따릅니다.
이름을 못 읽으면 켜지 않고 체크도 켜지 않습니다 — 체크만 켜 두면 올라가는 줄 알게 됩니다. 그때는 표시 이름 칸이 열립니다.
부서와 무관하게, 코드를 주고받은 사람끼리 모입니다. 우리 팀과 같은 화면에 탭으로 있고, 상태도 설정도 완전히 분리됩니다.
roomId = SHA-256("worktime-room-v1:" + 코드) 앞 16자
joinKey = SHA-256("worktime-roomkey-v1:" + 코드) 앞 32자
여기서는 코드가 진짜 열쇠입니다. 12자 × 32자모 = 60비트 무작위라 부서명과 달리
맞혀 볼 수 없습니다. 헷갈리는 글자(I L O U)를 뺀 자모를 쓰고, 옮겨 적다
O→0, I→1 로 틀린 것은 받아 줍니다. 하이픈·대소문자·공백도 무시합니다.
서버는 이게 팀인지 방인지 모릅니다 — 어느 쪽이든 "주소 + 열쇠" 한 쌍일 뿐이라 서버 코드는 하나도 바뀌지 않았습니다.
친구 목록에는 부서를 같이 보여 줍니다. 여러 부서가 섞이기 때문입니다.
selfId(자리)와 writeKey(그 자리의 열쇠)는 사번에서 만듭니다.
selfId = SHA-256("worktime-self-v1:" + compSeq + ":" + empSeq)
writeKey = SHA-256("worktime-selfkey-v1:" + compSeq + ":" + empSeq)
처음에는 기기마다 무작위로 만들었는데, 그러면 확장과 웹앱에서 각각 한 줄씩 생기고 다시 켤 때마다 유령이 하나씩 쌓였습니다(실제로 그랬습니다). 사번으로 고정하면 어느 기기에서 켜든 같은 칸을 덮어씁니다.
writeKey 가 다르면 남의 칸을 덮어쓸 수 없고, 서버는 응답에서 writeKey 를
빼고 줍니다. 사번을 못 읽었을 때만 기기별 무작위 값으로 떨어지며, 그 경우는
중복을 막지 못합니다. 그래서 목록을 그릴 때 이름이 같으면 가장 최근 것만 남깁니다.
team.js 의 summarize 가 고르는 것이 전부입니다.
name, dept, inAt, outAt, workedMin, leftMin, monthLeftMin, leaveNm, done, brkMin
서버도 같은 목록만 화이트리스트로 받고 나머지는 버립니다(sanitize).
그룹웨어 토큰·쿠키는 공유 서버로 가지 않습니다. 근태 원본도 올리지 않습니다 —
계산은 각자 기기에서 끝내고 결과만 보냅니다.
퇴근 시각은 정량(소정근로) 기준을 공유합니다. 유연근무 최소 6시간은 각자 사정이라 남이 볼 값으로 맞지 않습니다.
done 은 실제로 퇴근 타각을 찍었는지입니다. 이게 없을 때는 leftMin <= 0 이면
퇴근으로 찍었는데, 그러면 8시간을 채우고 아직 일하는 사람이 퇴근한 것처럼
보였습니다. 두 경우가 전달 값에서 구분되지 않았습니다.
| 상태 | done |
leftMin |
화면 |
|---|---|---|---|
| 실제 퇴근 | true |
0 | 퇴근 (초록) |
| 목표 채움, 아직 근무 | false |
≤ 0 | 충족 |
| 근무 중 | false |
> 0 | N 남음 |
올라오는 값은 5분마다 찍히는 스냅샷입니다. 그대로 쓰면 5분에 한 번 훌쩍 뛰고 그 사이에는 멈춰 있습니다 — 남의 시간만 따로 흐르는 것처럼 보입니다.
출근 시각(inAt)·목표 퇴근 시각(outAt)·휴게시간(brkMin)만 있으면 나머지는
지금 시각으로 다시 셀 수 있습니다(live). 내 화면
(todayPlan)과 같은 식을 씁니다 — 점심 12:00~13:00 겹침을 뺍니다.
09:00 출근 · 18:00 목표 · 휴게 60분
10:00 → 경과 1:00 · 남음 7:00
12:30 → 경과 3:00 · 남음 5:00 (점심 30분 반영)
14:00 → 경과 4:00 · 남음 4:00 (점심 60분 다 씀)
목록은 30초마다 다시 그립니다. 새로 받지 않아도 시계는 흘러야 합니다.
brkMin 을 안 보내는 판본이 섞여 있으면 그 사람 줄만 올라온 값을 그대로 씁니다 —
점심을 모르고 계산하면 12~13시에 한 시간씩 어긋납니다. 퇴근한 사람과 휴가인
사람은 다시 세지 않습니다.
PC 와 폰이 들어간 방을 서로 맞춥니다. 맞출 거리를 주고받을 필요가 없습니다 —
자리(selfId)와 그 열쇠(writeKey)가 사번에서 나오므로 같은 사람의 기기가
같은 값을 계산합니다.
GET /api/me?u=<selfId>&k=<writeKey> → { rooms[], at }
PUT /api/me?u=<selfId>&k=<writeKey> { rooms[], at }
나중에 고친 쪽을 따릅니다. 두 기기에서 각자 고쳤으면 한쪽이 져야 하는데
"방금 고친 쪽" 이 맞습니다. 합치면 나간 방이 되살아납니다. 어느 방을 보고 있느냐
(active)는 기기마다 다른 것이라 올리지 않습니다.
대가: 계산식이 이 저장소에 공개돼 있고 사번은 조직도로 알 수 있습니다. 그래서 사번을 아는 사람은 남의 방 목록을 읽을 수 있습니다 — 방 코드가 친구 방의 유일한 자물쇠이므로, 그 방에 들어올 수도 있습니다. 기기 연결 코드를 한 번 입력하는 방식이면 이 구멍이 없지만, 기기마다 한 번씩 누르는 번거로움과 맞바꿉니다. 설정이 0 인 쪽을 골랐습니다.
방 목록은 90일 안 쓰면 지웁니다. 근무 기록(36시간)과 달리 며칠 쉬었다고 사라지면 안 되기 때문입니다.
PUT /api/teams/:id { joinKey, name } 있으면 확인, 없으면 생성
GET /api/teams/:id?k=<joinKey> → { name, members[] }
PUT /api/teams/:id/me?k=<joinKey> { …요약 }
DELETE /api/teams/:id/me?k=<joinKey>
무작위로 팀을 만들어 주던 POST /api/teams 는 없앴습니다. 팀이 전부 이름에서
파생되므로 쓰이지 않는데, 인터넷에 열려 있으면 아무나 팀 한도를 채울 수 있습니다.
- 열쇠 비교는
crypto.timingSafeEqual로 합니다. - 팀·방 생성은 IP 당 시간당 60회, 틀린
joinKey는 120회 까지 받습니다. 처음엔 10회·30회로 잡았는데 실제로 막혔습니다 — 사무실이 한 공인 IP 를 쓰면 동료들 것까지 합산되고, 팀 주소 규칙을 한 번 바꾸면 그 순간 모두가 새 팀을 하나씩 만듭니다. 게다가 생성이 막히면 그 방 조회가 "없는 방" 이 되어 틀린 열쇠 계정까지 갉아먹었습니다. 팀·방은 한 번 만들면 계속 쓰므로 평상시 생성은 0 에 가깝습니다.
파일 하나(server/store.js)입니다. 팀 몇 개에 사람 수십 명, 1인당 몇백 바이트라 DB 를 세울 이유가 없습니다. 그래서 레플리카는 1개입니다 — 두 벌이 같은 파일을 들면 나중에 쓴 쪽이 이깁니다.
- 36시간 지난 게시물은 조회할 때 걸러지고 지워집니다. 보여 주려는 건 "오늘 누가 언제 퇴근하나" 라, 사흘을 들고 있으면 그만둔 사람이나 옛 판본이 남긴 자리가 계속 목록에 떴습니다. 어제 저녁~오늘 아침이 이어 보일 만큼만 남깁니다.
- 빈 채로 그만큼 지난 팀·방은 시간마다 도는 정리에서 삭제됩니다.
| 위치 | 주기 |
|---|---|
| gw.goorm.io 패널 | 5분마다 (상주하므로 이게 주력) |
| 툴바 팝업 | 열 때 + 2분 간격 |
| 웹앱 | 공유 화면을 열 때 |
우리 팀과 친구를 둘 다 켜 두면 요약을 한 번만 만들어 양쪽에 보냅니다.
팝업만으로는 대부분의 시간 동안 남이 보는 값이 낡습니다. 그래서 패널이 올립니다. 어제 올린 값이 오늘 퇴근 시각처럼 보이면 안 되므로, 날짜가 다르면 목록에서 "오늘 기록 없음" 으로 접습니다.
확장이 worktime.goorm.io 를 부르지만 host_permissions 에 넣지 않았습니다.
공유 API 가 Origin 을 반영해 CORS 를 허용하므로 권한 없이도 됩니다
(chrome-extension://… 오리진으로 확인). 권한이 늘면 크롬이 업데이트 후
확장을 비활성화하고 사용자에게 재승인을 요구하는데, 그 대가가 더 큽니다.
쓰는 사람이 화면에서 바로 적어 보냅니다. 읽는 것은 관리자뿐입니다.
POST /api/feedback { text, name?, dept?, ver? }
GET /api/feedback?k=<FEEDBACK_KEY> → { items[] } 최신 먼저
DELETE /api/feedback?k=…&at=<ts> 한 건 지우기
DELETE /api/feedback?k=…&all=1 전부 비우기
읽고 처리한 것은 지웁니다. 안 지우면 다음에 또 같은 걸 읽게 됩니다.
FEEDBACK_KEY 는 k8s 시크릿(worktime-api/feedback-key)에서 환경변수로 들어갑니다.
안 주면 읽기가 아예 막힙니다 — 받기만 하고 아무도 못 읽습니다. 열쇠를 안 걸어
두면 남이 보낸 글을 누구나 읽게 되므로 기본을 막힘으로 뒀습니다.
kubectl -n worktime get secret worktime-api -o jsonpath='{.data.feedback-key}' | base64 -d
curl -s "https://worktime.goorm.io/api/feedback?k=<열쇠>"
이름·부서·버전를 같이 보냅니다 — 누가 어떤 판에서 겪었는지 알아야 고칠 수 있습니다. 화면에도 "이름과 함께 전달됩니다" 라고 적어 둡니다. IP 당 시간당 20건, 글은 1000자까지, 500건이 넘으면 오래된 것부터 버립니다.
버전업 뒤 한 번만 띄웁니다. 노트는 확장 안에 담아 둡니다 (src/notes.json) — 저장소에서 받아 오면 그 사이 새 판이 나왔을 때 설치된 것과 다른 노트를 보여 주게 됩니다.
{ "version": "1.8.7", "title": "…", "items": ["…", "…"] }본 판을 seenNotes 에 적어 두고, 다르면 띄웁니다. 처음 설치한 사람에게는 띄우지
않습니다 — 본 적도 없는 "바뀐 점" 은 군더더기라, 기록이 아예 없으면 조용히
적어만 두고 넘어갑니다.
본 것으로 적는 건 닫을 때 입니다. 띄우자마자 적어 버리면, 버전업 직후 팝업이
gw.goorm.io 세션을 아직 못 잡아 새로고침부터 하게 되는 상황에서 노트를 읽어
보기도 전에 사라집니다. 실제로 그래서 "한 번 띄우기" 가 안 먹는 것처럼 보였습니다.
그리고 맨 아래 바뀐 점 으로 언제든 다시 열 수 있습니다. 한 번 띄우는 건
거들 뿐이라, 못 보고 지나가도 잃는 게 없습니다.
notes.json 의 버전이 manifest.json 과 다르면 검사에서
걸립니다. 어긋나면 엉뚱한 판의 노트를 띄우게 됩니다.
팝업과 페이지 패널에 새 버전 안내가 뜹니다. 자동 설치는 안 됩니다 — 압축해제 로드 방식이라 Chrome 이 대신 갱신해 줄 수 없고, 릴리스 페이지 링크만 띄웁니다.
GET https://raw.githubusercontent.com/goorm-dev/sre-amaranth-extension/main/latest.json
→ { "version": "1.3.1", "url": "…/releases/latest" }
GitHub API 가 아니라 raw 파일을 씁니다. API 는 인증 없이 시간당 60회/IP 제한이
걸리고, raw 는 사실상 없습니다. raw 응답에 Access-Control-Allow-Origin: * 이 있어
host_permissions 를 늘리지 않아도 됩니다 — 권한 재승인 프롬프트가 뜨지 않습니다.
20분마다 확인합니다. 확장을 쓸 때(팝업을 열거나 gw.goorm.io 페이지가 뜰 때)만
확인하고, 마지막 확인이 20분 이내면 캐시를 씁니다. 백그라운드 타이머는 없습니다.
캐시는 확장 버전이 바뀌면 무효가 됩니다. 안 그러면 방금 올린 버전이 TTL 동안 "새 버전 있음" 으로 남습니다.
팝업의 새로고침 은 TTL 을 무시하고 즉시 확인합니다.
처음엔 TTL 이 6시간이었는데, 릴리스 직후 반나절 가까이 아무에게도 안 뜨는 일이 있었습니다.
latest.json은 95바이트고 raw 는 rate limit 이 사실상 없어서 짧게 잡는 게 맞습니다.
무시한 버전은 다시 띄우지 않습니다.
태그 = manifest.json 의 버전이고, 기본은 패치 올림입니다. 마이너는 성격이
다른 기능이 들어갈 때만 올립니다. 사용자가 안내에서 보는 숫자와 릴리스
페이지의 숫자를 같게 두기 위한 것입니다.
1.7.20까지 패치로만 올리다 보니 자릿수가 길어져1.8.0에서 한 번 끊었습니다. 그 다음부터는 다시 패치 올림입니다.
버전을 올려 커밋한 뒤 release.sh 하나로 끝냅니다.
sh release.sh "팀 근무시간 공유" # 커밋 본문을 릴리스 노트로
sh release.sh "팀 근무시간 공유" notes.md # 따로 쓴 노트로
검사 → 패키징 → git push → 태그 → gh release create 를 한 번에 합니다.
main 이 아니거나, 커밋 안 된 변경이 있거나, 태그·릴리스가 이미 있으면 멈춥니다.
푸시와 릴리스를 묶은 이유: 손으로 하다가 한 번 빠뜨렸습니다. latest.json 은
1.7.5 인데 릴리스가 없어서, 확장은 "새 버전 있음" 만 띄우고 받을 게 없었습니다.
latest.json 을 안 올리면 반대로 아무에게도 안내가 안 뜹니다. 릴리스 태그가
아니라 이 파일 하나만 보기 때문입니다. 눈으로는 안 잡혀서 검사에 넣었습니다.
앱(APK)은 확장과 버전도 릴리스도 따로 갑니다. 태그가 app-v… 로 시작합니다.
1. app/package.json 의 version 을 올린다 ← 유일한 출처
2. sh app/set-version.sh ← 안드로이드 프로젝트에 박아 넣는다
sh app/sync-lib.sh
(cd app && node_modules/.bin/cap sync android)
3. cd app/android && JAVA_HOME=/opt/homebrew/opt/openjdk@21 ./gradlew assembleDebug
4. gh release create app-v<버전> <apk> --title "앱 v<버전> — …"
5. gh release edit v<확장 최신 버전> --latest ← 이걸 빠뜨리면 안 된다
5번을 빠뜨리면 확장 업데이트가 APK 를 가리킵니다. latest.json 의 url 이
releases/latest 인데, 앱 릴리스를 새로 만들면 GitHub 가 그걸 Latest 로 잡습니다.
확장 사용자가 "받기" 를 누르면 APK 페이지로 갑니다.
app/android/ 는 cap add android 로 만들어지는 생성물이라 통째로 gitignore 돼
있습니다. 거기 적힌 versionCode 는 그 기계에만 있고 프로젝트를 다시 만들면 1 로
돌아갑니다. 그래서 추적되는 app/package.json 의 version 을 유일한 출처로 삼고
app/set-version.sh 가 계산해 넣습니다 (1.4.2 → 10402).
내려가는 값이면 거기서 멈춥니다 — 안드로이드가 다운그레이드를 거부합니다.
assembleDebug 로 뽑습니다. 서명 설정이 따로 없어서 릴리스 빌드는 서명이 안 되고,
서명이 달라지면 덮어 설치가 막혀 사용자가 앱을 지웠다 깔아야 합니다 — 그러면
팀·친구 설정이 날아갑니다. 기존 APK 는 로컬 디버그 키로 서명돼 있으므로 같은 키로
계속 뽑습니다. 올리기 전에 서명이 같은지 확인합니다.
apksigner verify --print-certs <apk> | grep "SHA-256 digest"
- 진행 중인 달의 소정근로시간은 공휴일 표 기반 추정입니다 (위 참고).
- 내장 공휴일 표는 서버 목록이 빈약한 해에만 쓰이는 폴백입니다 (2025–2027년, 수작업). 대체공휴일 규칙은 위 표를 참고하세요.
- 휴게시간은 60분 고정으로 계산합니다. 서버는 근무시간에 따라 더 빼기도 합니다 (8/10: 13시간 34분 근무에 90분 차감).
- 반차 인정분을 4시간으로 보는데, 서버는 실제로 더 후하게 정산합니다.
8/14 오전반차는
appcomeTm을 07:30 으로 당겨appworkTm=570을 인정했습니다. 즉 여기 표시되는 값은 보수적이고, 배치 후 서버 값으로 교정됩니다. - 세션이 만료되면
gw.goorm.io에서 다시 로그인해야 합니다. - 휴가 신청은 초안까지만 만듭니다. 결재상신은 사용자가 결재 화면에서 직접 누릅니다.
- 아이폰은 지원하지 않습니다. 익스텐션도 앱도 쓸 수 없고, 웹앱은 위 이유로 접었습니다.