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

+ Recent posts