실행 흐름 우회
개요
정의된 실행 흐름이 있음에도 불구하고 검증, 변환, 후처리 같은 중간 단계를 건너뛰는 문제는 시스템의 일관성을 무너뜨리며, 이는 하네스를 통해 차단되어야 한다.
이 문서의 질문
이 문서는 harness/core/common-patterns의 다섯 기준 중 직접 경로 우회와 상태 / 의미 우회의 경계에 가장 가깝다.
핵심 질문은 다음과 같다.
결과만 맞는 것이 아니라 정의된 실행 절차를 실제로 통과했는가
문제
구조도 지켜졌고 상태도 Store를 통해 바뀐다.
하지만 실제 코드에서는 다음과 같은 우회가 발생한다.
var doc = await _repository.LoadAsync(id);
return doc;또는
await _backendBridge.SaveAsync(request);또는
return new ExportResult
{
Path = outputPath
};겉보기에는 큰 문제가 없어 보인다.
필요한 결과도 나온다.
하지만 이 코드는 공통적으로 같은 문제를 가진다.
결과는 생성되었지만
정의된 실행 절차는 실행되지 않았다
문제의 본질
이 문제는 단순한 메서드 생략이 아니다.
핵심은 다음이다.
시스템은 “무엇을 하느냐"보다
어떤 순서로 하느냐에 의해 안정성이 결정된다
절차의 평탄화
정상 흐름은 보통 다음처럼 구성된다.
request → validate → normalize → execute → update state → publish event → return result하지만 사람과 AI는 자주 다음처럼 축약한다.
request → execute → return이 차이는 단계 두세 개가 빠진 것처럼 보이지만,
실제로는 시스템의 안전장치가 통째로 사라진다.
중간 단계의 의미 상실
실행 흐름의 각 단계는 단순 장식이 아니다.
validate
→ 입력 보장normalize
→ 내부 형식 통일execute
→ 실제 처리update state
→ 후속 상태 반영publish event
→ 외부 반응 연결
이 중 하나만 빠져도 결과는 불완전해질 수 있다.
특히 AI는 “지금 보이는 목적"만 맞추려 하기 때문에,
후속 단계는 쉽게 생략된다.
성공처럼 보이는 실패
이 케이스가 위험한 이유는 실패가 바로 드러나지 않기 때문이다.
저장은 됨
조회도 됨
UI도 갱신되는 것처럼 보임
하지만 실제로는:
validation 누락
event 누락
cache 갱신 누락
history 누락
telemetry 누락
이런 문제들이 뒤늦게 나타난다.
즉, 이 문제는 에러보다 더 나쁘다.
겉으로는 성공처럼 보이기 때문이다.
AI의 선택 패턴
사람과 AI는 다음 상황에서 실행 흐름을 우회한다.
메서드 체인이 길어 보일 때
중간 단계를 이해하지 못했을 때
빠른 결과를 우선할 때
비슷한 기능이 이미 하위 계층에 있을 때
즉 사람과 AI는 다음 질문으로 코드를 만든다.
“어떻게 가장 빨리 결과를 만들 수 있지?”
하지만 하네스 관점의 질문은 다르다.
“어떤 절차를 반드시 통과해야 하지?”
목표 구조
핵심은 다음이다.
실행은 함수 호출이 아니라
정의된 절차를 통과하는 과정이어야 한다
Rule
외부 요청은 지정된 UseCase 또는 Command Handler를 통해서만 처리
Repository, Bridge, Engine의 직접 호출로 결과를 반환하는 경로 금지
validate, normalize, event publish 같은 필수 단계 생략 금지
실행 흐름은 단일 진입점으로 고정
Validator
다음 조건을 검사한다.
UI 또는 상위 서비스가 UseCase를 거치지 않고 하위 계층 호출 여부
특정 요청 타입이 필수 핸들러를 거치지 않았는지 여부
결과 반환 전에 필수 후처리 호출이 존재하는지 여부
event publish 또는 state update 누락 여부
Execution Loop
코드 생성
실행 흐름 규칙 검사
단계 누락 또는 우회 감지
실패 처리
허용된 진입점 기준으로 재생성
통과할 때까지 반복
이 케이스의 핵심은
“메서드를 많이 부르게 만드는 것”이 아니라
“필수 절차를 빠뜨릴 수 없게 만드는 것”이다.
하네스 적용 위치
이 케이스의 하네스는 진입점, 절차 내부, 외부 계층 호출 제한에 놓인다.
진입점 레벨
ViewModel이나 UI는 UseCase 또는 CommandHandler 하나만 호출할 수 있어야 한다.
절차 레벨
validation, normalize, state update, publish 같은 필수 단계는 handler 내부 순서로 고정한다.
검증 레벨
Repository와 Bridge 직접 호출, 필수 단계 누락, handler 바깥 side effect를 analyzer와 빌드 규칙으로 막는다.
실제 구현
단일 진입점으로 절차를 고정한다
이 패턴의 핵심은 요청이 어느 계층에서 시작되든 반드시 하나의 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을 막는다
[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
telemetry / logging 연결
성공의 의미 회복
단순히 값이 반환되었다고 성공이 아니다.
정의된 절차를 통과했을 때만 성공이 된다.
AI 행동 안정화
이제 사람과 AI는 더 이상 다음을 쉽게 선택할 수 없다.
하위 계층 직접 호출
중간 단계 생략
결과만 반환하는 shortcut
대신:
먼저 진입점을 찾고
그 안에서 정해진 절차를 따르게 된다
실무 적용 팁
- 외부 계층에서 보이는 surface를 UseCase 또는 Handler 하나로 좁히면 우회 표면이 크게 줄어든다.
- 절차 단계가 많아질수록 helper로 흩어 놓기보다 handler 내부에서 명시적으로 보이게 두는 편이 낫다.
- 성공 조건은 반환값이 아니라 정의된 단계 통과 여부로 다뤄야 한다.
핵심 포인트
실행은 결과 생성이 아니라 절차 통과
생략된 단계는 단순 최적화가 아니라 안전장치 제거
UseCase는 편의 래퍼가 아니라 흐름 고정 장치
요약
실행 흐름 우회 문제는 하네스를 통해서만 안정적으로 제어된다.
단일 진입점으로 실행 흐름 고정
UseCase / Command Handler 내부에 필수 절차 명시
상위 계층의 하위 호출 금지
Roslyn Analyzer로 직접 호출 차단
빌드 에러로 강제
이 구조를 통해
시스템은 더 이상 “결과만 맞는 코드"로 움직이지 않고,
정의된 절차를 통과한 실행만 허용하게 된다.