본문으로 건너뛰기
모던 C++ 심화LESSON 24

최종 실습: 확장 가능한 작업 큐와 리소스 관리 도구

난이도중급 → 심화
예상 시간70분
선수지식이전 강의

24강. 최종 실습: 확장 가능한 작업 큐와 리소스 관리 도구

이번 강의에서 해결할 문제​

개별 문법 예제를 넘어 객체 수명, 소유권, STL, 저장 실패와 테스트가 한 구조에서 함께 동작해야 합니다. 완성 코드를 한 번에 복사하면 왜 unique_ptr가 필요한지, 저장 실패 뒤 상태가 어떻게 유지되는지 확인하기 어렵습니다. 작은 수직 조각부터 책임을 분리해 확장합니다.

학습 목표​

먼저 알아야 할 핵심 개념​

의존성 역전은 서비스가 구체 파일 저장 클래스가 아니라 ITaskStorage 계약에 의존하게 합니다. commit은 모든 검증과 저장 준비가 성공한 뒤 상태를 확정하는 단계입니다. 관찰자는 서비스 상태를 소유하지 않고 알림만 받으며, 알림 수명이 실제로 공유될 때만 shared_ptr와 weak_ptr를 제한적으로 사용합니다.

왜 이 문제가 위험한가​

TaskService가 파일 스트림, 콘솔 출력, 검색 인덱스와 상태 전환을 모두 처리하면 테스트가 파일 시스템과 출력 순서에 묶입니다. raw pointer로 저장소를 소유하면 삭제 책임이 흐리고, 콜백을 shared_ptr로 서로 소유하면 순환 참조가 생길 수 있습니다. 저장 전에 메모리 상태를 바꾸면 실패 후 되돌리기도 어렵습니다.

가장 작은 예제로 시작하기​

전체 저장 구조를 만들기 전에 값 타입의 한 상태 전환부터 검증합니다.

task_vertical_slice.cpp
#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개 이상

나쁜 초기 설계​

review_only_bad_design.cpp
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규칙 조정·조회·저장 commitunique_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

리팩터링 단계​

  1. raw Task* 목록을 vector<Task> 값 저장으로 바꿉니다.
  2. 상태를 enum class와 Task::complete() 규칙 안으로 옮깁니다.
  3. 저장을 ITaskStorage 뒤로 숨기고 서비스가 unique_ptr로 단독 소유합니다.
  4. 완료할 후보 목록을 복사해 변경하고 저장 성공 뒤 실제 상태로 이동 대입합니다.
  5. ID 인덱스는 vector 변경 뒤 다시 만들거나 항상 함께 갱신합니다.
  6. 로그는 FileLogger로 분리하고 도메인 결과를 UI가 문장으로 바꿉니다.
  7. 알림이 필요할 때만 weak observer를 추가하고 만료 항목을 정리합니다.

구현​

1. Task와 오류 결과​

Task.h
#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. 저장소와 관찰자 계약​

ITaskStorage.h
#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​

TaskService.h
#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​

FileStorageAndLogger.h
#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하고 버전·체크섬·파싱 오류를 처리해야 합니다.

코드 한 줄씩 이해하기​

  1. Task는 값 타입이라 vector 복사 후보를 만들 수 있습니다.
  2. TaskService가 unique_ptr<ITaskStorage>를 받아 구체 저장 방식을 모릅니다.
  3. unordered_map은 ID를 vector 위치로 연결하고 vector commit 뒤 다시 생성합니다.
  4. 저장 실패는 candidate를 버리고 기존 tasks_를 보존합니다.
  5. 관찰자는 외부가 shared 소유하고 서비스는 weak로 보관해 수명을 늘리지 않습니다.
  6. FileLogger의 ofstream이 RAII로 파일을 닫으며 복사는 금지됩니다.

실행 결과와 메모리·동작 흐름​

작업 등록은 입력 검증 → 중복 확인 → candidate 생성 → 저장 → 메모리 commit → 인덱스 갱신 → 알림 순서입니다. 완료도 candidate에서 전환한 뒤 저장 성공 시만 commit합니다. 실패 경로에서는 서비스가 가진 vector와 인덱스가 바뀌지 않습니다.

테스트​

테스트에서는 실제 파일 대신 성공·실패를 제어할 수 있는 저장소를 주입합니다.

task_service_tests.cpp
#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개​

  1. 저장 실패 전에 candidate를 사용하면 기존 메모리 상태를 어떻게 보존할 수 있나요?
  2. ITaskStorage를 unique_ptr로 소유하고 Observer를 weak_ptr로 관찰하는 이유는 무엇인가요?
  3. vector와 unordered_map 인덱스를 함께 사용할 때 commit 뒤 인덱스를 갱신해야 하는 이유는 무엇인가요?

핵심 요약​

다음 강의 연결​

표준 C++의 수명과 소유권 기반을 완성했습니다. 엔진 수명 모델은 Unreal Engine C++ 과정에서 Reflection·UObject GC·엔진 컨테이너 규칙으로 확장하고, 자료 처리 선택은 자료구조 · 알고리즘에서 더 깊게 학습하세요.

프로젝트 구조의 책임·의존성을 더 확장하려면 객체지향 설계 · 디자인 패턴으로 이어갈 수 있습니다.

MINI QUIZ

최종 실습: 확장 가능한 작업 큐와 리소스 관리 도구 미니 퀴즈

선택 즉시 정답과 해설을 확인할 수 있습니다. 결과는 이 브라우저에만 저장됩니다.

0 / 2
  1. 문제 1“최종 실습: 확장 가능한 작업 큐와 리소스 관리 도구”에서 오류나 데이터 손실을 줄이는 선택은 무엇인가요?
  2. 문제 2“최종 실습: 확장 가능한 작업 큐와 리소스 관리 도구”에서 ‘vector 원소 주소를 인덱스에 저장’ 문제가 생겼습니다. 가장 알맞은 진단 또는 대응은 무엇인가요?
LESSON STATUS

학습을 마쳤나요?

직접 실습과 점검 질문까지 확인한 뒤 완료로 표시하세요.

24강. 최종 실습: 확장 가능한 작업 큐와 리소스 관리 도구 미완료 상태