[Unity C#] FileStream과 XOR 연산으로 파일 암호화,복호화 구현하기
처음으로 유니티 에디터에서 C# 실습을 하게 되었다. 이번에는 FileStream을 배우면서 에디터에서 파일 입출력이 어떻게 동작하는지 직접 확인해 보았다.
게임에서는 세이브 파일, 설정 파일, JSON/XML, 바이너리 데이터 등을 디스크에 읽고 쓰는 작업이 필요하다. 이런 작업에 사용하는 C# 입출력 클래스의 개념을 직접 실습하면서 와닿게 하려는 선생님의 의도였던 것 같다.
오늘은 FileStream을 사용해 간단한 파일 암호화와 복호화를 구현했다. 이번 글에서는 문제를 해결하면서 어떤 방식으로 접근했고, 왜 그렇게 코드를 작성했는지 정리해 보려고 한다.
1. 파일 스트림(FileStream)의 기본 개념
- 스트림(Stream): 데이터가 흐르는 통로이다. 메모리와 외부 저장매체 사이에서 데이터를 주고받을 때 사용한다.
System.IO.FileStream: 파일에 데이터를 바이트(Byte) 단위로 읽고 쓰기 위한 클래스이다.
File.WriteAllText()처럼 문자열을 한 번에 저장하는 방식과 달리, FileStream을 사용하면 파일에 저장된 데이터를 바이트 단위로 읽고 처리할 수 있다.
실습문제 중에서는 원본 파일을 한 바이트씩 읽고 XOR 연산을 적용한 다음 새로운 파일에 저장하는 방식으로 암호화와 복호화를 구현했다.
2. 실습 문제

FileStream에서 현재 데이터를 읽거나 쓰는 위치를 나타내는 Position 속성과 바이트 단위의 데이터 처리를 활용하여, 텍스트 파일을 간단히 암호화하고 복호화하는 스크립트를 작성하는 문제였다.
요구사항
File.WriteAllText()로original.txt에"Hello Unity World"를 저장한다.FileStream으로 원본 파일을 한 바이트씩 읽어0xAB와 XOR 연산한 뒤encrypted.dat에 저장한다.- 암호화한 파일에 동일한 XOR 연산을 적용해
decrypted.txt에 저장한다. - 원본 파일과 복호화 결과를 비교하여 정상적으로 복원되었는지 확인한다.
문제를 읽고 먼저 파일을 생성하는 과정, 암호화하는 과정, 복호화하는 과정, 결과를 확인하는 과정을 각각 나누어 구현하기로 했다.
3. Unity에서 실행 흐름 구성하기
이번 실습은 유니티 에디터에서 게임 오브젝트에 C# 스크립트를 부착하여 실행하는 방식이었다.
Start()와 Update()를 사용하여 실행 흐름을 구성했다.
private void Start()
{
Requirements1();
}
private void Update()
{
if(Input.GetKeyDown(KeyCode.Alpha1))
{
Requirements2();
}
if(Input.GetKeyDown(KeyCode.Alpha2))
{
Requirements3();
}
if(Input.GetKeyDown(KeyCode.Alpha3))
{
Requirements4();
}
}
원본 파일은 암호화를 수행하기 전에 반드시 존재해야 하므로 게임이 시작될 때 Start()에서 생성하도록 했다.
이후 암호화, 복호화, 결과 출력은 각각 숫자 키를 입력했을 때 실행되도록 했다.
- 1번 키: 원본 파일 암호화
- 2번 키: 암호화된 파일 복호화
- 3번 키: 원본 파일과 복호화 결과 확인
이렇게 구성하면 각 단계가 정상적으로 동작하는지 순서대로 확인할 수 있다.
4. 원본 파일 생성
private void Requirements1()
{
//요구사항1
Debug.Log($"persistentDataPath: {Application.persistentDataPath}");
folder = Path.Combine(Application.persistentDataPath, "Sercurity");
if (!Directory.Exists(folder))
{
Directory.CreateDirectory(folder);
}
path = Path.Combine(folder, "original.txt");
File.WriteAllText(path, "Hello Unity World");
}
파일을 생성하기 위해 먼저 저장할 경로가 필요했다. 유니티에서는 Application.persistentDataPath를 통해 애플리케이션의 데이터를 저장할 수 있는 경로를 가져올 수 있다.
이 경로 아래에 별도의 폴더를 생성하기 위해 Path.Combine()을 사용했다.
folder = Path.Combine(Application.persistentDataPath, "Sercurity");
이후 Directory.Exists()를 통해 폴더의 존재 여부를 확인하고, 존재하지 않을 경우 Directory.CreateDirectory()를 사용해 생성하도록 했다.
마지막으로 original.txt의 경로를 만들고 File.WriteAllText()로 문자열을 저장했다.
path = Path.Combine(folder, "original.txt");
File.WriteAllText(path, "Hello Unity World");
원본 파일의 경로는 이후 암호화 과정에서도 사용하므로 path라는 필드에 저장했다.
5. FileStream으로 암호화 구현하기
이번 문제에서 가장 중요한 부분은 파일을 바이트 단위로 읽어 XOR 연산을 적용하는 과정이었다.
암호화 과정을 생각했을 때 다음과 같은 순서로 처리하면 된다고 생각했다.
원본 파일 읽기 → XOR 연산 → 암호화된 파일에 쓰기
원본 파일에서 데이터를 읽는 작업과 암호화 결과를 저장하는 작업이 동시에 필요하므로 두 개의 FileStream을 사용했다.
private void Requirements2()
{
//요구사항2 암호화
encryptionPath = Path.Combine(folder, "encrypted.dat");
byte key = 0xAB;
using (FileStream fs = new FileStream(path, FileMode.Open))
{
using (FileStream encryptionFile = new FileStream(encryptionPath, FileMode.Create))
{
int a;
while ((a = fs.ReadByte()) != -1)
{
encryptionFile.WriteByte((byte)(a ^ key));
}
}
}
}
5.1. FileMode.Open과 FileMode.Create
using (FileStream fs = new FileStream(path, FileMode.Open))
{
using (FileStream encryptionFile = new FileStream(encryptionPath, FileMode.Create))
{
// 암호화 수행
}
}
먼저 FileMode.Open은 이미 존재하는 파일을 열 때 사용한다. 여기서는 원본 파일인 original.txt를 읽어야 하므로 사용했다.
반면 암호화 결과는 새로운 파일에 저장해야 하므로 FileMode.Create를 사용했다. 이 모드는 파일이 존재하지 않으면 새로 생성하고, 이미 존재하면 기존 내용을 덮어쓴다.
또한 using을 사용하여 파일 작업이 끝난 후 FileStream이 자동으로 정리되도록 했다.
5.2. ReadByte()와 while문
int a;
while ((a = fs.ReadByte()) != -1)
{
encryptionFile.WriteByte((byte)(a ^ key));
}
이 반복문은 파일을 한 바이트씩 읽어 암호화하는 역할을 한다.
ReadByte()는 파일에서 한 바이트를 읽어 정수 형태로 반환하며 더 이상 읽을 데이터가 없으면 -1을 반환한다.
따라서 다음 두 작업을 while문의 조건식에서 동시에 수행했다.
fs.ReadByte()로 읽은 값을a에 저장한다.a가-1인지 확인하여 반복문을 종료할지 결정한다.
여기서 변수를 byte가 아닌 int로 선언한 이유는 ReadByte()의 반환형이 int이기 때문이다. 특히 파일의 끝을 나타내는 -1을 확인할 수 있어야 한다.
또한 ReadByte()가 실행될 때마다 스트림의 현재 위치인 Position이 다음 바이트로 이동하므로 별도로 위치를 증가시키지 않아도 파일의 데이터를 순서대로 읽을 수 있다.
5.3. XOR 연산과 WriteByte()
encryptionFile.WriteByte((byte)(a ^ key));
^는 C#에서 비트 단위 XOR 연산을 수행하는 연산자다.
XOR은 두 비트가 서로 다르면 1, 같으면 0을 반환한다.
| A | B | A ^ B |
|---|---|---|
| 0 | 0 | 0 |
| 0 | 1 | 1 |
| 1 | 0 | 1 |
| 1 | 1 | 0 |
이번 문제 예시가 0xAB 있길래 암호화 키로 사용했다.
ReadByte()로 읽은 데이터와 키를 XOR 연산한 다음, 결과를 (byte)로 형변환하여 WriteByte()로 저장했다.
결과적으로 반복문이 한 번 실행될 때마다 원본 파일에서 바이트 하나를 읽고 암호화된 바이트 하나를 저장하는 구조가 되었다.
6. XOR 연산을 이용한 복호화
암호화된 파일을 다시 원본으로 복원하려면 어떻게 해야 할까?
XOR 연산에는 동일한 키로 두 번 연산하면 원래 데이터로 돌아온다는 성질이 있다.
원본 데이터: A
암호화: A ^ key
복호화: (A ^ key) ^ key = A
따라서 복호화를 위해 별도의 연산을 구현할 필요 없이, 암호화된 파일에 동일한 키를 사용하여 XOR 연산을 다시 적용하면 된다.
이 성질을 이용해 암호화 코드와 거의 동일한 구조로 복호화를 구현했다.
private void Requirements3()
{
//요구사항3 복호화
encryptionPath = Path.Combine(folder, "encrypted.dat");
decryptionPath = Path.Combine(folder, "decrypted.txt");
byte key = 0xAB;
using (FileStream fs = new FileStream(encryptionPath, FileMode.Open))
{
using (FileStream decryptionFile = new FileStream(decryptionPath, FileMode.Create))
{
int a;
while ((a = fs.ReadByte()) != -1)
{
decryptionFile.WriteByte((byte)(a ^ key));
}
}
}
}
암호화 과정과 달라진 점은 읽어오는 파일과 저장할 파일이다.
암호화에서는 original.txt를 읽고 encrypted.dat에 저장했다면, 복호화에서는 encrypted.dat를 읽고 decrypted.txt에 저장한다.
반복문 내부의 XOR 연산은 암호화 과정과 동일하다.
7. 암호화 및 복호화 결과 확인
마지막으로 원본 파일과 복호화된 파일의 내용이 일치하는지 확인했다.
private void Requirements4()
{
//요구사항 4 출력
using (FileStream fs = new FileStream(encryptionPath, FileMode.Open))
{
Debug.Log($"persistentDataPath: {Application.persistentDataPath}");
Debug.Log($"원본: {File.ReadAllText(path)}");
Debug.Log($"암호화 완료 (파일 크기: {fs.Length} bytes)");
}
Debug.Log("복호화 완료");
Debug.Log($"복호화 결과: {File.ReadAllText(decryptionPath)}");
Debug.Log($"원본과 일치: {File.ReadAllText(path) == File.ReadAllText(decryptionPath)}");
}
먼저 File.ReadAllText()를 사용하여 원본 파일의 문자열을 출력했다.
암호화된 파일은 FileStream.Length를 이용해 파일 크기를 확인했다.
원본 문자열인 Hello Unity World는 영문자와 공백으로 구성된 17바이트이므로 한 바이트씩 XOR 연산하여 저장한 암호화 파일 역시 17바이트가 된다.
복호화 결과도 File.ReadAllText()로 읽은 다음 == 관계 연산자를 사용하여 원본 문자열과 비교했다.
정상적으로 암호화와 복호화가 이루어졌고 결과는 다음과 같이 출력된다.

현재 코드에서는 암호화와 복호화 작업을 각각 1번과 2번 키로 실행하지만 출력은 3번 키를 눌러 결과를 확인할 때 출력하도록 작성했다.
전체 코드 (Practice2.cs)
using UnityEngine;
using System.IO;
using System.Collections.Generic;
public class Practice2 : MonoBehaviour
{
private string folder;
private string path;
private string encryptionPath;
private string decryptionPath;
private Dictionary<string, string> settings = new Dictionary<string, string>();
private void Start()
{
Requirements1();
}
private void Update()
{
if(Input.GetKeyDown(KeyCode.Alpha1))
{
Requirements2();
}
if(Input.GetKeyDown(KeyCode.Alpha2))
{
Requirements3();
}
if(Input.GetKeyDown(KeyCode.Alpha3))
{
Requirements4();
}
}
private void Requirements1()
{
//요구사항1
Debug.Log($"persistentDataPath: {Application.persistentDataPath}");
folder = Path.Combine(Application.persistentDataPath, "Sercurity");
if (!Directory.Exists(folder))
{
Directory.CreateDirectory(folder);
}
path = Path.Combine(folder, "original.txt");
File.WriteAllText(path, "Hello Unity World");
}
private void Requirements2()
{
//요구사항2 암호화
encryptionPath = Path.Combine(folder, "encrypted.dat");
byte key = 0xAB;
using (FileStream fs = new FileStream(path, FileMode.Open))
{
using (FileStream encryptionFile = new FileStream(encryptionPath, FileMode.Create))
{
int a;
while ((a = fs.ReadByte()) != -1)
{
encryptionFile.WriteByte((byte)(a ^ key));
}
}
}
}
private void Requirements3()
{
//요구사항3 복호화
encryptionPath = Path.Combine(folder, "encrypted.dat");
decryptionPath = Path.Combine(folder, "decrypted.txt");
byte key = 0xAB;
using (FileStream fs = new FileStream(encryptionPath, FileMode.Open))
{
using (FileStream decryptionFile = new FileStream(decryptionPath, FileMode.Create))
{
int a;
while ((a = fs.ReadByte()) != -1)
{
decryptionFile.WriteByte((byte)(a ^ key));
}
}
}
}
private void Requirements4()
{
//요구사항 4 출력
using (FileStream fs = new FileStream(encryptionPath, FileMode.Open))
{
Debug.Log($"persistentDataPath: {Application.persistentDataPath}");
Debug.Log($"원본: {File.ReadAllText(path)}");
Debug.Log($"암호화 완료 (파일 크기: {fs.Length} bytes)");
}
Debug.Log("복호화 완료");
Debug.Log($"복호화 결과: {File.ReadAllText(decryptionPath)}");
Debug.Log($"원본과 일치: {File.ReadAllText(path) == File.ReadAllText(decryptionPath)}");
}
}
느낀점
사실 파일 스트림도 어려운 내용이였지만 오늘 아침 점심 시간에 진행했던 UI실습이 너무너무 어려웠다. 선생님 말씀으로는 신입 게임 개발자들 UI 구현부터 시킨다고 하셨는데 만약 신입 개발자가 된다면 사수 개발자? 선배님들 속 터져 죽는거 아닐까 싶다. 지금 UI 못하는게 스스로 게임 못하는 것처럼 화가 난다.
하이어라키 구조 열심히 짜고 UI 요소들 위치도 정성껏 다 잡아놨는데… 뒤늦게 "아 이거 빼먹었네?" 하고 중간 오브젝트에 뒤늦게 Vertical/Horizontal Layout Group이나 Content Size Fitter를 툭 던져 넣으면 재밌는 일이 생긴다. 왜 미리 미리 생각하지 못했을까 생각은 오래하면서... 게임에서도 손이 기억하는 것처럼 계속 숙달해야할 것 같다.



