서비스를 만들다보면 보통 인증과 권한 체크를 보안의 출발점으로 생각한다.
나 역시도 올 초에 회사에 떠들썩(?)한 일이 생기기 전에는 "인증된 사용자만 접근할 수 있으면 충분하다"는 쪽에 가까웠다.
하지만 실제 운영 환경에서 첨부파일 보안 요구 사항을 다루면서 생각이 많이 바뀌었다.
인증은 입구를 지키는 장치일 뿐이고, 진짜 보안은 데이터가 시스템 밖으로 나간 이후까지 통제할 수 있을 때 성립한다.
이번 프로젝트는 첨부파일 다운로드를 개편한 API 개발이다.
시작은 단순하게 Express 서버를 만들고, 사용자 인증을 위해 LDAP 검증 API를 붙였다.
사용자가 암호화된 id, pw를 보내면 서버가 LDAP 엔드포인트를 호출하고, 결과를 응답하는 구조다.
export const validate = async (req, res) => {
try {
const { id, pw } = req.body;
const response = await fetch(config().VALIDATE_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
id: id,
pw: pw,
}),
});
const resData = await response.json();
if (resData.code === "0") {
return res.status(200).json({ code: "0", msg: "success" });
} else {
return res.status(401).json({ code: "-1", msg: resData.msg });
}
} catch (error) {
logger().error({
reqId: req.id,
stack: error.stack,
});
return res.status(500).json({ code: "-1", msg: error.message });
}
};이제 문제는 인증이 끝난 뒤였다. 브라우저에서 정적인 다운로드 링크를 제공하면, 사용자가 정상적으로 로그인했는지와 별개로 링크가 복사되거나 파일이 외부로 전달될 수 있다.
즉 접근 제어만으로는 문서 자체를 보호할 수 없다. 이 지점에서 설계 방향을 바꿨다.
"다운로드를 허용하는 API"가 아니라, "암호화된 형태로 첨부파일을 전달하는 API"를 만들기로 정했다.
그래서 추가한 것이 /encrypt 흐름이다.
클라이언트는 더 이상 원본 첨부 URL을 직접 사용하지 않고, 보안 API를 통해서만 파일을 요청한다.
서버는 먼저 외부 파일 서버에서 원본을 가져온다. 이때 응답이 PDF 파일이고, 사용자가 PDF 비밀번호를 입력했다면 원본을 그대로 전달할지 않도록 했다.
스트림을 임시 파일로 저장한 뒤 qpdf로 암호화를 적용하고, 암호화 된 결과물만 다시 다운로드하게 만들었다.
export const proxyDownload = async (req, res) => {
const { fileUrl, key } = req.body;
const clientCookie = req.headers.cookie;
if (!fileUrl) return res.status(400).send("파일 URL이 누락되었습니다.");
try {
const response = await service.fetchExternalFile(fileUrl, clientCookie);
const contentType = response.headers.get("content-type") || "";
if (fileUrl.includes(".pdf") && key) {
const requestId = Date.now();
const tempInput = path.resolve(`./temp_in_${requestId}.pdf`);
const tempOutput = path.resolve(`./temp_out_${requestId}.pdf`);
const fileStream = fs.createWriteStream(tempInput);
await finished(Readable.fromWeb(response.body).pipe(fileStream));
try {
await service.encryptWithQpdf(tempInput, tempOutput, key);
res.setHeader("Content-Type", "application/pdf");
res.download(tempOutput, `encrypted.pdf`, (err) => {
if (fs.existsSync(tempInput)) fs.unlinkSync(tempInput);
if (fs.existsSync(tempOutput)) fs.unlinkSync(tempOutput);
});
} catch (encryptError) {
console.error("암호화 실패, 원본 전송 시도:", encryptError);
if (fs.existsSync(tempInput)) fs.unlinkSync(tempInput);
if (fs.existsSync(tempOutput)) fs.unlinkSync(tempOutput);
res.status(500).send("암호화 처리 중 오류가 발생했습니다.");
}
} else {
res.setHeader("Content-Type", contentType);
const reader = Readable.fromWeb(response.body);
reader.pipe(res);
}
} catch (error) {
console.error(error);
res.status(500).send("파일 프록시 다운로드 중 오류 발생");
}
};이 구현에서 기술적으로 가장 신경 쓴 부분은 세 가지다.
(1) 외부 파일 응답을 단순 버퍼가 아니라 스트림으로 처리해 메모리 부담을 줄였다.
(2) 암호화 작업은 어플리케이션 코드만으로 해결할 수 없어서 도커 컨테이너안에 qpdf바이너리를 설치하고 Node에서 호출하도록 연결했다.
(3) 임시 파일이 남지 않도록 성공/실패 흐름 모두에서 정리 로직을 두고 리스크를 줄이려고 했다.
보안 기능은 보통 정책만 생각하기 쉽지만, 실제로는 파일 I/O, 스트림, 프로세스 실행, 장애 복구까지 함께 설계하는것이 옳다.
암호화 자체는 서비스 레이어로 분리했다. 외부 파일 fetch와 qpdf 실행을 분리해 컨트롤러는 요청 흐름 제어에 집중하고 서비스는 실제 처리 책임을 가지도록 했다.
export const fetchExternalFile = async (fileUrl, clientCookie) => {
const response = await fetch(fileUrl, {
method: "GET",
headers: {
Cookie: clientCookie,
},
});
if (!response.ok) {
throw new Error(`외부 서버 응답 에러: ${response.statusText}`);
}
return response;
};
const execPromise = util.promisify(exec);
export const encryptWithQpdf = async (inputPath, outputPath, password) => {
const command = `qpdf --encrypt "${password}" "${password}" 256 -- "${inputPath}" "${outputPath}"`;
return await execPromise(command);
};운영 정책을 코드 밖으로 분리한 것도 중요한 결정이었다. 첨부 파일 암호화 적용 여부와 대상 부서, 대상 인원은 별도로 관리하게 했다.(자세히 밝힐 수 없기에...)
이렇게 하면 조직 정책이 바뀌었을 때 비즈니스 조건문을 다시 짜는 대신, 정책 데이터만 바꿔 대응할 수 있다.
내가 여태 경험한 보안 요구사항은 고정값이 아니라 계속 바뀌는 운영 규칙이기 때문에, 제발 하드코딩하지 말고 외부화를 하자!
이번 API 설계의 본질적인 변화는 인증된 사용자에게 링크를 내주는 수준에서 멈추지 않고, 문서가 전달되는 방식 자체를 보안 정책 중심으로 설계한 것이다.
한편 백엔드 뿐만 아니라, 프론트엔드의 다운로드 UX도 함께 재설계해야했다.
기존에는 화면에 첨부파일 링크를 그대로 노출하고, 사용자가 클릭하면 브라우저가 직접 파일을 내려받는 흐름이었다. 하지만 이 구조는 보안 관점에서 취약하다고 결정내렸다.
링크가 DOM에 노출되는 순간 복사, 재사용, 외부 전달 가능성이 생기고, 프론트엔드는 단순히 "보여주는 역할"만 수행하게 된다.
결국 보안이 백엔드 한 곳에만 있는 것이 아니라, 프론트엔드가 어떤 방식으로 리소스 접근을 허용하느냐에서도 결정된다는 사실을 다시 확인했다.
그래서 프론트엔드에서는 정적인 <a> 태그 기반 다운로드를 제거하고, 사용자의 명시적인 액션을 기준으로 보안 API를 호출하는 구조로 변화했다.
사용자가 첨부 문서를 클릭했다고 해서 즉시 원본 URL로 이동하는것이 아니라, 먼저 현재 사용자가 암호화 적용 대상인지, 첨부파일 암호화 정책이 켜져 있는지, 그리고 추가 인증 또는 첨부문서 비밀번호 입력이 필요한지를 UI 레벨에서 한번 더 확인하는 흐름이다.
이 변화는 단순한 화면 수정이 아니라, 다운로드를 "링크 탐색"이 아니라 "보안 절차를 통과한 요청"으로 바꾼다는 점에서 의미가 있다.
프론트엔드의 역할은 여기서 끝나지 않는다. 보안 UX를 설계할 때 가장 중요한 것은 사용자가 왜 한번 더 인증해야 하는지, 왜 비밀번호를 입력해야 하는지 납득할 수 있게 만드는 것이다.
사용성만 생각하면 클릭 한번으로 바로 다운로드되는 경험이 더 편하다. 하지만 보안 요구사항이 있는 서비스에서는 편의성과 통제 사이의 균형을 잡아야 한다.
그래서 프론트엔드는 단순히 입력창읠 띄우는 것이 아니라, 현재 요청이 어떤 보안 절차를 거치는 지 설명하고, 실패 시 "다운로드 실패"라는 모호한 문구 대신 인증 실패인지, 암호화 처리 실패인지, 네트워크 오류인지 구분된 메시지를 제공해야한다.
이런 세밀한 피드백이 있어야 보안 기능이 사용자에게 불편한 장애물이 아니라, 이해 가능한 시스템으로 받아들여진다.
기술 구현 측면에서도 프론트엔드는 생각보다 많은 책임을 가진다.
원본 파일 URL을 클라이언트 상태나 마크업에 오래 남겨두지 않고, 가능한 한 짧은 생명주기의 요청 데이터로 다루는 것이 중요하다.
프론트엔드는 최소한 보안 측면에서는 백엔드 API를 호출하는 소비자에 그치지 않고, 민감한 리소스가 브라우저 안에서 어떻게 노출되고 얼마나 오래 남는지까지 고려해야한다.
회사의 보안 정책은 백엔드에서만 잘한다고 완성되지 않는다.
프론트엔드가 여전히 정적 링크를 렌더링하거나, 원본 리소스 접근을 쉽게 허용한다면 전체 시스템은 쉽게 우회될 수 있다.
반대로 프론트엔드가 다운로드 버튼, 인증 입력, 정책 확인, 에러 핸들링까지 보안 시나리오에 맞게 설계되면, 백엔드의 보호 장치와 맞물려 훨씬 강한 방어선이 만들어진다.
결국 설계는 사용자의 액션부터 결과까지 전체 사용자 흐름을 고려해야한다.
끝