Menu
Coddy logo textTech

Thread-Safe 구조체 설계

Coddy GO 여정의 객체 지향 프로그래밍 섹션에 포함된 레슨 — 107개 중 65번째.

이제 뮤텍스(mutexes)와 WaitGroup을 이해했으므로, 이를 결합하여 여러 고루틴에서 동시에 안전하게 사용할 수 있는 구조체를 설계해 보겠습니다. 스레드 안전한 구조체(thread-safe struct)는 메서드 내에서 동기화를 캡슐화하므로, 호출자가 잠금(locking)에 대해 걱정할 필요가 없습니다.

패턴은 간단합니다. 구조체에 뮤텍스(mutex)를 포함시키고, 공유 상태에 접근하는 모든 메서드에서 이를 잠그는 것입니다:

type SafeCounter struct {
    mu    sync.Mutex
    count int
}

func (c *SafeCounter) Increment() {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.count++
}

func (c *SafeCounter) Value() int {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.count
}

읽기 전용인 Value() 메서드조차 뮤텍스를 잠근다는 점에 유의하세요. 이것이 없으면, 한 고루틴이 읽는 동안 다른 고루틴이 쓰기를 수행하여 데이터 경합(data race)이 발생할 수 있습니다. 만약 읽기 작업이 쓰기 작업보다 훨씬 더 빈번하다면, 대신 sync.RWMutex를 사용하고 읽기 작업에는 RLock()을 호출하세요.

핵심 설계 원칙: 뮤텍스를 비공개(private)로 유지하세요. 소문자 필드 이름(mu)을 사용하면 외부 코드에서 직접 접근하는 것을 방지할 수 있습니다. 모든 동기화는 메서드를 통해 이루어지므로 스레드 안전성(thread safety)을 완벽하게 제어할 수 있습니다.

여러 필드가 있는 구조체의 경우, 일관된 상태를 보장하기 위해 모든 관련 필드를 동일한 뮤텍스(mutex)로 보호하세요:

type Account struct {
    mu      sync.Mutex
    balance int
    history []string
}

func (a *Account) Deposit(amount int) {
    a.mu.Lock()
    defer a.mu.Unlock()
    a.balance += amount
    a.history = append(a.history, fmt.Sprintf("+%d", amount))
}

balancehistory는 모두 원자적으로 업데이트됩니다. 즉, 어떤 고루틴(goroutine)도 하나는 변경되었지만 다른 하나는 변경되지 않은 일관되지 않은 상태를 관찰할 수 없습니다.

challenge icon

챌린지

쉬움

구조체 메서드 내에서 동기화의 적절한 캡슐화를 보여주는 스레드 안전(thread-safe)한 은행 계좌 시스템을 구축해 보겠습니다. 여러분의 계좌는 호출자에게 잠금(locking) 세부 정보를 노출하지 않고 동시 입금, 출금 및 잔액 확인을 안전하게 처리할 것입니다.

코드는 두 개의 파일로 구성됩니다:

  • account.go: 스레드 안전한 은행 계좌를 정의합니다.

    sync.Mutex가 포함된 BankAccount 구조체, balance 필드(int), 그리고 모든 성공적인 작업을 문자열로 기록하는 transactions 슬라이스를 생성합니다.

    다음 메서드들을 구현하세요:

    • NewBankAccount(initial int) *BankAccount - 주어진 초기 잔액과 빈 트랜잭션 슬라이스를 가진 새 계좌를 생성합니다.
    • Deposit(amount int) - 잔액에 금액을 추가하고 트랜잭션을 +[amount]로 기록합니다.
    • Withdraw(amount int) bool - 잔액이 충분하면 금액을 차감하고, -[amount]를 기록한 뒤 true를 반환합니다. 그렇지 않으면 아무것도 수정하지 않고 false를 반환합니다.
    • Balance() int - 현재 잔액을 반환합니다.
    • History() []string - 트랜잭션 슬라이스의 복사본을 반환합니다.

    구조체의 필드에 접근하는 모든 메서드는 스레드 안전을 보장하기 위해 뮤텍스를 잠가야 합니다. 잠금 해제에는 defer를 사용하세요. 외부 코드에서 메서드를 통해서만 접근할 수 있도록 뮤텍스와 모든 필드는 비공개(소문자)로 유지하세요.

  • main.go: 은행 업무를 처리하고 스레드 안전한 계좌를 시연합니다.

    초기 잔액을 읽은 다음 작업 횟수를 읽습니다. 각 작업에 대해 유형(deposit, withdraw, 또는 balance)을 읽고, 입금/출금의 경우 금액을 읽습니다.

    각 작업에 대한 결과를 출력하세요:

    • deposit: Deposited [amount], Balance: [new balance]를 출력합니다.
    • withdraw: 성공하면 Withdrew [amount], Balance: [new balance]를 출력하고, 실패하면 Withdrawal failed: insufficient funds를 출력합니다.
    • balance: Current balance: [balance]를 출력합니다.

    모든 작업이 끝난 후, 각 항목을 새 줄에 표시하여 트랜잭션 내역을 출력하되, 첫 번째 항목에만 History: 접두사를 붙입니다.

다음과 같은 입력이 제공됩니다:

  • 1행: 초기 잔액 (정수)
  • 2행: 작업 횟수 (정수)
  • 다음 행들: 각 작업에 대해 유형(deposit, withdraw, 또는 balance)이 오고, 입금/출금의 경우 다음 행에 금액이 옵니다.

예를 들어, 다음과 같이 입력되면:

100
5
deposit
50
balance
withdraw
30
withdraw
200
balance

출력은 다음과 같아야 합니다:

Deposited 50, Balance: 150
Current balance: 150
Withdrew 30, Balance: 120
Withdrawal failed: insufficient funds
Current balance: 120
History: +50
-30

여기서 핵심 원칙은 모든 동기화가 BankAccount 메서드 내부에 숨겨져 있다는 것입니다. 호출자는 잠금에 대해 전혀 생각하지 않고 단순히 Deposit(), Withdraw(), Balance()를 사용하며, 구조체가 내부적으로 스레드 안전을 처리합니다.

치트 시트

스레드 안전 구조체(thread-safe struct)는 뮤텍스를 내장하고 공유 상태에 접근하는 모든 메서드에서 이를 잠금으로써 메서드 내에서 동기화를 캡슐화합니다:

type SafeCounter struct {
    mu    sync.Mutex
    count int
}

func (c *SafeCounter) Increment() {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.count++
}

func (c *SafeCounter) Value() int {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.count
}

핵심 원칙:

  • 데이터 경합을 방지하기 위해 읽기 전용 메서드를 포함하여 공유 상태에 접근하는 모든 메서드에서 뮤텍스를 잠급니다
  • 함수가 일찍 반환되더라도 뮤텍스가 해제되도록 defer를 사용합니다
  • 외부 코드에서 직접 접근할 수 없도록 뮤텍스를 비공개(소문자 필드 이름)로 유지합니다
  • 읽기 작업이 많은 워크로드의 경우 sync.RWMutex를 사용하고 읽기 시 RLock()을 호출합니다

여러 필드가 있는 구조체의 경우, 일관된 상태를 보장하기 위해 동일한 뮤텍스로 모든 관련 필드를 보호하십시오:

type Account struct {
    mu      sync.Mutex
    balance int
    history []string
}

func (a *Account) Deposit(amount int) {
    a.mu.Lock()
    defer a.mu.Unlock()
    a.balance += amount
    a.history = append(a.history, fmt.Sprintf("+%d", amount))
}

이를 통해 balancehistory가 모두 원자적으로 업데이트되도록 보장합니다. 즉, 어떤 고루틴도 일관되지 않은 상태를 관찰할 수 없습니다.

직접 해보기

package main

import (
	"bufio"
	"fmt"
	"os"
	"strconv"
	"strings"
)

func main() {
	reader := bufio.NewReader(os.Stdin)

	// 초기 잔액 읽기
	initialStr, _ := reader.ReadString('\n')
	initial, _ := strconv.Atoi(strings.TrimSpace(initialStr))

	// 작업 횟수 읽기
	numOpsStr, _ := reader.ReadString('\n')
	numOps, _ := strconv.Atoi(strings.TrimSpace(numOpsStr))

	// 은행 계좌 생성
	account := NewBankAccount(initial)

	// 각 작업 처리
	for i := 0; i < numOps; i++ {
		opType, _ := reader.ReadString('\n')
		opType = strings.TrimSpace(opType)

		switch opType {
		case "deposit":
			amountStr, _ := reader.ReadString('\n')
			amount, _ := strconv.Atoi(strings.TrimSpace(amountStr))
			// TODO: Deposit을 호출하고 결과 출력
			// 형식: "Deposited [amount], Balance: [new balance]"

		case "withdraw":
			amountStr, _ := reader.ReadString('\n')
			amount, _ := strconv.Atoi(strings.TrimSpace(amountStr))
			// TODO: Withdraw를 호출하고 적절한 결과 출력
			// 성공 시: "Withdrew [amount], Balance: [new balance]"
			// 실패 시: "Withdrawal failed: insufficient funds"
			_ = amount // 구현 시 이 라인을 제거하세요

		case "balance":
			// TODO: Balance를 호출하고 결과 출력
			// 형식: "Current balance: [balance]"
		}
	}

	// TODO: 거래 내역 출력
	// 첫 번째 항목 앞에는 "History: "가 붙어야 함
	// 이후 항목들은 접두사 없이 새 줄에 출력되어야 함
}
quiz icon실력 점검

이 레슨에는 짧은 퀴즈가 포함되어 있습니다. 레슨을 시작해 문제를 풀고 진행 상황을 기록하세요.

객체 지향 프로그래밍의 모든 레슨