이번 글에서는 서버 측에서 요청과 응답 데이터를 안전하게 처리하기 위한 패킷 암호화/복호화 기능을 구현해보겠습니다.
테스트를 위해 Unity 기반의 간단한 클라이언트를 함께 제작하였으며,
암호화된 데이터 송수신이 정상적으로 이루어지는지를 검증합니다.
이 클라이언트는 추후 구글 로그인에서도 사용될 예정입니다.
[패킷 암호화/복호화가 필요한 이유]
- 평문 데이터는 노출 위험이 높음
- 프록시 툴을 활요한 트래픽 가로채기를 통해 위조, 변조가 발생할 수 있음
- 비신뢰 클라이언트와의 통신환경에서 데이터 위조, 변조에 대한 방지가 필요함
이러한 이유로 HTTPS와 별개로 패킷 암호화를 통해 보안성을 한층 강화하는 전략이 필요합니다.
[미들웨어 생성]
● 암호화 / 복호화를 미들웨어로 생성하면 생기는 장점
- 컨트롤러나 라우팅처리와 분리하여, 보안 로직과 비즈니스 로직의 관심사를 명확히 구분함
- 모든 요청과 응답에 대해 전역적으로 처리할 수 있어, 코드 중복 없이 효율적인 적용이 가능
- 라우터별로 암호화, 복호화 코드를 반복하지 않고 한 곳에서 공통적관리 가능
1. 파일 생성

[Middleware] 디렉토리에 PacketEncryptionMiddleware.cs 파일을 생성합니다.
2. 암호화 키 생성
서버와 클라이언트만 공유하는 비밀 키를 생성합니다.
이 키는 네트워크를 통해 전송되지 않으며, 별도로 안전하게 관리하여야 합니다.

PACKET_SECRET=SecretKey
.env 파일에 PACKET_SECRET 키를 추가합니다.
3. 미들웨어 초기화

public PacketEncryptionMiddleware(RequestDelegate next, IConfiguration config)
{
_next = next;
_secret = config["PACKET_SECRET"] ?? throw new ArgumentNullException("PACKET_SECRET 설정이 필요합니다.");
}
PacketEncryptionMiddleware 클래스에서 RequestDelegate 와 _secret값을 주입받아 초기화 합니다.
RequestDelegate : 미들웨어는 다음 미들웨어로 제어를 넘겨야 하는데, 그 역할을 해줌
[암호화]
이번 프로젝트에서는 매 요청마다 무작위 Salt를 생성하여,
고정된 키 기반의 AES 암호화를 수행하는 구조로 구현합니다.
암호화로직은 일반적으로 다음과 같이 구성됩니다.


[복호화]
암호화와 같은 방식으로 복호화를 진행하였습니다.

[Request, Response를 암호화/복호화]
이번에는 요청(Request) 본문과 응답(Response) 본문을 암호화/복호화 처리하는 로직을 구현해 보겠습니다.

1. RequestBody 복호화
if(context.Request.Method == HttpMethods.Post && context.Request.ContentLength > 0)
{
using (var reader = new StreamReader(context.Request.Body))
{
//Body를 복호화
var body = await reader.ReadToEndAsync();
var decryptedBody = Decrypt(body);
//복호화된 Body로 교체
var byteArray = Encoding.UTF8.GetBytes(decryptedBody);
context.Request.Body = new MemoryStream(byteArray);
}
}
먼저 POST 요청에 대해 본문(Body)을 낚아채 복호화 합니다.
복호화된 내용을 새로운 메모리 스트림에 담아 context.Request.Body를 교체합니다.
이후 컨트롤러에서는 이미 복호화된 데이터를 그대로 사용하면 됩니다.
2. Response Body 암호화
//기존 응답 스트림을 저장
var originalBodyStream = context.Response.Body;
//새로운 메모리 스트림으로 응답 바디 교체
using var newBodyStream = new MemoryStream();
context.Response.Body = newBodyStream;
//컨트롤러 응답 기다림
await _next(context);
//스트림 포인터를 초기위치로 이동
context.Response.Body.Seek(0, SeekOrigin.Begin);
컨트롤러에서 반환하는 응답도 가로채기 위해, 응답 스트림을 메모리 스트림으로 교체합니다.
기존 스트림을 사용하면 기존 내용이 쌓여있을 수 있으므로, 별도의 스트림으로 작업하는 것이 안정적입니다.
3. 암호화
//컨트롤러가 만든 응답을 꺼내서 읽음
var plainResponseBody = await new StreamReader(context.Response.Body).ReadToEndAsync();
//응답 암호화
var encryptedResponseBody = Encrypt(plainResponseBody);
var responseByteArray = Encoding.UTF8.GetBytes(encryptedResponseBody);
컨트롤러에서 생성된 응답 본문을 읽은 후 다시 암호화합니다.
4. 응답 스트림 복원
//원래 응답 스트림으로 복원
context.Response.Body = originalBodyStream;
await context.Response.Body.WriteAsync(responseByteArray, 0, responseByteArray.Length);
모든 작업이 끝난 후, 처음 저장해두었던 응답 스트림을 복원합니다.
[암호화/복호화 실행 환경 설정]
실행 환경에 관계없이 매번 암호화/복호화를 적용하게 되면
Postman이나 Swagger등으로 API를 테스트하는것이 힘들어집니다.
이러한 불편을 줄이기 위해
개발 환경에서는 로직을 생략하고
운영 환경에서만 실제로 동작하도록 구성합니다.

launchSettings.json 파일 또는 환경변수에서
ASPNETCORE_ENVIRONMENT 의 값을 설정하실 수 있습니다.
● 테스트 개발환경의 경우
"ASPNETCORE_ENVIRONMENT": "Development"
● 배포 환경의 경우
"ASPNETCORE_ENVIRONMENT": "Production"
[암호화/복호화 실행]

if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
else
{
app.UseMiddleware<PacketEncryptionMiddleware>();
}
Program.cs 에 Development가 아닐 경우 MiddleWare를 실행시키는 코드를 추가합니다.
+ MapOpenApi는 Swagger 가 데이터를 가져올 수 있도록 돕는 함수입니다.
[테스트]
테스트용 임시 Unity 클라이언트를 만들었습니다.
https://github.com/Minoritygames2/UnityApiTester/releases/tag/HttpRequese
암호화/복호화 방식은 서버와 동일한 구조로 구현되어있습니다.
● 테스트 환경

개발 환경에서는 암호화 없이도 요청과 응답이 정상처리됩니다.
● 배포환경
1. 암호화 하지 않을 경우


서버 에러가 납니다.
2. 올바른 키로 암호화 한 경우

정상 응답을 받을 수 있습니다.
3. 올바르지 않은 키로 암호화한 경우

에러가 나는것을 보실 수 있습니다.
이하 코드 전문
-. PacketEncryptionMiddleware.cs
using System.Security.Cryptography;
using System.Text;
namespace PPProject.Middleware
{
public class PacketEncryptionMiddleware
{
private readonly RequestDelegate _next;
private const int SaltSize = 16;
private const int KeySize = 32;
private const int IvSize = 16;
private const int Iterations = 100000;
private readonly string _secret;
public PacketEncryptionMiddleware(RequestDelegate next, IConfiguration config)
{
_next = next;
_secret = config["PACKET_SECRET"] ?? throw new ArgumentNullException("PACKET_SECRET 설정이 필요합니다.");
}
public async Task InvokeAsync(HttpContext context)
{
if(context.Request.Method == HttpMethods.Post && context.Request.ContentLength > 0)
{
using (var reader = new StreamReader(context.Request.Body))
{
//Body를 복호화
var body = await reader.ReadToEndAsync();
var decryptedBody = Decrypt(body);
//복호화된 Body로 교체
var byteArray = Encoding.UTF8.GetBytes(decryptedBody);
context.Request.Body = new MemoryStream(byteArray);
}
}
//기존 응답 스트림을 저장
var originalBodyStream = context.Response.Body;
//새로운 메모리 스트림으로 응답 바디 교체
using var newBodyStream = new MemoryStream();
context.Response.Body = newBodyStream;
//컨트롤러 응답 기다림
await _next(context);
//스트림 포인터를 초기위치로 이동
context.Response.Body.Seek(0, SeekOrigin.Begin);
//컨트롤러가 만든 응답을 꺼내서 읽음
var plainResponseBody = await new StreamReader(context.Response.Body).ReadToEndAsync();
//응답 암호화
var encryptedResponseBody = Encrypt(plainResponseBody);
var responseByteArray = Encoding.UTF8.GetBytes(encryptedResponseBody);
//원래 응답 스트림으로 복원
context.Response.Body = originalBodyStream;
await context.Response.Body.WriteAsync(responseByteArray, 0, responseByteArray.Length);
}
private byte[] DeriveKey(string password, byte[] salt)
{
return Rfc2898DeriveBytes.Pbkdf2(
password: password,
salt: salt,
iterations: Iterations,
hashAlgorithm: HashAlgorithmName.SHA256,
outputLength: KeySize
);
}
private string Encrypt(string plainText)
{
var plainBytes = Encoding.UTF8.GetBytes(plainText);
var salt = RandomNumberGenerator.GetBytes(SaltSize);
var iv = RandomNumberGenerator.GetBytes(IvSize);
var key = DeriveKey(_secret, salt);
//AES 암호화 객체 생성
using Aes aes = Aes.Create();
aes.Key = key;
aes.IV = iv;
aes.Mode = CipherMode.CBC;
aes.Padding = PaddingMode.PKCS7;
//실제 암호화 객체 생성
using var encryptor = aes.CreateEncryptor();
//암호문
var cipherBytes = encryptor.TransformFinalBlock(plainBytes, 0, plainBytes.Length);
var resultByte = new byte[SaltSize + IvSize + cipherBytes.Length];
Buffer.BlockCopy(salt, 0, resultByte, 0, SaltSize);
Buffer.BlockCopy(iv, 0, resultByte, SaltSize, IvSize);
Buffer.BlockCopy(cipherBytes, 0, resultByte, SaltSize + IvSize, cipherBytes.Length);
return Convert.ToBase64String(resultByte);
}
private string Decrypt(string encryptedText)
{
var bytes = Convert.FromBase64String(encryptedText);
var salt = new byte[SaltSize];
var iv = new byte[IvSize];
var cipherBytes = new byte[bytes.Length - SaltSize - IvSize];
Buffer.BlockCopy(bytes, 0, salt, 0, SaltSize);
Buffer.BlockCopy(bytes, SaltSize, iv, 0, IvSize);
Buffer.BlockCopy(bytes, SaltSize + IvSize, cipherBytes, 0, cipherBytes.Length);
//키 생성
var key = DeriveKey(_secret, salt);
using Aes aes = Aes.Create();
aes.Key = key;
aes.IV = iv;
aes.Mode = CipherMode.CBC;
aes.Padding = PaddingMode.PKCS7;
using var decryptor = aes.CreateDecryptor();
var resultBytes = decryptor.TransformFinalBlock(cipherBytes, 0, cipherBytes.Length);
return Encoding.UTF8.GetString(resultBytes);
}
}
}
-. Program.cs
using PPProject.Auth;
using PPProject.Infrastructure;
using PPProject.Middleware;
var builder = WebApplication.CreateBuilder(args);
//.env 파일 로드
DotNetEnv.Env.Load();
builder.Configuration.AddEnvironmentVariables();
builder.Services.AddControllers();
builder.Services.AddOpenApi();
builder.Services.AddMysql(builder.Configuration);
builder.Services.AddSnowflake();
builder.Services.AddAuth();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
else
{
app.UseMiddleware<PacketEncryptionMiddleware>();
}
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();
-. .env
MYSQL_HOST=pp-mysql
MYSQL_PORT=3306
MYSQL_USER=gameuser
MYSQL_PASSWORD=qwer1234!
MYSQL_DB=ppdb
PACKET_SECRET=SecretKey
-. launchSettings.json
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://0.0.0.0:80",
"environmentVariables": {
//"ASPNETCORE_ENVIRONMENT": "Development"
"ASPNETCORE_ENVIRONMENT": "Production"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://0.0.0.0:443;http://0.0.0.0:80",
"environmentVariables": {
//"ASPNETCORE_ENVIRONMENT": "Development"
"ASPNETCORE_ENVIRONMENT": "Production"
}
}
}
}
'게임 웹서버 만들기' 카테고리의 다른 글
| 구글 Windows 로그인 - 게임 웹서버 만들기 9 (0) | 2025.12.12 |
|---|---|
| Redis연결, 세션키 생성 - 게임 웹서버 만들기 8 (0) | 2025.12.12 |
| 게스트 로그인, Dapper, IDGen, 서비스팩토리 - 게임 웹서버 만들기 6 (0) | 2025.12.07 |
| MySql 연결하기 - 게임 웹서버 만들기 5 (0) | 2025.12.07 |
| 패킷 파라미터 유효성 검증 추가 - 게임 웹서버 만들기 4 (0) | 2025.12.03 |