실험실 전용 로그인 시스템 만들기
지금까지 HTML로 페이지를 만들고, Express로 서버를 세우고, 데이터베이스에 시료를 저장하고, 쿠키로 세션을 관리하는 법을 배웠습니다. 여기까지 오면 자연스러운 질문이 생깁니다:
"이 웹앱을 우리 실험실 사람들만 쓰게 할 수 없을까?"
시료 관리 시스템에 민감한 실험 데이터가 있다면, 아무나 접속하면 안 됩니다. 공동 연구 그룹에게만 결과를 공유하고 싶을 수도 있고, 연구 발표 자료를 로그인한 리뷰어에게만 보여주고 싶을 수도 있습니다.
이때 필요한 것이 로그인 시스템입니다.
쿠키에서 로그인으로
auth-cookies 토픽에서 쿠키와 세션을 배웠습니다. "이 브라우저가 아까 로그인한 사람이구나"를 서버가 기억하는 구조였습니다.
하지만 한 가지 빠진 것이 있었습니다 — "로그인한 사람"을 어떻게 확인하는가? 아이디와 비밀번호를 받아서, 데이터베이스에 저장된 것과 비교하고, 맞으면 세션을 만들어주는 과정. 이것을 직접 만들면 코드가 복잡해지고, 보안 실수가 나기 쉽습니다.
이 복잡한 인증 과정을 대신 처리해주는 도구가 Passport.js입니다.
Passport.js: 인증 전문 미들웨어
Passport.js는 Express용 인증 미들웨어입니다. "인증은 이걸로 하세요"라고 만들어진 도구입니다.
npm install passportnpm install passport-local핵심 개념은 **전략(Strategy)**입니다. 인증 방법마다 별도의 전략 패키지가 있습니다:
| 전략 | 패키지 | 인증 방식 |
|---|---|---|
| Local | passport-local | 이메일 + 비밀번호 |
passport-google-oauth20 | Google 계정 | |
| GitHub | passport-github2 | GitHub 계정 |
| JWT | passport-jwt | JSON Web Token |
현재 Passport.js에는 300가지 이상의 전략이 등록되어 있습니다. 필요한 인증 방법의 전략만 설치하면 됩니다.
Passport 초기 설정: Express에 끼워넣기
Passport를 Express에 연결하는 기본 설정입니다:
const express = require("express");
const session = require("express-session");
const passport = require("passport");
const app = express();
app.use(express.urlencoded({ extended: false }));
app.use(session({
secret: "lab-secret-key",
resave: false,
saveUninitialized: false,
}));
app.use(passport.initialize());
app.use(passport.session());passport.initialize() — Passport를 Express에 등록합니다.
passport.session() — 세션에 저장된 사용자 정보를 매 요청마다 복원합니다.
이전 auth-cookies 토픽에서 배운 express-session과 함께 씁니다. 세션이 "기억"이라면, Passport는 "누가 기억되어야 하는지"를 결정합니다.
serializeUser와 deserializeUser
사용자가 로그인하면 세션에 사용자 정보를 저장해야 합니다. 하지만 사용자 객체 전체를 세션에 넣으면 무겁습니다. Passport는 이것을 직렬화(serialize)/역직렬화(deserialize) 패턴으로 해결합니다:
passport.serializeUser(function(user, done) {
done(null, user.id);
});
passport.deserializeUser(function(id, done) {
// 데이터베이스에서 id로 사용자 찾기
db.query("SELECT * FROM users WHERE id = ?", [id], function(err, rows) {
done(err, rows[0]);
});
});serializeUser — 로그인 성공 시 실행. 세션에 무엇을 저장할지 결정합니다. 보통 user.id만 저장합니다.
deserializeUser — 매 요청마다 실행. 저장된 id로 데이터베이스에서 사용자를 찾아 req.user에 넣어줍니다.
비유하면 — 도서관 대출 카드에 회원번호만 적어두고(serialize), 나중에 회원번호로 회원 정보를 조회하는 것(deserialize)과 같습니다.
로컬 인증: 이메일 + 비밀번호
가장 기본적인 로그인 방식입니다. 사용자가 이메일과 비밀번호를 입력하면, 데이터베이스에서 확인하고 로그인을 처리합니다.
const passport = require("passport");
const LocalStrategy = require("passport-local").Strategy;
const bcrypt = require("bcrypt");
passport.use(new LocalStrategy(
{ usernameField: "email" },
function(email, password, done) {
db.query("SELECT * FROM users WHERE email = ?", [email], function(err, rows) {
if (err) return done(err);
if (rows.length === 0) return done(null, false, { message: "등록되지 않은 이메일" });
const user = rows[0];
const isMatch = bcrypt.compareSync(password, user.password_hash);
if (!isMatch) return done(null, false, { message: "비밀번호 불일치" });
return done(null, user);
});
}
));done(null, user) — 인증 성공. Passport가 이 user를 serializeUser에 넘깁니다.
done(null, false) — 인증 실패. 에러는 아니지만 로그인 거부.
done(err) — 서버 에러 (DB 연결 실패 등).
Passport가 해주는 것:
- 로그인 폼에서 받은 이메일/비밀번호를 전략에 전달
- 인증 성공 시
serializeUser→ 세션에 사용자 ID 저장 - 이후 요청마다
deserializeUser→req.user로 현재 로그인한 사용자 정보 접근
이전에 배운 Express 미들웨어(app.use())와 같은 패턴입니다. Passport도 미들웨어로 끼워넣으면, 라우트에서 req.user로 로그인한 사용자를 확인할 수 있습니다.
회원가입: 새 연구원 등록
로그인이 있으려면 먼저 회원가입이 필요합니다. 전체 흐름:
사용자 → 회원가입 폼 (이메일, 비밀번호 입력)
→ 서버: 이메일 중복 확인
→ 서버: 비밀번호를 해시 처리 (bcrypt)
→ 서버: 해시된 비밀번호를 데이터베이스에 저장
→ 로그인 가능실제 Express 라우트로 구현하면:
const bcrypt = require("bcrypt");
app.post("/register", function(req, res) {
const { email, password, name } = req.body;
// 1. 이메일 중복 확인
db.query("SELECT * FROM users WHERE email = ?", [email], function(err, rows) {
if (rows.length > 0) {
return res.status(400).send("이미 등록된 이메일입니다");
}
// 2. 비밀번호 해시 처리
const hashedPassword = bcrypt.hashSync(password, 10);
// 3. 데이터베이스에 저장
db.query(
"INSERT INTO users (email, password_hash, name, role) VALUES (?, ?, ?, ?)",
[email, hashedPassword, name, "member"],
function(err) {
if (err) return res.status(500).send("등록 실패");
res.redirect("/login");
}
);
});
});비밀번호는 절대 평문으로 저장하지 않습니다. bcrypt 같은 해시 함수로 변환한 후 저장합니다.
const bcrypt = require("bcrypt");
// 회원가입 시: 평문 → 해시
const hashedPassword = bcrypt.hashSync("myLabPassword", 10);
// → "$2b$10$X7YKz..." 같은 해시값 저장
// 로그인 시: 입력값의 해시와 저장된 해시 비교
const isMatch = bcrypt.compareSync("myLabPassword", hashedPassword);
// → true 또는 falsebcrypt.hashSync(password, 10) — 10은 salt round (해시 반복 횟수). 숫자가 클수록 안전하지만 느립니다. 10이 일반적 권장값입니다.
이것은 실험실 보안과 같습니다. 건물 출입 카드의 코드가 평문으로 저장되어 있다면, 데이터베이스가 유출될 때 모든 문이 열립니다. 해시 처리는 — 카드를 복제할 수 없도록 만드는 것입니다.
로그인/로그아웃 라우트
회원가입 후 실제 로그인/로그아웃 라우트:
// 로그인 페이지 렌더링
app.get("/login", function(req, res) {
res.send(`
<form action="/login" method="POST">
<input type="email" name="email" placeholder="이메일" required />
<input type="password" name="password" placeholder="비밀번호" required />
<button type="submit">로그인</button>
</form>
`);
});
// 로그인 처리 — Passport가 LocalStrategy를 자동 실행
app.post("/login",
passport.authenticate("local", {
successRedirect: "/dashboard",
failureRedirect: "/login",
})
);
// 로그아웃
app.get("/logout", function(req, res) {
req.logout(function(err) {
if (err) return res.status(500).send("로그아웃 실패");
res.redirect("/");
});
});passport.authenticate("local") — LocalStrategy 이름("local")을 지정하면 Passport가 해당 전략을 실행합니다. 성공하면 successRedirect, 실패하면 failureRedirect로 이동합니다. 여기서 "local"은 passport-local 패키지가 등록하는 기본 이름입니다.
접근 제어: 누가 무엇을 볼 수 있는가
로그인 시스템의 진짜 목적은 접근 제어입니다. "이 페이지는 로그인한 사람만", "이 기능은 관리자만" — 이것을 구현합니다.
// 로그인한 사용자만 접근 가능한 미들웨어
function requireLogin(req, res, next) {
if (req.isAuthenticated()) {
return next();
}
res.redirect("/login");
}
// 시료 데이터 — 로그인 필수
app.get("/samples/confidential", requireLogin, function(req, res) {
res.json({ data: "기밀 실험 데이터", user: req.user.email });
});
// 공개 페이지 — 누구나 접근
app.get("/publications", function(req, res) {
res.json({ papers: publicPapers });
});바이오 연구에서 이런 시나리오는 흔합니다:
| 시나리오 | 접근 제어 |
|---|---|
| 실험실 내부 시료 관리 | 랩 멤버만 로그인 가능 |
| 공동 연구 데이터 공유 | 등록된 연구 그룹만 열람 |
| 연구 발표 자료 | 리뷰어에게만 공개 |
| 장비 예약 시스템 | 로그인한 연구원만 예약 |
| 결과 대시보드 | PI는 전체, 학생은 본인 데이터만 |
다중 사용자와 역할(Role)
사용자가 여러 명이면 **역할(Role)**이 필요합니다:
// 사용자 테이블 구조
// id | email | password_hash | role
// 1 | pi@lab.com | $2b$10$... | admin
// 2 | grad1@lab.com | $2b$10$... | member
// 3 | intern@lab.com | $2b$10$... | viewer
function requireAdmin(req, res, next) {
if (req.isAuthenticated() && req.user.role === "admin") {
return next();
}
res.status(403).json({ error: "관리자 권한이 필요합니다" });
}
// 시료 삭제 — 관리자만
app.delete("/sample/:id", requireAdmin, function(req, res) {
// 삭제 로직
});PI(교수)는 admin, 대학원생은 member, 인턴은 viewer — 실험실의 권한 구조를 그대로 코드로 옮기는 것입니다.
OAuth 2.0: 소셜 로그인의 원리
"Google 계정으로 로그인" 버튼을 본 적 있을 것입니다. 이것이 OAuth 2.0입니다.
핵심 원리를 비유로 설명하면 — 호텔 카드키 발급 과정과 같습니다:
1. 투숙객(사용자)이 호텔(내 웹앱)에 체크인하고 싶음
2. 호텔은 직접 신분 확인 능력이 없음
3. 호텔: "저기 프론트 데스크(Google)에서 신분 확인하고 오세요"
4. 투숙객이 프론트 데스크에서 여권(Google 아이디/비밀번호) 제시
5. 프론트 데스크가 임시 교환권(Authorization Code) 발급
6. 호텔이 교환권 + 사업자등록증(Client Secret)으로 카드키(Access Token) 교환
7. 카드키로 투숙객 정보(이름, 이메일) 조회 가능핵심: 내 웹앱은 사용자의 Google 비밀번호를 절대 보지 못합니다. Google이 대신 신분을 확인해주고, "이 사람 맞아요"라는 토큰만 전달합니다.
OAuth 등장인물 3명
| 역할 | 정체 | 비유 |
|---|---|---|
| Resource Owner | 사용자 (연구원) | 호텔 투숙객 |
| Client | 내 웹앱 (시료 관리 시스템) | 호텔 |
| Resource Server | Google, GitHub 등 | 프론트 데스크 (신분 확인) |
Passport.js에서 Google OAuth를 사용하면:
const GoogleStrategy = require("passport-google-oauth20").Strategy;
passport.use(new GoogleStrategy({
clientID: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
callbackURL: "/auth/google/callback"
},
function(accessToken, refreshToken, profile, done) {
// profile.emails[0].value → 사용자 이메일
// 데이터베이스에서 찾거나 새로 생성
done(null, user);
}
));
app.get("/auth/google",
passport.authenticate("google", { scope: ["profile", "email"] })
);clientID와 clientSecret은 Google Cloud Console에서 발급받습니다. "나는 이런 앱을 만들 건데, 사용자 인증을 구글한테 맡기고 싶어요"라고 등록하는 과정입니다.
OAuth 핵심 용어 정리
Client ID와 Client Secret
Google Cloud Console에서 앱을 등록하면 두 가지를 발급받습니다:
| 용어 | 비유 | 역할 |
|---|---|---|
| Client ID | 사업자등록번호 | 공개 가능. "이 앱이 인증을 요청합니다"를 식별 |
| Client Secret | 사업자 도장 | 절대 비공개. 서버 → Google 간 비밀 인증에만 사용 |
Client Secret이 유출되면 다른 사람이 내 앱인 척 Google에 인증 요청을 보낼 수 있습니다.
Redirect URI (Callback URL)
Google 로그인 후 "인증 완료, 여기로 돌려보내"라고 등록하는 주소입니다:
https://my-lab-app.com/auth/google/callbackGoogle Cloud Console에 등록한 Redirect URI와 코드의 callbackURL이 정확히 일치해야 합니다. 한 글자라도 다르면 에러입니다. 이것은 보안장치 — 인증 결과가 엉뚱한 사이트로 가는 것을 방지합니다.
Scope: 발렛키 비유
OAuth의 scope는 **"어디까지 허용할 것인가"**를 정합니다.
자동차 발렛 주차를 생각해보세요. 발렛키(valet key)를 주면 — 시동을 걸고 주차할 수 있지만, 트렁크는 열 수 없고, 글로브 박스도 잠겨 있습니다. 필요한 권한만 제한적으로 부여하는 것입니다.
passport.authenticate("google", { scope: ["profile", "email"] })| scope | 접근 범위 | 발렛키 비유 |
|---|---|---|
profile | 이름, 프로필 사진 | 운전석 접근 (주차 가능) |
email | 이메일 주소 | 대시보드 확인 (연락처 확인) |
drive.readonly | Google Drive 파일 읽기 | 트렁크 열기 (짐 확인) |
calendar | 캘린더 읽기/쓰기 | 차 안 일정표 수정 |
우리 실험실 로그인에는 profile과 email이면 충분합니다. "이 사람이 누구인지"만 알면 되니까요. 불필요한 scope을 요청하면 사용자가 불안해하고, Google이 앱 검수를 요구할 수도 있습니다.
Authorization Code Flow (전체 흐름)
OAuth 2.0에서 가장 안전하고 일반적인 방식입니다:
1. 사용자가 "Google로 로그인" 클릭
→ 브라우저가 Google 로그인 페이지로 이동
2. 사용자가 Google에 아이디/비밀번호 입력
→ Google이 "이 앱에 이름, 이메일을 제공해도 되겠습니까?" 확인
3. 사용자가 "허용" 클릭
→ Google이 Authorization Code를 우리 서버의 Redirect URI로 전달
4. 우리 서버가 Authorization Code + Client Secret을 Google에 전송
→ Google이 Access Token 발급
5. 우리 서버가 Access Token으로 사용자 프로필(이름, 이메일) 조회
→ Passport의 콜백 함수에서 이 정보로 로그인 처리핵심: Authorization Code는 1회용 교환권입니다. 이것 자체로는 사용자 정보를 볼 수 없고, Client Secret과 합쳐야 Access Token으로 교환됩니다. 두 개가 동시에 탈취되지 않는 한 안전합니다.
Google OAuth 라우트 설정
실제 Express 라우트:
// 1. "Google로 로그인" 버튼이 이 URL로 이동
app.get("/auth/google",
passport.authenticate("google", { scope: ["profile", "email"] })
);
// 2. Google 인증 후 여기로 돌아옴 (Redirect URI)
app.get("/auth/google/callback",
passport.authenticate("google", { failureRedirect: "/login" }),
function(req, res) {
res.redirect("/dashboard");
}
);사용자 입장에서는 "Google로 로그인" 버튼 하나를 누르면 끝입니다. 뒤에서 Authorization Code → Access Token → 프로필 조회가 자동으로 일어납니다.
왜 소셜 로그인을 쓸까
| 장점 | 설명 |
|---|---|
| 사용자 편의 | 새 비밀번호를 만들 필요 없음 |
| 보안 위임 | 비밀번호 저장/관리의 부담을 Google에 위임 |
| 신뢰성 | Google이 2단계 인증 등을 이미 제공 |
| 빠른 구현 | Passport 전략 설치 + 설정이면 끝 |
실험실 내부 도구에서는 특히 유용합니다. 연구원들은 이미 Google 계정이 있으니, 별도 회원가입 없이 "Google로 로그인" 한 번으로 바로 접속할 수 있습니다.
전체 그림: 인증 시스템의 계층
지금까지 배운 것을 쌓아봅니다:
[auth-cookies] 쿠키 & 세션 — "이 브라우저를 기억한다"
↓
[login-system] 로그인 — "누구인지 확인한다"
↓
├── Local 인증 (이메일 + 비밀번호 + bcrypt)
├── OAuth 2.0 (Google/GitHub 소셜 로그인)
└── 역할 기반 접근 제어 (admin/member/viewer)쿠키/세션이 "기억"이라면, 로그인은 "확인"이고, 역할은 "권한"입니다. 건물에 비유하면 — 출입증(쿠키)이 있어도 모든 방에 들어갈 수 있는 것은 아닙니다. 어떤 방은 PI 카드만, 어떤 방은 모든 연구원 카드로 열립니다.
직접 해보기 (Faded Example)
아래 빈칸을 채워 Passport.js 로컬 인증 전략을 완성하세요.
const passport = require("passport");const = require("passport-local").Strategy;const bcrypt = require("bcrypt");passport.use(new LocalStrategy({ usernameField: "" },function(email, password, done) {db.query("SELECT * FROM users WHERE email = ?", [email], function(err, rows) {if (rows.length === 0) return done(null, );const user = rows[0];const isMatch = bcrypt.(password, user.password_hash);if (!isMatch) return done(null, false);return done(null, );});}));
다음 단계
이 토픽은 로그인 시스템의 구조와 흐름을 이해하는 것이 목적입니다. 실제 구현에서는:
passport-local+bcrypt— 이메일/비밀번호 로그인 구현passport-google-oauth20— Google 소셜 로그인 추가- 역할 미들웨어 —
requireLogin,requireAdmin등 접근 제어 - HTTPS — 로그인 정보가 네트워크에서 평문으로 전송되지 않도록
BioPlayground 자체도 Supabase의 인증 시스템을 사용합니다. 직접 Passport.js로 구현하든, Supabase 같은 BaaS를 쓰든 — 이 토픽에서 배운 개념(해시, OAuth 흐름, 역할 기반 제어)은 동일하게 적용됩니다.
흔한 에러 & 해결법
Q: 비밀번호를 해시하지 않고 저장하면 어떻게 되나요?
데이터베이스가 유출될 경우 모든 사용자의 비밀번호가 노출됩니다. 많은 사람이 같은 비밀번호를 여러 사이트에서 사용하기 때문에, 한 곳의 유출이 연쇄적인 피해로 이어집니다. 해시 처리는 선택이 아니라 필수입니다.
Q: OAuth의 Client Secret은 어디에 저장하나요?
코드에 직접 적으면 안 됩니다. 환경 변수(.env 파일)에 저장하고, .gitignore에 .env를 추가해서 Git에 올라가지 않도록 합니다. 유출되면 다른 사람이 내 앱인 척 할 수 있습니다.
Q: Passport.js 없이 로그인을 만들 수 있나요?
가능합니다. bcrypt로 비밀번호를 비교하고, 세션에 사용자 정보를 저장하면 됩니다. 하지만 소셜 로그인, 세션 직렬화, 에러 처리 등을 직접 구현해야 하므로 코드가 복잡해집니다. Passport.js는 이 반복 작업을 줄여주는 프레임워크입니다.
Q: Supabase를 쓰면 이것을 직접 만들 필요가 없나요?
맞습니다. Supabase는 이메일/비밀번호 인증, Google 소셜 로그인, 역할 기반 접근 제어를 설정만으로 제공합니다. 하지만 "Supabase가 내부에서 무엇을 하고 있는지" 이해하려면 이 토픽의 개념이 필요합니다. 도구를 쓰더라도 원리를 아는 사람이 문제를 더 빨리 해결합니다.