ASP.NET Core Web API 기본 연습과 Basic 인증 사용하기
소개
이 글은 ASP.NET Core Web API를 사용해 직원(Employee) 및 관련 사진(Photo) 데이터를 관리하는 시스템을 구축하는 과정을 설명합니다. 프로젝트 생성부터 CRUD 메서드 작성, 데이터베이스 설정, Swagger 통합, 그리고 CORS 설정까지 전반적인 개발 과정을 다룹니다. 또한 Basic 인증을 적용하여 API 보호 기능을 추가하고, 인증된 사용자의 이메일을 확인하는 기능까지 함께 구현합니다.
이 강의에서 사용한 전체 소스는 현재는 다음 경로에 있습니다. 나중에 Hawaso, DotNetNote, VisualAcademy 등의 기본 강의 소스에 포함될 수도 있습니다.
https://github.com/VisualAcademy/Kodee/tree/main/src/EmployeePicture
목차
- 프로젝트 생성 및 기본 설정
- 모델 및 데이터베이스 설정
- ViewModel 및 Mapster 설정
- Web API 기능 구현
- Swagger 및 HTTP 요청 테스트
- Web API 인증
- 소스 코드 전체 보기
1. 프로젝트 생성 및 기본 설정
1-1 ASP.NET Core Web API 프로젝트 생성
.NET CLI를 사용해 Web API 프로젝트를 생성합니다. 강의 기본 방식은 Visual Studio를 사용하는 방식입니다. 프로젝트 이름은 원하는 이름을 사용하세요. 여기서는 VisualAcademy.ApiService 프로젝트를 기준으로 설명합니다.
dotnet new webapi -o VisualAcademy.ApiService
cd VisualAcademy.ApiService
실제 프로젝트는 단일 Web API 프로젝트만 있는 구조가 아니라, 다음과 같이 여러 프로젝트가 함께 구성된 솔루션 형태로 확장할 수 있습니다.
VisualAcademy.ApiService: Employee-Photo 관리용 Web APIVisualAcademy.ServiceDefaults: 공통 서비스 기본 설정VisualAcademy.AppHost: AppHostVisualAcademy.Web: 웹 프런트엔드 예제VisualAcademy.Tests: 테스트 프로젝트
즉, 단순한 CRUD Web API 예제를 넘어 이후 확장 가능한 구조로 출발할 수 있다는 점이 특징입니다.
참고로, Aspire로 프로젝트를 만들고 AppHost 프로젝트를 시작 프로젝트로 설정 후 실행하면 다음 그림과 같은 .NET Aspire 대시보드가 표시됩니다.
그림: .NET Aspire 대시보드

1-2 OpenAPI 및 Swagger 통합
Program.cs에서 OpenAPI와 Swagger UI를 설정합니다. 이를 통해 API 메타데이터를 자동으로 생성하고 브라우저에서 API를 직접 테스트할 수 있습니다.
if (app.Environment.IsDevelopment())
{
app.MapOpenApi(); // /openapi/{name}.json 엔드포인트 등록
app.UseSwaggerUI(options =>
{
options.SwaggerEndpoint("/openapi/v1.json", "weather api");
});
}
OpenAPI 및 Swagger 설정의 흐름은 다음과 같습니다.
builder.Services.AddOpenApi();- 개발 환경에서
app.MapOpenApi(); app.UseSwaggerUI(...)로 UI 제공
즉, OpenAPI 문서 생성과 Swagger UI 테스트 환경을 동시에 제공하는 구조입니다.
Open API 설정
Program.cs에서는 다음처럼 OpenAPI를 등록합니다.
builder.Services.AddOpenApi();
이 설정은 API 엔드포인트 정보를 자동 문서화하기 위한 기반입니다. 이후 개발 환경에서 MapOpenApi()를 호출하면 /openapi/v1.json 문서를 노출할 수 있고, Swagger UI는 이 JSON을 바탕으로 화면을 구성합니다.
ApiService 프로젝트에 MVC 기능 추가
- AddControllers() 추가
- MapControllers() 추가
여러가지 방법으로 GET 메서드 테스트 및 CORS 설정 후 JavaScript로 조회
코드: EmployeePicture\Kodee\Kodee.Web\wwwroot\employee-get.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Employee Data</title>
</head>
<body>
<h1>Employee Data</h1>
<ul id="employeeList"></ul>
<script>
fetch('https://localhost:7312/api/Employee')
.then(response => response.json())
.then(data => {
const list = document.getElementById('employeeList');
data.forEach(employee => {
const li = document.createElement('li');
li.textContent = employee;
list.appendChild(li);
});
})
.catch(error => console.error('Error:', error));
</script>
</body>
</html>
UseStaticFiles 확장 메서드로 변경
Weatherforecast 관련 AP 삭제 후 Employee API 테스트
2. 모델 및 데이터베이스 설정
ASP.NET Core Web API에서는 데이터를 구조화하고 저장하기 위해 모델 클래스와 데이터베이스 컨텍스트(DbContext)를 설정해야 합니다. 이번 단계에서는 직원(Employee)과 사진(Photo) 간의 관계를 정의하고, Entity Framework Core를 통해 이를 데이터베이스와 연동할 수 있도록 준비합니다.
2-1 Employee 및 Photo 모델 추가
Employee 모델
Employee 클래스는 직원 정보를 나타내는 모델입니다. 기본 키 Id와 함께 FirstName, LastName 속성을 포함하고 있으며, 하나의 직원이 여러 장의 사진을 가질 수 있도록 Photos 컬렉션을 포함합니다.
namespace VisualAcademy.ApiService.Models;
public class Employee
{
public long Id { get; set; }
public string FirstName { get; set; } = string.Empty;
public string LastName { get; set; } = string.Empty;
// Photos와의 관계 설정(일대다)
public ICollection<Photo> Photos { get; set; } = new List<Photo>();
}
이 구조를 통해 Employee 1명과 Photo 여러 개가 연결되는 일대다(One-to-Many) 관계를 자연스럽게 표현할 수 있습니다.
Photo 모델
Photo 클래스는 사진 정보를 표현합니다. 각 사진은 고유한 Id와 FileName 속성을 가지며, 특정 직원(Employee)에 연결될 수 있도록 EmployeeId와 탐색 속성 Employee를 포함합니다.
namespace VisualAcademy.ApiService.Models;
public class Photo
{
public long Id { get; set; }
public string FileName { get; set; } = string.Empty;
// Employee와의 관계 설정(다대일)
public long? EmployeeId { get; set; }
public Employee? Employee { get; set; }
}
이렇게 하면 Photo는 선택적으로 Employee에 연결될 수 있고, EF Core는 이를 외래 키 관계로 인식합니다.
EF Core 설치
Entity Framework Core는 ORM(Object-Relational Mapping) 기술로, 개체 모델을 관계형 데이터베이스와 매핑해줍니다. VisualAcademy.ApiService.csproj에는 다음 패키지를 추가합니다.
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.6">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
프로젝트 전체의 대상 프레임워크와 패키지 버전을 일관되게 맞추는 것이 중요합니다.
EmployeePhotoDbContext 설정
EmployeePhotoDbContext는 DbContext를 상속한 클래스이며, Employee와 Photo 엔터티를 데이터베이스에 매핑하는 역할을 합니다.
using Microsoft.EntityFrameworkCore;
namespace VisualAcademy.ApiService.Models;
// DbContext를 상속한 클래스: EF Core가 사용할 데이터베이스 컨텍스트 정의
public class EmployeePhotoDbContext : DbContext
{
// 매개변수가 없는 기본 생성자 (테스트나 특별한 경우에 사용 가능)
public EmployeePhotoDbContext() : base()
{
}
// DI를 통해 옵션을 받아 사용하는 생성자 (실제 앱 실행 시 주로 사용)
public EmployeePhotoDbContext(DbContextOptions<EmployeePhotoDbContext> options)
: base(options)
{
}
// Employees 테이블에 해당하는 DbSet - 직원 정보를 관리
public DbSet<Employee> Employees { get; set; }
// Photos 테이블에 해당하는 DbSet - 사진 정보를 관리
public DbSet<Photo> Photos { get; set; }
}
이 컨텍스트를 통해 EF Core는 Employees, Photos 테이블을 생성하고, LINQ 기반 CRUD 작업을 수행할 수 있습니다.
ConnectionStrings 설정
appsettings.json의 연결 문자열 설정은 다음과 같습니다.
{
"ConnectionStrings": {
"DefaultConnection": "Server=(localdb)\\mssqllocaldb;Database=EmployeePhoto;Trusted_Connection=True;"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
이 설정은 애플리케이션이 사용할 데이터베이스 연결 문자열을 정의합니다.
"DefaultConnection"은 연결 문자열 이름입니다.Server=(localdb)\\mssqllocaldb는 로컬 개발용 SQL Server 인스턴스를 의미합니다.Database=EmployeePhoto는 사용할 데이터베이스 이름입니다.Trusted_Connection=True는 Windows 인증 사용을 의미합니다.
실제 데이터베이스 생성 단계 (EF Core 마이그레이션 사용)
DbContext 및 데이터베이스 연결 설정
Program.cs에서는 다음과 같이 연결 문자열을 읽고 DbContext를 등록합니다.
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");
builder.Services.AddDbContext<EmployeePhotoDbContext>(options =>
options.UseSqlServer(connectionString));
이 코드는 appsettings.json의 "DefaultConnection" 값을 읽어 EmployeePhotoDbContext를 DI 컨테이너에 등록합니다. 이후 컨트롤러에서 생성자 주입으로 컨텍스트를 받아 사용할 수 있습니다.
1. 패키지 매니저 콘솔(Package Manager Console) 열기
Visual Studio 상단 메뉴에서 다음을 선택합니다.
도구 > NuGet 패키지 관리자 > 패키지 관리자 콘솔
2. 마이그레이션 추가
Add-Migration InitialCreate
이 명령은 Employee, Photo 모델을 기반으로 초기 마이그레이션 파일을 생성합니다.
3. 데이터베이스 생성 및 적용
Update-Database
이 명령은 생성된 마이그레이션을 실제 데이터베이스에 반영하여 EmployeePhoto 데이터베이스와 관련 테이블을 생성합니다.
결과
EmployeePhoto데이터베이스가 생성됩니다.Employees,Photos테이블이 생성됩니다.- LocalDB 환경에서는 SQL Server Object Explorer 또는 SSMS에서 확인할 수 있습니다.
예를 들어 다음 경로에서 확인할 수 있습니다.
(localdb)\mssqllocaldb > Databases > EmployeePhoto > Tables > dbo.Employees, dbo.Photos
변경사항이 있을 경우
모델이 변경되면 다음 명령으로 다시 반영합니다.
Add-Migration AddNewFieldToPhoto
Update-Database
3. ViewModel 및 Mapster 설정
엔터티를 그대로 외부에 노출하지 않고, ViewModel을 통해 필요한 데이터만 주고받는 구조를 유지합니다. 이 과정에서 Mapster를 사용하면 엔터티와 ViewModel 간 변환을 간결하게 처리할 수 있습니다.
3-1 ViewModel 생성
EmployeeViewModel
namespace VisualAcademy.ApiService.ViewModels;
public record EmployeeViewModel(long Id, string FirstName, string LastName);
EmployeeViewModel은 직원의 핵심 정보만 API 요청/응답에 사용하기 위해 정의한 불변 레코드 타입입니다.
PhotoViewModel
namespace VisualAcademy.ApiService.ViewModels;
public record PhotoViewModel(long Id, string FileName, long? EmployeeId);
PhotoViewModel은 사진 ID, 파일명, 연결된 직원 ID만 외부에 노출하는 용도로 사용합니다.
3-2 Mapster 설치 및 설정
Mapster는 개체 간 매핑을 간단하게 해주는 라이브러리입니다. 프로젝트 파일에는 다음과 같이 Mapster가 포함되어 있습니다.
<PackageReference Include="Mapster" Version="10.0.7" />
필요하다면 CLI로도 추가할 수 있습니다.
dotnet add package Mapster
이후 컨트롤러에서는 다음과 같은 방식으로 사용합니다.
var employee = value.Adapt<Employee>();
var response = employee.Adapt<EmployeeViewModel>();
또는 목록 조회 시에는 다음처럼 ProjectToType<T>()를 사용할 수 있습니다.
var employees = await _context.Employees
.ProjectToType<EmployeeViewModel>()
.ToListAsync();
이 방식은 코드 양을 줄여주고, DTO/ViewModel 변환 실수를 줄여준다는 장점이 있습니다.
4. Web API 기능 구현
이제 실제 API 엔드포인트를 구현합니다. EmployeeController는 Authorize 속성으로 보호되며, 일반 CRUD 외에 현재 인증된 사용자의 이메일을 반환하는 API도 함께 구현합니다.
4-1 GET: 모든 직원 조회
[HttpGet]
[ProducesResponseType(typeof(IEnumerable<EmployeeViewModel>), StatusCodes.Status200OK)]
public async Task<IActionResult> Get()
{
var employees = await _context.Employees
.ProjectToType<EmployeeViewModel>()
.ToListAsync();
return Ok(employees);
}
이 메서드는 Employees 테이블에 저장된 전체 직원을 조회한 뒤, EmployeeViewModel 목록으로 변환하여 반환합니다. 수동 Select 방식 대신 Mapster의 ProjectToType을 사용하고 있습니다.
4-2 GET: 특정 직원 조회
[HttpGet("{id}")]
[ProducesResponseType(typeof(EmployeeViewModel), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Get(int id)
{
var employee = await _context.Employees
.Include(e => e.Photos)
.FirstOrDefaultAsync(e => e.Id == id);
if (employee == null)
{
return NotFound();
}
var response = employee.Adapt<EmployeeViewModel>();
return Ok(response);
}
이 메서드는 특정 ID의 직원을 조회합니다. Include(e => e.Photos)를 사용해 관련 사진 정보도 함께 로드합니다. 다만 응답 자체는 EmployeeViewModel로 반환하므로, 현재 버전에서는 사진 목록까지 응답 본문에 포함되지는 않습니다.
4-3 POST: 새로운 직원 추가
[HttpPost]
[ProducesResponseType(typeof(EmployeeViewModel), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> Post([FromBody] EmployeeViewModel value)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
var employee = value.Adapt<Employee>();
_context.Employees.Add(employee);
await _context.SaveChangesAsync();
var response = employee.Adapt<EmployeeViewModel>();
return CreatedAtAction(nameof(Get), new { id = response.Id }, response);
}
이 메서드는 요청 본문으로 받은 직원 정보를 Employee 엔터티로 변환해 저장합니다. 저장 후 생성된 엔터티를 다시 EmployeeViewModel로 변환해 반환하며, CreatedAtAction을 사용해 새 리소스의 조회 경로도 함께 제공합니다.
4-4 PUT: 직원 정보 수정
[HttpPut("{id}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> Put(long id, [FromBody] EmployeeViewModel model)
{
if (!ModelState.IsValid)
return BadRequest(ModelState);
var employee = await _context.Employees.FindAsync(id);
if (employee == null)
return NotFound();
employee.FirstName = model.FirstName;
employee.LastName = model.LastName;
_context.Entry(employee).State = EntityState.Modified;
try
{
await _context.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException)
{
if (!EmployeeExists(id))
return NotFound();
throw;
}
return NoContent();
}
private bool EmployeeExists(long id)
{
return _context.Employees.Any(e => e.Id == id);
}
이 메서드는 기존 직원 정보를 수정합니다.
- 모델 상태 확인
- DB에서 기존 직원 조회
- 없는 경우 404 반환
- 이름/성 수정
EntityState.Modified설정- 저장 중 동시성 예외 처리
- 성공 시
204 NoContent반환
4-5 DELETE: 직원 및 관련 데이터 삭제
[HttpDelete("{id}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Delete(long id)
{
var employee = await _context.Employees
.Include(e => e.Photos)
.FirstOrDefaultAsync(e => e.Id == id);
if (employee == null)
return NotFound();
_context.Photos.RemoveRange(employee.Photos);
_context.Employees.Remove(employee);
await _context.SaveChangesAsync();
return NoContent();
}
이 메서드는 직원을 삭제할 때, 연결된 사진도 함께 삭제합니다.
- 직원과 관련 사진을 함께 조회
- 직원이 없으면 404 반환
- 연결된 사진들을 먼저 제거
- 직원 제거
- 저장
이 방식은 삭제 흐름이 코드에 명확히 드러난다는 장점이 있습니다.
4-6 GET: 현재 로그인 사용자 이메일 조회
현재 인증된 사용자의 이메일을 반환하는 API입니다.
[HttpGet("GetCurrentUserEmail")]
[ProducesResponseType(typeof(string), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public IActionResult GetCurrentUserEmail()
{
var userEmail = User.Identity?.Name;
if (string.IsNullOrEmpty(userEmail))
{
return Unauthorized(new { message = "User is not authenticated." });
}
return Ok(new { email = userEmail });
}
이 메서드는 Basic 인증이 성공했을 때 ClaimsPrincipal에 저장된 사용자 이름을 읽어 반환합니다. 인증이 실제로 정상 동작하는지 빠르게 검증할 수 있는 테스트용 API로도 활용할 수 있습니다.
5. Swagger 및 HTTP 요청 테스트
5-1 Swagger 테스트
Swagger UI를 통해 API 요청을 테스트할 수 있습니다. launchSettings.json에서는 HTTPS 프로필에서 브라우저가 자동 실행되고, launchUrl이 "swagger"로 설정되어 있어 개발 시 곧바로 Swagger 화면에서 테스트할 수 있습니다.
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5395",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "swagger",
"applicationUrl": "https://localhost:7312;http://localhost:5395",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
이 설정을 통해 개발자는 프로젝트 실행 직후 Swagger UI에서 엔드포인트를 확인하고 테스트할 수 있습니다.
5-2 HTTP 요청 샘플
EmployeeControllerTest.http 파일은 일반 CRUD 테스트뿐 아니라 Basic 인증 요청도 함께 포함하고 있습니다.
@HostAddress = https://localhost:7312
###
GET {{HostAddress}}/api/employee
Accept: application/json
###
GET {{HostAddress}}/api/employee/1
Accept: application/json
###
POST {{HostAddress}}/api/employee
Content-Type: application/json
{
"FirstName": "John",
"LastName": "Doe"
}
###
POST {{HostAddress}}/api/employee
Content-Type: application/json
{
"FirstName": "Jane",
"LastName": "Doe"
}
###
PUT {{HostAddress}}/api/employee/3
Content-Type: application/json
{
"Id": 3,
"FirstName": "Alice Updated",
"LastName": "Johnson Updated"
}
###
DELETE {{HostAddress}}/api/employee/3
###
###
GET {{HostAddress}}/api/employee
Accept: application/json
Authorization: Basic YWRtaW5AdmlzdWFsYWNhZGVteS5jb206c2VjdXJlcGFzc3dvcmQ=
###
GET {{HostAddress}}/api/employee/5
Accept: application/json
Authorization: Basic YWRtaW5AdmlzdWFsYWNhZGVteS5jb206c2VjdXJlcGFzc3dvcmQ=
###
GET {{HostAddress}}/api/employee/GetCurrentUserEmail
Accept: application/json
Authorization: Basic YWRtaW5AdmlzdWFsYWNhZGVteS5jb206c2VjdXJlcGFzc3dvcmQ=
###
이 파일을 사용하면 Visual Studio 또는 VS Code의 REST Client 확장에서 API를 쉽게 검증할 수 있습니다.
6. Web API 인증
Web API에 고정된 이메일과 암호를 사용하는 Basic 인증 구현
EmployeeController 전체에 Basic 인증을 적용하면 Employee 관련 API는 인증 없이 접근할 수 없게 됩니다.
고정된 이메일과 비밀번호를 사용하는 Basic 인증은 인증 미들웨어 구조를 이해하고 빠르게 보호된 API를 테스트하기 위한 학습용 구현으로 적합합니다.
6-1 Basic 인증 미들웨어 구현
6-1-1 인증 핸들러 클래스 작성
ASP.NET Core에서는 AuthenticationHandler<TOptions>를 상속하여 커스텀 인증 핸들러를 만들 수 있습니다.
VisualAcademy.ApiService\Security\BasicAuthenticationHandler.cs
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;
using System.Security.Claims;
using System.Text;
using System.Text.Encodings.Web;
namespace VisualAcademy.ApiService.Security;
public class BasicAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
private const string FixedEmail = "admin@visualacademy.com";
private const string FixedPassword = "securepassword";
public BasicAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder) : base(options, logger, encoder)
{
}
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
if (!Request.Headers.ContainsKey("Authorization"))
{
return Task.FromResult(AuthenticateResult.Fail("Authorization header missing."));
}
try
{
var authHeader = Request.Headers["Authorization"].ToString();
if (!authHeader.StartsWith("Basic ", StringComparison.OrdinalIgnoreCase))
{
return Task.FromResult(AuthenticateResult.Fail("Invalid Authorization header."));
}
var encodedCredentials = authHeader.Substring("Basic ".Length).Trim();
var decodedBytes = Convert.FromBase64String(encodedCredentials);
var decodedCredentials = Encoding.UTF8.GetString(decodedBytes);
var credentials = decodedCredentials.Split(':');
if (credentials.Length != 2)
{
return Task.FromResult(AuthenticateResult.Fail("Invalid Authorization header format."));
}
var email = credentials[0];
var password = credentials[1];
if (email != FixedEmail || password != FixedPassword)
{
return Task.FromResult(AuthenticateResult.Fail("Invalid email or password."));
}
var claims = new[] { new Claim(ClaimTypes.Name, email) };
var identity = new ClaimsIdentity(claims, Scheme.Name);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, Scheme.Name);
return Task.FromResult(AuthenticateResult.Success(ticket));
}
catch
{
return Task.FromResult(AuthenticateResult.Fail("Error occurred during authentication."));
}
}
}
이 핸들러의 처리 흐름은 다음과 같습니다.
Authorization헤더 존재 여부 확인"Basic "접두사 확인- Base64 디코딩
email:password형식 분리- 고정된 이메일/비밀번호와 비교
- 일치하면
ClaimsPrincipal생성 후 인증 성공 반환 - 아니면 인증 실패 반환
6-1-2 인증 스키마 등록
Program.cs에서 커스텀 인증 핸들러를 등록합니다.
builder.Services.AddAuthentication("BasicAuthentication")
.AddScheme<AuthenticationSchemeOptions, BasicAuthenticationHandler>("BasicAuthentication", null);
앱 실행 파이프라인에도 인증 미들웨어를 추가해야 합니다.
app.UseAuthentication();
app.UseAuthorization();
이 순서를 지켜야 인증 후 권한 검사가 정상 동작합니다.
6-2 인증 요구 사항 적용
EmployeeController 클래스 전체에 [Authorize]를 적용합니다.
[Authorize]
[Route("api/[controller]")]
[ApiController]
public class EmployeeController : ControllerBase
{
...
}
따라서 다음 API들은 모두 인증이 필요합니다.
GET /api/employeeGET /api/employee/{id}POST /api/employeePUT /api/employee/{id}DELETE /api/employee/{id}GET /api/employee/GetCurrentUserEmail
컨트롤러 전체에 인증을 거는 방식은 민감한 리소스 관리 API를 일관되게 보호할 때 깔끔한 방법입니다.
6-3 Basic 인증용 Authorization 헤더 만들기
예를 들어 다음 문자열을 Base64로 인코딩합니다.
admin@visualacademy.com:securepassword
터미널에서 다음처럼 만들 수 있습니다.
echo -n "admin@visualacademy.com:securepassword" | base64
예제에서 사용한 결과는 다음과 같습니다.
YWRtaW5AdmlzdWFsYWNhZGVteS5jb206c2VjdXJlcGFzc3dvcmQ=
따라서 HTTP 요청 헤더는 이렇게 작성합니다.
Authorization: Basic YWRtaW5AdmlzdWFsYWNhZGVteS5jb206c2VjdXJlcGFzc3dvcmQ=
6-4 인증 테스트 예제
GET 요청 (성공)
GET {{HostAddress}}/api/employee
Authorization: Basic YWRtaW5AdmlzdWFsYWNhZGVteS5jb206c2VjdXJlcGFzc3dvcmQ=
현재 로그인 사용자 이메일 확인
GET {{HostAddress}}/api/employee/GetCurrentUserEmail
Authorization: Basic YWRtaW5AdmlzdWFsYWNhZGVteS5jb206c2VjdXJlcGFzc3dvcmQ=
성공하면 다음과 같은 JSON을 받을 수 있습니다.
{
"email": "admin@visualacademy.com"
}
GET 요청 (실패 예시)
GET {{HostAddress}}/api/employee
Authorization: Basic YWRtaW5AdmlzdWFsYWNhZGVteS5jb206YmFkcGFzc3dvcmQ=
비밀번호가 다르면 인증 실패가 발생합니다.
6-5 주의사항
- 고정된 이메일과 비밀번호는 학습용 또는 테스트용 구현입니다.
- 프로덕션 환경에서는 데이터베이스 기반 사용자 인증 또는 OAuth2/JWT 등으로 확장하는 것이 바람직합니다.
- Basic 인증은 반드시 HTTPS와 함께 사용해야 합니다.
UseHttpsRedirection()을 포함해 개발 단계에서도 HTTPS 사용 습관을 유지할 수 있습니다.
7. 소스 코드 전체 보기
아래는 프로젝트를 재현하는 데 필요한 핵심 파일들입니다.
전체 소스 목록
VisualAcademy.ApiService\VisualAcademy.ApiService.csproj
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\VisualAcademy.ServiceDefaults\VisualAcademy.ServiceDefaults.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Mapster" Version="10.0.7" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.6">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="10.1.7" />
</ItemGroup>
</Project>
VisualAcademy.ApiService\Models\Employee.cs
namespace VisualAcademy.ApiService.Models;
public class Employee
{
public long Id { get; set; }
public string FirstName { get; set; } = string.Empty;
public string LastName { get; set; } = string.Empty;
// Photos와의 관계 설정(일대다)
public ICollection<Photo> Photos { get; set; } = new List<Photo>();
}
VisualAcademy.ApiService\Models\Photo.cs
namespace VisualAcademy.ApiService.Models;
public class Photo
{
public long Id { get; set; }
public string FileName { get; set; } = string.Empty;
// Employee와의 관계 설정(다대일)
public long? EmployeeId { get; set; }
public Employee? Employee { get; set; }
}
VisualAcademy.ApiService\Models\EmployeePhotoDbContext.cs
using Microsoft.EntityFrameworkCore;
namespace VisualAcademy.ApiService.Models;
// DbContext를 상속한 클래스: EF Core가 사용할 데이터베이스 컨텍스트 정의
public class EmployeePhotoDbContext : DbContext
{
// 매개변수가 없는 기본 생성자 (테스트나 특별한 경우에 사용 가능)
public EmployeePhotoDbContext() : base()
{
}
// DI를 통해 옵션을 받아 사용하는 생성자 (실제 앱 실행 시 주로 사용)
public EmployeePhotoDbContext(DbContextOptions<EmployeePhotoDbContext> options)
: base(options)
{
}
// Employees 테이블에 해당하는 DbSet - 직원 정보를 관리
public DbSet<Employee> Employees { get; set; }
// Photos 테이블에 해당하는 DbSet - 사진 정보를 관리
public DbSet<Photo> Photos { get; set; }
}
VisualAcademy.ApiService\ViewModels\EmployeeViewModel.cs
namespace VisualAcademy.ApiService.ViewModels;
public record EmployeeViewModel(long Id, string FirstName, string LastName);
VisualAcademy.ApiService\ViewModels\PhotoViewModel.cs
namespace VisualAcademy.ApiService.ViewModels;
public record PhotoViewModel(long Id, string FileName, long? EmployeeId);
VisualAcademy.ApiService\Security\BasicAuthenticationHandler.cs
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;
using System.Security.Claims;
using System.Text;
using System.Text.Encodings.Web;
namespace VisualAcademy.ApiService.Security;
public class BasicAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
private const string FixedEmail = "admin@visualacademy.com";
private const string FixedPassword = "securepassword";
public BasicAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder) : base(options, logger, encoder)
{
}
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
if (!Request.Headers.ContainsKey("Authorization"))
{
return Task.FromResult(AuthenticateResult.Fail("Authorization header missing."));
}
try
{
var authHeader = Request.Headers["Authorization"].ToString();
if (!authHeader.StartsWith("Basic ", StringComparison.OrdinalIgnoreCase))
{
return Task.FromResult(AuthenticateResult.Fail("Invalid Authorization header."));
}
var encodedCredentials = authHeader.Substring("Basic ".Length).Trim();
var decodedBytes = Convert.FromBase64String(encodedCredentials);
var decodedCredentials = Encoding.UTF8.GetString(decodedBytes);
var credentials = decodedCredentials.Split(':');
if (credentials.Length != 2)
{
return Task.FromResult(AuthenticateResult.Fail("Invalid Authorization header format."));
}
var email = credentials[0];
var password = credentials[1];
if (email != FixedEmail || password != FixedPassword)
{
return Task.FromResult(AuthenticateResult.Fail("Invalid email or password."));
}
var claims = new[] { new Claim(ClaimTypes.Name, email) };
var identity = new ClaimsIdentity(claims, Scheme.Name);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, Scheme.Name);
return Task.FromResult(AuthenticateResult.Success(ticket));
}
catch
{
return Task.FromResult(AuthenticateResult.Fail("Error occurred during authentication."));
}
}
}
VisualAcademy.ApiService\appsettings.json
{
"ConnectionStrings": {
"DefaultConnection": "Server=(localdb)\\mssqllocaldb;Database=EmployeePhoto;Trusted_Connection=True;"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
VisualAcademy.ApiService\appsettings.Development.json
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
VisualAcademy.ApiService\Program.cs
using VisualAcademy.ApiService.Models;
using VisualAcademy.ApiService.Security;
using Microsoft.AspNetCore.Authentication;
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
// Add service defaults & client integrations.
builder.AddServiceDefaults();
// Add services to the container.
builder.Services.AddProblemDetails();
builder.Services.AddControllers();
// Learn more about configuring OpenAPI
builder.Services.AddOpenApi();
builder.Services.AddCors(options =>
{
options.AddPolicy("AllowAllOrigins", policy =>
{
policy.AllowAnyOrigin()
.AllowAnyMethod()
.AllowAnyHeader();
});
});
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");
builder.Services.AddDbContext<EmployeePhotoDbContext>(options =>
options.UseSqlServer(connectionString));
// 인증 스키마 추가
builder.Services.AddAuthentication("BasicAuthentication")
.AddScheme<AuthenticationSchemeOptions, BasicAuthenticationHandler>("BasicAuthentication", null);
var app = builder.Build();
// Configure the HTTP request pipeline.
app.UseExceptionHandler();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi(); // /openapi/{name}.json 엔드포인트 등록
app.UseSwaggerUI(options =>
{
options.SwaggerEndpoint("/openapi/v1.json", "weather api");
});
}
app.UseCors("AllowAllOrigins");
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.MapDefaultEndpoints();
app.MapControllers();
app.Run();
VisualAcademy.ApiService\Controllers\EmployeeController.cs
using VisualAcademy.ApiService.Models;
using VisualAcademy.ApiService.ViewModels;
using Mapster;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace VisualAcademy.ApiService.Controllers;
[Authorize]
[Route("api/[controller]")]
[ApiController]
public class EmployeeController : ControllerBase
{
private readonly EmployeePhotoDbContext _context;
public EmployeeController(EmployeePhotoDbContext context)
{
_context = context;
}
#region GetAll
// GET: api/Employee
// 모든 직원 목록을 조회
[HttpGet]
[ProducesResponseType(typeof(IEnumerable<EmployeeViewModel>), StatusCodes.Status200OK)]
public async Task<IActionResult> Get()
{
var employees = await _context.Employees
.ProjectToType<EmployeeViewModel>()
.ToListAsync();
return Ok(employees);
}
#endregion
#region GetById
// GET api/<EmployeeController>/5
// 특정 ID의 직원 정보를 조회
[HttpGet("{id}")]
[ProducesResponseType(typeof(EmployeeViewModel), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Get(int id)
{
var employee = await _context.Employees
.Include(e => e.Photos)
.FirstOrDefaultAsync(e => e.Id == id);
if (employee == null)
{
return NotFound();
}
var response = employee.Adapt<EmployeeViewModel>();
return Ok(response);
}
#endregion
#region POST
// POST api/<EmployeeController>
// 새로운 직원을 생성
[HttpPost]
[ProducesResponseType(typeof(EmployeeViewModel), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> Post([FromBody] EmployeeViewModel value)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
var employee = value.Adapt<Employee>();
_context.Employees.Add(employee);
await _context.SaveChangesAsync();
var response = employee.Adapt<EmployeeViewModel>();
return CreatedAtAction(nameof(Get), new { id = response.Id }, response);
}
#endregion
#region PUT
// PUT api/<EmployeeController>/5
// 기존 직원 정보를 업데이트
[HttpPut("{id}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> Put(long id, [FromBody] EmployeeViewModel model)
{
if (!ModelState.IsValid)
return BadRequest(ModelState);
var employee = await _context.Employees.FindAsync(id);
if (employee == null)
return NotFound();
employee.FirstName = model.FirstName;
employee.LastName = model.LastName;
_context.Entry(employee).State = EntityState.Modified;
try
{
await _context.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException)
{
if (!EmployeeExists(id))
return NotFound();
throw;
}
return NoContent();
}
private bool EmployeeExists(long id)
{
return _context.Employees.Any(e => e.Id == id);
}
#endregion
#region DELETE
// DELETE api/<EmployeeController>/5
// 특정 직원 데이터를 삭제
[HttpDelete("{id}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Delete(long id)
{
var employee = await _context.Employees
.Include(e => e.Photos)
.FirstOrDefaultAsync(e => e.Id == id);
if (employee == null)
return NotFound();
_context.Photos.RemoveRange(employee.Photos);
_context.Employees.Remove(employee);
await _context.SaveChangesAsync();
return NoContent();
}
#endregion
#region GetCurrentUserEmail
[HttpGet("GetCurrentUserEmail")]
[ProducesResponseType(typeof(string), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public IActionResult GetCurrentUserEmail()
{
var userEmail = User.Identity?.Name;
if (string.IsNullOrEmpty(userEmail))
{
return Unauthorized(new { message = "User is not authenticated." });
}
return Ok(new { email = userEmail });
}
#endregion
}
VisualAcademy.ApiService\Controllers\EmployeeControllerTest.http
@HostAddress = https://localhost:7312
###
GET {{HostAddress}}/api/employee
Accept: application/json
###
GET {{HostAddress}}/api/employee/1
Accept: application/json
###
POST {{HostAddress}}/api/employee
Content-Type: application/json
{
"FirstName": "John",
"LastName": "Doe"
}
###
POST {{HostAddress}}/api/employee
Content-Type: application/json
{
"FirstName": "Jane",
"LastName": "Doe"
}
###
PUT {{HostAddress}}/api/employee/3
Content-Type: application/json
{
"Id": 3,
"FirstName": "Alice Updated",
"LastName": "Johnson Updated"
}
###
DELETE {{HostAddress}}/api/employee/3
###
GET {{HostAddress}}/api/employee
Accept: application/json
Authorization: Basic YWRtaW5AdmlzdWFsYWNhZGVteS5jb206c2VjdXJlcGFzc3dvcmQ=
###
GET {{HostAddress}}/api/employee/5
Accept: application/json
Authorization: Basic YWRtaW5AdmlzdWFsYWNhZGVteS5jb206c2VjdXJlcGFzc3dvcmQ=
###
GET {{HostAddress}}/api/employee/GetCurrentUserEmail
Accept: application/json
Authorization: Basic YWRtaW5AdmlzdWFsYWNhZGVteS5jb206c2VjdXJlcGFzc3dvcmQ=
###
VisualAcademy.ApiService\Properties\launchSettings.json
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5395",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "swagger",
"applicationUrl": "https://localhost:7312;http://localhost:5395",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
마무리
이 프로젝트는 Employee와 Photo 간 관계를 EF Core로 모델링하고, Mapster를 통한 ViewModel 매핑, Swagger/OpenAPI 기반 테스트, 그리고 Basic 인증 기반 보호 API까지 포함하는 구조로 구성됩니다.
즉, 기본적인 CRUD 예제를 유지하면서도 인증과 API 테스트 환경을 함께 갖춘 ASP.NET Core Web API 예제로 활용할 수 있습니다.