프로그래밍을 처음 배울 때 로그인은 보통 사용자가 입력한 아이디와 비밀번호가 데이터베이스의 값과 일치하는지 확인하는 기능이라고 배운다.
if (
input.email === user.email &&
input.password === user.password
) {
return "Login successful";
}
두 값이 맞으면 로그인을 성공시키고, 다르면 실패시킨다.
기본 개념을 이해하기에는 충분한 설명이다. 하지만 실제 서비스를 개발하면 문자열 두 개를 비교하는 것보다 더 많은 판단이 필요하다.
로그인은 단순히 아이디와 비밀번호를 확인하는 기능이 아니다.
로그인은 사용자가 주장한 신원이 실제 본인의 것인지 여러 증거와 정책으로 확인하고, 그 결과를 이후 요청에서 안전하게 사용할 수 있는 인증 상태로 전환하는 과정이다.
크리스가 팀 협업 서비스를 개발한다고 생각해 보자. 사용자는 이메일과 비밀번호를 입력해 로그인한다.
type LoginInput = {
email: string;
password: string;
};
이메일은 어떤 계정을 확인해야 하는지 알려준다. 그러나 이메일 주소를 입력할 수 있다는 사실은 해당 계정의 소유자임을 증명하지 않는다.
email
↓
어떤 계정을 확인할 것인가?
password
↓
그 계정을 사용할 수 있는 증거를 가지고 있는가?
이 둘은 서로 다른 책임을 가진다.
처음에는 다음처럼 이메일만으로 사용자를 찾을 수 있다.
const user =
await userRepository.findByEmail(input.email);
하지만 찾은 사용자를 바로 로그인시키면 안 된다.
if (user) {
return createSession(user.id);
}
이 코드는 이메일 주소만 알면 다른 사용자의 인증 상태를 만들 수 있다.
사용자를 찾는 단계와 신원을 증명하는 단계는 분리해야 한다.
const user =
await userRepository.findByEmail(input.email);
const passwordMatches =
user &&
(await passwordHasher.verify(
input.password,
user.passwordHash
));
if (!passwordMatches) {
throw new InvalidCredentialsError();
}
이 코드는 계정을 찾은 뒤 제출된 비밀번호가 저장된 인증 정보와 일치하는지 검증한다.
로그인의 첫 번째 설계 판단은 “어떤 사용자인가”와 “정말 그 사용자인가”를 구분하는 데서 시작한다.
다음과 같이 비밀번호 원문을 저장하면 안 된다.
type User = {
email: string;
password: string;
};
데이터베이스가 노출되면 모든 사용자의 비밀번호가 그대로 공개된다. 같은 비밀번호를 다른 서비스에서도 사용한 사용자는 더 큰 피해를 볼 수 있다.
서비스에는 사용자의 원래 비밀번호를 다시 보여줄 필요가 없다. 로그인 시 같은 비밀번호가 제출되었는지만 확인하면 된다.
type UserCredential = {
userId: string;
passwordHash: string;
};
회원가입 시 비밀번호를 단방향 형태로 변환해 저장한다.
const passwordHash =
await passwordHasher.hash(signupInput.password);
await credentialRepository.create({
userId,
passwordHash,
});
로그인할 때는 제출된 비밀번호를 원문과 비교하지 않고 저장된 해시를 검증한다.
const passwordMatches =
await passwordHasher.verify(
loginInput.password,
credential.passwordHash
);
여기서 passwordHasher는 비밀번호 저장에 적합한 알고리즘과 각 사용자별 salt, 비용 설정을 처리한다고 가정한다.
암호화와 해싱의 구체적인 차이는 별도의 주제로 다룰 수 있지만 로그인 설계에서 필요한 핵심은 분명하다.
비밀번호는 사용자를 설명하는 프로필 데이터가 아니다. 제한된 경로에서만 다뤄야 하는 인증 증거다.
로그인 폼에서 전달된 값은 외부 입력이다. 외부 입력은 검증 전까지 신뢰할 수 없다.
import { z } from "zod";
const LoginInputSchema = z.object({
email: z
.string()
.trim()
.email()
.max(254)
.transform((value) => value.toLowerCase()),
password: z
.string()
.min(1)
.max(256),
});
const input =
LoginInputSchema.parse(request.body);
이 검증은 이메일 형식을 정리하고 지나치게 긴 입력을 제한한다.
로그인 단계에서는 회원가입과 같은 비밀번호 복잡도 규칙을 다시 적용하지 않는 편이 낫다. 과거 정책으로 생성된 정상 비밀번호가 현재 규칙과 다를 수 있기 때문이다. 로그인에서는 비밀번호가 전달되었는지와 시스템이 처리할 수 있는 길이인지 확인한 뒤 실제 인증 정보와 비교한다.
이메일의 대소문자 처리 방식도 서비스 전체에서 일관되어야 한다.
const normalizedEmail =
normalizeEmail(input.email);
const user =
await userRepository.findByEmail(normalizedEmail);
회원가입, 로그인, 비밀번호 재설정에서 서로 다른 정규화 규칙을 사용하면 같은 주소가 다른 계정처럼 취급될 수 있다.
검증은 사용자가 본인인지 증명하지 않는다. 인증 로직이 예측 가능한 입력을 받아 안전하게 동작하도록 요청 경계를 정리한다.
다음과 같이 실패 이유를 구체적으로 나누면 사용자에게 친절해 보일 수 있다.
if (!user) {
throw new Error("This email is not registered.");
}
if (!passwordMatches) {
throw new Error("The password is incorrect.");
}
하지만 공격자는 응답을 비교해 어떤 이메일이 가입되어 있는지 확인할 수 있다.
chris@example.com → 비밀번호가 틀림
unknown@example.com → 가입되지 않은 이메일
이 정보는 이후 비밀번호 추측, 피싱, 계정 탈취 시도에 사용될 수 있다.
외부 응답은 계정 존재 여부를 구분하지 않도록 설계할 수 있다.
if (!user || !passwordMatches) {
throw new InvalidCredentialsError(
"Email or password is incorrect."
);
}
내부 로그에서는 운영에 필요한 실패 유형을 구분할 수 있지만 비밀번호나 민감한 인증 정보는 기록하지 않아야 한다.
logger.warn("Login failed", {
reason: user ? "password_mismatch" : "user_not_found",
requestId,
ipHash,
});
내부 관찰 가능성과 외부 정보 노출은 서로 다른 책임이다.
응답 시간 차이도 계정 존재 여부를 드러낼 수 있다. 존재하지 않는 계정에서도 실제 비밀번호 검증과 비슷한 비용의 처리를 수행하는 방법을 고려할 수 있다.
const passwordHash =
user?.passwordHash ?? DUMMY_PASSWORD_HASH;
const passwordMatches =
await passwordHasher.verify(
input.password,
passwordHash
);
이 코드는 사용자가 없더라도 일정한 형태의 비밀번호 검증을 수행한다.
완전히 동일한 응답 시간을 보장하기는 어렵지만, 계정 존재 여부에 따라 처리 과정이 지나치게 달라지지 않도록 할 수 있다.
비밀번호가 맞아도 계정이 서비스를 사용할 수 없는 상태일 수 있다.
type AccountStatus =
| "pending_verification"
| "active"
| "suspended"
| "locked"
| "deleted";
단순히 비밀번호만 확인하면 정지된 사용자도 로그인할 수 있다.
if (passwordMatches) {
return sessionService.create(user.id);
}
계정 상태를 함께 확인해야 한다.
if (!passwordMatches) {
throw new InvalidCredentialsError();
}
switch (user.status) {
case "pending_verification":
throw new EmailVerificationRequiredError();
case "suspended":
case "locked":
case "deleted":
throw new AccountUnavailableError();
case "active":
break;
}
그러나 상태마다 외부에 어느 정도의 정보를 보여줄지도 판단해야 한다.
이메일 인증이 필요한 사용자는 다음 행동을 안내할 수 있다. 반면 보안상 잠긴 계정이나 삭제 여부는 공격자에게 자세히 공개하지 않는 편이 나을 수 있다.
계정 상태의 Source of Truth는 서버의 사용자 데이터다. 프론트엔드에서 저장한 isActive나 이전 로그인 결과를 기준으로 현재 상태를 판단해서는 안 된다.
const user =
await userRepository.findAuthenticationRecord(
normalizedEmail
);
로그인 시점에는 현재 상태와 인증 정보를 서버에서 다시 확인해야 한다.
로그인 성공은 “비밀번호가 맞다”만 의미하지 않는다. 현재 정책에 따라 해당 계정이 인증 상태를 만들 수 있다는 의미까지 포함해야 한다.
비밀번호는 복사되거나 재사용되거나 피싱으로 노출될 수 있다. 서비스의 위험 수준에 따라 추가 인증 단계가 필요할 수 있다.
if (user.mfaEnabled) {
return {
status: "mfa_required",
challengeId:
await mfaService.createChallenge(user.id),
};
}
비밀번호 검증은 끝났지만 로그인 전체는 아직 완료되지 않았다.
이메일과 비밀번호 확인
↓
추가 인증 필요 여부 판단
↓
일회용 코드 또는 보안 키 검증
↓
최종 인증 상태 생성
추가 인증 요청에는 짧은 생명주기와 제한된 권한을 가진 임시 상태가 필요하다.
type LoginChallenge = {
id: string;
userId: string;
type: "totp" | "security_key" | "recovery_code";
expiresAt: string;
attemptsRemaining: number;
};
이 도전 상태는 일반 로그인 세션이 아니다. 추가 증거를 제출할 수 있는 제한된 중간 상태다.
일회용 코드도 외부 입력이므로 검증 전까지 신뢰할 수 없다.
const MfaInputSchema = z.object({
challengeId: z.string().uuid(),
code: z.string().regex(/^\d{6}$/),
});
코드 형식뿐 아니라 다음 사항도 확인해야 한다.
추가 인증은 화면 하나를 더 보여주는 기능이 아니다. 서로 다른 인증 증거를 순서대로 확인하는 상태 전이 과정이다.
서버가 사용자의 신원을 확인했더라도 다음 요청은 별도의 HTTP 요청으로 들어온다.
POST /login
GET /projects
POST /projects/123/tasks
로그인 요청에서 비밀번호를 확인한 사실을 이후 요청과 연결할 방법이 필요하다.
const session =
await sessionService.create({
userId: user.id,
authenticatedAt: new Date(),
});
서버는 세션이나 토큰을 발급해 인증 결과를 다음 요청에서 확인할 수 있게 한다.
중요한 것은 인증 상태에 필요한 정보만 담는 것이다.
type Session = {
id: string;
userId: string;
createdAt: string;
expiresAt: string;
lastSeenAt: string;
};
인증 상태에는 생명주기가 필요하다.
로그인 성공 응답으로 사용자 전체 데이터를 그대로 저장하는 방식은 주의해야 한다.
localStorage.setItem(
"user",
JSON.stringify({
...user,
passwordHash: user.passwordHash,
})
);
비밀번호 해시와 내부 보안 정보는 클라이언트에 전달할 이유가 없다.
return {
user: {
id: user.id,
displayName: user.displayName,
},
authentication: {
expiresAt: session.expiresAt,
},
};
클라이언트에는 화면에 필요한 최소 정보만 전달해야 한다.
세션과 토큰의 구체적인 구조는 다음 주제에서 더 자세히 다룰 수 있다. 로그인 관점에서 핵심은 인증 성공이 영구적인 신뢰를 만드는 것이 아니라 만료되고 폐기할 수 있는 제한된 인증 상태를 만든다는 점이다.
사용자가 로그인하면 서버는 “누구인지 확인된 사용자”라는 사실을 알 수 있다.
그러나 로그인했다는 사실만으로 모든 프로젝트를 수정할 권한이 생기지는 않는다.
if (!currentUser) {
throw new UnauthenticatedError();
}
이 코드는 인증 여부만 확인한다.
프로젝트 삭제에는 별도의 권한 검사가 필요하다.
const membership =
await membershipRepository.find({
projectId,
userId: currentUser.id,
});
if (membership?.role !== "owner") {
throw new ForbiddenError();
}
두 질문을 구분해야 한다.
인증(Authentication)
이 요청을 보낸 사용자는 누구인가?
권한(Authorization)
이 사용자가 이 데이터에 이 행동을 할 수 있는가?
로그인은 인증 과정이다. 로그인 이후의 각 요청은 해당 자원과 행동에 대한 권한을 별도로 확인해야 한다.
관리자 페이지를 프론트엔드에서 숨기는 것만으로는 권한 검사가 되지 않는다.
{currentUser.role === "admin" && (
<AdminMenu />
)}
이 코드는 화면 표시를 제어할 뿐이다. 서버 API에서도 현재 사용자의 권한을 확인해야 한다.
await authorizationService.assertCan({
actor: currentUser,
action: "manage_users",
resource: organization,
});
인증은 권한 판단에 필요한 사용자 신원을 제공하지만 허용되는 행동까지 자동으로 결정하지 않는다.
공격자는 하나의 계정에 많은 비밀번호를 시도하거나 여러 계정에 흔한 비밀번호를 시도할 수 있다.
for (const password of leakedPasswords) {
await login({
email: "chris@example.com",
password,
});
}
단순히 비밀번호가 맞는지만 검사하면 공격자는 제한 없이 추측할 수 있다.
로그인 경로에는 시도 제한이 필요하다.
const attemptKey =
`login:${ipAddress}:${normalizedEmail}`;
const attempts =
await rateLimiter.consume(attemptKey);
if (!attempts.allowed) {
throw new TooManyLoginAttemptsError();
}
제한 기준은 한 가지 값에만 의존하지 않는 편이 낫다.
따라서 IP, 계정, 시간 범위, 실패 패턴, 위험 신호를 조합해 단계적으로 대응할 수 있다.
일반적인 실패
↓
짧은 지연 또는 추가 기록
↓
반복 실패
↓
일시적 요청 제한
↓
높은 위험
↓
추가 인증·보안 알림·계정 보호
성공한 로그인 이후에도 실패 횟수를 무조건 삭제하기보다 보안 분석을 위한 기록을 남길 수 있다. 다만 로그에 비밀번호나 인증 코드를 저장해서는 안 된다.
로그인 실패는 사용자 경험의 일부이면서 보안 신호이기도 하다.
사용자는 자신의 계정에 어떤 기기가 로그인되어 있는지 확인하고 싶을 수 있다.
type LoginEvent = {
userId: string;
occurredAt: string;
result: "success" | "failure";
ipHash: string;
userAgentSummary: string;
requestId: string;
};
로그인 이벤트는 다음 질문에 답하는 데 도움을 준다.
그러나 보안 로그 자체에도 민감한 정보가 포함될 수 있다.
사용자에게 보이는 기기 목록과 내부 보안 로그의 목적도 다르다.
| 기록 | 목적 | 보관 정보 |
|---|---|---|
| 사용자용 로그인 기기 | 세션 확인과 로그아웃 | 기기 이름, 최근 사용 시각 |
| 보안 이벤트 | 공격 탐지와 조사 | 결과, 위험 신호, 요청 ID |
| 운영 로그 | 장애 원인 추적 | 오류 유형, 처리 단계 |
| 감사 기록 | 중요한 계정 변경 추적 | 수행자, 변경 내용, 시각 |
로그인은 성공과 실패를 반환하고 끝나는 요청이 아니다. 계정 보호와 사고 대응을 위해 추적 가능한 인증 사건이다.
프론트엔드에서 사용자 상태를 초기화하면 화면상으로는 로그아웃된 것처럼 보인다.
authStore.setState({
currentUser: null,
isAuthenticated: false,
});
그러나 서버의 세션이나 재사용 가능한 인증 정보가 그대로 남아 있다면 다시 사용할 수 있다.
로그아웃은 현재 인증 상태를 더 이상 사용할 수 없게 만드는 과정이어야 한다.
await sessionService.revoke(currentSession.id);
클라이언트도 보유한 인증 정보를 제거한다.
authStore.clear();
로그아웃의 범위도 구분해야 한다.
type LogoutScope =
| "current_session"
| "other_sessions"
| "all_sessions";
사용자는 현재 기기에서만 로그아웃하거나 계정 탈취가 의심될 때 모든 기기의 인증 상태를 종료할 수 있어야 한다.
비밀번호 변경, 이메일 변경, 추가 인증 해제와 같은 중요한 보안 변경 후 기존 세션을 유지할지도 정책으로 정해야 한다.
로그인은 인증 상태를 만드는 과정이고 로그아웃은 그 상태의 신뢰를 철회하는 과정이다. 화면 상태만 지우는 것으로는 충분하지 않다.
사용자가 비밀번호를 잊으면 이메일을 통해 재설정 링크를 받을 수 있다.
await passwordResetService.request({
email: input.email,
});
비밀번호 재설정은 기존 비밀번호 없이 새로운 인증 정보를 설정하는 기능이다. 따라서 사실상 또 하나의 로그인 경로와 비슷한 위험을 가진다.
계정 존재 여부를 노출하지 않도록 응답을 통일할 수 있다.
return {
message:
"If an account exists, a reset link has been sent.",
};
재설정 토큰에는 제한된 목적과 생명주기가 필요하다.
type PasswordResetToken = {
userId: string;
tokenHash: string;
expiresAt: string;
usedAt: string | null;
};
재설정 토큰은 다음 조건을 가져야 한다.
계정 복구 흐름이 로그인보다 약하면 공격자는 로그인 화면 대신 복구 기능을 공격한다.
로그인 보안은 비밀번호 확인 코드만으로 완성되지 않는다. 가입, 인증, 로그인, 추가 인증, 로그아웃, 비밀번호 변경, 복구까지 연결된 전체 계정 생명주기로 설계해야 한다.
팀 협업 서비스의 로그인 흐름을 정리하면 다음과 같다.
flowchart LR
A[이메일·비밀번호 입력] --> B[입력 검증·정규화]
B --> C[계정과 인증 정보 조회]
C --> D[비밀번호 검증]
D --> E[계정 상태 확인]
E --> F{추가 인증 필요?}
F -->|예| G[추가 인증 검증]
F -->|아니오| H[인증 상태 발급]
G --> H
H --> I[보안 이벤트 기록]
I --> J[이후 요청에서 인증 확인]
J --> K[각 자원에 대한 권한 확인]
각 단계는 서로 다른 질문에 답한다.
로그인은 한 번의 if문이 아니라 신뢰할 수 없는 요청을 제한된 인증 상태로 바꾸는 여러 단계의 판단 과정이다.
이메일은 계정을 식별하지만 해당 계정의 소유권을 증명하지 않는다.
식별과 인증 증거 검증을 분리해야 한다.
데이터베이스가 노출되면 사용자의 비밀번호가 그대로 공개된다.
서비스에는 원문 복원이 아니라 안전한 비교가 필요하다.
“존재하지 않는 이메일”과 “잘못된 비밀번호”를 구분하면 가입된 계정을 수집할 수 있다.
외부 응답과 내부 로그의 상세 수준을 분리해야 한다.
정지되거나 삭제된 계정이 새로운 인증 상태를 만들 수 있다.
인증 정보뿐 아니라 현재 계정 정책도 확인해야 한다.
인증 상태가 만료되거나 폐기되지 않으면 탈취 후 장기간 사용될 수 있다.
세션과 토큰에는 제한된 생명주기와 철회 방법이 필요하다.
인증은 사용자가 누구인지 확인할 뿐이다.
각 자원에 대한 소유권, 역할, 행동 권한은 별도로 검사해야 한다.
서버의 인증 상태가 남아 있으면 탈취된 정보가 계속 사용될 수 있다.
로그아웃은 서버에서 인증 상태의 신뢰를 철회해야 한다.
공격자는 더 약한 복구 경로를 통해 계정을 탈취할 수 있다.
계정 복구도 인증과 같은 수준의 검증, 만료, 제한, 기록이 필요하다.
프로그래밍을 처음 배울 때는 로그인을 아이디와 비밀번호가 일치하는지 확인하는 기능이라고 이해해도 충분하다.
const passwordMatches =
await verifyPassword(input.password, user.passwordHash);
하지만 실제 서비스에서 로그인은 하나의 비교로 끝나지 않는다.
로그인은 아이디와 비밀번호를 확인하는 기능이 아니다.
로그인은 사용자가 주장한 신원이 실제 본인의 것인지 여러 증거와 정책으로 확인하고, 그 결과를 이후 요청에서 안전하게 사용할 수 있는 인증 상태로 전환하는 과정이다.