실행 흐름 우회
개요
정의된 실행 흐름이 있음에도 불구하고 검증, 변환, 후처리 같은 중간 단계를 건너뛰는 문제는 시스템의 일관성을 무너뜨리며, 이는 하네스를 통해 차단되어야 한다.
이 문서의 질문
이 문서는 하네스 개요에서 정리한 다섯 공통 패턴 중 contract flattening에 가장 가깝다.
핵심 질문은 다음과 같다.
결과만 맞는 것이 아니라 정의된 실행 절차를 실제로 통과했는가
문제
구조도 지켜졌고 상태도 Store를 통해 바뀐다.
하지만 실제 코드에서는 다음과 같은 우회가 발생한다.
var doc = await _repository.LoadAsync(id);
return doc;또는
await _backendBridge.SaveAsync(request);또는
return new ExportResult
{
Path = outputPath
};겉보기에는 큰 문제가 없어 보인다.
필요한 결과도 나온다.
하지만 이 코드는 공통적으로 같은 문제를 가진다.
결과는 생성되었지만
정의된 실행 절차는 실행되지 않았다
문제의 본질
이 우회는 메서드 몇 개가 빠지는 문제가 아니라,
성공의 기준이 결과 생성으로 축소되는 문제다.
- validation과 normalize가 결과 생성 앞단에서 빠진다.
- state update, event, telemetry 같은 후속 단계가 성공처럼 누락된다.
- 사람과 AI 모두 “가장 빨리 결과를 만드는 호출"을 합법 경로로 오해한다.
목표 구조
핵심은 다음이다.
실행은 함수 호출이 아니라
정의된 절차를 통과하는 과정이어야 한다
사례 규칙 요약
- 외부 요청은 지정된 UseCase 또는 Command Handler를 통해서만 처리
- Repository, Bridge, Engine의 직접 호출로 결과를 반환하는 경로 금지
- validate, normalize, event publish 같은 필수 단계 생략 금지
- 실행 흐름은 단일 진입점으로 고정
자세한 rule/validator 설계 원칙은 규칙 설계와 검증기 설계를 따른다. 이 케이스에서는 위 금지 표면과 합법 경로가 바로 검사 대상이다.
하네스 적용 위치
이 케이스의 하네스는 진입점, 절차 내부, 외부 계층 호출 제한에 놓인다.
진입점 레벨
ViewModel이나 UI는 UseCase 또는 CommandHandler 하나만 호출할 수 있어야 한다.
절차 레벨
validation, normalize, state update, publish 같은 필수 단계는 handler 내부 순서로 고정한다.
검증 레벨
Repository와 Bridge 직접 호출, 필수 단계 누락, handler 바깥 side effect를 analyzer와 빌드 규칙으로 막는다.
실제 구현
핵심 구현은 두 가지다.
- 외부 계층이 반드시 하나의
UseCase또는Handler를 통과하게 만든다. - validate, normalize, state update, publish를 handler 안에 드러내 둔다.
구현 예시 펼치기
단일 진입점으로 절차를 고정한다
이 패턴의 핵심은 요청이 어느 계층에서 시작되든 반드시 하나의 UseCase 또는 Handler를 통과하게 만드는 것이다.
public interface IOpenDocUseCase
{
Task<OpenDocResult> ExecuteAsync(OpenDocCommand command, CancellationToken cancellationToken = default);
}외부 계층은 ExecuteAsync만 호출할 수 있고, repository나 bridge는 더 이상 합법 진입점이 아니다.
필수 단계를 handler 안에 명시적으로 묶는다
public sealed class OpenDocUseCase : IOpenDocUseCase
{
public async Task<OpenDocResult> ExecuteAsync(OpenDocCommand command, CancellationToken cancellationToken = default)
{
_validator.Validate(command);
var normalizedId = NormalizeDocId(command.DocId);
var doc = await _repository.LoadAsync(normalizedId, cancellationToken);
_store.Open(doc);
await _eventPublisher.PublishAsync(new DocOpenedEvent(doc.Id), cancellationToken);
return new OpenDocResult(doc.Id, doc.Content);
}
}여기서 중요한 것은 반환값이 아니라 절차다.
- validate
- normalize
- load
- state update
- event publish
- return
이 순서가 코드에 드러나야 우회도 명확하게 검출된다.
외부 계층의 direct call을 금지한다
잘못된 방향은 ViewModel이나 UI가 repository에서 결과만 바로 가져오는 것이다.
public async Task<string> OpenAsync(string docId)
{
var doc = await _repository.LoadAsync(docId);
return doc.Content;
}값은 맞을 수 있어도 validation, normalization, store update, event publish가 모두 사라진다. 그래서 허용되는 형태는 handler 호출뿐이다.
public async Task<string> OpenAsync(string docId)
{
var result = await _openDocUseCase.ExecuteAsync(new OpenDocCommand(docId));
return result.Content;
}반복되는 흐름은 공용 파이프라인으로 박아둘 수 있다
여러 명령이 같은 순서를 가져야 한다면 base pipeline으로도 고정할 수 있다.
public abstract class CommandHandler<TCommand, TResult>
{
public async Task<TResult> ExecuteAsync(TCommand command, CancellationToken cancellationToken = default)
{
Validate(command);
var normalized = Normalize(command);
var result = await HandleAsync(normalized, cancellationToken);
await AfterSuccessAsync(normalized, result, cancellationToken);
return result;
}
}이렇게 하면 단계를 건너뛰려면 구현 세부가 아니라 파이프라인 자체를 깨야 하므로 우회 비용이 올라간다.
다른 언어에서도 기준은 같다
Rust에서도 repository load나 state update가 제각각 호출되지 않고 같은 절차 안에 묶여야 한다.
pub async fn execute(&mut self, command: OpenDocCommand) -> anyhow::Result<OpenDocResult> {
self.validate(&command)?;
let normalized_id = self.normalize_doc_id(&command.doc_id);
let doc = self.repo.load(&normalized_id).await?;
self.store.open(&doc).await?;
self.events.publish_doc_opened(&doc.id).await?;
Ok(OpenDocResult {
doc_id: doc.id,
content: doc.content,
})
}핵심은 언어가 아니라 “결과를 돌려주는 함수”가 아니라 “필수 단계를 가진 절차”를 합법 경로로 고정하는 것이다.
위반 예시와 실패 결과
다음 코드는 반드시 실패해야 한다.
public async Task OpenAsync(string id)
{
var doc = await _repository.LoadAsync(id);
_docStore.Replace(doc);
}실패 결과:
error ATL301: Type 'Repository' must not be called directly from this layer. Use the designated use case or command handler instead.값이 맞더라도 절차를 건너뛰면 성공으로 취급하지 않아야 한다.
보조 강제 수단
이 케이스는 호출 순서 전체를 추론하려 하기보다, 누가 어떤 역할을 직접 호출할 수 있는지를 먼저 고정하는 편이 안정적이다.
ViewModel과 UI에서 repository direct call을 막는다
Analyzer 예시 펼치기
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public sealed class ExecutionFlowBypassAnalyzer : DiagnosticAnalyzer
{
public override void Initialize(AnalysisContext context)
{
context.RegisterOperationAction(AnalyzeInvocation, OperationKind.Invocation);
}
private static void AnalyzeInvocation(OperationAnalysisContext context)
{
if (context.ContainingSymbol.ContainingType is not INamedTypeSymbol containingType)
return;
if (!containingType.Name.EndsWith("ViewModel"))
return;
var invocation = (IInvocationOperation)context.Operation;
var targetType = invocation.TargetMethod.ContainingType;
if (targetType?.Name.EndsWith("Repository") == true ||
targetType?.Name.EndsWith("Bridge") == true)
{
context.ReportDiagnostic(Diagnostic.Create(Rules.ExecutionFlowBypass, invocation.Syntax.GetLocation(), targetType.Name));
}
}
}이 규칙은 거칠지만 효과적이다. 외부 계층이 handler 대신 하위 계층을 직접 부르는 순간을 빠르게 막아준다.
필수 단계 누락은 handler 내부 규칙으로 따로 잡는다
필요하면 ExecuteAsync 안에 Validate(...), PublishAsync(...), Store.Open(...) 같은 호출이 빠졌는지 별도 규칙으로 확인할 수 있다. 중요한 것은 메서드명보다 책임이다.
- 진입점은 handler/use case
- repository 호출은 handler 하위에서만 허용
- state update와 event publish도 handler 내부 책임
빌드 실패로 고정한다
[*.cs]
dotnet_diagnostic.ATL301.severity = error이제 실행 흐름 우회는 스타일 차이가 아니라 즉시 막히는 구조 위반이 된다.
결과
하네스 적용 이후 변화는 분명하다.
- 모든 요청이 지정된 UseCase 또는 Handler를 통과하며 validation, normalization, state update, event publish가 함께 복원된다.
- 단순히 값이 반환되었다고 성공이 아니라, 정의된 절차를 통과했을 때만 성공이 된다.
- 사람과 AI 모두 하위 계층 직접 호출보다 먼저 합법 진입점과 필수 단계를 찾게 된다.
실무 적용 팁
- 외부 계층에서 보이는 surface를 UseCase 또는 Handler 하나로 좁히면 우회 표면이 크게 줄어든다.
- 절차 단계가 많아질수록 helper로 흩어 놓기보다 handler 내부에서 명시적으로 보이게 두는 편이 낫다.
- 성공 조건은 반환값이 아니라 정의된 단계 통과 여부로 다뤄야 한다.
요약
실행 흐름 우회 문제는 하네스를 통해서만 안정적으로 제어된다.
단일 진입점으로 실행 흐름 고정
UseCase / Command Handler 내부에 필수 절차 명시
상위 계층의 하위 호출 금지
Roslyn Analyzer로 직접 호출 차단
빌드 에러로 강제
이 구조를 통해
시스템은 더 이상 “결과만 맞는 코드"로 움직이지 않고,
정의된 절차를 통과한 실행만 허용하게 된다.