최종 실습: 확장 가능한 작업 큐와 리소스 관리 도구
24강. 최종 실습: 확장 가능한 작업 큐와 리소스 관리 도구
이번 강의에서 해결할 문제
개별 문법 예제를 넘어 객체 수명, 소유권, STL, 저장 실패와 테스트가 한 구조에서 함께 동작해야 합니다. 완성 코드를 한 번에 복사하면 왜 unique_ptr가 필요한지, 저장 실패 뒤 상태가 어떻게 유지되는지 확인하기 어렵습니다. 작은 수직 조각부터 책임을 분리해 확장합니다.
학습 목표
먼저 알아야 할 핵심 개념
의존성 역전은 서비스가 구체 파일 저장 클래스가 아니라 ITaskStorage 계약에 의존하게 합니다. commit은 모든 검증과 저장 준비가 성공한 뒤 상태를 확정하는 단계입니다. 관찰자는 서비스 상태를 소유하지 않고 알림만 받으며, 알림 수명이 실제로 공유될 때만 shared_ptr와 weak_ptr를 제한적으로 사용합니다.
왜 이 문제가 위험한가
TaskService가 파일 스트림, 콘솔 출력, 검색 인덱스와 상태 전환을 모두 처리하면 테스트가 파일 시스템과 출력 순서에 묶입니다. raw pointer로 저장소를 소유하면 삭제 책임이 흐리고, 콜백을 shared_ptr로 서로 소유하면 순환 참조가 생길 수 있습니다. 저장 전에 메모리 상태를 바꾸면 실패 후 되돌리기도 어렵습니다.
가장 작은 예제로 시작하기
전체 저장 구조를 만들기 전에 값 타입의 한 상태 전환부터 검증합니다.
#include <cassert>
#include <string>
enum class TaskStatus { Pending, Completed };
struct Task {
int id{};
std::string name;
TaskStatus status{TaskStatus::Pending};
bool complete() noexcept {
if (status == TaskStatus::Completed) return false;
status = TaskStatus::Completed;
return true;
}
};
int main() {
Task task{1, "build"};
assert(task.complete());
assert(task.status == TaskStatus::Completed);
assert(!task.complete());
}
첫 호출만 상태를 바꾸고 두 번째 호출은 실패해야 합니다. 이 최소 규칙이 통과한 뒤 저장 후보, 인덱스와 Observer를 하나씩 추가합니다.
요구사항
Task: ID, 이름, 우선순위, 상태를 가진 값 타입TaskService: 등록, 조회, 완료 전환과 저장 조정ITaskStorage: 저장 방식 추상화FileLogger: 파일 로그 자원을 RAII로 관리vector: 작업의 순서 있는 저장unordered_map: ID에서 vector 위치로 빠른 조회unique_ptr: TaskService가 저장소 구현을 단독 소유shared_ptr·weak_ptr: 선택적인 상태 구독자 수명에만 제한적으로 사용- 오류 결과: 중복 ID, 잘못된 우선순위, 이미 완료, 저장 실패 구분
- 테스트: 정상 등록·완료, 잘못된 전환, 저장 실패, 구독자 만료 등 최소 3개 이상
나쁜 초기 설계
struct TaskManager {
std::vector<Task*> tasks;
std::ofstream log;
void complete(int id) {
for (Task* task : tasks) {
if (task->id == id) task->status = 2;
}
saveEverything();
std::cout << "done\n";
}
};
이 구조는 누가 Task*를 삭제하는지, 숫자 2가 어떤 상태인지, 저장 실패 때 완료 상태를 되돌리는지 알 수 없습니다. 파일·콘솔·도메인 책임이 한 클래스에 있고 테스트는 실제 파일과 출력에 의존합니다.
문제 분석
| 문제 | 실제 증상 | 개선 방향 |
|---|---|---|
| raw Task 소유 | 누수·이중 해제·댕글링 | Task를 값으로 vector에 저장 |
| 정수 상태 | 잘못된 값 7도 대입 가능 | enum class TaskStatus |
| 상태 변경 후 저장 | 저장 실패인데 메모리는 완료 | 후보 상태 저장 성공 후 commit |
| 구체 파일 의존 | 단위 테스트가 느리고 취약 | ITaskStorage 주입 |
| 출력과 규칙 혼합 | 오류 정책 재사용 불가 | 결과 타입과 UI 분리 |
책임과 소유권 표
| 객체 | 책임 | 소유하는 것 | 비소유 관계 |
|---|---|---|---|
Task | 값과 유효한 상태 전환 | 문자열·상태 값 | 없음 |
TaskService | 규칙 조정·조회·저장 commit | unique_ptr<ITaskStorage>, Task 값 목록 | weak 구독자 |
ITaskStorage | 저장 계약 | 없음 | 전달받은 Task 읽기 |
FileTaskStorage | 파일 쓰기 | 경로 값, 작업 중 스트림 | 없음 |
FileLogger | 로그 파일 수명·기록 | ofstream | 없음 |
ITaskObserver | 변경 알림 소비 | UI가 shared 소유할 수 있음 | 서비스는 weak 관찰 |
TaskService와 저장소는 공동 소유가 아니므로 unique_ptr가 맞습니다. Observer는 UI 등 외부 수명과 알림 중 사용 수명이 겹칠 수 있어 외부가 shared_ptr로 소유하고 서비스는 weak_ptr로만 관찰합니다.
파일 구조
task-tool/
├─ CMakeLists.txt
├─ include/task/
│ ├─ Task.h
│ ├─ Result.h
│ ├─ ITaskStorage.h
│ ├─ ITaskObserver.h
│ ├─ TaskService.h
│ └─ FileLogger.h
├─ src/
│ ├─ TaskService.cpp
│ ├─ FileTaskStorage.cpp
│ ├─ FileLogger.cpp
│ └─ main.cpp
└─ tests/task_service_tests.cpp
리팩터링 단계
- raw
Task*목록을vector<Task>값 저장으로 바꿉니다. - 상태를
enum class와Task::complete()규칙 안으로 옮깁니다. - 저장을
ITaskStorage뒤로 숨기고 서비스가unique_ptr로 단독 소유합니다. - 완료할 후보 목록을 복사해 변경하고 저장 성공 뒤 실제 상태로 이동 대입합니다.
- ID 인덱스는 vector 변경 뒤 다시 만들거나 항상 함께 갱신합니다.
- 로그는
FileLogger로 분리하고 도메인 결과를 UI가 문장으로 바꿉니다. - 알림이 필요할 때만 weak observer를 추가하고 만료 항목을 정리합니다.
구현
1. Task와 오류 결과
#pragma once
#include <stdexcept>
#include <string>
#include <utility>
enum class TaskStatus { Pending, Completed };
class Task {
public:
Task(int id, std::string name, int priority)
: id_{id}, name_{std::move(name)}, priority_{priority} {
if (id_ <= 0 || name_.empty() || priority_ < 0 || priority_ > 5)
throw std::invalid_argument{"invalid task"};
}
int id() const noexcept { return id_; }
const std::string& name() const noexcept { return name_; }
int priority() const noexcept { return priority_; }
TaskStatus status() const noexcept { return status_; }
bool complete() noexcept {
if (status_ == TaskStatus::Completed) return false;
status_ = TaskStatus::Completed;
return true;
}
private:
int id_{};
std::string name_;
int priority_{};
TaskStatus status_{TaskStatus::Pending};
};
enum class ServiceError { None, DuplicateId, NotFound, AlreadyCompleted, StorageFailure };
struct ServiceResult {
bool ok{};
ServiceError error{ServiceError::None};
std::string detail;
};
Task는 표준 값 멤버만 가져 Rule of Zero를 따릅니다. 상태는 외부에서 직접 대입하지 않고 complete()로만 전환합니다.
2. 저장소와 관찰자 계약
#pragma once
#include <string>
#include <vector>
#include "Task.h"
class ITaskStorage {
public:
virtual ~ITaskStorage() = default;
virtual ServiceResult save(const std::vector<Task>& tasks) = 0;
};
class ITaskObserver {
public:
virtual ~ITaskObserver() = default;
virtual void onTaskChanged(const Task& task) = 0;
};
인터페이스 소멸자는 가상입니다. 저장 함수는 상태를 읽기만 하므로 const 참조를 받고, 실패 이유를 결과로 반환합니다.
3. TaskService
#pragma once
#include <algorithm>
#include <memory>
#include <optional>
#include <unordered_map>
#include <vector>
#include "ITaskStorage.h"
class TaskService {
public:
explicit TaskService(std::unique_ptr<ITaskStorage> storage)
: storage_{std::move(storage)} {
if (!storage_) throw std::invalid_argument{"storage required"};
}
ServiceResult add(Task task) {
if (index_.count(task.id()) != 0)
return {false, ServiceError::DuplicateId, "duplicate id"};
std::vector<Task> candidate = tasks_;
candidate.push_back(std::move(task));
if (auto saved = storage_->save(candidate); !saved.ok) return saved;
tasks_ = std::move(candidate);
rebuildIndex();
notify(tasks_.back());
return {true, ServiceError::None, {}};
}
ServiceResult complete(int id) {
const auto found = index_.find(id);
if (found == index_.end())
return {false, ServiceError::NotFound, "task not found"};
std::vector<Task> candidate = tasks_;
Task& changed = candidate[found->second];
if (!changed.complete())
return {false, ServiceError::AlreadyCompleted, "already completed"};
if (auto saved = storage_->save(candidate); !saved.ok) return saved;
tasks_ = std::move(candidate);
notify(tasks_[found->second]);
return {true, ServiceError::None, {}};
}
std::optional<Task> find(int id) const {
const auto found = index_.find(id);
if (found == index_.end()) return std::nullopt;
return tasks_[found->second];
}
std::vector<Task> orderedByPriority() const {
std::vector<Task> result = tasks_;
std::sort(result.begin(), result.end(), [](const Task& a, const Task& b) {
return a.priority() > b.priority();
});
return result;
}
void subscribe(const std::shared_ptr<ITaskObserver>& observer) {
observers_.push_back(observer);
}
private:
void rebuildIndex() {
index_.clear();
for (std::size_t i = 0; i < tasks_.size(); ++i) index_[tasks_[i].id()] = i;
}
void notify(const Task& task) {
observers_.erase(std::remove_if(observers_.begin(), observers_.end(),
[&](std::weak_ptr<ITaskObserver>& observer) {
if (auto alive = observer.lock()) { alive->onTaskChanged(task); return false; }
return true;
}), observers_.end());
}
std::unique_ptr<ITaskStorage> storage_;
std::vector<Task> tasks_;
std::unordered_map<int, std::size_t> index_;
std::vector<std::weak_ptr<ITaskObserver>> observers_;
};
candidate를 저장한 뒤에만 tasks_로 commit하므로 저장 실패에서는 기존 메모리 상태가 유지됩니다. 단순함을 위해 전체 vector를 복사했으며, 실제 데이터가 커질 때만 트랜잭션 저장이나 변경 명령 구조를 측정해 도입합니다.
4. 파일 저장소와 FileLogger
#pragma once
#include <fstream>
#include <string>
#include <utility>
#include "ITaskStorage.h"
class FileTaskStorage final : public ITaskStorage {
public:
explicit FileTaskStorage(std::string path) : path_{std::move(path)} {}
ServiceResult save(const std::vector<Task>& tasks) override {
std::ofstream stream{path_, std::ios::trunc};
if (!stream) return {false, ServiceError::StorageFailure, "open failed"};
for (const Task& task : tasks) {
stream << task.id() << '\t' << task.priority() << '\t'
<< static_cast<int>(task.status()) << '\t' << task.name() << '\n';
}
if (!stream) return {false, ServiceError::StorageFailure, "write failed"};
return {true, ServiceError::None, {}};
}
private:
std::string path_;
};
class FileLogger {
public:
explicit FileLogger(const std::string& path) : stream_{path, std::ios::app} {
if (!stream_) throw std::runtime_error{"cannot open log"};
}
FileLogger(const FileLogger&) = delete;
FileLogger& operator=(const FileLogger&) = delete;
void write(const std::string& message) {
stream_ << message << '\n';
if (!stream_) throw std::runtime_error{"cannot write log"};
}
private:
std::ofstream stream_;
};
간단한 교육용 형식이므로 탭·줄바꿈 이스케이프와 원자적 파일 교체는 확장 과제로 남깁니다. 운영 도구에서는 임시 파일에 쓴 뒤 rename하고 버전·체크섬·파싱 오류를 처리해야 합니다.
코드 한 줄씩 이해하기
- Task는 값 타입이라 vector 복사 후보를 만들 수 있습니다.
- TaskService가
unique_ptr<ITaskStorage>를 받아 구체 저장 방식을 모릅니다. unordered_map은 ID를 vector 위치로 연결하고 vector commit 뒤 다시 생성합니다.- 저장 실패는 candidate를 버리고 기존
tasks_를 보존합니다. - 관찰자는 외부가 shared 소유하고 서비스는 weak로 보관해 수명을 늘리지 않습니다.
- FileLogger의 ofstream이 RAII로 파일을 닫으며 복사는 금지됩니다.
실행 결과와 메모리·동작 흐름
작업 등록은 입력 검증 → 중복 확인 → candidate 생성 → 저장 → 메모리 commit → 인덱스 갱신 → 알림 순서입니다. 완료도 candidate에서 전환한 뒤 저장 성공 시만 commit합니다. 실패 경로에서는 서비스가 가진 vector와 인덱스가 바뀌지 않습니다.
테스트
테스트에서는 실제 파일 대신 성공·실패를 제어할 수 있는 저장소를 주입합니다.
#include <cassert>
#include <memory>
#include <vector>
#include "TaskService.h"
class FakeStorage final : public ITaskStorage {
public:
bool fail{};
int saveCount{};
ServiceResult save(const std::vector<Task>&) override {
++saveCount;
if (fail) return {false, ServiceError::StorageFailure, "forced failure"};
return {true, ServiceError::None, {}};
}
};
void testAddAndFind() {
auto storage = std::make_unique<FakeStorage>();
TaskService service{std::move(storage)};
assert(service.add(Task{1, "build", 3}).ok);
assert(service.find(1)->name() == "build");
}
void testDuplicateIdRejected() {
TaskService service{std::make_unique<FakeStorage>()};
assert(service.add(Task{1, "build", 3}).ok);
const auto duplicate = service.add(Task{1, "test", 2});
assert(!duplicate.ok && duplicate.error == ServiceError::DuplicateId);
}
void testStorageFailureDoesNotCommitCompletion() {
auto storage = std::make_unique<FakeStorage>();
FakeStorage* control = storage.get(); // service보다 짧게 쓰는 비소유 테스트 관찰자
TaskService service{std::move(storage)};
assert(service.add(Task{1, "build", 3}).ok);
control->fail = true;
const auto result = service.complete(1);
assert(!result.ok && result.error == ServiceError::StorageFailure);
assert(service.find(1)->status() == TaskStatus::Pending);
}
void testSecondCompletionRejected() {
TaskService service{std::make_unique<FakeStorage>()};
assert(service.add(Task{1, "build", 3}).ok);
assert(service.complete(1).ok);
assert(service.complete(1).error == ServiceError::AlreadyCompleted);
}
int main() {
testAddAndFind();
testDuplicateIdRejected();
testStorageFailureDoesNotCommitCompletion();
testSecondCompletionRejected();
}
테스트용 raw pointer control은 소유권이 없고 TaskService보다 먼저 사용을 끝냅니다. 테스트가 종료된 뒤 저장소에 접근하지 않습니다. 더 긴 수명 제어가 필요하면 FakeStorage 상태를 별도 shared 상태 값으로 분리할 수 있습니다.
단계별 실습
흔한 실수와 해결 방법
언제 사용하고 언제 피할까
인터페이스 분리는 저장 방식 교체와 테스트 대역이 실제 요구일 때 사용합니다. 구현이 하나뿐이고 교체 가능성도 없는 작은 스크립트까지 무조건 추상화하지 않습니다. shared_ptr는 공동 소유가 도메인에 존재할 때만 사용합니다.
게임 개발 연결
표준 C++ TaskService는 Unreal 프로젝트의 일반 비-UObject 모듈에도 원칙을 적용할 수 있습니다. UObject 수명은 GC 규칙을 따르므로 std::shared_ptr<UObject>로 바꾸지 않습니다. 작업 데이터를 TArray·TMap으로 옮길 때는 엔진 컨테이너의 API·무효화 규칙을 다시 확인합니다. 비동기 저장 뒤 UObject/UI 반영은 게임 스레드 요구를 지키고, Delegate 구독은 객체 수명과 등록 해제 정책을 명시합니다.
이해 점검 질문 3개
- 저장 실패 전에 candidate를 사용하면 기존 메모리 상태를 어떻게 보존할 수 있나요?
- ITaskStorage를 unique_ptr로 소유하고 Observer를 weak_ptr로 관찰하는 이유는 무엇인가요?
- vector와 unordered_map 인덱스를 함께 사용할 때 commit 뒤 인덱스를 갱신해야 하는 이유는 무엇인가요?
핵심 요약
다음 강의 연결
표준 C++의 수명과 소유권 기반을 완성했습니다. 엔진 수명 모델은 Unreal Engine C++ 과정에서 Reflection·UObject GC·엔진 컨테이너 규칙으로 확장하고, 자료 처리 선택은 자료구조 · 알고리즘에서 더 깊게 학습하세요.
프로젝트 구조의 책임·의존성을 더 확장하려면 객체지향 설계 · 디자인 패턴으로 이어갈 수 있습니다.
최종 실습: 확장 가능한 작업 큐와 리소스 관리 도구 미니 퀴즈
선택 즉시 정답과 해설을 확인할 수 있습니다. 결과는 이 브라우저에만 저장됩니다.
학습을 마쳤나요?
직접 실습과 점검 질문까지 확인한 뒤 완료로 표시하세요.