<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: 인사책</title>
    <description>The latest articles on DEV Community by 인사책 (@insacheck).</description>
    <link>https://dev.to/insacheck</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4127384%2F70c8f203-2973-4619-b619-444e986fd31b.jpg</url>
      <title>DEV Community: 인사책</title>
      <link>https://dev.to/insacheck</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/insacheck"/>
    <language>en</language>
    <item>
      <title>Designing an Audit Trail for Attendance Edits Without Overwriting Source Events</title>
      <dc:creator>인사책</dc:creator>
      <pubDate>Thu, 15 Oct 2026 02:25:19 +0000</pubDate>
      <link>https://dev.to/insacheck/designing-an-audit-trail-for-attendance-edits-without-overwriting-source-events-19dk</link>
      <guid>https://dev.to/insacheck/designing-an-audit-trail-for-attendance-edits-without-overwriting-source-events-19dk</guid>
      <description>&lt;p&gt;An attendance correction looks like a simple update:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;attendance_records&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;clock_out_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'2026-10-15T18:04:00+09:00'&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;8412&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That query fixes the current row, but it also destroys the answer to several questions: What was recorded first? Who requested the correction? What evidence supported it? Who approved it? Which value was used for payroll before the change?&lt;/p&gt;

&lt;p&gt;For systems that affect pay, deleting that context is a design problem. A safer model treats the original clock event as immutable and records corrections as a separate, reviewable trail.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with two different kinds of truth
&lt;/h2&gt;

&lt;p&gt;Attendance software usually contains at least two truths:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Observed truth&lt;/strong&gt;: the raw event produced by a device, browser, QR scan, or administrator import.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Operational truth&lt;/strong&gt;: the approved interpretation used for schedules, overtime, and payroll.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Those values can disagree without either being useless. A raw 18:47 clock-out may be genuine, while an approved 18:00 work end may reflect an employee who forgot to clock out after changing clothes. The system should preserve both facts instead of rewriting one into the other.&lt;/p&gt;

&lt;p&gt;In Korean workplaces, teams often search for these tools as &lt;strong&gt;출퇴근기록기&lt;/strong&gt; (clock-in recorder) or &lt;strong&gt;근태관리기&lt;/strong&gt; (attendance management tool). Whatever label the product uses, the important architectural question is the same: does a correction replace evidence, or does it add a decision to the evidence?&lt;/p&gt;

&lt;h2&gt;
  
  
  Model source events as append-only data
&lt;/h2&gt;

&lt;p&gt;A source event should identify where it came from and when the server received it. Device time alone is not enough because a phone clock can be wrong or an offline client may synchronize later.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;attendance_events&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;                &lt;span class="n"&gt;BIGSERIAL&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;employee_id&lt;/span&gt;       &lt;span class="nb"&gt;BIGINT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;event_type&lt;/span&gt;        &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event_type&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'clock_in'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'clock_out'&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
  &lt;span class="n"&gt;occurred_at&lt;/span&gt;       &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;received_at&lt;/span&gt;       &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="n"&gt;source_type&lt;/span&gt;       &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;source_reference&lt;/span&gt;  &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;payload_hash&lt;/span&gt;      &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt;        &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The application role that inserts these rows should not have permission to update or delete them. Retention and privacy rules may eventually require controlled deletion, but that should be a separate administrative process rather than an ordinary edit endpoint.&lt;/p&gt;

&lt;h2&gt;
  
  
  Put corrections in their own workflow
&lt;/h2&gt;

&lt;p&gt;A correction request is not another clock event. It is a proposal to interpret one or more source events differently.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;attendance_corrections&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;                  &lt;span class="n"&gt;BIGSERIAL&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;employee_id&lt;/span&gt;         &lt;span class="nb"&gt;BIGINT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;work_date&lt;/span&gt;           &lt;span class="nb"&gt;DATE&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;requested_clock_in&lt;/span&gt;  &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;requested_clock_out&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;reason_code&lt;/span&gt;         &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;reason_detail&lt;/span&gt;       &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;requested_by&lt;/span&gt;        &lt;span class="nb"&gt;BIGINT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;requested_at&lt;/span&gt;        &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="n"&gt;status&lt;/span&gt;              &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'pending'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'approved'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'rejected'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'cancelled'&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
  &lt;span class="n"&gt;decided_by&lt;/span&gt;          &lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;decided_at&lt;/span&gt;          &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;decision_note&lt;/span&gt;       &lt;span class="nb"&gt;TEXT&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The correction should reference the affected source events through a join table. This matters when one workday has duplicate scans, a missing clock-out, or a shift that crosses midnight.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build the effective record instead of mutating it
&lt;/h2&gt;

&lt;p&gt;Read models can combine raw events with the latest approved correction. The calculation can live in a database view, a materialized projection, or an application service.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;effective attendance
  = original source events
  + latest approved correction
  + policy version used for interpretation
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keeping the policy version is important. A record calculated under a 30-minute break rule should not silently change when the company later adopts a 45-minute rule.&lt;/p&gt;

&lt;p&gt;For a practical example of the employee-facing recording flow, this &lt;a href="https://insacheck.com/attendance" rel="noopener noreferrer"&gt;attendance record management guide&lt;/a&gt; is useful context. The implementation detail to preserve is the boundary between captured events and later administrative decisions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make state transitions explicit
&lt;/h2&gt;

&lt;p&gt;Do not let clients set any status they want. Define legal transitions in one domain function:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pending&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;approved&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;rejected&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;cancelled&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;allowed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Status&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;approved&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;rejected&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;cancelled&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;approved&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt;
  &lt;span class="na"&gt;rejected&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt;
  &lt;span class="na"&gt;cancelled&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;canTransition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Status&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;allowed&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If an approved correction is wrong, create a new correction that supersedes it. Reopening and editing the old decision makes the audit trail ambiguous.&lt;/p&gt;

&lt;h2&gt;
  
  
  Protect the workflow from concurrency bugs
&lt;/h2&gt;

&lt;p&gt;Two managers can open the same pending request and approve different values. Use optimistic locking or a conditional update:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;attendance_corrections&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'approved'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;decided_by&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;decided_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'pending'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The update must affect exactly one row. A zero-row result means the request was already handled and the client should refresh instead of pretending the approval succeeded.&lt;/p&gt;

&lt;p&gt;For payroll export, store a snapshot identifier or cutoff time. Otherwise, an approval that arrives during export can create a file that cannot be reproduced later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Log business meaning, not just HTTP requests
&lt;/h2&gt;

&lt;p&gt;An infrastructure log saying &lt;code&gt;PATCH /attendance/8412 200&lt;/code&gt; is not enough. Emit a domain event with the decision context:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"event"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"attendance_correction_approved"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"correction_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;392&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"employee_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;117&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"work_date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-10-15"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"previous_effective_clock_out"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-10-15T18:47:12+09:00"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"new_effective_clock_out"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-10-15T18:00:00+09:00"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"reason_code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"forgot_to_clock_out"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"decided_by"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"policy_version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"attendance-policy-2026-09"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Avoid copying free-form notes, precise location data, or device identifiers into every analytics system. The audit store can retain necessary evidence with stricter access controls, while downstream events carry only the fields needed for operations.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test invariants, not only endpoints
&lt;/h2&gt;

&lt;p&gt;The most valuable tests assert properties that must remain true across refactors:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A source event cannot be updated through the normal application role.&lt;/li&gt;
&lt;li&gt;A pending correction can be decided only once.&lt;/li&gt;
&lt;li&gt;A rejected correction never changes the effective attendance record.&lt;/li&gt;
&lt;li&gt;An approved correction does not erase the referenced source events.&lt;/li&gt;
&lt;li&gt;Payroll export can be reproduced from a recorded cutoff and policy version.&lt;/li&gt;
&lt;li&gt;A manager cannot approve a correction outside the manager's authorized team.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Property-based tests are particularly useful around overnight shifts, duplicate events, daylight-saving transitions, and late offline synchronization.&lt;/p&gt;

&lt;h2&gt;
  
  
  A small implementation checklist
&lt;/h2&gt;

&lt;p&gt;Before shipping an attendance correction feature, verify that the system can answer these questions without reading application logs by hand:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;What did the employee or device originally submit?&lt;/li&gt;
&lt;li&gt;When did the server receive it?&lt;/li&gt;
&lt;li&gt;What replacement value was requested, and why?&lt;/li&gt;
&lt;li&gt;Who approved or rejected the request?&lt;/li&gt;
&lt;li&gt;Which policy version produced the payable result?&lt;/li&gt;
&lt;li&gt;Which version was included in a specific payroll export?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If any answer disappears after an edit, the design is still overwriting history.&lt;/p&gt;

&lt;p&gt;The goal is not to implement event sourcing everywhere. It is to preserve the few boundaries that matter: observations remain observations, decisions remain decisions, and payroll results remain reproducible. That separation makes corrections easier to explain, safer to authorize, and much less painful to debug.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>database</category>
      <category>architecture</category>
      <category>backend</category>
    </item>
    <item>
      <title>근태 데이터에서 월말 오류를 줄이는 4가지 검증 규칙</title>
      <dc:creator>인사책</dc:creator>
      <pubDate>Wed, 16 Sep 2026 07:00:56 +0000</pubDate>
      <link>https://dev.to/insacheck/geuntae-deiteoeseo-weolmal-oryureul-julineun-4gaji-geomjeung-gyucigabe-2101</link>
      <guid>https://dev.to/insacheck/geuntae-deiteoeseo-weolmal-oryureul-julineun-4gaji-geomjeung-gyucigabe-2101</guid>
      <description>&lt;p&gt;근태 관리는 단순히 출근과 퇴근 시간을 저장하는 기능으로 끝나지 않습니다. 월말에 확인해야 할 것은 누가 몇 시간을 일했는가뿐 아니라, 수정이 왜 발생했는지와 어떤 기준으로 확정됐는지입니다. 이 글은 근태 데이터를 다루는 개발·운영 팀이 오류를 줄이기 위해 적용할 수 있는 검증 규칙을 정리한 메모입니다.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. 원본 이벤트와 수정 요청을 덮어쓰지 않습니다
&lt;/h2&gt;

&lt;p&gt;출근 기록을 수정할 때 기존 시간을 바로 바꾸면, 나중에 오류의 원인을 되짚기 어렵습니다. 원본 이벤트와 수정 이벤트를 별도로 남기고, 어떤 기록이 현재 유효한지 상태값으로 판단하는 편이 안전합니다.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwf32gwdcsnx0mawj69b2.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwf32gwdcsnx0mawj69b2.jpg" alt="스마트폰으로 출근 기록을 확인하는 인사책 근태관리 예시" width="799" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;수정 요청에는 요청 사유, 요청 시각, 승인자, 승인 결과를 별도 필드로 둡니다. 이렇게 하면 시간값 하나를 고치는 일이 아니라, 검토 가능한 변경 이력을 만드는 일이 됩니다.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. 예외를 정상 흐름과 분리합니다
&lt;/h2&gt;

&lt;p&gt;지각, 외근, 교육, 누락, 휴가처럼 근태에 영향을 주는 예외는 정상 출퇴근 이벤트와 같은 방식으로 처리하면 집계가 흔들립니다. 예외 유형과 증빙 여부, 승인 상태를 분리하면 월말에 확인할 대상이 훨씬 선명해집니다.&lt;/p&gt;

&lt;p&gt;예를 들어 &lt;code&gt;missing_check_in&lt;/code&gt;, &lt;code&gt;remote_work&lt;/code&gt;, &lt;code&gt;leave&lt;/code&gt;, &lt;code&gt;manual_correction&lt;/code&gt;처럼 상태를 구분하고, 자동 집계 전에 미승인 예외만 모아 검토하는 방식이 좋습니다.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. 주 단위 집계를 월말보다 먼저 확인합니다
&lt;/h2&gt;

&lt;p&gt;월말에 한 번에 오류를 찾으면 수정 범위가 커집니다. 주 단위로 근무시간, 휴게시간, 연장근로 가능성을 확인하면 데이터 문제와 운영 문제를 일찍 분리할 수 있습니다. 한국의 근무시간 한도와 월말 점검 항목은 &lt;a href="https://insacheck.com/guides/attendance/%EC%A3%BC52%EC%8B%9C%EA%B0%84-%EA%B7%BC%EB%AC%B4%EC%8B%9C%EA%B0%84-%EA%B3%84%EC%82%B0" rel="noopener noreferrer"&gt;인사책의 주 52시간 근무시간 계산 가이드&lt;/a&gt;처럼 제도 기준과 기록 검증 절차를 함께 보는 것이 실무적입니다.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. 확정 결과에는 승인 근거를 남깁니다
&lt;/h2&gt;

&lt;p&gt;집계가 끝났다고 해서 데이터가 설명 가능한 상태가 되는 것은 아닙니다. 담당자가 왜 이 시간이 확정됐는가를 질문받았을 때 원본 이벤트, 수정 요청, 승인 이력을 순서대로 확인할 수 있어야 합니다.&lt;/p&gt;

&lt;p&gt;이 규칙은 규모가 작은 팀에서도 유용합니다. 처음부터 복잡한 시스템을 만들기보다, 변경을 덮어쓰지 않고 예외를 분리하며 주 단위 검토를 반복하는 것만으로도 월말 확인 비용을 줄일 수 있습니다.&lt;/p&gt;

&lt;p&gt;실제 운영 흐름에 맞춰 출퇴근 기록과 예외 처리 기준을 정리하려는 팀은 &lt;a href="https://insacheck.com/attendance" rel="noopener noreferrer"&gt;인사책 출퇴근 기록 관리 안내&lt;/a&gt;에서 기본 기능과 적용 방식을 확인할 수 있습니다.&lt;/p&gt;

&lt;p&gt;기록의 정확성은 더 많은 입력 화면보다, 나중에 설명할 수 있는 검증 흐름에서 나옵니다.&lt;/p&gt;

</description>
      <category>productivity</category>
      <category>testing</category>
      <category>database</category>
    </item>
  </channel>
</rss>
