트랙 BB4180분

통제 점검 자동화

실습 26에서 Orbit 로그를 검사했을 때 “병합 전에 승인 리뷰가 있다” 규칙의 위반은 0건이었다. 그 로그의 사례는 업무 이슈였고, PR 안의 커밋 순서와 검사 결과는 들어 있지 않았다. 사례를 PR로 바꾸고 커밋과 검사 사건을 넣으면 같은 6~8월의 PR에서 무엇이 보이는가. 그리고 이 점검을 릴리스 때마다 사람이 하지 않고 매주 빠짐없이 하려면 무엇이 필요한가. 이 모듈은 PR 단위 이벤트 로그를 만들고, 통제 목표를 규칙 파일과 4-eyes 검사로 적어 전수 점검하고, 예외를 기록으로 처리하고, 점검을 주간 워크플로로 옮긴다.

진행
설명 40분 (1단계, 6단계), 실습 120분 (2~5단계, 7단계), 정리 20분 (8단계)
결과
PR 단위 이벤트 로그, 통제 규칙 파일, 4-eyes 점검 결과, .github/workflows/control-check.yml, 분기 통제 점검 보고
명령
pr-event-log.sh, process-map.sh --rules, four-eyes.sh, gh label create, gh workflow run, gh issue comment
cd ~/gh-pm-lab
source pm-lab/env.sh

1점검은 누가, 얼마나, 무엇을 대상으로 하는가

COSO 2013의 모니터링 활동은 두 원칙으로 이뤄진다. 원칙 16은 통제가 작동하는지 지속적 평가(ongoing evaluation)나 개별 평가(separate evaluation)로 확인하라고 하고, 원칙 17은 찾은 결함을 평가해 고칠 책임이 있는 사람에게 제때 알리라고 한다. 3선 모델에 대응시키면 다음과 같다.

역할점검에서 하는 일이 모듈에서
1선 (개발자, PM)규칙 아래에서 일하고, 예외가 생기면 그 자리에서 기록한다PR의 예외 기록 댓글
2선 (품질·보안 담당)주기적으로 점검 결과를 검토하고 예외를 인정하거나 시정을 요구한다주간 점검 이슈의 결정 기록 댓글
3선 (내부감사)점검이 설계대로 돌았는지 같은 데이터로 독립적으로 다시 수행한다분기 통제 점검 보고의 재수행

지속적 통제 모니터링(continuous controls monitoring, CCM)은 통제가 지켜졌는지를 시스템 기록으로 자동 판정해 주기적으로 보고하는 방식이다. 사람이 하는 점검은 보통 표본 점검이다. 모집단에서 일부를 골라 확인하고 그 결과로 전체를 추정한다. 기록이 모두 기계로 읽히면 전수 점검이 가능하다. 아래 계산은 PR 129건 중 8건이 어떤 통제든 하나 이상 어겼다고 할 때, 무작위 표본이 위반을 하나라도 포함할 확률이다. 129와 8은 이 모듈의 4단계에서 얻는 숫자다.

python3 -c 'from math import comb
N, bad = 129, 8
for n in (10, 25, 40):
    miss = comb(N - bad, n) / comb(N, n)
    print(n, "건 표본: 위반 포함", round(1 - miss, 3), "/ 위반 없음", round(miss, 3))'
10 건 표본: 위반 포함 0.485 / 위반 없음 0.515
25 건 표본: 위반 포함 0.831 / 위반 없음 0.169
40 건 표본: 위반 포함 0.954 / 위반 없음 0.046

25건을 뽑아도 위반을 하나도 보지 못할 확률이 0.169다. 위반을 하나 찾더라도 나머지 몇 건이 어디에 있는지는 모른다. 전수 점검은 위반 목록 전체를 준다. 대신 다른 질문이 생긴다. 모집단이 완전한가. PR을 거치지 않고 main에 들어간 커밋은 PR 목록에 없다. 기간의 main 커밋마다 연결된 PR이 있는지 확인하는 완전성 검사를 함께 둔다.

gh api "repos/$PM_REPO/commits?sha=main&since=2026-10-12T00:00:00Z&until=2026-11-12T00:00:00Z&per_page=100" \
  --paginate --jq '.[].sha' \
  | while read -r sha; do
      n=$(gh api "repos/$PM_REPO/commits/$sha/pulls" --jq 'length')
      [ "$n" -gt 0 ] || echo "PR 없이 들어온 커밋: $sha"
    done

2PR 단위 이벤트 로그

실습 22의 이벤트 로그는 업무 이슈 하나를 사례로 삼았다. 통제 점검의 대상은 main에 들어간 변경이므로 사례를 병합된 PR 하나로 바꾼다. pr-event-log.sh는 event-log.sh와 같은 열(case_id, activity, timestamp, resource, ref, area)로 PR의 사건을 적는다.

활동원천resource
PR 생성createdAt작성자
커밋commits[].committedDate. 커밋 시각이며 푸시 시각과 다를 수 있다커밋 작성자
검사 통과, 검사 실패마지막 커밋의 statusCheckRollup을 모은 사건 하나. 하나라도 통과하지 않았으면 실패ci
변경 요청, 리뷰 승인reviews[]의 CHANGES_REQUESTED, APPROVED리뷰어
예외 기록본문에 “예외 기록”이 들어 있는 PR 댓글댓글 작성자
PR 병합mergedAt병합한 사람

ref 열에는 PR이 닫는 업무 번호를, area 열에는 PR의 area: 라벨을 적는다. 저장소에서 바로 만들 때는 bash github-pm-starter-kit/pr-event-log.sh "$PM_REPO" --since 2026-10-12 --until 2026-11-11처럼 실행한다. 여기서는 B3에서 받은 Launchpad의 릴리스 PR 파일을 그대로 읽는다.

#22는 리뷰 승인(10:00) 뒤에 커밋(10:20)이 있고, #26은 리뷰 승인 없이 병합되었다. B3의 추적 보고가 찾은 두 빈 곳이 사건 순서로 보인다. B3의 PR 파일에는 댓글(comments)이 없어 예외 기록 사건이 들어 있지 않다. 저장소에서 바로 만들면 댓글까지 읽는다.

3통제 목표를 규칙 파일로 적고 검사하기

실습 26의 process-rules.tsv 형식으로 통제 목표 세 개를 적는다.

# 통제 점검 규칙 (트랙 B4). process-map.sh --rules 형식이며 열 구분은 탭이다.
# 사례는 병합된 PR 하나다 (pr-event-log.sh). 커밋은 커밋 시각, 검사는 마지막 커밋의 검사 결과다.
# 유형        A        B                     기한(작업일)  설명
# precedence  B가 나올 때마다 그 앞(직전 B 이후)에 A가 있어야 한다
# response    A가 나오면 그 뒤에 B가 나와야 한다
# type	A	B	limit	description	scope
precedence	리뷰 승인	PR 병합	-	병합 전에 승인 리뷰가 있다
response	커밋	리뷰 승인	-	마지막 커밋 뒤에 승인 리뷰가 있다
precedence	검사 통과	PR 병합	-	병합 전에 검사가 통과했다

둘째 규칙은 response 유형이다. 커밋마다 그 뒤에 승인이 있어야 하므로, 마지막 커밋 뒤에 승인이 없으면 위반이다. 승인 뒤에 커밋을 더하고 다시 승인받지 않은 PR이 걸린다. 셋째 규칙의 검사 사건은 마지막 커밋의 검사 결과이므로, 병합 직전 코드의 검사가 통과했는지를 본다.

예시 데이터 samples/b-pr-events.csv는 2단계 Orbit 팀이 2026년 6월 1일부터 8월 28일까지 13주 동안 병합한 PR의 PR 단위 로그다. 실습 26과 같은 팀, 같은 기간의 PR이다.

cp github-pm-starter-kit/samples/b-pr-events.csv pm-lab/
cp github-pm-starter-kit/samples/b-control-rules.tsv pm-lab/control-rules.tsv
bash github-pm-starter-kit/process-map.sh pm-lab/b-pr-events.csv --rules pm-lab/control-rules.tsv \
  | sed -n '1,6p;/## 적합성 검사/,$p'
# 프로세스 지도

- 사례(업무) 129건, 사건 849건, 활동 8종
- 기간: 2026-05-27 09:24 ~ 2026-08-28 17:37
- 변형 15개, 가장 많은 변형이 사례 38건

## 적합성 검사

| 규칙 | 내용 | 위반 사례 |
| - | - | - |
| precedence 리뷰 승인 / PR 병합 | 병합 전에 승인 리뷰가 있다 | 없음 |
| response 커밋 / 리뷰 승인 | 마지막 커밋 뒤에 승인 리뷰가 있다 | 3건: #1126, #1187, #1230 |
| precedence 검사 통과 / PR 병합 | 병합 전에 검사가 통과했다 | 3건: #1144, #1151, #2157 |

규칙을 모두 지킨 사례: 123/129건

PR 129건 중 123건이 세 규칙을 모두 지켰다. “병합 전에 승인 리뷰가 있다”는 실습 26처럼 위반이 없다. 사례를 PR로 바꾸고 커밋과 검사를 넣자 다른 두 규칙에서 3건씩 나왔다. 검사 규칙의 위반 3건을 로그에서 직접 연다.

grep -E '^#(1144|1151|2157),' pm-lab/b-pr-events.csv
#1144,커밋,2026-06-10T12:28:00+09:00,dev-d,#144,engineering
#1144,커밋,2026-06-12T14:15:00+09:00,dev-d,#144,engineering
#1144,커밋,2026-06-15T09:41:00+09:00,dev-d,#144,engineering
#1144,PR 생성,2026-06-15T10:00:00+09:00,dev-d,#144,engineering
#1144,검사 실패,2026-06-15T10:19:00+09:00,ci,#144,engineering
#1144,리뷰 승인,2026-06-15T13:06:00+09:00,dev-b,#144,engineering
#1144,PR 병합,2026-06-15T13:16:00+09:00,dev-d,#144,engineering
#1151,커밋,2026-06-26T11:42:00+09:00,dev-d,#151,engineering
#1151,커밋,2026-06-29T09:16:00+09:00,dev-d,#151,engineering
#1151,커밋,2026-06-29T09:17:00+09:00,dev-d,#151,engineering
#1151,PR 생성,2026-06-29T11:09:00+09:00,dev-d,#151,engineering
#1151,변경 요청,2026-06-29T11:52:00+09:00,dev-a,#151,engineering
#1151,커밋,2026-06-29T12:40:00+09:00,dev-d,#151,engineering
#1151,검사 실패,2026-06-29T13:02:00+09:00,ci,#151,engineering
#1151,예외 기록,2026-06-30T09:45:00+09:00,pm-oh,#151,engineering
#1151,리뷰 승인,2026-06-30T11:45:00+09:00,dev-a,#151,engineering
#1151,PR 병합,2026-06-30T13:26:00+09:00,dev-d,#151,engineering
#2157,커밋,2026-06-25T11:38:00+09:00,dev-e,#157,design
#2157,커밋,2026-06-25T17:41:00+09:00,dev-e,#157,design
#2157,커밋,2026-06-25T18:05:00+09:00,dev-e,#157,design
#2157,PR 생성,2026-06-26T10:03:00+09:00,dev-e,#157,design
#2157,리뷰 승인,2026-06-26T10:35:00+09:00,dev-c,#157,design
#2157,PR 병합,2026-06-26T11:54:00+09:00,dev-e,#157,design
PR로그가 말하는 것확인할 것
#1144마지막 커밋의 검사 실패(10:19) 뒤 승인(13:06)과 병합(13:16)실패한 검사가 필수 검사였는가. 필수였다면 누가 우회했는가
#1151검사 실패(06-29 13:02) 다음 날 pm-oh의 예외 기록(09:45), 그 뒤 승인과 병합예외 기록의 이유와 사후 조치가 지켜졌는가
#2157검사 사건이 아예 없다. design 영역 PRCI 워크플로가 경로 조건 때문에 이 PR에서 돌지 않았는가. 그렇다면 필수 검사가 “없음”을 통과로 처리했는가

규칙 파일은 위반 PR을 찾지만, 예외 기록이 있는지는 판정하지 않는다. #1151처럼 예외 기록이 있는 위반은 다음 단계의 4-eyes 점검 결과에서 대조한다.

44-eyes 원칙 전수 점검

“작성자와 승인자가 다르다”는 사건 순서만으로는 적을 수 없다. 누가 커밋했고 누가 승인했는지 사람을 비교해야 한다. 실습 26의 PM4Py filter_four_eyes_principle은 두 활동의 사람이 같은 사례를 찾았다. GitHub에서는 PR 작성자가 자기 PR을 승인할 수 없으므로 그 검사는 늘 0건이다. four-eyes.sh는 기준을 넓혀, 마지막 커밋 뒤에 그 PR에 커밋하지 않은 사람의 승인이 있는지 본다.

bash github-pm-starter-kit/four-eyes.sh pm-lab/b-pr-events.csv
# 4-eyes 점검

- 병합된 PR 129건 (병합 2026-06-01 16:59 ~ 2026-08-28 17:37), 전수 점검
- 통과 124건, 위반 5건 (예외 처리 1건, 미해결 4건)
  - 마지막 커밋 뒤 승인 없음: 3건 (#1126, #1187, #1230)
  - 승인자가 커밋 작성자: 2건 (#1119, #1162)

## 위반

| PR | 업무 | 작성자 | 커밋한 사람 | 마지막 커밋 | 승인 | 병합 | 판정 | 예외 기록 |
| - | - | - | - | - | - | - | - | - |
| #1119 | #119 | dev-e | dev-b, dev-e | 06-02 14:53 dev-b | 06-03 11:42 dev-b | 06-03 11:53 | 승인자가 커밋 작성자 | **없음** |
| #1126 | #126 | dev-a | dev-a | 06-12 13:20 dev-a | 06-12 13:03 dev-b | 06-12 13:48 | 마지막 커밋 뒤 승인 없음 | 06-12 13:40 pm-oh |
| #1162 | #162 | pm-oh | dev-b, pm-oh | 06-29 16:03 dev-b | 06-30 14:25 dev-b | 06-30 15:55 | 승인자가 커밋 작성자 | **없음** |
| #1187 | #187 | dev-a | dev-a | 07-29 10:06 dev-a | 07-29 09:53 dev-f | 07-29 10:36 | 마지막 커밋 뒤 승인 없음 | **없음** |
| #1230 | #230 | dev-c | dev-c | 08-20 16:23 dev-c | 08-20 16:12 dev-d | 08-20 16:42 | 마지막 커밋 뒤 승인 없음 | **없음** |

미해결 위반 4건: #1119, #1162, #1187, #1230. 점검 이슈에 원인과 시정 조치를 결정 기록으로 남긴다.

## 예외 기록이 있는 PR

다른 규칙(process-map.sh --rules)의 위반과 대조한다.

| PR | 업무 | 예외 기록 | 4-eyes 판정 |
| - | - | - | - |
| #1126 | #126 | 06-12 13:40 pm-oh | 마지막 커밋 뒤 승인 없음 |
| #1151 | #151 | 06-30 09:45 pm-oh | 통과 |
판정PR뜻
마지막 커밋 뒤 승인 없음#1126, #1187, #1230승인 뒤에 작성자가 커밋을 더하고 다시 승인받지 않았다. 규칙 파일의 둘째 규칙과 같은 PR이다
승인자가 커밋 작성자#1119, #1162리뷰어가 변경 요청 뒤 직접 수정 커밋을 하고 그 커밋을 승인했다. 규칙 파일로는 찾지 못한 위반이다

#1126은 pm-oh의 예외 기록이 있어 예외 처리로 세었고, 미해결은 4건이다. 예외 기록 표의 #1151은 4-eyes는 통과했지만 3단계의 검사 규칙을 어긴 PR이다. 두 점검의 위반 PR을 합치면 몇 건인지 센다.

{ bash github-pm-starter-kit/process-map.sh pm-lab/b-pr-events.csv --rules pm-lab/control-rules.tsv | sed -n '/## 적합성 검사/,$p'
  bash github-pm-starter-kit/four-eyes.sh pm-lab/b-pr-events.csv | sed -n '/## 위반/,/^미해결/p'
} | grep -oE '#[12][0-9]{3}' | sort -u | tr '\n' ' '; echo
#1119 #1126 #1144 #1151 #1162 #1187 #1230 #2157 

13주 동안 129건 중 8건이 세 통제 목표 중 하나 이상을 어겼다. 1단계의 표본 계산에 쓴 숫자가 이것이다. 실습 26의 업무 단위 로그에는 이 8건의 위반을 판정할 사건(PR 안의 커밋 순서, 커밋한 사람, 검사 결과)이 없었다. 사례의 단위와 기록한 사건의 종류가 점검할 수 있는 통제의 범위를 정한다.

2단계의 Launchpad 로그에도 같은 점검을 돌려 둔다. 7단계에서 저장소를 바로 읽은 결과와 비교한다.

사람을 평가하는 점검이 아니다

위반 표에는 아이디가 나온다. 점검의 목적은 통제의 설계와 운영에서 빈 곳을 찾는 것이다. #1119와 #1162의 리뷰어가 같은 사람이라도, 고칠 대상은 리뷰어가 커밋하고 그 커밋을 승인할 수 있게 둔 설정(B2의 require_last_push_approval)이다. 위반 건수를 개인별로 세는 표는 만들지 않는다.

5예외 처리: 기록하고 검토한다

예외로 인정하려면 정해진 절차로 통제를 건너뛰었다는 기록이 있어야 한다. 기록이 없으면 위반이다. 1선은 예외가 생긴 PR에 정해진 양식의 댓글을 남기고, 2선은 주간 점검 이슈에서 그 기록을 검토해 인정하거나 시정을 요구한다. four-eyes.sh는 PR에 커밋하지 않은 사람이 쓴 예외 기록만 인정한다. 작성자가 스스로 쓴 예외 기록은 “있음 (커밋한 사람이 작성)”으로 표시하고 미해결로 센다.

gh pr comment 26 -R "$PM_REPO" --body-file - <<'EOF'
**예외 기록**
- 대상: #26 (승인 없이 관리자 우회로 병합)
- 이유: 베타 초대 메일 발송(11-11 12:00) 전에 링크 오류를 고쳐야 한다. 리뷰어 두 명이 외근 중이다.
- 확인: test, e2e 통과. 변경은 메일 템플릿의 링크 한 줄이다.
- 사후 조치: dev-lee가 11-12까지 사후 리뷰를 남긴다.
- 승인: gildong (PM)
EOF

점검 이슈의 검토는 Level 1의 결정 기록 양식으로 남긴다. 예외 인정과 시정 조치를 PR마다 적는다.

gh issue comment 27 -R "$PM_REPO" --body-file - <<'EOF'
**결정 기록**
- 일시: 2026-11-16
- 결정: #26은 예외로 인정한다(사후 리뷰 11-12 확인). #22는 시정한다. 규칙 세트 4211의 dismiss_stale_reviews_on_push와 require_last_push_approval을 켠다
- 이유: #22는 승인 뒤 커밋이 다시 검토되지 않았다. 같은 설정 결함이 B2 통제 매트릭스에 있다
- 결정자: (2선 담당)
- 관련: PR #22, #26, 규칙 세트 4211
- 목표·일정 영향: 없음
EOF
gh issue close 27 -R "$PM_REPO" --reason completed

6외부 기준과의 대응

감사나 인증을 받는 조직은 통제를 외부 기준의 항목에 대응시켜 보인다. 아래는 이 트랙의 통제를 자주 쓰는 두 기준에 대응시킨 출발점이다. 어느 항목을 충족했는지는 감사인이 증적을 보고 판단한다.

기준 항목요구 (요약)트랙 B의 통제증적
ISO/IEC 27001:2022 부속서 A 5.3 직무 분리(Segregation of duties)상충하는 직무와 책임 영역을 분리한다작성자와 다른 사람의 승인, 마지막 푸시 승인four-eyes.sh 결과, 규칙 세트
A 8.4 소스 코드 접근(Access to source code)소스 코드, 개발 도구, 라이브러리의 읽기·쓰기 접근을 적절히 관리한다저장소 역할, 협업자 명단 대조, 토큰 관리control-matrix.sh의 접근 권한 표
A 8.25 보안 개발 생애주기(Secure development life cycle)소프트웨어와 시스템의 안전한 개발 규칙을 정하고 적용한다필수 검사, 코드 소유자 리뷰, 산출물 증명gh ruleset check, 추적 보고의 검사 열
A 8.32 변경 관리(Change management)정보 처리 시설과 정보 시스템의 변경은 변경 관리 절차를 따른다B1의 변경 통제 절차, 승인 뒤 병합, 주간 점검변경 대장 대조, process-map.sh --rules 결과, 점검 이슈
SOC 2 공통 기준 CC8.1 (AICPA 2017 Trust Services Criteria)인프라, 데이터, 소프트웨어, 절차의 변경을 승인하고, 설계·개발하고, 구성하고, 문서화하고, 시험하고, 승인을 거쳐 반영한다변경 요청, 리뷰 승인, 필수 검사, 릴리스 추적릴리스 추적 보고, 분기 통제 점검 보고

COSO 원칙으로 보면 규칙 세트와 필수 검사는 원칙 11(기술에 대한 일반 통제), 주간 점검은 원칙 16(지속적 평가), 점검 이슈의 결정 기록은 원칙 17(결함의 평가와 전달)에 해당한다.

7매주 자동 점검

실습 29의 process-report.yml처럼 키트 스크립트를 저장소에 두고 예약 워크플로로 돌린다. 이 워크플로는 지난 7일 동안 병합된 PR을 모두 읽어 규칙 검사와 4-eyes 점검을 하고, 위반이 하나라도 있으면 kind:control 라벨의 이슈를 만든다. 위반이 없으면 실행 요약에만 결과를 남긴다.

# 매주 월요일 아침, 지난 7일 동안 병합된 PR을 모두 점검하고 위반이 있으면 kind:control 이슈를 만든다 (트랙 B4).
#
# 설치:
#   1. 키트의 pr-event-log.sh, process-map.sh, four-eyes.sh, lib/ 와 팀의 통제 규칙 파일을
#      저장소의 .github/pm-kit/ 폴더에 복사한다. 규칙 파일 이름은 control-rules.tsv로 한다
#      (예: cp samples/b-control-rules.tsv .github/pm-kit/control-rules.tsv).
#   2. 이 파일을 .github/workflows/control-check.yml로 함께 올린다.
#   3. 라벨을 만든다: gh label create kind:control --color B60205 --description "통제 점검 결과"
# 같은 저장소의 PR을 읽고 이슈를 만들므로 기본 토큰(GITHUB_TOKEN)으로 동작한다.
# 예외 처리와 시정 조치는 만들어진 이슈에 결정 기록 댓글로 남긴다.
name: control-check

on:
  schedule:
    # 월요일 00:23 UTC = 09:23 한국 시각
    - cron: "23 0 * * 1"
  workflow_dispatch:
    inputs:
      since:
        description: "이 날짜부터 병합된 PR을 점검한다 (YYYY-MM-DD, 비우면 7일 전)"
        required: false
        default: ""
      until:
        description: "이 날짜까지 병합된 PR을 점검한다 (YYYY-MM-DD, 비우면 어제)"
        required: false
        default: ""

permissions:
  contents: read
  pull-requests: read
  issues: write

concurrency:
  group: control-check
  cancel-in-progress: false

jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: PR 단위 이벤트 로그와 통제 점검
        id: check
        env:
          GH_TOKEN: ${{ github.token }}
          SINCE_INPUT: ${{ inputs.since }}
          UNTIL_INPUT: ${{ inputs.until }}
        run: |
          kit=.github/pm-kit
          since=${SINCE_INPUT:-$(date -u -d '7 days ago' +%F)}
          until=${UNTIL_INPUT:-$(date -u -d 'yesterday' +%F)}
          echo "since=$since" >> "$GITHUB_OUTPUT"
          echo "until=$until" >> "$GITHUB_OUTPUT"
          bash "$kit/pr-event-log.sh" "$GITHUB_REPOSITORY" --since "$since" --until "$until" > pr-events.csv
          prs=$(awk -F, 'NR > 1 { print $1 }' pr-events.csv | sort -u | wc -l | tr -d ' ')
          if [ "$prs" -eq 0 ]; then
            echo "$since ~ $until 에 병합된 PR이 없다." | tee -a "$GITHUB_STEP_SUMMARY"
            echo "rule_bad=0" >> "$GITHUB_OUTPUT"
            echo "eyes_bad=0" >> "$GITHUB_OUTPUT"
            exit 0
          fi
          bash "$kit/process-map.sh" pr-events.csv --rules "$kit/control-rules.tsv" > process-map.md
          rule_bad=$(sed -n 's/^규칙을 모두 지킨 사례: \([0-9]*\)\/\([0-9]*\)건$/\1 \2/p' process-map.md | awk '{ print $2 - $1 }')
          eyes_bad=$(bash "$kit/four-eyes.sh" pr-events.csv --count)
          {
            echo "# 통제 점검: $since ~ $until"
            echo
            echo "- 저장소: $GITHUB_REPOSITORY, 병합된 PR ${prs}건 (전수)"
            echo "- 실행 기록: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"
            echo "- 규칙을 하나라도 어긴 PR ${rule_bad}건 (예외 기록 포함), 4-eyes 미해결 위반 ${eyes_bad}건"
            echo
            sed -n '/^## 적합성 검사/,$p' process-map.md
            echo
            bash "$kit/four-eyes.sh" pr-events.csv
            echo
            echo "## 검토"
            echo
            echo "위반마다 예외로 인정할지, 시정 조치를 할지 결정 기록 댓글로 남기고 이 이슈를 닫는다."
          } > report.md
          echo "rule_bad=$rule_bad" >> "$GITHUB_OUTPUT"
          echo "eyes_bad=$eyes_bad" >> "$GITHUB_OUTPUT"
          cat report.md >> "$GITHUB_STEP_SUMMARY"

      - name: 위반이 있으면 점검 이슈 만들기
        if: steps.check.outputs.rule_bad != '0' || steps.check.outputs.eyes_bad != '0'
        env:
          GH_TOKEN: ${{ github.token }}
          SINCE: ${{ steps.check.outputs.since }}
          UNTIL: ${{ steps.check.outputs.until }}
          RULE_BAD: ${{ steps.check.outputs.rule_bad }}
          EYES_BAD: ${{ steps.check.outputs.eyes_bad }}
        run: |
          gh issue create -R "$GITHUB_REPOSITORY" \
            --title "[통제 점검] $SINCE ~ $UNTIL: 규칙 위반 PR ${RULE_BAD}건, 4-eyes 미해결 ${EYES_BAD}건" \
            --body-file report.md \
            --label kind:control
부분하는 일
permissions코드와 PR은 읽기만 하고, 이슈만 쓴다. 기본 토큰(GITHUB_TOKEN)으로 충분하다
workflow_dispatch.inputs수동 실행 때 점검 기간을 정한다. 분기 점검을 같은 워크플로로 돌릴 때 쓴다
env로 입력 넘기기입력값을 run 안에 직접 쓰지 않고 환경 변수로 넘긴다. 입력에 셸 명령이 섞여도 실행되지 않는다
위반 수 세기규칙 검사는 “규칙을 모두 지킨 사례: X/Y건” 줄에서 Y − X를, 4-eyes는 four-eyes.sh --count(예외 처리되지 않은 위반 수)를 쓴다
gh issue create보고서를 본문으로, kind:control 라벨을 붙여 만든다
gh label create kind:control -R "$PM_REPO" --color B60205 --description "통제 점검 결과"
cd "$PM_REPO_NAME"
git switch main && git pull -q
git switch -c ci/control-check
mkdir -p .github/pm-kit .github/workflows
cp -R ../github-pm-starter-kit/{pr-event-log.sh,process-map.sh,four-eyes.sh,lib} .github/pm-kit/
cp ../pm-lab/control-rules.tsv .github/pm-kit/control-rules.tsv
cp ../github-pm-starter-kit/templates/workflows/control-check.yml .github/workflows/
git add .github
git commit -m "ci: 주간 통제 점검 워크플로 추가"
git push -u origin ci/control-check
gh pr create --fill
cd ..

PR을 병합한 뒤 기간을 정해 한 번 실행한다. 아래는 Launchpad 저장소에서 M1 기간으로 실행한 경우를 gh 2.101.0과 가짜 API 서버로 재현한 화면이다. 워크플로의 점검 단계는 키트 스크립트를 그대로 돌려 확인했다.

# 통제 점검: 2026-10-12 ~ 2026-11-11

- 저장소: gildong/launchpad-pm-lab, 병합된 PR 8건 (전수)
- 실행 기록: https://github.com/gildong/launchpad-pm-lab/actions/runs/11873402215
- 규칙을 하나라도 어긴 PR 4건 (예외 기록 포함), 4-eyes 미해결 위반 1건

## 적합성 검사

| 규칙 | 내용 | 위반 사례 |
| - | - | - |
| precedence 리뷰 승인 / PR 병합 | 병합 전에 승인 리뷰가 있다 | 1건: #26 |
| response 커밋 / 리뷰 승인 | 마지막 커밋 뒤에 승인 리뷰가 있다 | 2건: #22, #26 |
| precedence 검사 통과 / PR 병합 | 병합 전에 검사가 통과했다 | 2건: #21, #7 |

규칙을 모두 지킨 사례: 4/8건

# 4-eyes 점검

- 병합된 PR 8건 (병합 2026-10-16 10:30 ~ 2026-11-11 11:20), 전수 점검
- 통과 6건, 위반 2건 (예외 처리 1건, 미해결 1건)
  - 마지막 커밋 뒤 승인 없음: 1건 (#22)
  - 승인 없음: 1건 (#26)

## 위반

| PR | 업무 | 작성자 | 커밋한 사람 | 마지막 커밋 | 승인 | 병합 | 판정 | 예외 기록 |
| - | - | - | - | - | - | - | - | - |
| #22 | #3 | dev-kim | dev-kim | 11-06 10:20 dev-kim | 11-06 10:00 dev-lee | 11-06 10:30 | 마지막 커밋 뒤 승인 없음 | **없음** |
| #26 | - | dev-kim | dev-kim | 11-11 11:05 dev-kim | - | 11-11 11:20 | 승인 없음 | 11-11 11:16 gildong |

미해결 위반 1건: #22. 점검 이슈에 원인과 시정 조치를 결정 기록으로 남긴다.

## 예외 기록이 있는 PR

다른 규칙(process-map.sh --rules)의 위반과 대조한다.

| PR | 업무 | 예외 기록 | 4-eyes 판정 |
| - | - | - | - |
| #26 | - | 11-11 11:16 gildong | 승인 없음 |

## 검토

위반마다 예외로 인정할지, 시정 조치를 할지 결정 기록 댓글로 남기고 이 이슈를 닫는다.

4단계에서 B3의 파일로 만든 로그는 미해결 위반이 #22와 #26의 2건이었다. 저장소에서 바로 읽으면 PR 댓글까지 읽으므로, #26에 남긴 gildong의 예외 기록이 예외 처리로 세어져 미해결은 1건이 되었다. 규칙 검사의 위반 4건에는 예외 기록이 있는 #26도 들어 있다. 이슈를 검토하는 2선은 5단계의 결정 기록으로 PR마다 인정 또는 시정을 남기고 이슈를 닫는다.

8분기 통제 점검 보고

주간 이슈는 2선의 검토 기록이고, 분기 보고는 감독 기구와 3선에 내는 요약이다. 키트의 양식을 채운다.

# 분기 통제 점검 보고: (저장소), YYYY-MM-DD ~ YYYY-MM-DD

작성: (2선 담당) · 작성일: YYYY-MM-DD · 데이터: pr-events.csv (pr-event-log.sh), 점검 이슈 kind:control (트랙 B4)

## 1. 범위와 모집단

| 항목 | 값 |
| - | - |
| 대상 | 기본 브랜치에 병합된 PR 전부 (전수) |
| 모집단 | PR __건, 기간 __ ~ __ |
| 완전성 확인 | 기본 브랜치 커밋 중 PR 없이 들어온 커밋 __건 |
| 쓴 규칙 | control-rules.tsv (커밋: 날짜와 해시) |

## 2. 통제 목표별 결과

| 통제 목표 | 점검 방법 | 위반 | 예외 승인 | 미해결 |
| - | - | - | - | - |
| 병합 전에 승인 리뷰가 있다 | process-map.sh 규칙 1 | | | |
| 마지막 커밋 뒤에 승인 리뷰가 있다 | process-map.sh 규칙 2 | | | |
| 병합 전에 검사가 통과했다 | process-map.sh 규칙 3 | | | |
| 작성자와 승인자가 다르다 (4-eyes) | four-eyes.sh | | | |

## 3. 예외

| PR | 위반 | 예외 기록 (누가, 언제) | 사후 조치 | 확인 |
| - | - | - | - | - |

## 4. 미해결 위반과 시정 조치

| PR | 위반 | 원인 | 시정 조치 | 담당 | 기한 |
| - | - | - | - | - | - |

## 5. 설정 변경 제안

(예: 규칙 세트의 require_last_push_approval 켜기. 근거가 된 위반 PR 번호를 적는다.)

## 6. 판단

(통제가 기간 동안 설계대로 운영되었는가. 미해결 위반이 통제의 결함인지, 일회성 예외인지 적는다.)

Orbit의 13주로 2절을 채우면 아래와 같다. 숫자는 3~4단계의 실행 결과에서 옮겼다.

통제 목표점검 방법위반예외 기록미해결
병합 전에 승인 리뷰가 있다process-map.sh 규칙 1000
마지막 커밋 뒤에 승인 리뷰가 있다process-map.sh 규칙 23 (#1126, #1187, #1230)1 (#1126)2
병합 전에 검사가 통과했다process-map.sh 규칙 33 (#1144, #1151, #2157)1 (#1151)2
작성자와 승인자가 다르다four-eyes.sh5 (#1119, #1126, #1162, #1187, #1230)1 (#1126)4

5절의 설정 변경 제안은 위반의 원인에서 나온다. 마지막 커밋 승인 위반과 승인자 커밋 위반은 dismiss_stale_reviews_on_push와 require_last_push_approval을 켜면 예방 통제로 막힌다. 검사 위반은 실패한 검사를 필수 검사에 넣고, 경로 조건 때문에 검사가 돌지 않는 PR을 어떻게 처리할지 정해야 막힌다. 6절의 판단에는 이 설정을 바꾼 날짜를 적고, 다음 분기 보고에서 같은 위반이 사라졌는지 확인한다. 실습 26에서 규칙을 바꾼 날짜를 커밋으로 남겼던 것과 같은 이유다.

막히면
증상확인과 해결
pr-event-log.sh가 --json에 다음 필드가 빠졌다로 끝난다--from-json에 준 파일을 B3의 --json 목록으로 다시 받는다. comments와 labels는 없어도 된다
process-map.sh가 오류: 사건이 없다로 끝난다기간에 병합된 PR이 없다. 워크플로는 이 경우 점검을 건너뛰고 “병합된 PR이 없다”만 남긴다
모든 PR이 “마지막 커밋 뒤 승인 없음”이다커밋 시각이 승인보다 늦게 기록되었다. 리베이스나 스쿼시로 커밋 시각이 바뀌는 팀이면 커밋 시각 대신 B3처럼 승인한 커밋의 해시를 비교한다
워크플로가 HTTP 403: Resource not accessible by integration으로 이슈를 만들지 못한다permissions에 issues: write가 있는지, 조직 설정이 워크플로 토큰의 쓰기를 막는지 Settings → Actions → General에서 확인한다
could not add label: 'kind:control' not found라벨이 없다. gh label create kind:control을 먼저 실행한다
예외 기록을 남겼는데 예외 처리로 세지 않는다댓글 본문에 “예외 기록”이 들어 있는지, 그 PR에 커밋한 사람이 쓴 댓글이 아닌지 확인한다
  • 전수 점검과 표본 점검의 차이를 표본 계산 결과로 설명할 수 있다.
  • pr-event-log.sh로 PR 단위 이벤트 로그를 만들었다.
  • 통제 규칙 세 개로 process-map.sh를 돌리고, 위반 PR의 사건을 로그에서 직접 열었다.
  • four-eyes.sh로 4-eyes를 전수 점검하고, 규칙 검사로는 찾지 못한 위반 유형을 확인했다.
  • 예외 기록 댓글과 점검 이슈의 결정 기록을 하나씩 남겼다.
  • 통제 네 개를 ISO/IEC 27001:2022 부속서 A와 SOC 2 CC8.1에 대응시켰다.
  • control-check.yml을 실습 저장소에 설치하고 한 번 실행했다.
  • 분기 통제 점검 보고의 2절을 채웠다.

마치며...

통제 점검을 자동화한다는 것은 통제 목표를 기계가 판정할 수 있는 규칙으로 적고, 판정에 필요한 사건을 빠짐없이 기록하고, 판정 결과를 사람이 검토하는 자리로 정기적으로 보내는 일이다. Orbit의 13주에서 업무 단위 로그는 승인 뒤 병합 규칙의 위반 0건을 보였다. PR 단위 로그는 129건 중 8건이 세 통제 목표 중 하나 이상을 어긴 것을 보였고, 그중 예외 기록이 있는 PR은 #1126과 #1151의 2건이었다. 트랙 B는 기준선의 변경 대장, 규칙 세트와 통제 매트릭스, 릴리스 추적 보고, 주간 점검 이슈와 분기 보고로 통제의 목표, 활동, 증적, 검토를 한 번씩 갖췄다. 5절의 설정을 바꾼 뒤 다음 분기에 같은 명령을 돌리면, 마지막 커밋 승인 위반과 승인자 커밋 위반이 사라졌는지 숫자로 확인할 수 있다.