19 파일 입출력 라이브러리

김민영·2023년 1월 22일
0

C# 기초 프로그래밍

목록 보기
13/18

지금까지 우리는 Sokoban 맵을 그려내기 위해 코드 속에서 좌표와 string을 하나하나 직접 지정했습니다. 이 방식은 좌표를 하나하나 지정하기 번거롭고, 코드로 작성한 맵을 출력하여 확인하기 전에는 완성된 맵의 모양을 확인하기 어렵다는 단점이 있었는데요. 여러 버전(스테이지 등)의 맵을 별도의 파일에 그려둔 후, 그 파일을 읽어와 프로그램 내에서 처리할 수는 없을까요? 그 방법에 대해 알아봅시다.

🎞️ 파일

1. 파일이란

: 정보를 저장하기 위한 단위로, 프로그램 또한 파일의 일종입니다. 운영체제가 파일 시스템을 통해 관리합니다.

▶ 텍스트 파일

1) 사람이 읽을 수 있는 기호로 구성된 파일
2) 텍스트 에디터를 이용해 저장된 정보(데이터)를 확인할 수 있음
3) 예시

  • html 파일
    : 웹페이지는 html 파일들로 구성되어 있으며 웹브라우저가 해당 파일들을 읽어 그려냅니다.

▶ 바이너리 파일

1) 텍스트 파일 외의 파일들
2) 예시

  • 이미지 파일
  • 오디오 파일

2. 경로

1) 파일의 위치 정보
2) 운영체제의 파일 시스템을 통해 특정 파일에 접근하기 위해 경로를 알아야 함

▶ 절대 경로 (Absolute Path)

1) 파일의 절대적인 위치 정보
2) 예시

  • C:/Project/Something.txt

▶ 상대 경로 (Relative Path)

1) 특정 파일을 기준으로 표현한 위치 정보
2) 예시

  • ../Project/Something.txt
  • ../ 는 상위 폴더를 의미

→ 프로그래밍에선 일반적으로 상대 경로를 많이 이용합니다. 그 이유는 프로그램이 실행될 모든 컴퓨터의 디렉토리 구성이 같을 수 없기 때문입니다. 상대 경로를 이용할 경우 개발 환경과 다른 상황에서도 알맞은 경로를 찾아갈 수 있으므로 절대 경로에 비해 유연한 방식이라고 할 수 있습니다.

🎞️ Path Class

Windows는 경로에 \가 포함되어있어 코드 작성시 이스케이프 시퀀스 처리가 힘들 수 있습니다. Path Class의 Path.Join과 같은 기능을 이용하면 이와같은 처리를 보다 쉽게 할 수 있으며, 복잡한 경로일수록 Path Class를 사용하 처리하는 것이 좋습니다.

1. 텍스트 파일 생성 및 쓰기

: 아래 예제들은 모두 현재 프로젝트 파일에 파일을 생성했기에 비주얼스튜디오의 솔루션 탐색기에서 솔루션 우클릭 > 파일 탐색기에서 열기 > bin > Debug (디버그 모드로 실행한 경우) > net6.0 (사용 버전) 에서 확인할 수 있습니다. 이는 프로그램이 생성된 경로와 같습니다.

▶ WriteAllText()

1) 텍스트 파일 작성을 위해 사용할 수 있는 메소드

  • 문자열 인자를 지정한 경로에 파일을 생성해 입력합니다.
  • 입력할 데이터의 내용 중 개행이 필요하다면, 프로그래머가 직접 문자열에 개행문자를 넣어야 합니다.
  • 개행 방식은 운영체제에 따라 다를 수 있습니다.

2) File.WriteAllText("파일 경로", "입력할 데이터");
3) 사용 예시

File.WriteAllText("Sample", "Hello World!\nThis is sample.");
  • 프로그램의 현재 위치에 Sample이라는 이름을 가지는 텍스트 파일을 생성
  • Hello World\nThis is sample 을 파일에 입력
  • 입력이란 파일에게 데이터를 준다는 의미로, 이 외에도 어딘가에 데이터를 전달하는 행위를 입력이라고 합니다.

▶ WriteAllLines()

1) 텍스트 파일 작성을 위해 사용할 수 있는 메소드

  • 문자열 배열을 인자로 받아 지정한 경로에 파일을 생성해 입력합니다.
  • 문자열 배열의 원소 하나를 입력한 후 개행합니다. 따라서 원소1의 다음 원소는 원소1의 다음 줄에 입력됩니다.
  • 파일을 확인하는 운영체제에 맞게 알맞은 개행 방식을 실행합니다.

2) File.WriteAllLines("파일 경로", 입력할 데이터(배열));
3) 사용 예시

string[] contents = 
{
    "Hello World!",
    "This is sample2."
};

File.WriteAllLines("Sample2", contents);

▶ AppendAllText()

1) 텍스트 파일에 내용을 추가할 수 있는 메소드
2) File.AppendAllText("파일 경로", "추가할 데이터");
3) 사용 예시

File.AppendAllText("Sample.txt", "\nThis is new sentence");
  • 해당 경로의 Sample.txt 파일에 작성된 마지막 데이터 직후에 \nThis is new sentence를 추가합니다.

▶ AppendAllLines()

1) 텍스트 파일에 내용을 추가할 수 있는 메소드
2) File.AppendAllLines("파일 경로", 추가할 데이터(배열));
3) 사용 예시

string[] contents2 =
{
   "This is new sentence"
};

File.AppendAllLines("Sample2.txt", contents2);

▶ Write와 Append 비교

1) 이미 존재하는 파일의 경로로 WriteAllText()와 WriteAllLines()를 실행하는 경우 새로운 입력 데이터로 덮어쓰기 합니다.
2) 존재하지 않는 파일 경로로 AppendAllText(), AppendAllLines()를 실행하는 경우 파일을 새로 생성하여 내용을 추가합니다.

2. 파일 읽기

▶ ReadAllText()

1) 텍스트 파일을 읽어올 수 있는 메소드

  • 텍스트 파일의 모든 내용을 하나의 문자열로 반환합니다.
    2) File.ReadAllText("파일 경로");
    3) 사용 예시
string text = File.ReadAllText("Sample.txt");

▶ ReadAllLines()

1) 텍스트 파일을 읽어올 수 있는 메소드

  • 텍스트 파일의 모든 내용을 한 줄씩 끊어 배열로 반환합니다.

▶ 유의사항

1) 경로에 파일이 존재하지 않는 경우 오류가 발생합니다. 리소스(파일)를 다루는 것은 예외가 많다는 것을 유의하고 사용해야 하며, 예외를 적절히 처리해야 합니다.

2) 리소스란 운영체제로부터 빌려오는 자원으로, 사용 후 반환해야 합니다. File 메소드는 파일을 닫는 기능이 구현되어 있지만, StreamWriter 및 StreamReader 방식은 그렇지 않습니다.

using (StreamWriter file = new StreamWriter("Sample.txt"))
     file.WriteLine("Hello World");

위와 같이 using문을 사용한 경우 범위를 벗어나면 파일을 반환합니다. 혹은 Dispose 메소드를 사용할 수 있습니다.

🎞️ 파일을 읽어와 맵 만들기

1. 메모장을 맵툴로 사용하기

1) 프로젝트 폴더에 Assets > Stage 경로 생성
2) 메모장을 이용해 맵을 작성 (인코딩 방식에 유의해야 함)

  • 첫줄의 숫자는 이후 코드를 작성하며 이유를 설명하는데, 차례로 벽의 수 / 박스의 수 / 골의 수 입니다.

2. 스테이지 파일 불러오기 (LoadStage 함수)

▶ Game 클래스에 LoadStage 함수 작성

public static string[] LoadStage(int stageNumber)
{
    // 파일을 불러온다 -> 한줄로 끊어오는 방식이 좌표 구성에 편할 것
    // 1. 경로를 구성한다.
    string stageFilePath = Path.Combine("Assets", "Stage", $"Stage{stageNumber:D2}.txt");  // Assets > Stage > Stage01.txt 같은 파일 불러옴
    Console.WriteLine(stageFilePath);
    // D2 : 포맷팅 방법 두자리를 채우는데 빈 공간은 0으로

    // Debug.Asset(false); 혹은 Debug.Assert(false, "여기가 고장남"); 과 같이 함께 출력할 메세지를 입력할 수도 있음
   // 따라서 잘못된 데이터를 허용한다면 조건문을 이용할 수 있고, 아예 허용하지 않는 경우 Asset를 이용
   
   // 2. 파일이 존재하는지 확인
   if (!File.Exists(stageFilePath))
   {
       ExitWithError($"스테이지 파일이 없습니다. 스테이지 번호({stageNumber})");
       Environment.Exit(-1);
       // 이건 유저한테도 보이는 메세지
       // 단정문은 디버그 용도라는 관점
    }

   // 3. 파일의 내용을 불러온다.
   return File.ReadAllLines(stageFilePath);

  // Tip > stageNumber도 1 2 와같은 단순 숫자 말고 열거형으로 구성해서 Stage의 특성을 나타내는 걸로 바꿔도 됨
}

▶ Assert(단정문)

1) 안전하게 프로그래밍을 하기 위한 기법으로, 조건문이 참인지 검사하여 시스템이 의도대로 구축되었는지 검사하는 용도로 사용합니다.

2) 추후 알고리즘을 이용해 함수를 만들 때, 사전 조건과 사후 조건을 확인하며 보통 이 조건들을 확인하기 위하여 단정문을 사용합니다.

  • 사전 조건 (Pre-Condition): 알고리즘 실행 전 반드시 충족되어야 할 조건
  • 사후 조건 (Post-Condition): 알고리즘 실행 후 반드시 충족되어야 할 조건
  • 파일이 존재하는 것은 현재 작성하려는 파일을 불러오는 함수의 사전 조건

3) if문과의 차이

  • if문은 조건문이 false일 때에 해당하는 동작을 실행합니다. 즉, 무조건 프로그램이 종료되진 않습니다.
  • Assert를 사용할 경우 조건문이 false일 때 프로그램을 종료하고 에러 메세지를 출력합니다. 이 메세지를 통해 프로그래머는 버그를 해결할 수 있습니다.
  • 따라서 잘못된 데이터를 허용하는 경우 조건문을 이용할 수 있고, 아예 허용하지 않는 경우에는 Assert를 이용할 수 있습니다.

▶ Program 클래스에서 LoadStage 호출 후 내용을 출력

string[] lines = Game.LoadStage(1);

// 불러온 파일을 한 줄씩 출력
for(int i = 0; i < lines.Length; ++i)
{
	Console.WriteLine(lines[i]);
}

3. 스테이지 파일 파싱(Parsing)하여 초기 데이터 구성 (ParseStage 함수)

▶ Game 클래스에서 ParseStage 함수 만들기

public static void ParseStage(string[] stage, out Player player, out Box[] boxes, out Wall[] walls, out Goal[] goals)
{
	// 이 함수는 이미 파일을 읽어온 후(LoadStage 후) 실행하기 때문에 반드시 stage가 null이 아니어야 함
    // stage == null인 걸 허용할 수 없는 상태이므로 Asset를 사용해야 함 (if말고)
    Debug.Assert(stage != null);
    
    player = null;
    
    // 1. 파일의 첫 줄에서 메타데이터를 파싱 (벽의 수 / 박스의 수 / 골의 수)
    string[] stageMetadata = stage[0].Split(" ");
    walls = new Wall[int.Parse(stageMetadata[0])];
    boxes = new Box[int.Parse(stageMetadata[1])];
    goals = new Goal[int.Parse(stageMetadata[2])];
    
    int wallIndex = 0;
    int boxIndex = 0;
    int goalIndex = 0;
    
    for(int y = 1; y < stage.Length; ++y)
    {
    	for(int x = 0; x < stage[y].Length; ++x)
        {
        	switch (stage[y][x])
            {
            	// y - 1을 y좌표로 사용하는 이유는 텍스트파일의 첫 줄은 게임의 메타데이터를 저장하는 데 이용했기 때문
            	case ObjectSymbol.Player:
                	player = new Player { X = x, Y = y - 1 };
                    break;
                    
                case ObjectSymbol.Wall:
                	walls[wallIndex] = new Wall { X = x, Y = y - 1 };
                    ++wallIndex;
                    break;
                    
                case ObjectSymbol.Box:
                	boxes[boxIndex] = new Box { X = x, Y = y - 1 };
                    ++boxIndex;
                    break;
                    
                case ObjectSymbol.Goal:
                	goals[goalIndex] = new Goal { X = x, Y = y - 1 };
                    ++goalIndex;
                    break;
                    
                case ' ':  // 맵의 공백인 부분은 스페이스로 입력했기에 읽어온 데이터가 공백 한 칸일 때 아무것도 처리하지 않는다는 구문을 넣어줌
                	break;
                    
                default:
                	// 데이터는 항상 조작될 수 있기에 검증을 거치는 코드가 필요함
                    // 'P', '#', 'B', 'G', ' '을 제외한 문자가 포함된 경우 잘못된 맵임을 default로 처리
                	ExitWithError("스테이지 파일이 잘못되었습니다.");
                    break;
}

▶ Program 클래스에서 ParseStage 호출하여 맵에 포함된 정보들을 반환

Player player;
Box[] boxes;
Wall[] walls;
Goal[] goals;
int pushedBox = 0;
Game.ParseStage(lines, out player, out boxes, out walls, out goals);

4. 게임 진행 구현

5. 게임이 종료되었다면 다음 스테이지 불러오는 기능 구현

오늘 배운 방법은 맵 외에도 프로그램을 구성하는 다양한 데이터를 파일로 저장하고, 그 파일을 다루기 위해 활용할 수 있습니다.

+) 파일을 더욱 효율적이고 쉽게 관리할 수 있도록 데이터를 적절히 분리하여 저장할 필요가 있습니다.

0개의 댓글