개선은 진작 했지만, 관련해서 포스팅하지 않은 이유는 "티스토리 계정을 까먹어서..."
자동 로그인 해둔 것들은 포맷 후에 다시 로그인해야 하니 여간 귀찮은 일이 아니다.
어찌어찌 로그인해서 쓴다.
이전과의 차이점
- 자격증명 체크 개선
- 프로세스 생명주기 관리 개선
- 진행상황 트래킹 개선
- 이터레이션 처리 개선
- 일부 클래스 개선
1. 자격증명 체크 개선
포맷으로 인해 기존 암호회 데이터를 사용하지 못하게 됐으니, 새로운 데이터를 만들 필요가 있다.
그래서 난 "어차피 실행해서 로그인이 불가능하면 새로운 자격증명을 입력하면 된다"라는 생각으로 실행했다.
하지만 이전 버전에서는 각 스레드마다 로그인 체크를 하기 때문에 모든 스레드가 동시에 새로운 자격증명 입력 요구를 하게 되는 상황이 발생했다.
몇 번이고 그걸 입력할 수는 없기에 프로그램 실행 시에 이를 검증하는 식으로 개선했다.
Logger.Info("Check credential...");
Console.WriteLine("Check credential...");
using (Encryption en = new())
{
{
en.LoadAndValidateCredentials(out string id, out string pw);
}
using (WebDriverService wd = new WebDriverService())
{
IWebDriver driver = null;
if (!wd.SetupAndLogin(out driver))
{
Logger.Info("Failed to login.");
Console.WriteLine("Failed to login. Enter new credential.");
en.EnterCredentials();
}
else
{
Logger.Info("Login passed.");
Console.WriteLine("Login passed.");
}
}
Thread.Sleep(3000);
}
Logger.Info("Initializing...");
가장 먼저 LoadAndValidateCredentials()
로 데이터의 유무를 판단한다.
데이터가 없거나 정상적으로 불러올 수 없다면 자격증명 입력을 받을 것이다.
원래 다른 곳에 사용되는 함수를 여기서도 사용하는 것이라, out
한정자가 그대로 드러나 있다.
일단 메모리에 남아있는 시간을 최대한 줄이기 위해 별도 블록에 넣어두었다.
Main()
에서 검증용으로 사용하는 함수를 별도로 만들어 사용하는 것이 좋아 보인다.
이후 드라이버를 만들어 실제로 로그인이 되는지 테스트한다.
로그인이 되지 않으면 자격증명을 다시 입력받는다.
문제점은 로그인 실패 후 다시 입력받을 때, 무조건 정확한 자격증명 정보를 입력하는 것을 전제한다는 것이다.
만약 로그인에 실패하더라도 해당 구간을 통과해 메인 프로세싱 코드로 넘어가게 된다.
이는 반복문에 잡아둬서 로그인 성공 시에만 넘어가도록 개선해야 할 것이다.
2. 프로세스 생명주기 관리 개선
셀레니움을 통해 만들어지는 크롬 관련 프로세스들은 클리커 프로세스 아래에서 실행되지 않는다.
따라서 부득이 메인 프로세스를 종료해야 하는 상황이 있을 때 제대로 이들이 종료되지 않고 남아있을 수 있다.
실제로 X를 눌러 프로세스에 종료 시그널을 보냈을 때 크롬 관련 백그라운드 프로세스들은 종료되지 않고 남아있는 것을 확인할 수 있었다.
정상적인 흐름대로 진행되더라도 확실한 정리를 위해 보강했다고 보는 것이 옳겠다.
public class WebDriverService : IDisposable
{
private ChromeDriver _driver;
private ChromeDriverService _service;
private bool disposedValue;
public WebDriverService()
{
AppDomain.CurrentDomain.ProcessExit += (sender, e) => CleanUp();
Console.CancelKeyPress += (sender, e) =>
{
CleanUp();
e.Cancel = true; // Prevent the process from terminating immediately
};
}
// ...
}
먼저 WebDriverService
클래스가 IDisposable
인터페이스를 상속받게 했다.
셀레니움 자체는 IDisposable
을 상속받고 구현되었지만, WebDriverService
를 통해 셀레니움을 사용하는 만큼 명시적으로 이들을 정리해 줄 필요가 있었다.
위의 생성자엔 프로그램 종료 시그널이나 컨트롤+C 같은 취소 시그널이 들어오면 리소스들을 정리하도록 했다.
그리고 WebDriverService
는 더 이상 정적 클래스가 아니다.
각 스레드에서 각자의 드라이버들을 정리해야 하기 때문에 스레드 별로 인스턴스를 두었다.
public void CleanUp()
{
_driver?.Quit();
_driver?.Dispose();
_service?.Dispose();
}
그리고 정리는 CleanUp()
을 통해서 이루어진다.
3. 진행상황 트래킹 개선
눈에 보이는 부분이기 때문에 확실히 신경이 쓰이는 부분.
트래커는 어느 스레드에서나 같은 인스턴스에 접근할 수 있어야 하기 때문에 정적 클래스로 만들었다.
그리고 갱신하는 방법도 바꾸었다.
public class ProgressTracker
{
private static readonly Lazy<ProgressTracker> _instance = new Lazy<ProgressTracker>(() => new ProgressTracker());
public static ProgressTracker Instance => _instance.Value;
private readonly ConcurrentDictionary<string, ProgressInfo> _progressTracker;
private readonly AppSettings _settings;
private ProgressTracker()
{
_progressTracker = new ConcurrentDictionary<string, ProgressInfo>();
_settings = SettingsManager.LoadSettings();
}
// ...
}
여기선 Lazy<T>
를 통해 비동기로 싱글톤 인스턴스를 만들 수 있게 했다.
_progressTracker
는 여러 스레드에 의해 사용될 것이기 때문에 ConcurrenctDictionary<T>
로 선언하였다.
이번에 개선하면서 달라진 점은, 직접적으로 값을 수정하지 않는다는 점이다.
스레드는 이러이러한 값이 증가할 것이라는 신호를 트래커에 주고, 트래커는 이를 처리하여 내부에서 수에 변동을 주는 것이다.
아래의 UpdateProgress()
를 보자.
public void UpdateProgress(string url, bool incrementIteration = false, bool incrementError = false, int threadCountChange = 0)
{
_progressTracker.AddOrUpdate(url,
(key) => new ProgressInfo { Status = ProgressStatus.Waiting, Iteration = 0, ErrorCount = 0, ThreadCount = 0 },
(key, oldValue) =>
{
if (incrementIteration) oldValue.Iteration++;
if (incrementError) oldValue.ErrorCount++;
oldValue.ThreadCount += threadCountChange;
oldValue.ThreadCount = Math.Max(oldValue.ThreadCount, 0);
if (incrementError) oldValue.Status = ProgressStatus.Error;
else if (oldValue.Iteration == _settings.MaxIter) oldValue.Status = ProgressStatus.Finished;
else if (oldValue.ThreadCount > 0) oldValue.Status = ProgressStatus.Running;
else if (oldValue.Iteration > 0) oldValue.Status = ProgressStatus.Suspended;
else oldValue.Status = ProgressStatus.Waiting;
return oldValue;
});
}
변수로 어떤 URL에 대한 변동인지 알기 위해 url
을 받고, 이터레이션 횟수가 늘었는지, 에러가 있었는지, 스레드가 새로이 URL을 처리하러 왔는지 또는 다 처리하고 빠져나갔는지 판단하기 위한 정보를 받는다.
받은 url
은 Key
이기 때문에 Value
들을 찾아서 업데이트하는 식으로 진행된다.
여러 스레드가 이 함수를 호출할 것이기 때문에 ConcurrenctDictionary<T>
로 선언한 것이다.
프리팅은 PrintProgress()
로 이루어진다.
public void PrintProgress()
{
int urlMaxLength = _progressTracker.Keys.Max(url => url.Length) + 2;
int statusMaxLength = Enum.GetNames(typeof(ProgressStatus)).Max(name => name.Length) + 2;
int iterationMaxLength = 10; // "Iteration".Length + 여유 공간
int errorCountMaxLength = 7; // "ErrCnt".Length + 여유 공간
int threadCountMaxLength = 8; // "ThrdCnt".Length + 여유 공간
// ...
}
이전처럼 GUI가 따로 없기 때문에 프롬프트 상에 출력한다.
구분감을 주기 위해 각 열의 최대 길이를 정해둔다.
// 커서 위치 저장 및 초기 설정
Console.Clear();
bool isInitialPrint = true;
int cursorTop = Console.CursorTop;
Console.WriteLine("### Ad Clicker V3\n");
이전엔 무식하게 Console.Clear()를 사용해 싹 다 지우고 새로 쓰는 방식을 택했다.
이는 깜빡임 문제도 있고 불필요한 부하가 생기는 것과 같기 때문에 갱신이 필요한 부분만 갱신할 수 있도록 커서의 위치를 활용한다.
while (true)
{
if (isInitialPrint)
{
Console.WriteLine($"{"URL".PadRight(urlMaxLength)} {"Status".PadRight(statusMaxLength)} {"Iteration".PadRight(iterationMaxLength)} {"ErrCnt".PadRight(errorCountMaxLength)} {"ThrdCnt".PadRight(threadCountMaxLength)}");
Console.WriteLine(new string('-', urlMaxLength + statusMaxLength + iterationMaxLength + errorCountMaxLength + threadCountMaxLength));
isInitialPrint = false;
}
int currentLine = cursorTop + 4; // 헤더와 구분선 아래부터 시작
foreach (var entry in _progressTracker)
{
Console.SetCursorPosition(0, currentLine++);
string url = entry.Key.PadRight(urlMaxLength);
var info = entry.Value;
string status = info.Status.ToString().PadRight(statusMaxLength);
string iteration = $"{info.Iteration}/{_settings.MaxIter}".PadRight(iterationMaxLength);
string errorCount = info.ErrorCount.ToString().PadRight(errorCountMaxLength);
string threadCount = info.ThreadCount.ToString().PadRight(threadCountMaxLength);
Console.ForegroundColor = GetColorForStatus(info.Status);
Console.WriteLine($"{url} {status} {iteration} {errorCount} {threadCount}");
Console.ResetColor();
}
Thread.Sleep(1000); // 1초 대기
}
첫 프린트일 경우에만 헤더들을 출력하고, 그다음부턴 필요한 부분만 커서를 옮겨가며 갱신한다.
깜빡거림이 없으니 참 좋더라.
반복문 안에서 헤더와 같은 지속적 갱신이 필요 없는 부분은 피하고, 필요한 부분만 1초마다 갱신하게 된다.
4. 이터레이션 처리 개선
사실 여긴 크게 건드릴 생각은 없었는데, 원하는 걸 구현하려다 보니 많이 바뀌게 됐다.
기존엔 Job
리스트를 통째로 가져와서 그걸 처리하는 식이었다.
깊은 복사로 가져오게 되니 리스트에 뭘 추가하든 의미가 없었다.
따라서 Job
리스트를 가져오는 대신 하나씩 Dequeue 하여
처리할 수 있게 하려고 했다.
먼저 처리할 Job이라는 구조를 정의하자.
public class Job
{
public string Url { get; private set; }
public int Iteration { get; private set; }
public Job(string url, int itCnt)
{
Url = url;
Iteration = itCnt;
}
}
어떤 URL을 처리할지, 그걸 몇 회 반복할지에 대한 정보만을 가진다.
그리고 Job
을 관리할 JobManager
클래스를 선언한다.
public class JobManager
{
private ConcurrentQueue<Job> _jobPool;
private readonly AppSettings _setting = SettingsManager.LoadSettings();
private ProgressTracker _progressTracker;
public JobManager()
{
_progressTracker = ProgressTracker.Instance;
_jobPool = new();
GenerateJobs(GetUrlsToProcess());
Logger.Info("JobManger initialized.");
}
private void GenerateJobs(List<string> urls)
{
int totalIterations = _setting.MaxIter;
int iterationsPerSet = _setting.MaxSet;
int totalSets = (totalIterations + iterationsPerSet - 1) / iterationsPerSet;
foreach (var url in urls)
{
int remainingIterations = totalIterations;
for (int currentSet = 0; currentSet < totalSets; currentSet++)
{
int itCnt = Math.Min(remainingIterations, iterationsPerSet);
_jobPool.Enqueue(new Job(url, itCnt));
remainingIterations -= itCnt;
}
_progressTracker.UpdateProgress(url);
}
Logger.Info("Jobs are generated.");
}
public ConcurrentQueue<Job> GetJobs()
{
return _jobPool;
}
public void AppendJob(string url, int remain)
{
_jobPool.Enqueue(new Job(url, remain));
}
private List<string> GetUrlsToProcess()
{
return new List<string>
{
// ...
};
}
}
생성자가 호출되면 Job
들을 생성해 _jobPool
에 저장한다.
Job
을 생성하는 것은 Set
을 기준으로 잘라서 만든다.
만약 토탈 35회의 반복 횟수를 가지고 세트가 10이라 가정하자.
그럼 GenerateJobs()
는 [10, 10, 10, 5]의 4개의 Job으로 나누어 저장한다.
GetJobs()
는 _jobPool
을 리턴한다.
하지만 한 번만 호출되는 것이 아니라 매번 태스크를 생성할 때마다 호출된다.
_jobManager.GetJobs().TryDequeue(out Job job)
위와 같은 형태로 사용하게 되는데, 여기도 개선의 여지가 있다.
풀을 통쨰로 던져주고 거기서 Dequeue 하는
것보단 Job
하나를 던져주는 것이 메모리 효율성에 있어서도 더 좋을 것이다.
AppendJob()
은 외부에서 추가 Job
을 생성했을 때 이를 풀에 추가하기 위한 것이다.
에러 등으로 인해 마저 처리하지 못한 반복을 나중에 처리하려면 새로운 Job
을 풀에 추가해야 하기 때문이다.
Url 프로세싱으로 가자.
public async Task StartProcessing()
{
StartProgressDisplay();
await ProcessUrlsAsync();
}
private void StartProgressDisplay() => Task.Run(() => _progressTracker.PrintProgress());
Main()
에서 StartProcessing()
을 호출하면 트래킹 현황을 출력하는 태스크를 시작하고,
ProcessUrlsAsync()
를 호출해 실제 이터레이션을 시작하게 된다.
private async Task ProcessUrlsAsync()
{
Stopwatch stopwatch = Stopwatch.StartNew();
using (var maxDegreeOfParallelism = new SemaphoreSlim(_appSettings.MaxWorker))
{
List<Task> tasks = new List<Task>();
while (!_jobManager.GetJobs().IsEmpty || tasks.Any(t => !t.IsCompleted))
{
if (_jobManager.GetJobs().TryDequeue(out Job job))
{
await maxDegreeOfParallelism.WaitAsync();
tasks.Add(Task.Run(() =>
{
try
{
ProcessSingleUrlAsync(job);
}
catch (Exception ex)
{
Logger.Error($"ProcessUrlsAsync Error: {ex}");
}
finally
{
maxDegreeOfParallelism.Release();
}
}));
}
else
{
// Remove completed tasks
tasks.RemoveAll(t => t.IsCompleted);
await Task.Delay(100);
}
}
await Task.WhenAll(tasks);
}
PrintCompletionLog(stopwatch);
}
- 실제 작업에 소요된 시간을 측정하기 위해
Stopwatch
를 선언한다. SemaphoreSlim
을 사용해 최대 동시 작업 수를 제한한다.- 풀이 비거나 모든 태스크가 완료될 때까지 반복한다.
- 루프 안으로 들어와 모든
Job
을 처리할 때 까지 루프. - 모든 작업이 끝나면 결과를 출력하고 마무리한다.
좀 더 안으로 들어가자.
private void ProcessSingleUrlAsync(Job job)
{
_progressTracker.UpdateProgress(job.Url, false, false, 1);
using (var driverService = new WebDriverService())
{
if (driverService.SetupAndLogin(out IWebDriver driver))
{
using (driver)
{
try
{
ProcessJob(driver, job);
}
catch (Exception ex)
{
Logger.Error($"ProcessSingleUrlAsync Error: {ex}");
}
finally
{
_progressTracker.UpdateProgress(job.Url, false, false, -1);
}
}
}
else
{
_progressTracker.UpdateProgress(job.Url, false, false, -1);
Logger.Error("Driver setup failed.");
}
}
Logger.Debug($"WebDriverService Disposed | {job.Url} | {job.Iteration}");
}
UpdateProgress()
를 통해 트래킹을 관리한다.- 드라이버 서비스 준비
- 로그인한다.
- 문제가 있다면 해당 URL에 대한 처리 스레드에서 빠지고 로그를 남김. - 제대로 드라이버가
Dispose
되는지 확인하기 위해 디버그 로그도 찍는다.
여기서 ProcessJob()
이 비동기로 처리되지 않는 이유는 다음과 같다.
"셀레니움은 동기식으로 동작하기 때문"이다.
브라우저 제어를 위해선 어떤 요소가 로드되는 걸 기다려야 하기는 등의 대기 시간이 필요하다.
이는 모든 요소가 정상적으로 로드되어야 정상적인 브라우저 제어가 가능하기 때문이다.
만약에 이런 과정들을 비동기로 처리하게 되면 순서가 보장되지 않아 결코 원하는 결과를 얻을 수 없을 것이다.
따라서 실제 브라우저 제어 코드가 들어가게 되는 ProcessJob()
아래는 모두 동기식으로 동작하게 된다.
private void ProcessJob(IWebDriver driver, Job job)
{
string originalWindow = driver.CurrentWindowHandle;
driver.Navigate().GoToUrl(job.Url);
for (int iteration = 0; iteration < job.Iteration; iteration++)
{
if (!TryProcessIteration(driver, job.Url, originalWindow))
{
int remaining = job.Iteration - iteration - 1;
_jobManager.AppendJob(job.Url, remaining);
Logger.Info($"Remaining Job appended. {job.Url} | Remaining: {remaining}");
return;
}
}
}
private bool TryProcessIteration(IWebDriver driver, string url, string originalWindow)
{
try
{
FindAndClick(driver, url);
Thread.Sleep(_appSettings.IterationInterval); // IterationInterval
CloseTabs(driver, originalWindow);
_progressTracker.UpdateProgress(url, true);
driver.Navigate().Refresh();
return true;
}
catch (Exception ex)
{
Logger.Error($"Iteration Error: {ex}");
_progressTracker.UpdateProgress(url, false, true);
return false;
}
}
실제 처리는 이렇게 이루어진다.
해당 Job
을 받아서 그 URL에 액세스 후 정해진 처리를 반복한다.
이렇게 한 사이클이 구성되는 것이다.
중간에 Sleep()
을 넣은 것은 해당 URL에 단기간에 너무 많은 부하가 걸리게 하지 않기 위해 취한 조치이다.
디도스로 몰린다거나 하는 것은 정말 사양하고 싶다.
5. 일부 클래스 개선
뭐 큰 걸 한 것처럼 해놨지만 별거 없다.
private static byte[] EncryptData(string data)
{
try
{
// Encrypts the data
byte[] encryptedData = ProtectedData.Protect(
Encoding.Unicode.GetBytes(data),
null,
DataProtectionScope.LocalMachine);
return encryptedData;
}
catch (Exception ex)
{
// Logs any exception that occurs during the encryption process
Logger.Error($"EncryptData error: {ex.Message}");
throw;
}
}
위 멤버 함수는 해당 클래스 인스턴스에 있는 데이터에 일절 접근하지 않는다.
따라서 정적으로 만들었다.
이는 많은 C# 코딩 가이드에 포함되어 있는 지침이기도 하다.
이것뿐만 아니라 전체적으로 가이드를 따라 수정할 수 있는 점들을 수정했다.
6. 비동기는 확실히 어렵다
처음부터 이렇게 방향이 잡혀서 뚝딱 만든 것은 아니다.
C#에서 사용 가능한 TPL과 같은 비동기 기법을 적용해 보는 등 여러 가지 시도를 해봤는데,
결국 이렇게 마무리가 된 것이다.
그리고 지금 여기서 취한 처리 방식은 어찌 보면 바람직하지 못하다고 볼 수 있다.
정말 웹 스크래핑과 다를 게 없는... 동작을 보여주고 있다.
따라서 이보다 웹 서버에 부하를 덜 주면서 더 효율적으로 처리하는 방법을 앞으로도 고민해 봐야 할 것이다.
'Study > C++ & C#' 카테고리의 다른 글
[C++] Session 다중 접속 (0) | 2024.04.05 |
---|---|
[C++] Boost.Asio 에코 서버 (0) | 2024.03.29 |
[C#] 심플한 게임 런처 (0) | 2024.03.06 |
[C#] ###Clicker (0) | 2024.02.29 |
[C#] 자동화 플러그인 수정 (0) | 2023.10.17 |