VBA UserForm 한 행 저장: 접수 번호·중복 클릭·부분 기록 확인하기
요청 입력 폼의 저장 버튼을 두 번 눌렀을 때 두 행이 생기면 사용자에게 같은 작업이 두 번 배정될 수 있습니다. 이번 실습은 모니터암 요청 한 건을 기록하고 쓰기 완료 뒤 폼 표시에서 오류가 나더라도 다시 저장하지 않는 경계를 살펴봅니다. 단순히 마지막 행 다음에 값을 넣는 예제보다 저장 여부를 구분하는 코드에 집중합니다.
첨부의 기록 시트는 머리글만 있습니다. 예상 행을 미리 써서 실행된 것처럼 보이게 하지 않았습니다. Python 참조 검사로 입력·번호·되돌림 계약을 확인했으며 Excel UserForm을 만들거나 VBA를 실행한 결과는 아닙니다.

빈 기록 시트에서 두 건을 접수합니다
practice-66.xlsx를 별도 orders-demo.xlsm으로 저장합니다. 기록!A1:D1은 품목·분류·긴급·접수 번호입니다. 첫 입력은 품목 모니터암, 분류 장비, 긴급 체크입니다. 기대 행은 A2:D2 = 모니터암, 장비, 예, 1입니다.
다음에는 공백 품목으로 저장을 눌러 행이 늘지 않는지 확인합니다. 폼을 새로 열고 취소해도 같아야 합니다. 마지막으로 키보드·사무용품·긴급 해제를 저장하면 A3:D3 = 키보드, 사무용품, 아니오, 2가 됩니다. 이 세 동작을 구분해야 “버튼을 눌렀다”와 “유효한 행이 기록됐다”를 혼동하지 않습니다.
이 실습의 접수 번호는 현재 유효한 기존 번호의 최댓값+1입니다. 기존 번호가 1과 7이면 다음은 8이지 데이터 행 수+1인 3이 아닙니다. 마지막 최대 번호 행을 지웠다면 번호 재사용 가능성도 있으므로 영구적으로 유일한 업무 ID를 보장하는 설계는 아닙니다.
폼 디자이너와 코드 창은 따로 준비합니다
UserForm은 응용 프로그램 사용자 인터페이스를 구성하는 창 또는 대화 상자입니다. ZIP은 자동 완성된 폼 디자이너 파일을 제공하지 않습니다. VBA 편집기에서 UserForm을 추가하고 (Name)을 frmOrderEntry로 정한 뒤 다음 컨트롤을 만듭니다.
| 종류 | Name | 역할 |
|---|---|---|
| TextBox | txtItem | 품목 1~200자 |
| ComboBox | cboCategory | 사무용품·장비·서비스 중 선택 |
| CheckBox | chkUrgent | 긴급 여부 |
| CommandButton | btnSave | 저장 |
| CommandButton | btnCancel | 취소 |
Caption과 Name은 다릅니다. 이벤트 코드가 찾는 것은 Name입니다. DropDownList 스타일에서는 사용자가 목록의 값 중 하나를 선택합니다. 코드에서도 목록값을 다시 검사하므로 화면의 선택 제한만 믿지는 않습니다.

표준 모듈 전체: 기존 행을 먼저 검증합니다
open_userform.bas는 표준 모듈로 가져옵니다. 폼 코드는 그 다음 별도의 폼 코드 창에 넣습니다. ThisWorkbook을 사용하므로 이 실습 XLSM에 코드를 넣어야 합니다.
기존 A:D 전체에서 마지막 사용 행을 찾고, 네 값이 완전한지·접수 번호가 양의 정수인지·번호가 중복되지 않는지 검사합니다. A열이 비고 B열에 메모만 남은 꼬리 행도 발견하면 중단합니다. 보호 시트, 표 개체, 수식이 있는 기존 기록, 병합 대상 범위는 이 단순 값 기록 예제의 입력 범위가 아닙니다.
EnableEvents가 True이면 해당 개체의 이벤트가 활성화됩니다. 쓰기 동안은 원래 값을 보관한 뒤 이벤트를 끄고, 완료 또는 실패 후 캡처했던 값으로 복구합니다. 복원 실패까지 성공으로 덮지 않습니다.
첫 Attribute VB_Name 줄은 BAS 가져오기용 메타데이터입니다. BAS를 가져올 때는 그대로 두고, 코드 창에 직접 붙여넣을 때는 이 첫 줄만 제외합니다.
Attribute VB_Name = "OpenOrderForm"
Option Explicit
Public Sub OpenOrderEntry()
On Error GoTo Failed
frmOrderEntry.Show vbModal
Exit Sub
Failed:
MsgBox "Form error. Inspect the record sheet before retrying: " & Err.Description, vbExclamation
End Sub
Public Function OrderText(ByVal key As String) As String
Select Case key
Case "sheet": OrderText = K(44592, 47197)
Case "item": OrderText = K(54408, 47785)
Case "category": OrderText = K(48516, 47448)
Case "urgent": OrderText = K(44596, 44553)
Case "receipt": OrderText = K(51217, 49688, 32, 48264, 54840)
Case "office": OrderText = K(49324, 47924, 50857, 54408)
Case "equipment": OrderText = K(51109, 48708)
Case "service": OrderText = K(49436, 48708, 49828)
Case "yes": OrderText = K(50696)
Case "no": OrderText = K(50500, 45768, 50724)
Case "title": OrderText = K(50629, 47924, 32, 50836, 52397, 32, 51077, 47141)
Case "save": OrderText = K(51200, 51109)
Case "cancel": OrderText = K(52712, 49548)
Case Else: Err.Raise vbObjectError + 660, , "Unknown label key."
End Select
End Function
Private Function K(ParamArray codes() As Variant) As String
Dim code As Variant
For Each code In codes
K = K & ChrW(CLng(code))
Next code
End Function
Public Function CategoryAllowed(ByVal value As String) As Boolean
CategoryAllowed = value = OrderText("office") Or value = OrderText("equipment") _
Or value = OrderText("service")
End Function
Private Function GoodItem(ByVal value As Variant) As Boolean
Dim text As String
If VarType(value) <> vbString Then Exit Function
text = CStr(value)
If Len(text) = 0 Or Len(text) > 200 Or text <> Trim$(text) Then Exit Function
If InStr(text, vbCr) > 0 Or InStr(text, vbLf) > 0 Or InStr(text, vbTab) > 0 Then Exit Function
If InStr(1, "=+-@", Left$(text, 1), vbBinaryCompare) > 0 Then Exit Function
GoodItem = True
End Function
Private Sub NoFormulas(ByVal target As Range)
Dim state As Variant
state = target.HasFormula
If IsNull(state) Then Err.Raise vbObjectError + 660, , "Formula cells are not supported."
If CBool(state) Then Err.Raise vbObjectError + 660, , "Formula cells are not supported."
End Sub
Private Function ReceiptNumber(ByVal value As Variant) As Long
Dim number As Double
Select Case VarType(value)
Case vbByte, vbInteger, vbLong, vbSingle, vbDouble, vbCurrency, vbDecimal, 20
number = CDbl(value)
Case Else
Err.Raise vbObjectError + 660, , "Existing receipt must be numeric."
End Select
If number < 1 Or number > 2147483647# Or number <> Fix(number) Then
Err.Raise vbObjectError + 660, , "Existing receipt must be a positive Long integer."
End If
ReceiptNumber = CLng(number)
End Function
Private Function ExistingMaxReceipt(ByVal ws As Worksheet, ByVal lastRow As Long) As Long
Dim values As Variant, r As Long, number As Long, largest As Long
Dim used As New Collection
If lastRow < 2 Then Exit Function
values = ws.Range("A2:D" & CStr(lastRow)).Value2
For r = 1 To UBound(values, 1)
If Not GoodItem(values(r, 1)) Then
Err.Raise vbObjectError + 660, , "Existing item is invalid or a partial row exists: " & CStr(r + 1)
End If
If VarType(values(r, 2)) <> vbString Or VarType(values(r, 3)) <> vbString Then
Err.Raise vbObjectError + 660, , "Existing category or urgent field is invalid."
End If
If Not CategoryAllowed(CStr(values(r, 2))) Then
Err.Raise vbObjectError + 660, , "Existing category is outside the allowed list."
End If
If values(r, 3) <> OrderText("yes") And values(r, 3) <> OrderText("no") Then
Err.Raise vbObjectError + 660, , "Existing urgent field must be yes or no."
End If
number = ReceiptNumber(values(r, 4))
used.Add number, "n:" & CStr(number)
If number > largest Then largest = number
Next r
ExistingMaxReceipt = largest
End Function
Public Function TryAppendOrder(ByVal itemName As String, ByVal category As String, _
ByVal urgent As Variant, ByRef committed As Boolean, ByRef resultRow As Long, _
ByRef resultID As Long, ByRef message As String, ByRef retrySafe As Boolean) As Boolean
Dim ws As Worksheet, found As Range, target As Range
Dim lastRow As Long, maxID As Long, c As Long
Dim block(1 To 1, 1 To 4) As Variant, actual As Variant
Dim formats(1 To 4) As String
Dim oldEvents As Boolean, eventsCaptured As Boolean, rowOwned As Boolean
Dim rollbackFailed As Boolean, eventsFailed As Boolean
Dim names As Variant, primaryError As String
On Error GoTo Failed
committed = False
retrySafe = True
resultRow = 0
resultID = 0
message = ""
itemName = Trim$(itemName)
If Not GoodItem(itemName) Then
Err.Raise vbObjectError + 660, , "Item: use 1-200 characters, no tabs/newlines, no initial = + - @."
End If
If Not CategoryAllowed(category) Then Err.Raise vbObjectError + 660, , "Choose an allowed category."
If VarType(urgent) <> vbBoolean Then Err.Raise vbObjectError + 660, , "Urgent must be True or False."
If ThisWorkbook.ReadOnly Then Err.Raise vbObjectError + 660, , "Use a writable practice copy."
Set ws = ThisWorkbook.Worksheets(OrderText("sheet"))
If ws.ProtectContents Then Err.Raise vbObjectError + 660, , "The record sheet is protected."
If ws.ListObjects.Count > 0 Then Err.Raise vbObjectError + 660, , "This example requires a plain range, not an Excel table."
names = Array(OrderText("item"), OrderText("category"), OrderText("urgent"), OrderText("receipt"))
For c = 1 To 4
If VarType(ws.Cells(1, c).Value2) <> vbString Then
Err.Raise vbObjectError + 660, , "Missing or non-text record header."
End If
If ws.Cells(1, c).Value2 <> names(c - 1) Then
Err.Raise vbObjectError + 660, , "Record headers do not match the practice file."
End If
Next c
Set found = ws.Range("A:D").Find(What:="*", After:=ws.Cells(1, 1), _
LookIn:=xlFormulas, LookAt:=xlPart, SearchOrder:=xlByRows, _
SearchDirection:=xlPrevious, MatchCase:=False, MatchByte:=False, SearchFormat:=False)
If found Is Nothing Then Err.Raise vbObjectError + 660, , "Record headers are missing."
lastRow = found.Row
NoFormulas ws.Range("A1:D" & CStr(lastRow))
maxID = ExistingMaxReceipt(ws, lastRow)
If lastRow >= ws.Rows.Count Then Err.Raise vbObjectError + 660, , "No worksheet row remains."
If maxID = 2147483647 Then Err.Raise vbObjectError + 660, , "Receipt number limit reached."
resultRow = lastRow + 1
resultID = maxID + 1
Set target = ws.Cells(resultRow, 1).Resize(1, 4)
If Application.WorksheetFunction.CountA(target) <> 0 Then
Err.Raise vbObjectError + 660, , "Destination is not empty; no overwrite."
End If
For c = 1 To 4
If target.Cells(1, c).MergeCells Or target.Cells(1, c).HasArray Then
Err.Raise vbObjectError + 660, , "Merged or array-formula destination is unsupported."
End If
formats(c) = CStr(target.Cells(1, c).NumberFormat)
Next c
block(1, 1) = itemName
block(1, 2) = category
If CBool(urgent) Then
block(1, 3) = OrderText("yes")
Else
block(1, 3) = OrderText("no")
End If
block(1, 4) = resultID
oldEvents = Application.EnableEvents
eventsCaptured = True
Application.EnableEvents = False
rowOwned = True
target.Resize(1, 3).NumberFormat = "@"
target.Cells(1, 4).NumberFormat = "0"
target.Value2 = block
NoFormulas target
actual = target.Value2
For c = 1 To 3
If VarType(actual(1, c)) <> vbString Then
Err.Raise vbObjectError + 660, , "Written text changed type."
End If
If StrComp(CStr(actual(1, c)), CStr(block(1, c)), vbBinaryCompare) <> 0 Then
Err.Raise vbObjectError + 660, , "Written text did not match."
End If
Next c
If ReceiptNumber(actual(1, 4)) <> resultID Then
Err.Raise vbObjectError + 660, , "Written receipt did not match."
End If
committed = True
GoTo Finish
Failed:
primaryError = "Error " & Err.Number & ": " & Err.Description
Resume Finish
Finish:
On Error Resume Next
If rowOwned And Not committed Then
Err.Clear
target.ClearContents
If Err.Number <> 0 Then rollbackFailed = True
For c = 1 To 4
Err.Clear
target.Cells(1, c).NumberFormat = formats(c)
If Err.Number <> 0 Then rollbackFailed = True
Next c
Err.Clear
If Application.WorksheetFunction.CountA(target) <> 0 Then rollbackFailed = True
If Err.Number <> 0 Then rollbackFailed = True
End If
If eventsCaptured Then
Err.Clear
Application.EnableEvents = oldEvents
If Err.Number <> 0 Then eventsFailed = True
Err.Clear
If Application.EnableEvents <> oldEvents Then eventsFailed = True
If Err.Number <> 0 Then eventsFailed = True
End If
On Error GoTo 0
retrySafe = Not committed And Not rollbackFailed And Not eventsFailed
If committed Then
message = "Row " & resultRow & ", receipt " & resultID & " written and compared. The workbook was NOT saved to disk."
ElseIf rollbackFailed Then
message = primaryError & vbCrLf & "Rollback UNCONFIRMED. Inspect reserved row " & resultRow & "; do not retry automatically."
ElseIf rowOwned Then
message = primaryError & vbCrLf & "The previously empty reserved row was cleared. Existing rows were not cleared."
Else
message = primaryError & vbCrLf & "No new row was written."
End If
If eventsFailed Then message = message & vbCrLf & "Event-state restoration UNCONFIRMED. Inspect Excel before retrying."
TryAppendOrder = committed
End Function
frmOrderEntry에 넣을 전체 이벤트 코드
다음은 frmOrderEntry-code.txt와 같습니다. 표준 모듈이 아니라 만든 UserForm의 코드 창에 넣습니다. mBusy는 같은 폼의 진행 중 재진입을 막고 mSaved는 이미 쓰기가 완료됐음을 기억합니다. 취소는 쓰기 함수를 부르지 않습니다.
Option Explicit
Private mBusy As Boolean
Private mSaved As Boolean
Private Sub UserForm_Initialize()
Me.Caption = OrderText("title")
txtItem.Value = ""
txtItem.MaxLength = 200
cboCategory.Clear
cboCategory.Style = fmStyleDropDownList
cboCategory.AddItem OrderText("office")
cboCategory.AddItem OrderText("equipment")
cboCategory.AddItem OrderText("service")
cboCategory.ListIndex = 0
chkUrgent.TripleState = False
chkUrgent.Value = False
chkUrgent.Caption = OrderText("urgent")
btnSave.Caption = OrderText("save")
btnCancel.Caption = OrderText("cancel")
End Sub
Private Sub btnSave_Click()
Dim rowNumber As Long, receipt As Long, message As String
Dim retrySafe As Boolean, result As Boolean
If mBusy Or mSaved Then Exit Sub
On Error GoTo UIError
mBusy = True
btnSave.Enabled = False
result = TryAppendOrder(CStr(txtItem.Value), CStr(cboCategory.Value), _
chkUrgent.Value, mSaved, rowNumber, receipt, message, retrySafe)
mBusy = False
If result Then
MsgBox message, vbInformation, "Record written"
Unload Me
Else
btnSave.Enabled = retrySafe
MsgBox message, vbExclamation, "Check the input or record sheet"
End If
Exit Sub
UIError:
mBusy = False
btnSave.Enabled = False
If mSaved Then
MsgBox "The row was already committed. Do not save it again. Inspect the record sheet.", vbExclamation
Else
MsgBox "Form-state error. Inspect the record sheet before retrying: " & Err.Description, vbExclamation
End If
End Sub
Private Sub btnCancel_Click()
If mBusy Then Exit Sub
Unload Me
End Sub
Private Sub UserForm_QueryClose(Cancel As Integer, CloseMode As Integer)
If mBusy Then Cancel = True
End Sub
UserForm을 만들고 두 건을 입력하는 순서
-
practice-66.xlsx를 별도 orders-demo.xlsm으로 저장하고 open_userform.bas를 표준 모듈로 가져옵니다.
-
UserForm frmOrderEntry와 컨트롤 배치 시트의 5개 컨트롤을 직접 만듭니다. 이름과 Caption을 혼동하지 않습니다.
-
frmOrderEntry-code.txt를 폼의 코드 창에 붙여넣고 OpenOrderEntry를 실행합니다.
-
모니터암/장비/긴급은 A2:D2 모니터암/장비/예/1, 빈 품목·취소는 변화 없음, 키보드/사무용품/해제는 A3:D3 키보드/사무용품/아니오/2를 기대합니다.
-
실패 후 기록 상태와 retrySafe를 확인합니다. 자동 저장·영구 번호·동시 사용자 처리는 제공하지 않습니다. 실제 Excel 실행은 미검증입니다.
검증 결과: 어디까지 썼는지를 대조합니다
Python 참조 검사는 빈 기록에서 위 두 입력의 전체 4열과 접수 번호 1·2를 확인했습니다. 빈 품목·허용하지 않은 분류·Boolean이 아닌 긴급 값·부분 행·중복 번호는 거부하며 기존 목록이 바뀌지 않아야 합니다. 저장 도중 실패한 모델에서는 이번에 예약한 한 행만 제거하는 규칙도 검사합니다. 이는 Excel 셀 쓰기·롤백을 실제 실행한 증거가 아닙니다.
Excel에서는 OpenOrderEntry로 폼을 열고 같은 순서로 확인합니다. 저장 성공 메시지 뒤 폼이 닫히고 기록 행이 한 번만 증가하는지, 빈 품목 경고와 취소 뒤에는 그대로인지 대조하세요. 첨부 검산 예시의 기대값과 실제 기록 시트를 나란히 볼 수 있습니다.

저장 오류 뒤의 재시도 판단
입력 검사에서 실패했다면 해당 항목을 고칩니다. 쓰기 시작 후 실패했다면 코드는 이번에 소유한 대상 행만 지우고 서식을 복원하려 시도합니다. 삭제·설정 복원까지 확인된 경우에만 버튼 재시도를 허용합니다. 기존 행을 비웠다가 다시 채우는 방식은 사용하지 않습니다.
행은 이미 완료됐는데 폼 처리에서 오류가 난 경우 mSaved=True가 남아 다시 저장하지 않습니다. 버튼이 비활성화돼 있으면 무작정 새 폼을 열지 말고 기록!A:D를 먼저 확인합니다. 일부 행이 남았거나 설정 복원이 실패하면 연습 사본을 보관해 원인을 확인하세요.
운영에 옮기기 전의 한계
파일 자동 저장, 다중 사용자의 동시 기록 잠금, 강제 종료 복구, 영구 접수 번호 발급은 구현하지 않았습니다. 셀 오류를 모두 트랜잭션처럼 되돌린다고 주장하지 않습니다. 폼 이름·컨트롤 생성·컴파일·실제 Excel 이벤트 처리는 데스크톱 Excel에서 별도로 확인해야 합니다.
공식 자료
Application.EnableEvents property (Excel)
출처 원문 수집일: S1 2026-09-24; S2 2026-09-24; S3 2026-09-24.
이어서 연습할 글
단추의 실행과 집계 결과를 나누어 확인하는 방법은 오류까지 검산하는 매크로 버튼 예제에서도 연습할 수 있습니다.