PH pullh
현장 노트 / 라인 현황판 팀이 말하는 C#의 장점
팀 인터뷰 5분 C#

라인 현황판 팀이 말하는 C#의 장점

제조 현장 대시보드를 만들던 팀은 왜 웹 프레임워크보다 C# 도구 체인을 먼저 떠올렸을까. 짧은 인터뷰 형식으로 정리했다.

C# 현장 이야기 커버 이미지

이 팀은 화려한 제품을 만드는 곳이 아닙니다. 분 단위 생산량과 설비 알림이 곧바로 보여야 하는 현장 화면을 책임집니다. 화면 하나가 조립 라인 여섯 개의 상태 타일을 띄우고, 그 타일은 작업자가 다음 행동을 결정하는 근거가 됩니다.

인터뷰를 잡은 계기는 언어 자랑이 아니었습니다. 이 팀은 교대 시간마다 현황판이 멈춘 것처럼 보이는 문제를 며칠 동안 쫓았고, 그 과정에서 처음 세운 가설이 완전히 빗나갔습니다. 그 실패 기록이 오히려 C#으로 현장 시스템을 만든다는 게 무엇인지 잘 보여 준다고 판단했습니다.

이 인터뷰는 여러 현장에서 반복적으로 관찰되는 패턴을 하나의 가상 팀으로 합쳐 재구성한 예시입니다. 등장하는 조직과 수치는 설명을 위한 것이며 특정 회사나 제품의 실측 기록이 아닙니다.

먼저, 이 팀이 만드는 화면

Q. 왜 C#이었나요?

선택이라기보다 이미 깔린 바닥이었습니다. 설비에서 올라오는 데이터를 받는 수집기, 사내 인증, 리포트 배치가 전부 .NET으로 굴러가고 있었습니다. 현황판만 다른 스택으로 만들면 인증 토큰을 다시 붙이고 배포 파이프라인을 하나 더 만들어야 했습니다. 팀이 넷이고 그중 둘이 야간 대기를 도는 상황에서, 운영해야 할 런타임을 늘리지 않는 쪽이 합리적이었습니다.

실제로 도움이 된 건 언어 문법보다 서버에서 화면까지 한 언어로 이어진다는 점이었습니다. 백그라운드 수집 서비스, 실시간 푸시 허브, 화면에 내려보내는 DTO를 같은 프로젝트 안에서 같은 타입으로 다룰 수 있었습니다.

Q. 현장 화면에서 가장 신경 쓰는 건 무엇인가요?

지표가 예쁘게 보이는 것이 아니라, 화면이 거짓말을 하지 않는 것입니다. 사무실 대시보드는 숫자가 5분 늦어도 아무 일이 없습니다. 현장은 다릅니다. 타일이 초록이면 작업자는 그 라인이 지금 돌아간다고 믿고 다음 공정으로 자재를 밀어 넣습니다.

그래서 팀 안에서는 규칙이 하나 있습니다. 데이터를 못 받는 상황과 데이터가 정상인 상황은 절대 같은 색으로 그리지 않는다는 것입니다. 값을 모르면 회색으로 두고 마지막 갱신 시각을 같이 적습니다. 이 규칙이 결국 이번 문제를 잡는 실마리가 됐습니다.

교대 시간마다 멈춰 보이던 타일

Q. 문제를 처음 어떻게 알게 됐나요?

버그 리포트가 아니라 말로 들어왔습니다. 오후 교대 조장이 “현황판이 아직 오전 조 상태를 붙들고 있다”고 했습니다. 3번 라인은 이미 정지해서 사람이 옆에 서 있는데 화면에서는 여전히 가동 중이었습니다. 반대로 2번 라인은 재가동했는데 화면은 정지로 남아 있었습니다.

이상한 점은 화면이 죽어 보이지는 않았다는 것입니다. 시계도 돌아가고, 생산 수량 숫자는 계속 올라갔습니다. 오직 라인 상태 타일만 굳어 있었습니다. 새로고침하면 정상으로 돌아왔고, 그래서 초기에는 사람마다 본 증상이 달랐습니다.

Q. 무엇부터 측정했나요?

먼저 화면이 서버에서 무엇을 언제 받는지를 그대로 찍었습니다. 실시간 푸시 허브 쪽에 연결 상태, 수신 메시지 일련번호, 페이로드 해시를 남기는 로그를 넣고 교대 시간 전후 20분을 그대로 받아 봤습니다. 그리고 같은 시간에 유선으로 붙은 키오스크와 무선 태블릿을 나란히 두고 두 화면을 동시에 기록했습니다.

결과는 예상과 정반대였습니다. 메시지는 2초마다 빠짐없이 도착하고 있었습니다. 다만 내용이 계속 같았습니다.

교대 시각 전후 수신 로그 (키오스크 / 태블릿 동시 기록, 발췌)

[13:58:02] hub=connected id=kiosk-01  seq=8841 bytes=612 sha=9c1f... line3=RUNNING ts=13:57:58Z
[13:58:02] hub=connected id=tab-04    seq=8841 bytes=612 sha=9c1f... line3=RUNNING ts=13:57:58Z
[14:00:04] hub=connected id=kiosk-01  seq=8901 bytes=612 sha=9c1f... line3=RUNNING ts=13:57:58Z
[14:00:04] hub=connected id=tab-04    seq=8901 bytes=612 sha=9c1f... line3=RUNNING ts=13:57:58Z
[14:06:10] hub=connected id=kiosk-01  seq=8988 bytes=612 sha=9c1f... line3=RUNNING ts=13:57:58Z

재연결 횟수(20분)        : 0
수신 메시지 수(20분)     : 600
페이로드가 바뀐 횟수     : 0
DB의 line3 상태 변경 시각: 14:01:12Z (STOPPED)
프로세스 재시작 후 첫 응답: 즉시 STOPPED 반영

Q. 이 표를 보고 무엇을 알았나요?

세 가지가 한 번에 정리됐습니다. 첫째, 연결은 끊긴 적이 없습니다. 둘째, 서버는 계속 무언가를 보내고 있는데 그 내용이 20분 동안 단 한 번도 바뀌지 않았습니다. 셋째, DB에는 14시 01분에 정지 기록이 분명히 들어와 있었습니다. 즉 전달 경로의 문제가 아니라 서버가 만들어 내는 값 자체가 낡아 있었다는 뜻입니다.

그리고 마지막 줄이 결정적이었습니다. 프로세스를 재시작하면 곧바로 정상 값이 나왔습니다. 재시작으로 사라지는 낡음이라면 저장소가 아니라 프로세스가 들고 있는 무언가입니다.

이틀을 쓴 틀린 가설

Q. 처음에는 무엇을 의심했나요?

네트워크였습니다. 현장 공유기는 교대 시간에 사람이 몰리면서 단말이 갑자기 늘고, 실제로 무선 품질이 나빠지는 시간대이기도 합니다. 그래서 "푸시 연결이 조용히 끊기고 클라이언트가 마지막 화면을 그대로 붙들고 있다"는 그림을 그렸습니다. 꽤 그럴듯했습니다. 새로고침하면 낫는다는 증상도 이 가설과 맞아떨어졌습니다.

두 번째 가설은 브라우저였습니다. 태블릿을 세워 두면 화면이 꺼지고 탭이 백그라운드로 밀리면서 타이머가 억제되는 동작은 실제로 존재합니다. 교대 시간에는 태블릿이 거치대에 놓여 있는 시간이 길어지니 이것도 설명이 됐습니다.

Q. 그 가설들은 어떻게 지워졌나요?

대조 실험 하나로 끝났습니다. 유선 랜에 물린 키오스크는 화면 보호기도 없고 탭이 밀릴 일도 없습니다. 그 키오스크와 태블릿을 같은 라인 앞에 나란히 두고 교대 시간을 통과시켰습니다. 두 화면이 똑같이 굳었습니다. 위 로그의 두 번째 열이 그 결과입니다. 같은 seq, 같은 해시, 같은 낡은 타임스탬프였습니다.

여기서 네트워크 가설은 죽었습니다. 유선과 무선이 동시에, 같은 값으로 틀렸다면 문제는 두 단말 아래쪽 공통 지점, 즉 서버입니다. 브라우저 타이머 가설도 같이 죽었습니다. 타이머가 억제됐다면 수신 메시지 수가 줄어야 하는데 20분에 600건, 2초 간격 그대로였습니다.

돌아보면 이 이틀이 아깝지 않았습니다. 다만 순서가 틀렸습니다. 우리는 가장 자주 의심받는 계층부터 팠고, 가장 싸게 확인할 수 있는 실험을 나중에 했습니다. 유선과 무선을 나란히 놓는 데는 10분이면 충분했습니다.

범인은 수명 관리였다

Q. 실제 원인은 무엇이었나요?

2초마다 상태를 밀어 주는 백그라운드 서비스가 생성자에서 조회 담당 객체를 한 번 주입받아 계속 들고 있었습니다. 그 조회 객체는 다시 DB 컨텍스트를 물고 있었고, 결국 프로세스가 뜬 순간 만들어진 컨텍스트 하나가 몇 주 동안 살아 있었습니다. 이 컨텍스트는 변경 추적을 켠 상태였습니다.

변경 추적이 켜져 있으면 같은 기본 키를 다시 조회할 때 DB에서 읽은 새 값 대신 이미 추적 중인 인스턴스를 돌려줍니다. 라인 상태 테이블은 행이 여섯 개뿐이고 상태 컬럼이 갱신될 뿐 새 행이 늘지 않습니다. 그래서 프로세스가 처음 읽은 여섯 개의 값이 영원히 굳었습니다.

생산 수량 타일이 멀쩡했던 이유도 여기서 설명됩니다. 그쪽은 실적 행이 계속 새로 쌓이는 구조라 매번 새로운 키를 읽었고, 추적 캐시를 비켜 갔습니다. 그래서 화면 절반은 살아 있고 절반은 굳은, 사람을 가장 헷갈리게 하는 모양이 나왔습니다.

Q. 왜 하필 교대 시간에 보였나요?

사실 그 타일은 프로세스가 뜬 순간부터 계속 굳어 있었습니다. 다만 라인 상태는 평소에 잘 바뀌지 않습니다. 하루 중 여섯 개 라인의 상태가 한꺼번에 움직이는 시각이 교대 시간이었을 뿐입니다. 증상이 나타난 시각이 원인의 시각이라고 믿은 것이 우리가 이틀을 돌아간 진짜 이유였습니다.

Q. 어떻게 고쳤나요?

조회 객체의 등록 수명을 싱글턴에서 스코프로 되돌리고, 푸시 루프가 매 주기마다 스코프를 직접 열도록 바꿨습니다. 그리고 읽기 전용 조회에는 추적을 끄고, 화면에 내려보내는 시각은 서버 로컬 시간이 아니라 UTC 기준 값으로 고정했습니다. 교대 경계에서 시각 표기가 흔들리는 일을 아예 없애기 위해서였습니다.

Program.cs 등록과 푸시 서비스 (수정 후)

// 이전: 조회 객체가 싱글턴이라 DB 컨텍스트가 프로세스와 함께 늙었습니다.
// builder.Services.AddSingleton<ILineStatusReader, LineStatusReader>();

builder.Services.AddScoped<ILineStatusReader, LineStatusReader>();
builder.Services.AddHostedService<LineStatusPushService>();

public sealed class LineStatusPushService : BackgroundService
{
    private static readonly TimeSpan Interval = TimeSpan.FromSeconds(2);

    private readonly IServiceScopeFactory _scopes;
    private readonly IHubContext<LineStatusHub> _hub;
    private readonly ILogger<LineStatusPushService> _log;

    public LineStatusPushService(
        IServiceScopeFactory scopes,
        IHubContext<LineStatusHub> hub,
        ILogger<LineStatusPushService> log)
        => (_scopes, _hub, _log) = (scopes, hub, log);

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        using var timer = new PeriodicTimer(Interval);

        while (await timer.WaitForNextTickAsync(stoppingToken))
        {
            // 주기마다 새 스코프를 열어야 DB 컨텍스트도 함께 새로 만들어집니다.
            using var scope = _scopes.CreateScope();
            var reader = scope.ServiceProvider.GetRequiredService<ILineStatusReader>();

            try
            {
                IReadOnlyList<LineTileDto> tiles = await reader.ReadTilesAsync(stoppingToken);
                await _hub.Clients.Group("line-status").SendAsync("tiles", tiles, stoppingToken);
            }
            catch (Exception ex) when (ex is not OperationCanceledException)
            {
                // 실패를 조용히 삼키면 화면은 마지막 값을 계속 초록으로 보여 줍니다.
                _log.LogError(ex, "라인 상태 조회 실패");
                await _hub.Clients.Group("line-status").SendAsync("tilesUnavailable", stoppingToken);
            }
        }
    }
}

// 읽기 전용 조회에서 변경 추적을 끄고, 시각은 UTC로 내려보냅니다.
public async Task<IReadOnlyList<LineTileDto>> ReadTilesAsync(CancellationToken ct) =>
    await _db.LineStates
        .AsNoTracking()
        .OrderBy(s => s.LineCode)
        .Select(s => new LineTileDto(
            s.LineCode,
            s.State,
            new DateTimeOffset(s.UpdatedAtUtc, TimeSpan.Zero)))
        .ToListAsync(ct);

Q. 이 문제를 다시 잡아내려면 어떤 장치가 필요했나요?

테스트를 하나 추가했습니다. 같은 서비스 제공자에서 두 번 스코프를 열어 조회했을 때, 중간에 바뀐 값이 두 번째 조회에 반영되는지 확인하는 통합 테스트입니다. 예전 코드로 되돌리면 이 테스트가 곧바로 깨집니다. 수명 설정은 눈으로 리뷰해서 잡기 어려운 종류의 실수라, 이런 식으로 실행되는 규칙으로 남기는 편이 낫습니다. 비동기 반복 작업의 취소와 예외 처리는 C# 비동기 가이드에, 이런 통합 테스트를 짜는 방식은 C# 테스트 가이드에 더 정리해 두었습니다.

이 팀이 다시 적어 둔 것

인터뷰 말미에 팀이 사내 문서에 남긴 문장을 그대로 옮겼습니다. 언어에 대한 이야기가 아니라 운영에 대한 이야기입니다.

  • 증상이 보이는 시각과 원인이 생긴 시각은 다르다. 교대 시간에 보였다고 교대 시간에 생긴 문제가 아니다.
  • 가장 싼 실험을 먼저 한다. 유선 단말과 무선 단말을 나란히 놓는 데 10분, 네트워크 가설을 파는 데 이틀이 걸렸다.
  • “데이터가 안 온다”와 “데이터가 안 바뀐다”는 완전히 다른 문제다. 수신 건수와 페이로드 해시를 함께 남기면 둘을 한 줄로 구분할 수 있다.
  • 오래 사는 객체가 짧게 살아야 할 자원을 붙들고 있는지 본다. 의존성 수명은 성능 설정이 아니라 정확성 설정이다.
  • 화면에 내려보내는 시각은 UTC로 고정하고 표시 단계에서만 지역 시간으로 바꾼다.
“우리가 만든 건 대시보드가 아니라 작업자의 판단 근거입니다. 그래서 이 팀에서 가장 나쁜 버그는 느린 화면이 아니라, 자신 있게 틀린 값을 보여 주는 화면입니다. 이번에 배운 건 그 자신감을 코드 어디에서 만들어 내고 있었는지였습니다.”

Next Read

C# 실무 가이드로 이어서 보기

현장 시스템에서 어려운 부분은 대개 문법이 아니라 수명과 상태입니다. 의존성 수명, 비동기 취소, 통합 테스트를 어떻게 다루는지 가이드에서 이어서 볼 수 있습니다.