language/blazor

[Blazor Web App] 11. 로그인 데이터 분리하기 - UserAccount와 TestUserRepository 만들기

렁치 2026. 8. 8. 14:22

지난 작업에서는 로그인에 성공했을 때 사용자 정보를 UserInfo에 담아 LoginResult로 반환하도록 구조를 정리했다.

로그인 상태를 보관하는 LoginStateService는 사용자 정보를 직접 만들지 않고, LoginService가 만들어준 결과를 저장만 하도록 바꿨다.

구조가 조금씩 정리되고 있었지만, LoginService를 다시 보니 아직 테스트 사용자 정보가 직접 들어 있었다.

private readonly string testUserId = "sample-admin";
private readonly string testPassword = "1111";

로그인에 성공했을 때 표시할 사용자 이름과 등급도 LoginService 안에서 직접 만들고 있었다.

userInfo.UserId = loginRequest.UserId;
userInfo.UserName = "테스트 관리자";
userInfo.UserGrade = "관리자";

사용자가 한 명뿐일 때는 이 방식도 단순하고 이해하기 쉬웠다.

그런데 사용자 계정이 두 명, 세 명으로 늘어나면 이야기가 달라진다.

sample-admin / 1111
sample-user / 2222
sample-manager / 3333
...

이런 값이 모두 LoginService 안에 들어가면 로그인 로직과 사용자 데이터가 한곳에 섞인다.

LoginService

├─ 테스트 사용자 데이터 보관
├─ 아이디 확인
├─ 비밀번호 확인
├─ 사용자 정보 생성
└─ 로그인 결과 생성

그래서 이번에는 사용자 데이터를 보관하는 책임로그인 여부를 판단하는 책임을 나눠보기로 했다.


이번에 만들 구조

기존 구조는 다음과 같았다.

LoginService
├─ 테스트 계정 보관
├─ 아이디 비교
├─ 비밀번호 비교
└─ 로그인 결과 생성

변경 후에는 다음과 같이 나눈다.

TestUserRepository
├─ 테스트 사용자 목록 보관
└─ 아이디로 사용자 검색

LoginService
├─ 입력값 검증
├─ Repository에 사용자 검색 요청
├─ 비밀번호 검증
└─ LoginResult 생성

화면을 담당하는 Home.razor는 계속 LoginService만 호출한다.

Home.razor
↓
LoginService
↓
TestUserRepository

 


UserAccount 모델 만들기

로그인 검증에 필요한 사용자 계정 데이터를 하나의 객체로 묶었다.

파일 위치는 다음과 같다.

Models/UserAccount.cs
namespace Study.Web.Models
{
    public class UserAccount
    {
        public string UserId { get; set; } = "";
        public string Password { get; set; } = "";
        public string UserName { get; set; } = "";
        public string UserGrade { get; set; } = "";
    }
}

UserAccount는 로그인 검증에 필요한 원본 계정 데이터다.

UserId
→ 로그인 아이디

Password
→ 로그인 검증에 사용할 비밀번호

UserName
→ 사용자 이름

UserGrade
→ 사용자 등급

UserAccount와 UserInfo는 무엇이 다를까?

프로젝트에는 이미 UserInfo가 있다.

namespace Study.Web.Models
{
    public class UserInfo
    {
        public string UserId { get; set; } = "";
        public string UserName { get; set; } = "";
        public string UserGrade { get; set; } = "";
    }
}

처음에는 UserAccountUserInfo를 하나로 합쳐도 되지 않을까 싶었다.

두 모델 모두 아이디, 이름, 등급을 가지고 있기 때문이다.

하지만 두 모델은 사용하는 시점과 목적이 다르다.

UserAccount
→ 로그인 검증을 위한 계정 데이터
→ Password 포함

UserInfo
→ 로그인 성공 후 화면과 상태에서 사용할 정보
→ Password 제외

비밀번호는 로그인할 때만 필요하다.

로그인에 성공한 뒤 회원 페이지나 상태 서비스에서 비밀번호를 계속 가지고 있을 이유는 없다.

그래서 로그인에 성공하면 UserAccount 전체를 넘기지 않고, 필요한 정보만 UserInfo로 옮긴다.

userInfo.UserId = foundUser.UserId;
userInfo.UserName = foundUser.UserName;
userInfo.UserGrade = foundUser.UserGrade;
UserAccount
↓ 필요한 정보만 복사
UserInfo

현재 예제에서는 학습을 위해 비밀번호 문자열을 코드 안에 직접 넣었다.

실제 서비스에서는 비밀번호를 평문으로 저장하거나 그대로 비교하면 안 되고, 해시 처리된 값을 검증하는 구조가 필요하다.


문자열 프로퍼티 경고가 발생했다

처음에는 UserAccount를 다음처럼 작성했다.

public string UserId { get; set; }
public string Password { get; set; }
public string UserName { get; set; }
public string UserGrade { get; set; }

빌드는 성공했지만 CS8618 경고가 발생했다.

null을 허용하지 않는 속성이
생성자를 종료할 때 null이 아닌 값을 포함해야 합니다.

컴파일러 입장에서는 다음 객체가 만들어진 직후를 생각한다.

UserAccount userAccount;

userAccount = new UserAccount();

이 시점에는 UserId, Password, UserName, UserGrade에 값이 들어 있다는 보장이 없다.

따라서 각 프로퍼티가 null일 가능성이 있다고 판단한다.

이를 해결하기 위해 다른 모델과 마찬가지로 빈 문자열을 초기값으로 넣었다.

public string UserId { get; set; } = "";

이 코드는 다음 의미로 이해했다.

UserId는 null을 허용하지 않는다.
아직 값이 정해지지 않았다면 빈 문자열을 가진다.

string?으로 바꾸는 방법도 있지만, 그렇게 하면 해당 값이 null이어도 정상적인 상태라는 뜻이 된다.

로그인 계정의 아이디와 비밀번호가 null인 상태를 정상으로 보고 싶지는 않았기 때문에 빈 문자열 초기화를 사용했다.


TestUserRepository 만들기

테스트 사용자 목록을 보관할 클래스를 만들었다.

파일 위치는 다음과 같다.

Repositories/TestUserRepository.cs
using Study.Web.Models;

namespace Study.Web.Repositories
{
    public class TestUserRepository
    {
        private readonly List<UserAccount> testUserAccounts;

        public TestUserRepository()
        {
            testUserAccounts = new List<UserAccount>();

            AddTestUsers();
        }

        public UserAccount? FindByUserId(string userId)
        {
            UserAccount? foundUser;
            UserAccount currentUser;

            foundUser = null;

            for (int index = 0; index < testUserAccounts.Count; index++)
            {
                currentUser = testUserAccounts[index];

                if (currentUser.UserId == userId)
                {
                    foundUser = currentUser;
                    break;
                }
            }

            return foundUser;
        }

        private void AddTestUsers()
        {
            UserAccount adminUser;
            UserAccount normalUser;

            adminUser = new UserAccount();
            adminUser.UserId = "sample-admin";
            adminUser.Password = "1111";
            adminUser.UserName = "테스트 관리자";
            adminUser.UserGrade = "관리자";

            normalUser = new UserAccount();
            normalUser.UserId = "sample-user";
            normalUser.Password = "2222";
            normalUser.UserName = "테스트 사용자";
            normalUser.UserGrade = "일반 사용자";

            testUserAccounts.Add(adminUser);
            testUserAccounts.Add(normalUser);
        }
    }
}

List로 사용자 여러 명 보관하기

다음 필드는 여러 사용자 계정을 보관한다.

private readonly List<UserAccount> testUserAccounts;

List<T>에서 T는 목록에 들어갈 데이터 타입이다.

List<int>
→ 정수 목록

List<string>
→ 문자열 목록

List<UserAccount>
→ UserAccount 객체 목록

Java의 List<UserAccount>ArrayList<UserAccount>, C++의 std::vector<UserAccount>와 비슷하게 생각할 수 있었다.

List<UserAccount> testUserAccounts;
std::vector<UserAccount> testUserAccounts;

readonly인데 Add는 왜 가능할까?

목록 필드에는 readonly가 붙어 있다.

private readonly List<UserAccount> testUserAccounts;

처음에는 readonly라면 사용자 추가도 할 수 없는 것 아닌가 싶었다.

하지만 readonly는 필드가 참조하는 목록 객체 자체를 다른 목록으로 바꾸는 것을 제한한다.

testUserAccounts = new List<UserAccount>();

이런 재대입은 생성 이후 제한된다.

반면 기존 목록 안의 데이터를 변경하는 것은 가능하다.

testUserAccounts.Add(adminUser);
testUserAccounts.Add(normalUser);

정리하면 다음과 같다.

목록 필드에 다른 List 객체를 다시 대입
→ 제한됨

현재 List 안에 사용자 추가
→ 가능함

생성자에서 사용자 목록 준비하기

Repository 객체가 만들어질 때 사용자 목록도 함께 초기화한다.

public TestUserRepository()
{
    testUserAccounts = new List<UserAccount>();

    AddTestUsers();
}

실행 순서는 다음과 같다.

TestUserRepository 객체 생성
↓
빈 UserAccount 목록 생성
↓
AddTestUsers() 실행
↓
테스트 사용자 두 명 추가

생성자는 클래스 이름과 같고 반환형을 작성하지 않는다.

public TestUserRepository()

Java에서 사용하던 생성자와 거의 같은 형태라서 이해하기 쉬웠다.


아이디로 사용자 검색하기

사용자 검색은 FindByUserId()에서 처리한다.

public UserAccount? FindByUserId(string userId)

반환형 뒤의 ?는 사용자를 찾지 못했을 때 null을 반환할 수 있다는 뜻이다.

사용자를 찾음
→ UserAccount 반환

사용자를 찾지 못함
→ null 반환

검색은 목록의 0번부터 차례대로 진행한다.

for (int index = 0; index < testUserAccounts.Count; index++)
{
    currentUser = testUserAccounts[index];

    if (currentUser.UserId == userId)
    {
        foundUser = currentUser;
        break;
    }
}

흐름을 손으로 풀어보면 다음과 같다.

0번 사용자 가져오기
↓
입력한 아이디와 비교
↓
같으면 foundUser에 저장
↓
반복문 종료

다르면 다음 사용자 확인
↓
끝까지 없으면 null 반환

LINQ를 사용하면 더 짧게 작성할 수도 있다.

return testUserAccounts.FirstOrDefault(
    user => user.UserId == userId);

하지만 이번에는 검색이 실제로 어떤 순서로 진행되는지 확인하고 싶어서 for문을 사용했다.


LoginService에서 Repository 사용하기

LoginService는 더 이상 테스트 계정 문자열을 직접 비교하지 않는다.

먼저 Repository에 사용자를 찾아달라고 요청한다.

foundUser = testUserRepository.FindByUserId(
    loginRequest.UserId);

검색 결과가 null이면 해당 아이디를 가진 사용자가 없다는 뜻이다.

if (foundUser == null)
{
    loginResult.IsSuccess = false;
    loginResult.Message = "아이디가 올바르지 않습니다.";
    return loginResult;
}

사용자를 찾았다면 비밀번호를 비교한다.

isMatchedPassword =
    loginRequest.Password == foundUser.Password;

비밀번호까지 일치하면 필요한 사용자 정보만 UserInfo로 옮긴다.

userInfo.UserId = foundUser.UserId;
userInfo.UserName = foundUser.UserName;
userInfo.UserGrade = foundUser.UserGrade;

변경 후 로그인 흐름

Home.razor
↓
LoginService
↓
TestUserRepository.FindByUserId()
↓
UserAccount 또는 null 반환
↓
비밀번호 비교
↓
UserInfo 생성
↓
LoginResult 반환

화면에서 보이는 기능은 크게 바뀌지 않았다.

하지만 사용자 데이터를 보관하는 위치와 로그인 판단을 담당하는 위치가 분리됐다.


테스트 결과

예제 계정 두 개로 로그인 흐름을 확인했다.

sample-admin / 1111
→ 로그인 성공
→ 테스트 관리자
→ 관리자

sample-user / 2222
→ 로그인 성공
→ 테스트 사용자
→ 일반 사용자

존재하지 않는 아이디
→ 아이디가 올바르지 않습니다.

존재하는 아이디와 틀린 비밀번호
→ 비밀번호가 올바르지 않습니다.

로그아웃
→ 홈 화면으로 이동
→ 로그아웃 안내 메시지 표시

오늘 배운 점

  • 사용자 데이터 보관과 로그인 판단은 서로 다른 책임이다.
  • UserAccount는 비밀번호를 포함한 로그인 검증용 모델이다.
  • UserInfo는 로그인 이후 사용할 정보만 가진다.
  • 로그인 이후 상태에는 비밀번호를 포함하지 않는 편이 좋다.
  • List<UserAccount>로 여러 사용자 객체를 관리할 수 있다.
  • 생성자는 객체가 만들어질 때 초기 상태를 준비한다.
  • UserAccount?는 검색 결과가 없을 수 있다는 뜻이다.
  • readonly List<T>에서도 기존 목록에 값을 추가할 수 있다.
  • Repository는 데이터 저장 방식과 서비스 로직 사이의 경계를 만든다.
  • 빌드 성공과 경고 없음은 서로 다른 문제이므로 경고도 확인해야 한다.

다음 작업

다음에는 LoginServiceTestUserRepository라는 구체 클래스에 직접 의존하지 않도록 인터페이스를 추가할 예정이다.

이 과정에서 인터페이스, 생성자 주입, DI가 객체를 연결하는 흐름을 더 자세히 살펴볼 것이다.