ASP.NET Core MVC에서 Serilog AppLogs(Id 없이) 읽기 전용 뷰어 만들기

  • 7 minutes to read

이 문서는 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를 추가하는 것이 장기적으로 가장 안정적입니다.)

더 깊이 공부하고 싶다면
DevLec에서는 실무 중심의 C#, .NET, ASP.NET Core, Blazor, 데이터 액세스 강좌를 단계별로 제공합니다. 현재 수강 가능한 강좌 외에도 더 많은 과정이 준비되어 있습니다.
DevLec.com에서 자세한 커리큘럼을 확인해 보세요.
DevLec 공식 강의
C# Programming
C# 프로그래밍 입문
프로그래밍을 처음 시작하는 입문자를 위한 C# 기본기 완성 과정입니다.
ASP.NET Core 10.0
ASP.NET Core 10.0 시작하기 MVC Fundamentals Part 1 MVC Fundamentals Part 2
웹 애플리케이션의 구조와 MVC 패턴을 ASP.NET Core로 실습하며 익힐 수 있습니다.
Blazor Server
풀스택 웹개발자 과정 Part 1 풀스택 웹개발자 과정 Part 2 풀스택 웹개발자 과정 Part 3
실무에서 바로 활용 가능한 Blazor Server 기반 관리자·포털 프로젝트를 만들어 봅니다.
Data & APIs
Entity Framework Core 시작하기 ADO.NET Fundamentals Blazor Server Fundamentals Minimal APIs
데이터 액세스와 Web API를 함께 이해하면 실무 .NET 백엔드 개발에 큰 도움이 됩니다.
VisualAcademy Docs의 모든 콘텐츠, 이미지, 동영상의 저작권은 박용준에게 있습니다. 저작권법에 의해 보호를 받는 저작물이므로 무단 전재와 복제를 금합니다. 사이트의 콘텐츠를 복제하여 블로그, 웹사이트 등에 게시할 수 없습니다. 단, 링크와 SNS 공유, Youtube 동영상 공유는 허용합니다. www.VisualAcademy.com
박용준 강사의 모든 동영상 강의는 데브렉에서 독점으로 제공됩니다. www.devlec.com