문제: 그룹웨어 API를 어떻게 호출하나
사내 그룹웨어는 브라우저에서 로그인한 세션 쿠키로 API를 호출하는 구조다. 공개 API키 같은 게 없고, 그냥 브라우저처럼 로그인해서 쿠키를 들고 API를 쳐야 한다.
Node.js에서 이걸 하려면 axios로 로그인 과정을 그대로 흉내내야 한다.
SSO 로그인 흐름
대부분의 사내 SSO는 아래 패턴을 따른다.
1단계: 로그인 페이지 GET → 초기 세션 쿠키 획득
2단계: 로그인 form POST → 인증 처리
3단계: SSO 토큰 발급 페이지 GET → 리다이렉트로 토큰 획득
4단계: 대상 도메인에 토큰으로 접근 → 해당 도메인 세션 쿠키 획득
핵심은 각 단계에서 받은 쿠키를 다음 요청에 그대로 넘겨줘야 한다는 것이다.
axios로 SSO 구현하기
쿠키 파싱 헬퍼
axios는 브라우저와 달리 쿠키를 자동으로 관리해주지 않는다. 응답 헤더에서 직접 파싱해야 한다.
function parseCookies(setCookieHeader) {
if (!setCookieHeader) return '';
const cookies = Array.isArray(setCookieHeader)
? setCookieHeader
: [setCookieHeader];
return cookies
.map(c => c.split(';')[0]) // 'name=value; Path=/' → 'name=value'
.join('; ');
}
1단계: 초기 세션 쿠키 획득
const initRes = await axios.get('https://portal.company.com/login', {
maxRedirects: 0,
validateStatus: s => s < 400,
});
let cookie = parseCookies(initRes.headers['set-cookie']);
2단계: 로그인
const loginRes = await axios.post(
'https://portal.company.com/login/check',
new URLSearchParams({
id: process.env.LOGIN_ID,
password: process.env.LOGIN_PW,
}),
{
headers: {
Cookie: cookie,
'Content-Type': 'application/x-www-form-urlencoded',
},
maxRedirects: 0,
validateStatus: s => s < 400,
}
);
cookie = parseCookies(loginRes.headers['set-cookie']) || cookie;
로그인 요청은 form-data(application/x-www-form-urlencoded) 방식으로 보내야 한다. axios.post에 객체를 그냥 넘기면 JSON으로 보내져서 로그인이 안 된다. URLSearchParams로 감싸줘야 한다.
3단계: SSO 토큰 발급
const ssoRes = await axios.get('https://portal.company.com/sso-redirect', {
headers: { Cookie: cookie },
maxRedirects: 0, // 리다이렉트 수동으로 따라가야 함
validateStatus: s => s < 400,
});
// 리다이렉트 URL에서 토큰 추출
const ssoUrl = ssoRes.headers['location'];
maxRedirects: 0이 중요하다. axios가 자동으로 리다이렉트를 따라가면 쿠키가 유실된다. 리다이렉트를 수동으로 처리해야 각 단계의 쿠키를 직접 챙길 수 있다.
4단계: 대상 도메인 세션 획득
const domainRes = await axios.get(ssoUrl, {
maxRedirects: 5, // 여기서는 따라가도 됨
validateStatus: s => s < 400,
});
const domainCookie = parseCookies(domainRes.headers['set-cookie']);
이 domainCookie가 실제 API 호출에 쓰는 세션이다.
도메인별 세션 분리
우리 그룹웨어는 기능별로 서브도메인이 달랐다. 예를 들면 휴가는 leave.company.com, 회의실은 gw.company.com 식이다.
문제는 도메인이 다르면 쿠키가 공유되지 않는다는 거다. 각 도메인에 별도로 SSO 로그인을 해야 한다.
// 서버 시작 시 두 도메인 모두 로그인
let avsCookie = await login('leave'); // 휴가 도메인
let gwCookie = await login('gw'); // 회의실 도메인
// API 호출 시 해당 도메인 쿠키 사용
async function getVacation(date) {
return axios.post(VACATION_API_URL, payload, {
headers: { Cookie: avsCookie, ...customHeaders },
});
}
async function getMeetingRooms(date) {
return axios.post(ROOM_API_URL, payload, {
headers: { Cookie: gwCookie, ...customHeaders },
});
}
커스텀 헤더 문제
API를 호출했는데 404나 의미 없는 오류 코드가 계속 반환됐다. 로그인도 됐고 쿠키도 맞는데 왜 안 되나 한참 삽질했다.
브라우저 Network 탭을 열어서 실제 요청을 비교해보니 커스텀 헤더들이 빠져있었다.
const customHeaders = {
'__service_id__': 'SERVICE_NAME',
'__view_id__': 'view-identifier',
'__menu_id__': 'MENU_CODE',
'ajax': 'true',
'x-requested-with': 'XMLHttpRequest',
};
그룹웨어 API는 이런 커스텀 헤더로 어떤 서비스/메뉴에서 요청이 왔는지 검증한다. 빠지면 요청이 거부된다.
해결법: 브라우저 Network 탭에서 실제 API 요청을 찾아 Request Headers를 전부 복사해서 동일하게 맞춰줬다.
세션 유지 (30분마다 keepalive)
SSO 세션은 일정 시간 요청이 없으면 만료된다. 봇이 새벽에 아무것도 안 하다가 아침에 알림을 보내려 하면 세션이 끊겨있는 상황이 생긴다.
cron.schedule('*/30 * * * *', async () => {
try {
// 세션 페이지에 GET 요청으로 keepalive
await axios.get(SESSION_KEEP_URL, {
headers: { Cookie: avsCookie },
});
await axios.get(SESSION_KEEP_URL_GW, {
headers: { Cookie: gwCookie },
});
} catch (err) {
// 실패 시 재로그인
avsCookie = await login('leave');
gwCookie = await login('gw');
}
});
keepalive 실패 시 재로그인하도록 해두면 세션이 끊겨도 자동 복구된다.
메모리 캐시로 API 호출 줄이기
같은 날짜 데이터를 10분마다 계속 API에서 가져오면 서버에 부담이 된다. 메모리 캐시로 불필요한 호출을 줄였다.
const cache = new Map();
const CACHE_TTL = 15 * 60 * 1000; // 15분
async function fetchWithCache(key, fetchFn) {
const cached = cache.get(key);
if (cached && Date.now() - cached.time < CACHE_TTL) {
return cached.data;
}
const data = await fetchFn();
cache.set(key, { data, time: Date.now() });
return data;
}
트러블슈팅 정리
| 증상 | 원인 | 해결 |
|---|---|---|
| 로그인 후 API 호출 시 인증 오류 | axios가 리다이렉트 자동 처리 중 쿠키 유실 | maxRedirects: 0으로 수동 처리 |
| API 404 / 의미 없는 오류 코드 | 커스텀 헤더 누락 | 브라우저 Network 탭에서 헤더 전부 확인 후 동일하게 설정 |
| 아침 알림 시 세션 만료 | SSO 세션 TTL 초과 | 30분마다 keepalive + 실패 시 재로그인 |
| 같은 도메인인데 API마다 세션 다름 | 서브도메인별 쿠키 분리 | 도메인별 별도 로그인 세션 관리 |
| form 로그인 안 됨 | axios POST에 JSON으로 전송됨 | URLSearchParams로 감싸서 form-data 형식으로 전송 |
다음 편에서는 수집한 데이터를 스냅샷으로 관리하고, 이전 상태와 비교해서 변동을 감지하는 로직과 Slack 알림 발송 구현을 다룬다.