[Blazor Web App] 07. 로그인 상태를 서비스로 관리하기 - 직접 URL 접근 막기와 로그아웃 메시지

지난번에는 로그인 성공 후 /member 페이지로 이동하는 흐름을 만들었다.
로그인 성공 시에는 NavigationManager를 사용해서 회원 페이지로 이동하게 했다.
NavigationManager.NavigateTo("/member");
그리고 Member.razor에는 로그아웃 버튼도 추가했다.
private void Logout()
{
NavigationManager.NavigateTo("/");
}
겉으로 보기에는 로그인과 로그아웃 흐름이 어느 정도 만들어진 것처럼 보였다.

/ 접속
→ 로그인
로그인 성공
→ /member 이동
로그아웃
→ / 이동
그런데 테스트를 하다 보니 바로 어색한 부분이 보였다.
주소창에 직접 /member를 입력하면 로그인하지 않아도 회원 페이지에 들어갈 수 있었다.
/member 직접 입력
→ 회원 페이지 접근 가능
아직 진짜 인증과 인가를 붙인 상태가 아니기 때문에 당연한 결과이긴 하다.
그래도 지금 단계에서 최소한 “로그인 상태인지 아닌지” 정도는 따로 관리해보는 게 좋을 것 같았다.
그래서 이번에는 LoginStateService를 만들었다.
로그인 검증과 로그인 상태는 다르다
지금까지 만든 LoginService는 로그인 검증을 담당한다.
Home.razor
→ LoginRequest에 입력값 저장
→ LoginService.Login(loginRequest) 호출
→ LoginResult 반환
LoginService가 하는 일은 대략 이런 것들이다.
아이디가 비어 있는가?
비밀번호가 비어 있는가?
아이디가 맞는가?
비밀번호가 맞는가?
그런데 로그인에 성공한 뒤의 “상태”는 또 다른 문제다.
예를 들면 이런 정보들이다.
현재 로그인 상태인가?
로그인한 사용자의 아이디는 무엇인가?
로그아웃 후 보여줄 메시지가 있는가?
처음에는 로그인 검증과 상태 관리를 한 서비스에서 처리해도 되지 않나 싶었다.
하지만 생각해보면 역할이 다르다.
LoginService
→ 로그인 검증 담당
LoginStateService
→ 로그인 상태 보관 담당
LoginService는 “맞는 계정인가?”를 판단하고, LoginStateService는 “지금 로그인된 상태인가?”를 기억하는 쪽에 가깝다.
그래서 상태 관리를 위한 서비스를 따로 만들기로 했다.
LoginStateService 만들기
Services 폴더 안에 새 파일을 만들었다.
Services/LoginStateService.cs
처음 작성한 코드는 이런 형태다.
namespace Study.Web.Services
{
public class LoginStateService
{
public bool IsLoggedIn { get; private set; }
public string UserId { get; private set; } = "";
public string StatusMessage { get; private set; } = "";
public void Login(string userId)
{
IsLoggedIn = true;
UserId = userId;
StatusMessage = "";
}
public void Logout()
{
IsLoggedIn = false;
UserId = "";
StatusMessage = "로그아웃되었습니다.";
}
public void ClearStatusMessage()
{
StatusMessage = "";
}
}
}
이번 클래스는 세 가지 값을 관리한다.
IsLoggedIn
→ 현재 로그인 여부
UserId
→ 로그인한 사용자 아이디
StatusMessage
→ 다른 페이지에서 보여줄 임시 상태 메시지
처음에는 단순히 IsLoggedIn만 있으면 되지 않을까 싶었다.
하지만 회원 페이지에서 로그인한 아이디를 보여주려면 UserId도 필요했고, 로그아웃 후 홈 화면에 안내 메시지를 보여주려면 StatusMessage도 필요했다.
get은 public, set은 private
여기서 눈에 들어온 부분이 있었다.
public bool IsLoggedIn { get; private set; }
이전에는 보통 이렇게 썼다.
public string UserId { get; set; } = "";
그런데 이번에는 set 앞에 private이 붙었다.
public bool IsLoggedIn { get; private set; }
이 의미는 이렇게 이해했다.
외부에서는 값을 읽을 수 있다.
하지만 외부에서 직접 값을 바꿀 수는 없다.
값 변경은 LoginStateService 내부에서만 가능하다.
외부에서는 이런 식으로 읽을 수 있다.
if (LoginStateService.IsLoggedIn == false)
{
}
하지만 이런 식으로 직접 바꾸는 것은 막힌다.
LoginStateService.IsLoggedIn = true;
로그인 상태는 아무 곳에서나 바뀌면 안 된다.
그래서 Login()과 Logout() 메서드를 통해서만 상태를 바꾸게 만들었다.
public void Login(string userId)
{
IsLoggedIn = true;
UserId = userId;
StatusMessage = "";
}
public void Logout()
{
IsLoggedIn = false;
UserId = "";
StatusMessage = "로그아웃되었습니다.";
}
이렇게 하니까 상태 변경 흐름이 더 분명해졌다.
로그인 성공
→ LoginStateService.Login(userId)
로그아웃
→ LoginStateService.Logout()
값을 그냥 열어두는 것보다, 상태를 바꾸는 통로를 메서드로 제한하는 편이 안전하고 읽기 쉬워 보였다.
Program.cs에 서비스 등록하기
서비스 클래스를 만들었다고 해서 바로 사용할 수 있는 것은 아니다.
Blazor에서 @inject로 가져다 쓰려면 Program.cs에 등록해야 한다.
기존에는 LoginService만 등록되어 있었다.
builder.Services.AddScoped<LoginService>();
여기에 LoginStateService도 추가했다.
builder.Services.AddScoped<LoginService>();
builder.Services.AddScoped<LoginStateService>();
전체 흐름은 대략 이런 형태다.
using Study.Web.Components;
using Study.Web.Services;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
builder.Services.AddScoped<LoginService>();
builder.Services.AddScoped<LoginStateService>();
var app = builder.Build();
지금은 AddScoped의 생명주기를 깊게 파고들지는 않았다.
현재 단계에서는 “컴포넌트에서 주입받아 사용할 서비스를 등록하는 과정” 정도로 이해하고 넘어갔다.
Home.razor에서 로그인 상태 저장하기
이제 로그인에 성공했을 때 LoginStateService에 상태를 저장해야 한다.
먼저 Home.razor 위쪽에 LoginStateService를 주입했다.
@page "/"
@using Study.Web.Models
@using Study.Web.Services
@inject LoginService LoginService
@inject LoginStateService LoginStateService
@inject NavigationManager NavigationManager
기존 로그인 성공 흐름은 이런 식이었다.
if (loginResult.IsSuccess)
{
isLoggedIn = true;
messageClass = "login-message success-message";
NavigationManager.NavigateTo("/member");
return;
}
여기에 로그인 상태 저장 코드를 추가했다.
LoginStateService.Login(loginRequest.UserId);
정리 후 Login() 함수는 이렇게 됐다.
private void Login()
{
LoginResult loginResult;
loginResult = LoginService.Login(loginRequest);
if (loginResult.IsSuccess)
{
isLoggedIn = true;
LoginStateService.Login(loginRequest.UserId);
NavigationManager.NavigateTo("/member");
return;
}
isLoggedIn = false;
loginMessage = loginResult.Message;
messageClass = "app-message error-message";
}
이제 로그인 성공 시 세 가지 일이 일어난다.
1. Home.razor 내부 상태값 변경
2. LoginStateService에 로그인 상태 저장
3. /member 페이지로 이동
여기서 loginMessage = loginResult.Message; 위치도 정리했다.
처음에는 LoginService 호출 직후에 바로 메시지를 넣었다.
loginMessage = loginResult.Message;
하지만 성공하면 바로 /member로 이동한다.
성공 메시지를 Home.razor에 넣어도 사용자가 볼 시간이 거의 없다.
반대로 실패했을 때는 메시지를 반드시 보여줘야 한다.
그래서 실패 처리 쪽에만 메시지를 넣도록 바꿨다.
isLoggedIn = false;
loginMessage = loginResult.Message;
messageClass = "app-message error-message";
이렇게 하니 흐름이 더 분명해졌다.
성공
→ 상태 저장 후 페이지 이동
실패
→ 메시지와 오류 스타일 표시
Member.razor에서 로그인 상태 확인하기
이제 /member 페이지에 직접 접근했을 때 로그인 상태를 확인해야 한다.
먼저 Member.razor에 LoginStateService를 주입했다.
@page "/member"
@using Study.Web.Services
@inject LoginStateService LoginStateService
@inject NavigationManager NavigationManager
그리고 OnInitialized() 메서드를 추가했다.
protected override void OnInitialized()
{
if (LoginStateService.IsLoggedIn == false)
{
NavigationManager.NavigateTo("/");
return;
}
}
이 코드는 Member.razor가 처음 열릴 때 실행된다.
흐름은 이렇게 된다.
/member 페이지 열림
→ OnInitialized() 실행
→ LoginStateService.IsLoggedIn 확인
→ false면 / 로 이동
이제 로그인하지 않은 상태에서 /member를 직접 입력하면 홈 화면으로 돌아간다.
/member 직접 입력
→ 로그인 상태 아님
→ / 로 이동
아직 진짜 인증/인가는 아니지만, 적어도 학습용 상태 관리 흐름은 생긴 것이다.
OnInitialized()가 정확히 무엇인지, 왜 override를 붙이는지는 이 글에서는 깊게 들어가지 않았다.
일단 이번 글에서는 “컴포넌트가 처음 열릴 때 실행되는 초기화 지점” 정도로만 이해하고 넘어갔다.
회원 페이지에서 로그인한 아이디 표시하기
기존 회원 정보에는 아이디를 직접 적어두었다.
<p>아이디: admin</p>
이제는 LoginStateService에 저장된 아이디를 보여주도록 바꿨다.
<p>아이디: @LoginStateService.UserId</p>
흐름은 이렇게 연결된다.
Home.razor
→ LoginStateService.Login(loginRequest.UserId)
Member.razor
→ @LoginStateService.UserId 출력
이렇게 하니까 회원 페이지가 단순히 고정 문구를 보여주는 화면이 아니라, 로그인 상태 서비스에 저장된 값을 읽는 화면이 됐다.
로그아웃도 상태 서비스로 처리하기
기존 로그아웃 함수는 단순히 홈으로 이동만 했다.
private void Logout()
{
NavigationManager.NavigateTo("/");
}
이제는 로그아웃 상태도 저장해야 한다.
private void Logout()
{
LoginStateService.Logout();
NavigationManager.NavigateTo("/");
}
LoginStateService.Logout() 안에서는 세 가지 값을 정리한다.
public void Logout()
{
IsLoggedIn = false;
UserId = "";
StatusMessage = "로그아웃되었습니다.";
}
로그아웃 흐름은 이렇게 됐다.
로그아웃 버튼 클릭
→ LoginStateService.Logout()
→ IsLoggedIn = false
→ UserId = ""
→ StatusMessage = "로그아웃되었습니다."
→ / 로 이동
로그아웃 후 Home 화면에 메시지 표시하기
로그아웃 후 홈으로 돌아오면 처음에는 아무 메시지도 없었다.
사용자 입장에서는 왜 홈으로 돌아왔는지 알기 어렵다.
그래서 LoginStateService에 StatusMessage를 추가했다.
public string StatusMessage { get; private set; } = "";
로그아웃할 때는 이 메시지를 넣는다.
StatusMessage = "로그아웃되었습니다.";
그리고 Home.razor가 열릴 때 이 메시지를 읽어서 화면에 표시한다.
protected override void OnInitialized()
{
if (string.IsNullOrWhiteSpace(LoginStateService.StatusMessage) == false)
{
loginMessage = LoginStateService.StatusMessage;
messageClass = "app-message success-message";
LoginStateService.ClearStatusMessage();
}
}
흐름은 이렇게 된다.
Member.razor
→ Logout()
→ LoginStateService.StatusMessage 저장
→ / 로 이동
Home.razor
→ OnInitialized()
→ StatusMessage 읽기
→ loginMessage에 복사
→ 화면에 표시
→ StatusMessage 비우기
여기서 ClearStatusMessage()도 필요했다.
public void ClearStatusMessage()
{
StatusMessage = "";
}
로그아웃 메시지는 한 번만 보여주면 된다.
계속 남아 있으면 나중에 새로고침하거나 다시 홈에 들어왔을 때도 메시지가 반복될 수 있다.
그래서 홈 화면이 메시지를 읽은 뒤에는 서비스 쪽 메시지를 비워준다.
한 번 보여줄 메시지
→ 화면에 복사
→ 원본 상태 메시지 제거
이 구조는 나중에 알림 메시지나 임시 상태 전달을 만들 때도 활용할 수 있을 것 같다.
Home.razor 정리 후 코드
현재 Home.razor의 핵심 구조는 이렇게 됐다.
실제 프로젝트명이나 회사 정보는 블로그에 노출하지 않기 위해 예시 값으로 바꿨다.
@page "/"
@using Study.Web.Models
@using Study.Web.Services
@inject LoginService LoginService
@inject LoginStateService LoginStateService
@inject NavigationManager NavigationManager
<PageTitle>Home</PageTitle>
<div class="app-page">
<header class="app-header">
<h1>@title</h1>
<p>@description</p>
</header>
<main class="app-content">
<section class="app-section">
<h2>회원 로그인</h2>
<div class="form-row">
<label for="user-id">아이디</label>
<input id="user-id" @bind="loginRequest.UserId" />
</div>
<div class="form-row">
<label for="password">비밀번호</label>
<input id="password" type="password" @bind="loginRequest.Password" />
</div>
<button class="app-button" @onclick="Login">@loginButtonText</button>
<p class="@messageClass">@loginMessage</p>
</section>
</main>
<footer class="page-footer">
<p>Tel:0000-0000 / FAX:00-0000-0000</p>
<p>https://example.com</p>
<p>Made by Sample Company</p>
<p>Ver: 0.0.1</p>
</footer>
</div>
@code
{
private string title = "Sample Blazor Web";
private string description = "Blazor와 C#을 학습하는 페이지입니다.";
private string loginButtonText = "회원로그인";
private LoginRequest loginRequest = new();
private string loginMessage = "";
private string messageClass = "app-message";
private bool isLoggedIn = false;
protected override void OnInitialized()
{
if (string.IsNullOrWhiteSpace(LoginStateService.StatusMessage) == false)
{
loginMessage = LoginStateService.StatusMessage;
messageClass = "app-message success-message";
LoginStateService.ClearStatusMessage();
}
}
private void Login()
{
LoginResult loginResult;
loginResult = LoginService.Login(loginRequest);
if (loginResult.IsSuccess)
{
isLoggedIn = true;
LoginStateService.Login(loginRequest.UserId);
NavigationManager.NavigateTo("/member");
return;
}
isLoggedIn = false;
loginMessage = loginResult.Message;
messageClass = "app-message error-message";
}
}
Member.razor 정리 후 코드
Member.razor는 이렇게 정리됐다.
@page "/member"
@using Study.Web.Services
@inject LoginStateService LoginStateService
@inject NavigationManager NavigationManager
<PageTitle>Member</PageTitle>
<div class="app-page">
<header class="app-header">
<h1>회원 페이지</h1>
<p>로그인 성공 후 이동하는 임시 페이지입니다.</p>
</header>
<main class="app-content">
<section class="app-section">
<h2>로그인 완료</h2>
<p>정상적으로 로그인되었습니다.</p>
<p>이 페이지는 나중에 회원 전용 화면으로 확장할 예정입니다.</p>
<div class="button-row">
<button class="app-button" @onclick="ToggleMemberInfo">
회원 정보 보기 / 숨기기
</button>
<button class="app-button" @onclick="Logout">
로그아웃
</button>
</div>
@if (isShowMemberInfo)
{
<div class="member-info">
<h3>회원 정보</h3>
<p>아이디: @LoginStateService.UserId</p>
<p>회원 등급: 테스트 회원</p>
<p>접속상태: 로그인 완료</p>
</div>
}
</section>
</main>
</div>
@code
{
private bool isShowMemberInfo = false;
protected override void OnInitialized()
{
if (LoginStateService.IsLoggedIn == false)
{
NavigationManager.NavigateTo("/");
return;
}
}
private void ToggleMemberInfo()
{
if (isShowMemberInfo)
{
isShowMemberInfo = false;
return;
}
isShowMemberInfo = true;
}
private void Logout()
{
LoginStateService.Logout();
NavigationManager.NavigateTo("/");
}
}
아직 진짜 보안은 아니다
이번에 LoginStateService를 만들면서 /member 직접 접근을 어느 정도 막았다.
하지만 이건 아직 진짜 보안 인증은 아니다.
현재 구조는 브라우저와 서버의 실제 인증 상태를 다루는 것이 아니라, Blazor 앱 안에서 임시로 로그인 상태를 저장하는 구조다.
LoginStateService
→ 학습용 로그인 상태 저장
Authentication / Authorization
→ 실제 인증/인가
실제 서비스에서는 나중에 이런 것들이 필요하다.
쿠키
세션
AuthenticationState
AuthorizeRouteView
[Authorize]
DB 기반 사용자 검증
비밀번호 해시 검증
지금 단계에서는 여기까지 가지 않았다.
이번 목표는 어디까지나 컴포넌트 간 상태를 서비스로 공유하는 흐름을 이해하는 것이었다.
테스트 결과
테스트는 이렇게 했다.
1. / 접속
→ 로그인 화면 표시
2. /member 직접 입력
→ 로그인 상태가 아니므로 / 로 이동
3. admin / 1234 로그인
→ /member 이동
4. 회원 정보 보기 / 숨기기 클릭
→ 회원 정보 표시 / 숨김 정상 동작
5. 회원 정보에 로그인한 UserId 표시
→ 아이디 표시 확인
6. 로그아웃 클릭
→ LoginStateService.Logout() 실행
→ / 로 이동
7. Home 화면에 "로그아웃되었습니다." 표시
8. 새로고침 후 메시지가 계속 반복되지 않는지 확인
이제 단순히 페이지를 이동하는 수준이 아니라, 서비스에 상태를 저장하고 여러 컴포넌트에서 그 상태를 읽는 흐름까지 만들어졌다.
오늘 배운 점
- 로그인 검증과 로그인 상태 관리는 서로 다른 역할이다.
LoginService는 로그인 판단을 담당하고,LoginStateService는 로그인 상태를 보관하도록 분리했다.public get,private set구조를 사용하면 외부에서는 읽을 수 있지만 직접 수정은 막을 수 있다.- 상태 변경은
Login()과Logout()같은 메서드를 통해 처리하는 편이 흐름이 명확하다. - Blazor 서비스에 상태를 저장하면 여러 컴포넌트에서 같은 상태를 공유할 수 있다.
OnInitialized()를 사용하면 컴포넌트가 처음 열릴 때 로그인 상태를 확인할 수 있다.StatusMessage를 이용하면 로그아웃 후 홈 화면에 임시 메시지를 전달할 수 있다.- 한 번 표시한 메시지는
ClearStatusMessage()로 비워주는 게 좋다. - 현재 구조는 학습용 상태 관리이지, 실제 인증/인가는 아니다.
다음 작업
다음에는 이번에 사용한 OnInitialized(), protected override, Razor 컴포넌트의 내부 구조를 따로 정리해볼 예정이다.
이번 작업을 하면서 이런 의문이 생겼다.
.razor 파일은 왜 ComponentBase를 상속한다고 볼 수 있는가?
상속 표시가 없는데 OnInitialized()를 override할 수 있는 이유는 무엇인가?
protected는 왜 쓰는가?
@code 안의 함수 작성 순서가 실행 순서를 결정하는가?
messageClass는 왜 아직 필요한가?
이 내용은 기능 구현보다는 개념 이해에 가까우므로, 다음 글에서 따로 정리하는 게 좋을 것 같다.