웹에서 긁어 온 게시글 제목이나 상품명을 그대로 파일 이름으로 저장하려다 보면 "경로에 잘못된 문자가 있습니다" 같은 예외를 꼭 만납니다. 윈도우 파일 이름에는 \ / : * ? " < > | 아홉 문자를 쓸 수 없기 때문이죠. 보통은 이 문자들을 _ 로 바꿔 버리고 끝내는데, 그러면 나중에 파일 이름만 보고 원래 제목을 알아낼 방법이 없어집니다. "질문: A/B 테스트?" 와 "질문: AB 테스트" 가 같은 파일명이 되어 덮어써지는 사고도 납니다.

그래서 금지 문자를 정해진 규칙의 코드로 바꿔 저장했다가, 필요할 때 거꾸로 풀어서 원래 이름을 되살리는 함수를 만들어 두고 씁니다.

광고

파일 이름에 사용할 수 없는 문자를 안내하는 윈도우 경고 화면

방법 1: %+문자코드로 바꾸기

처음 만든 방식은 URL 인코딩처럼 % 뒤에 문자 코드를 붙이는 것입니다.

    Public Function StringToFileName(fName As String) As String
        Dim V() As String = {"%", "\", "/", ":", "*", "?", """", "<", ">", "|"} '// 변환시
        Dim str As String = fName
        For Each T As String In V
            Dim s As String = CStr(Asc(T))
            If s.Length < 3 Then
                s = "0" & s
                s = String.Format("%{0}", Microsoft.VisualBasic.Right(s, 2))
            Else
                s = "0" & s
                s = Microsoft.VisualBasic.Right(s, 4)
                s = String.Format("%{0}%{1}", s.Substring(0, 2), s.Substring(2, 2))
            End If
            str = Replace$(str, T, s)
        Next
        Return str
    End Function

    Public Function FileNameToString(fName As String) As String
        Dim V() As String = {"\", "/", ":", "*", "?", """", "<", ">", "|", "%"} '// 복구시
        Dim str As String = fName
        For Each T As String In V
            Dim s As String = CStr(Asc(T))
            If s.Length < 3 Then
                s = "0" & s
                s = String.Format("%{0}", Microsoft.VisualBasic.Right(s, 2))
            Else
                s = "0" & s
                s = Microsoft.VisualBasic.Right(s, 4)
                s = String.Format("%{0}%{1}", s.Substring(0, 2), s.Substring(2, 2))
            End If
            str = Replace$(str, s, T)
        Next
        Return str
    End Function

Asc() 가 돌려주는 값은 10진수입니다. 그래서 \(92) 는 %92, :(58) 은 %58 이 되고, 세 자리인 |(124) 는 앞에 0을 붙여 0124 로 만든 뒤 두 자리씩 끊어 %01%24 가 됩니다. 이름은 비슷하지만 표준 URL 인코딩(16진수, \ → %5C)과는 값이 다르니 브라우저나 Uri.UnescapeDataString 으로 풀면 안 됩니다.

배열 순서가 중요한 포인트입니다. 변환할 때는 % 를 맨 먼저 바꿉니다. 원래 이름에 있던 % 를 먼저 %37 로 바꿔 두지 않으면, 뒤에서 만들어진 %92 같은 코드와 원래의 % 가 구분되지 않기 때문입니다. 반대로 복구할 때는 % 를 맨 마지막에 되돌립니다. 이 순서가 뒤바뀌면 "50%:할인" 같은 이름이 엉뚱하게 복구됩니다.

방법 2: _U+유니코드 16진수로 바꾸기

Path.GetInvalidFileNameChars() 를 쓰면 금지 문자 목록을 직접 적지 않아도 됩니다. 이 목록에는 위의 아홉 문자뿐 아니라 제어 문자(코드 0~31)까지 들어 있어서, 크롤링한 텍스트에 탭이나 줄바꿈이 섞여 들어오는 경우까지 한 번에 처리됩니다.

Public Function ReplaceInvalidFileNameChars(fileName As String) As String
    Dim invalidChars As Char() = System.IO.Path.GetInvalidFileNameChars()
    Dim safeFileName As New System.Text.StringBuilder(fileName)
    For Each c As Char In invalidChars
        safeFileName.Replace(c, "_U" + AscW(c).ToString("X4"))
    Next
    Return safeFileName.ToString()
End Function

AscW(c).ToString("X4") 가 유니코드 값을 대문자 16진수 4자리로 만들어 줍니다. \ 는 _U005C, ? 는 _U003F 가 됩니다.

복구는 정규식으로 _U + 16진수 4자리 패턴을 찾아 문자로 되돌립니다.

Public Function RecoverFileName(safeFileName As String) As String
    Dim recoveredFileName As New System.Text.StringBuilder(safeFileName)

    ' 순차적으로 Unicode 포인트에 해당하는 문자를 복구합니다.
    Dim regex As New System.Text.RegularExpressions.Regex("_U([0-9A-F]{4})")
    Dim m As System.Text.RegularExpressions.Match = regex.Match(safeFileName)
    While m.Success
        Dim unicodeChar As Char = ChrW(Convert.ToInt32(m.Groups(1).Value, 16))
        recoveredFileName.Replace(m.Value, unicodeChar)
        m = m.NextMatch()
    End While

    Return recoveredFileName.ToString()
End Function

두 방법 모두 걸리는 함정

방법 2는 깔끔하지만 원래 이름에 _U 뒤로 16진수 4자리가 오는 문자열이 있으면 복구할 때 그것까지 문자로 바뀌어 버립니다. 예를 들어 원래 이름이 report_U0041.txt 였다면 복구 결과는 reportA.txt 가 됩니다. 이런 이름이 실제로 나올 확률은 낮지만, 사용자가 이름을 직접 짓는 환경이라면 무시하기 어렵습니다.

해결하려면 방법 1에서 % 를 먼저 인코딩했던 것처럼, 접두사의 첫 글자인 _ 자체도 인코딩 대상에 넣으면 됩니다. 그리고 복구는 Regex.Replace 한 번으로 처리해야 합니다. 원래 코드처럼 매치마다 StringBuilder 전체에 Replace 를 거는 방식은, 앞에서 되돌린 결과가 뒤의 패턴과 우연히 같아지면 한 번 더 바뀌는 문제가 생길 수 있거든요. 아래는 그 두 가지를 보완해서 새로 작성한 버전입니다.

Public Function EncodeFileName(fileName As String) As String
    Dim invalid As New HashSet(Of Char)(System.IO.Path.GetInvalidFileNameChars())
    Dim sb As New System.Text.StringBuilder()
    For Each c As Char In fileName
        If c = "_"c OrElse invalid.Contains(c) Then
            sb.Append("_U").Append(AscW(c).ToString("X4"))
        Else
            sb.Append(c)
        End If
    Next
    Return sb.ToString()
End Function

Public Function DecodeFileName(safeName As String) As String
    Return System.Text.RegularExpressions.Regex.Replace(safeName, "_U([0-9A-F]{4})",
        Function(m) ChrW(Convert.ToInt32(m.Groups(1).Value, 16)).ToString())
End Function

문자를 하나씩 보면서 새로 쌓기 때문에 치환 순서를 신경 쓸 필요가 없고, 원래 있던 _ 는 _U005F 로 바뀌므로 디코딩 시 모든 _U 패턴이 "우리가 만든 것"이라고 확신할 수 있습니다. 대신 밑줄이 많은 이름은 파일명이 꽤 길어집니다.

그 밖에 챙길 것

문자 치환만으로 모든 문제가 풀리진 않습니다. 윈도우에서는 CON, PRN, AUX, NUL, COM1, LPT1 같은 예약 이름을 파일명으로 쓸 수 없고, 이름 끝의 마침표나 공백은 저장할 때 잘려 나갑니다. 경로 전체 길이 제한(기본 260자)도 있어서, 긴 제목을 인코딩하면 길이가 더 늘어나 저장이 실패할 수 있습니다. 실무에서는 인코딩한 이름이 일정 길이를 넘으면 잘라내고, 원래 제목은 DB나 별도 메타 파일에 따로 기록해 두는 방식을 같이 쓰는 편이 안전합니다.