지난 글에서는 로그인 입력값을 LoginRequest 모델로 묶었다.
처음에는 Home.razor 안에 userId, password 변수가 따로 있었다.
private string userId = "";
private string password = "";
이걸 아래처럼 바꿨다.
private LoginRequest loginRequest = new();
그리고 입력창도 LoginRequest 객체에 연결했다.
<input id="user-id" @bind="loginRequest.UserId" />
<input id="password" type="password" @bind="loginRequest.Password" />
이제 아이디와 비밀번호는 따로 흩어진 변수가 아니라, 로그인 요청 데이터라는 하나의 묶음으로 관리된다.
이번에는 여기서 한 단계 더 나아가서 Home.razor 안에 있던 로그인 검증 로직을 LoginService 클래스로 분리했다.
왜 Service로 분리하려고 했나?
처음에는 Home.razor 안에서 로그인 검증을 직접 했다.
private void Login()
{
if (string.IsNullOrWhiteSpace(loginRequest.UserId))
{
loginMessage = "아이디를 입력하세요.";
messageClass = "login-message error-message";
return;
}
if (string.IsNullOrWhiteSpace(loginRequest.Password))
{
loginMessage = "비밀번호를 입력하세요.";
messageClass = "login-message error-message";
return;
}
loginMessage = $"{loginRequest.UserId}님 환영합니다. 로그인 시도 완료.";
messageClass = "login-message success-message";
}
이 코드도 동작은 했다.
하지만 Home.razor는 화면을 담당하는 파일에 가깝다.
입력창 보여주기
버튼 보여주기
사용자 입력 받기
결과 메시지 출력하기
반면 로그인 검증은 화면보다는 기능 로직에 가깝다.
아이디가 비어 있는가?
비밀번호가 비어 있는가?
아이디가 맞는가?
비밀번호가 맞는가?
성공인지 실패인지 판단하기
그래서 이 로직을 LoginService라는 클래스로 분리하기로 했다.
목표는 이런 구조였다.
Home.razor
↓
LoginService
↓
LoginResult
Home.razor는 화면과 이벤트 처리를 담당하고, LoginService는 로그인 판단을 담당하게 만들고 싶었다.
LoginResult 만들기
서비스가 로그인 처리를 하면 결과를 돌려줘야 한다.
처음에는 그냥 문자열 메시지만 반환해도 되지 않나 싶었다.
하지만 로그인 결과에는 메시지만 필요한 게 아니었다.
성공인지 실패인지
화면에 보여줄 메시지가 무엇인지
이 두 가지를 같이 담을 모델이 필요했다.
그래서 Models/LoginResult.cs 파일을 만들었다.
namespace Study.Web.Models;
public class LoginResult
{
public bool IsSuccess { get; set; }
public string Message { get; set; } = "";
}
LoginRequest가 요청 데이터라면, LoginResult는 처리 결과 데이터다.
LoginRequest
├─ UserId
└─ Password
LoginResult
├─ IsSuccess
└─ Message
IsSuccess는 로그인 성공 여부를 나타낸다.
true → 성공
false → 실패
Message는 화면에 보여줄 메시지를 담는다.
아이디를 입력하세요.
비밀번호를 입력하세요.
아이디가 올바르지 않습니다.
비밀번호가 올바르지 않습니다.
admin님 환영합니다. 로그인 성공.
로그인 하나인데 모델이 너무 많은 것 아닌가?
중간에 이런 의문이 들었다.
로그인 하나 처리하는데 LoginRequest, LoginResult처럼 모델이 너무 많이 생기는 것 아닌가?
그냥 Login.cs 하나로 만들면 안 되나?
파일 하나에 관련 클래스를 같이 넣는 것은 가능하다.
예를 들어 Login.cs 파일 하나 안에 LoginRequest, LoginResult를 같이 둘 수도 있다.
namespace Study.Web.Models;
public class LoginRequest
{
public string UserId { get; set; } = "";
public string Password { get; set; } = "";
}
public class LoginResult
{
public bool IsSuccess { get; set; }
public string Message { get; set; } = "";
}
하지만 요청과 결과를 하나의 클래스로 합치는 것은 별로 좋아 보이지 않았다.
예를 들어 이렇게 만들 수도 있다.
public class Login
{
public string UserId { get; set; } = "";
public string Password { get; set; } = "";
public bool IsSuccess { get; set; }
public string Message { get; set; } = "";
}
그런데 이렇게 하면 역할이 섞인다.
UserId, Password
→ 로그인 요청 데이터
IsSuccess, Message
→ 로그인 처리 결과 데이터
요청과 결과는 성격이 다르다.
그래서 이번에는 실무적인 구조를 연습한다는 의미로 파일도 역할별로 나누기로 했다.
LoginRequest
→ 입력값
LoginResult
→ 처리 결과
파일이 늘어나는 것 자체가 목적은 아니지만, 역할이 다른 코드를 구분하는 습관은 필요하다고 느꼈다.
LoginService 만들기
이제 Services 폴더를 만들고 LoginService.cs 파일을 만들었다.
Study.Web
├─ Models
│ ├─ LoginRequest.cs
│ └─ LoginResult.cs
│
├─ Services
│ └─ LoginService.cs
│
└─ Components
└─ Pages
└─ Home.razor
처음에는 아이디와 비밀번호가 비어 있지만 않으면 성공이었다.
하지만 그건 너무 단순해서, 테스트용 아이디와 비밀번호를 정해두고 맞을 때만 성공하게 바꿨다.
최종적으로 작성한 LoginService는 이런 형태다.
using Study.Web.Models;
namespace Study.Web.Services
{
public class LoginService
{
private readonly string testUserId = "admin";
private readonly string testPassword = "1234";
public LoginResult Login(LoginRequest loginRequest)
{
LoginResult loginResult;
bool isMatchedUserId;
bool isMatchedPassword;
isMatchedUserId = false;
isMatchedPassword = false;
loginResult = new LoginResult();
if (loginRequest == null)
{
loginResult.IsSuccess = false;
loginResult.Message = "로그인 요청 정보가 없습니다.";
return loginResult;
}
if (string.IsNullOrWhiteSpace(loginRequest.UserId))
{
loginResult.IsSuccess = false;
loginResult.Message = "아이디를 입력하세요.";
return loginResult;
}
if (string.IsNullOrWhiteSpace(loginRequest.Password))
{
loginResult.IsSuccess = false;
loginResult.Message = "비밀번호를 입력하세요.";
return loginResult;
}
isMatchedUserId = loginRequest.UserId == testUserId;
isMatchedPassword = loginRequest.Password == testPassword;
if (isMatchedUserId == false)
{
loginResult.IsSuccess = false;
loginResult.Message = "아이디가 올바르지 않습니다.";
return loginResult;
}
if (isMatchedPassword == false)
{
loginResult.IsSuccess = false;
loginResult.Message = "비밀번호가 올바르지 않습니다.";
return loginResult;
}
loginResult.IsSuccess = true;
loginResult.Message = $"{loginRequest.UserId}님 환영합니다. 로그인 성공.";
return loginResult;
}
}
}
private readonly로 테스트 계정 분리하기
처음에는 테스트용 아이디와 비밀번호를 Login() 메서드 안에 지역 변수로 두었다.
string testUserId;
string testPassword;
testUserId = "admin";
testPassword = "1234";
그런데 이 값들은 로그인할 때마다 새로 바뀌는 값이 아니다.
LoginService가 가지고 있는 고정된 테스트 기준값에 더 가깝다.
그래서 클래스 필드로 올렸다.
private readonly string testUserId = "admin";
private readonly string testPassword = "1234";
여기서 private은 이 클래스 안에서만 사용한다는 뜻이다.
readonly는 처음 정해진 뒤에는 바꾸지 않겠다는 뜻이다.
즉 이런 의미다.
LoginService 내부에서만 사용하는 테스트 계정 값이다.
중간에 실수로 바꾸지 않도록 고정해둔다.
나중에는 이 값을 코드 안에 직접 두면 안 된다.
DB나 설정 파일에서 가져오는 구조가 되어야 한다.
하지만 지금은 DB로 가기 전 단계이기 때문에, 서비스 구조를 연습하기 위한 임시 값으로 사용했다.
null 검사는 왜 넣었나?
중간에 loginRequest == null 검사도 추가했다.
if (loginRequest == null)
{
loginResult.IsSuccess = false;
loginResult.Message = "로그인 요청 정보가 없습니다.";
return loginResult;
}
지금 Home.razor에서는 이렇게 객체를 만들고 있기 때문에 일반적인 화면 흐름에서는 거의 null이 나오지 않는다.
private LoginRequest loginRequest = new();
하지만 LoginService는 나중에 다른 곳에서도 호출될 수 있다.
Home.razor
다른 Razor 컴포넌트
API Controller
테스트 코드
다른 Service
누군가 실수로 이렇게 호출할 수도 있다.
loginService.Login(null);
그 상태에서 아래 코드에 접근하면 오류가 난다.
loginRequest.UserId
객체가 없는데 그 안의 값을 읽으려고 하기 때문이다.
그래서 서비스 입장에서는 먼저 null을 검사해두는 게 더 방어적인 코드라고 느꼈다.
현재 단계에서 반드시 발생하는 문제는 아니지만, 서비스 클래스는 나중에 어디서 호출될지 모르기 때문에 이런 검사를 넣어두는 것도 의미가 있다고 생각했다.
성공 조건을 먼저 쓸지, 실패 조건을 먼저 쓸지
이번에 코드를 작성하면서 가장 많이 고민한 부분은 이거였다.
성공 조건을 먼저 작성하는 게 좋은가?
실패 조건을 먼저 검사하는 게 좋은가?
예를 들어 성공 조건을 먼저 쓰면 이렇게 작성할 수 있다.
if (loginRequest.UserId == testUserId && loginRequest.Password == testPassword)
{
loginResult.IsSuccess = true;
loginResult.Message = $"{loginRequest.UserId}님 환영합니다. 로그인 성공.";
return loginResult;
}
loginResult.IsSuccess = false;
loginResult.Message = "아이디 또는 비밀번호가 올바르지 않습니다.";
return loginResult;
짧고 간단하다.
하지만 지금 로그인 로직은 단순히 성공 여부만 판단하는 게 아니다.
요청 객체가 있는지 확인
아이디가 비어 있는지 확인
비밀번호가 비어 있는지 확인
아이디가 맞는지 확인
비밀번호가 맞는지 확인
이런 흐름이 있다.
그래서 이번에는 실패 조건을 먼저 검사하고, 문제가 있으면 바로 return하는 방식으로 작성했다.
if (string.IsNullOrWhiteSpace(loginRequest.UserId))
{
loginResult.IsSuccess = false;
loginResult.Message = "아이디를 입력하세요.";
return loginResult;
}
이런 스타일을 early return 또는 guard clause 방식이라고 부른다고 한다.
내가 이해한 장점은 이렇다.
잘못된 입력은 앞에서 걸러낸다.
문제가 있으면 바로 함수가 끝난다.
중첩 if가 깊어지지 않는다.
마지막에는 성공 처리만 남는다.
코드가 무조건 짧다고 좋은 건 아닌 것 같다.
나중에 다시 봤을 때 흐름이 보여야 하고, 디버깅할 때 중간 값을 확인하기 쉬워야 한다.
이번 코드에서는 비교 결과도 바로 if 안에 넣지 않고 변수로 분리했다.
isMatchedUserId = loginRequest.UserId == testUserId;
isMatchedPassword = loginRequest.Password == testPassword;
그리고 그 결과를 가지고 실패 처리를 했다.
if (isMatchedUserId == false)
{
loginResult.IsSuccess = false;
loginResult.Message = "아이디가 올바르지 않습니다.";
return loginResult;
}
이 정도는 너무 장황하지 않으면서도 흐름을 보기 좋은 방식이라고 느꼈다.
Program.cs에 LoginService 등록하기
LoginService를 만들었다고 해서 바로 Home.razor에서 사용할 수 있는 것은 아니었다.
ASP.NET Core / Blazor에서는 서비스를 보통 DI 컨테이너에 등록한 뒤 가져다 쓴다.
Program.cs에 서비스를 등록했다.
using Study.Web.Components;
using Study.Web.Services;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
builder.Services.AddScoped<LoginService>();
var app = builder.Build();
핵심은 이 부분이다.
builder.Services.AddScoped<LoginService>();
처음에는 그냥 new LoginService()로 만들면 안 되나 싶었다.
물론 직접 만들 수도 있다.
하지만 Blazor나 ASP.NET Core에서는 보통 객체 생성을 프레임워크에 맡긴다.
이걸 DI, Dependency Injection이라고 한다.
내가 이해한 차이는 이렇다.
직접 new
→ 내가 필요한 순간에 직접 객체를 만든다.
DI
→ Program.cs에 등록해두고, 필요한 곳에서 주입받아 사용한다.
지금은 깊게 들어가지는 않았지만, 실무에서는 DI를 많이 쓰는 것 같다.
나중에 DB 연결, 설정, 로그, 다른 서비스들이 붙으면 직접 new로 만드는 것보다 DI로 관리하는 편이 구조가 좋아질 것 같다.
Home.razor에서 @inject 사용하기
이제 Home.razor에서 LoginService를 가져왔다.
@page "/"
@using Study.Web.Models
@using Study.Web.Services
@inject LoginService LoginService
<PageTitle>Home</PageTitle>
핵심은 이 부분이다.
@inject LoginService LoginService
뜻은 이렇게 이해했다.
LoginService 타입의 객체를 Blazor에게 받아서
이 컴포넌트 안에서는 LoginService라는 이름으로 사용하겠다.
그러면 Login() 함수 안에서 이렇게 호출할 수 있다.
loginResult = LoginService.Login(loginRequest);
Home.razor의 Login() 함수가 짧아졌다
기존에는 Home.razor 안에서 직접 아이디, 비밀번호를 검사했다.
이제는 LoginService를 호출하고, 결과만 화면에 반영한다.
private void Login()
{
LoginResult loginResult;
loginResult = LoginService.Login(loginRequest);
loginMessage = loginResult.Message;
if (loginResult.IsSuccess)
{
messageClass = "login-message success-message";
return;
}
messageClass = "login-message error-message";
}
이제 Home.razor의 역할은 조금 더 화면 쪽에 가까워졌다.
입력값을 가지고 있다.
버튼 클릭 이벤트를 받는다.
LoginService를 호출한다.
결과 메시지를 화면에 보여준다.
CSS 클래스를 바꾼다.
반대로 로그인 판단은 LoginService로 이동했다.
아이디가 비었는지 검사
비밀번호가 비었는지 검사
테스트 계정과 비교
LoginResult 반환
지금 구조 정리
현재 구조는 이렇게 됐다.
Study.Web
├─ Components
│ └─ Pages
│ ├─ Home.razor
│ └─ Home.razor.css
│
├─ Models
│ ├─ LoginRequest.cs
│ └─ LoginResult.cs
│
├─ Services
│ └─ LoginService.cs
│
└─ Program.cs
역할은 이렇게 나뉜다.
LoginRequest
→ 사용자가 입력한 로그인 요청 데이터
LoginResult
→ 로그인 처리 결과 데이터
LoginService
→ 로그인 검증 로직
Program.cs
→ 서비스 등록
Home.razor
→ 화면 표시와 사용자 이벤트 처리
이전보다 파일은 늘어났다.
하지만 역할이 나뉘면서 코드가 어디에 있어야 하는지 조금 더 명확해졌다.
처음에는 로그인 하나 처리하는데 파일이 너무 많아지는 것 아닌가 싶었다.
그래도 실무적인 구조로 가려면 이런 식으로 역할을 나누는 습관이 필요할 것 같다.
테스트 결과
테스트는 이렇게 했다.
아이디 비움
→ 아이디를 입력하세요.
비밀번호 비움
→ 비밀번호를 입력하세요.
아이디 틀림
→ 아이디가 올바르지 않습니다.
비밀번호 틀림
→ 비밀번호가 올바르지 않습니다.
admin / 1234 입력
→ admin님 환영합니다. 로그인 성공.
처음과 비교하면 기능 자체가 엄청 복잡해진 것은 아니다.
하지만 내부 구조는 많이 달라졌다.
기존:
Home.razor가 화면과 로그인 검증을 모두 처리
변경 후:
Home.razor는 화면 담당
LoginService는 로그인 검증 담당
LoginRequest는 입력값 담당
LoginResult는 결과 담당
이렇게 나누고 보니까 나중에 DB를 붙일 때도 Home.razor를 크게 건드리지 않고 LoginService 내부를 바꾸면 될 것 같다.
오늘 배운 점
LoginRequest는 요청 데이터,LoginResult는 처리 결과 데이터로 나누는 게 좋다.- 파일이 늘어나는 것보다 역할이 명확한지가 더 중요하다.
LoginService를 만들면 화면 코드와 로그인 로직을 분리할 수 있다.Program.cs에AddScoped<LoginService>()로 서비스를 등록해야@inject로 사용할 수 있다.@inject는 Blazor 컴포넌트에서 등록된 서비스를 받아오는 문법이다.null검사는 서비스가 예상치 못한 입력을 받았을 때를 대비하는 방어 코드다.- 로그인 검증처럼 단계가 있는 로직은 실패 조건을 먼저 검사하고 마지막에 성공 처리하는 방식이 읽기 좋다.
- 무조건 짧은 코드가 좋은 코드는 아니다. 나중에 봐도 흐름이 보이고 디버깅하기 쉬운 코드가 좋은 코드일 수 있다.
private readonly는 클래스 안에서 사용하는 고정값을 표현할 때 유용하다.
다음 작업
다음에는 로그인 성공 상태를 화면에 반영해볼 예정이다.
지금은 로그인 성공 후에도 메시지만 바뀌는데, 다음에는 bool isLoggedIn 같은 상태값을 추가해서 로그인 전 화면과 로그인 후 화면을 분리해볼 수 있을 것 같다.