diff --git a/cmd/main.go b/cmd/main.go index ed5dd0e..70869d7 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -1,68 +1,260 @@ package main import ( + "context" "flag" "fmt" "os" + "os/signal" + "syscall" "time" "git.lhk.o-r.kr/freerer2/simple_backup/internal/backup" + "git.lhk.o-r.kr/freerer2/simple_backup/internal/constants" + "git.lhk.o-r.kr/freerer2/simple_backup/internal/logger" "git.lhk.o-r.kr/freerer2/simple_backup/internal/path" ) -func main() { - if len(os.Args) < 3 { - fmt.Println("사용법: backup <원본경로> <백업경로> [옵션]") - os.Exit(1) +type Options struct { + Src string + Dst string + GroupBy string + CompareMode string + Incremental bool + DryRun bool + Verbose bool + Force bool +} + +type ValidationError struct { + Option string + Value string + Msg string +} + +func (e *ValidationError) Error() string { + return fmt.Sprintf("%s 옵션 오류: %s (%s)", e.Option, e.Msg, e.Value) +} + +func showHelp() { + helpText := `사용법: backup <원본경로> <백업경로> [옵션] + +옵션: + -g, --group-by 백업 폴더 구조 기준 (기본값: day) + 가능한 값: year, mon, day, hour, min, sec + + -i, --incremental 증분 백업 사용 + 기존 백업과 비교하여 변경된 파일만 백업 + + -c, --compare 파일 비교 방식 선택 (기본값: time) + - time: 파일 수정 시간으로 비교 + - hash: 파일 내용의 해시값으로 비교 + + -d, --dry-run 하지 않고 어떤 파일이 복사되는지 출력 + 실제 파일 시스템을 변경하지 않음 + + -v, --verbose 복사 로그 자세히 출력 + 진행 상황과 세부 정보를 표시 + + -f, --force 속성 무시하고 무조건 덮어쓰기 + 기존 파일 존재 시 강제로 덮어씀 + +예시: + backup /source /backup --group-by day --compare hash + backup /home/user/docs /backup/docs -i -v + backup /data /backup -d --force + +자세한 정보: https://github.com/yourusername/backup` + + fmt.Println(helpText) +} + +func validateOptions(opts Options) error { + // 필수 경로 검증 + if opts.Src == "" { + return &ValidationError{Option: "src", Value: opts.Src, Msg: "원본 경로가 지정되지 않았습니다"} + } + if opts.Dst == "" { + return &ValidationError{Option: "dst", Value: opts.Dst, Msg: "백업 경로가 지정되지 않았습니다"} + } + + // 원본 경로 존재 확인 + if _, err := os.Stat(opts.Src); os.IsNotExist(err) { + return &ValidationError{Option: "src", Value: opts.Src, Msg: "원본 경로가 존재하지 않습니다"} + } + + // group-by 옵션 검증 + validGroupBy := map[string]bool{ + "": true, + constants.GroupByYear: true, + constants.GroupByMonth: true, + constants.GroupByDay: true, + constants.GroupByHour: true, + constants.GroupByMin: true, + constants.GroupBySec: true, + } + if !validGroupBy[opts.GroupBy] { + return &ValidationError{ + Option: "group-by", + Value: opts.GroupBy, + Msg: "지원하지 않는 그룹화 옵션입니다. 가능한 값: year, mon, day, hour, min, sec", + } + } + + // compare 옵션 검증 + validCompare := map[string]bool{ + constants.CompareTime: true, + constants.CompareHash: true, + } + if !validCompare[opts.CompareMode] { + return &ValidationError{ + Option: "compare", + Value: opts.CompareMode, + Msg: "지원하지 않는 비교 모드입니다. 가능한 값: time, hash", + } + } + + // 증분 백업 + force 모드 조합 경고 + if opts.Incremental && opts.Force { + fmt.Println("경고: force 모드와 증분 백업을 함께 사용하면 증분 백업의 이점이 사라질 수 있습니다") + } + + return nil +} + +func parseFlags() (Options, error) { + if len(os.Args) < 2 || (len(os.Args) == 2 && (os.Args[1] == "-h" || os.Args[1] == "--help")) { + showHelp() + os.Exit(0) + } + + if len(os.Args) < 3 { + return Options{}, fmt.Errorf("원본 경로와 백업 경로를 모두 지정해야 합니다") } - src := os.Args[1] - dst := os.Args[2] fs := flag.NewFlagSet("backup", flag.ExitOnError) - groupBy := fs.String("group-by", "", "백업 폴더 구조 기준: year, mon, day, hour, min, sec") - snapshot := fs.String("snapshot", "", "스냅샷 기준: y, ym, ymd") - dryRun := fs.Bool("dry-run", false, "복사하지 않고 어떤 파일이 복사되는지 출력") - verbose := fs.Bool("verbose", false, "복사 로그 자세히 출력") - force := fs.Bool("force", false, "속성 무시하고 무조건 덮어쓰기") - _ = fs.Parse(os.Args[3:]) + fs.Usage = showHelp - now := time.Now() - backupPath := dst - - if *groupBy != "" { - backupPath = path.GetGroupByFolder(dst, *groupBy, now) + opts := Options{ + Src: os.Args[1], + Dst: os.Args[2], + GroupBy: constants.DefaultGroupBy, + CompareMode: constants.DefaultCompare, } - if *snapshot != "" { - var snapshotFolder string - switch *snapshot { - case "y": - snapshotFolder = fmt.Sprintf("%d", now.Year()) - case "ym": - snapshotFolder = fmt.Sprintf("%d%02d", now.Year(), int(now.Month())) - case "ymd": - snapshotFolder = fmt.Sprintf("%d%02d%02d", now.Year(), int(now.Month()), now.Day()) - default: - fmt.Printf("오류: 지원하지 않는 snapshot 옵션입니다: %s\n", *snapshot) - os.Exit(1) - } - backupPath = fmt.Sprintf("%s/snapshots/%s", dst, snapshotFolder) + // 플래그 정의 + fs.StringVar(&opts.GroupBy, "group-by", constants.DefaultGroupBy, "") + fs.StringVar(&opts.GroupBy, "g", constants.DefaultGroupBy, "") + fs.StringVar(&opts.CompareMode, "compare", constants.DefaultCompare, "") + fs.StringVar(&opts.CompareMode, "c", constants.DefaultCompare, "") + fs.BoolVar(&opts.Incremental, "incremental", false, "") + fs.BoolVar(&opts.Incremental, "i", false, "") + fs.BoolVar(&opts.DryRun, "dry-run", false, "") + fs.BoolVar(&opts.DryRun, "d", false, "") + fs.BoolVar(&opts.Verbose, "verbose", false, "") + fs.BoolVar(&opts.Verbose, "v", false, "") + fs.BoolVar(&opts.Force, "force", false, "") + fs.BoolVar(&opts.Force, "f", false, "") + + if err := fs.Parse(os.Args[3:]); err != nil { + return opts, err } - fmt.Println("원본 경로:", src) - fmt.Println("최종 백업 경로:", backupPath) - if *dryRun { - fmt.Println("Dry-run 모드 활성화됨") - } - if *verbose { - fmt.Println("Verbose 모드 활성화됨") - } - if *force { - fmt.Println("Force 모드 활성화됨") - } + return opts, nil +} - if err := backup.RunBackup(src, backupPath, *dryRun, *verbose, *force); err != nil { - fmt.Println("백업 중 오류 발생:", err) +func printOptions(opts Options, backupPath string) { + fmt.Println("\n실행 설정:") + fmt.Println("----------------------------------------") + fmt.Printf("원본 경로: %s\n", opts.Src) + fmt.Printf("최종 백업 경로: %s\n", backupPath) + fmt.Printf("그룹화 방식: %s\n", opts.GroupBy) + fmt.Printf("비교 모드: %s\n", opts.CompareMode) + fmt.Printf("증분 백업: %v\n", opts.Incremental) + fmt.Printf("드라이런 모드: %v\n", opts.DryRun) + fmt.Printf("상세 로그: %v\n", opts.Verbose) + fmt.Printf("강제 덮어쓰기: %v\n", opts.Force) + fmt.Println("----------------------------------------\n") +} + +func main() { + opts, err := parseFlags() + if err != nil { + fmt.Println("오류:", err) + showHelp() os.Exit(1) } + + if err := validateOptions(opts); err != nil { + fmt.Println("오류:", err) + fmt.Println("\n사용법을 확인하려면 --help를 사용하세요") + os.Exit(1) + } + + // 컨텍스트 설정 + ctx := context.Background() + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + // 시그널 처리 + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) + go func() { + <-sigCh + fmt.Println("\n백업 취소 요청됨...") + cancel() + }() + + now := time.Now() + backupPath := opts.Dst + if opts.GroupBy != "" { + backupPath = path.GetGroupByFolder(opts.Dst, opts.GroupBy, now) + } + + log := logger.New(opts.Verbose) + + // 진행 상황 콜백 함수 + progressCallback := func(p backup.Progress) { + if opts.Verbose { + percentComplete := 0.0 + if p.TotalFiles > 0 { + percentComplete = float64(p.ProcessedFiles) / float64(p.TotalFiles) * 100 + } + fmt.Printf("\r진행률: %.1f%% (%d/%d) - 현재: %s\n", + percentComplete, p.ProcessedFiles, p.TotalFiles, p.CurrentFile) + } + } + + mode := backup.CompareTime + if opts.CompareMode == constants.CompareHash { + mode = backup.CompareHash + } + + backupOpts := backup.BackupOptions{ + DryRun: opts.DryRun, + Verbose: opts.Verbose, + Force: opts.Force, + Incremental: opts.Incremental, + CompareMode: mode, + Logger: log, + Progress: progressCallback, + } + + // main 함수에서 해당 부분을 다음과 같이 교체: + if opts.Verbose { + printOptions(opts, backupPath) + } + + if err := backup.RunBackup(ctx, opts.Src, backupPath, backupOpts); err != nil { + if err == context.Canceled { + fmt.Println("\n백업이 취소되었습니다") + } else { + fmt.Println("\n백업 중 오류 발생:", err) + } + os.Exit(1) + } + + if opts.Verbose { + fmt.Println("\n백업이 성공적으로 완료되었습니다") + } } diff --git a/internal/backup/backup.go b/internal/backup/backup.go index 977743b..b8afd7a 100644 --- a/internal/backup/backup.go +++ b/internal/backup/backup.go @@ -1,61 +1,263 @@ package backup import ( + "context" + "crypto/md5" + "encoding/json" "fmt" - "git.lhk.o-r.kr/freerer2/simple_backup/internal/copy" + "io" "os" "path/filepath" + "time" + + "git.lhk.o-r.kr/freerer2/simple_backup/internal/constants" + "git.lhk.o-r.kr/freerer2/simple_backup/internal/copy" + "git.lhk.o-r.kr/freerer2/simple_backup/internal/logger" + "git.lhk.o-r.kr/freerer2/simple_backup/internal/path" ) -// RunBackup: 백업 실행 함수 -func RunBackup(src, dst string, dryRun, verbose, force bool) error { - if !dryRun { - // 백업 폴더 생성 - if err := os.MkdirAll(dst, 0755); err != nil { - return fmt.Errorf("백업 폴더 생성 실패: %w", err) +type CompareMode int + +const ( + CompareTime CompareMode = iota + CompareHash +) + +type Progress struct { + CurrentFile string + ProcessedFiles int + TotalFiles int +} + +type ProgressCallback func(Progress) + +type BackupOptions struct { + DryRun bool + Verbose bool + Force bool + Incremental bool + CompareMode CompareMode + Logger *logger.Logger + Progress ProgressCallback + dirCache *path.DirCache +} + +type BackupMeta struct { + LastBackup time.Time `json:"lastBackup"` + Files map[string]string `json:"files"` // 파일 경로 -> 해시 또는 수정 시간 +} + +func loadBackupMeta(dst string) (*BackupMeta, error) { + metaPath := filepath.Join(dst, constants.MetaFileName) + data, err := os.ReadFile(metaPath) + if err != nil { + if os.IsNotExist(err) { + return &BackupMeta{ + Files: make(map[string]string), + }, nil + } + return nil, fmt.Errorf("메타 파일을 읽을 수 없습니다: %w", err) + } + + var meta BackupMeta + if err := json.Unmarshal(data, &meta); err != nil { + return nil, fmt.Errorf("메타 파일을 파싱할 수 없습니다: %w", err) + } + + if meta.Files == nil { + meta.Files = make(map[string]string) + } + return &meta, nil +} + +func saveBackupMeta(dst string, meta *BackupMeta) error { + metaPath := filepath.Join(dst, constants.MetaFileName) + data, err := json.MarshalIndent(meta, "", " ") + if err != nil { + return fmt.Errorf("메타 데이터를 직렬화할 수 없습니다: %w", err) + } + + if err := os.WriteFile(metaPath, data, constants.DefaultFileMode); err != nil { + return fmt.Errorf("메타 파일을 저장할 수 없습니다: %w", err) + } + + return nil +} + +func calculateFileHash(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer func(f *os.File) { + err := f.Close() + if err != nil { + + } + }(f) + + h := md5.New() + if _, err := io.Copy(h, f); err != nil { + return "", err + } + + return fmt.Sprintf("%x", h.Sum(nil)), nil +} + +func RunBackup(ctx context.Context, src, dst string, opts BackupOptions) error { + if opts.dirCache == nil { + opts.dirCache = path.NewDirCache() + } + + // 대상 디렉토리 생성 + if !opts.DryRun { + // 대상 디렉토리 생성 + if err := opts.dirCache.EnsureDir(dst); err != nil { + return fmt.Errorf("대상 디렉토리를 생성할 수 없습니다: %w", err) } } - return filepath.Walk(src, func(path string, info os.FileInfo, err error) error { + // 증분 백업을 위한 메타데이터 로드 + var meta *BackupMeta + var err error + if opts.Incremental { + meta, err = loadBackupMeta(dst) + if err != nil { + return err + } + } + + // 파일 목록 수집 + var files []string + err = filepath.Walk(src, func(path string, info os.FileInfo, err error) error { if err != nil { return err } - relPath, err := filepath.Rel(src, path) - if err != nil { - return err - } - targetPath := filepath.Join(dst, relPath) - + // 디렉토리는 건너뜀 if info.IsDir() { - if verbose { - fmt.Println("디렉터리 생성:", targetPath) - } - if !dryRun { - return os.MkdirAll(targetPath, info.Mode()) - } return nil } - if dryRun { - fmt.Println("복사 예정 파일:", targetPath) + // 메타파일은 건너뜀 + if filepath.Base(path) == constants.MetaFileName { return nil } - // force 옵션이 없으면 수정 시간 비교 - if !force { - dstInfo, err := os.Stat(targetPath) - if err == nil && !info.ModTime().After(dstInfo.ModTime()) { - if verbose { - fmt.Println("복사 스킵 (업데이트 필요 없음):", path) - } - return nil - } - } - - if verbose { - fmt.Println("파일 복사:", path, "→", targetPath) - } - return copy.RunCopy(path, targetPath) + files = append(files, path) + return nil }) + + if err != nil { + return fmt.Errorf("파일 목록을 수집할 수 없습니다: %w", err) + } + + // 진행 상황 초기화 + progress := Progress{ + TotalFiles: len(files), + } + + // 파일 복사 함수 + copyFile := func(srcPath string, dstPath string) error { + if opts.DryRun { + return nil + } + + // 대상 디렉토리 생성 + dstDir := filepath.Dir(dstPath) + if err := opts.dirCache.EnsureDir(dstDir); err != nil { + return fmt.Errorf("대상 디렉토리를 생성할 수 없습니다: %w", err) + } + + return copy.RunCopy(srcPath, dstPath) + } + + // 각 파일 처리 + for i, srcPath := range files { + select { + case <-ctx.Done(): + return context.Canceled + default: + } + + // 진행 상황 업데이트 + progress.CurrentFile = srcPath + progress.ProcessedFiles = i + 1 + if opts.Progress != nil { + opts.Progress(progress) + } + + // 상대 경로 계산 + relPath, err := filepath.Rel(src, srcPath) + if err != nil { + return fmt.Errorf("상대 경로를 계산할 수 없습니다: %w", err) + } + + dstPath := filepath.Join(dst, relPath) + + // 파일이 이미 존재하는지 확인 + dstInfo, err := os.Stat(dstPath) + fileExists := err == nil + + if fileExists && !opts.Force { + if opts.Incremental { + // 증분 백업 모드에서는 메타데이터를 확인 + srcInfo, err := os.Stat(srcPath) + if err != nil { + return fmt.Errorf("원본 파일 정보를 읽을 수 없습니다: %w", err) + } + + oldValue, exists := meta.Files[relPath] + var newValue string + + switch opts.CompareMode { + case CompareTime: + newValue = srcInfo.ModTime().String() + if exists && oldValue == newValue { + opts.Logger.Printf("건너뜀 (시간 동일): %s\n", relPath) + continue + } + case CompareHash: + newValue, err = calculateFileHash(srcPath) + if err != nil { + return fmt.Errorf("파일 해시를 계산할 수 없습니다: %w", err) + } + if exists && oldValue == newValue { + opts.Logger.Printf("건너뜀 (해시 동일): %s\n", relPath) + continue + } + } + + meta.Files[relPath] = newValue + } else { + // 일반 모드에서는 크기와 수정 시간만 비교 + srcInfo, err := os.Stat(srcPath) + if err != nil { + return fmt.Errorf("원본 파일 정보를 읽을 수 없습니다: %w", err) + } + + if srcInfo.Size() == dstInfo.Size() && srcInfo.ModTime().Equal(dstInfo.ModTime()) { + opts.Logger.Printf("건너뜀 (동일): %s\n", relPath) + continue + } + } + } + + // 파일 복사 + if err := copyFile(srcPath, dstPath); err != nil { + return fmt.Errorf("파일을 복사할 수 없습니다: %w", err) + } + + opts.Logger.Printf("복사됨: %s\n", relPath) + } + + // 증분 백업 메타데이터 저장 + if opts.Incremental && !opts.DryRun { + meta.LastBackup = time.Now() + if err := saveBackupMeta(dst, meta); err != nil { + return err + } + } + + return nil } diff --git a/internal/constants/constants.go b/internal/constants/constants.go new file mode 100644 index 0000000..bdc5502 --- /dev/null +++ b/internal/constants/constants.go @@ -0,0 +1,28 @@ +package constants + +// 그룹화 옵션 +const ( + GroupByYear = "year" + GroupByMonth = "mon" + GroupByDay = "day" + GroupByHour = "hour" + GroupByMin = "min" + GroupBySec = "sec" +) + +// 비교 모드 옵션 +const ( + CompareTime = "time" + CompareHash = "hash" +) + +// 버퍼 크기 +const ( + CopyBufferSize = 1024 * 1024 // 1MB +) +const ( + DefaultGroupBy = "day" + DefaultCompare = "time" + MetaFileName = ".backup_meta.json" + DefaultFileMode = 0755 +) diff --git a/internal/copy/copy.go b/internal/copy/copy.go index 2137e26..55ff673 100644 --- a/internal/copy/copy.go +++ b/internal/copy/copy.go @@ -1,48 +1,70 @@ package copy import ( + "fmt" "io" "os" + "sync" + + "git.lhk.o-r.kr/freerer2/simple_backup/internal/constants" ) -// CopyFile 복사 함수: srcFile → dstFile +var bufferPool = sync.Pool{ + New: func() interface{} { + return make([]byte, constants.CopyBufferSize) + }, +} + func RunCopy(srcFile, dstFile string) error { + // 소스 파일 정보 확인 + srcInfo, err := os.Stat(srcFile) + if err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("source file does not exist: %s", srcFile) + } + return fmt.Errorf("failed to get source file info: %w", err) + } + + // 소스 파일 열기 src, err := os.Open(srcFile) if err != nil { - return err + return fmt.Errorf("failed to open source file: %w", err) } - - defer func(src *os.File) { - err := src.Close() - if err != nil { - + defer func() { + if cerr := src.Close(); cerr != nil && err == nil { + err = fmt.Errorf("failed to close source file: %w", cerr) } - }(src) + }() + // 대상 파일 생성 dst, err := os.Create(dstFile) if err != nil { - return err + return fmt.Errorf("failed to create destination file: %w", err) } - defer func(dst *os.File) { - err := dst.Close() - if err != nil { - + defer func() { + if cerr := dst.Close(); cerr != nil && err == nil { + err = fmt.Errorf("failed to close destination file: %w", cerr) } - }(dst) + }() - _, err = io.Copy(dst, src) - if err != nil { - return err + // 버퍼 풀에서 가져오기 + buf := bufferPool.Get().([]byte) + defer bufferPool.Put(buf) + + // 파일 내용 복사 + if _, err = io.CopyBuffer(dst, src, buf); err != nil { + return fmt.Errorf("failed to copy file contents: %w", err) } - // 원본 파일의 권한을 복사 - info, err := os.Stat(srcFile) - if err == nil { - err := os.Chmod(dstFile, info.Mode()) - if err != nil { - return err - } + // 파일 권한과 시간 정보 복사 + if err := os.Chmod(dstFile, srcInfo.Mode()); err != nil { + return fmt.Errorf("failed to copy file permissions: %w", err) + } + + // 수정 시간과 접근 시간 복사 + if err := os.Chtimes(dstFile, srcInfo.ModTime(), srcInfo.ModTime()); err != nil { + return fmt.Errorf("failed to copy file timestamps: %w", err) } return nil diff --git a/internal/logger/logger.go b/internal/logger/logger.go new file mode 100644 index 0000000..9d3d091 --- /dev/null +++ b/internal/logger/logger.go @@ -0,0 +1,32 @@ +package logger + +import ( + "bufio" + "fmt" + "os" + "sync" +) + +type Logger struct { + verbose bool + writer *bufio.Writer + mu sync.Mutex +} + +func New(verbose bool) *Logger { + return &Logger{ + verbose: verbose, + writer: bufio.NewWriter(os.Stdout), + } +} + +func (l *Logger) Printf(format string, args ...interface{}) { + if !l.verbose { + return + } + + l.mu.Lock() + _, _ = fmt.Fprintf(l.writer, format, args...) + _ = l.writer.Flush() + l.mu.Unlock() +} diff --git a/internal/path/path.go b/internal/path/path.go index 25a89bf..006a05f 100644 --- a/internal/path/path.go +++ b/internal/path/path.go @@ -2,23 +2,36 @@ package path import ( "fmt" + "os" + "sync" "time" + + "git.lhk.o-r.kr/freerer2/simple_backup/internal/constants" ) -// GetGroupByFolder: group-by 옵션에 따라 백업 경로 생성 +// GetGroupByFolder generates a backup path based on the groupBy option and time. +// If groupBy is invalid or empty, it returns the basePath unchanged. +// +// Parameters: +// - basePath: the base directory path for backup +// - groupBy: grouping option (year, mon, day, hour, min, sec) +// - t: time to use for path generation +// +// Returns: +// - string: generated path based on groupBy option func GetGroupByFolder(basePath, groupBy string, t time.Time) string { switch groupBy { - case "year": + case constants.GroupByYear: return fmt.Sprintf("%s/%d", basePath, t.Year()) - case "mon": + case constants.GroupByMonth: return fmt.Sprintf("%s/%d%02d", basePath, t.Year(), t.Month()) - case "day": + case constants.GroupByDay: return fmt.Sprintf("%s/%d%02d%02d", basePath, t.Year(), t.Month(), t.Day()) - case "hour": + case constants.GroupByHour: return fmt.Sprintf("%s/%d%02d%02d_%02d", basePath, t.Year(), t.Month(), t.Day(), t.Hour()) - case "min": + case constants.GroupByMin: return fmt.Sprintf("%s/%d%02d%02d_%02d%02d", basePath, t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute()) - case "sec": + case constants.GroupBySec: return fmt.Sprintf("%s/%d%02d%02d_%02d%02d%02d", basePath, t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), t.Second()) case "": return basePath @@ -27,3 +40,39 @@ func GetGroupByFolder(basePath, groupBy string, t time.Time) string { return basePath } } + +// 디렉토리 캐시 +type DirCache struct { + sync.RWMutex + exists map[string]bool +} + +func NewDirCache() *DirCache { + return &DirCache{ + exists: make(map[string]bool), + } +} + +func (c *DirCache) EnsureDir(path string) error { + c.RLock() + if c.exists[path] { + c.RUnlock() + return nil + } + c.RUnlock() + + c.Lock() + defer c.Unlock() + + // 다시 확인 (다른 고루틴이 생성했을 수 있음) + if c.exists[path] { + return nil + } + + if err := os.MkdirAll(path, constants.DefaultFileMode); err != nil { + return err + } + + c.exists[path] = true + return nil +}