ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Software Engineering] Common Concerns and Principles
    카테고리 없음 2026. 4. 14. 17:50

     

    "동작하는 코드"를 만드는 것과 "좋은 소프트웨어"를 만드는 것은 다르다. 이번 글에서는 특정 기술이나 프레임워크 이야기가 아니라, 모든 소프트웨어 프로젝트에 공통적으로 등장하는 반복적인 아이디어들을 정리한다. 이 개념들은 소프트웨어 공학뿐만 아니라 공학 전반에 걸쳐 적용되는 원칙들이다. 이것들을 이해하고 있으면 지금 마주한 문제가 어떤 종류의 문제인지 더 빨리 파악할 수 있고, 해결책도 더 잘 설계할 수 있다.

     

     


     

    Abstraction: 컴퓨터 과학의 근간

    Donald Knuth는 "Layers of abstraction"을 컴퓨터 과학의 모든 것을 연결하는 가장 중요한 단일 개념, 즉 토대라고 표현했다. 컴퓨터 과학은 본질적으로 추상화의 예술이라고 볼 수 있다.

     

    추상화란 더 중요한 속성에 집중할 수 있도록 낮은 수준의 세부 사항들을 제거하거나 숨기는 과정이다.

    왜 중요한가? 시스템의 내부 동작을 외부에 전부 노출시키면, 외부 세계는 그 내부에 연결되고 사용하고 심지어 악용한다. 그러면 나중에 시스템을 수정하려고 할 때 문제가 생긴다. 추상화를 통해 단순화된 모듈로 복잡성을 관리하는 것이 핵심이다.

     

    추상화는 모든 설계 원칙의 기반이 된다. Separation of Concerns(SoC), Single Responsibility Principle(SRP), Encapsulation, Generalization 모두 추상화라는 토대 위에서 "복잡성 관리"라는 공통된 목표를 향해 서 있다.

     

     

     

    Separation of Concerns (SoC): 관심사의 분리

    SoC는 가장 중요한 설계 원칙 중 하나다. 시스템이나 프로그램은 서로 다른 섹션으로 나뉘어야 하며, 각 섹션은 별도의 관심사를 다뤄야 한다. 이렇게 하면 코드를 이해하기 쉽고, 재사용하기 쉽고, 변경하기 쉬워진다.

     

    OSI 7계층 모델이 SoC의 대표적인 예시다. 물리 계층은 raw 비트 전송만 담당하고, 전송 계층은 신뢰성 있는 데이터 전송만 담당하고, 애플리케이션 계층은 HTTP 같은 고수준 프로토콜만 담당한다. 각 계층이 자신의 관심사에만 집중하기 때문에 네트워크 시스템 전체를 이해하고, 재사용하고, 변경하기가 훨씬 쉬워진다. 또 각각의 관심사는 서로 인접하는 부분만 알고 있다.

     

    Cross-cutting Concerns (횡단 관심사): 분리하기 어려운 관심사

    SoC에서 한 가지 골치 아픈 문제가 있다. 바로 cross-cutting concerns다. 이것은 시스템의 여러 부분에 걸쳐 영향을 미치는 관심사들이다. 로깅, 보안 체크, 성능 모니터링, 에러 처리 같은 것들이 여기에 해당한다. 이런 것들은 여러 곳에 흩어져 있기 때문에 하나로 집중시키기가 어렵다.

     

     

     

    예를 들어 Logger를 생각해보자. Logger를 하나의 모듈에 집중시키면 모든 모듈이 그 모듈에 의존해야 한다. 그렇다고 안 집중시키면 로깅 코드가 여기저기 흩어진다. 원하는 것은 각각 분리하면서도 시스템 전체에 적용하는 것이다.

    // 기존 방식: BankAccount가 Logger에 명시적으로 의존
    class Logger {
        public void log(String message) { System.out.println("LOG: " + message); }
    }
    
    class BankAccount {
        private double balance;
        private Logger logger = new Logger(); // 명시적 의존성
    
        public void withdraw(double amount) {
            if (amount > balance) {
                logger.log("Insufficient funds");
            } else {
                balance -= amount;
                logger.log("Withdrawal of " + amount);
            }
        }
    }

     

    이 방식의 문제는 로깅 호출이 100군데에 있을 때 로깅 호출에 버그가 생기면 100군데를 전부 수정해야 한다는 것이다.

    이 문제를 해결하기 위해 등장한 것이 Aspect-Oriented Programming(AOP)다.

     

     

    AOP: 개념적 분리

    AOP는 하나의 관심사를 프로그램의 "aspect"로 표현하는 새로운 프로그래밍 패러다임이다. Aspect는 cross-cutting concern의 지점인 "join-point"에 시스템 "weaved into".

    Java Spring의 AOP를 예로 들면 이렇다.

    @Aspect
    public class LoggingAspect {
        @Before("execution(* com.app.*.*(..))")
        public void logBefore(JoinPoint joinPoint) {
            System.out.println("Executing: " + joinPoint.getSignature());
        }
    }

    비즈니스 모듈 코드에는 로깅 코드가 전혀 없다. 대신 aspect에 "모든 DB 호출에서 이 동작이 수행되어야 한다"고 개념적으로 선언한다. 물리적 삽입 없이 언제 어디서 weave할지를 개념적으로 명시한다.

     

    SoC와 AOP의 차이를 정리하면 이렇다.

    SoC는 물리적 분리다. 코드를 다른 "버킷"(폴더, 클래스, 레이어)에 수동으로 배치해야 한다. X군데에 로깅을 원하면 X번의 log 호출을 물리적으로 작성해야 한다. Logger 클래스로 관심사를 분리하더라도 말이다.

     

    AOP는 개념적 분리다. 관심사를 한 번만 "Aspect"로 정의하면 된다. 이 차이가 실제로 얼마나 중요한지는 로깅 호출 인자에 버그가 있고 그게 100군데에서 호출된다고 상상해보면 즉시 이해된다.

     

     


     

     

     

    Modularity: 레고처럼 조립 가능한 시스템

    Abstraction과 SoC가 잘 구현되면 자연스럽게 Modularity(모듈성)가 만들어진다. 모듈성이 높은 시스템은 컴포넌트들을 서로 분리해서 다른 방식으로 재조합할 수 있다.

     

    좋은 추상화(좋은 인터페이스로 연결)와 잘 구현된 SoC를 가진 시스템을 "모듈형"이라고 부른다. 시스템은 느슨하게 결합된(loosely coupled) 모듈들로 구성되어야 하며, 각 모듈은 높은 응집도(highly cohesive)를 가져야 한다. 느슨한 결합은 쉽게 분리할 수 있다는 뜻이고, 높은 응집도는 SoC가 잘 되어있다는 뜻이다.

     

    레고가 좋은 비유다. 레고 하나하나가 자기완결적이고 독립적인 소프트웨어 모듈이라면, Interchangeability(같은 타입의 레고는 서로 바꿀 수 있다), Composability(다양한 방식으로 조립 가능하다), Encapsulation(하나의 일을 잘 한다), Standardized interfaces(쉽게 맞물린다)가 모두 좋은 모듈형 소프트웨어 설계의 특성과 일치한다.

     

    하드웨어에서는 이 정도 수준의 모듈성이 꽤 성공적으로 달성됐다. USB, Ethernet 같은 인터페이스, 표준화된 전자 부품들이 대표적이다. 소프트웨어에서는 어떨까? Unix 파이프라인, API, PyTorch를 TensorFlow로 교체하는 것 같은 시도들이 있지만, 하드웨어만큼의 수준에는 아직 이르지 못한 것도 사실이다.

     

    Unix Philosophy: 모듈성의 실제 구현

    소프트웨어 모듈성의 고전적인 예시가 Unix 파이프라인이다. Mcllroy, Pinson, Tague가 1978년 Bell Systems Technical Journal에 정리한 Unix 철학은 이렇다.

     

    각 프로그램은 한 가지 일을 잘 해야 한다. 새로운 작업을 하려면, 새 기능을 추가해서 기존 프로그램을 복잡하게 만들 것이 아니라 새로 만들어야 한다. 모든 프로그램의 출력이 아직 알 수 없는 다른 프로그램의 입력이 될 수 있다고 생각해야 한다.

     

    실제로 파일의 줄 수를 세고 싶으면 cat myfile.txt | wc -l, 현재 디렉토리의 파일 수를 세고 싶으면 ls -1 | wc -l이 된다.

    • wc는 줄 세는 것 하나만 잘 한다.
    • cat은 파일을 읽는 것 하나만 잘 한다.
    • ls는 파일 목록을 나열하는 것 하나만 잘 한다.

    그런데 이것들을 파이프로 연결하면 무한히 다양한 작업이 가능하다.

     

     

     


     

     

    Reusability: 재사용 가능한 컴포넌트

    잘 설계되고 재사용 가능한 모듈은 곧 컴포넌트가 된다. 이것이 Component Based Software Engineering(CBSE)의 핵심이다. 프로그램을 컴포넌트들로 분해하고, 각 컴포넌트는 특정 일을 잘 하면서 재사용 가능하다는 아이디어다.

     

    Delphi 같은 비주얼 프로그래밍 IDE가 이 이상을 잘 구현했다. GUI는 본질적으로 조합 가능하고, Java Swing이나 기본 Android 위젯 같은 많은 GUI 라이브러리들은 컴포넌트로 볼 수 있다. ReactJS도 같은 맥락이다. React의 모든 것은 컴포넌트이고, 컴포넌트들을 조합해서 UI를 만든다.

     

     


     

     

    Redundancy: 중복은 항상 나쁜가

    Redundancy(중복)는 엄밀히 필요한 것보다 더 많이 갖는 것이다. 이게 좋은 걸까, 나쁜 걸까?

    결론부터 말하자면 상황에 따라 다르다. 

     

    나쁜 Redundancy: Code Clone

    Code Clone은 같은 코드 스니펫이 여러 곳에 복붙되어 중복 코드를 만드는 것이다. 나쁜 이유는 두 가지다.

    • 저작권, 코드 출처(지적 재산권) 문제가 생길 수 있고,
    • 버그 수정이나 기능 추가가 어려워진다.

    복붙된 코드 중 일부만 수정하면 어떻게 되는가?

    # 나쁜 예: 중복 로직
    def rectangle_area(width, height):
        return width * height
    
    def square_area(side):
        return side * side  # rectangle_area와 중복된 로직
    
    # 좋은 예: 리팩토링으로 기능이 한 곳에만 존재
    def rectangle_area(width, height):
        return width * height
    
    def square_area(side):
        return rectangle_area(side, side)  # rectangle_area 재사용

    해결책은 기능이 단 한 곳에만 존재하도록 코드를 리팩토링하는 것이다.

     

    좋은 Redundancy: 가용성과 신뢰성

    반면 중복이 꼭 나쁜 것만은 아니다. 가용성을 높이기 위해 서버를 이중화하거나, 인터넷이 끊기지 않도록 여러 네트워크 서비스 제공자를 두거나, 전략적 수준에서 데이터가 자연재해에도 살아남도록 지리적 중복을 두는 것은 좋은 중복이다.

     

    소프트웨어 관점에서 좋은 중복의 예로 N-version Programming이 있다. 핵심 시스템을 독립적으로 N개의 버전을 개발한다. 최종 결과는 다수결 투표로 결정한다. 하나의 버전에 버그가 있어도 나머지가 올바른 결과를 내면 시스템 전체는 정상 동작한다. 항공기나 원자력 발전소 같이 실패 비용이 극도로 높은 시스템에서 실제로 사용되는 방법이다.

     

     


     

     

    Correctness: 기능적 정확성

    소프트웨어는 기능적으로 정확해야 한다. 어떻게 보장하는가? 실험적 방법(테스팅)이나 분석적 방법(형식 검증)을 사용한다. 중간 단계의 정확성을 보장하는 방법(정적 분석 등)도 있고, 이미 검증된 라이브러리와 컴포넌트를 사용하는 것도 방법이다.

     

    면접관: CV에 수학을 빠르게 잘 한다고 적혀있네요. 17×19는 얼마인가요?
    지원자: 36입니다.
    면접관: 그건 전혀 근접하지도 않는데요.
    지원자: 하지만 빨랐잖아요.

     

    빠르게 틀린 것보다 조금 느리더라도 정확한 것이 중요하다. 성능과 정확성은 서로 다른 속성이고, 정확성이 선행되어야 한다.

     

     


     

     

    Reliability: 신뢰성은 확률이다

    Reliability는 특정 환경에서 특정 기간 동안 소프트웨어가 아무 실패 없이 동작할 확률이다.

    P = (실패 케이스 수) / (고려 대상 전체 케이스 수)

     

    흥미로운 점은 이미 실패가 발생한 것을 소급해서 분석하는 것은 상대적으로 쉽지만, 아직 실패하지 않은 소프트웨어 시스템의 신뢰성을 미리 평가하는 것은 훨씬 어렵다는 것이다. 추정이 어렵고 무거운 확률 이론이 필요하다. 또한 신뢰성의 정의는 시간환경에 따라 달라진다는 점도 중요하다.

     

     

    실제 사례: 1991년 걸프전 패트리엇 미사일

    패트리엇 미사일 방어 시스템은 처음에는 성공적인 것으로 여겨졌지만, 나중에 결함이 있었던 것으로 밝혀졌고 이로 인해 28명이 사망했다.

    패트리엇 시스템은 내부적으로 1/10초 단위로 시간을 측정했다. 부팅 후 경과 시간을 초 단위로 구하기 위해 시스템 클록에 1/10을 곱했는데, 이것을 24비트 부동소수점 레지스터에 저장했다.

     

    문제는 1/10이 24비트 부동소수점으로 정확하게 표현될 수 없다는 것이다. 1/10을 이진수로 표현하면 무한 소수가 된다.

    1/10 = 1/2⁴ + 1/2⁵ + 1/2⁸ + 1/2⁹ + 1/2¹² + 1/2¹³ + ...

     

    이것을 24번째 자리에서 잘라버리면 아주 작은 오차가 생긴다. 하지만 이 오차가 100시간 동안 누적되면 약 0.34초(= 0.000000095 × 360000 × 10)의 오차가 된다. 0.34초면 스커드 미사일이 500미터 이상 이동하기에 충분한 시간이다.

     

    왜 이 시스템이 신뢰성 있다고 여겨졌을까? 짧은 시간 동안 운용할 때는 오차가 작아서 아무 문제가 없었기 때문이다.

    신뢰성의 정의가 "시간과 환경에 따라 달라진다"는 것이 바로 이런 케이스를 말한다. 어떻게 피할 수 있었을까?

    더 큰 부동소수점 레지스터를 쓰거나, 정수 단위로 시간을 관리하거나, 장시간 운용 시나리오에서의 테스트를 했다면 발견할 수 있었을 것이다.

     

     


     

     

    Scalability: 크기 변화에 대응하는 능력

    Scalability는 크기가 변할 수 있는 능력이다. 이론적으로 소프트웨어는 물리적 크기 제한이 없지만, 실제로는 확장성이 공짜로 오지 않는다.

    "가장 효율적인 정렬 알고리즘은 무엇인가?"라는 질문을 생각해보자.

     

    Merge sort는 정밀하고, O(n log n)이며, 수학적으로 증명된 알고리즘이다. 그런데 1페타바이트 데이터를 정렬하려면 어떻게 해야 하는가? 단일 머신에서 O(n log n)의 알고리즘이 있어도 물리적으로 불가능하다.

     

    분산 정렬이 필요하고, 네트워크 지연, 장애 처리, 데이터 분배 같은 전혀 다른 종류의 문제들이 등장한다. 알고리즘 복잡도 분석은 점근적(asymptotic)이라 실제 wall clock 시간을 알 수 없고, 코드가 돌아가는 특정 하드웨어 플랫폼에 대한 정보도 없다. 확장성은 알고리즘의 효율성과는 다른 차원의 문제다.

     

     


     

    Usability / Accessibility: 동작하는 것과 쓰기 좋은 것은 다르다

    "It works"와 "It is nice to use"는 다르다. 기능적으로 완벽한 시스템이 쓰기 어렵다면 사람들은 쓰지 않는다.

     

    키오스크가 좋은 예다. 음식 주문 키오스크는 기능적으로 동작한다. 메뉴를 선택하고 결제할 수 있다. 하지만 노인분들이나 기술에 익숙하지 않은 분들에게는 쓰기 매우 어렵다. 기능이 있다는 것과 그 기능이 실제 사용자에게 접근 가능하다는 것은 다른 문제다.

     

    Usability는 얼마나 쉽고 효율적으로 사용할 수 있는가이고, Accessibility는 다양한 사용자(장애인, 노인, 기술 비숙련자 등)가 사용할 수 있는가다.

     

     


     

     

    Maintainability: 소프트웨어는 완성되지 않는다

    실제 소프트웨어 프로젝트에서 "완성"이란 없다. 코드와 서비스와 제품은 개발자가 조직을 떠난 후에도 살아남아 계속 진화한다. 실제 프로젝트 조사에 따르면 유지보수 비용이 총 소유 비용(TCO)의 60% 이상을 차지한다.

     

    소프트웨어 생애주기 동안 이루어지는 변경은 IEEE1219/P14764에 따라 네 가지로 분류된다. Perfective changes(기능 추가)가 50% 이상으로 가장 많고, Corrective changes(버그 수정)와 Adaptive changes(새로운 환경에 적응)가 각각 약 20%, 그리고 ISO/IEC 14764에 새로 추가된 Preventive changes(잠재적 결함 예방)도 있다.

     

    흥미로운 점은 버그 수정이 전체의 20%에 불과하다는 것이다. 절반 이상이 기능 추가다. 소프트웨어는 계속 성장한다. 이 때문에 소프트웨어가 원활한 진화를 허용하는지가 근본적인 관심사가 된다. 이로부터 문서화, 코드 주석을 지원할 것인지 말 것인지, 리팩토링 등 많은 논의들이 파생된다.

     


     

     

    Testability: 테스트하기 쉬운 코드

    Testability는 Maintainability와 밀접하게 연관된 개념이다. 소프트웨어를 테스트하기 쉬워야 한다는 것인데, "테스트하기 쉽다"는 것은 무엇을 의미하는가?

     

    테스팅은 여러 수준에서 이루어진다. Unit 테스트(개별 함수와 클래스), Integration 테스트(유닛 간 연결 테스트), System 테스트(전체를 합쳐서 테스트)로 나뉜다. 좋은 모듈형 설계는 자연스럽게 이것을 잘 지원한다. 또한 테스터와 개발자는 다른 사람일 수 있다. 가독성 높고 잘 문서화된 코드는 자연스럽게 테스트하기 쉽다.

    # 테스트하기 어려운 코드: 하나의 함수에 모든 것이 섞여있다
    def process(data):
        # 명확한 구조 없이 모든 것이 한 함수 안에
        for i in range(len(data)):
            if data[i] % 2 == 0: data[i] *= 2
            else: data[i] += 1
        return sum(data)
    
    # SoC를 적용해 테스트와 유지보수가 쉬워진 코드
    def transform(value):
        return value * 2 if value % 2 == 0 else value + 1
    
    def process(data):
        return sum(transform(x) for x in data)

    아래 버전에서는 transform 함수를 독립적으로 테스트할 수 있다. 짝수일 때, 홀수일 때를 각각 단위 테스트로 검증하면 된다. 위 버전에서는 process 함수 전체를 통해서만 테스트할 수 있다.

     

     


     

     

    Security: 설계부터 코드까지 전방위적 고려

    좋은 소프트웨어는 보안이 있어야 한다. 처리하는 정보를 보호해야 한다. 소프트웨어 보안을 철저히 고려하면 아키텍처와 설계부터 시작해서 좋은 코드 작성을 거쳐 사용성에 이르기까지 모든 것에 영향을 미친다.

     

    보안의 흥미로운 측면 중 하나가 Side-Channel Attack이다. 사이드 채널 공격은 소프트웨어가 설계된 방식이 아니라 구현된 방식 때문에 수집 가능한 정보를 악용한다.

    bool check_password(const char input[]) {
        const char correct_password[] = "hunter2";
        if (strlen(input) != strlen(correct_password)) return false;
        for (int i = 0; i < strlen(correct_password); i++) {
            if (input[i] != correct_password[i]) {
                return false;
            }
        }
        return true;
    }

    이 코드의 "Timing Attack"을 생각해보자.

    이 함수는 첫 번째로 다른 문자를 만나는 순간 즉시 false를 반환한다. 즉, 입력이 올바른 비밀번호와 앞 문자를 많이 공유할수록 함수 실행 시간이 길어진다. 공격자가 다양한 입력의 응답 시간을 측정하면서 비밀번호를 한 글자씩 추론해낼 수 있다.

     

    이것이 Timing Attack이다. 보안 취약점이 로직의 설계 문제가 아니라 구현 방식(조기 종료 최적화)에서 비롯됐다는 점이 핵심이다.

     

     

     


     

    Functional vs. Non-Functional Requirements

    지금까지 다룬 속성들을 요구사항 관점에서 분류하면 두 가지로 나뉜다.

    Functional Requirements는 입출력 동작 관점에서 기대되는 것이다. 즉, 정확성이다. "사용자가 로그인하면 대시보드로 이동해야 한다"가 기능적 요구사항이다.

     

    Non-Functional Requirements는 특정 동작이나 정확성이 아니라 소프트웨어 시스템의 일반적인 운용과 관련된 속성들이다. 신뢰성, 보안, 접근성, 성능 같은 것들이 여기에 해당한다. 지금까지 다룬 대부분의 품질 속성들이 비기능적 요구사항이다.

     

    비기능적 요구사항은 분석하거나 테스트하기가 훨씬 어렵다. "로그인이 동작하는가"는 테스트하기 쉽다. "이 시스템이 충분히 안전한가", "이 시스템이 100만 사용자를 감당할 수 있는가"는 어떻게 테스트하겠는가? 그 어려움이 비기능적 요구사항을 더욱 신중하게 초기에 정의해야 하는 이유다.

     

    Worst-Case Execution Time (WCET) Analysis

    특정 실시간 임베디드 시스템에서는 프로그램 실행에 얼마나 걸리는지를 알아야 한다. 에어백 컨트롤러의 경우 에어백이 효과적인 특정 시간 창이 있는데, 그 시간 창보다 일찍 또는 늦게 작동하면 안전하지 않다. 자율주행차의 제동 시스템은 충돌을 피하기 위해 특정 속도 제한을 초과하지 않아야 한다.

     

    우리가 보통 분석하는 알고리즘 복잡도는 두 가지 측면에서 이런 요구사항에 답하지 못한다. 점근적 분석이라 구체적인 실제 실행 시간을 알 수 없고, 코드가 돌아가는 특정 하드웨어 플랫폼에 대한 정보가 없다. WCET 분석은 이 두 가지를 고려해서 특정 하드웨어에서 특정 코드의 최악 실행 시간을 정적으로 분석하는 기법이다.

     

     


     

     

    정리

    지금까지 다룬 것들은 소프트웨어 개발에서 고려해야 할 것들의 전부가 아니다.

     

    Abstraction, SoC, Modularity, Redundancy, Correctness, Reliability, Scalability, Usability, Maintainability, Testability, Security. 이것들은 서로 독립적이지 않다. SoC를 잘 하면 Modularity가 생기고, Modularity가 있으면 Testability가 높아지고, Testability가 높으면 Maintainability가 좋아진다.

     

    이 많은 품질 기준들을 고려해야 한다는 사실이 왜 소프트웨어 개발에 다양한 전문성과 다양성이 필요한지를 설명한다. 보안 전문가, UX 디자이너, 성능 엔지니어, 도메인 전문가가 모두 필요하다.

     

     

     

     

    출처: 경북대학교 손정주 교수님, "소프트웨어공학" 강의 자료

Designed by Tistory.