Delphi와 C++Builder용 네이티브 Excel 스프레드시트 컴포넌트인 HotXLS가 2026년 9월에 서로 관련된 AGGREGATE 수정 두 건을 내놓았습니다. 버전 2.382.0은 옵션 인자를 바로잡아 코드 1/3/5/7이 숨겨진 행을 무시하고, 2/3/6/7이 오류를 무시하며, 0부터 3까지가 중첩된 SUBTOTAL과 AGGREGATE 셀을 무시하도록 만들었습니다. Microsoft가 문서에 적어 둔 그대로입니다. 이어서 버전 2.382.3은 그 선택 플래그들이 함수가 참조하는 바로 그 셀들의 평가로 새어 들어가는 것을 막았습니다. 첫 번째 결함은 표를 옮겨 적을 때 늘 그렇듯 민망합니다. 비트 위치가 뒤바뀌어 있어서 0이 아닌 옵션 코드를 쓴 모든 수식이 작성자가 요청하지도 않은 정책을 받았습니다. 두 번째가 더 흥미로운데, 일시적인 필드로 재귀 순회에 문맥을 전달하는 모든 평가기에서 만나게 될 모양이기 때문입니다. 바깥쪽 집계가 플래그를 세우고 범위를 순회하다가, 아직 계산되지 않은 수식을 가진 셀을 끌어옵니다. 그 수식이 같은 계산기에서 실행되고, 같은 플래그가 세워진 것을 보고, 조용히 엉뚱한 행을 집계해서, 수식 텍스트만 봐서는 아무도 설명할 수 없는 크기만큼 어긋난 숫자를 만들어 냅니다
AGGREGATE 옵션 0부터 7은 실제로 무엇을 선택할까요?
AGGREGATE의 옵션 인자는 3비트 행렬이고, 세 비트는 서로 독립적입니다. 비트 0(값 1)은 숨겨진 행을 무시한다는 뜻이고, 비트 1(값 2)은 오류 값을 무시한다는 뜻이며, 비트 2(값 4)는 중첩된 SUBTOTAL과 AGGREGATE 셀을 무시하는 것을 그만두라는 뜻입니다. 낮은 코드에서는 그런 셀을 건너뛰는 것이 기본값이기 때문입니다. 여기서 뒤집어 이해하기 쉬운 점이 두 가지 있습니다. 숨겨진 행 비트는 중간 비트가 아니라 낮은 비트이므로, AGGREGATE(9,1,...)이 필터 합계 형태이고 AGGREGATE(9,2,...)가 오류 관용 형태입니다. 그리고 중첩 집계 정책은 나머지 둘에 대해 반대로 되어 있습니다. 자기 수식이 SUBTOTAL이나 AGGREGATE인 셀을 평범한 값으로 취급하는 것은 코드 4부터 7까지뿐입니다. ECMA-376 Part 1 §18.17.7은 SUBTOTAL을 코드 1-11과 101-111에 걸친 같은 숨겨진 행 포함/제외 구분으로 정의하고, OOXML 파일에 _xlfn. 접두사로 저장되는 AGGREGATE가 그 구분을 옵션 인자로 일반화합니다. 그래서 Microsoft가 AGGREGATE 함수에 대해 공개한 표는 편의가 아니라 엔진이 충족해야 할 계약입니다
| 옵션 | 숨겨진 행 | 오류 값 | 중첩 SUBTOTAL / AGGREGATE |
|---|---|---|---|
| 0 | 포함 | 전파 | 무시 |
| 1 | 무시 | 전파 | 무시 |
| 2 | 포함 | 무시 | 무시 |
| 3 | 무시 | 무시 | 무시 |
| 4 | 포함 | 전파 | 포함 |
| 5 | 무시 | 전파 | 포함 |
| 6 | 포함 | 무시 | 포함 |
| 7 | 무시 | 무시 | 포함 |
HotXLS는 AGGREGATE 옵션을 왜 뒤집어 구현했을까요?
원래의 TXLSCalculator.CalcAggregateFunc가 표 자체가 아니라 표를 풀어 쓴 설명에서 작성되었기 때문입니다. 이 코드는 ignoreErrors := (optCode >= 4) and (optCode <= 7)로 계산하고 코드 2, 3, 6, 7에 숨겨진 행 게이트를 세웠으며, 중첩 집계 정책은 아예 구현되지 않았습니다. SUBTOTAL과 AGGREGATE의 숨겨진 행을 다룬 이전 글이 그 공백을 열린 한계로 적어 두고 당시 배포된 옛 매핑을 설명했는데, 그 설명은 코드에 대해서는 정확했고 Excel에 대해서는 틀렸습니다. 그리고 오랫동안 아무도 눈치채지 못했는데, 대부분의 사람이 조합해서 쓰는 두 정책, 즉 숨김과 오류가 두 표 모두에서 코드 3과 7에 떨어지기 때문입니다. 비트 하나짜리 코드만이 이 뒤바뀜을 드러냈습니다. AGGREGATE(9,1,A1:A4)는 필터링되지 않은 합계를 반환했고, AGGREGATE(9,2,...)는 숨겨진 행을 건너뛰면서도 #DIV/0!을 계속 전파했습니다. 결함은 고객 파일이 아니라 lxCalc.pas의 정적 리뷰에서 드러나 프로젝트 알려진 문제 등록부에 HXLS-008로 기록되었고, 이는 비트 하나짜리 코드가 실제 업무용 워크북에서 얼마나 드물게 나타나는지를 말해 줍니다. 버전 2.382.0은 디코딩을 세 가지 집합 소속 검사로 다시 쓰고, 워크북이 TXLSIsRowHidden과 함께 제공하는 새 TXLSIsSubtotalCell 콜백을 통해 중첩 정책용 게이트를 하나 더 추가했습니다
// TXLSCalculator.CalcAggregateFunc, v2.382.3 형태
if (optCode < 0) or (optCode > 7) then
begin
Result := lxErrorValue; // Excel은 0..7 밖의 코드를 거부합니다
Exit;
end;
ignoreErrors := optCode in [2, 3, 6, 7];
prevIgnoreHidden := FIgnoreHiddenRows;
prevIgnoreSubtotal := FIgnoreSubtotalCells;
FIgnoreHiddenRows := (optCode in [1, 3, 5, 7]) and Assigned(FIsRowHidden);
FIgnoreSubtotalCells := (optCode in [0, 1, 2, 3]) and Assigned(FIsSubtotalCell);
try
// ... function_num을 내부 iftab에 매핑하고 ref1..refN을 순회합니다 ...
finally
FIgnoreHiddenRows := prevIgnoreHidden;
FIgnoreSubtotalCells := prevIgnoreSubtotal;
end;
두 플래그가 옵션이 요구할 때만 설정되는 것이 아니라 무조건 대입된다는 점을 눈여겨보십시오. v2.382.0 버전은 여전히 if ... then FIgnoreHiddenRows := True를 썼기 때문에, SUBTOTAL(109, ...) 안에 중첩된 코드 4짜리 AGGREGATE가 바깥의 숨겨진 행 게이트를 지우지 않고 물려받았습니다. 진입 시 디코딩된 값을 대입하고 finally 블록에서 이전 값을 복원하면, 각 AGGREGATE 호출이 자기 순회가 진행되는 동안만 자기 정책을 소유하고 그 이상은 갖지 않습니다. 버전 2.382.0은 배열 형태도 정직하게 만들었습니다. 인자가 1차원이나 2차원 Variant 배열로 평가되면 CalcAggregateFunc가 이제 모든 원소를 순회하며 원소마다 오류 정책을 적용합니다. 예전 코드는 NaN double인지만 검사하고 그 밖에는 배열 전체를 ExcelSum에 넘겼습니다
바깥쪽 AGGREGATE가 참조하는 수식으로 왜 새어 들어갈까요?
FIgnoreHiddenRows와 FIgnoreSubtotalCells가 계산기의 필드이고, 계산기는 한 번의 재계산 동안 평가되는 모든 수식이 공유하기 때문입니다. 게이트는 여섯 개의 셀 순회 루프가 모든 시그니처에 파라미터를 끼워 넣지 않고도 이를 참조할 수 있도록 임시 필드로 설계되었고, 게이트가 세워져 있는 동안 실행되는 모든 것이 그 게이트를 세운 집계에 속한다면 그 설계는 건전합니다. 그 가정이 한 지점에서 깨집니다. FGetValue입니다. 순회자가 워크북에 셀 값을 요청했는데 그 셀이 캐시된 결과가 없는 수식을 들고 있으면, 워크북은 그 자리에서 같은 TXLSCalculator 위에서, 바깥 게이트가 그대로 세워진 채로 수식을 컴파일하고 평가합니다. HotXLS.WorkbookApiTests.pas의 회귀 픽스처가 네 셀로 이 실패를 보여 줍니다. A1은 10, A2는 숨겨진 행에서 20, A3은 =1/0, A4는 =SUBTOTAL(9,A1:A2)이고 올바른 값은 30입니다. 이제 =AGGREGATE(9,7,A1:A4)를 평가해 봅시다. 숨겨진 행을 무시하고, 오류를 무시하고, 중첩된 소계를 값으로 셉니다. Excel은 10 + 30 = 40을 반환합니다. A4가 캐시되지 않은 상태에서 2.382.3 이전 엔진은 숨겨진 행 게이트를 세우고 A4까지 순회하다가 그 평가를 유발했고, 코드 9의 CalcSubtotalFunc가 세워진 게이트를 물려받았습니다. 이 함수는 코드 101부터 111까지에만 플래그를 세우고 결코 지우지 않기 때문입니다. A4는 30이 아니라 10으로 평가되었고, 바깥 합계는 20으로 돌아왔습니다. 잘못된 숫자를 만들어 낸 경로에서 두 수식 중 어느 것도 숨겨진 행을 언급하지 않습니다
중첩 집계 게이트는 반대 방향으로 같은 방식으로 새어 나갔습니다. 코드 0부터 3까지에서는 FIgnoreSubtotalCells가 세워지고 GetValueItemRange의 일반 범위 순회자가 이를 존중하므로, 수식이 =SUM(B1:B3)인 참조 대상이 조용히 B2를 떨어뜨리는데, 하필 B2가 SUBTOTAL을 담고 있었다면 그렇게 됩니다. 더 나쁜 것은 CalcSubtotalFunc가 종료 시 이전 값을 복원하는 대신 FIgnoreSubtotalCells를 False로 초기화한다는 점입니다. 그래서 순회 도중 캐시 없는 SUBTOTAL 참조 대상에 도달하면 그 뒤의 모든 셀에서 바깥 게이트가 해제되었습니다. 프로젝트 알려진 문제 등록부는 이것을 HXLS-008 아래 중첩 선택 상태 누수로 분류하는데, 이 버그 부류에 딱 맞는 이름입니다. 자기를 세운 프레임에서는 맞고 자기를 물려받는 모든 프레임에서는 틀린 전역 임시 플래그입니다
AggregateGetCellValue와 AggregateGetItemValue가 순회를 격리하는 방식
v2.382.3의 수정은 AGGREGATE가 스스로 계산하지 않은 값을 읽는 모든 지점에 경계를 둡니다. TXLSCalculator.AggregateGetCellValue가 원시 FGetValue 호출을 감쌉니다. 두 플래그를 저장하고, 지우고, 가져오기를 수행한 뒤 finally 블록에서 복원합니다. 바깥 집계는 방금 가져온 셀에 대해 여전히 자기 정책을 적용합니다. 숨겨진 행과 중첩 셀 검사가 가져오기 주위의 순회자에서 일어나기 때문입니다. 하지만 참조 수식 자체는 아무 정책 없이 실행되고, Excel이 하는 일이 그것입니다
function TXLSCalculator.AggregateGetCellValue(SheetIndex, Row, Col: Integer;
var Value: Variant; var OutOfRange: Boolean): Integer;
var
Hidden, Nested: Boolean;
begin
Hidden := FIgnoreHiddenRows;
Nested := FIgnoreSubtotalCells;
FIgnoreHiddenRows := False; // 참조 수식은 자기 정책을 스스로 소유합니다
FIgnoreSubtotalCells := False;
try
Result := FGetValue(SheetIndex, Row, Col, Value, OutOfRange);
finally
FIgnoreHiddenRows := Hidden;
FIgnoreSubtotalCells := Nested;
end;
end;
AggregateGetItemValue는 범위가 아닌 인자에 대해 같은 일을 하고, 플래그를 지우는 것보다 더 많은 일을 해야 합니다. A1:A4/(B1:B4-20) 같은 인자는 원소 모양이 살아남아야 하는 계산된 배열이기 때문입니다. 이 래퍼는 평범한 범위를 AggregateGetCellValue를 통해 2차원 Variant 배열로 구체화하면서, 오류 코드를 반환한 셀을 VarAsError로 매핑해 원소마다 오류 정책을 계속 적용할 수 있게 하고, 이항·단항 연산자 노드(SA_ADD, SA_DIV, SA_UNARMINUS 등)를 ApplyArrayBinaryOp와 ApplyArrayUnaryOp로 재귀하며, 그 밖의 것은 평소의 GetValueItem으로 넘어갑니다. 구체화 앞에는 가드가 두 개 있습니다. EffectiveFormulaArrayMemoryLimit보다 큰 범위는 lxErrorResourceLimit를 반환하고, 여러 시트에 걸치거나 뒤집힌 범위는 #VALUE!를 반환합니다. 리소스 한계 코드는 옵션 2/3/6/7에서도 의도적으로 무시 가능한 셀 오류로 취급되지 않습니다. 사용자가 #N/A를 건너뛰라고 했다고 해서 엔진이 자기 메모리 부족 신호를 삼키면 거짓말이기 때문입니다. 세 AGGREGATE 순회자 모두, 즉 SUM 계열의 AggregateCollectRange, STDEV와 VAR과 PRODUCT의 AggregateReduceVariance, MEDIAN과 분위 형태의 AggregateReduceWithK가 FGetValue와 GetValueItem에서 두 래퍼로 바뀌었고, 각각 FIsSubtotalCell을 통한 중첩 셀 검사를 얻었습니다
오류를 무시하지 않을 때 AGGREGATE는 어떤 오류를 반환할까요?
v2.382.3부터는 원래의 오류입니다. 버전 2.382.0은 오류 셀을 올바르게 감지했지만 그 모두를 lxErrorValue로 뭉개 버려서, #DIV/0! 셀에 대한 AGGREGATE(9,4,A1:A3)가 #VALUE!를 반환했습니다. Excel은 마주친 첫 오류를 그대로 전파합니다. 대체 헬퍼 AggregateErrorCode는 Variant가 진짜 varError이든 일곱 가지 오류 문자열 중 하나이든 그에 맞는 lxError* 코드로 매핑하고, AggregateValueIsError는 이제 결과가 0이 아닌지 검사하는 것일 뿐입니다. 각 순회자는 자기가 처음 본 오류 코드를 기록하고 그 코드를 반환합니다. 이는 수식이 한 번도 계산되지 않아 오류가 캐시된 Variant가 아니라 FGetValue의 반환 코드로 도착하는 셀도 캐시된 경우와 같은 방식으로 전파된다는 뜻입니다. AggregateCollectRange 안에서는 집계 함수 두 개가 특별 취급을 받고, 그 취급은 SUM이 아니라 SUBTOTAL과 맞습니다. 내부 함수 0, 즉 COUNT에서는 옵션 코드와 무관하게 오류 셀을 세지도 전파하지도 않습니다. COUNT는 숫자만 세기 때문입니다. 내부 함수 169, 즉 COUNTA에서는 오류 셀이 비어 있지 않은 값이므로 1로 세고, 옵션 코드가 오류를 무시하면 건너뜁니다. 이 비대칭은 AGGREGATE 밖에서도 Excel이 COUNT와 COUNTA를 다루는 방식이고, 일반적인 "오류면 전파한다" 규칙이 조용히 틀리는 종류의 세부 사항입니다
8가지 옵션 회귀 행렬이 검증하는 것
위에서 설명한 픽스처는 AggregateFunc_OptionMatrixCoversHiddenErrorsAndNestedAggregates에서 전체 행렬로 실행됩니다. 옵션 코드 0부터 7까지 각각에 대해 SUM 형태와 MEDIAN 형태를 A1:A4 위에서 평가하고, 결과를 손으로 유도한 기대값과 대조합니다. 코드 0, 1, 4, 5는 A3의 #DIV/0!를 전파해야 합니다. 넷 다 오류를 무시하지 않기 때문입니다. 코드 2는 중첩된 A4를 건너뛰고 10과 20에서 SUM 30, MEDIAN 15를 냅니다. 코드 3은 10과 10입니다. 코드 6은 A4의 30이 이제 세어져 60과 20을 냅니다. 코드 7은 40과 20인데, 누수 수정 전에 20을 반환하던 경우가 바로 이것입니다. 알려진 문제 등록부에 기록된 더 넓은 인수 실행은 열아홉 개 함수 번호를 여덟 코드 전부에 대해, 모든 참조 대상을 캐시된 경우와 캐시 없는 경우로 나눠 Win32와 Win64에서 304개 시나리오로 커버합니다
var
Book: TXLSXWorkbook;
Sheet: TXLSXWorksheet;
begin
Book := TXLSXWorkbook.Create;
try
Sheet := Book.Sheets.Add('Data');
Sheet.Cells[1, 1].Value := 10;
Sheet.Cells[2, 1].Value := 20;
Sheet.Cells[3, 1].Formula := '=1/0';
Sheet.Cells[4, 1].Formula := '=SUBTOTAL(9,A1:A2)'; // 그룹 소계 = 30
Sheet.RowHidden[2] := True;
Sheet.Cells[6, 1].Formula := '=AGGREGATE(9,1,A1:A4)'; // #DIV/0! 숨김 건너뜀, 오류 전파
Sheet.Cells[7, 1].Formula := '=AGGREGATE(9,3,A1:A4)'; // 10 숨김 + 오류 + 중첩 모두 건너뜀
Sheet.Cells[8, 1].Formula := '=AGGREGATE(9,6,A1:A4)'; // 60 오류만 건너뜀
Sheet.Cells[9, 1].Formula := '=AGGREGATE(9,7,A1:A4)'; // 40 v2.382.3 이전에는 20이었습니다
Book.Recalculate;
Book.SaveAs('aggregate-options.xlsx');
finally
Book.Free;
end;
end;
경계가 여전히 어디에 있는지
이 위에 무언가를 만들기 전에 알아 둘 한계가 셋입니다. 첫째, 중첩 집계 판정은 텍스트 기반입니다. TXLSXWorkbook.GetCalcIsSubtotalCell과 클래식 엔진 쪽 쌍둥이는 셀 수식이 SUBTOTAL(, AGGREGATE(, _xlfn.AGGREGATE(로 시작하면 등호가 있든 없든 True를 답합니다. 그래서 =IF(C1,SUBTOTAL(9,B1:B9),0)이나 =SUBTOTAL(9,B1:B9)*2 같은 수식은 중첩으로 인식되지 않고, Excel이라면 건너뛸 자리에서 코드 0부터 3까지가 이중 계산합니다. 계산된 소계를 내보내는 생성기는 집계 호출을 수식 맨 앞에 두어야 합니다. 둘째, 격리는 세 AGGREGATE 순회자 안에만 있습니다. CalcSubtotalFunc는 여전히 GetValueItemRange, CollectRangeValues, SubtotalReduceVariance를 통해 순회하고, 이들은 FGetValue를 직접 호출합니다. 그래서 범위에 캐시 없는 참조 수식이 들어 있는 SUBTOTAL(109, ...)는 여전히 자기 숨겨진 행 게이트를 그 참조 대상에 넘길 수 있습니다. 전체 Recalculate는 의존 대상보다 참조 대상을 먼저 평가하므로 캐시된 경로를 타고 게이트가 물려지지 않습니다. 노출은 Calculate를 통한 임시 평가와 캐시된 값 없이 로드된 워크북으로 한정되며, 큰 모델의 응답성을 유지하려고 의존성 그래프에 대한 증분 재계산에 기대고 있다면 같은 순서 보장이 이 누수를 잠재워 줍니다. 셋째, 두 게이트는 Assigned(FIsRowHidden)과 Assigned(FIsSubtotalCell)을 조건으로 합니다. 두 워크북 파사드 모두 생성자에서 콜백을 연결하지만, 원래 인자 두 개만으로 TXLSCalculator를 직접 만드는 코드는 모든 옵션 코드에 대해 조용히 예전의 전부 포함 동작을 얻습니다. 합계가 이상한데 수식 텍스트는 멀쩡해 보인다면, 평가를 단계별로 추적하는 것이 참조 대상이 물려받은 게이트 아래에서 평가되었는지, 아니면 콜백이 아예 연결되지 않았는지를 확인하는 가장 빠른 길입니다
여기서 설명한 계산 엔진, 옵션 디코더, 격리된 가져오기 래퍼, 그리고 이들을 고정하는 회귀 행렬은 모두 HotXLS Delphi 스프레드시트 컴포넌트에 소스로 포함되어 제공됩니다. 이 컴포넌트는 Excel 설치 없이 Delphi와 C++Builder에서 XLS, XLSX, ODS 워크북을 읽고 쓰고 재계산합니다