[Java] java.nio.file.Files 사용법 익히기

식빵·2021년 12월 21일
1

Java Lab

목록 보기
1/23
post-thumbnail

🥝 작성 계기

일을 하다보니 java.nio.file.Files 를 알게 되었다.
꽤나 유용한 거 같아서 이 기회에 간단한 사용법을 익혀보려고 이 글을 썼다.
지금부터 이 패키지에 어떤 기능이 있는지 간단하게 알아보자.

그리고 java.nio.file.Files 로만 하기 힘들거나, 더 심플하게 코딩할 수 있는
다른 API 들도 중간중간 소개하겠다.


참고: 주로 사용하는 패키지 목록. import 시 헷갈리면 여기를 참조하세요!

  • import java.util.stream.Collectors;
  • import java.io.BufferedInputStream;
  • import java.io.BufferedOutputStream;
  • import java.io.BufferedReader;
  • import java.io.BufferedWriter;
  • import java.io.InputStream;
  • import java.io.OutputStream;
  • import java.nio.file.Files;
  • import java.nio.file.Paths;
  • import java.nio.file.attribute.BasicFileAttributes;
  • import java.time.Instant;
  • import java.time.LocalDateTime;
  • import java.time.ZoneId;
  • 시간 관련된 건 다 java.time 패키지 하위에 있는 걸 사용한다.


🥝 파일 속성 조회

@Test
void FileFindFilteringTest() throws IOException {
    String filePath = "D:/upload/wow.txt";
    BasicFileAttributes basicFileAttributes 
    	= Files.readAttributes(Paths.get(filePath), BasicFileAttributes.class);
    System.out.println("isRegularFile = " + basicFileAttributes.isRegularFile());
    System.out.println("creationTime = " + basicFileAttributes.creationTime());
    System.out.println("lastModifiedTime = " + basicFileAttributes.lastModifiedTime());
}

출력 결과:

isRegularFile() = true
creationTime() = 2021-12-21T11:03:45.21Z
lastModifiedTime() = 2021-12-21T10:04:22Z




🥝 Stream 을 통한 파일 복제

  • Files.newInputStream(Paths.get(realPath))
  • Files.newOutputStream(Paths.get(writePath))
@Test
void testMe() {
    // 그냥 경로를 지정하는 코드다 신경쓰지 말자
    String realPath = "D:/[Java] Read And Write File/123.txt";
    String writePath = realPath.substring(0, realPath.lastIndexOf("/")) + "/wow.txt" ;

    // 여기서부터 집중
    try(InputStream is = Files.newInputStream(Paths.get(realPath));
        OutputStream os = Files.newOutputStream(Paths.get(writePath))
    ) {

        // 어떤 java 버전에서도 가능한 정석 코드
        byte[] buffer = new byte[8192];
        int read;
        while ((read = is.read(buffer)) >= 0) {
            os.write(buffer, 0, read);
        }

        // java 9 이상 사용시 Inputstream 의 transferTo(OutputStream out); 사용
        // is.transferTo(os);

        // Spring 을 사용 중이라면 아래 2가지 방식 모두 고려해보자.
        // StreamUtils.copy(is, os); // is, os 를 close 하지 않음, 내부 버퍼 4096 사용
        // FileCopyUtils.copy(is, os); // is, os 를 자동으로 close 함, 내부 버퍼 8192 사용

    } catch (Exception e) {
        e.printStackTrace();
    }
}

위에서 사용한 것은 InputStream, OutputStream 이였다면
이번에는 Reader, Writer로 파일을 제어할 수 있는 방법도 알아보자.

  • Files.newBufferedReader(Paths.get("파일경로"), StandardCharsets.UTF_8);
  • Files.newBufferedWriter(Paths.get("파일경로"), StandardCharsets.UTF_8);

이 방법으로 다시 위의 동일한 파일 복제 기능을 구현하려면 아래처럼 코딩한다.

String realPath = "D:/[Java] Read And Write File/123.txt";
String writePath = realPath.substring(0, realPath.lastIndexOf("/")) + "/wow.txt" ;

try(BufferedReader reader = Files.newBufferedReader(Paths.get(realPath), StandardCharsets.UTF_8);
    BufferedWriter writer = Files.newBufferedWriter(Paths.get(writePath), StandardCharsets.UTF_8)
) {

    // 어떤 java 버전에서도 가능한 정석 코드
    char[] buffer = new char[8192];
    int read;
    while ((read = reader.read(buffer)) >= 0) {
        writer.write(buffer, 0, read);
    }

} catch (Exception e) {
    e.printStackTrace();
}

지금까지 Stream 을 사용한 파일 복제 방법을 알아봤다.

그런데 단순 파일 복제는 아래에서 볼 경로를 통한 파일 복제 방법이 더 편하다.
Stream 방식은 HttpServletRequest, HttpServletResponse 와 작업을 할 때 더 유용하다.





🥝 경로를 통한 파일 복제

정말 간단하다.

public static void main(String[] args) throws IOException {
    String realPath = "D:/[Java] Read And Write File/123.txt";
    String writePath = realPath.substring(0, realPath.lastIndexOf("/")) + "/wow.txt" ;
    Files.copy(Paths.get(realPath), Paths.get(writePath));
}

Files.copy(); 는 오버라이드로 3가지 종류가 있다

  • Files.copy(Path path, OutputStream out)
  • Files.copy(InputStream in, Path path, CopyOption... option)
  • Files.copy(Path path, Path target, CopyOption... option)

대충보면 어떻게 쓰는지 감이 잡히리라 생각한다. 더 이상의 설명은 안하겠다.

참고로 java.nio.file.CopyOption 인자 값으로 몇가지 추가적인 옵션을 줄 수 있다.

다른 블로그에서는 StandardCopyOption.REPLACE_EXISTING 를 흔히 쓰는 걸 봤다. 필요하다면 사용하자.





🥝 Directory 복사하기

Files 에는 재귀적으로 Directory 내의 모든 것을 복사하는 기능은 없다.

Directory의 내부 모든 파일을 재귀적으로 복사하고 싶다면
org.springframework.util.FileSystemUtils.copyRecursively(~) 를 사용하자.





🥝 파일의 문자열 모두 읽어오기


방법1: Files.newBufferedReader

public static void main(String[] args) {
    String temp = "D:/[Java] Read And Write File/123.txt";

    try(BufferedReader br = Files.newBufferedReader(Paths.get(temp), StandardCharsets.UTF_8)) {
        
        StringBuilder builder = new StringBuilder();
        String str = null;
        while((str = br.readLine()) != null) {
            builder.append(str).append("\n");
        }
        System.out.print(builder.toString());
        
    } catch (IOException e) {
        e.printStackTrace();
    }
}

방법2: Files.lines

public static void main(String[] args) {
    String temp = "D:/[Java] Read And Write File/123.txt";

    try(Stream<String> lines = Files.lines(Paths.get(temp), StandardCharsets.UTF_8)) {
        
        StringBuilder builder = new StringBuilder();
        lines.forEach(s -> builder.append(s).append("\n"));
        System.out.println(builder.toString());
        
    } catch (IOException e) {
        e.printStackTrace();
    }
}





🥝 텍스트 파일에 글 쓰기

public static void main(String[] args) throws IOException {
    String temp = "D:/[Java] Read And Write File/123.txt";
    ArrayList<String> messageList = new ArrayList<>(Arrays.asList("hello", "world"));
    
    
    // 방법1: Files.write
    // 두번째 파라미터로 Iterable<? extends CharSequence> iterable 를 받는다.
    Files.write(Paths.get(temp), messageList, StandardCharsets.UTF_8);
    
    // 방법2: Files.writeString
    Files.writeString(Paths.get("C:/upload/jackson/ex1.txt"), 
    				  "안녕하세요", StandardCharsets.UTF_8);
}

위 2가지 방법 모두 기존 텍스트 파일의 내용은 다 지워지고, 새로이 작성되는 점 주의하기 바란다.





🥝 파일 생성과 동시에 쓰기작업

@Test
@Order(4)
@DisplayName("실제 Java 파일의 내용을 템플릿화한다")
void createJavaFileContentTemplateTest() {
	try (BufferedWriter bufferedWriter
	     = Files.newBufferedWriter(
         Paths.get("C:/study/file_create/Employee.java"),
         StandardCharsets.UTF_8, 
         StandardOpenOption.CREATE)
   	) {
         // StandardOpenOption.CREATE 덕분에 파일 생성과 동시에 글쓰기가 가능하다.
		bufferedWriter.write("""
			package me.dailycode.vo;
			           
	        import lombok.Getter;
	        import lombok.Setter;
	        import lombok.ToString;
	        
	        @Getter
	        @Setter
	        @ToString
	        public class %s {
	            /**
	            * %s
	            */
				private %s %s;
			}
			""".formatted("Employee", "comment", "type", "fieldName"));
		
	} catch (IOException e) {
		fail(e.getMessage(), e);
	}
}





🥝 directory 내의 파일 조회하기

테스트를 위하여 C:\upload\directory1 경로에 아래와 같은 디렉토리 및 파일 구조를 잡았다.

\---directory1
    +---directory1_depth1
    |   |   directory1_dept1_file1.txt
    |   |   directory1_dept1_file2.txt
    |   |
    |   \---directory1_dept2
    |           directory1_dept2_file1.txt
    |           directory1_dept2_file2.txt
    |
    \---directory2_depth1
            directory2_dept1_file1.txt
            directory2_dept1_file2.txt

이제 directory 모든 파일들을 조회해보자.

@Test
void FilesWalkTest() {
    String directoryPath = "C:/upload/directory1";
    List <Path> list = Collections.emptyList();
    
    try(Stream<Path> walk = Files.walk(Paths.get(directoryPath))) {
    
        list = walk.filter(Files::isRegularFile)
                .collect(Collectors.toList());
                
    } catch (IOException e) {
        e.printStackTrace();
    }
    
    list.forEach(System.out::println);
}

출력은 아래와 같이 나온다.


폴더의 depth 를 지정하고 싶다면 Files.walk(Paths.get(directoryPath), 2);
처럼 maxDepth를 지정할 수도 있다.

그리고 반드시 사용할 때는 try-with-resource를 사용해서 close 가 되도록 해주자.

API Note:
This method must be used within a try-with-resources statement or similar control structure to ensure that the stream's open directories are closed promptly after the stream's operations have completed.

참고:https://stackoverflow.com/questions/54096143/how-to-close-implicit-stream-in-java





🥝 특정 확장자의 파일들만 찾아내기

테스트를 위하여 C:\upload\directory1 경로에 아래와 같은 디렉토리 및 파일 구조를 잡았다.

\---directory1
    |   dIZwF9gV0Z.jpg
    |   Hpdf_ghlRA7mJEb.jpg
    |
    +---directory1_depth1
    |   |   directory1_dept1_file1.txt
    |   |   directory1_dept1_file2.txt
    |   |
    |   \---directory1_dept2
    |           directory1_dept2_file1.txt
    |           directory1_dept2_file2.txt
    |
    \---directory2_depth1
            directory2_dept1_file1.txt
            directory2_dept1_file2.txt
            idea64_w4zW6Yf1T3.jpg
            idea64_W8HLV3vgyV.jpg
            idea64_ZbmjlckX4b.jpg

Java 코드는 아래와 같다.

@Test
void FileFindTest() {
    String[] fileExtensions = {"png", "jpg", "gif"};
    String directoryPath = "C:/upload/directory1";
    Path path = Paths.get(directoryPath);
    List<Path> collect = Collections.emptyList();

    if (Files.isDirectory(path)) {

        try(Stream<Path> walk = Files.walk(path)) {
            collect = walk.filter(p -> !Files.isDirectory(p))
                    .filter(f -> Arrays.stream(fileExtensions)
                    		.anyMatch(s -> f.toString().endsWith(s))
                    ).collect(Collectors.toList());
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    collect.forEach(System.out::println);
}

출력 결과





🥝 특정 기간 동안 수정된 파일들만 찾아내기

위에서는 Files.walk를 사용했지만, 이번에는 Files.find API를 사용해보자.

아래 코드는 어제 자정(= 00:00)부터 현재까지 변경 이력이 있는 파일 목록을 조회하는 코드다.

/*
디렉토리 구조
C:\UPLOAD\DIRECTORY1
|   23day.png  ===> 2021-12-23 에 수정됨
|   dodo.png
|   lala.png
|
+---directory1_depth1
|   |   directory1_dept1_file1.txt ===> 2021-12-22 에 수정됨
|   |   directory1_dept1_file2.txt
|   |
|   \---directory1_dept2
|           directory1_dept2_file1.txt
|           directory1_dept2_file2.txt ===> 2021-12-22 에 수정됨
|
+---directory2_depth1
|       directory2_dept1_file1.txt
|       directory2_dept1_file2.txt
|
\---img
        at_12_17(2).png
        at_12_17.png
        image1.png
*/

public static void main(String[] args) {


    // 필터링할 파일을 갖고 있는 디렉토리
    Path directoryPath = Paths.get("C:/upload/directory1");

    // 앞으로 자주 쓸 DateTimeFormatter 를 선언 및 할당
    DateTimeFormatter dateFormat = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");

    ZonedDateTime yesterdayTruncate
        = ZonedDateTime.now(ZoneId.of("Asia/Seoul"))	// 내가 사는 한국 시간 기준으로
        .minusDays(1)					// 하루를 빼고
        .truncatedTo(ChronoUnit.DAYS); 			// 거기에 자정으로 시간을 맞춘다.

    // 참고) 현재 코드를 작성한 시간은 2021-12-23 오후 12 시 29분 
    System.out.println("today : " 
    	+ ZonedDateTime.now(ZoneId.of("Asia/Seoul")).format(dateFormat));
        
    // 2021-12-22 00:00:00 이 출력된다.
    System.out.println("yesterdayTruncate : " 
    	+ yesterdayTruncate.format(dateFormat));

    // Instant를 쓰면 TimeZone을 신경 쓰지 않고 시간 연산을 할 수 있기 때문에 편리하다.
    Instant yesterdayInstant = yesterdayTruncate.toInstant();

    // 필터링 한 파일 목록을 저장할 리스트
    List<Path> result = Collections.emptyList();

    // Files.find의 인자값은 아래와 같다.
    // 첫번째 인자값은 디렉토리명
    // 두번째 파라미터는 디렉토리 내부에서 검색할 때 몇 레벨까지 검색할지 결정
    // 세번째 인자값은 필터링 콜백
    try(Stream<Path> pathStream = Files.find(directoryPath, 
    					     Integer.MAX_VALUE, 
                             		     (path, basicFileAttributes) -> {

        // 폴더는 무시 + 읽을 수 없는 파일도 무시
        if(Files.isDirectory(path) || !Files.isReadable(path)) {
            return false;
        }

        // 최종 수정된 날짜를 조회
        FileTime fileModifedTime = basicFileAttributes.lastModifiedTime();

        // 조회된 날짜에서 Instant 를 뽑아냄
        Instant fileInstant = fileModifedTime.toInstant();

        // 최종적으로 위에서 작성한 yesterdayInstant 와 비교한다.
        // 만약 0 이상이면  2021-12-22 00:00:00 시간을 포함한 그 이후의 시간에
        // 파일이 수정되었다는 것을 의미한다.
        return fileInstant.compareTo(yesterdayInstant) >= 0;

    })) {

        // 결과를 수집한다.
        result = pathStream.collect(Collectors.toList());
    } catch (IOException e) {
        e.printStackTrace();
    }


    // 결과를 조회한다.
    result.forEach(p -> {
        try {

            // 속성 조회
            BasicFileAttributes readAttributes = 
            	Files.readAttributes(p, BasicFileAttributes.class);

            // 파일 수정시간 조회
            FileTime lastModifiedTime = readAttributes.lastModifiedTime();

            // 수정 시간에서 ZonedDateTime을 뽑아냄
            ZonedDateTime zonedDateTime = 
            	lastModifiedTime.toInstant().atZone(ZoneId.of("Asia/Seoul"));

            // 출력
            System.out.println(String.format("%-100s [%s]", 
            			p.toAbsolutePath().toString(),
                        	zonedDateTime.format(dateFormat)));

        } catch (IOException e) {
            e.printStackTrace();
        }

    });
}

Files.findFiles.walk 와 마찬가지로 사용할 때는 try-with-resource를 사용해야 한다.

Files.walkFiles.find API는 굉장히 유사하다.
그러니 자신이 더 편리하다 생각하는 API를 사용하면 될 듯하다.

참고로 위 코드에서 ZoneDateTime 과 Instant 에 대해서 잘 모르겠다면
이 블로그를 참고하면 좋다.





🥝 Directory 에서 Recursive 하게 특정 파일들만 복사해가기

2023-12-24 내용 추가

디렉토리 내에서 특정 파일의 prefix확장자
확인하고 복사하는 기능을 만들고 싶다면 Files.walkFileTree 통해서 가능합니다.


디렉토리 파일 복사 Code

테스트 코드 작성 환경
JDK : Azul 17
Spring version : 6

아래 코드의 목표는 다음과 같습니다.

위 디렉토리 내부에 있는 파일들 중에서

  • 파일 이름이 copy_ 로 시작되면서,
  • 파일의 확장자가 txt,csv 인 것만 복사해가고 싶은 겁니다.

지금부터 이걸 가능케 하는 코드를 짜보겠습니다.
참고로 jdk 17 의 record 문법을 약간 씁니다!
record 가 생소하면 가볍게 검색하고 오시기 바랍니다.

package me.dailycode.apachecamel.filecopy;

import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.util.StringUtils;

import java.io.IOException;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.EnumSet;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;

@Slf4j
public class FileCopyTests {
	
	@Test
    void testCopyRecursive() throws IOException {
		String destinationPath = "C:/test_data/to";
		String sourcePath = "C:/test_data/from";
		copyRecursively(
                Paths.get(sourcePath),
                Paths.get(destinationPath),
                new AdditionalOptions("copy_", "txt,csv")
        );
    }
    
    private void copyRecursively(Path src, Path dest) throws IOException {
        copyRecursively(src, dest, new AdditionalOptions("",""));
    }
    
    private void copyRecursively(Path src, Path dest, AdditionalOptions options) throws IOException {
        // src 로 준 디렉토리 경로 "내부"의 파일들만 검색하기 위해서 고의적으로 1을 줬다.
        // 참고로 이렇게 하면 src 디렉토리 하부의 subdirectory 는 뒤져보지 않는다!
        // 딱 src 디렉토리의 "파일"만 뒤져본다.
        final int MAX_SEARCH_DEPTH = 1;
        
        // 필수값 체크
        checkRequiredArguments(src, dest);
        
        // 앞서서 "zip,txt" 처럼 받은 확장자 문자열을 Set 으로 변환시킵니다.
        Set<String> setOfFileExtensions = options.getFileExtSet();
        
        // 맨 처음 src 에 대해 접근할 때 디렉토리인지 파일인지를 먼저 알아야겠죠?
        // 이를 위해서 Files.readAttributes() 메소드를 호출하는 겁니다.
        BasicFileAttributes srcAttr = Files.readAttributes(src, BasicFileAttributes.class);
        
        
        if (srcAttr.isDirectory()) { // 디렉토리인 지 확인합니다.
            //EnumSet.of(FOLLOW_LINKS) // 만약 링크 디렉토리도 타깃으로 잡고 싶다면 이렇게!
            // 링크 디렉토리까지는 파일 복사 타깃을 하고 싶지 않다면 이렇게!
            Files.walkFileTree(src, EnumSet.noneOf(FileVisitOption.class), MAX_SEARCH_DEPTH, new SimpleFileVisitor<Path>() {
                @Override
                public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException {
                    Files.createDirectories(dest.resolve(src.relativize(dir)));
                    return FileVisitResult.CONTINUE;
                }
                @Override
                public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {

                    // 파일의 Prefix 체크 - 체크 조건(filteringFilePrefix 여부)
                    if (StringUtils.hasText(options.filteringFilePrefix())) {
                        // 파일 prefix 가 맞지 않다면? return FileVisitResult.CONTINUE;
                        String pureFileName = StringUtils.stripFilenameExtension(file.getFileName().toString());
                        if (!pureFileName.startsWith(options.filteringFilePrefix())) {
                            return FileVisitResult.CONTINUE;
                        }
                    }

                    // 파일의 확장자 체크 - 체크 조건(setOfFileExtensions 가 비었는지 아닌지 여부)
                    if (!setOfFileExtensions.isEmpty()) {
                        String fileAbsolutePath = file.toAbsolutePath().toString();
                        String filenameExtension = StringUtils.getFilenameExtension(fileAbsolutePath);
                        // 파일 확장자가 맞지 않다면? return FileVisitResult.CONTINUE;
                        if (!setOfFileExtensions.contains(filenameExtension)) {
                            return FileVisitResult.CONTINUE;
                        }
                    }

                    // 만약 위 2개의 조건을 모두 넘어왔다면 Files.copy + fileList.add
                    Path destFile = dest.resolve(src.relativize(file));
                    Files.copy(file, destFile, StandardCopyOption.REPLACE_EXISTING);
                    return FileVisitResult.CONTINUE;
                }
            });
        }
        else if (srcAttr.isRegularFile()) {
            // 혹여 단일 파일에 대한 처리도 필요하다면 여기에 작성.
            throw new IllegalArgumentException("파일에 대해서는 처리하지 않습니다.");
        }
        else {
            throw new IllegalArgumentException("존재하지 않는 디렉토리입니다");
        }
    }
    
    
    private record AdditionalOptions(String filteringFilePrefix, String filteringFileExt) {
	    public AdditionalOptions {
		    if (!StringUtils.hasText(filteringFilePrefix)) {
			    filteringFilePrefix = "";
		    }
		    if (!StringUtils.hasText(filteringFileExt)) {
			    filteringFileExt = "";
		    }
	    }
        
        public Set<String> getFileExtSet() {
            // comma word to Java Set Instance
            Set<String> strings = StringUtils.commaDelimitedListToSet(filteringFileExt);
            
            // 잘못된 확장자 사용을 방지하기 위한 재파싱 과정
            return strings.stream()
                .map(String::trim)  // 일단 양쪽에 있는 공백 지우고~
                .filter(s -> !StringUtils.containsWhitespace(s)) // 글자 사이에 공백이 있으면, ignore!
                .collect(Collectors.toSet());
        }
    }
    
    
    private static void checkRequiredArguments(Path src, Path dest) {
        Objects.requireNonNull(src, "Source Path required!");
        Objects.requireNonNull(dest, "destination Path required!");
    }
}

테스트

Source Directory


Destination Directory

  • prefix: copy_
  • 확장자 : "txt,csv"

... 인 것만 복사됐네요! 성공입니다!

profile
백엔드를 계속 배우고 있는 개발자입니다 😊

0개의 댓글