수정 이력(6건, 최종 수정 2026년 09월 03일)
이 글에 적용한 변경 사항의 기록입니다. 보관해 둔 수정 전 버전은 DOI가 부여된 고정 URL에서 읽을 수 있습니다.
- Codex 리뷰에 따라 상담·문의 링크에 /ko/ 로케일 접두를 붙였습니다. 본문의 기술적인 주장은 바꾸지 않았습니다.
- 관련 기사 링크를 한국어 permalink에 맞추는 등 CI가 지적한 표시용 수정을 반영했습니다. 본문의 기술적인 주장은 바꾸지 않았습니다.
- permalink·저자 표기·지식 맵 래퍼·깨진 내부 링크 등 CI가 지적한 표시용 수정을 반영했습니다. 본문의 기술적인 주장은 바꾸지 않았습니다.
- 기사 맨 앞에 「이 기사의 지식 맵」 절을 추가했습니다. 본문에서 다루는 개념과 그 관계를 요약·그림·상세 페이지 링크로 정리한 것입니다. 본문의 주장은 바꾸지 않았습니다.
- 외부 리뷰(1283건)에 대응하기 위해 본문을 업데이트했습니다. 개별 변경 내용은 아래 이력을 참조하세요.
- 월말 마감 계산이 왜 안전한지를 구체 예로 설명했습니다(거래일에서 매번 다시 조립하며, 지급기일도 연월만 읽고 일을 다시 만들기 때문입니다). 공휴일 마스터의 DDL 예와 잠정 플래그 운영, 내각부 CSV 가져오기 코드, `AppContext` 스위치 설정 방법 3가지, 연호 완화와 엄격의 대비표(엄격하게 해도 연 범위에 들어가는 날짜는 걸러내지 못한다는 점)를 추가했습니다.
- 최초 공개
이 글을 인용하기(DOI(등록된 아카이브): 10.5281/zenodo.21635416)
아래 DOI는 이전에 등록된 아카이브를 가리키며 현재 본문과 다를 수 있습니다. 현재 본문을 참조할 때는 이 페이지의 URL을 사용하세요.
Go Komura (2026). 「업무 앱의 연호·공휴일·마감일 처리 ── 연호 변경에 강한 설계와 JapaneseCalendar·영업일 계산 실무」. 합동회사 코무라소프트. https://comcomponent.com/ko/blog/japanese-calendar-holidays-closing-date-guide/
- DOI(등록된 아카이브)
- 10.5281/zenodo.21635416
- DOI(마지막 등록 버전)
- 10.5281/zenodo.21635417
「장표 날짜는 연호로」「지급기일은 20일 마감 다음 달 말일 지급, 말일이 휴일이면 직전 영업일」「집계는 회계연도 단위로」──일본 업무 앱에는 이 나라에서만 통하는 날짜 요건이 기본으로 들어가 있습니다. 그리고 이 영역에는 일반적인 날짜·시간 처리와는 다른 함정이 있습니다. 연호는 변경으로 늘고, 공휴일은 법 개정으로 움직이며, 「월말」은 달마다 길이가 다릅니다. 즉 모두 「나중에 바뀌는 사양」입니다.
이전 글 「업무 앱의 날짜·시간과 타임존」에서는 UTC 저장 원칙과 타임존 취급을 정리했습니다. 이번에는 그 후속편으로 일본 로컬의 세 가지 큰 요건──연호·공휴일·마감일──을 다룹니다. 연호 변경 대응 설계, 공휴일을 하드코딩하면 안 되는 이유와 데이터로 두는 방법, AddMonths의 월말 조정 사양을 바탕으로 한 마감일 계산까지 구현 예와 판단표로 설명합니다.
1. 먼저 결론
- 내부 처리와 저장은 서기(그레고리력), 연호는 표시하는 순간에만. 연호 문자열로 저장하면 정렬 불가·연호 변경 시 표기 혼재·잘못된 날짜 혼입이라는 삼중고가 됩니다.
- 연호 변환은 자체 대응표가 아니라
JapaneseCalendar에 맡깁니다. 연호 정의는 OS 쪽에서 공급되므로, 연호 변경 때는 플랫폼 업데이트로 따라갑니다.1 앱에 남은 「헤세이」「레이와」 하드코딩만 뒤처집니다. - 공휴일은 계산식으로 판정할 수 없습니다. 춘분·추분은 전년에 확정되고, 법 개정이나 특별조치법으로 공휴일 자체가 움직이기 때문입니다.2 공휴일 마스터+갱신 운영으로 설계하고, 데이터 소스에는 내각부가 공표하는 공휴일 CSV를 쓸 수 있습니다.
- 「월말」「영업일」「회계연도」는 코드보다 먼저 사양으로 정의하는 것입니다. 특히
AddMonths는 월말을 자동으로 조정하므로, 마감일 계산은 기준일에서 매번 계산하는 것이 원칙입니다.3
데이터 종류별로 두는 방법을 먼저 정리합니다.
| 데이터 | 두는 방법 | 변환·판정 시점 |
|---|---|---|
| 날짜 자체(수주일·지급기일) | 서기의 DateOnly / DateTime(DB는 date 형) |
─ |
| 연호 표기(장표·화면) | 두지 않음 | 표시·인쇄 순간에 JapaneseCalendar로 변환 |
| 공휴일 | 공휴일 마스터(테이블 or 설정 파일) | 영업일 계산 시 마스터 참조 |
| 회사 휴무일(하계 휴가·창립기념일) | 공휴일 마스터와 별도인 휴무일 마스터 | 동일(공휴일과 섞지 않음) |
| 마감일·지급 조건 | 거래처 마스터의 속성(「20일 마감 다음 달 말일 지급」) | 청구 처리 시 기준일에서 계산 |
| 회계연도 | 두지 않음(도출값) | 표시·집계 시 날짜에서 계산 |
그림의 실선은 항상 성립하는 관계, 점선은 조건이 붙는 관계입니다(성립 조건은 상세 페이지의 관계별 설명에 적혀 있습니다). 관계 전체 목록(총 24건, 근거와 확신도 포함)과 주요 개념의 정의는 지식 맵 상세 페이지에 정리되어 있습니다(일본어). 데이터: JSON-LD / Turtle
2. 연호 ── 표시 전용으로 두기
2.1 왜 내부를 서기로 통일하는가
연호는 사람용 표기이지, 계산에 맞는 표현이 아닙니다. 「헤세이 9년」과 「레이와 9년」은 문자열 비교로는 순서를 정할 수 없고, 연호를 걸친 기간 계산에는 결국 서기로의 변환이 필요합니다. 게다가 연호는 개정마다 체계가 늘어나므로, 연호 그대로 저장된 데이터는 연호 변경마다 신구 표기가 섞입니다. 「쇼와 64년 1월 10일」(존재하지 않음. 1월 8일부터 헤세이) 같은 잘못된 데이터 혼입도, 연호 입력을 날것 그대로 저장하는 시스템의 단골 문제입니다.
또 하나 놓치기 쉬운 제약으로, .NET의 JapaneseCalendar가 지원하는 것은 메이지 원년(1868년) 9월 8일 이후입니다.4 그보다 앞선 날짜──예를 들어 호적에서 온 오래된 생년월일──는 연호 그대로는 올바르게 다룰 수 없습니다. 내부를 서기 그레고리력으로 통일해 두면, 이런 문제는 모두 「표시 계층만의 이야기」로 가둘 수 있습니다.
2.2 JapaneseCalendar로 표시와 입력
.NET에서 여러 연호(era)를 다룰 수 있는 캘린더는 JapaneseCalendar(와 JapaneseLunisolarCalendar)뿐이며, 서식 지정자 g가 연호 이름에 대응합니다.1 연호로 표시하는 최소 코드는 다음과 같습니다.
using System.Globalization;
var jaJpWareki = new CultureInfo("ja-JP");
jaJpWareki.DateTimeFormat.Calendar = new JapaneseCalendar();
var date = new DateOnly(2026, 7, 11);
Console.WriteLine(date.ToString("ggy年M月d日", jaJpWareki)); // 令和8年7月11日
Console.WriteLine(date.ToString("ggyy/MM/dd", jaJpWareki)); // 令和08/07/11
연호 입력을 받을 때는 자유 입력 문자열 파싱이 아니라 「연호는 콤보 박스로 고르게 하고, 연월일은 숫자로 받는」 UI가 안전합니다. 화면으로는 다음 배치면 충분합니다.
[연호 ▼] [ 8 ] 년 [ 7 ] 월 [ 11 ] 일 서기: 2026-07-11
- 연호 ── 콤보 박스. 기본값은 현재 연호. 과거 데이터를 입력하는 화면이라면 다루는 범위의 연호만 후보로 냅니다.
- 년·월·일 ── 숫자 입력. 연의 상한은 연호마다 다르므로, 고른 연호에 따라 입력 가능한 범위를 바꿉니다.
- 서기 미리보기 ── 변환 후 서기를 그 자리에서 표시합니다. 다음에 말할 「쇼와 64년 1월 10일」 같은 조합을 입력자가 알아챌 수 있는 것은 이 표시가 있을 때뿐입니다.
- 유지하는 값 ── 화면이 가지는 것은 서기입니다. 연호 3항목은 입력 도구일 뿐입니다.
꼭 문자열로 받아야 한다면 ParseExact로 서식을 고정합니다.
var input = "令和8年7月11日";
var parsed = DateTime.ParseExact(input, "ggy年M月d日", jaJpWareki);
Console.WriteLine(parsed.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)); // 2026-07-11
원년 표기에는 주의가 필요합니다. 연호 변경 대응 업데이트가 적용된 환경에서는 각 연호의 1년째가 기본으로 「레이와 원년」처럼 원년으로 서식되고, 숫자 「레이와 1년」으로 되돌리기 위한 호환 스위치(Switch.System.Globalization.FormatJapaneseFirstYearAsANumber)가 준비되어 있습니다.5 장표 양식이 「레이와 1년」과 「레이와 원년」 중 어느 쪽을 요구하는지는 구현 전에 사양으로 확인하세요. 또 하나, 기본에서는 연호 연 범위 검사가 완화(relaxed)되어 있어 「헤세이 33년」처럼 존재하지 않는 연 지정도 받아들여 실제 날짜(레이와 3년)로 환산됩니다. 입력 검증으로 엄격히 걸러 내고 싶다면 Switch.System.Globalization.EnforceJapaneseEraYearRanges로 강제할 수 있습니다.5 완화를 유지할지 엄격히 할지는 기존 데이터가 얼마나 더러운지(연호 변경 전에 인쇄·입력된 「헤세이 32년」을 어떻게 다룰지)로 정하게 됩니다.
이 스위치는 코드가 아니라 구성 파일에서 설정합니다.5 .NET (Core)/.NET 5 이후는 런타임 구성의 configProperties에 씁니다.
{
"runtimeOptions": {
"configProperties": {
"Switch.System.Globalization.FormatJapaneseFirstYearAsANumber": true,
"Switch.System.Globalization.EnforceJapaneseEraYearRanges": true
}
}
}
프로젝트에서 관리할 때는 runtimeconfig.template.json에 configProperties 부분을 적어 두면, 빌드 때 생성되는 <앱 이름>.runtimeconfig.json에 반영됩니다. .NET Framework 4.6 이후는 app.config의 AppContextSwitchOverrides입니다.
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<runtime>
<AppContextSwitchOverrides value="Switch.System.Globalization.EnforceJapaneseEraYearRanges=true" />
</runtime>
</configuration>
.NET Framework 4.5.2 이전을 보수하는 경우에는 HKEY_LOCAL_MACHINE\Software\Microsoft\.NETFramework\AppContext에 같은 이름 항목을 REG_SZ로 두는 방식이 됩니다.5
다만 이 스위치로 무엇이 막히고 무엇이 막히지 않는지는 구체 예로 잡아 두지 않으면 잘못 판단합니다. 공식 문서에는 완화 상태에서 「쇼와 65년 1월 9일」을 지정하면 헤세이 2년 1월 9일(서기 1990년 1월 9일)로 다루고, 엄격화하면 「유효한 값은 1〜64입니다」라는 ArgumentOutOfRangeException이 된다는 예가 실려 있습니다.5 이 규칙을 그대로 적용하면 다음과 같습니다.
| 연호 지정 | 기본(완화) | 엄격화한 경우 |
|---|---|---|
| 헤세이 33년◯월◯일 | 받아들여져 레이와 3년에 해당하는 날짜가 됨 | ArgumentOutOfRangeException |
| 쇼와 65년 1월 9일 | 받아들여져 1990-01-09(헤세이 2년 1월 9일)가 됨 | ArgumentOutOfRangeException(쇼와의 유효 연은 1〜64) |
| 쇼와 64년 1월 10일 | 받아들여져 1989-01-10이 됨 | 통과함(연으로는 1〜64 범위 안) |
3행째가 주의입니다. 쇼와는 64년까지 존재하므로 연 범위 검사만으로는 「쇼와 64년 1월 10일」을 걸러 내지 못합니다. 쇼와는 1989년 1월 7일에 끝났는데, 검사하는 것은 연 값뿐이기 때문입니다. 2.1에서 말한 「쇼와 64년 1월 10일」 같은 더러운 데이터는 바로 이 틈으로 들어옵니다. 연호를 넘는 달의 날짜까지 엄밀히 검증하려면 스위치에 기대지 않고 연호마다 시작일·종료일을 두고 날짜 단위로 대조하는 검증을 자체로 작성하게 됩니다.
2.3 연호 변경에 강한 코드와, 뒤처지는 것
레이와 변경(2019년) 경험에서 말할 수 있는 것은 플랫폼은 따라가지만 앱의 자체 구현은 따라가지 않는다는 점입니다. 연호 정의는 .NET에 하드코딩되어 있지 않고 OS 쪽이 가진 연호 정보를 참조하는 구조이므로, OS와 .NET 업데이트를 적용해 두면 JapaneseCalendar의 변환·서식은 새 연호를 따라갑니다.1 뒤처지는 것은 다음과 같은 앱 쪽 자산입니다.
if (year >= 2019) era = "令和";같은 자체 연호 변환 테이블·분기- 장표 템플릿·Excel 셀 서식·이미 인쇄한 용지에 직접 쓴 연호
- 「R」「H」 같은 연호 알파벳 1문자를 독자 규칙으로 다루는 코드(기간계 시스템 연동 포맷에 많음)
- 연호 그대로 저장된 데이터 그 자체
연호 변경 대응 개수 견적에서는 먼저 소스와 템플릿 전체를 「쇼와·헤세이·레이와·원년」으로 기계적으로 grep해 점검합니다. VB6나 Access 시절부터 돌아가는 업무 앱에는 자체 변환 테이블이 거의 확실히 박혀 있으므로, 「VB6 / Access 업무 앱의 연장과 이행」에서 쓴 연장 판단과 세트로 다루는 경우가 많은 논점입니다.
2.4 Windows 지역 설정이라는 복병
놓치기 쉬운 함정을 하나 더. Windows 지역 설정에서는 사용자가 OS 캘린더 종류를 「연호」로 바꿀 수 있습니다. CultureInfo.CurrentCulture는 기본으로 사용자 설정 변경(사용자 오버라이드)을 반영하므로, 6 이 설정의 단말에서는 컬처 기본 DateTime.ToString()이 갑자기 「令和08/07/11」 같은 연호 문자열을 반환하게 됩니다. 컬처 기본 ToString()으로 로그나 CSV나 DB에 날짜를 쓰는 코드가 있으면 그 단말에서만 저장 데이터에 연호가 섞여 후단 파싱이 깨집니다.
대책은 이전 기사와 같은 결론으로 돌아갑니다. 경계(저장·통신·로그)를 넘는 날짜 문자열은 반드시 CultureInfo.InvariantCulture와 명시 서식으로 씁니다. 컬처 기본 서식은 화면 표시 전용입니다. 컬처가 표시에 주는 영향 전반은 「WinForms/WPF 앱의 다국어화」도 참고가 됩니다.
3. 공휴일 ── 하드코딩하면 안 되는 이유와 마스터 운영
3.1 공휴일은 「바뀐다」
공휴일 판정을 switch (month, day)로 쓰면 안 됩니다. 공휴일은 국민의 축일에 관한 법률로 정해져 있으며, 법 개정과 운영으로 자주 바뀌어 왔기 때문입니다.2 근년의 실제 예만 들어도 다음과 같습니다.
| 연 | 변경 |
|---|---|
| 2016 | 산의 날(8월 11일)이 신설됨 |
| 2019 | 연호 변경에 따라 천황탄생일이 12월 23일→2월 23일로. 2019년에는 천황탄생일이 존재하지 않음 |
| 2019 | 황위 계승에 따른 특례로 5월 1일(즉위의 날) 등이 임시 공휴일·휴일 |
| 2020 | 체육의 날이 스포츠의 날로 개칭 |
| 2020·2021 | 올림픽 특별조치법으로 바다의 날·스포츠의 날·산의 날이 그해만 이동 |
게다가 춘분의 날과 추분의 날은 애초에 법률에 날짜가 적혀 있지 않습니다. 천문 계산을 바탕으로 전년에 확정·공표되는 구조이므로, 미래의 춘분·추분을 확실히 아는 방법은 공표를 기다리는 것 외에는 없습니다.2 「공휴일은 매년 같다」는 전제의 계산식은 원리적으로 성립하지 않습니다.
3.2 데이터 소스와 두는 방법의 판단표
공휴일 판정은 「데이터+갱신 운영」으로 설계합니다. 주요 선택지를 비교합니다.
| 방식 | 내용 | 맞는 경우 | 주의점 |
|---|---|---|---|
| 공휴일 마스터(자체 관리) | 테이블에 연월일과 명칭을 두고 관리 화면에서 갱신 | 회사 휴무일과 함께 운영하려는 업무 시스템 전반 | 갱신을 업무 운영에 넣기(연 1회 확인을 캘린더화) |
| 내각부 CSV 가져오기 | 내각부 공표 공휴일 CSV를 가져와 마스터를 갱신 | 마스터 갱신의 1차 소스가 필요할 때(권장) | 공표된 기간만 들어 있음. 문자 코드에 주의 |
| 공휴일 라이브러리(NuGet 등) | 계산 로직+수록 데이터로 판정 | 사내 도구처럼 갱신 운영을 둘 수 없는 소규모 용도 | 장래 법 개정은 라이브러리 업데이트에 의존. 수록 규칙 검증이 필요 |
| 하드코딩 | 소스에 날짜를 직접 씀 | 없음 | 법 개정마다 전 클라이언트 재배포가 됨 |
1차 소스로는 내각부가 「국민의 축일」 페이지에서 공표하는 쇼와 30년(1955년)부터 다음 해분까지의 공휴일 월일·명칭 CSV를 쓸 수 있습니다.2 가져올 때의 실무적 주의는 두 가지입니다. (1) 공표된 것은 확정분뿐이므로 다음다음 해 이후 공휴일을 물었을 때의 동작(오류로 할지, 법률 기반 잠정값으로 채우고 「잠정」 플래그를 붙일지)을 사양으로 정해 둘 것, (2) 이 CSV의 문자 코드는 Shift_JIS 계열이므로 읽기는 「CSV는 「그냥 텍스트」가 아니다」에서 쓴 대로 인코딩을 명시할 것.
마스터 쪽 형태도 구체화해 둡니다. 공휴일은 「날짜와 그 명칭」 이상의 것이 아니므로 테이블은 단순해도 됩니다. 늘려 두고 싶은 것은 잠정 플래그와 가져오기 출처 두 열입니다.
-- 공휴일 마스터. 대체휴일·국민의 휴일도 1행으로 둔다
CREATE TABLE public_holidays (
holiday_date date NOT NULL PRIMARY KEY, -- 1일 1행. 날짜 자체가 기본 키
holiday_name nvarchar(64) NOT NULL, -- 내각부 CSV 명칭을 그대로 저장('元日' '休日' 등)
is_provisional bit NOT NULL, -- 1 = 잠정(공표 전을 법률 기반으로 미리 채운 행)
source nvarchar(32) NOT NULL, -- 'cao-csv' / 'manual' 등 가져오기 출처
updated_at datetime2(0) NOT NULL
);
-- 회사 휴무일 마스터. 공휴일과 나눠 둔다(이유는 3.3절 끝)
CREATE TABLE company_holidays (
holiday_date date NOT NULL PRIMARY KEY,
reason nvarchar(64) NOT NULL, -- '夏季休業' '創立記念日' 등
updated_at datetime2(0) NOT NULL
);
is_provisional이 핵심입니다. 내각부 CSV에 들어 있는 것은 확정분뿐이므로, 그 이후 연도를 다뤄야 하는 시스템에서는 법률 규칙에서 기계적으로 생성한 행을 is_provisional = 1로 넣어 두고, 공표되는 대로 0 행으로 덮어쓰는 운영을 합니다. 이렇게 해 두면 「이 지급기일 판정에 잠정 공휴일이 섞였는지」를 나중에 설명할 수 있습니다. 반대로 잠정 행을 허용하지 않는 업무(이체 기일 확정 등)에서는 호출 쪽에서 is_provisional = 0으로 좁히면 되고, 테이블을 나눌 필요는 없습니다.
가져오기 쪽은 다음 형태가 됩니다. 내각부 CSV는 1행째가 헤더이고, 날짜가 1955/1/1처럼 제로 패딩되지 않으므로 서식을 고정해 파싱합니다.
using System.Globalization;
using System.Text;
// 내각부 「국민의 축일에 대하여」에서 공개하는 syukujitsu.csv를 읽는다.
// 1행째: 国民の祝日・休日月日,国民の祝日・休日名称
// 2행째 이후: 1955/1/1,元日 ── 문자 코드는 Shift_JIS(CP932)
public static List<(DateOnly Date, string Name)> ReadCabinetOfficeHolidayCsv(string path)
{
// .NET(Core)/.NET 5 이후는 이 등록이 없으면 GetEncoding이 예외가 된다(CSV 기사 4.2절)
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
var sjis = Encoding.GetEncoding("shift_jis");
var holidays = new List<(DateOnly, string)>();
var isFirstLine = true;
foreach (var line in File.ReadLines(path, sjis))
{
if (isFirstLine) { isFirstLine = false; continue; } // 헤더 행을 버린다
if (string.IsNullOrWhiteSpace(line)) continue;
var columns = line.Split(',');
if (columns.Length < 2) continue;
// 「1955/1/1」 형식. 컬처 기본에 맡기지 않고 서식을 고정한다(2.4절과 같은 이유)
var date = DateOnly.ParseExact(columns[0].Trim(), "yyyy/M/d", CultureInfo.InvariantCulture);
holidays.Add((date, columns[1].Trim()));
}
return holidays;
}
읽어 들인 행을 public_holidays에 is_provisional = 0 / source = 'cao-csv'로 반영하고, 같은 날짜에 잠정 행이 있으면 덮어쓰는 것이 갱신 처리의 내용입니다. 대체휴일·국민의 휴일은 명칭 「休日」 행으로 CSV에 처음부터 들어 있습니다.2
3.3 대체휴일·국민의 휴일과 영업일 계산
축일법에는 날짜 목록 외에 파생 규칙이 두 가지 있습니다. 대체휴일(공휴일이 일요일에 해당할 때, 그 날 이후 가장 가까운 「공휴일이 아닌 날」을 휴일로 한다)과 국민의 휴일(전날과 다음 날이 공휴일에 끼인 평일을 휴일로 한다)입니다.2 내각부 CSV에는 이들도 행으로 포함되어 있으므로, CSV 가져오기 방식이면 마스터 참조만으로 충분합니다. 자체로 계산 보완하는 경우에도 이 로직은 법률에서 오므로 비교적 안정적입니다.
영업일 계산은 「토일이 아님·공휴일 마스터에 없음·회사 휴무일 마스터에 없음」 세 조건으로 씁니다.
public sealed class BusinessDayCalendar
{
private readonly HashSet<DateOnly> _publicHolidays; // 공휴일 마스터(대체휴일 포함)
private readonly HashSet<DateOnly> _companyHolidays; // 회사 휴무일 마스터
public BusinessDayCalendar(IEnumerable<DateOnly> publicHolidays,
IEnumerable<DateOnly> companyHolidays)
{
_publicHolidays = [.. publicHolidays];
_companyHolidays = [.. companyHolidays];
}
public bool IsBusinessDay(DateOnly d) =>
d.DayOfWeek is not (DayOfWeek.Saturday or DayOfWeek.Sunday)
&& !_publicHolidays.Contains(d)
&& !_companyHolidays.Contains(d);
// 기일이 휴일이면 「직전 영업일로 당긴다」. 뒤로 밀지는 계약 사양으로 정한다
public DateOnly ToPreviousBusinessDay(DateOnly d)
{
while (!IsBusinessDay(d)) d = d.AddDays(-1);
return d;
}
}
공휴일과 회사 휴무일을 별도 마스터로 나눈 것은 의도적입니다. 「은행 이체 기일 판정은 공휴일만 본다(자사 하계 휴가는 관계없다)」「출하일 판정은 둘 다 본다」처럼 용도에 따라 참조하는 휴일 집합이 다르기 때문입니다. 하나의 마스터에 섞어 버리면, 나중에 분리하는 개수는 저장된 데이터의 고고학이 됩니다.
4. 마감일·지급 조건·회계연도 ── 「월말」을 계산으로 올바르게 다루기
4.1 AddMonths의 월말 조정 사양
마감일 계산의 첫 함정은 AddMonths입니다. DateTime.AddMonths / DateOnly.AddMonths는 결과 달에 존재하지 않는 날이 되면 그달의 마지막 날로 조정합니다.3
var jan31 = new DateOnly(2026, 1, 31);
var feb = jan31.AddMonths(1); // 2026-02-28 ── 월말로 조정된다(올바름)
var mar = feb.AddMonths(1); // 2026-03-28 ── 「월말」이라고 생각한 날짜가 28일로 바뀐다
조정 자체는 올바른 사양이지만, 조정한 결과에 다시 AddMonths를 겹치면 「월말」이라는 의도가 사라집니다. 매월 청구 일정을 「직전 실행일+1개월」로 짜면, 2월을 지나는 순간부터 영원히 28일 실행으로 바뀌는 형태로 드러납니다. 대책은 두 가지입니다.
- 반복 날짜는 직전 결과가 아니라 기준 정의(「매월 말일」「매월 20일」)에서 매번 계산한다
- 「월말」은 날짜 리터럴이 아니라
DateTime.DaysInMonth(y, m)에서 도출한다
4.2 「20일 마감 다음 달 말일 지급」의 구현
마감일과 지급 조건은 거래처마다의 속성입니다. 「20일 마감 다음 달 말일 지급」을 코드로 쓰면 다음과 같습니다.
public static class PaymentTerms
{
// 거래일 → 마감일(매월 20일 마감 예)
public static DateOnly GetClosingDate(DateOnly tradeDate, int closingDay)
{
var dayInMonth = Math.Min(closingDay, DateTime.DaysInMonth(tradeDate.Year, tradeDate.Month));
var closing = new DateOnly(tradeDate.Year, tradeDate.Month, dayInMonth);
return tradeDate <= closing ? closing : closing.AddMonths(1);
// 주: closingDay=31(월말 마감)은 AddMonths의 조정으로 올바르게 다음 달 말일이 된다
}
// 마감일 → 지급기일(다음 달 말일 지급 예). 휴일 조정은 사양(앞당김/뒤로 밀기)을 확인해 적용
public static DateOnly GetPaymentDueDate(DateOnly closingDate, BusinessDayCalendar calendar)
{
var nextMonth = closingDate.AddMonths(1);
var endOfMonth = new DateOnly(nextMonth.Year, nextMonth.Month,
DateTime.DaysInMonth(nextMonth.Year, nextMonth.Month));
return calendar.ToPreviousBusinessDay(endOfMonth); // 「휴일이면 직전 영업일」 계약인 경우
}
}
4.1에서 AddMonths를 경고한 직후에 그 AddMonths를 두 곳에서 쓰고 있습니다. 모순이 아니므로, 왜 여기서는 안전한지를 보충합니다. 4.1에서 위험했던 것은 조정된 결과를 그대로 다음 계산의 입력으로 쓰는 일이었습니다. 위 코드는 어느 쪽도 그렇게 되어 있지 않습니다.
GetClosingDate는 매번 거래일에서 그달의 마감일을 다시 조립합니다.AddMonths(1)을 쓰는 것은 「해당 월 마감일을 지났으므로 다음 달 마감일로 한다」는 한 번뿐이며, 직전 반환값을 재사용하지 않습니다. 게다가closingDay = 31(월말 마감)인 경우, 1월 31일에서 만든 마감일에AddMonths(1)하면 2월 28일이 되지만, 이는 조정 결과가 「월말 마감」의 의미와 딱 맞는 경우입니다. 즉 여기서는 조정을 상쇄하는 것이 아니라 조정을 이용합니다.GetPaymentDueDate는AddMonths(1)결과에서 연과 월만 읽습니다. 일은DateTime.DaysInMonth로 다시 만들므로, 조정으로 일이 28일이 되어 있어도 결과에 영향이 없습니다. 1월 31일 마감일이면 「2월의 말일」로서 2월 28일, 8월 31일 마감일이면 「9월의 말일」로서 9월 30일이 나옵니다.
요컨대 AddMonths를 「연월을 하나 진행한다」는 용도로 한정하고 일은 기준 정의(매월 말일·매월 20일)에서 반드시 다시 만들면 안전하게 쓸 수 있습니다. 금기는 「직전 결과에 계속 더하는」 쪽입니다.
코드보다 중요한 것은 그 앞의 사양 확정입니다. 「월말 마감」의 월말은 역일인가 영업일인가. 기일이 휴일일 때 앞당기는가 뒤로 미루는가(지급하는 쪽은 앞당김, 받는 쪽의 입금 예정은 뒤로 밀기,처럼 입장에 따라 관행이 다릅니다). 29〜31일 마감 계약은 2월에 어떻게 해석하는가. ──여기가 문서화되어 있지 않으면 구현자의 코드가 그대로 「아무도 합의하지 않은 사양」이 됩니다. 마감일 주변 버그 조사의 상당수는 코드 오류가 아니라 사양의 부재에 도달합니다.
4.3 회계연도와 분기
일본 업무 앱의 「회계연도」는 거의 4월 시작입니다. 회계연도는 저장하지 않고, 날짜에서 나온 도출값으로 다룹니다.
public static int GetFiscalYear(DateOnly d) => d.Month >= 4 ? d.Year : d.Year - 1;
// 2026-03-31 → 2025년도, 2026-04-01 → 2026년도
public static int GetFiscalQuarter(DateOnly d) => ((d.Month + 8) % 12) / 3 + 1;
// 4-6월 → Q1, 7-9월 → Q2, 10-12월 → Q3, 1-3월 → Q4
주의점은 「회계연도」를 쉽게 DB에 중복 저장하지 않는 것입니다. 날짜가 있으면 언제나 도출할 수 있는 값을 별도 컬럼에 두면, 날짜 수정 때 갱신 누락으로 날짜와 회계연도가 어긋난 데이터가 생깁니다. 집계 성능 때문에 꼭 둬야 한다면 계산 열(생성 열)로 두고 앱에서의 쓰기를 금합니다.
4.4 테스트 ── 「그날만」 나타나는 버그를 먼저 밟기
연호·공휴일·마감일 버그는 특정 날짜에서만 드러납니다. 이전 기사에서 소개한 TimeProvider로 현재 시각을 주입하는 방법(「업무 앱의 날짜·시간과 타임존」 7장)을 전제로, 이 영역에서 최소한 테스트해 두고 싶은 날짜는 다음 다섯입니다.
- 연호 변경 경계: 1989-01-07/08(쇼와→헤세이), 2019-04-30/05-01(헤세이→레이와)의 표시와, 연호 입력의 역변환
- 원년: 「레이와 원년」 표기의 장표 확인(2.2절)
- 윤년: 2월 29일을 포함하는 마감·지급 조건 계산(2024년, 2028년)
- 월말의 연쇄: 1월 31일 기점으로 12개월분 마감일을 생성해 월말이 유지되는지(4.1절)
- 회계연도 경계: 3월 31일과 4월 1일의 회계연도·분기 판정
여기에 더해 리뷰 때는 다음 패턴을 grep으로 기계적으로 집을 수 있습니다.
| 발견하면 의심할 코드 | 무엇이 일어날 수 있는가 | 고치는 방법 |
|---|---|---|
"平成" "令和" 같은 연호 리터럴 |
다음 연호 변경에서 뒤처짐 | JapaneseCalendar + g 서식으로(2.3절) |
공휴일 날짜 리터럴(5, 3 등) |
법 개정·특별조치법으로 오판정 | 공휴일 마스터 참조로(3.2절) |
인자 없는 ToString()의 저장·출력 경로 |
연호 설정 단말에서 저장 데이터에 연호 혼입 | InvariantCulture + 명시 서식(2.4절) |
직전날짜.AddMonths(1)의 반복 |
2월 통과 후 월말이 계속 어긋남 | 기준일에서 매번 계산(4.1절) |
new DateTime(y, m, 31) |
30일 이하인 달에서 예외 | DateTime.DaysInMonth에서 도출 |
5. 정리
연호·공휴일·마감일은 모두 「나중에 바뀌는 사양」을 다루는 처리입니다. 그래서 설계 원칙도 같습니다. 바뀌는 것은 코드에 쓰지 않고, 데이터와 변환 계층에 격리한다──내부와 저장은 서기로 통일하고 연호는 표시 전용 변환으로 두며, 공휴일은 마스터와 갱신 운영으로 두고, 「월말」「영업일」「기일의 휴일 조정」은 계약 사양으로 문서화한 뒤 기준일 기반으로 계산한다. 여기까지 하면 다음 연호 변경도, 다음 공휴일법 개정도, 앱에게는 「마스터 갱신」과 「OS 업데이트 적용」으로 끝나는 이벤트가 됩니다.
당사에서는 장표·청구 주변 날짜 처리의 설계와 구현, 기존 앱에 박힌 연호·공휴일 하드코딩의 점검과 개수, 마감일 사양 정리를 포함한 설계 리뷰를 다룹니다. 「연호 변경 대응이 어디에 몇 건 있는지 모르겠다」는 단계부터도 도와드릴 수 있습니다.
관련 기사
- 업무 앱의 날짜·시간과 타임존 ── DateTime의 함정부터 UTC 저장 원칙, 테스트 설계까지
- CSV는 「그냥 텍스트」가 아니다 ── C# 업무 앱의 CSV 실무(문자 코드·Excel 호환·인젝션 대책)
- WinForms/WPF 앱의 다국어화 ── resx·새틀라이트 어셈블리·컬처 전환 실무
- VB6 / Access 업무 앱의 연장과 이행 ── 남기기·감싸기·바꾸기의 판단표
- Excel 장표 출력 만드는 방법 - COM/Open XML/템플릿
관련 상담 영역
合同会社小村ソフト에서는 일본 상관습에 맞춘 업무 앱의 날짜 처리(연호 장표·영업일 계산·마감일 처리)의 설계와 구현, 기존 앱의 연호 변경 대응·공휴일 대응의 점검과 개수를 다룹니다.
참고 링크
-
Microsoft Learn, Work with eras - .NET. JapaneseCalendar와 JapaneseLunisolarCalendar가 여러 연호(era)를 인식하는 유일한 캘린더 클래스라는 점, 서식 지정자 「g」로 연호를 서식하는 점, 연호 정보가 플랫폼 쪽에서 공급되어 앱 하드코딩에 의존하지 않는다는 점에 대하여. ↩ ↩2 ↩3
-
내각부, 「국민의 축일」에 대하여. 국민의 축일에 관한 법률에 따른 공휴일 목록, 춘분의 날·추분의 날이 전년에 확정되어 공표된다는 점, 대체휴일·국민의 휴일 규정, 쇼와 30년부터 다음 해까지의 공휴일 월일·명칭 CSV 제공에 대하여. ↩ ↩2 ↩3 ↩4 ↩5 ↩6
-
Microsoft Learn, DateTime.AddMonths(Int32) Method. 가산 결과의 일이 결과 달의 일수를 넘으면 결과 달의 마지막 날로 조정된다는 점(1월 31일+1개월이 2월 28일/29일이 되는 예)에 대하여. ↩ ↩2
-
Microsoft Learn, JapaneseCalendar Class. JapaneseCalendar가 지원하는 날짜 범위가 메이지 원년(서기 1868년) 9월 8일 이후라는 점, 연호(era) 취급에 대하여. ↩
-
Microsoft Learn, Work with calendars - .NET. 연호 1년째를 「원년」으로 서식하는 기본 동작과 숫자 표기로 되돌리는 Switch.System.Globalization.FormatJapaneseFirstYearAsANumber 스위치, 연호 연 범위 검사가 기본으로 완화되어 있으며 Switch.System.Globalization.EnforceJapaneseEraYearRanges로 엄격화할 수 있다는 점에 대하여. ↩ ↩2 ↩3 ↩4 ↩5
-
Microsoft Learn, CultureInfo.CurrentCulture Property. CurrentCulture가 기본으로 Windows 사용자의 지역 설정 변경(사용자 오버라이드)을 반영한다는 점, 서식의 기본값이 이 컬처에 의존한다는 점에 대하여. ↩
관련 기사
같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.
WMI/CIM을 C#·PowerShell에서 쓰기 ── 하드웨어 정보 가져오기·프로세스 모니터링·원격 조회의 실무 가이드
PC 시리얼 번호 조회, 디스크 여유 공간 모니터링, 프로세스 시작 감지의 흔한 답이 WMI/CIM입니다. Get-CimInstance 등 CIM cmdlet 사용법과 구 Get-WmiObject에서의 이전, C#의 System.Managemen...
자사 개발 Windows 앱이 바이러스로 오인되면 ── Microsoft Defender 오탐 대응과 성능 영향에 대처하는 법
자사 개발 Windows 앱이 Microsoft Defender에 오탐되었을 때의 정규 대응을 정리합니다. 현대 바이러스 백신의 동작 방식, Microsoft에 오탐을 신고하는 방법, 격리에서 복원하기, 제외 설정을 올바르게 넣는 방법과 그 위험...
슬립・최대 절전 모드・Modern Standby와 장시간 가동 앱 ── '한밤중에 멈춰 있었다'를 설계로 막는다
장시간 동작하는 Windows 앱이 '아침에 보니 멈춰 있었다'가 되는 원인을 S3 슬립/최대 절전 모드/Modern Standby의 차이에서 정리합니다. 슬립 중 타이머와 TCP 연결의 동작, SetThreadExecutionState로 억제하...
Windows on Arm에서 업무 앱은 동작하는가 ── x64 에뮬레이션(Prism)과 네이티브 DLL·COM의 현실
「Windows on Arm에서 업무 앱은 동작하는가」에 개발자와 사내 IT를 위해 답합니다. x64 에뮬레이션(Prism)의 구조, 드라이버처럼 동작하지 않는 계층, .NET의 AnyCPU와 P/Invoke 문제, Arm 대응 체크리스트까지 정...
MAX_PATH와 Windows 경로·파일 이름의 함정 ── 260자 제한, 예약 이름, 끝의 점, 대소문자
「파일을 찾을 수 없습니다」의 단골 원인인 경로·파일 이름 제한을 정리합니다. MAX_PATH=260자의 구성, LongPathsEnabled로 긴 경로를 켜는 방법, CON 등의 예약 이름, 끝 점의 정규화, Path.Combine의 함정까지 ...
관련 토픽
이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.
Windows 기술 토픽
Windows 개발, 장애 조사, 기존 자산 활용에 관한 KomuraSoft LLC 기사를 모은 토픽 허브입니다.
이 주제와 연결되는 서비스
이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.
Windows 앱 개발
장표의 연호 표시, 영업일 계산, 마감일 처리처럼 일본 업무 요건을 구현하는 일은 Windows 업무 앱 개발의 핵심 상담 범위이기 때문입니다.
Windows 소프트웨어 유지 보수 & 현대화
기존 앱에 박혀 있는 연호·공휴일 하드코딩을 점검하고, 다음 연호 변경·법 개정에도 버티는 형태로 고치는 일은 기존 Windows 소프트웨어의 개수·보수에 해당하기 때문입니다.
기술 상담 & 설계 리뷰
「월말」「영업일」「회계연도」의 정의를 어디까지 사양으로 만들고 데이터로 둘지 정리하는 일은 설계 리뷰를 동반하는 기술 상담이기 때문입니다.
자주 묻는 질문
이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.
- 데이터베이스에 연호 그대로 저장하면 안 되나요?
- 권하지 않습니다. 이유는 네 가지입니다. 첫째, 연호 문자열은 「헤세이 9년」과 「레이와 9년」처럼 연호를 넘으면 문자열 비교로 대소가 깨져 정렬도 범위 검색도 할 수 없습니다. 둘째, 다음 연호 변경이 일어나는 순간 이미 저장된 데이터와 새 데이터의 표기가 섞입니다. 셋째, 「쇼와 64년 1월 8일」처럼 실제로 없는 날짜(그날은 이미 헤세이 원년)가 섞이기 쉽고, 파싱이 실패하는 시한폭탄이 됩니다. 넷째, .NET의 JapaneseCalendar가 다룰 수 있는 것은 메이지 원년(1868년) 9월 8일 이후이며, 그보다 오래된 생년월일 등은 연호 그대로는 올바르게 다룰 수 없습니다. 내부 처리와 저장은 서기(그레고리력)로 통일하고, 연호는 화면과 장표에 내는 순간에만 변환하는 것이 원칙입니다.
- 다음 연호 변경이 오면 앱은 어떻게 대비하면 되나요?
- 연호 정의 자체는 .NET에 하드코딩되어 있지 않고 OS 쪽 연호 정보를 참조하는 구조이므로, OS와 .NET 업데이트를 적용해 두면 「새 연호로 표시」 자체는 따라갑니다. 레이와 변경 때도 대응의 중심은 플랫폼 업데이트 적용이었습니다. 문제는 앱 쪽 코드와 데이터입니다. 「헤세이」「레이와」를 문자열로 하드코딩한 분기, 연호와 서기 대응표를 자체로 가진 변환 함수, 장표 템플릿에 직접 쓴 연호, 이미 인쇄한 양식의 「레이와 년」 란 등은 자동으로 따라가지 않습니다. 대비로는 (1) 내부·저장을 서기로 통일하고 연호는 표시 전용으로 두기, (2) 자체 연호 변환을 그만두고 JapaneseCalendar에 맞추기, (3) 소스 전체를 「쇼와·헤세이·레이와」로 grep해 점검하기, (4) 가상의 연호 전환을 가정한 테스트를 준비하기, 이 네 가지가 현실적입니다.
- 공휴일은 계산식으로 판정할 수 없나요? 왜 데이터로 두어야 하나요?
- 완전한 계산식은 원리적으로 만들 수 없습니다. 이유는 세 가지입니다. 첫째, 춘분과 추분은 천문 계산을 바탕으로 각의를 거쳐 전년에 확정하는 구조이며, 법률에 고정 날짜로 적혀 있지 않습니다. 둘째, 공휴일은 법 개정으로 추가·이동·개칭됩니다. 실제로 2016년 산의 날 신설, 2019년 천황탄생일의 12월 23일에서 2월 23일로의 이동(2019년에는 천황탄생일이 없음), 체육의 날에서 스포츠의 날로의 개칭이 있었습니다. 셋째, 2020년·2021년에는 특별조치법으로 바다의 날·스포츠의 날·산의 날이 올림픽에 맞춰 이동했으며, 「매년 제3월요일」 같은 규칙 자체가 임시로 덮어쓰이는 경우가 있습니다. 따라서 공휴일 판정은 「공휴일 마스터(데이터)+갱신 운영」으로 설계하고, 계산 로직은 대체휴일 등의 보조에 그치는 것이 맞습니다.
- AddMonths(1)을 썼더니 지급기일이 월말에서 어긋났습니다. 왜인가요?
- DateTime.AddMonths는 계산 결과 달에 존재하지 않는 날이 되면 그달의 마지막 날로 조정하는 사양이기 때문입니다. 1월 31일에 AddMonths(1)하면 2월 28일(윤년이면 29일)이 됩니다. 이것 자체는 올바른 동작이지만, 조정한 결과를 다시 다음 달로 넘기면 2월 28일에 AddMonths(1)한 결과는 3월 28일이 되어 「월말이라고 생각한 날짜가 28일로 바뀐」 채로 이후 계속 어긋납니다. 대책은 반복 계산에서 직전 결과에 더하지 말고 반드시 기준일(계약일이나 「매월 말일」이라는 정의)에서 매번 계산할 것, 「월말」을 날짜 리터럴이 아니라 DateTime.DaysInMonth로 도출할 것, 이 두 가지입니다.