본문 바로가기
경험 일지

[PyCharm] 소스 루트(Source Root) 설정 하나가 만든 아찔한 버그

by Marco Backman 2025. 10. 7.

 

예시 코드 URL: https://github.com/MarcoBackman/EngineRefactorExample

 

 

 

 

IDE의 편리한 기능이 때로는 예기치 못한 버그의 원인이 되기도 합니다. 오늘은 PyCharm(다른 IDE에서도 동일하게 발생할 수 있습니다)에서 복수의 소스 루트(Source Root)를 잘못 설정했을 때 마주쳤던, 디버깅하기 까다로운 테스트 실패 경험을 공유하고자 합니다.

소스 루트, 왜 설정할까요?

소스 루트를 설정하는 주된 이유는 파이썬 인터프리터에게 우리 프로젝트의 모듈을 어디서부터 찾아야 할지 알려주기 위함입니다. 이를 통해 아래와 같이 from my_package.my_module import my_function 같은 절대 경로 임포트를 IDE가 올바르게 인식하고, 코드 어시스트 기능을 제공할 수 있게 됩니다.

보통 프로젝트 하나에 소스 루트 하나를 지정하지만, 여러 모듈을 독립적으로 관리할 때는 복수의 소스 루트를 설정하기도 합니다. 바로 이 지점에서 문제가 시작될 수 있습니다.

 


 

Source Root 설정 법

 

1번은 Source root

2번의 Test는 test 모듈

3번은 source root를 지정 안했을 때의 패키지 경로 오류가 Pycharm에서 뜨는것을 볼 수 있습니다.

 

해당 문제점을 방지하기 위해서는 root 설정을 하는데요

 

설정하려는 root 폴더에 우클릭을 한 뒤 Mark Directory as "Sources Root" 로 설정하면 IDE에서 모듈 import 오류가 보이지 않을 것 입니다.

 

이렇게 설정했는데도 빨간줄이 보인다면 IDE 캐시를 삭제하고 재실행 하면 에러문이 보이지 않을 겁니다.

 


✅ 정상적인 상황: 단일 소스 루트와 성공하는 테스트

먼저 문제가 없는 코드와 프로젝트 구조를 살펴보겠습니다. Inventory라는 클래스 변수를 공유하는 간단한 클래스와 이를 사용하는 EngineA 클래스가 있습니다.

 

프로젝트 구조

src/
├── common/
│   ├── __init__.py
│   └── inventory.py
└── engine/
    ├── __init__.py
    └── engine_a.py
tests/
└── test_engine.py

 

src 폴더를 Sources Root로 지정한 상태입니다.

 

코드 스니펫

from typing import Dict

class Inventory:

    dict: Dict[str, str] = {}

    @classmethod
    def set_data(cls, dict_values: Dict[str, str]):
        cls.dict = dict_values

 

from common.inventory import Inventory
from common.mode_enum import ValidationMode, EngineMode
from engine.base_engine import BaseEngine
from typing import Dict

class EngineA(BaseEngine):

    def __init__(self,
                 validation_mode: ValidationMode = ValidationMode.VALIDATION_NONE):
        super().__init__(EngineMode.ENGINE_A, validation_mode)

    @classmethod
    def set_data(cls, data: Dict[str, str]):
        Inventory.set_data(data)

    @classmethod
    def get_data(cls):
        return Inventory.dict

    @classmethod
    def create_engine_instance(cls, validation_mode: ValidationMode):
        cls.set_data({"a": "A", "b": "B"})
        """Factory method to create engine with validation"""
        if validation_mode:
            return cls(validation_mode)
        else :
            print("No validation node provided, running with no validation mode...")
            return cls(ValidationMode.VALIDATION_NONE)

    def preprocess(self):
        print("Preprocessing...")

    def process(self):
        print("Processing...")

    def postprocess(self):
        print("PostProcessing...")

 

 

테스트 케이스

EngineA의 인스턴스를 생성하면 Inventory.data가 특정 값으로 채워지는 것을 검증하는 간단한 테스트입니다.

import unittest

from common.inventory import Inventory
from common.mode_enum import ValidationMode
from engine.engine_a import EngineA

class MyTestCase(unittest.TestCase):

    def test_same_inventory(self):

        actual_engine = EngineA.create_engine_instance(ValidationMode.VALIDATION_NONE)
        actual_engine.process()

        self.assertEqual({"a": "A", "b": "B"}, Inventory.dict)

if __name__ == '__main__':
    unittest.main()

 

 

이 테스트는 당연히 성공합니다. EngineA가 수정한 Inventory와 테스트 케이스가 바라보는 Inventory는 완벽히 동일한 객체이기 때문입니다.

 

 

 


문제 발생: 소스 루트를 중복 지정했을 때

 

여기서 실수가 발생했습니다. 실수로 src가 이미 소스 루트로 지정된 상태에서, 하위 폴더인 common까지 추가로 소스 루트로 지정한 것입니다.

 

이제 PyCharm은 common 폴더를 최상위 경로 중 하나로 인식합니다. 그 결과, 테스트 코드의 inventory 임포트 구문이 다음과 같이 변경되어도 IDE는 아무런 경고를 표시하지 않습니다.

import unittest

from inventory import Inventory
from common.mode_enum import ValidationMode
from engine.engine_a import EngineA

class MyTestCase(unittest.TestCase):

    def test_same_inventory(self):

        actual_engine = EngineA.create_engine_instance(ValidationMode.VALIDATION_NONE)
        actual_engine.process()

        self.assertEqual({"a": "A", "b": "B"}, Inventory.dict)

if __name__ == '__main__':
    unittest.main()

 

common 자체가 소스 루트가 되었으니, 그 안에 있는 inventory.py는 common 접두사 없이 바로 임포트할 수 있게 된 것이죠. 하지만 이 코드로 테스트를 실행하면, 놀랍게도 테스트는 실패합니다.

 

 

분명 EngineA 인스턴스를 생성하며 Inventory.data를 채웠는데, 테스트에서는 빈 딕셔너리({})로 나옵니다. 대체 무슨 일이 일어난 걸까요?

 


원인 분석: 두 개의 다른 모듈 인스턴스

범인을 찾기 위해, 두 Inventory 객체의 메모리 주소를 직접 확인해 보았습니다.

import unittest

from inventory import Inventory
from common.mode_enum import ValidationMode
from engine.engine_a import EngineA

class MyTestCase(unittest.TestCase):

    def test_same_inventory(self):

        actual_engine = EngineA.create_engine_instance(ValidationMode.VALIDATION_NONE)
        actual_engine.process()
        print("\n")
        print("Direct dict id=", id(Inventory.dict))
        print("\n")
        print("Instance's dict id=",id(actual_engine.get_data()))

        self.assertEqual(id(Inventory.dict), id(actual_engine.get_data()))
        self.assertEqual({"a": "A", "b": "B"}, Inventory.dict)

if __name__ == '__main__':
    unittest.main()

 

결과:

 

원인이 명확해졌습니다. 두 객체의 메모리 주소가 다릅니다.

파이썬은 모듈을 임포트할 때, 내부적으로 sys.modules라는 캐시에 모듈 경로: 모듈 객체 형태로 저장하고 재사용합니다.

  1. engine.engine_a는 from common.inventory import Inventory를 통해 모듈을 로드했습니다. 파이썬은 이 모듈을 'common.inventory' 라는 키로 캐싱합니다.
  2. tests.test_engine은 from inventory import Inventory를 통해 모듈을 로드했습니다. 파이썬은 이 모듈을 'inventory' 라는 다른 키로 캐싱합니다.

결과적으로, 이름만 같을 뿐 완전히 별개인 두 개의 Inventory 모듈 객체가 메모리에 생성된 것입니다. EngineA는 1번 Inventory의 data를 수정했지만, 테스트 케이스는 아무도 건드리지 않은 2번 Inventory의 비어있는 data를 확인했으니 테스트가 실패할 수밖에 없었던 것이죠.

임포트 구문을 다시 from common.inventory import Inventory로 수정하자, 두 id는 동일하게 출력되고 테스트는 정상적으로 통과했습니다.

 


결론: IDE 설정은 코드 실행에 직접적인 영향을 준다

이번 경험을 통해 IDE의 경로 설정 같은 '편의 기능'이 단순히 코드 편집에만 영향을 주는 것이 아니라, 코드의 실제 동작 방식까지 바꿀 수 있다는 사실을 깨달았습니다.

  • 소스 루트는 신중하게, 최소한으로 설정하세요. 가급적 프로젝트의 최상단(src 등)에 하나만 지정하는 것이 혼란을 막는 가장 좋은 방법입니다.
  • 예상치 못한 테스트 실패 시, 임포트 경로와 IDE 설정을 확인하세요. 코드가 논리적으로 완벽해 보인다면, 환경적인 요인이 문제일 수 있습니다.
  • 모듈의 동일성(Identity)이 중요하다면 id() 함수로 확인하는 습관을 들이는 것이 디버깅에 큰 도움이 됩니다.

모든 IDE 기능을 사용하기 전에 그 목적과 잠재적인 여파를 충분히 이해하는 것이 얼마나 중요한지 다시 한번 느끼게 된 계기였습니다. 여러분도 비슷한 문제로 시간을 낭비하는 일이 없기를 바랍니다.