결재 시스템 구현 가이드 - 용어, 업무 흐름, 설계 포인트
💡 [참고] 결재 관련 시리즈 포스트입니다. 순서대로 읽어보시길 권장합니다.
1. 개요
결재 시스템을 처음 구현해야 한다는 요건이 떨어졌을 때, 막막한 이유 중 하나는 도메인 용어를 정확히 모르는 상태에서 설계를 시작해야 한다는 점이다.
이번 글에서는 결재 시스템을 구현할 때 개발자가 반드시 알아야 할 핵심 용어, 업무 흐름, 데이터 구조, 상태 관리, 그리고 구현 시 고려해야 할 포인트들을 정리한다.
📌 전체 구조 한눈에 보기
2. 핵심 도메인 용어 정리
결재 시스템을 구현하려면 먼저 도메인 용어를 정확히 이해해야 한다. 용어가 명확하지 않으면 DB 설계와 API 설계가 엇나간다.
📌 기안 / 상신
기안(起案)은 결재 문서를 작성하는 행위, 상신(上申)은 작성된 문서를 결재자에게 제출하는 행위다.
실무에서는 혼용하지만, 구현 관점에서는 아래와 같이 구분한다.
| 용어 | 구현상 의미 | 상태 변화 |
|---|---|---|
| 기안 | 문서 초안 생성 (임시저장 포함) | DRAFT |
| 상신 | 결재 요청 제출 | DRAFT → IN_PROGRESS |
📌 결재선 (ApprovalLine)
결재선은 문서가 거쳐야 하는 결재자들의 목록과 순서다. 단순한 배열이 아니라 각 단계마다 타입, 순서, 상태를 가진다.
1
2
3
4
5
Document
└── ApprovalLine[]
├── step 1: 팀장 / type=APPROVE / status=APPROVED
├── step 2: 타부서 / type=AGREE / status=AGREED
└── step 3: 본부장 / type=APPROVE / status=PENDING
결재선의 현재 처리 단계를 추적하는 포인터(currentStep)가 필요하다. 이 포인터를 기준으로 누구에게 알림을 보내고, 누구의 결재를 받아야 하는지 결정한다.
📌 결재 유형 (ApprovalStepType)
결재선의 각 단계는 역할이 다르다. 구현 시 아래 유형을 enum으로 관리하는 것이 일반적이다.
| 유형 | 상수명 | 행동 가능 | 결재선 노출 | 비고 |
|---|---|---|---|---|
| 결재 | APPROVE | 승인 / 반려 | O | 기본 유형 |
| 합의 | AGREE | 합의 / 반대 | O | 반대해도 진행 가능 |
| 확인 | CONFIRM | 승인 / 반려 | X | 이력에만 기록 |
| 감사 | AUDIT | 승인 (반려는 옵션) | O | |
| 수신 | RECEIVE | 접수 / 반송 | 별도 | 문서 수신 후 처리 |
💡 CONFIRM과 APPROVE의 차이: 동일하게 승인/반려가 가능하지만, CONFIRM은 결재문서 본문에 서명 칸이 없고 이력에만 기록된다.
isVisible필드로 제어하거나 별도 타입으로 분리하면 된다.
📌 합의 방식 (AgreePolicy)
합의자가 여러 명일 때 처리 방식을 정의한다.
| 방식 | 상수명 | 동작 |
|---|---|---|
| 순차합의 | SEQUENTIAL | 지정 순서대로 한 명씩 처리 |
| 병렬합의 | PARALLEL | 모든 합의자에게 동시 전달, 순서 무관하게 처리 가능 |
병렬합의는 “전원 완료 시 다음 단계로 넘어간다”는 로직이 필요하다. 타임아웃 처리도 고려해야 한다.
3. 문서 상태 머신 설계
결재 시스템의 핵심은 문서 상태 + 결재선 단계 상태를 정확히 관리하는 것이다. 상태가 틀리면 알림, 권한, UI가 전부 틀어진다.
📌 문서 상태 (DocumentStatus)
1
2
3
4
5
6
7
public enum DocumentStatus {
DRAFT, // 임시저장 / 기안 중
IN_PROGRESS, // 결재 진행 중
APPROVED, // 결재 완료
REJECTED, // 반려됨
CANCELLED // 상신취소 / 회수됨
}
상태 전이 규칙:
1
2
3
4
5
6
DRAFT → IN_PROGRESS : 상신(기안 제출)
IN_PROGRESS → APPROVED : 최종 결재자 승인
IN_PROGRESS → REJECTED : 임의 단계에서 반려
IN_PROGRESS → CANCELLED : 상신취소 or 회수
APPROVED → REJECTED : 강제반려 (관리자 전용)
REJECTED → IN_PROGRESS : 재기안 상신
📌 결재선 단계 상태 (StepStatus)
1
2
3
4
5
6
7
8
9
10
11
12
public enum StepStatus {
PENDING, // 내 차례가 되지 않은 대기 상태
WAITING, // 내 차례, 처리 대기 중
APPROVED, // 승인 완료
REJECTED, // 반려
AGREED, // 합의 완료
DISAGREED, // 반대 표시
SKIPPED, // 전결로 인해 건너뜀
DELEGATED, // 대결로 위임됨
PRE_APPROVED, // 선결 처리됨 (후결 대기)
POST_APPROVED // 후결 처리 완료
}
📌 현재 단계 포인터 관리
결재선을 순회하며 현재 처리해야 할 단계를 찾는 로직이 자주 쓰인다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 현재 처리 대상 단계 조회
public ApprovalLineStep getCurrentStep(List<ApprovalLineStep> steps) {
return steps.stream()
.filter(s -> s.getStatus() == StepStatus.WAITING)
.findFirst()
.orElse(null);
}
// 결재 처리 후 다음 단계 활성화
public void activateNextStep(Document doc) {
List<ApprovalLineStep> steps = doc.getApprovalLine();
// WAITING → APPROVED 처리 후
// 다음 PENDING 단계를 WAITING으로 변경
steps.stream()
.filter(s -> s.getStatus() == StepStatus.PENDING)
.findFirst()
.ifPresent(next -> {
next.setStatus(StepStatus.WAITING);
notificationService.notifyApprover(next.getApproverId(), doc);
});
// 더 이상 PENDING 단계가 없으면 → 문서 상태를 APPROVED로
boolean allDone = steps.stream()
.noneMatch(s -> s.getStatus() == StepStatus.PENDING
|| s.getStatus() == StepStatus.WAITING);
if (allDone) {
doc.setStatus(DocumentStatus.APPROVED);
doc.setDocumentNumber(generateDocumentNumber(doc));
archiveService.archive(doc);
}
}
4. ERD 설계 포인트
📌 핵심 테이블 구조
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
-- 결재 문서
CREATE TABLE approval_document (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
form_id BIGINT NOT NULL, -- 결재 양식 FK
title VARCHAR(255) NOT NULL,
content TEXT, -- JSON 또는 HTML
status VARCHAR(30) NOT NULL, -- DocumentStatus
drafter_id BIGINT NOT NULL, -- 기안자
submitted_at DATETIME,
completed_at DATETIME,
doc_number VARCHAR(100), -- 최종결재 시 채번
is_urgent BOOLEAN DEFAULT FALSE,
is_public BOOLEAN DEFAULT TRUE,
retention_days INT,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
-- 결재선 단계
CREATE TABLE approval_line_step (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
document_id BIGINT NOT NULL,
step_order INT NOT NULL, -- 순서 (1부터)
step_type VARCHAR(30) NOT NULL, -- ApprovalStepType
approver_id BIGINT, -- NULL이면 부서 단위
dept_id BIGINT,
status VARCHAR(30) NOT NULL, -- StepStatus
action VARCHAR(30), -- 실제 수행한 액션
comment TEXT,
processed_at DATETIME,
delegate_from BIGINT, -- 대결인 경우 원 결재자 ID
agree_policy VARCHAR(20), -- SEQUENTIAL / PARALLEL (합의용)
UNIQUE (document_id, step_order)
);
-- 결재 이력 (감사 로그)
CREATE TABLE approval_history (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
document_id BIGINT NOT NULL,
step_id BIGINT,
actor_id BIGINT NOT NULL,
action VARCHAR(30) NOT NULL, -- ApprovalAction
comment TEXT,
created_at DATETIME NOT NULL
);
-- 참조자 / 열람자
CREATE TABLE approval_viewer (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
document_id BIGINT NOT NULL,
viewer_id BIGINT,
dept_id BIGINT,
viewer_type VARCHAR(20) NOT NULL, -- REFERENCE / READ
is_confirmed BOOLEAN DEFAULT FALSE,
confirmed_at DATETIME
);
-- 대결 위임 설정
CREATE TABLE approval_delegation (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
delegator_id BIGINT NOT NULL, -- 원 결재자
delegate_id BIGINT NOT NULL, -- 대결자
start_date DATE NOT NULL,
end_date DATE NOT NULL,
is_active BOOLEAN DEFAULT TRUE,
created_at DATETIME NOT NULL
);
💡
content컬럼에 결재 문서 본문을 저장할 때, 양식에 따라 구조가 다르면 JSON으로 저장하거나 별도 테이블로 분리한다. 양식이 단순하면 HTML blob도 실용적인 선택이다.
5. 특수 결재 구현 포인트
📌 선결 / 후결 구현
선결은 내 차례가 되기 전에 먼저 결재하는 것이다. DB 설계 관점에서는 step_order 순서를 무시하고 먼저 처리할 수 있어야 한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 선결 처리 로직
public void preApprove(Long documentId, Long approverId, String comment) {
ApprovalLineStep myStep = findMyStep(documentId, approverId);
// 내 차례가 아닌(PENDING) 경우에만 선결 가능
if (myStep.getStatus() != StepStatus.PENDING) {
throw new IllegalStateException("선결 불가: 이미 처리된 단계이거나 WAITING 상태");
}
if (myStep.getStepType() != ApprovalStepType.APPROVE) {
throw new IllegalStateException("APPROVE 타입만 선결 가능");
}
myStep.setStatus(StepStatus.PRE_APPROVED);
myStep.setComment(comment);
myStep.setProcessedAt(LocalDateTime.now());
// 선결로 최종 결재자가 처리한 경우 → 문서 완료 처리
if (isLastStep(myStep)) {
completeDocument(documentId);
}
// 후결 대기: 내 앞 단계들은 POST_APPROVE를 기다림
// → 앞 단계 처리자에게 후결 요청 알림
}
후결: 선결로 완료된 문서에서 원래 순서의 결재자가 서명하는 것. 반려 불가.
1
2
3
4
5
6
public void postApprove(Long documentId, Long approverId) {
ApprovalLineStep myStep = findMyStep(documentId, approverId);
// 문서가 이미 APPROVED 상태 → 반려 불가, 서명만 가능
myStep.setStatus(StepStatus.POST_APPROVED);
myStep.setProcessedAt(LocalDateTime.now());
}
📌 전결 구현
전결은 중간 결재자가 이후 단계를 건너뛰고 결재를 완료하는 것이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public void finalApprove(Long documentId, Long approverId, String comment) {
ApprovalLineStep myStep = findMyStep(documentId, approverId);
// 최종결재자는 전결 불가 (이미 마지막)
if (isLastStep(myStep)) {
throw new IllegalStateException("최종 결재자는 전결 불가");
}
myStep.setStatus(StepStatus.APPROVED);
myStep.setAction("FINAL_APPROVE");
myStep.setComment(comment);
// 이후 단계 전부 SKIPPED 처리
List<ApprovalLineStep> laterSteps = getLaterSteps(documentId, myStep.getStepOrder());
laterSteps.forEach(s -> s.setStatus(StepStatus.SKIPPED));
// 문서 완료
completeDocument(documentId);
}
📌 대결 구현
대결은 부재 시 결재 권한을 위임하는 것이다. 실제 결재는 대결자가 하지만, 원 결재자는 나중에 후열(열람)을 할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 결재 대기 문서 조회 시 대결 포함 로직
public List<Document> getMyPendingDocuments(Long userId) {
// 내가 직접 결재자인 문서
List<Document> directDocs = approvalLineRepo.findByApproverIdAndStatus(userId, StepStatus.WAITING);
// 내가 대결자로 위임받은 문서
List<ApprovalDelegation> delegations = delegationRepo.findActiveByDelegateId(userId);
List<Long> delegatorIds = delegations.stream()
.map(ApprovalDelegation::getDelegatorId).collect(toList());
List<Document> delegatedDocs = approvalLineRepo.findByApproverIdsAndStatus(delegatorIds, StepStatus.WAITING);
return merge(directDocs, delegatedDocs);
}
// 대결 처리 후 결재선에 기록
public void delegateApprove(Long documentId, Long delegateId, Long originalApproverId, String comment) {
ApprovalLineStep step = findStepByApprover(documentId, originalApproverId);
step.setStatus(StepStatus.APPROVED);
step.setDelegateFrom(originalApproverId); // 원 결재자 기록
step.setProcessedBy(delegateId); // 실제 처리자
// 후열 대기: originalApproverId에게 후열 알림
notificationService.notifyPostView(originalApproverId, documentId);
}
📌 강제반려 구현
관리자만 가능한 예외 처리다. 이미 완료된 문서도 되돌릴 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 관리자 권한 체크가 선행되어야 함
@PreAuthorize("hasRole('APPROVAL_ADMIN')")
public void forceReject(Long documentId, Long adminId, String reason) {
Document doc = documentRepo.findById(documentId).orElseThrow();
// 사유 필수
if (!StringUtils.hasText(reason)) {
throw new IllegalArgumentException("강제반려 사유는 필수입니다.");
}
doc.setStatus(DocumentStatus.REJECTED);
// 이력 기록
ApprovalHistory history = ApprovalHistory.builder()
.documentId(documentId)
.actorId(adminId)
.action("FORCE_REJECT")
.comment(reason)
.createdAt(LocalDateTime.now())
.build();
historyRepo.save(history);
// 기안자에게 재기안 알림
notificationService.notifyForceReject(doc.getDrafterId(), documentId, reason);
}
6. 문서번호 채번 설계
최종 결재가 완료되는 시점에 문서번호를 자동 생성해야 한다. 동시성 문제가 발생할 수 있는 지점이다.
📌 채번 전략
방법 1: DB 시퀀스 + 형식 조합
1
2
3
4
5
6
7
-- 연도별 시퀀스 테이블
CREATE TABLE doc_number_seq (
form_id BIGINT NOT NULL,
year CHAR(4) NOT NULL,
seq BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (form_id, year)
);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 락을 걸고 채번
@Transactional
public String generateDocumentNumber(Long formId, String formCode) {
String year = String.valueOf(LocalDate.now().getYear());
// SELECT ... FOR UPDATE로 동시성 제어
DocNumberSeq seq = seqRepo.findByFormIdAndYearForUpdate(formId, year)
.orElseGet(() -> seqRepo.save(new DocNumberSeq(formId, year, 0L)));
seq.setSeq(seq.getSeq() + 1);
seqRepo.save(seq);
// 형식: [양식코드]-[년도]-[5자리 순번]
return String.format("%s-%s-%05d", formCode, year, seq.getSeq());
}
방법 2: Redis INCR + 형식 조합 (분산환경에서 더 적합)
1
2
3
4
5
public String generateDocumentNumber(String formCode) {
String key = "doc:seq:" + formCode + ":" + LocalDate.now().getYear();
Long seq = redisTemplate.opsForValue().increment(key);
return String.format("%s-%s-%05d", formCode, LocalDate.now().getYear(), seq);
}
7. 알림 설계 포인트
결재 시스템에서 알림은 단순한 부가 기능이 아니다. 결재 흐름을 이끄는 핵심 트리거다.
📌 알림이 발송되어야 하는 시점
| 이벤트 | 수신자 |
|---|---|
| 상신 완료 | 첫 번째 결재자 |
| 결재(승인) | 다음 결재자 (없으면 기안자에게 완료 알림) |
| 반려 | 기안자 |
| 합의 요청 | 합의자 전원 (병렬) 또는 첫 번째 합의자 (순차) |
| 대결 처리 | 원 결재자 (후열 요청) |
| 선결 처리 | 앞 단계 결재자들 (후결 요청) |
| 강제반려 | 기안자 |
| 참조 지정 | 참조자 |
| 댓글 등록 | 해당 문서 열람 가능한 모든 사용자 |
📌 알림 이벤트 처리 (이벤트 기반 분리)
Spring 환경에서는 도메인 이벤트로 알림 로직을 분리하면 결재 처리 로직이 깔끔해진다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 결재 처리 서비스
@Transactional
public void approve(Long documentId, Long approverId, String comment) {
// ... 결재 처리 로직 ...
// 이벤트 발행 (알림 로직 분리)
applicationEventPublisher.publishEvent(
new ApprovalProcessedEvent(documentId, approverId, "APPROVED", nextApproverId)
);
}
// 알림 처리 이벤트 리스너
@EventListener
@Async
public void onApprovalProcessed(ApprovalProcessedEvent event) {
if (event.getNextApproverId() != null) {
notificationService.send(event.getNextApproverId(),
NotificationType.APPROVAL_REQUESTED, event.getDocumentId());
} else {
// 최종 완료 → 기안자에게 완료 알림
notificationService.send(drafterOf(event.getDocumentId()),
NotificationType.APPROVAL_COMPLETED, event.getDocumentId());
}
}
8. 문서함(Archive) 설계
결재가 완료된 문서는 자동으로 문서함에 보관된다. 문서함은 크게 개인과 부서로 나뉜다.
📌 문서함 종류 및 구현 방식
| 문서함 | 조회 기준 | 비고 |
|---|---|---|
| 기안 문서함 | drafter_id = me | 전체 상태 포함 |
| 임시 저장함 | drafter_id = me AND status = DRAFT/CANCELLED | |
| 결재 문서함 | approver_id = me (결재선 기준) | 진행/완료 탭 분리 |
| 참조/열람 문서함 | approval_viewer.viewer_id = me | |
| 수신 문서함 | approval_line_step.approver_id = me AND type = RECEIVE | |
| 부서 문서함 | drafter의 dept_id = 내 dept_id | 공개여부 필터 필요 |
문서함은 별도 archive 테이블 없이 기존 document + approval_line을 조인해서 뷰처럼 사용해도 되지만, 검색 성능을 위해 archive 테이블을 따로 두고 결재 완료 시 insert하는 방식을 많이 사용한다.
📌 공개 여부 처리
부서 문서함 조회 시 is_public 필드에 따라 접근을 제한해야 한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 부서 문서함 조회
public Page<Document> getDeptDocuments(Long deptId, Long currentUserId) {
return documentRepo.findAll((root, query, cb) -> {
// 공개 문서 OR 내가 결재/참조/열람자인 문서
Predicate isPublic = cb.isTrue(root.get("isPublic"));
Predicate isMyDoc = cb.or(
cb.equal(root.get("drafterId"), currentUserId),
/* 결재선 서브쿼리 */
);
return cb.and(
cb.equal(root.join("drafter").get("deptId"), deptId),
cb.or(isPublic, isMyDoc)
);
}, pageable);
}
9. 정리
결재 시스템 구현에서 빠지기 쉬운 함정들을 정리한다.
- 상태 머신을 먼저 설계하라. 문서 상태와 단계 상태가 명확하지 않으면 예외 케이스마다 조건문이 늘어난다.
- 결재선 순서와 현재 처리 단계 포인터를 분리하라.
stepOrder와currentActiveStep을 혼용하면 선결/전결 같은 특수 케이스에서 복잡해진다. - 합의 방식(병렬/순차)은 결재선 단계 레코드 구조에 영향을 준다. 병렬합의는 같은
stepOrder에 여러 레코드가 생길 수 있다. 또는 별도 병렬 그룹 ID를 두는 방법도 있다. - 강제반려와 일반 반려를 동일 로직에서 처리하지 마라. 권한 체계와 이후 처리 방식이 다르다.
- 문서번호 채번은 트랜잭션 내 락이 필요하다. 동시성 이슈를 초기에 설계하지 않으면 채번 중복이 발생한다.
- 알림은 도메인 로직에서 분리하라. 이벤트 기반으로 처리해야 결재 처리 트랜잭션이 알림 실패에 영향받지 않는다.
- 대결 구현 시 원 결재자와 실제 처리자를 별도 컬럼으로 관리하라.
processed_by와approver_id를 구분해야 결재선 표시와 후열 처리가 정확하다.
참고 자료
- 다우오피스 헬프데스크 - 전자결재 소개: https://helpdesk.daouoffice.co.kr/hc/ko/articles/42424955855385
- 다우오피스 헬프데스크 - 결재처리(결재/합의/반려): https://helpdesk.daouoffice.co.kr/hc/ko/articles/45253223561113
- 다우오피스 헬프데스크 - 선결/후결/전결: https://helpdesk.daouoffice.co.kr/hc/ko/articles/45265901184281
- 다우오피스 헬프데스크 - 대결/후열: https://helpdesk.daouoffice.co.kr/hc/ko/articles/43337105202841
- 다우오피스 헬프데스크 - 수신/접수/반송: https://helpdesk.daouoffice.co.kr/hc/ko/articles/43337353211545
