본문으로 건너뛰기
Kreath Archive
TechProjectsBooksAbout
TechProjectsBooksAbout
TechProjectsBooksAbout
© 2026 Kreath. All rights reserved.
홈TechProjectsBooksAbout
//
  1. 홈
  2. 테크
  3. 10장: 실전 프로젝트 - 현대적 인증 시스템 구축
2026년 7월 6일·아키텍처·

10장: 실전 프로젝트 - 현대적 인증 시스템 구축

Keycloak 기반 인증, 패스키 등록, OAuth 2.1 + PKCE 플로우, OpenFGA를 활용한 ReBAC, 토큰 관리, API 인증, 모니터링과 감사까지 통합하는 실전 프로젝트를 구축합니다.

21분2,249자1개 섹션
securityprotocoldesign-patternsinfrastructureperformance
공유
modern-auth10 / 10
12345678910
이전9장: 규제 대응과 컴플라이언스

실전 프로젝트 - 현대적 인증 시스템 구축

이 시리즈의 마지막 장에서는 지금까지 다룬 모든 개념을 통합하여 하나의 완결된 인증 시스템을 구축합니다. Keycloak을 IdP로, OAuth 2.1 + PKCE를 인증 플로우로, 패스키를 주요 인증 수단으로, OpenFGA를 권한 엔진으로 사용하는 프로젝트입니다.

프로젝트 아키텍처

인프라 구성: Docker Compose

전체 시스템을 로컬에서 실행할 수 있는 Docker Compose 구성입니다.

docker-compose.yml
yaml
services:
  # --- 인증 인프라 ---
  keycloak:
    image: quay.io/keycloak/keycloak:25.0
    command: start-dev --import-realm
    environment:
      KC_DB: postgres
      KC_DB_URL: jdbc:postgresql://keycloak-db:5432/keycloak
      KC_DB_USERNAME: keycloak
      KC_DB_PASSWORD: ${KC_DB_PASSWORD}
      KC_HOSTNAME: localhost
      KC_HTTP_PORT: 8080
      KC_FEATURES: passkeys
      KEYCLOAK_ADMIN: admin
      KEYCLOAK_ADMIN_PASSWORD: ${KC_ADMIN_PASSWORD}
    ports:
      - "8080:8080"
    volumes:
      - ./keycloak/realm-export.json:/opt/keycloak/data/import/realm.json
    depends_on:
      keycloak-db:
        condition: service_healthy
 
  keycloak-db:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: keycloak
      POSTGRES_USER: keycloak
      POSTGRES_PASSWORD: ${KC_DB_PASSWORD}
    volumes:
      - keycloak-pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U keycloak"]
      interval: 5s
      timeout: 5s
      retries: 5
 
  # --- 인가 인프라 ---
  openfga:
    image: openfga/openfga:latest
    command: run
    environment:
      OPENFGA_DATASTORE_ENGINE: postgres
      OPENFGA_DATASTORE_URI: postgres://openfga:${FGA_DB_PASSWORD}@openfga-db:5432/openfga?sslmode=disable
    ports:
      - "8081:8080"   # HTTP API
      - "8082:8081"   # gRPC
      - "3000:3000"   # Playground
    depends_on:
      openfga-db:
        condition: service_healthy
 
  openfga-db:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: openfga
      POSTGRES_USER: openfga
      POSTGRES_PASSWORD: ${FGA_DB_PASSWORD}
    volumes:
      - openfga-pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U openfga"]
      interval: 5s
      timeout: 5s
      retries: 5
 
  # --- 관측성 ---
  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
 
volumes:
  keycloak-pgdata:
  openfga-pgdata:
Warning

이 Docker Compose 구성은 개발 및 학습용입니다. 프로덕션 환경에서는 Keycloak의 start 모드 사용, TLS 설정, 별도의 관리형 데이터베이스, 클러스터링, 시크릿 관리(Vault 등)가 필수입니다.

1단계: Keycloak 렐름 설정

keycloak/realm-export.json
json
{
  "realm": "modern-auth-demo",
  "enabled": true,
  "sslRequired": "external",
  "registrationAllowed": false,
  "loginWithEmailAllowed": true,
  "bruteForceProtected": true,
  "failureFactor": 5,
  "maxFailureWaitSeconds": 900,
  "passwordPolicy": "length(8) and notUsername and passwordHistory(4)",
  "accessTokenLifespan": 300,
  "ssoSessionIdleTimeout": 1800,
  "ssoSessionMaxLifespan": 36000,
  "webAuthnPolicyRpEntityName": "Modern Auth Demo",
  "webAuthnPolicyRpId": "localhost",
  "webAuthnPolicyUserVerificationRequirement": "required",
  "webAuthnPolicyAttestationConveyancePreference": "none",
  "webAuthnPolicyAuthenticatorAttachment": "platform",
  "webAuthnPolicyResidentKeyRequirement": "required",
  "clients": [
    {
      "clientId": "bff-client",
      "name": "BFF Client",
      "protocol": "openid-connect",
      "publicClient": false,
      "secret": "CHANGE_ME_IN_PRODUCTION",
      "standardFlowEnabled": true,
      "directAccessGrantsEnabled": false,
      "serviceAccountsEnabled": false,
      "redirectUris": ["http://localhost:3001/auth/callback"],
      "webOrigins": ["http://localhost:3001"],
      "attributes": {
        "pkce.code.challenge.method": "S256",
        "post.logout.redirect.uris": "http://localhost:3001"
      },
      "defaultClientScopes": [
        "openid",
        "profile",
        "email"
      ]
    },
    {
      "clientId": "api-gateway",
      "name": "API Gateway (Service Account)",
      "protocol": "openid-connect",
      "publicClient": false,
      "secret": "CHANGE_ME_IN_PRODUCTION",
      "serviceAccountsEnabled": true,
      "standardFlowEnabled": false,
      "directAccessGrantsEnabled": false
    }
  ],
  "roles": {
    "realm": [
      { "name": "user", "description": "일반 사용자" },
      { "name": "editor", "description": "편집자" },
      { "name": "admin", "description": "관리자" }
    ]
  }
}

2단계: 패스키 등록과 인증

Keycloak은 v23부터 WebAuthn/패스키를 네이티브로 지원합니다. 하지만 커스텀 등록 플로우가 필요한 경우, 별도의 패스키 서비스를 구현할 수 있습니다.

src/services/passkey-service.ts
typescript
import {
  generateRegistrationOptions,
  verifyRegistrationResponse,
  generateAuthenticationOptions,
  verifyAuthenticationResponse,
} from "@simplewebauthn/server";
import type {
  RegistrationResponseJSON,
  AuthenticationResponseJSON,
} from "@simplewebauthn/types";
import { Redis } from "ioredis";
 
const redis = new Redis(process.env.REDIS_URL);
 
const RP_NAME = "Modern Auth Demo";
const RP_ID = process.env.RP_ID ?? "localhost";
const ORIGIN = process.env.ORIGIN ?? "http://localhost:3001";
 
export class PasskeyService {
  // 등록 옵션 생성
  async createRegistrationOptions(userId: string, userEmail: string) {
    const existingCredentials = await this.getCredentials(userId);
 
    const options = await generateRegistrationOptions({
      rpName: RP_NAME,
      rpID: RP_ID,
      userName: userEmail,
      excludeCredentials: existingCredentials.map((c) => ({
        id: c.credentialId,
        type: "public-key",
        transports: c.transports,
      })),
      authenticatorSelection: {
        authenticatorAttachment: "platform",
        userVerification: "required",
        residentKey: "required",
      },
      attestationType: "none",
    });
 
    // 챌린지를 Redis에 저장 (5분 TTL)
    await redis.setex(
      `passkey:challenge:${userId}`,
      300,
      options.challenge,
    );
 
    return options;
  }
 
  // 등록 검증
  async verifyRegistration(
    userId: string,
    response: RegistrationResponseJSON,
  ): Promise<boolean> {
    const expectedChallenge = await redis.get(
      `passkey:challenge:${userId}`,
    );
 
    if (!expectedChallenge) {
      throw new Error("챌린지가 만료되었습니다.");
    }
 
    const verification = await verifyRegistrationResponse({
      response,
      expectedChallenge,
      expectedOrigin: ORIGIN,
      expectedRPID: RP_ID,
    });
 
    if (verification.verified && verification.registrationInfo) {
      const { credential } = verification.registrationInfo;
      await this.saveCredential(userId, {
        credentialId: credential.id,
        publicKey: Buffer.from(credential.publicKey).toString("base64"),
        counter: credential.counter,
        transports: response.response.transports ?? [],
        createdAt: new Date(),
      });
 
      // 챌린지 삭제
      await redis.del(`passkey:challenge:${userId}`);
    }
 
    return verification.verified;
  }
 
  // 인증 옵션 생성
  async createAuthenticationOptions() {
    const options = await generateAuthenticationOptions({
      rpID: RP_ID,
      userVerification: "required",
    });
 
    // 챌린지를 Redis에 저장
    await redis.setex(
      `passkey:auth-challenge:${options.challenge}`,
      300,
      "pending",
    );
 
    return options;
  }
 
  // 인증 검증
  async verifyAuthentication(
    response: AuthenticationResponseJSON,
    expectedChallenge: string,
  ): Promise<{ verified: boolean; userId: string | null }> {
    const credential = await this.findCredentialById(response.id);
    if (!credential) {
      return { verified: false, userId: null };
    }
 
    const verification = await verifyAuthenticationResponse({
      response,
      expectedChallenge,
      expectedOrigin: ORIGIN,
      expectedRPID: RP_ID,
      credential: {
        id: credential.credentialId,
        publicKey: Buffer.from(credential.publicKey, "base64"),
        counter: credential.counter,
      },
    });
 
    if (verification.verified) {
      await this.updateCounter(
        credential.credentialId,
        verification.authenticationInfo.newCounter,
      );
      await redis.del(`passkey:auth-challenge:${expectedChallenge}`);
    }
 
    return {
      verified: verification.verified,
      userId: credential.userId,
    };
  }
 
  private async getCredentials(userId: string) {
    // DB에서 사용자의 자격 증명 목록 조회
    return getCredentialsByUserId(userId);
  }
 
  private async saveCredential(userId: string, credential: CredentialData) {
    return saveCredentialToDb(userId, credential);
  }
 
  private async findCredentialById(credentialId: string) {
    return findCredentialInDb(credentialId);
  }
 
  private async updateCounter(credentialId: string, newCounter: number) {
    return updateCredentialCounter(credentialId, newCounter);
  }
}

3단계: BFF 서버 구현

src/bff/server.ts
typescript
import express from "express";
import session from "express-session";
import RedisStore from "connect-redis";
import { Redis } from "ioredis";
 
const app = express();
const redis = new Redis(process.env.REDIS_URL);
 
// 세션 설정
app.use(session({
  store: new RedisStore({ client: redis }),
  secret: process.env.SESSION_SECRET ?? "change-me",
  resave: false,
  saveUninitialized: false,
  cookie: {
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
    sameSite: "lax",
    maxAge: 24 * 60 * 60 * 1000, // 24시간
  },
}));
 
app.use(express.json());
 
// --- OAuth 2.1 + PKCE 플로우 ---
 
// 로그인 시작
app.get("/auth/login", (req, res) => {
  const codeVerifier = generateCodeVerifier();
  const codeChallenge = generateCodeChallenge(codeVerifier);
  const state = generateRandomString(32);
 
  req.session.oauthState = state;
  req.session.codeVerifier = codeVerifier;
 
  const params = new URLSearchParams({
    response_type: "code",
    client_id: process.env.KC_CLIENT_ID ?? "",
    redirect_uri: `${process.env.BFF_URL}/auth/callback`,
    scope: "openid profile email",
    state,
    code_challenge: codeChallenge,
    code_challenge_method: "S256",
  });
 
  const keycloakAuthUrl = `${process.env.KC_URL}/realms/modern-auth-demo/protocol/openid-connect/auth`;
  res.redirect(`${keycloakAuthUrl}?${params.toString()}`);
});
 
// 콜백 처리
app.get("/auth/callback", async (req, res) => {
  const { code, state, error } = req.query;
 
  if (error) {
    return res.redirect(`/?error=${error}`);
  }
 
  // State 검증
  if (state !== req.session.oauthState) {
    return res.status(403).json({ error: "State mismatch" });
  }
 
  try {
    // 토큰 교환
    const tokenResponse = await fetch(
      `${process.env.KC_URL}/realms/modern-auth-demo/protocol/openid-connect/token`,
      {
        method: "POST",
        headers: { "Content-Type": "application/x-www-form-urlencoded" },
        body: new URLSearchParams({
          grant_type: "authorization_code",
          client_id: process.env.KC_CLIENT_ID ?? "",
          client_secret: process.env.KC_CLIENT_SECRET ?? "",
          code: code as string,
          redirect_uri: `${process.env.BFF_URL}/auth/callback`,
          code_verifier: req.session.codeVerifier ?? "",
        }),
      },
    );
 
    if (!tokenResponse.ok) {
      throw new Error("토큰 교환 실패");
    }
 
    const tokens = await tokenResponse.json();
 
    // 토큰을 서버 세션에 저장
    req.session.accessToken = tokens.access_token;
    req.session.refreshToken = tokens.refresh_token;
    req.session.idToken = tokens.id_token;
    req.session.tokenExpiresAt =
      Date.now() + tokens.expires_in * 1000;
 
    // PKCE 임시 데이터 정리
    delete req.session.oauthState;
    delete req.session.codeVerifier;
 
    res.redirect("/");
  } catch (error) {
    console.error("인증 콜백 오류:", error);
    res.redirect("/?error=auth_failed");
  }
});
 
// 세션 정보 조회 (프론트엔드용)
app.get("/auth/session", (req, res) => {
  if (!req.session.accessToken) {
    return res.json({ authenticated: false });
  }
 
  // ID Token에서 사용자 정보 추출 (토큰 자체는 전달하지 않음)
  const idPayload = decodeJwtPayload(req.session.idToken ?? "");
  res.json({
    authenticated: true,
    user: {
      id: idPayload.sub,
      name: idPayload.name,
      email: idPayload.email,
    },
  });
});
 
// 로그아웃
app.post("/auth/logout", async (req, res) => {
  const idToken = req.session.idToken;
 
  // 세션 삭제
  req.session.destroy(() => {
    // Keycloak 로그아웃
    if (idToken) {
      const logoutUrl = new URL(
        `${process.env.KC_URL}/realms/modern-auth-demo/protocol/openid-connect/logout`,
      );
      logoutUrl.searchParams.set("id_token_hint", idToken);
      logoutUrl.searchParams.set(
        "post_logout_redirect_uri",
        process.env.BFF_URL ?? "",
      );
      res.json({ logoutUrl: logoutUrl.toString() });
    } else {
      res.json({ logoutUrl: "/" });
    }
  });
});
 
// --- API 프록시 (토큰 자동 갱신 포함) ---
 
app.use("/api", async (req, res, next) => {
  if (!req.session.accessToken) {
    return res.status(401).json({ error: "인증이 필요합니다." });
  }
 
  // 토큰 만료 확인 및 자동 갱신
  if (Date.now() >= (req.session.tokenExpiresAt ?? 0) - 30000) {
    try {
      const refreshResponse = await fetch(
        `${process.env.KC_URL}/realms/modern-auth-demo/protocol/openid-connect/token`,
        {
          method: "POST",
          headers: {
            "Content-Type": "application/x-www-form-urlencoded",
          },
          body: new URLSearchParams({
            grant_type: "refresh_token",
            client_id: process.env.KC_CLIENT_ID ?? "",
            client_secret: process.env.KC_CLIENT_SECRET ?? "",
            refresh_token: req.session.refreshToken ?? "",
          }),
        },
      );
 
      if (refreshResponse.ok) {
        const newTokens = await refreshResponse.json();
        req.session.accessToken = newTokens.access_token;
        req.session.refreshToken = newTokens.refresh_token;
        req.session.tokenExpiresAt =
          Date.now() + newTokens.expires_in * 1000;
      } else {
        // Refresh 실패 시 재인증 필요
        req.session.destroy(() => {});
        return res.status(401).json({
          error: "세션이 만료되었습니다. 재로그인이 필요합니다.",
        });
      }
    } catch {
      return res.status(500).json({ error: "토큰 갱신 실패" });
    }
  }
 
  // 백엔드로 프록시
  next();
});
 
app.listen(3001, () => {
  console.log("BFF 서버가 포트 3001에서 실행 중입니다.");
});

4단계: OpenFGA 기반 ReBAC 구현

src/services/authorization-service.ts
typescript
import { OpenFgaClient, CredentialsMethod } from "@openfga/sdk";
 
// OpenFGA 인가 모델
const AUTHORIZATION_MODEL = `
model
  schema 1.1
 
type user
 
type organization
  relations
    define admin: [user]
    define member: [user] or admin
 
type team
  relations
    define org: [organization]
    define lead: [user]
    define member: [user] or lead
 
type folder
  relations
    define org: [organization]
    define owner: [user]
    define editor: [user, team#member]
    define viewer: [user, team#member, organization#member]
    define can_edit: owner or editor
    define can_view: can_edit or viewer
 
type document
  relations
    define parent_folder: [folder]
    define owner: [user]
    define editor: [user, team#member]
    define viewer: [user, team#member]
    define can_edit: owner or editor
    define can_view: can_edit or viewer or can_view from parent_folder
    define can_delete: owner
    define can_share: owner or editor
`;
 
export class AuthorizationService {
  private client: OpenFgaClient;
 
  constructor() {
    this.client = new OpenFgaClient({
      apiUrl: process.env.FGA_API_URL ?? "http://localhost:8081",
      storeId: process.env.FGA_STORE_ID ?? "",
      authorizationModelId: process.env.FGA_MODEL_ID ?? "",
    });
  }
 
  // 권한 확인
  async check(
    userId: string,
    relation: string,
    objectType: string,
    objectId: string,
  ): Promise<boolean> {
    const result = await this.client.check({
      user: `user:${userId}`,
      relation,
      object: `${objectType}:${objectId}`,
    });
    return result.allowed ?? false;
  }
 
  // 문서 접근 권한 확인
  async canViewDocument(
    userId: string,
    documentId: string,
  ): Promise<boolean> {
    return this.check(userId, "can_view", "document", documentId);
  }
 
  async canEditDocument(
    userId: string,
    documentId: string,
  ): Promise<boolean> {
    return this.check(userId, "can_edit", "document", documentId);
  }
 
  async canDeleteDocument(
    userId: string,
    documentId: string,
  ): Promise<boolean> {
    return this.check(userId, "can_delete", "document", documentId);
  }
 
  // 관계 추가 (권한 부여)
  async grantAccess(
    subjectType: string,
    subjectId: string,
    relation: string,
    objectType: string,
    objectId: string,
  ): Promise<void> {
    await this.client.write({
      writes: [
        {
          user: `${subjectType}:${subjectId}`,
          relation,
          object: `${objectType}:${objectId}`,
        },
      ],
    });
  }
 
  // 관계 제거 (권한 회수)
  async revokeAccess(
    subjectType: string,
    subjectId: string,
    relation: string,
    objectType: string,
    objectId: string,
  ): Promise<void> {
    await this.client.write({
      deletes: [
        {
          user: `${subjectType}:${subjectId}`,
          relation,
          object: `${objectType}:${objectId}`,
        },
      ],
    });
  }
 
  // 사용자가 접근 가능한 문서 목록
  async listAccessibleDocuments(userId: string): Promise<string[]> {
    const result = await this.client.listObjects({
      user: `user:${userId}`,
      relation: "can_view",
      type: "document",
    });
    return result.objects;
  }
 
  // 문서 공유 (다른 사용자에게 권한 부여)
  async shareDocument(
    sharerId: string,
    documentId: string,
    targetUserId: string,
    permission: "viewer" | "editor",
  ): Promise<void> {
    // 공유 권한 확인
    const canShare = await this.check(
      sharerId,
      "can_share",
      "document",
      documentId,
    );
 
    if (!canShare) {
      throw new ForbiddenError("문서를 공유할 권한이 없습니다.");
    }
 
    await this.grantAccess(
      "user",
      targetUserId,
      permission,
      "document",
      documentId,
    );
 
    // 감사 로그
    await logAuditEvent({
      action: "document_shared",
      actor: sharerId,
      target: targetUserId,
      resource: `document:${documentId}`,
      permission,
      timestamp: new Date(),
    });
  }
}

5단계: API 인증 미들웨어

src/middleware/auth-middleware.ts
typescript
import type { Request, Response, NextFunction } from "express";
import { createRemoteJWKSet, jwtVerify } from "jose";
import { AuthorizationService } from "../services/authorization-service";
 
const jwks = createRemoteJWKSet(
  new URL(
    `${process.env.KC_URL}/realms/modern-auth-demo/protocol/openid-connect/certs`,
  ),
);
 
const authzService = new AuthorizationService();
 
// 인증 미들웨어
export function authenticate() {
  return async (req: Request, res: Response, next: NextFunction) => {
    const authHeader = req.headers.authorization;
 
    if (!authHeader?.startsWith("Bearer ")) {
      return res.status(401).json({ error: "Bearer 토큰이 필요합니다." });
    }
 
    try {
      const token = authHeader.substring(7);
      const { payload } = await jwtVerify(token, jwks, {
        issuer: `${process.env.KC_URL}/realms/modern-auth-demo`,
        algorithms: ["RS256"],
      });
 
      req.user = {
        id: payload.sub as string,
        email: payload.email as string,
        roles: (payload.realm_access as { roles: string[] })?.roles ?? [],
      };
 
      next();
    } catch (error) {
      return res.status(401).json({ error: "유효하지 않은 토큰입니다." });
    }
  };
}
 
// 인가 미들웨어 (ReBAC)
export function authorize(relation: string, getObject: (req: Request) => string) {
  return async (req: Request, res: Response, next: NextFunction) => {
    if (!req.user) {
      return res.status(401).json({ error: "인증이 필요합니다." });
    }
 
    const object = getObject(req);
    const allowed = await authzService.check(
      req.user.id,
      relation,
      object.split(":")[0],
      object.split(":")[1],
    );
 
    if (!allowed) {
      // 감사 로그: 접근 거부
      await logAuditEvent({
        action: "access_denied",
        actor: req.user.id,
        resource: object,
        relation,
        timestamp: new Date(),
      });
 
      return res.status(403).json({ error: "접근 권한이 없습니다." });
    }
 
    next();
  };
}
 
// 라우트에 적용
// app.get(
//   "/api/documents/:id",
//   authenticate(),
//   authorize("can_view", (req) => `document:${req.params.id}`),
//   documentController.getDocument,
// );
//
// app.put(
//   "/api/documents/:id",
//   authenticate(),
//   authorize("can_edit", (req) => `document:${req.params.id}`),
//   documentController.updateDocument,
// );
//
// app.delete(
//   "/api/documents/:id",
//   authenticate(),
//   authorize("can_delete", (req) => `document:${req.params.id}`),
//   documentController.deleteDocument,
// );

6단계: 모니터링과 감사

src/services/audit-service.ts
typescript
import { Redis } from "ioredis";
 
const redis = new Redis(process.env.REDIS_URL);
 
interface AuditEvent {
  // 이벤트 식별
  eventId: string;
  eventType: AuditEventType;
  timestamp: Date;
 
  // 행위자
  actorId: string;
  actorType: "user" | "service" | "system";
  actorIp?: string;
  actorUserAgent?: string;
 
  // 대상
  resourceType: string;
  resourceId: string;
 
  // 상세
  action: string;
  outcome: "success" | "failure";
  reason?: string;
  metadata?: Record<string, unknown>;
}
 
type AuditEventType =
  | "authentication"
  | "authorization"
  | "user_management"
  | "token_management"
  | "admin_action"
  | "data_access";
 
export class AuditService {
  async log(event: Omit<AuditEvent, "eventId" | "timestamp">): Promise<void> {
    const fullEvent: AuditEvent = {
      ...event,
      eventId: crypto.randomUUID(),
      timestamp: new Date(),
    };
 
    // 1. 영구 저장소에 기록
    await this.persistEvent(fullEvent);
 
    // 2. 실시간 스트림에 발행 (모니터링 대시보드용)
    await redis.xadd(
      "audit:stream",
      "*",
      "event",
      JSON.stringify(fullEvent),
    );
 
    // 3. 보안 알림 트리거 확인
    await this.checkAlertConditions(fullEvent);
  }
 
  private async checkAlertConditions(event: AuditEvent): Promise<void> {
    // 연속 인증 실패 감지
    if (
      event.eventType === "authentication" &&
      event.outcome === "failure"
    ) {
      const failureCount = await this.getRecentFailures(
        event.actorId,
        5 * 60 * 1000, // 5분 이내
      );
 
      if (failureCount >= 5) {
        await this.triggerAlert({
          severity: "high",
          type: "brute_force_suspected",
          message: `사용자 ${event.actorId}의 5분 내 ${failureCount}회 인증 실패 감지`,
          event,
        });
      }
    }
 
    // 비정상 접근 패턴 감지
    if (
      event.eventType === "authorization" &&
      event.outcome === "failure"
    ) {
      const deniedCount = await this.getRecentDenials(
        event.actorId,
        10 * 60 * 1000,
      );
 
      if (deniedCount >= 10) {
        await this.triggerAlert({
          severity: "medium",
          type: "privilege_escalation_suspected",
          message: `사용자 ${event.actorId}의 반복적 권한 거부 감지`,
          event,
        });
      }
    }
 
    // 관리자 작업 알림
    if (event.eventType === "admin_action") {
      await this.triggerAlert({
        severity: "info",
        type: "admin_action_performed",
        message: `관리자 ${event.actorId}이(가) ${event.action} 작업 수행`,
        event,
      });
    }
  }
 
  private async getRecentFailures(
    actorId: string,
    windowMs: number,
  ): Promise<number> {
    const key = `audit:failures:${actorId}`;
    const now = Date.now();
    // 윈도우 밖의 데이터 정리
    await redis.zremrangebyscore(key, 0, now - windowMs);
    return redis.zcard(key);
  }
 
  private async getRecentDenials(
    actorId: string,
    windowMs: number,
  ): Promise<number> {
    const key = `audit:denials:${actorId}`;
    const now = Date.now();
    await redis.zremrangebyscore(key, 0, now - windowMs);
    return redis.zcard(key);
  }
 
  private async triggerAlert(alert: SecurityAlert): Promise<void> {
    // 실제 구현: Slack, PagerDuty, 이메일 등으로 알림 전송
    console.warn(`[SECURITY ALERT] ${alert.severity}: ${alert.message}`);
    await redis.xadd(
      "alerts:stream",
      "*",
      "alert",
      JSON.stringify(alert),
    );
  }
 
  private async persistEvent(event: AuditEvent): Promise<void> {
    // 실제 구현: PostgreSQL, Elasticsearch 등에 저장
    // 최소 1년 보존 (PCI-DSS 요구)
    await saveToAuditStore(event);
  }
}
 
interface SecurityAlert {
  severity: "info" | "low" | "medium" | "high" | "critical";
  type: string;
  message: string;
  event: AuditEvent;
}
Tip

감사 로그는 "나중에 추가하자"가 통하지 않는 영역입니다. 시스템 설계 초기부터 모든 인증/인가 이벤트를 기록하는 구조를 만들어야 합니다. 로그가 없으면 보안 사고 발생 시 원인 분석이 불가능하고, 규제 감사에서 즉시 부적합 판정을 받습니다.

전체 요청 흐름 요약

시리즈를 마치며

10개 장에 걸쳐 현대 인증과 권한 관리의 전체 지형을 살펴보았습니다. 1장의 역사적 맥락에서 시작하여, OAuth 2.1과 OIDC의 프로토콜, 패스키의 비밀번호 없는 미래, 아이덴티티 플랫폼의 선택, ReBAC의 관계 기반 권한 모델, 토큰 관리 전략, 제로 트러스트 아키텍처, API 인증, 규제 대응까지 다루었습니다.

마지막 장에서 이 모든 것을 하나의 시스템으로 통합했습니다. 핵심 교훈을 정리합니다.

  • 인증은 단일 기술이 아닌 아키텍처입니다. IdP, 토큰 관리, 세션 전략, API 게이트웨이가 유기적으로 연결되어야 합니다.
  • 보안은 초기 설계에 녹아들어야 합니다. 감사 로깅, 토큰 폐기, 최소 권한 원칙은 나중에 추가하기 어렵습니다.
  • 표준을 따르면 복잡도가 줄어듭니다. OAuth 2.1, OIDC, WebAuthn, Zanzibar 등 검증된 표준과 오픈소스를 활용하면 자체 구현의 위험을 줄일 수 있습니다.
  • 사용자 경험과 보안은 트레이드오프가 아닙니다. 패스키는 비밀번호보다 안전하면서도 사용하기 쉽습니다. BFF 패턴은 보안을 강화하면서도 프론트엔드 개발을 단순하게 합니다.

인증과 권한 관리는 끊임없이 진화합니다. 이 시리즈가 현재 시점의 모범 사례를 이해하고, 미래의 변화에 적응할 수 있는 기반이 되기를 바랍니다.

이 글이 도움이 되셨나요?

관련 글

아키텍처

9장: 규제 대응과 컴플라이언스

GDPR, PCI-DSS, SOC 2가 인증 시스템에 부과하는 구체적 요구사항, NIST 800-63B 비밀번호 가이드라인, 감사 로깅 설계, MFA 요구사항, 데이터 거주 전략을 정리합니다.

2026년 7월 2일·21분
아키텍처

8장: API 인증과 서비스 간 통신 보안

API 키 관리, OAuth Client Credentials, mTLS를 활용한 서비스 간 인증, 마이크로서비스에서의 JWT 전파, API 게이트웨이 인증 패턴을 실전 코드와 함께 설명합니다.

2026년 6월 29일·18분
아키텍처

7장: 제로 트러스트 아키텍처와 인증

제로 트러스트의 핵심 원칙과 인증의 관계를 분석하고, 지속적 검증, 디바이스 신뢰, 컨텍스트 기반 접근 제어, BeyondCorp 모델, 아이덴티티 인식 프록시를 설명합니다.

2026년 6월 27일·17분
이전 글9장: 규제 대응과 컴플라이언스

댓글

목차

약 21분 남음
  • 실전 프로젝트 - 현대적 인증 시스템 구축
    • 프로젝트 아키텍처
    • 인프라 구성: Docker Compose
    • 1단계: Keycloak 렐름 설정
    • 2단계: 패스키 등록과 인증
    • 3단계: BFF 서버 구현
    • 4단계: OpenFGA 기반 ReBAC 구현
    • 5단계: API 인증 미들웨어
    • 6단계: 모니터링과 감사
    • 전체 요청 흐름 요약
    • 시리즈를 마치며