[알고리즘 회고] 복잡한 시뮬레이션 문제, 5단계 설계(SOP)로 완벽 통제하기 (feat. 이기적 유전자)

이성진·2026년 3월 14일

[코테 SOP] 복잡한 시뮬레이션, 5단계 설계로 완벽 통제하기 (이기적 유전자)

💡 Note
이 글은 특정 알고리즘 플랫폼(백준, 프로그래머스 등)의 기출문제가 아닌, 생태계 시뮬레이션과 객체지향적 설계를 연습하기 위해 직접 요구사항을 정의하고 백지상태에서 아키텍처를 설계하여 구현해 본 커스텀 문제에 대한 회고입니다.


📌 들어가며: 시뮬레이션의 늪에서 벗어나기

소프트웨어 마에스트로(SoMa)와 같은 고난도 코딩 테스트에서 가장 발목을 잡는 유형은 단연 '복잡한 구현/시뮬레이션'이다.

과거에는 문제를 읽자마자 하나의 거대한 solution 함수 안에 모든 로직을 때려 넣으며(일명 스파게티 코드) 풀곤 했다. 하지만 이런 방식은 예외 조건이 하나만 추가되거나 꼬여도 디버깅 지옥에 빠지기 십상이다.

이번 '이기적 유전자(반복되는 죄수의 딜레마)' 생태계 시뮬레이션 문제를 풀며, 무작정 키보드부터 두드리는 습관을 버리고 설계 주도형 사고(SOP, Standard Operating Procedure)를 철저히 도입해 보았다.


🚀 1. 5단계 모듈화 설계 (Top-Down)

전체 생태계가 돌아가는 시스템을 5개의 독립적인 톱니바퀴(함수)로 쪼개는 뼈대(Skeleton) 설계부터 진행했다.

  • get_species: 개체의 식별자(Index) 번호를 받아 무슨 종족인지 판별한다.
  • action_by_entity: 종족 특성과 상대방의 이전 행동을 바탕으로 이번 턴의 액션(협력/배신)을 결정한다.
  • match: 생존한 개체들을 무작위로 1:1 매칭하고, 홀수일 경우 부전승 처리를 한다.
  • play_game: 매칭된 두 개체가 게임을 진행하고 점수(에너지)를 부여한다. (특히 복수자의 첫 턴 배신 로직을 우아하게 처리하는 데 집중했다)
  • update_populations: 라운드 종료 후 각 개체의 에너지에 따라 아사/생존/번식(자가 복제)을 판정하고 생태계 인구수를 갱신한다.

🧩 2. 바텀업(Bottom-Up) 조립과 구현 우선순위

설계가 끝난 후, 가장 먼저 어떤 함수부터 짤 것인가? 여기서 소프트웨어 공학의 '의존성(Dependency)' 개념을 적용했다.

가장 먼저 구현한 것은 '외부의 다른 함수를 호출하지 않고, 부수 효과(Side Effect)가 없으며, 시간 복잡도가 O(1)O(1)인 순수 함수'였다. get_speciesaction_by_entity 같은 말단 부품(Leaf Node)부터 조립하니, 데이터가 훼손될 두려움 없이 메인 로직을 향해 확신을 가지고 나아갈 수 있었다.


🚨 3. 뼈아팠던 트러블 슈팅 (Trouble Shooting)

설계가 완벽하다고 생각했지만, 실전 파이썬 환경에서는 여지없이 에러가 터졌다. 가장 핵심적인 2가지 트러블 슈팅을 기록한다.

💣 Trouble 1: 상태 갱신 누락으로 인한 IndexError

[원인]
매 라운드 update_populations 함수를 거치며 생태계의 인구수 배열(populations)은 변한다. 하지만 메인 루프에서 처음 입력받은 초기 인구수 변수를 갱신하지 않고 그대로 매칭 풀(index_list)을 만들려다 보니, 2라운드부터 존재하지 않는 인덱스를 참조하며 에러가 터졌다.

[해결]
라운드가 시작될 때마다 새롭게 갱신된 populations의 합계를 구하여 현재 상태를 정확히 동기화했다.

# 수정 전: 초기 인구수로 고정됨
index_list = list(range(S + B + R)) 

# 수정 후: 매 라운드 갱신된 전체 인구수 반영
index_list = list(range(sum(populations)))
random.shuffle(index_list)

💣 Trouble 2: 튜플 언패킹의 오해 (ValueError)

[원인]
에러 메시지: ValueError: not enough values to unpack (expected 4, got 2)
코드를 한 줄로 줄여보겠다고 함수 두 개의 반환값(각각 2개의 값을 가진 튜플)을 아래처럼 한 줄에 언패킹하려 했다.

# 에러 발생 코드
specie_e1, ri_e1, specie_e2, ri_e2 = get_species(e1, pop), get_species(e2, pop)

파이썬은 우항을 ((종족1, 인덱스1), (종족2, 인덱스2)) 형태의 2개의 큰 덩어리로 인식하기 때문에, 좌항의 4개 변수에 담을 수 없어 발생한 에러였다.

[해결]
가독성과 안전성을 위해 함수 호출과 변수 할당을 명확히 두 줄로 분리했다.

# 수정 후 코드
specie_e1, ri_e1 = get_species(e1, populations)
specie_e2, ri_e2 = get_species(e2, populations)

💡 4. 마치며

C언어의 포인터처럼 복잡하게 접근하려던 처음의 생각에서 벗어나, 데이터를 넘겨주고 리턴 받는 파이썬다운 모듈화 코드를 완성했다.

문제를 작게 쪼개고 데이터의 생명주기를 통제하는 법. 설계에 투자한 10분이 디버깅 시간 1시간을 아껴준다는 것을 뼈저리게 느낀 값진 경험이었다. 앞으로 만날 어떤 복잡한 요구사항 앞에서도 무작정 키보드에 손을 올리기보다, 노트와 펜을 먼저 꺼내 드는 개발자가 되어야겠다.


📄 최종 완성 코드

import random

def match(index_list): # [3] v
    #셔플된 index 배열을 두개씩 pop한뒤 e1, e2 반환
    if len(index_list)>=2:
        e1, e2 = index_list.pop(), index_list.pop()
    elif len(index_list)== 1:
        e1, e2 = index_list.pop(), -1 #하나만 남은 경우 e2를 -1로 전달
    return e1, e2

def play_game(e1, e2 , G, populations, e_list, scores): # [4] v
    #match된 객체 두개의 게임을 G번 진행하고 각 라운드 액션에 따라 get_scores(a1, a2)(scores를 기준으로 점수를 부여)하고 e_list를 업데이트함
    #부전승인지 확인
    if e2 == -1:
        return e_list
    specie_e1, ri_e1 = get_species(e1,populations)
    specie_e2, ri_e2 = get_species(e2,populations)
    e1_last_move, e2_last_move = 'D','D' #R은 첫무브가 무조건 배신이므로 D로 초기화
    for i in range(G):
        a1, a2 = action_by_entity(specie_e1,e2_last_move), action_by_entity(specie_e2,e1_last_move)
        score_e1, score_e2 = get_scores(a1,a2,scores)
        e_list[specie_e1][ri_e1]+= score_e1
        e_list[specie_e2][ri_e2]+= score_e2
        e1_last_move = a1
        e2_last_move = a2
    return e_list

def get_scores(a1, a2, scores): # [3] v
    m, n, k, l = scores
    if a1 == 'C':
        if a2 == 'C':
            return m , m
        else:
            return k, l
    else:
        if a2 == 'C':
            return l, k
        else:
            return n, n

def action_by_entity(species,op_last_move): # [2] v
    #개체별로 행동을 설정하고 특히 R개체는 상대 마지막 움직임에 따라 행동을 달리한다.
    #액션(C/D)을 리턴한다
    if species == 0:
        return 'C'
    if species == 1:
        return 'D'
    if species == 2:
        return op_last_move

def get_species(e,populations): # [1] v
    #개체 인덱스와 개체 분포를 비교하여 개체 종류를 S,B,R중 에서 한개 그리고 종족 내에서의 인덱스(로컬 인덱스)도 리턴한다.
    S,B,R = populations
    if e < S:
        species, local_index = 0 , e # S이면 0
    elif e< S+B:
        species, local_index = 1 , e-S #B이면 1
    else:
        species, local_index = 2 , e-(S+B) #R이면 2
    return species, local_index

def update_populations(e_list,Rep): #[5]
    #Rep에 따라 개체를 생성 소멸하고 e_list에 업데이트한다.
    #populations도 업데이트한다.
    new_e_list = [[],[],[]]
    new_populations = []
    for specie in range(len(e_list)): #종족별
        for local_idx in range(len(e_list[specie])): #rocal index
            if e_list[specie][local_idx]<= 0:
                continue
            if e_list[specie][local_idx] < Rep:
                new_e_list[specie].append(e_list[specie][local_idx])
            else:
                new_e_list[specie].append(e_list[specie][local_idx]//2)
                new_e_list[specie].append(e_list[specie][local_idx]//2)
        new_populations.append(len(new_e_list[specie]))
        
    return new_e_list, new_populations
                
def solutions(scores,populations,rules): # [6]
    E, Rep, G, T=rules
    # 초기 e_list 초기화
    e_list = [[E]*count for count in populations]

    for _ in range(T):
        # index_list 초기화
        index_list = list(range(sum(populations)))
        # 매 라운드 시작 시 index_list 셔플
        random.shuffle(index_list)
        while (len(index_list)):
            e1, e2 = match(index_list)
            e_list=play_game(e1, e2 , G, populations, e_list, scores)
        e_list, populations = update_populations(e_list,Rep)

    return populations

# 실행 예시 (맨 아래에 추가)
if __name__ == "__main__":
    scores = [3, 1, 0, 5]
    populations = [10, 10, 10]
    rules = [10, 100, 5, 100]
    result = solutions(scores, populations, rules)
    print("최종 살아남은 생태계:", result)
profile
알고리즘과 cs지식 학습

0개의 댓글