
지난 작업에서는 LoginService가 구체적인 TestUserRepository가 아니라 IUserRepository 인터페이스에 의존하도록 구조를 바꿨다.
현재 로그인 호출 흐름은 다음과 같았다.
Home.razor
↓
LoginService.Login()
↓
IUserRepository.FindByUserId()
↓
TestUserRepository
지금 사용하는 TestUserRepository는 메모리 안의 List<UserAccount>를 검색한다.
따라서 검색 결과가 거의 즉시 나오고, 반드시 비동기로 만들 필요는 없다.
하지만 나중에 실제 DB나 외부 API를 연결하면 결과를 바로 받을 수 없다.
💡 실제 DB 비동기 호출 흐름
DB에 사용자 검색 요청 ➡️ DB 서버에서 쿼리 실행 ➡️ 검색 결과 반환
이 과정에는 기다리는 시간이 생긴다.
실제 DB를 연결하기 전에 비동기 호출 구조를 먼저 연습해보기로 했다.
Task가 가장 이해하기 어려웠다
이번 작업에서 가장 낯설었던 것은 Task였다.
처음에는 Task를 새로운 스레드와 비슷한 것으로 생각했다.
하지만 Task는 반드시 새 스레드를 뜻하는 것이 아니었다.
지금 단계에서는 다음처럼 이해했다.
Task
→ 지금 진행 중이거나 나중에 완료될 작업

결과 그 자체가 아니라, 결과를 만들어내는 작업을 표현한다.
동기 메서드는 결과를 바로 반환한다
기존 Repository 메서드는 다음과 같았다.
public UserAccount? FindByUserId(string userId)
호출 코드는 다음과 같다.
UserAccount? foundUser;
foundUser = userRepository.FindByUserId(userId);
호출 흐름은 단순하다.
💡 동기 메서드 호출 흐름
FindByUserId() 호출 ➡️ 목록에서 사용자 검색 ➡️ UserAccount 또는 null 반환 ➡️ foundUser에 저장
반환형은 실제 검색 결과다.
UserAccount?
비동기 메서드는 Task를 반환한다
비동기 형태로 바꾸면서 반환형이 달라졌다.
public Task<UserAccount?> FindByUserIdAsync(
string userId)
기존 반환형:
UserAccount?
변경된 반환형:
Task<UserAccount?>
Task<UserAccount?>를 나눠서 보면 다음과 같다.
Task
→ 사용자 검색 작업
UserAccount?
→ 작업이 끝난 뒤 받을 실제 결과

따라서 다음 변수는 사용자가 아니다.
Task<UserAccount?> userTask;
userTask =
userRepository.FindByUserIdAsync(userId);
userTask는 사용자 검색 결과를 만들어내는 작업이다.
userTask
→ 사용자 검색 작업
foundUser
→ 작업이 끝난 뒤 얻은 사용자
Task에 await를 사용하면 T를 얻는다
다음 코드를 보면 관계가 조금 더 분명하다.
foundUser = await userRepository
.FindByUserIdAsync(userId);
FindByUserIdAsync()의 반환형은 다음과 같다.
Task<UserAccount?>
여기에 await를 사용하면 실제 검색 결과를 얻는다.
UserAccount?
타입 변화를 정리하면 다음과 같다.
Task<UserAccount?>
↓ await
UserAccount?
일반화하면 이렇게 기억할 수 있다.
Task<T>
↓ await
T
로그인 서비스에서도 같은 관계가 적용된다.
Task<LoginResult>
↓ await
LoginResult
택배로 생각해보기
Task<UserAccount>를 택배 배송에 비유해봤다.
UserAccount
→ 실제 택배 물건
Task<UserAccount>
→ 물건이 배송되는 작업
await
→ 배송이 끝난 뒤 물건을 받는 과정
동기 방식은 택배가 올 때까지 현관 앞에 계속 서 있는 것과 비슷하다.
💡 동기 방식의 택배 비유
택배 주문 ➡️ 택배가 올 때까지 그 자리에서 대기 ➡️ 택배 수령 ➡️ 다음 일 진행
비동기 방식은 배송이 끝나면 알려달라고 해두는 방식에 가깝다.
💡 비동기 방식의 택배 비유
택배 주문 ➡️ 배송 완료 시 알려달라고 요청 ➡️ 기다리는 동안 다른 작업 가능 ➡️ 배송 완료 ➡️ 이후 처리 계속
await는 CPU가 다음처럼 계속 상태를 확인한다는 뜻이 아니다.
끝났나?
끝났나?
끝났나?
조금 더 정확하게는 다음 의미에 가깝다.
작업이 끝나면 알려줘.
그때 이 지점부터 처리를 이어갈게.
Task와 Task의 차이
Task
별도의 결과값 없이 작업 완료 여부만 필요한 경우다.
public async Task SaveAsync()
{
await database.SaveChangesAsync();
}
저장 결과로 특정 객체를 받을 필요는 없다.
저장 작업이 끝났다는 사실만 중요하다.
Task
→ 완료될 작업
→ 결과값 없음
Task
작업이 끝난 뒤 결과값도 필요한 경우다.
public Task<UserAccount?> FindByUserIdAsync(
string userId)
Task<UserAccount?>
→ 사용자 검색 작업
→ 완료 후 UserAccount 또는 null 제공
정리하면 다음과 같다.
Task
→ 나중에 완료되는 작업
Task<T>
→ 나중에 완료되고 T 결과도 제공하는 작업
IUserRepository를 비동기로 변경하기
기존 인터페이스는 동기 메서드를 가지고 있었다.
using Study.Web.Models;
namespace Study.Web.Repositories
{
public interface IUserRepository
{
UserAccount? FindByUserId(string userId);
}
}
이를 다음처럼 변경했다.
using Study.Web.Models;
namespace Study.Web.Repositories
{
public interface IUserRepository
{
Task<UserAccount?> FindByUserIdAsync(
string userId);
}
}
메서드 이름 뒤에 Async도 붙였다.
FindByUserId
→ 동기 메서드
FindByUserIdAsync
→ 비동기 메서드
호출하는 사람이 이름만 보고도 비동기 메서드라는 사실을 알 수 있다.
TestUserRepository에서 Task.FromResult 사용하기
TestUserRepository는 실제 DB나 네트워크를 사용하지 않는다.
메모리에 있는 목록을 검색할 뿐이다.
public Task<UserAccount?> FindByUserIdAsync(
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 Task.FromResult(foundUser);
}
검색이 끝난 시점에는 foundUser 결과가 이미 준비되어 있다.
return Task.FromResult(foundUser);
Task.FromResult()는 이미 준비된 값을 완료된 Task 형태로 감싼다.
foundUser
→ 이미 계산된 실제 결과
Task.FromResult(foundUser)
→ 완료된 Task<UserAccount?> 형태
왜 TestUserRepository에는 async와 await가 없을까?
다음 메서드는 Task<UserAccount?>를 반환하지만 async가 붙어 있지 않다.
public Task<UserAccount?> FindByUserIdAsync(
string userId)
이유는 실제로 기다릴 비동기 작업이 없기 때문이다.
DB 조회 없음
파일 읽기 없음
네트워크 요청 없음
외부 API 호출 없음
목록 검색은 이미 동기적으로 끝난다.
따라서 의미 없는 await를 만들기 위해 다음과 같은 코드를 넣지는 않았다.
await Task.Delay(1);
이렇게 하면 비동기처럼 보일 수는 있지만 실제로는 불필요한 지연만 추가된다.
현재 Task.FromResult()는 비동기 성능을 얻기 위한 코드가 아니다.
현재 TestUserRepository
→ 실제 I/O 없음
→ 비동기 성능 효과 없음
목적
→ 나중의 DB Repository와 같은 인터페이스 사용
즉 DB 연결 전 호출 구조를 미리 맞춰보는 중간 단계다.
LoginService도 비동기로 변경하기
Repository가 Task<UserAccount?>를 반환하므로 LoginService는 await를 사용해야 한다.
기존 메서드:
public LoginResult Login(LoginRequest loginRequest)
변경된 메서드:
public async Task<LoginResult> LoginAsync(
LoginRequest loginRequest)
사용자 검색 부분도 바뀌었다.
foundUser = await userRepository
.FindByUserIdAsync(loginRequest.UserId);
FindByUserIdAsync()가 끝나면 foundUser에 UserAccount 또는 null이 들어간다.
async와 await는 역할이 다르다
async와 await는 항상 같이 보여서 같은 역할처럼 느껴졌다.
하지만 둘은 역할이 다르다.
async
public async Task<LoginResult> LoginAsync(
LoginRequest loginRequest)
이 메서드 안에서 비동기 흐름을 다룬다는 표시다.
메서드 내부에서 await를 사용할 수 있게 한다.
await
foundUser = await userRepository
.FindByUserIdAsync(loginRequest.UserId);
Task가 끝난 뒤 실제 결과를 받아오는 부분이다.
정리하면 다음과 같다.
async
→ 이 메서드가 비동기 흐름을 포함함
await
→ Task 완료 후 실제 결과를 받음
LoginService 전체 흐름
using Study.Web.Models;
using Study.Web.Repositories;
namespace Study.Web.Services
{
public class LoginService
{
private readonly IUserRepository userRepository;
public LoginService(
IUserRepository userRepository)
{
this.userRepository = userRepository;
}
public async Task<LoginResult> LoginAsync(
LoginRequest loginRequest)
{
LoginResult loginResult;
UserAccount? foundUser;
UserInfo userInfo;
bool isMatchedPassword;
loginResult = new LoginResult();
foundUser = null;
userInfo = new UserInfo();
isMatchedPassword = false;
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;
}
foundUser = await userRepository
.FindByUserIdAsync(
loginRequest.UserId);
if (foundUser == null)
{
loginResult.IsSuccess = false;
loginResult.Message =
"아이디가 올바르지 않습니다.";
return loginResult;
}
isMatchedPassword =
loginRequest.Password ==
foundUser.Password;
if (isMatchedPassword == false)
{
loginResult.IsSuccess = false;
loginResult.Message =
"비밀번호가 올바르지 않습니다.";
return loginResult;
}
userInfo.UserId = foundUser.UserId;
userInfo.UserName = foundUser.UserName;
userInfo.UserGrade = foundUser.UserGrade;
loginResult.IsSuccess = true;
loginResult.Message =
$"{foundUser.UserId}님 환영합니다.";
loginResult.UserInfo = userInfo;
return loginResult;
}
}
}
Home.razor 이벤트도 비동기로 변경하기
Home.razor는 LoginService.LoginAsync()를 호출한다.
따라서 로그인 버튼 이벤트도 await를 사용해야 한다.
기존 메서드:
private void Login()
변경된 메서드:
private async Task LoginAsync()
로그인 처리 코드는 다음과 같다.
private async Task LoginAsync()
{
LoginResult loginResult;
loginResult =
await LoginService.LoginAsync(loginRequest);
if (loginResult.IsSuccess)
{
LoginStateService.Login(
loginResult.UserInfo);
NavigationManager.NavigateTo("/member");
return;
}
loginMessage = loginResult.Message;
messageClass = "app-message error-message";
}
버튼 이벤트도 변경된 메서드 이름에 맞춘다.
<button class="login-button"
@onclick="LoginAsync">
@loginButtonText
</button>
await 없이 대입하면 왜 오류가 날까?
다음 코드는 작성할 수 없다.
LoginResult loginResult;
loginResult =
LoginService.LoginAsync(loginRequest);
왼쪽 타입은 실제 로그인 결과다.
LoginResult
오른쪽 타입은 로그인 작업이다.
Task<LoginResult>
왼쪽
→ 완성된 LoginResult
오른쪽
→ LoginResult를 만들어내는 작업
타입이 서로 다르기 때문에 직접 대입할 수 없다.
await를 사용하면 작업이 끝난 뒤 실제 결과를 얻는다.
loginResult =
await LoginService.LoginAsync(loginRequest);
Task<LoginResult>
↓ await
LoginResult
비동기 호출은 위쪽으로 전파된다
Repository가 비동기 메서드로 바뀌면 이를 호출하는 LoginService도 비동기가 된다.
LoginService가 비동기가 되면 이를 호출하는 Home.razor 이벤트도 비동기가 된다.
IUserRepository
Task<UserAccount?>
↑ await
LoginService
Task<LoginResult>
↑ await
Home.razor
Task
아래 계층의 비동기 결과를 사용해야 하는 위 계층도 함께 async와 await를 사용하게 된다.
이를 비동기 호출이 위쪽으로 전파된다고 이해했다.
async void 대신 async Task 사용하기
Blazor 이벤트 메서드를 다음처럼 작성하지 않았다.
private async void LoginAsync()
대신 Task를 반환했다.
private async Task LoginAsync()
async void는 호출하는 쪽에서 작업 완료 여부를 확인하기 어렵고, 발생한 예외도 정상적으로 추적하기 어렵다.
async void
→ 완료 상태 추적이 어려움
→ 예외 처리도 어려움
async Task
→ 완료 상태 추적 가능
→ Blazor가 이벤트 예외를 처리할 수 있음
따라서 비동기 이벤트 핸들러는 특별한 경우가 아니라면 async Task 형태로 작성하는 편이 좋다.
비동기라고 새 스레드가 생기는 것은 아니다
처음에는 async를 붙이면 자동으로 별도 스레드가 만들어진다고 생각했다.
하지만 이번 비동기의 핵심은 여러 계산을 동시에 실행하는 것이 아니다.
DB나 네트워크처럼 기다리는 시간이 있는 작업에서 실행 흐름을 불필요하게 붙잡지 않는 데 있다.
CPU 계산을 병렬로 실행
→ 이번 비동기의 핵심이 아님
DB 응답을 기다리는 동안
실행 흐름을 효율적으로 관리
→ 이번 비동기의 핵심
Task가 있다고 해서 항상 새로운 스레드가 생성되는 것은 아니다.
실제 DB Repository에서는 어떻게 달라질까?
현재 테스트 Repository는 다음처럼 이미 계산된 값을 Task로 감싼다.
return Task.FromResult(foundUser);
실제 DB Repository에서는 다음과 같은 형태가 될 수 있다.
public async Task<UserAccount?>
FindByUserIdAsync(string userId)
{
UserAccount? foundUser;
foundUser = await dbContext.UserAccounts
.FirstOrDefaultAsync(
user => user.UserId == userId);
return foundUser;
}
여기서는 실제로 SQL을 실행하고 DB 응답을 기다린다.
이때 Task와 await가 본래 목적대로 사용된다.
변경 후 전체 흐름
로그인 버튼 클릭
↓
Home.LoginAsync()
↓
await LoginService.LoginAsync()
↓
await IUserRepository.FindByUserIdAsync()
↓
TestUserRepository 사용자 검색
↓
Task<UserAccount?> 반환
↓
LoginService가 LoginResult 생성
↓
Task<LoginResult> 반환
↓
Home.razor에서 결과 처리
테스트 결과
비동기 구조로 변경한 뒤 기존 기능이 그대로 동작하는지 확인했다.
sample-admin / 1111
→ 관리자 로그인 성공
sample-user / 2222
→ 일반 사용자 로그인 성공
존재하지 않는 아이디
→ 아이디가 올바르지 않습니다.
틀린 비밀번호
→ 비밀번호가 올바르지 않습니다.
로그아웃
→ 홈 화면 이동
→ 로그아웃 메시지 표시
현재는 메모리 목록을 사용하기 때문에 화면에서 체감되는 속도 차이는 없다.
오늘 배운 점
Task는 결과 자체가 아니라 완료될 작업을 나타낸다.Task<T>는 작업 완료 후T결과를 제공한다.await Task<T>를 사용하면 실제T결과를 얻는다.async는 메서드가 비동기 흐름을 포함한다는 표시다.await는 Task 완료 후 결과를 받아 다음 코드를 이어간다.Repository가 비동기로 바뀌면 Service와 Razor 이벤트까지 비동기 호출이 전파된다.
Task.FromResult()는 이미 준비된 결과를 완료된 Task 형태로 감싼다.현재 테스트 Repository에는 실제 I/O가 없으므로 비동기 성능 효과는 없다.
비동기 이벤트는
async void보다async Task형태로 작성하는 편이 좋다.async를 사용한다고 자동으로 새로운 스레드가 만들어지는 것은 아니다.실제 DB 연결에서는 현재의 비동기 Repository 구조를 그대로 활용할 수 있다.