ASP.NET Core MVC에서 Serilog AppLogs(Id 없이) 읽기 전용 뷰어 만들기
이 문서는 Serilog.Sinks.MSSqlServer의 기본 테이블 스키마(Id 없음) 로 저장된 AppLogs를, 검색/페이징/최신순 정렬과 함께 목록 + 상세로 보여주는 MVC 읽기 전용 UI를 Azunt 네임스페이스로 구현합니다.
(삭제/수정은 제외 — 운영 로그는 보통 변경하지 않음)
0) 전제
- 테이블 스키마:
CREATE TABLE AppLogs (
Message NVARCHAR(MAX),
MessageTemplate NVARCHAR(MAX),
Level NVARCHAR(128),
TimeStamp DATETIMEOFFSET,
Exception NVARCHAR(MAX),
Properties NVARCHAR(MAX)
);
- 연결 문자열:
DefaultConnection(appsettings.json / Key Vault 등에서 제공) - Serilog로
AppLogs에 로그가 쌓이고 있다고 가정
1) 프로젝트 구조
Azunt/
├─ Controllers/
│ └─ AppLogsController.cs
├─ Data/
│ └─ LogsDbContext.cs
├─ Models/
│ └─ AppLog.cs
├─ Utils/
│ └─ LogKey.cs
└─ Views/
└─ AppLogs/
├─ Index.cshtml
└─ Details.cshtml
2) 모델 (Id 없음) — Models/AppLog.cs
// Models/AppLog.cs
using System;
namespace Azunt.Models
{
// Serilog standard table schema (no Id)
public class AppLog
{
public string? Message { get; set; }
public string? MessageTemplate { get; set; }
public string? Level { get; set; }
public DateTimeOffset? TimeStamp { get; set; }
public string? Exception { get; set; }
public string? Properties { get; set; }
}
}
3) DbContext (Keyless 매핑) — Data/LogsDbContext.cs
// Data/LogsDbContext.cs
using Azunt.Models;
using Microsoft.EntityFrameworkCore;
namespace Azunt.Data
{
public class LogsDbContext : DbContext
{
public LogsDbContext(DbContextOptions<LogsDbContext> options) : base(options) { }
public DbSet<AppLog> AppLogs => Set<AppLog>();
protected override void OnModelCreating(ModelBuilder mb)
{
mb.Entity<AppLog>(e =>
{
e.ToTable("AppLogs"); // dbo.AppLogs
e.HasNoKey(); // ★ No Id key in schema
e.HasIndex(nameof(AppLog.TimeStamp));
e.HasIndex(nameof(AppLog.Level));
});
}
}
}
4) 상세 식별용 해시 키 — Utils/LogKey.cs
Id가 없으므로, 상세 화면 이동 시 동일 레코드 매칭을 위해 내용 기반 해시를 씁니다. URL에는 충돌을 줄이기 위해
ts(ISO8601 UTC)와level도 함께 전송합니다.
// Utils/LogKey.cs
using System.Security.Cryptography;
using System.Text;
using Azunt.Models;
namespace Azunt.Utils
{
public static class LogKey
{
public static string MakeKey(AppLog x)
{
var payload =
$"{x.TimeStamp?.UtcDateTime.ToString("o")}|{x.Level}|{x.Message}|{x.MessageTemplate}|{x.Exception}|{x.Properties}";
using var sha = SHA256.Create();
var bytes = sha.ComputeHash(Encoding.UTF8.GetBytes(payload));
var sb = new StringBuilder(bytes.Length * 2);
foreach (var b in bytes) sb.Append(b.ToString("x2"));
return sb.ToString(); // hex
}
}
}
5) 컨트롤러 — Controllers/AppLogsController.cs
- 검색(q, level), 페이징(page/pageSize), 최신순(TimeStamp DESC)
- 상세는
(ts, level)로 후보를 줄이고 해시로 최종 매칭
// Controllers/AppLogsController.cs
using Azunt.Data;
using Azunt.Models;
using Azunt.Utils;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Globalization;
namespace Azunt.Controllers
{
public class AppLogsController : Controller
{
private readonly LogsDbContext _db;
public AppLogsController(LogsDbContext db) => _db = db;
public async Task<IActionResult> Index(string? q, string? level, int page = 1, int pageSize = 20)
{
page = Math.Max(1, page);
pageSize = Math.Clamp(pageSize, 5, 200);
var query = _db.AppLogs.AsNoTracking().AsQueryable();
if (!string.IsNullOrWhiteSpace(level))
query = query.Where(x => x.Level == level);
if (!string.IsNullOrWhiteSpace(q))
{
q = q.Trim();
query = query.Where(x =>
(x.Message != null && x.Message.Contains(q)) ||
(x.Exception != null && x.Exception.Contains(q)) ||
(x.Properties != null && x.Properties.Contains(q)) ||
(x.Level != null && x.Level.Contains(q)));
}
query = query.OrderByDescending(x => x.TimeStamp);
var total = await query.CountAsync();
var items = await query.Skip((page - 1) * pageSize).Take(pageSize).ToListAsync();
var vm = items.Select(x => new LogListItem
{
TimeStamp = x.TimeStamp,
Level = x.Level,
MessageShort = Trim(x.Message, 120),
Key = LogKey.MakeKey(x)
}).ToList();
ViewBag.Page = page;
ViewBag.PageSize = pageSize;
ViewBag.Total = total;
ViewBag.Query = q;
ViewBag.Level = level;
return View(vm);
static string Trim(string? s, int n)
=> string.IsNullOrEmpty(s) ? "" : (s.Length > n ? s[..n] + "…" : s);
}
// GET: /AppLogs/Details?key=...&ts=...&level=...
public async Task<IActionResult> Details(string key, string ts, string? level)
{
if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(ts))
return BadRequest();
if (!DateTimeOffset.TryParseExact(ts, "o", CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal, out var timestamp))
return BadRequest("Invalid timestamp.");
var candidates = _db.AppLogs.AsNoTracking().Where(x => x.TimeStamp == timestamp);
if (!string.IsNullOrWhiteSpace(level))
candidates = candidates.Where(x => x.Level == level);
var list = await candidates.ToListAsync();
var match = list.FirstOrDefault(x => LogKey.MakeKey(x) == key);
if (match == null) return NotFound();
return View(match);
}
}
public sealed class LogListItem
{
public string? Level { get; set; }
public DateTimeOffset? TimeStamp { get; set; }
public string? MessageShort { get; set; }
public string Key { get; set; } = "";
}
}
6) 뷰 — Views/AppLogs/Index.cshtml
- Message/TimeStamp만 짧게 표시
- 최신순, 검색/레벨 필터, 페이징
@model IEnumerable<Azunt.Controllers.LogListItem>
@using Microsoft.AspNetCore.WebUtilities
@{
ViewData["Title"] = "App Logs";
var page = (int)(ViewBag.Page ?? 1);
var pageSize = (int)(ViewBag.PageSize ?? 20);
var total = (int)(ViewBag.Total ?? 0);
var q = (string)(ViewBag.Query ?? "");
var level = (string)(ViewBag.Level ?? "");
int pageCount = (int)Math.Ceiling(total / (double)pageSize);
string BuildUrl(int p)
{
var dict = new Dictionary<string, string?> {
["q"] = string.IsNullOrWhiteSpace(q) ? null : q,
["level"] = string.IsNullOrWhiteSpace(level) ? null : level,
["pageSize"] = pageSize.ToString(),
["page"] = p.ToString()
};
var url = Url.Action("Index", "AppLogs") ?? "/AppLogs";
return QueryHelpers.AddQueryString(url, dict!);
}
}
<h2 class="h4">App Logs</h2>
<form method="get" class="form-inline" style="gap:.5rem; display:flex; flex-wrap:wrap; align-items:center;">
<input name="q" value="@q" class="form-control" placeholder="Search message/exception/properties..." style="min-width:280px" />
<select name="level" class="form-control">
<option value="">All levels</option>
@foreach (var lv in new[] {"Verbose","Debug","Information","Warning","Error","Fatal"})
{
<option value="@lv" selected="@(lv==level)">@lv</option>
}
</select>
<select name="pageSize" class="form-control">
@foreach (var ps in new[] {10,20,50,100})
{
<option value="@ps" selected="@(ps==pageSize)">@ps / page</option>
}
</select>
<button class="btn btn-primary">Search</button>
</form>
<table class="table table-sm table-hover" style="margin-top:.75rem">
<thead>
<tr>
<th style="width:140px">Time</th>
<th style="width:120px">Level</th>
<th>Message</th>
<th style="width:100px"></th>
</tr>
</thead>
<tbody>
@foreach (var x in Model)
{
var tsIso = x.TimeStamp?.UtcDateTime.ToString("o");
<tr>
<td>@x.TimeStamp?.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss")</td>
<td>@x.Level</td>
<td>@x.MessageShort</td>
<td class="text-right">
<a class="btn btn-sm btn-outline-secondary"
href="@Url.Action("Details", "AppLogs", new { key = x.Key, ts = tsIso, level = x.Level })">
Details
</a>
</td>
</tr>
}
</tbody>
</table>
@if (pageCount > 1)
{
<nav>
<ul class="pagination">
<li class="page-item @(page<=1?"disabled":"")">
<a class="page-link" href="@BuildUrl(page-1)">Prev</a>
</li>
@for (int i = Math.Max(1, page-2); i <= Math.Min(pageCount, page+2); i++)
{
<li class="page-item @(i==page?"active":"")">
<a class="page-link" href="@BuildUrl(i)">@i</a>
</li>
}
<li class="page-item @(page>=pageCount?"disabled":"")">
<a class="page-link" href="@BuildUrl(page+1)">Next</a>
</li>
</ul>
</nav>
}
7) 뷰 — Views/AppLogs/Details.cshtml
@model Azunt.Models.AppLog
@{
ViewData["Title"] = "Log Details";
string Pretty(string? s)
{
if (string.IsNullOrWhiteSpace(s)) return "";
try {
var json = System.Text.Json.JsonDocument.Parse(s).RootElement;
return System.Text.Json.JsonSerializer.Serialize(
json, new System.Text.Json.JsonSerializerOptions{ WriteIndented = true });
} catch { return s; }
}
}
<h2 class="h4">Log Details</h2>
<table class="table table-bordered table-sm">
<tr><th style="width:160px">TimeStamp</th><td>@(Model.TimeStamp?.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss.fff zzz"))</td></tr>
<tr><th>Level</th><td>@Model.Level</td></tr>
<tr><th>Message</th><td><pre class="mb-0" style="white-space:pre-wrap">@Model.Message</pre></td></tr>
<tr><th>MessageTemplate</th><td><pre class="mb-0" style="white-space:pre-wrap">@Model.MessageTemplate</pre></td></tr>
<tr><th>Exception</th><td><pre class="mb-0" style="white-space:pre-wrap">@Model.Exception</pre></td></tr>
<tr><th>Properties</th><td><pre class="mb-0">@Pretty(Model.Properties)</pre></td></tr>
</table>
<a class="btn btn-secondary" href="@Url.Action("Index")">Back</a>
8) DI 등록
Startup.cs를 사용하는 프로젝트라면
// using Microsoft.EntityFrameworkCore;
// using Azunt.Data;
public void ConfigureServices(IServiceCollection services)
{
services.AddControllersWithViews();
services.AddDbContext<Azunt.Data.LogsDbContext>(opt =>
opt.UseSqlServer(Configuration.GetConnectionString("DefaultConnection")));
}
최신 Program.cs (minimal hosting) 환경이라면:
builder.Services.AddDbContext<Azunt.Data.LogsDbContext>(opt =>
opt.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));
9) 권장 인덱스 (대용량 대비)
CREATE INDEX IX_AppLogs_TimeStamp_DESC ON dbo.AppLogs (TimeStamp DESC);
CREATE INDEX IX_AppLogs_Level ON dbo.AppLogs (Level);
10) 보안/운영 팁
- 이 페이지는 읽기 전용이므로, 운영 환경에선
[Authorize(Roles="Administrators")]등을 통해 관리자 전용으로 보호하세요. - 로그가 매우 많다면, 보관 정책(예: 30~90일)과 아카이빙 전략을 반드시 고려하세요.
- 상세 식별은 내용 기반 해시이므로, 동일한 로그가 여러 건 있으면 해시가 같을 수 있습니다. 우리는
TimeStamp + Level로 후보를 줄인 뒤 해시를 대조해 충돌 가능성을 낮췄습니다. (그래도 100% 고유 보장은 Id 추가가 최선)
끝
위 코드 파일들을 추가하고, AppLogs에 데이터가 있다면 /AppLogs로 접속해 최근 로그부터 검색/페이징/상세를 확인할 수 있습니다.
(Id 없이도 동작하지만, 가능한 경우에는 Id BIGINT IDENTITY를 추가하는 것이 장기적으로 가장 안정적입니다.)
추천 자료: ASP.NET Core 인증 및 권한 부여
추천 자료: .NET Blazor에 대해 알아보시겠어요? .NET Blazor 알아보기를 확인해보세요!