외주 제작 경험

어드민 로그인 인증 로직을 이해하기

Frisbeen 2026. 1. 7. 19:06

반성하며 시작 : 로그인을 대충 이런 느낌으로 어림짐작 했던 나

프론트엔드 개발자나 클라이언트 입장에서의 로그인은 이런 느낌일것입니다.

“로그인 요청을 보내면 서버에서 토큰이 온다” → “그 토큰을 저장하고 요청 헤더에 붙인다” → “서버는 그 토큰을 확인하고 권한을 부여한다”

 

이 3가지 흐름은 명확하게 알고 계십니다. 그러나, 백엔드 코드를 직접 작성하는 백엔드 개발자에서의 시선은 여기서 한단계 더 내려갑니다.

“토큰은 누가 어떤 정보로 만들어야하지?”

“서버는 토큰을 어떻게 믿지?”

“토큰이 만료되면, 왜 RefreshToken이 필요하지?”

로그인 성공은 단순하지 않다.

로그인 시스템을 구현할때는 보통 두 단계를 고려합니다.

먼저 인증(Authentication)을 하겠습니다.

“로그인 하려고 하는 너는 도대체 누구야”

 

 

그 이후 사용자가 입력한 이메일/비밀번호를 또 검증하죠. 성공을 한다면 서버에서 토큰을 제공해주겠습니다.

 

또 그 이후, 인증이 완료된 사용자에게 다른 질문을 던집니다. Authorization(인가)를 처리하겠습니다.

 

요청마다, 토큰을 검사하며, 토큰 안의 role을 보고 권한을 판단하는 구조입니다.

적용되는 Entitiy :  Admin Model에 관하여

로그인이 필요한 어드민 계정이 DB에서 어떤 형태로 저장되는가를 고려해야합니다.

주요 컬럼: email, password, name, role, is_active, last_login_at 제가 설계한 어드민 테이블의 Attribute들은 위와 같습니다.

여기서 password는 타입은 string이지만, hash함수를 주입하여 암호화하여 저장합니다.

role 또한 string 타입으로 생각할 수 있겠지만, enum 처리를 하였습니다.

 

관리자 권한을 미리 정의된 값으로만 제한해 권한 체계를 명확하게 하고, 잘못된 값으로 인한 버그와 보안 문제를 컴파일·런타임 단계에서 동시에 예방할 수 있겠습니다.

export enum AdminRole {
  SUPER_ADMIN = 'SUPER_ADMIN',
  ADMIN = 'ADMIN',
  EDITOR = 'EDITOR',
}

Node.js 환경으로 구축하고 있기에, ORM 모델은 Sequlieze를 활용하였습니다.

AdminModel.init(
  {
    id: {
      type: DataTypes.UUID,
      primaryKey: true,
      defaultValue: DataTypes.UUIDV4,
    },
    email: {
      type: DataTypes.STRING,
      allowNull: false,
      unique: true,
    },
    password: {
      type: DataTypes.STRING,
      allowNull: false,
    },
    name: {
      type: DataTypes.STRING,
      allowNull: false,
    },
    role: {
      type: DataTypes.ENUM(...Object.values(AdminRole)),
      allowNull: false,
      defaultValue: AdminRole.EDITOR,
    },
    isActive: {
      type: DataTypes.BOOLEAN,
      allowNull: false,
      defaultValue: true,
      field: 'is_active',
    },
    lastLoginAt: {
      type: DataTypes.DATE,
      allowNull: true,
      field: 'last_login_at',
    },
  },
  {
    sequelize,
    tableName: 'admins',
    timestamps: true,
    underscored: true,
  }
);

로그인 이전 : "Bcrypt야 고마워"

토큰은 로그인에 성공했을 때의 결과물입니다.

따라서 토큰 이야기를 하기 전에, “성공”이 무엇인지부터 코드로 정의되어야 합니다.

로그인 요청이 들어오면, 서버는 곧바로 토큰을 만들지 않습니다.

먼저 이 요청이 올바른 Admin인지 아닌지를 내부 로직으로 검증합니다.

1. 이메일을 기준으로 Admin 계정을 조회한다

로그인 요청으로 전달된 email을 이용해, 서버는 먼저 Admin 계정을 조회합니다.

레이어드 아키텍쳐 구조를 따르고 있기에, AdminRepo 계층에서 (DB 접근 가능 계층)에서 먼저 admin 계정이 있나 이메일로 체크합니다.

const admin =await AdminRepo.findByEmail(email);

여기서 확인하는 것은 두 가지입니다.

  • 해당 이메일을 가진 Admin이 실제로 존재하는지
  • 존재한다면, 현재 활성화된 계정인지 (isActive)
if (!admin || !admin.isActive) {
throw new AppError('Invalid credentials',401);
}

이 시점에서 로그인 실패가 결정되면,

비밀번호 검증 단계로는 넘어가지 않습니다.

2. 비밀번호는 복호화하지 않고, 비교만 한다

Admin 계정이 존재하고 활성화되어 있다면, 그 다음 단계는 비밀번호 검증입니다.

여기서 서버는 DB에 저장된 비밀번호를 “풀어보지” 않습니다. 그럴 수 없고, 그렇게 해서도 안 됩니다.

서버는

const isPasswordValid =await comparePassword(password, admin.password);

comparePassword는 내부적으로 bcrypt의 compare를 사용합니다.

export const comparePassword =async (
		password:string,
		hashedPassword:string
		):Promise<boolean> => {
			return await bcrypt.compare(password, hashedPassword);
};

이 bcrypt의 compare 함수는

  1. 사용자가 입력한 평문 비밀번호를 bcrypt가 동일한 방식으로 처리한 뒤
  2. DB에 저장된 해시 값과 일치하는지만 판단한다

강조하지만 서버는 비밀번호의 정답 여부만 알 뿐, 비밀번호 자체를 다시 알게 되지는 않습니다.


3. 여기까지 통과되면, 인증은 완료된다

이메일 존재 여부 + 계정 활성화 여부 + 비밀번호 비교 통과

이 세 조건이 모두 만족되면, 서버는 이 요청에 대해 다음과 같이 판단합니다.

“이 요청은 올바른 Admin이다”

이 시점까지가 로그인 이전 단계, 즉 인증(Authentication)입니다.

아직 JWT는 만들어지지 않았고, 아직 토큰은 클라이언트로 전달되지 않습니다.

로그인 이후 : 성공하면 토큰을 생성하고, 검증은 누가?

“서버가 토큰을 만든다” 를 담당하는 파일은 Jwt.ts에 한 곳에다가 분리하였습니다.

그 전에 JWT는 무엇인가를 알아야겠습니다.

직관적으로 설명하면 JWT는 서버가 클라이언트에게 제공하는 신분증입니다.

주민등록증과 같이 JWT라는 신분증에도 여러 정보가 들어가는데요, 여기에는 누가 로그인 했는지(id), 어떤 권한을 가지고 있는 신분인지 (role), 언제까지 유효한지(exp)등과 같은 정보들이 있습니다.

자 아래와 같은 payload JSON가 있다고 가정합니다.

{
"id": "wonbin",
"role": "ADMIN",
"iat": 1700000000,
"exp": 1700003600
}

JWT는 위 JSON 형태를

eyJpZCI6IndvbmJpbiIsInJvbGUiOiJBRE1JTiJ9

전송 가능한 문자열 형태(Base64URL)로 인코딩합니다.

그러나 문제가 있습니다. 위 복잡하게 생긴 저 긴 문자열은 암호화가 되지 않은 문자열입니다.

좀 더 어렵게 말하면 누구나 컴퓨터만 있다면 디코딩으로 순수 payload JSON을 얻는 것이죠.

따라서 위조가 된 로그인 정보인지 아닌지 그 누구도 모르기에 위조 방지용 서명을 붙입니다.

signature = HMAC(header.payload, SECRET)

여기서 중요한 포인트는 JWT는 암호목적의 수단이 아닌 위조방지와 무결성 보장의 의미가 있는 것입니다.

암호화 역할은 JWT가 책임지지 않습니다. 따라서 비밀번호는 payload에 넣으면 안되겠죠.

저는 일단 id, email, role만 넣어놨습니다.

export interface TokenPayload {
  adminId: string;
  email: string;
  role: string;
}

JWT는 암호화된 문자열이 아니라, 누구나 내용을 확인할 수 있지만 서버만이 생성할 수 있는 ‘위조 방지된 신분증’이라고 볼 수 있습니다.

서버는 요청이 들어올 때마다 이 서명을 검증함으로써, 해당 요청이 신뢰 가능한 사용자인지를 판단합니다.

추가적으로, 단일 책임 원칙을 준수한 jwt 설계

저는 “토큰 생성/검증” 로직을 jwt.ts라는 파일 하나에 몰아넣었습니다.

이건 단순히 파일을 깔끔하게 보이게 하려는 목적이 아닙니다.

JWT 관련 로직은 구현이 조금만 분산되어도, 실무에서 유지보수 난이도가 급격히 올라갑니다.

어떤 파일은 JWT_SECRET을 사용하고, 어떤 파일은 JWT_REFRESH_SECRET을 사용하며,

어디에서는 expiresIn이 15분인데 다른 곳에서는 30분으로 바뀌어 있고,

심지어 verify 단계에서 던지는 예외 처리 방식까지 제각각이면 문제 하나를 추적하는 데 드는 비용이 급격히 커집니다.

이런 상태에서는 디버깅이 시작부터 어려워집니다.

그래서 저는 JWT를 단순한 유틸이 아니라, 인증 시스템의 하부 규격이라고 보고 접근했습니다.

토큰을 어떻게 발급하고, 어떤 기준으로 검증할 것인지는 애플리케이션 전반에서 반드시 동일해야 하는 규칙이기 때문입니다.

그 결과, 토큰을 생성하는 규칙과 검증하는 규칙을 jwt.ts라는 파일 한 곳에서만 관리하도록 구성했습니다.

즉, jwt.ts는 다음 책임만을 가집니다.

  • Access Token을 어떤 규칙으로 생성할 것인지
  • Refresh Token을 어떤 규칙으로 생성할 것인지
  • Access Token을 어떤 기준으로 검증할 것인지
  • Refresh Token을 어떤 기준으로 검증할 것인지
export const generateAccessToken = (payload: TokenPayload): string => {
  return jwt.sign(payload, JWT_SECRET, {
    expiresIn: '15m',
  });
};

export const generateRefreshToken = (payload: TokenPayload): string => {
  return jwt.sign(payload, JWT_REFRESH_SECRET, {
    expiresIn: '7d',
  });
};

export const verifyAccessToken = (token: string): TokenPayload => {
  return jwt.verify(token, JWT_SECRET) as TokenPayload;
};

export const verifyRefreshToken = (token: string): TokenPayload => {
  return jwt.verify(token, JWT_REFRESH_SECRET) as TokenPayload;
};

여기서 generate와 verify가 한 파일에 같이 있는 게 중요한 이유는 단순합니다.

발급 규칙과 검증 규칙이 한 곳에서 관리되어야, 서버 전체가 동일한 기준으로 토큰을 신뢰할 수 있기 때문입니다.

jwt.sign → “이 토큰은 서버가 책임지고 발급했다”는 증거를 붙인다

jwt.verify → “그 증거가 진짜 서버의 보증이 맞는지 확인한다”

결론 : JWT를 다루는 태도

정리해보면, 로그인 시스템에서 JWT는 “로그인을 대신 해주는 마법 같은 토큰”이 아닙니다.

로그인 시점의 인증(Authentication)은 이메일과 비밀번호 검증으로 이미 끝나 있고,

JWT는 그 결과를 이후 요청에서도 증명하기 위한 수단일 뿐입니다.

그래서 jwt.sign은 “이 토큰은 서버가 책임지고 발급했다”는 보증을 붙이는 과정이고,

jwt.verify는 “그 보증이 여전히 유효한지, 그리고 위조되지 않았는지”를 요청마다 다시 확인하는 과정입니다.

이 구조에서 중요한 점은, 서버가 토큰을 기억하지 않아도 된다는 것입니다.서버는 DB를 다시 조회하지 않고도,

서명 검증만으로 이 요청을 신뢰할 수 있습니다.

이 신뢰의 기준이 흔들리지 않도록, 토큰 생성과 검증 규칙의 책임을 단일화 하는 것도

인증 시스템의 규약으로 다룰 때 도움이 될 것이라고 생각합니다.

결국 로그인 시스템에서 중요한 건 “토큰을 썼다”는 사실이 아니라 ,

  • 무엇을 인증으로 보고
  • 어디서 신뢰를 만들고
  • 어떤 기준으로 요청을 통과시킬 것인가

를 코드로 명확하게 나누는 일이라고 생각합니다.

JWT는 그중 한 조각일 뿐이고,그 조각을 어디에, 어떤 책임으로 두느냐가 로그인 시스템의 안정성을 결정합니다.