API 인증 방식을 구현할 때 많이 사용하는 JWT 에 Refresh Token을 붙여서 보다 안정적인 인증 시스템을 만들어보자.

JWT + Refresh Token 구조 요약

  • 로그인 성공 시 Access Token Refresh Token을 발급
  • Access Token은 만료 시간이 짧음 (ex. 30분)
  • Refresh Token은 길게 설정 (ex. 1일)
  • 클라이언트가 Access Token 만료로 401을 받으면, Refresh Token으로 새 Access Token을 요청
  • Refresh Token도 만료되었으면 다시 로그인

1. 로그인 - 토큰 발급

[HttpPost("signin")]
public async Task<IActionResult> SignIn(SignModel req)
{
    var user = await _db.Users.FirstOrDefaultAsync(u => u.UserId == req.UserID && u.Password == req.Password);
    
    //User 체크 로직 적용
    
    var claims = new List<Claim>
    {
        new Claim(ClaimTypes.NameIdentifier, user.UserId),
        new Claim(ClaimTypes.Sid, user.UserIdx.ToString()),
        new Claim(ClaimTypes.Name, user.UserName),
        // 필요한 claim 추가
    };

    var accessToken = new JwtSecurityTokenHandler().WriteToken(CreateToken(claims));
    var refreshToken = GenerateRefreshToken();

    int.TryParse(_configuration["JWT:RefreshTokenValidityInDays"], out int refreshTokenValidityInDays);
    var refreshExpiry = _db.GetKoreaDate().AddDays(refreshTokenValidityInDays);

    var tokenEntity = await _db.UserTokens.FirstOrDefaultAsync(t => t.UserIdx == user.UserIdx);
    if (tokenEntity == null)
    {
        tokenEntity = new UserToken
        {
            UserIdx = user.UserIdx,
            AccessToken = accessToken,
            RefreshToken = refreshToken,
            RefreshTokenExpiryTime = refreshExpiry
        };
        _db.UserTokens.Add(tokenEntity);
    }
    else
    {
        tokenEntity.AccessToken = accessToken;
        tokenEntity.RefreshToken = refreshToken;
        tokenEntity.RefreshTokenExpiryTime = refreshExpiry;
    }

    await _db.SaveChangesAsync();

    var token = new JwtSecurityTokenHandler().ReadJwtToken(accessToken);
    var expiration = DateTimeOffset.FromUnixTimeSeconds((long)token.Payload.Exp!).LocalDateTime;

    return Ok(new
    {
        Token = accessToken,
        RefreshToken = refreshToken,
        Expiration = expiration,
        Code = "0",
        Msg = "success"
    });
}

 

2. 토큰 재발급 (Refresh Token)

[HttpPost("refresh-token")]
public async Task<IActionResult> RefreshToken(TokenModel tokenModel)
{
    if (tokenModel == null)
        return BadRequest("잘못된 요청입니다.");

    var principal = GetPrincipalFromExpiredToken(tokenModel.AccessToken);
    if (principal == null)
        return BadRequest("잘못된 액세스 토큰입니다.");

    var userIdx = principal.FindFirst(ClaimTypes.Sid)?.Value;
    if (!int.TryParse(userIdx, out int uid))
        return BadRequest("사용자 정보를 찾을 수 없습니다.");

    var userToken = await _db.UserTokens.FirstOrDefaultAsync(t => t.UserIdx == uid);
    if (userToken == null || userToken.RefreshToken != tokenModel.RefreshToken)
        return BadRequest("잘못된 리프레시 토큰입니다.");

    if (userToken.RefreshTokenExpiryTime <= DateTime.Now)
        return BadRequest("리프레시 토큰이 만료되었습니다.");

    var newAccessToken = new JwtSecurityTokenHandler().WriteToken(CreateToken(principal.Claims.ToList()));
    var newRefreshToken = GenerateRefreshToken();

    int.TryParse(_configuration["JWT:RefreshTokenValidityInDays"], out int refreshTokenValidityInDays);
    userToken.AccessToken = newAccessToken;
    userToken.RefreshToken = newRefreshToken;
    userToken.RefreshTokenExpiryTime = _db.GetKoreaDate().AddDays(refreshTokenValidityInDays);

    await _db.SaveChangesAsync();

    return Ok(new
    {
        AccessToken = newAccessToken,
        RefreshToken = newRefreshToken
    });
}

 

3. 리프레시 토큰 초기화 (로그아웃 또는 강제 만료)

[Authorize]
[HttpPost("revoke/{id}")]
public async Task<IActionResult> Revoke(string id)
{
    var user = await _db.Users.FirstOrDefaultAsync(u => u.UserId == id);
    if (user == null)
        return BadRequest("잘못된 사용자 정보입니다.");

    var token = await _db.UserTokens.FirstOrDefaultAsync(t => t.UserIdx == user.UserIdx);
    if (token != null)
    {
        _db.UserTokens.Remove(token);
        await _db.SaveChangesAsync();
    }

    return NoContent();
}

 

4. 토큰 생성 및 유효성 검사 메서드

private JwtSecurityToken CreateToken(List<Claim> authClaims)
{
    var authSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_configuration["JWT:Secret"]));
    int.TryParse(_configuration["JWT:TokenValidityInMinutes"], out int tokenValidityInMinutes);

    return new JwtSecurityToken(
        issuer: _configuration["JWT:ValidIssuer"],
        audience: _configuration["JWT:ValidAudience"],
        expires: _db.GetKoreaDate().AddMinutes(tokenValidityInMinutes),
        claims: authClaims,
        signingCredentials: new SigningCredentials(authSigningKey, SecurityAlgorithms.HmacSha256)
    );
}

private static string GenerateRefreshToken()
{
    var randomNumber = new byte[64];
    using var rng = RandomNumberGenerator.Create();
    rng.GetBytes(randomNumber);
    return Convert.ToBase64String(randomNumber);
}

private ClaimsPrincipal? GetPrincipalFromExpiredToken(string? token)
{
    var tokenValidationParameters = new TokenValidationParameters
    {
        ValidateAudience = false,
        ValidateIssuer = false,
        ValidateIssuerSigningKey = true,
        IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_configuration["JWT:Secret"])),
        ValidateLifetime = false
    };

    var tokenHandler = new JwtSecurityTokenHandler();
    var principal = tokenHandler.ValidateToken(token, tokenValidationParameters, out SecurityToken securityToken);
    if (securityToken is JwtSecurityToken jwtToken &&
        jwtToken.Header.Alg.Equals(SecurityAlgorithms.HmacSha256, StringComparison.InvariantCultureIgnoreCase))
    {
        return principal;
    }

    throw new SecurityTokenException("잘못된 토큰입니다.");
}

 

5. appsettings.json 설정

"JWT": {
  "ValidAudience": "https://",
  "ValidIssuer": "https://",
  "Secret": "abcdef",
  "TokenValidityInMinutes": 30,
  "RefreshTokenValidityInDays": 1
}

 

해당 설정 코드는 .NET Core에서 JWT + Refresh Token을 활용한 인증 시스템 구현 방법으로, 외부에 노출되는 API 서비스나 앱 백엔드에서 많이 사용된다.
하지만 모든 시스템에 무조건 Refresh Token이 필요한 것은 아니다. 회사 내에서만 사용하는 내부 시스템이나 보안 부담이 적은 관리자용 시스템 등에서는 Access Token 하나로만 인증을 구성해도 충분한 경우가 많다.

또한 Middleware를 통해 만료된 토큰을 자동으로 감지하고 갱신 처리하는 기능을 구현하거나

토큰을 HttpOnly 쿠키로 저장하거나 IP/기기 정보 기반 토큰 제어 등 보안을 강화하는 방향으로 확장도 가능하다.

유효성 검사는 보통은 JavaScript를 통해 클라이언트에서 미리 체크하거나, 또는 보안상의 이유로 서버에서 다시 한 번 검사한다.

그런데 만약 사용자의 입력이 여러 필드에 걸쳐 동시에 잘못되었고, 
한꺼번에 모든 에러를 사용자에게 보여주는 구조가 필요하다면 IValidatableObject 를 통해 유효성 검사가 가능하다.

 

1. 모델 정의

using System.ComponentModel.DataAnnotations;

public class SaveModel : IValidatableObject
{
    public string ID { get; set; } = null!;   
    public string Name { get; set; } = null!;

    public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
    {
     	if (string.IsNullOrWhiteSpace(ID))
        {
             yield return new ValidationResult(
                 "ID 값이 필요합니다.",
                 new[] { nameof(ID) }
             );
        }
        if (string.IsNullOrWhiteSpace(Name))
        {
             yield return new ValidationResult(
                 "Name 값이 필요합니다.",
                 new[] { nameof(Name) }
             );
        }
    }
}

 

2. Razor Page 또는 Controller (POST)

public class IndexModel : PageModel
{
    public async Task<JsonResult> OnPostSave([FromForm] SaveModel data)
    {
    	//이 부분은 테스트 용
        if (!ModelState.IsValid)
        {
            var errors = ModelState
                .Where(kvp => kvp.Value.Errors.Count > 0)
                .SelectMany(kvp => kvp.Value.Errors.Select(e => new
                {
                    ErrorMessage = e.ErrorMessage,
                    MemberNames = new[] { kvp.Key }
                }));

            return new JsonResult(new
            {
                Success = false,
                Errors = errors
            });
        }

        return new JsonResult(new { Success = true });
    }
}

 

3. 유효성 처리 JavaScript

    <form id="saveForm">
        <label>ID: <input type="text" name="ID" id="ID" /></label><br />
        <label>Name: <input type="text" name="Name" id="Name" /></label><br />
        <button type="submit">저장</button>
    </form>

    <div id="errorArea" style="color:red; margin-top: 10px;"></div>
        const formElement = document.getElementById("saveForm");

        function markError(field) {
            field.classList.add("error-field");
        }

        function clearErrorMarks() {
            const errorFields = formElement.querySelectorAll(".error-field");
            errorFields.forEach(field => field.classList.remove("error-field"));
        }

        formElement.addEventListener("submit", async function (e) {
            e.preventDefault();
            clearErrorMarks();
            document.getElementById("errorArea").innerHTML = "";

            const formData = new FormData(this);

            const response = await fetch("/Index?handler=Save", {
                method: "POST",
                body: formData
            });

            const data = await response.json();

            if (data.Errors) {
                const errors = data.Errors;

                const errorMessages = `<div>${errors.map(error => `• ${error.ErrorMessage}`).join("<br />")}</div>`;
                document.getElementById("errorArea").innerHTML = errorMessages;

                errors.forEach(error => {
                    if (error.MemberNames && error.MemberNames.length > 0) {
                        error.MemberNames.forEach(fieldName => {
                            const field = formElement.querySelector(`[name="${fieldName}"]`);
                            if (field) {
                                markError(field);
                            }
                        });
                    }
                });
            } else if (!data.Success) {
                document.getElementById("errorArea").innerHTML = "알 수 없는 오류가 발생했습니다.";
            } else {
                console.log("저장 완료!");
                this.reset();
            }
        });

 

IValidatableObject를 이용한 유효성 검사는 자주 쓰이진 않지만 검증 로직이 여러 필드에 걸쳐 복잡하게 얽혀 있을 때
또는 여러 에러를 한 번에 수집해서 처리해야 할 때 유용하다.

보통은 각 필드에 어노테이션을 붙이거나 하나씩 수동으로 추가하는 방식이 많지만
이렇게 Validate() 안에서 조건별로 yield return만 추가해 주면
복잡한 유효성 체크도 훨씬 깔끔하게 정리할 수 있다.

Blazor 에 TailwindCSS를 적용하는 방법은 여러 가지가 있지만, 최근 TailwindCSS 4 버전으로 올라오면서 기존 방식이 잘 작동하지 않는 문제가 있다. 

 

기존 TailwindCSS v3 방식

Blazor 에 TailwindCSS 적용하기 (v3 기준)

TailwindCSS 4로 업그레이드한 후, 기존과 동일한 설정을 그대로 사용하면 문제가 생긴다.
기본 Tailwind 유틸리티 클래스는 CSS에 포함되지만, 프로젝트에서 사용하는 커스텀 클래스들은 빌드 결과물에 반영되지 않는다.

  • 일부 컴포넌트에 스타일이 아예 적용되지 않거나
  • 기본 스타일만 들어가고, 실제 작성한 유틸리티 클래스들이 누락
  • @apply 같은 지시어를 사용한 CSS가 무시됨

@tailwindcss/cli 패키지를 직접 사용할 수도 있는데 

npx @tailwindcss/cli -i wwwroot/css/tailwind.css -o wwwroot/assets/css/app.css

실행은 되지만 원하는 결과가 나오지 않는다.

 

해결 방법: tailwindcss 바이너리 직접 실행

TailwindCSS CLI 바이너리를 다운로드한 후, 
아래와 같이 직접 실행하는 방식으로 해결할 수 있다.

"scripts": {
    "build:css": "tailwindcss-windows-x64.exe  -i ./wwwroot/css/style.css -o ./wwwroot/css/app.css",
    "watch:css": "tailwindcss-windows-x64.exe  -i ./wwwroot/css/style.css -o ./wwwroot/css/app.css --watch"
  },

 

이 방식으로 실행하면 기본 스타일뿐 아니라 커스텀 유틸리티 클래스나 @apply 지시어로 작성한 CSS도 문제없이 반영된다.

 

추가 설정: Hot Reload & 정적 파일 감시 활성화

Properties/launchSettings.json 파일에서 각 프로필(http, https)에 아래 항목을 추가한다.

"hotReloadEnabled": true,
"watchStaticFiles": true

 

이제 프로젝트를 실행하면 TailwindCSS가 제대로 적용되고,
dotnet watch나 Visual Studio에서 F5로 실행해도 실시간으로 반영된다.

'blazor' 카테고리의 다른 글

Blazor 에 TailwindCSS 적용하기 (v3 기준)  (0) 2025.04.06

TailwindCSS를 Blazor 프로젝트에 적용하려면 기본적으로 몇 가지 설정이 필요하다.
아래는 TailwindCSS v3.x를 기준으로 적용했던 방식이다.

 

1. node와 npm 설치

TailwindCSS는 Node 기반이기 때문에 먼저 Node.js와 npm이 설치돼 있어야 한다.
Node.js 사이트에서 LTS 버전 설치.

 

2. Tailwind 및 관련 의존성 설치

npm init -y
npm install -D tailwindcss@3 postcss autoprefixer

 

3. tailwind.config.js 생성

npx tailwindcss init
module.exports = {
  content: [
    './**/*.razor',
    './**/*.html'
  ],
  theme: {
    extend: {},
  },
  plugins: [],
}

 

4. Tailwind 입력 파일 작성

wwwroot/css/tailwind.css 파일을 생성하고 아래 내용을 추가한다.

"scripts": {
  "build:css": "npx tailwindcss -i wwwroot/css/tailwind.css -o wwwroot/assets/css/app.css",
  "watch:css": "npx tailwindcss -i wwwroot/css/tailwind.css -o wwwroot/assets/css/app.css --watch"
}

 

6. _Host.cshtml 또는 index.html에 CSS 연결

Blazor Server일 경우 _Host.cshtml에,
Blazor WebAssembly일 경우 wwwroot/index.html에 아래 CSS 링크 추가한다.

<link href="assets/css/app.css" rel="stylesheet" />

 

7. 프로젝트 파일 수정(.csproj)

<Target Name="Tailwind" BeforeTargets="Build">
	<Exec Command="npm run build:css" />
</Target>

 

8. 실행 및 개발

터미널이나 Visual Studio의 패키지 관리자 콘솔(NPM 콘솔) 등에서 실행

npm run watch:css

 

이제 프로젝트를 실행하면 TailwindCSS가 제대로 적용되고,
dotnet watch나 Visual Studio에서 F5로 실행해도 실시간으로 반영된다.

'blazor' 카테고리의 다른 글

Blazor 에 TailwindCSS 적용하기 (v4 기준)  (0) 2025.04.06

.NET에서 DB와 연결해서 데이터를 처리할 수 있는 방법은 여러 가지가 있다.
그중에서도 가장 많이 쓰는 세 가지는 다음과 같다.

  • Entity Framework Core (ORM)
  • Dapper (Micro ORM)
  • ADO.NET

각 방식의 특징과 장단점, 그리고 언제 어떤 걸 선택해야 하는지 정리해봤다.

항목 Entity Framework Core Dapper ADO.NET
방식 ORM (객체-관계 매핑) Micro ORM Low-level API
접근 방식 LINQ로 추상화된 쿼리 직접 SQL 작성 SqlConnection, SqlCommand 직접 사용
코드량 적음 보통 많음
성능 중간 빠름 가장 빠름
유지보수 쉬움 SQL 유지 필요 유지 난이도 높음

 

1. 코드 비교

//ADO.NET
using var conn = new SqlConnection(connectionString);
conn.Open();
var cmd = new SqlCommand("SELECT * FROM Users WHERE Id = @Id", conn);
cmd.Parameters.AddWithValue("@Id", 1);
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
}

//Dapper
using var conn = new SqlConnection(connectionString);
var user = conn.QueryFirstOrDefault<User>("SELECT * FROM Users WHERE Id = @Id", new { Id = 1 });

//EF
var user = await _context.Users.FirstOrDefaultAsync(u => u.Id == 1);

 

2. 성능 비교

항목 ADO.NET Dapper EF Core
속도 🚀 최고속도 🔥 매우 빠름 😐 느림  
메모리 사용량 낮음 중간 높음
처리량(TPS) 높음 높음 낮음
추적 기능 없음 없음 있음(변경 감지)

개인적인 선택

개인적으로 나는 거의 모든 프로젝트에서 Dapper를 기본으로 사용하고 있다.
쿼리를 직접 작성해야 하는 부담은 있지만,
SQL을 명확하게 통제할 수 있다는 점과 성능이 일정하게 나온다는 점이 큰 장점이다.

 

Entity Framework는 상황에 따라

물론 프로젝트 따라 Entity Framework를 사용하는 경우도 있다.
주로 다음과 같은 상황에서 EF를 선택하게 된다.

  • 데이터 모델이 복잡하고 릴레이션이 많은 경우
  • LINQ와 마이그레이션에 익숙한 경우
  • 비즈니스 로직과 도메인 중심의 설계가 필요한 경우

다만 EF를 쓸 때도 항상 Db First 방식을 선호한다.
Code First 방식은 스키마 충돌이나 마이그레이션 충돌 문제가 자주 발생해서,
프로젝트 초기에만 편하지, 시간이 지날수록 유지보수에 더 부담이 된다.

 

Code First

Code First 방식은 개발 초기에는 확실히 편하다.
클래스만 정의하면 자동으로 테이블이 만들어지고, 마이그레이션도 바로 적용되기 때문에
스피드 있게 프로젝트를 시작할 수 있는 장점이 있다.
하지만 시간이 지나고 나면 문제가 생긴다.

  • 팀원이 많아지고
  • 마이그레이션 기록이 꼬이고
  • DB를 수동으로 수정하거나 데이터 마이그레이션이 필요해지면

스키마 충돌이나 마이그레이션 에러가 자주 발생한다.
특히 CI/CD 환경에서 여러 개발자가 동시에 작업할 경우,
작은 변경이 전체 빌드나 배포에 영향을 주는 일이 많다.
물론 Code First로 시작하고 중간에 연결을 끊는 방식도 가능하긴 한데
초기에는 Code First로 개발하다가 어느 시점 이후부터는 Database First처럼 수동으로 DB를 관리하면서 모델만 유지하는 방식이다.
하지만 그렇게 되면 EF의 마이그레이션 기능을 더 이상 쓰지 못하게 되고,
결국 수작업으로 스키마를 관리해야 하므로 EF의 장점을 포기하는 셈이 된다.

 

외래키(Foreign Key) 설정은 가급적 피함

EF를 사용할 때는 외래키 설정은 제외하는 편이다.
왜냐하면
릴레이션이 걸려 있으면 데이터 수정이나 삭제 시 의도치 않은 제약이 발생할 수 있고
복잡한 모델일수록 이중, 삼중 참조가 꼬이면서 유지보수가 어려워지기 때문
대신 논리적으로만 참조 관계를 유지하고,
코드 레벨에서 필요한 검증만 수행하는 쪽으로 방향을 잡는다.

 

ADO.NET은?

ADO는 직접 쓰는 경우는 거의 없고,
기존에 만들어져 있는 오래된 코드 유지보수가 필요할 때
간단한 수정이나 리팩터링 정도만 하고 있다.
직접 객체 매핑을 다 해줘야 하고, 코드도 장황해서 가급적 피하고 싶은 스타일이다.

결론(개인적인 추천)

관리자 페이지 - Dapper + EF (혼합)
기능이 단순한 홈페이지 - EF Core
API 서버 - EF Core
MVP, 빠른 개발 - Dapper
마이그레이션 자동화 필요 - EF Core

+ Recent posts