
지난 글에서는 테스트 사용자 정보를 LoginService에서 분리해 TestUserRepository로 옮겼다.
역할은 다음처럼 나뉘었다.
LoginService
→ 로그인 입력 검증
→ 비밀번호 확인
→ 로그인 결과 생성
TestUserRepository
→ 테스트 사용자 목록 보관
→ 아이디로 사용자 검색
기능은 잘 동작했지만 LoginService를 다시 보니 특정 Repository 클래스에 직접 의존하고 있었다.
private readonly TestUserRepository testUserRepository;
생성자도 TestUserRepository를 직접 받았다.
public LoginService(
TestUserRepository testUserRepository)
{
this.testUserRepository = testUserRepository;
}
현재는 메모리 목록을 사용하는 테스트 저장소뿐이라 문제가 없어 보였다.
하지만 나중에 실제 DB에서 사용자를 조회하는 Repository로 바꾸면 LoginService도 수정해야 한다.
현재
→ TestUserRepository
나중
→ DatabaseUserRepository
LoginService 입장에서 중요한 것은 Repository가 메모리를 사용하는지, SQL Server를 사용하는지, 외부 API를 사용하는지가 아니다.
필요한 기능은 하나다.
아이디를 전달하면 사용자를 찾아준다.
그래서 이 기능의 규격을 인터페이스로 분리해보기로 했다.
목표 구조
기존 구조는 다음과 같았다.
LoginService
↓
TestUserRepository
변경 후에는 다음과 같이 만든다.
LoginService
↓
IUserRepository
↑
TestUserRepository
향후 다른 저장소를 추가하면 이런 형태가 될 수 있다.
IUserRepository
├─ TestUserRepository
└─ DatabaseUserRepository

LoginService는 IUserRepository만 알면 된다.
실제로 어떤 Repository가 사용될지는 DI 설정에서 결정한다.
IUserRepository 만들기
새 인터페이스 파일을 만들었다.
Repositories/IUserRepository.cs
using Study.Web.Models;
namespace Study.Web.Repositories
{
public interface IUserRepository
{
UserAccount? FindByUserId(string userId);
}
}
인터페이스에는 사용자 검색 과정이 들어 있지 않다.
UserAccount? FindByUserId(string userId);
이 코드는 다음 약속만 정의한다.
문자열 아이디를 입력받는다.
↓
찾은 UserAccount를 반환한다.
↓
사용자가 없으면 null을 반환한다.
인터페이스에는 왜 구현 코드가 없을까?
클래스 메서드는 일반적으로 실제 처리 코드를 가진다.
public UserAccount? FindByUserId(string userId)
{
// 사용자 검색 처리
}
인터페이스는 처리 방법을 정하지 않는다.
UserAccount? FindByUserId(string userId);
대신 해당 기능을 구현하려는 클래스가 어떤 메서드를 제공해야 하는지 규격을 정한다.
IUserRepository
→ 사용자 검색 기능의 계약
TestUserRepository
→ 메모리 목록에서 검색하는 실제 구현
Java에서 사용하던 인터페이스와 거의 같은 개념이었다.
public interface UserRepository {
UserAccount findByUserId(String userId);
}
TestUserRepository가 인터페이스 구현하기
기존 클래스 선언은 다음과 같았다.
public class TestUserRepository
이를 다음처럼 변경했다.
public class TestUserRepository : IUserRepository
전체 구조는 이런 형태다.
using Study.Web.Models;
namespace Study.Web.Repositories
{
public class TestUserRepository : IUserRepository
{
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()
{
// 테스트 사용자 추가
}
}
}
: IUserRepository는 어떤 뜻일까?
public class TestUserRepository : IUserRepository
이 코드는 다음 의미다.
TestUserRepository는
IUserRepository에서 정한 기능을 제공한다.
따라서 인터페이스에 선언된 메서드를 반드시 구현해야 한다.
public UserAccount? FindByUserId(string userId)
메서드를 삭제하거나 이름을 다르게 바꾸면 컴파일 오류가 발생한다.
IUserRepository가 FindByUserId를 요구함
↓
TestUserRepository에 해당 메서드가 없음
↓
컴파일 오류
인터페이스가 단순한 설명용 문서가 아니라, 구현 클래스가 지켜야 할 계약이라는 점을 확인할 수 있었다.
LoginService가 인터페이스를 사용하도록 변경하기
기존 필드는 구체 클래스 타입이었다.
private readonly TestUserRepository testUserRepository;
이를 인터페이스 타입으로 바꿨다.
private readonly IUserRepository userRepository;
생성자도 함께 변경했다.
public LoginService(IUserRepository userRepository)
{
this.userRepository = userRepository;
}
사용자를 검색할 때도 인터페이스 필드를 사용한다.
foundUser = userRepository.FindByUserId(
loginRequest.UserId);
이제 LoginService는 실제 Repository 클래스가 무엇인지 모른다.
알고 있는 것은 IUserRepository에 다음 기능이 있다는 사실뿐이다.
FindByUserId()
this.userRepository가 헷갈렸다
생성자에는 같은 이름이 두 번 나온다.
public LoginService(IUserRepository userRepository)
{
this.userRepository = userRepository;
}
처음에는 왼쪽과 오른쪽이 같은 변수처럼 보였다.
하지만 역할이 다르다.
this.userRepository
→ LoginService 클래스가 계속 보관할 필드
userRepository
→ 생성자 호출 시 전달받은 매개변수
따라서 이 코드는 다음 의미다.
생성자로 전달받은 Repository 객체를
LoginService의 필드에 저장한다.
Java에서 자주 사용하던 방식과 동일했다.
public LoginService(UserRepository userRepository) {
this.userRepository = userRepository;
}
Program.cs에서 인터페이스와 구현체 연결하기
LoginService 생성자는 이제 IUserRepository를 요구한다.
public LoginService(IUserRepository userRepository)
하지만 인터페이스는 직접 객체로 만들 수 없다.
new IUserRepository();
어떤 구현 클래스를 사용할지 Program.cs에서 알려줘야 한다.
builder.Services
.AddScoped<IUserRepository, TestUserRepository>();
이 코드는 다음 의미로 이해했다.
IUserRepository 타입이 필요하면
TestUserRepository 객체를 제공한다.
관련 서비스 등록은 다음처럼 구성했다.
builder.Services
.AddScoped<IUserRepository, TestUserRepository>();
builder.Services.AddScoped<LoginService>();
builder.Services.AddScoped<LoginStateService>();
DI가 객체를 만드는 순서
Home.razor는 LoginService를 주입받는다.
@inject LoginService LoginService
Blazor가 LoginService를 만들기 위해 생성자를 확인한다.
public LoginService(IUserRepository userRepository)
생성자에는 IUserRepository가 필요하다.
DI 컨테이너는 Program.cs의 등록 정보를 확인한다.
AddScoped<IUserRepository, TestUserRepository>()
그리고 TestUserRepository 객체를 만들어 LoginService 생성자에 전달한다.
Home.razor가 LoginService 요청
↓
DI가 LoginService 생성자 확인
↓
IUserRepository가 필요함
↓
등록 정보를 확인
↓
TestUserRepository 생성
↓
LoginService 생성자에 전달
↓
완성된 LoginService를 Home.razor에 전달

DI를 사용하지 않고 직접 만든다면 대략 다음 흐름과 같다.
IUserRepository userRepository;
LoginService loginService;
userRepository = new TestUserRepository();
loginService = new LoginService(userRepository);
DI 컨테이너가 이 객체 생성과 연결 과정을 대신 처리해준다.
Dependency Injection과 Dependency Inversion
두 용어의 이름이 비슷해서 처음에는 같은 뜻처럼 느껴졌다.
Dependency Injection
필요한 객체를 클래스 내부에서 직접 만들지 않고 외부에서 전달받는 방식이다.
public LoginService(IUserRepository userRepository)
{
this.userRepository = userRepository;
}
LoginService가 Repository를 직접 new 하지 않음
↓
외부에서 만들어 전달받음
Dependency Inversion
상위 수준의 로직이 구체적인 구현 클래스보다 추상적인 규격에 의존하도록 만드는 원칙이다.
기존 구조:
LoginService
→ TestUserRepository
변경 구조:
LoginService
→ IUserRepository
← TestUserRepository
LoginService는 구체적인 저장 방식보다 사용자 검색이라는 추상적인 기능에 의존한다.
이번 코드에서는 DI를 이용해 의존성 역전 구조를 연결한 셈이다.
TestUserAccount를 UserAccount로 바꾼 이유
처음에는 계정 모델 이름을 TestUserAccount로 만들었다.
public class TestUserAccount
그런데 IUserRepository는 일반적인 사용자 저장소 인터페이스다.
IUserRepository
→ 일반적인 사용자 Repository 규격
TestUserAccount
→ 테스트 전용 계정처럼 보이는 모델
인터페이스가 반환하는 모델까지 테스트 전용 이름인 것은 조금 어색했다.
테스트 여부는 Repository 구현 방식의 특징이다.
TestUserRepository
→ 메모리의 테스트 목록을 사용하는 구현
반면 계정 모델은 저장 방식과 관계없이 사용될 수 있다.
UserAccount
→ 사용자 계정 데이터
그래서 모델 이름은 UserAccount로 일반화하고, 구현체 이름인 TestUserRepository만 유지했다.
나중에 DB Repository로 교체한다면
향후 다음과 같은 클래스를 만들 수 있다.
public class DatabaseUserRepository : IUserRepository
{
public UserAccount? FindByUserId(string userId)
{
// DB 사용자 검색
}
}
그리고 DI 등록만 변경한다.
builder.Services
.AddScoped<IUserRepository,
DatabaseUserRepository>();
LoginService는 수정할 필요가 없다.
TestUserRepository
↓
DatabaseUserRepository
LoginService
→ 변경 없음
이 부분에서 인터페이스를 만든 이유가 조금 더 분명하게 느껴졌다.
변경 후 구조
Home.razor
↓
LoginService
↓
IUserRepository
↓
TestUserRepository
↓
List<UserAccount>
각 역할은 다음과 같다.
Home.razor
→ 화면과 버튼 이벤트
LoginService
→ 로그인 업무 규칙
IUserRepository
→ 사용자 검색 기능의 규격
TestUserRepository
→ 메모리 기반 사용자 검색 구현
UserAccount
→ 로그인 검증용 계정 데이터
UserInfo
→ 로그인 후 사용할 사용자 정보
테스트 결과
기존 로그인 기능이 그대로 유지되는지 확인했다.
sample-admin / 1111
→ 관리자 로그인 성공
sample-user / 2222
→ 일반 사용자 로그인 성공
존재하지 않는 아이디
→ 아이디가 올바르지 않습니다.
틀린 비밀번호
→ 비밀번호가 올바르지 않습니다.
로그아웃
→ 홈 화면 이동
→ 로그아웃 메시지 표시
화면에서 보이는 동작은 같았지만, 내부 의존 관계는 달라졌다.
오늘 배운 점
인터페이스는 기능의 규격과 계약을 정의한다.
인터페이스에는 실제 처리 코드가 없어도 된다.
구현 클래스는 인터페이스에서 요구한 메서드를 제공해야 한다.
LoginService가 구체 Repository가 아니라 인터페이스에 의존하도록 만들 수 있다.생성자 주입은 필요한 객체를 외부에서 전달받는 방식이다.
DI 컨테이너는 객체 생성과 의존 관계 연결을 대신 처리한다.
AddScoped<IUserRepository, TestUserRepository>()는 인터페이스와 구현체를 연결한다.this.userRepository는 클래스 필드이고,userRepository는 생성자 매개변수다.의존성 주입과 의존성 역전은 관련이 있지만 같은 개념은 아니다.
테스트 여부는 Repository 구현체의 특징이지
UserAccount모델의 본질은 아니다.구현체를 교체해도 서비스 로직을 그대로 유지할 수 있는 구조를 만들 수 있다.