ASP.NET Core 4.4 · 4부. 인증·인가와 미들웨어

CORS·로깅·설정 관리

곁들여 갖추어야 할 것들입니다.

예상 학습 시간 20분 난이도 중급
개념 설명

기능은 아니지만 없으면 안 되는 것들

여기까지 만든 것으로 API 는 동작합니다. 그런데 실제로 올리려면 세 가지가 더 필요합니다. 기능이 아니라 앱이 제대로 돌아가는 데 필요한 것들입니다.

  • 설정 — 개발과 운영에서 달라야 하는 값들입니다. 연결 문자열이나 키가 그렇습니다.
  • 로깅 — 무슨 일이 있었는지 남깁니다. 2.5의 traceId 를 찾을 곳이 여기입니다.
  • CORS — 다른 주소의 화면이 이 API 를 부를 수 있게 합니다.
상세 사용법

설정은 겹쳐 읽습니다

한 곳에서만 읽는 것이 아니라 여러 곳을 차례로 읽어 덮어씁니다. 뒤에 읽는 것이 이깁니다.

차례어디무엇에
1appsettings.json어디서나 같은 값
2appsettings.{환경}.json개발·운영이 다른 값
3사용자 비밀개발 기계의 비밀값(저장소에 담기지 않습니다)
4환경 변수운영의 비밀값
5명령줄 인수그때 한 번만 바꿀 값
appsettings.json · appsettings.Development.json
// appsettings.json
{
  "Site": { "Name": "기본 이름", "PageSize": 10, "Debug": false }
}

// appsettings.Development.json — 있는 것만 덮어씁니다
{
  "Site": { "Name": "개발 이름", "Debug": true }
}
읽힌 값
// Development 로 띄웠을 때 {"환경":"Development","이름":"개발 이름","쪽크기":10,"디버그":true} // 환경 변수를 함께 주었을 때 // Site__Name="환경 변수 이름" Site__PageSize=99 {"환경":"Development","이름":"환경 변수 이름","쪽크기":99,"디버그":true}

이 코드는 호스트가 있어야 하므로 브라우저에서 실행할 수 없습니다.

PageSize10으로 남은 것을 보십시오. Development 파일에 없으므로 앞의 값이 그대로입니다. 파일 전체를 갈아 끼우는 것이 아니라 키 하나하나를 덮습니다.

환경 변수는 밑줄 두 개로 계층을 적습니다. Site:NameSite__Name 입니다. 운영 체제에 따라 콜론을 사용할 수 없어 그렇습니다.

형식으로 받아 사용합니다

c["Site:Name"] 처럼 문자열로 읽어도 되지만, 오타가 나면 null 이 돌아올 뿐 오류가 나지 않습니다. 형식에 담아 받는 편이 낫습니다.

// Program.cs — Build() 앞
builder.Services.Configure<SiteOptions>(builder.Configuration.GetSection("Site"));

// 받는 쪽
public class HomeController(IOptions<SiteOptions> site) : Controller {
    public IActionResult Index() => Content(site.Value.Name);
}

이 사이트도 같은 방식입니다. IOptions<DataConfiguration> 처럼 받아 사용합니다.

비밀값은 어느 파일에도 담지 마십시오. 개발 기계에서는 사용자 비밀을 사용합니다. 프로젝트 폴더 밖에 저장되므로 저장소에 담기지 않습니다.

dotnet user-secrets init
dotnet user-secrets set "ConnectionStrings:Default" "Server=…;Password=…"
상세 사용법

로그는 수준으로 걸러집니다

ILogger<T> 를 받아 찍습니다. 수준이 여섯 가지이고, 설정에 적은 것보다 낮은 것은 나가지 않습니다.

Program.cs
app.MapGet("/log", (ILogger<Program> log) => {
    var 이름 = "홍길동";

    log.LogTrace("추적입니다 {Name}", 이름);
    log.LogDebug("디버그입니다 {Name}", 이름);
    log.LogInformation("정보입니다 {Name}", 이름);
    log.LogWarning("경고입니다 {Name}", 이름);
    log.LogError("오류입니다 {Name}", 이름);

    return "찍었습니다";
});
서버 콘솔 (기본 수준 Information)
info: Program[0] 정보입니다 홍길동 warn: Program[0] 경고입니다 홍길동 fail: Program[0] 오류입니다 홍길동

앞의 둘이 나오지 않았습니다. 설정이 Information 이므로 그보다 낮은 Trace·Debug 는 걸러집니다. 수준은 appsettings.json 에서 정하고, 이름별로 다르게 둘 수 있습니다.

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"   // 프레임워크 것은 조용히
    }
  }
}

자리표시자를 사용합니다

"정보입니다 {Name}" 는 문자열 보간이 아닙니다. 앞에 $ 가 없습니다. 값을 따로 넘기면 로그를 받는 쪽이 "메시지" 와 "이름" 을 나누어 담습니다.

보간으로 적으면
log.LogInformation($"정보입니다 {이름}");

// 통째로 글자 하나입니다.
// 이름으로 찾을 수 없습니다.
자리표시자로 적으면
log.LogInformation("정보입니다 {Name}", 이름);

// 메시지 틀과 값이 따로 남습니다.
// Name = "홍길동" 으로 찾습니다.

콘솔에 찍힌 모양은 같습니다. 차이는 기록을 모아 두는 곳에서 드러납니다. 자리표시자로 적으면 "홍길동에게 있었던 일" 을 골라 볼 수 있고, 보간으로 적으면 글자 검색밖에 되지 않습니다.

그리고 수준에 걸려 나가지 않을 때는 조합조차 하지 않습니다. 보간은 그 자리에서 이미 글자를 만들어 버립니다.

상세 사용법

CORS 는 브라우저가 막습니다

https://app.example.com 의 화면이 https://api.example.com 을 부르면 브라우저가 막습니다. 주소가 다르기 때문입니다. 서버가 막는 것이 아닙니다.

Program.cs
builder.Services.AddCors(o => o.AddPolicy("앱", p => p
    .WithOrigins("https://app.example.com")
    .WithMethods("GET", "POST")
    .AllowCredentials()));

var app = builder.Build();
app.UseCors("앱");
받은 응답 머리글
// Origin: https://app.example.com Access-Control-Allow-Origin: https://app.example.com Access-Control-Allow-Credentials: true // Origin: https://evil.example.com (머리글이 없습니다) // OPTIONS + Access-Control-Request-Method: POST 204 No Content Access-Control-Allow-Origin: https://app.example.com Access-Control-Allow-Methods: GET,POST // Origin 없이 (curl) 200 {"값":1}

마지막 두 줄이 이 단원에서 가장 오해가 잦은 자리입니다. 허용하지 않은 출처에도 서버는 200과 본문을 그대로 돌려줍니다. 다만 허락하는 머리글을 붙이지 않으므로 브라우저가 화면에 전달하지 않습니다. curl 로는 그냥 됩니다.

그러므로 CORS 는 보안 장치가 아닙니다. 남이 못 부르게 하려면 4.2·4.3의 인증과 인가로 막아야 합니다. CORS 는 "브라우저야, 이 화면에는 보여 줘도 된다" 고 알리는 것입니다.

세 번째 묶음의 OPTIONS사전 요청 입니다. 브라우저가 본 요청을 보내기 전에 먼저 물어봅니다. 단순한 GET 이 아니거나 머리글을 붙였을 때 그렇습니다.

모두 허용과 자격 증명은 함께 둘 수 없습니다

Program.cs
p.AllowAnyOrigin().AllowCredentials()
기동할 때
Application startup exception System.InvalidOperationException: The CORS protocol does not allow specifying a wildcard (any) origin and credentials at the same time. Configure the CORS policy by listing individual origins if credentials needs to be supported.

막아 둔 까닭이 있습니다. 아무 출처에나 쿠키를 실어 보내도 된다고 하면, 어느 사이트든 남의 쿠키로 이 API 를 부를 수 있게 됩니다. 자격 증명을 함께 보내야 한다면 출처를 하나하나 적어야 합니다.

UseCorsUseAuthorization 보다 앞에 둡니다. 사전 요청에는 자격 증명이 실려 있지 않아, 인가가 먼저 지나면 401 로 막혀 버립니다.

버전 배지

사용자 비밀은 개발 기계에서만

Development

사용자 비밀은 Development 일 때만 읽힙니다. 운영에 올리면 그 값이 없어 앱이 뜨지 않거나 빈 값으로 동작합니다. 운영에서는 환경 변수나 키 보관 서비스를 사용합니다.

이 저장소에도 같은 것이 있습니다. 코드에 없지만 사이트가 도는 데 필요한 값들의 목록과 복구 절차를 SECRETS.md 에 적어 두고, 값 자체는 그 문서에도 적지 않습니다.

실습 문제

직접 해보기

1. 운영에서만 다르게 난이도 하

쪽 크기를 개발에서는 5, 운영에서는 20으로 두려 합니다. 어느 파일에 무엇을 적을지 적어보세요.

겹쳐 읽으므로 공통값을 앞에 두고 다른 것만 뒤에 적습니다.
// appsettings.json — 운영값을 기본으로 둡니다 { "Site": { "PageSize": 20 } } // appsettings.Development.json — 개발에서만 덮습니다 { "Site": { "PageSize": 5 } } // 반대로 두면 운영 파일(appsettings.Production.json)을 따로 // 만들어야 하고, 그것을 빠뜨리면 개발값으로 돕니다.
2. 로그가 너무 많을 때 난이도 중

EF Core 가 SQL 을 모두 찍어 기록이 넘칩니다. 내 코드의 정보는 그대로 두고 EF 것만 줄이려면 어떻게 할지 적어보세요.

수준은 이름별로 정할 수 있습니다. 그 이름은 ILogger<T> 의 T 에서 옵니다.
{ "Logging": { "LogLevel": { "Default": "Information", "Microsoft.EntityFrameworkCore.Database.Command": "Warning" } } } // 이름은 앞에서부터 맞춰 봅니다. Microsoft 라고만 적으면 // Microsoft 로 시작하는 것이 모두 걸립니다.
3. CORS 를 열었는데도 막힙니다 난이도 상

UseCors 를 넣었는데 브라우저 콘솔에 여전히 CORS 오류가 납니다. curl 로는 200이 옵니다. 무엇을 살펴야 할지 세 가지 이상 적어보세요.

curl 이 200을 받는 것은 정상입니다. 브라우저가 보는 것은 머리글이고, 본 요청 전에 사전 요청이 먼저 갑니다.
// 1. 출처가 글자까지 같은가. // https 와 http, 포트 번호, 끝의 빗금까지 봅니다. // "https://app.example.com/" 처럼 빗금이 붙으면 맞지 않습니다. // 2. 미들웨어 순서. // UseCors 가 UseAuthorization 보다 앞에 있는가. // 사전 요청에는 자격 증명이 없어 인가에 먼저 걸리면 401 입니다. // 3. 사전 요청이 통과하는가. // OPTIONS 로 직접 불러 204 와 Allow-Methods 가 오는지 봅니다. // 본 요청이 PUT 인데 WithMethods 에 없으면 여기서 막힙니다. // 4. 붙이는 머리글이 허용 목록에 있는가. // Authorization 을 붙여 보낸다면 WithHeaders 에 적어야 합니다. // curl 이 200 을 받는 것은 정상입니다. CORS 는 서버가 막는 것이 // 아니므로, curl 로는 판정할 수 없습니다. 머리글을 보십시오.
요약
  • 설정은 여러 곳을 겹쳐 읽습니다. 뒤에 읽는 것이 이기고, 없는 키는 앞의 값이 남습니다.
  • 비밀값은 파일에 담지 않습니다. 개발은 사용자 비밀, 운영은 환경 변수입니다.
  • 로그는 $ 없는 자리표시자로 적습니다. 값이 따로 남아 찾을 수 있습니다.
  • 로그 수준은 이름별로 정합니다. 앞에서부터 맞춰 봅니다.
  • CORS 는 브라우저가 막는 것이지 보안 장치가 아닙니다. 서버는 200과 본문을 그대로 돌려줍니다.