소프트웨어 아키텍처

📌 시스템을 효율적이고 안정적으로 만들기 위해, 각 구성 요소를 큰 그림으로 설계하는 설계도이다.

  • 아래의 가치 중 구조적 가치를 제공한다.

 

왜 아키텍처를 고민을 해야할까?

  1. 확장성(Scalability) 확보
    • 사용자 수가 늘어날 때, 시스템이 문제 없이 확장 가능
  2. 유지보수(Maintainability) 용이
    • 아키텍처가 잘 정리되어 있으면, 버그 수정이나 새로운 기능 추가 시 어떤 부분을 건드려야 할지 명확
    • 모듈별 역할과 의존성을 분리하면, 유지보수 비용과 시간이 크게 감소

아키텍처 구현을 위한 주요 패턴

1. 레이어드 아키텍처 (Layered Architecture)

특징

  • 전통적인 계층 기반의 구조로, 애플리케이션을 역할별로 구분(예: 프레젠테이션, 비즈니스 로직, 데이터 접근 등).
  • 계층 간 호출 순서가 정해져 있어, 각 계층은 바로 아래 계층만 호출.

장점

  • 역할 분리: 각 계층의 역할이 명확하여 코드 가독성과 유지보수가 용이.
  • 구조화된 설계: 초기에 아키텍처를 설계하기 쉬움.
  • 범용성: 대부분의 애플리케이션에 적용 가능.

단점

  • 계층 간 의존성: 변화가 많은 프로젝트에서 수정 비용 증가.
  • 성능 문제: 계층이 많아질수록 호출 스택이 깊어져 성능 저하 가능.
  • 유연성 부족: 비즈니스 로직과 인프라 간 결합도가 높음.

https://mesh.dev/20210910-dev-notes-007-hexagonal-architecture/

2. 헥사고날 아키텍처 (Hexagonal Architecture)

특징

  • 코어 도메인 중심 설계: 핵심 비즈니스 로직을 중심에 두고, 외부 인터페이스(데이터베이스, UI, 외부 API 등)는 어댑터로 연결.
  • 포트와 어댑터: 도메인은 포트를 통해 어댑터와 상호작용하며, 어댑터는 실제 구현체.

장점

  • 의존성 분리: 도메인과 외부 시스템 간 결합도를 낮춤.
  • 테스트 용이: 외부 의존성을 Mock으로 대체해 테스트 가능.
  • 유연성 증가: 데이터베이스 교체나 외부 인터페이스 변경이 용이.

단점

  • 진입 장벽: 설계와 구현의 복잡성으로 인해 초기 이해가 어려울 수 있음.
  • 설계 비용: 소규모 프로젝트에서는 과도한 설계가 될 가능성.

3. 클린 아키텍처 (Clean Architecture)

특징

  • 의존성 역전 원칙(DIP)을 적용하여, 핵심 비즈니스 로직이 외부 프레임워크에 의존하지 않도록 설계.
  • 로버트 C.마틴(Robert C. Martin, Uncle Bob)의 제안
  • 계층 구분:
    • 가장 안쪽: 엔티티(핵심 비즈니스 로직).
    • 중간: Use Case(앱의 동작 정의).
    • 바깥쪽: 프레젠테이션, 프레임워크(UI, 데이터베이스 등).

장점

  • 변경 용이성: 외부 기술 스택에 구애받지 않음.
  • 유지보수성: 비즈니스 로직과 구현체를 분리.
  • 테스트 가능: 핵심 로직은 외부 종속 없이 독립적으로 테스트 가능.

단점

  • 복잡성: 계층 간 명확한 설계가 필요하며, 과도하게 적용 시 개발 속도 저하.
  • 학습 비용: 팀 내 이해와 합의 필요.

 

클린 아키텍처 필수 요건

  1. 의존성 규칙(Dependency Rule)
    • 의존성은 바깥 레이어에서 안쪽 레이어로만 흐릅니다.
    • 핵심 비즈니스 로직(도메인)은 프레임워크, UI, DB를 전혀 몰라야 합니다.
  2. 도메인 로직(핵심 비즈니스 로직)의 독립성
    • 도메인 모델(Entity 등)은 순수 자바 코드로 작성해, 프레임워크나 DB 관련 코드를 넣지 않습니다.
    • 도메인 정책(Validation, Calculation 등)은 도메인 레벨에서 처리하고, 바깥 레이어(프레임워크/인프라) 의존 로직을 섞지 않습니다.
  3. 인터페이스를 통한 의존 역전(DIP)
    • 도메인이나 Use Case 측에서 필요한 기능(DB 저장, 메시지 전송 등)은 인터페이스로 정의합니다.
    • 실제 구현은 Adapter(구현체) 가 담당하고, 도메인/Use Case는 구현체를 몰라야 합니다.
  4. 테스트 용이성
    • 도메인/Use Case가 외부 환경(DB, 네트워크 등)에 의존하지 않도록 설계합니다.
    • Mock/Stub을 이용해 유닛 테스트가 쉽도록 구조를 마련합니다.

 


https://learn.microsoft.com/ko-kr/azure/architecture/guide/architecture-styles/microservices

4. 마이크로서비스 아키텍처 (Microservices Architecture)

특징

  • 시스템을 독립된 작은 서비스로 분리하여, 각각의 서비스가 독립적으로 배포 및 확장 가능.
  • 각 서비스는 독립적인 데이터베이스를 가질 수 있음.

장점

  • 확장성: 서비스별로 독립적인 스케일링 가능.
  • 유연성: 각 서비스마다 적합한 기술 스택 선택 가능.
  • 장애 격리: 특정 서비스 장애가 전체 시스템에 영향을 덜 미침.

단점

  • 운영 복잡성: 서비스 간 통신, 배포 관리, 모니터링 등 관리 부담 증가.
  • 데이터 일관성: 트랜잭션 관리가 복잡해질 수 있음.
  • 개발 비용: 초기 설계와 개발 단계에서 시간과 리소스 투자 필요.

https://akasai.space/architecture/about_event_driven_architecture/

5. 이벤트 드리븐 아키텍처 (Event-Driven Architecture)

특징

  • 컴포넌트 간 통신을 이벤트로 처리하여, 비동기 메시징 기반으로 동작.
  • 프로듀서컨슈머가 느슨하게 결합.

장점

  • 확장성: 비동기 처리로 높은 트래픽 처리 가능.
  • 유연성: 각 컴포넌트가 독립적으로 동작하며, 쉽게 추가 및 제거 가능.
  • 실시간 처리: 이벤트 기반으로 즉각적인 응답 처리.

단점

  • 복잡성: 이벤트 흐름 관리 및 디버깅이 어려움.
  • 데이터 정합성: 이벤트 중복 처리 및 트랜잭션 관리 필요.

6. 주요 아키텍처 비교

아키텍처 주요 특징 장점  단점 적용 사례
레이어드 아키텍처 계층별 역할 분리 구조화 쉬움, 가독성 높음 계층 간 의존성, 성능 저하 가능 전통적 웹 애플리케이션
헥사고날 아키텍처 도메인 중심, 외부는 어댑터로 연결 도메인 독립성, 테스트 용이 초기 설계 복잡 대규모 엔터프라이즈 시스템
클린 아키텍처 의존성 역전 원칙 적용, 계층 간 분리 유지보수 용이, 테스트 가능 복잡한 설계, 높은 학습 비용 비즈니스 로직이 복잡한 시스템
마이크로서비스 작은 서비스로 독립 배포 장애 격리, 서비스별 스케일링, 다양한 기술 스택 운영 및 데이터 관리 복잡 대규모 분산 시스템, 클라우드 네이티브 앱
이벤트 드리븐 이벤트 기반 비동기 처리 확장성, 높은 성능 디버깅 어려움, 데이터 정합성 문제 실시간 데이터 처리, IoT, 메시징 시스템

학습하면 좋은 추가 주제

  1. 각 아키텍처의 실제 적용 사례
    • 각 패턴을 기반으로 설계된 오픈소스 프로젝트 분석.
  2. 아키텍처 설계와 테스트 전략
    • 아키텍처별 단위 테스트, 통합 테스트, E2E 테스트 적용 방법.
  3. 도메인 중심 설계(DDD)
    • 헥사고날 및 클린 아키텍처와 결합해 효과적으로 도메인 로직 설계.
  4. 아키텍처 선택 기준
    • 프로젝트 규모, 팀 역량, 요구사항에 따라 아키텍처를 선택하는 전략.

 

'기본기' 카테고리의 다른 글

테스트와 개발  (0) 2025.02.21
리팩토링  (0) 2025.02.15
백엔드 로드맵  (0) 2025.01.06
Clean Cord  (4) 2024.12.05
이름 짓기 규칙  (0) 2024.12.05

TDD (Test-Driven Development)

1.1 TDD란 무엇인가?

  • **TDD(Test-Driven Development)**란:
    • 테스트 코드를 먼저 작성하고, 그 테스트를 통과하기 위한 구현 코드를 작성하는 개발 방법론.
    • 코드 작성 전에 테스트를 통해 문제 정의와 요구사항을 명확히 이해.
  • 핵심 개념:
    • 테스트 작성 → 테스트 통과 → 코드 리팩토링.
    • 코드 품질을 높이고, 안정성을 확보.
  • TDD의 슬로건:
    • “테스트부터 작성하자. 그리고 통과시키자!”

1.2 TDD의 기본 사이클: Red-Green-Refactor

  1. Red (실패를 즐기자):
    • 실패를 두려워하지 않고, 실패를 통해 요구사항을 이해.
    • 실패한 테스트는 현재 요구사항이 무엇인지를 알려줌.
    import org.junit.jupiter.api.Test;
    import static org.junit.jupiter.api.Assertions.assertEquals;
    
    public class CalculatorTest {
        @Test
        void testAddition() {
            Calculator calculator = new Calculator();
            int result = calculator.add(2, 3);
            assertEquals(5, result); // 실패! 왜? 구현 코드가 없으니까.
        }
    }
    
  2. Green (테스트 통과는 최소 노력으로):
    • 테스트를 통과시키는 코드를 작성.
    • 복잡하게 고민하지 않고, 테스트 요구사항만 충족.
    public class Calculator {
        public int add(int a, int b) {
            return a + b; // 통과!
        }
    }
    
  3. Refactor (이제 진짜 멋지게):
    • 코드의 가독성, 유지보수성을 개선.
    • 코드 동작은 동일하게 유지하면서, 구조를 더 깔끔하고 확장 가능하게 변경.
    public class Calculator {
        public int add(int a, int b) {
            return calculate(a, b, "+");
        }
    
        private int calculate(int a, int b, String operation) {
            switch (operation) {
                case "+":
                    return a + b;
                default:
                    throw new UnsupportedOperationException("Operation not supported");
            }
        }
    }
    

1.3 TDD가 중요한 이유

  1. 코드 안정성 보장:
    • 모든 기능에 대해 자동화된 테스트 작성.
    • 버그를 줄이고, 코드 변경에도 안정성 유지.
  2. 생산성 향상:
    • 코드 리팩토링 부담 감소.
    • 변경 사항이 발생해도 테스트를 통해 빠르게 문제 발견.
  3. 코드 품질 향상:
    • 테스트를 기반으로 설계된 코드는 요구사항에 부합하고, 명확하며 유지보수가 쉬움.
  4. 개발 프로세스 개선:
    • 코드 작성 전에 요구사항을 구체화.
    • 의미 없는 코드 작성을 방지.

1.4 TDD의 장단점

장점 단점

코드 신뢰성과 품질 향상. 초기 학습 곡선이 높음.
요구사항 변경에도 코드 구조를 쉽게 유지 가능. 테스트 작성에 시간이 추가적으로 소요.
디버깅 시간 절약 및 문제 사전 예방. 잘못된 테스트 설계 시 오히려 개발 속도 저하.
자동화된 테스트로 코드 변경 시 빠른 피드백 가능. 작은 프로젝트에서는 과도한 테스트 작성 가능성.

1.5 DDD (Domain-Driven Design)와의 관계

  1. TDD:
    • 코드를 테스트하면서 어떻게 구현할지에 초점.
    • 코드 안정성과 품질을 높이기 위해 활용.
  2. DDD:
    • 비즈니스 도메인을 설계하여 무엇을 구현할지에 초점.
    • 요구사항을 분석하고, 도메인 모델을 설계하여 복잡한 비즈니스 로직을 제대로 반영.
  3. TDD와 DDD의 차이:
  TDD DDD
목적 코드의 동작 보장 비즈니스 요구사항을 시스템에 반영
초점 테스트 작성 → 코드 작성 → 리팩토링 요구사항 분석 → 도메인 설계 → 코드 작성
중요성 코드 안정성과 품질 확보 비즈니스 로직의 정확한 반영

1.6 TDD 실습 시 추가 학습이 필요한 주제

  1. 테스트 작성 기법:
    • 단위 테스트(Unit Test), 통합 테스트(Integration Test)의 차이와 작성 방법.
    • Mocking 라이브러리(예: Mockito, MockK) 활용.
  2. 테스트 자동화 도구:
    • CI/CD 파이프라인에서 TDD를 통합하기 위한 Jenkins, GitHub Actions.
  3. 테스트 피라미드:
    • 테스트 종류(단위 테스트, 통합 테스트, UI 테스트)와 비중.
  4. 테스트 커버리지:
    • 코드의 어느 부분이 테스트되고, 어느 부분이 테스트되지 않는지 분석.
  5. 리팩토링 패턴:
    • 코드 리팩토링 시 자주 사용하는 디자인 패턴과 사례.

결론

TDD는 코드 안정성과 품질을 높이는 데 중요한 역할을 하는 개발 방법론입니다. 코드를 작성하기 전에 테스트를 먼저 작성함으로써 요구사항을 명확히 하고, 불필요한 작업을 줄일 수 있습니다. 또한, TDD는 DDD와 함께 사용하면 비즈니스 로직 설계와 구현을 더욱 효율적으로 진행할 수 있습니다.

TDD를 실무에서 성공적으로 적용하기 위해서는 단위 테스트 작성 기법, Mocking, 테스트 피라미드와 같은 추가 개념을 학습하는 것이 중요합니다. 😊

 

테스트 코드

📌 “내 코드, 정말 제대로 작동할까?”라는 질문에 대한 답을 미리 준비하는 개발자의 무기

  • 프로그램의 기능이 의도대로 동작 하는지 자동으로 확인
  • 안만든다는 선택지도 분명 존재는 하나, 서버를 주력으로 다루는 백엔드에서 오류가 안나도록 테스트를 진행하는 것은 사실상 필수라고 봐야한다.

 

Mock

 

1. Mock이란?

  • Mock(모의 객체):
    • 테스트 환경에서 실제 객체를 대신하여 동작하는 가짜 객체. [실제 객체는 DB,네트워크와 같은 외부 의존성에 의해 움직일 수 있는데, 테스트 시에는 이런게 없으면 편하니 유사 객체를 만든 것]
    • 외부 의존성을 제거하고, 테스트 대상 코드에만 집중할 수 있도록 도와줌.
    • 객체의 특정 동작을 미리 정의하거나 호출 여부를 검증하는 데 사용.

2. Mock의 필요성

  1. 외부 의존성 제거:
    • 데이터베이스, 네트워크, 파일 시스템 등 외부 시스템과 상호작용 없이 독립적인 테스트 가능.
  2. 테스트 성능 향상:
    • 실제 객체 대신 Mock 객체를 사용하여 빠르고 가벼운 테스트 환경 구현.
  3. 예외 및 경계 조건 테스트:
    • 실제 객체가 처리하지 못하는 특정 조건(예: 네트워크 장애, DB 연결 실패)을 Mock으로 시뮬레이션.
  4. 상태 검증:
    • Mock 객체를 통해 특정 메서드가 호출되었는지, 호출 횟수 등을 확인.

3. Mock과 Stub, Spy의 차이

항목 Mock Stub Spy

정의 테스트 중 동작을 시뮬레이션하는 가짜 객체 고정된 응답을 제공하는 가짜 객체 실제 객체를 감싸서 호출을 감시하고 일부 동작을 대체
주요 기능 호출 검증, 동작 시뮬레이션 미리 정의된 값을 반환 호출 여부와 횟수를 확인하며 일부 메서드를 모킹
사용 목적 메서드 호출 여부, 횟수, 전달된 인수 검증 정해진 동작을 반환 실제 객체와 모킹 동작을 혼합
예시 네트워크 요청 여부 검증 특정 메서드가 항상 같은 값을 반환하도록 설정 데이터베이스 객체의 동작 일부를 검증

4. Mock 구현 예제

Java - Mockito 사용

import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import static org.mockito.Mockito.*;

class UserServiceTest {

    @Test
    void testGetUserName() {
        // Mock 객체 생성
        UserRepository mockRepo = mock(UserRepository.class);

        // Mock 동작 정의
        when(mockRepo.getUserName(1)).thenReturn("Alice");

        // Mock 객체를 사용하는 서비스
        UserService userService = new UserService(mockRepo);

        // 테스트
        String name = userService.getUserName(1);
        assertEquals("Alice", name);

        // 호출 여부 검증
        verify(mockRepo, times(1)).getUserName(1);
    }
}

Kotlin - MockK 사용

import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlin.test.assertEquals

class UserServiceTest {

    @Test
    fun `test getUserName`() {
        // Mock 객체 생성
        val mockRepo = mockk<UserRepository>()

        // Mock 동작 정의
        every { mockRepo.getUserName(1) } returns "Alice"

        // Mock 객체를 사용하는 서비스
        val userService = UserService(mockRepo)

        // 테스트
        val name = userService.getUserName(1)
        assertEquals("Alice", name)

        // 호출 여부 검증
        verify(exactly = 1) { mockRepo.getUserName(1) }
    }
}

5. Mock의 주요 활용 사례

  1. 데이터베이스(Mock DB):
    • 실제 DB 연결 없이 테스트 수행.
    • 데이터 삽입/조회/삭제를 Mock으로 처리.
  2. 네트워크 호출(Mock API):
    • 외부 API의 응답을 Mock으로 설정하여 테스트.
  3. 예외 처리 테스트:
    • 특정 메서드 호출 시 예외를 발생하도록 설정.
  4. 의존성 많은 서비스(Mock Dependency):
    • 복잡한 의존성을 가진 클래스 테스트 시 간소화.

6. Mock 작성 시 주의사항

  1. 과도한 Mock 사용:
    • Mock을 너무 많이 사용하면 테스트의 신뢰성이 떨어질 수 있음.
    • 필요한 부분만 Mock하고, 가능한 실제 객체와 함께 테스트.
  2. 복잡한 동작 정의:
    • Mock 객체의 동작이 지나치게 복잡하면 테스트 가독성 저하.
    • 간단한 동작 정의를 선호.
  3. Mock 객체와 실제 객체의 혼용:
    • 동일한 테스트에서 Mock 객체와 실제 객체를 혼합 사용 시 결과를 이해하기 어려워질 수 있음.
  4. 잘못된 동작 정의:
    • 테스트 시 실제와 다르게 동작하도록 Mock을 정의하면 테스트 결과가 부정확해질 수 있음.

7. Mock 관련 추가 학습 주제

  1. 테스트 대역(Test Double):
    • Mock, Stub, Spy, Fake, Dummy의 차이와 사용법.
  2. Mock 라이브러리 심화:
    • Mockito(Java), MockK(Kotlin), Jest(JavaScript) 등 주요 라이브러리 활용.
  3. Mock을 활용한 통합 테스트:
    • 단위 테스트와 통합 테스트에서 Mock의 적절한 사용 시점 학습.
  4. CI/CD와 Mock:
    • CI/CD 파이프라인에서 Mock을 활용한 자동화 테스트.
  5. 비동기 및 동시성 테스트:
    • 비동기 작업(Mock의 비동기 동작 정의)과 동시성 처리 검증.

8. 정리

항목 설명
Mock이란? 외부 의존성을 제거하고, 테스트 대상 코드에 집중하기 위한 가짜 객체.
필요성 외부 시스템 제거, 테스트 성능 향상, 예외 및 경계 조건 테스트, 호출 검증.
Mock과 Stub의 차이 Mock은 호출 검증과 동작 정의를 지원하며, Stub은 정해진 동작만 수행.
활용 사례 데이터베이스(Mock DB), 네트워크 호출(Mock API), 예외 처리 테스트 등.
주의사항 과도한 Mock 사용 지양, 동작 정의 간소화, Mock 객체와 실제 객체의 혼용 주의.
추가 학습 주제 테스트 대역, Mock 라이브러리 심화, 통합 테스트, CI/CD와 Mock, 비동기 테스트.

+ Mocking : 테스트 환경에서 실제 객체를 대신하여 동작하는 가짜 객체(Mock Object)를 생성하고 사용하는 과정.

 

단위 테스트 (Unit Test)

 

1. 단위 테스트란?

  • 단위(Unit):
    • 애플리케이션의 가장 작은 구성 요소(메서드, 클래스 등)를 의미.
    • 단위 테스트는 이 작은 구성 요소가 올바르게 동작하는지 검증하는 테스트.
  • 단위 테스트의 목적:
    • 각 구성 요소의 정확성 보장.
    • 변경된 코드로 인해 기존 코드에 **부작용(Side Effect)**이 없는지 확인.
    • 버그를 사전에 방지하고, 유지보수를 쉽게 만듦.

2. 단위 테스트의 특징

  1. 독립성:
    • 테스트는 서로 독립적으로 실행되어야 함.
    • 하나의 테스트가 실패해도 다른 테스트에 영향을 주지 않아야 함.
  2. 빠른 실행:
    • 단위 테스트는 작은 단위의 코드만 검증하므로 빠르게 실행.
  3. Mocking 활용:
    • 외부 의존성을 제거하기 위해 Mock 객체를 사용.
    • 예: 데이터베이스, 네트워크 요청 등을 실제로 호출하지 않음.
  4. 작은 범위:
    • 클래스 또는 메서드 단위로 테스트 작성.
    • 외부 시스템이나 다른 모듈과 상호작용하지 않음.

3. 단위 테스트의 장점

장점 설명
빠른 피드백 코드 변경 후 테스트를 통해 즉시 결과 확인 가능.
버그 감소 개발 초기에 코드의 정확성을 검증하여 버그 발생 가능성을 줄임.
리팩토링 안전성 보장 테스트가 작성된 코드는 리팩토링 시에도 기능이 보장됨.
개발 속도 향상 초기에는 시간이 더 들지만, 유지보수 시간 단축으로 장기적으로 개발 속도가 향상됨.
문서화 역할 테스트 코드 자체가 해당 코드의 사용 방법과 요구사항을 문서화하는 역할을 함.

4. 단위 테스트 작성 방법

1) 단위 테스트 구성 요소

  1. Given (준비):
    • 테스트 환경 및 필요한 데이터를 설정.
  2. When (실행):
    • 테스트 대상 메서드 또는 기능을 호출.
  3. Then (검증):
    • 기대하는 결과와 실제 결과를 비교하여 테스트 통과 여부 확인.

2) 단위 테스트 예제 Java JUnit 예제

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;

public class CalculatorTest {
    @Test
    void testAddition() {
        // Given: 준비
        Calculator calculator = new Calculator();

        // When: 실행
        int result = calculator.add(2, 3);

        // Then: 검증
        assertEquals(5, result, "2 + 3은 5여야 합니다.");
    }
}

Kotlin JUnit 예제

import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test

class CalculatorTest {
    @Test
    fun testAddition() {
        // Given: 준비
        val calculator = Calculator()

        // When: 실행
        val result = calculator.add(2, 3)

        // Then: 검증
        assertEquals(5, result, "2 + 3은 5여야 합니다.")
    }
}

class Calculator {
    fun add(a: Int, b: Int): Int {
        return a + b
    }
}

5. 단위 테스트 작성 시 고려사항

  1. 단일 책임 원칙:
    • 하나의 테스트는 하나의 기능만 검증해야 함.
  2. 테스트 케이스 명확화:
    • 테스트 이름은 무엇을 테스트하는지 명확히 나타내야 함.
    • 예: testAddition_ShouldReturnSum_WhenGivenTwoIntegers.
  3. Mock 객체 사용:
    • 외부 의존성(데이터베이스, API 등)을 실제로 호출하지 않기 위해 Mocking 라이브러리 활용.
    • 예: Mockito(Java), MockK(Kotlin).
  4. 테스트 독립성 보장:
    • 각 테스트는 독립적으로 실행되어야 함.
    • 테스트 순서에 따라 결과가 달라지지 않도록 작성.
  5. 실패 케이스 테스트:
    • 정상 동작뿐 아니라, 예외 상황(Invalid Input, Null 등)도 검증.

6. 단위 테스트 도구 및 프레임워크

  1. Java:
    • JUnit: 가장 널리 사용되는 테스트 프레임워크.
    • Mockito: Mock 객체 생성 및 관리.
  2. Kotlin:
    • JUnit5: Kotlin에서도 기본적으로 사용 가능.
    • MockK: Kotlin용 Mocking 라이브러리.
  3. Python:
    • unittest: 표준 라이브러리로 제공되는 테스트 프레임워크.
    • pytest: Python의 강력한 테스트 도구.
  4. JavaScript:
    • Jest: Facebook에서 개발한 JavaScript 테스팅 라이브러리.
    • Mocha: Node.js 기반의 테스트 프레임워크.

7. 추가 학습이 필요한 주제

  1. Mocking과 Stub:
    • Mock 객체를 활용해 단위 테스트에서 외부 의존성을 제거하는 방법.
  2. 테스트 자동화:
    • CI/CD 파이프라인에서 단위 테스트를 자동화.
  3. 테스트 커버리지 도구:
    • 코드 커버리지 분석 도구(예: JaCoCo, Coverage.py).
  4. TDD(Test-Driven Development):
    • 테스트를 먼저 작성하는 방법론.
  5. Parameterized Test:
    • 동일한 테스트를 다양한 입력값으로 실행.

8. 정리

항목 설명
단위 테스트란? 애플리케이션의 가장 작은 구성 요소(메서드, 클래스 등)의 동작을 검증하는 테스트.
특징 독립성, 빠른 실행, 작은 범위의 검증, Mocking 활용.
장점 버그 감소, 코드 품질 향상, 리팩토링 안전성 보장, 빠른 피드백 제공.
도구 JUnit, MockK, Mockito, Jest, pytest 등.
추가 학습 주제 Mocking, 테스트 커버리지 분석, TDD, 테스트 자동화.

 

 

통합 테스트 (Integration Test)

 

1. 통합 테스트란?

  • 정의:
    • 애플리케이션의 **여러 구성 요소(모듈, 서비스)**가 함께 동작할 때, 이들이 올바르게 상호작용하는지 검증하는 테스트.
    • 단위 테스트가 개별 구성 요소를 테스트한다면, 통합 테스트는 그 구성 요소들 간의 통합 상태를 테스트.

2. 통합 테스트의 목적

  1. 구성 요소 간의 상호작용 검증:
    • 데이터베이스, API, 파일 시스템, 외부 서비스와 같은 외부 의존성과의 통합 동작 확인.
  2. 통합 시 발생할 수 있는 문제 탐지:
    • 개별적으로는 정상 작동하지만, 통합 시 발생하는 버그(데이터 불일치, 의존성 문제 등)를 식별.
  3. 시스템 신뢰성 확보:
    • 모듈 간의 연결이 올바르게 동작하여, 전체 시스템이 요구사항을 충족하는지 보장.

3. 통합 테스트의 특징

항목 설명
테스트 범위 시스템의 여러 구성 요소 또는 모듈 간의 상호작용 검증.
의존성 포함 데이터베이스, API, 메시지 큐 등 외부 시스템과의 통합 테스트를 포함.
테스트 비용 단위 테스트보다 설정과 실행에 더 많은 리소스와 시간이 필요.
실행 속도 단위 테스트보다 느리지만, E2E 테스트보다는 빠름.
사용 사례 데이터베이스 CRUD 테스트, 외부 API 호출 검증, 서비스 간 데이터 흐름 테스트.

4. 통합 테스트의 장단점

장점 단점
구성 요소 간의 호환성 검증 가능. 테스트 환경 설정 및 관리가 복잡.
외부 시스템과의 연동 문제를 사전에 발견. 단위 테스트보다 실행 속도가 느림.
시스템 전체의 신뢰성을 높임. 외부 의존성(네트워크, 데이터베이스)에 따라 테스트 결과가 달라질 수 있음.
실제 환경과 비슷한 테스트로 사용자 경험을 개선. 특정 문제의 원인을 개별 구성 요소로 좁히는 데 어려움이 있을 수 있음.

5. 통합 테스트 작성 방법

5.1 주요 단계

  1. 테스트 환경 설정:
    • 실제 데이터베이스나 외부 API와의 상호작용을 위한 환경 구성.
    • 테스트 전용 데이터베이스, Mock API 서버 설정.
  2. 데이터 초기화:
    • 테스트 시작 전, 필요한 초기 데이터 설정.
    • 테스트 종료 후, 데이터 정리(Clean-up).
  3. 테스트 실행:
    • 구성 요소 간의 데이터 흐름과 상호작용 테스트.
  4. 결과 검증:
    • 기대 결과와 실제 결과 비교.

5.2 통합 테스트 예제 Java + Spring Boot 통합 테스트

@SpringBootTest
@AutoConfigureMockMvc
class UserServiceIntegrationTest {

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private UserRepository userRepository;

    @BeforeEach
    void setUp() {
        userRepository.save(new User("Alice", "alice@example.com"));
    }

    @AfterEach
    void tearDown() {
        userRepository.deleteAll();
    }

    @Test
    void testGetUser() throws Exception {
        mockMvc.perform(get("/users/1"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.name").value("Alice"))
                .andExpect(jsonPath("$.email").value("alice@example.com"));
    }
}

Kotlin + Spring Boot 통합 테스트

@SpringBootTest
@AutoConfigureMockMvc
class UserServiceIntegrationTest {

    @Autowired
    private lateinit var mockMvc: MockMvc

    @Autowired
    private lateinit var userRepository: UserRepository

    @BeforeEach
    fun setUp() {
        userRepository.save(User("Alice", "alice@example.com"))
    }

    @AfterEach
    fun tearDown() {
        userRepository.deleteAll()
    }

    @Test
    fun `should return user details`() {
        mockMvc.perform(get("/users/1"))
            .andExpect(status().isOk)
            .andExpect(jsonPath("$.name").value("Alice"))
            .andExpect(jsonPath("$.email").value("alice@example.com"))
    }
}

6. 통합 테스트 시 Mock과 실제 객체의 사용

  1. Mock 사용:
    • 외부 API나 비효율적인 작업(Mock API, Mock 데이터베이스 등) 테스트.
    • 네트워크 장애, 예외 처리 시뮬레이션.
  2. 실제 객체 사용:
    • 데이터베이스와의 CRUD 테스트.
    • 실제 파일 시스템이나 외부 API와의 연동 확인.

7. 통합 테스트 시 주의사항

  1. 테스트 환경 설정:
    • 테스트용 데이터베이스와 실제 데이터베이스를 분리.
    • 환경 변수나 설정 파일로 테스트 환경 구성.
  2. 테스트 격리:
    • 각 테스트가 독립적으로 실행되도록 설정.
    • 테스트 실행 전후에 데이터를 초기화(Clean-up).
  3. 실행 시간 최적화:
    • 불필요한 작업(Mock 사용) 줄이고, 실행 시간을 최소화.
  4. 실제 환경과 유사성:
    • 가능한 실제 환경과 비슷한 조건에서 테스트 수행.

8. 통합 테스트와 단위 테스트의 차이

  단위 테스트 (Unit Test) 통합 테스트 (Integration Test)
테스트 범위 개별 구성 요소(메서드, 클래스 등). 여러 구성 요소 또는 시스템 전체.
실행 속도 빠름. 느림.
의존성 제거 Mock 객체를 사용해 외부 의존성을 제거. 실제 객체 또는 Mock을 혼합 사용.
사용 목적 개별 구성 요소의 동작을 검증. 구성 요소 간의 상호작용과 통합 검증.
테스트 복잡도 상대적으로 단순. 환경 설정 및 데이터 초기화가 필요하므로 복잡.

9. 추가 학습이 필요한 주제

  1. Mock Server 활용:
    • 외부 API 연동 테스트 시 Mock 서버 구현.
  2. TestContainers:
    • Docker 컨테이너를 사용해 실제 데이터베이스, 메시지 큐 테스트.
  3. CI/CD 파이프라인 통합:
    • 통합 테스트를 자동화하여 지속적인 배포 환경 구축.
  4. 테스트 격리 전략:
    • 데이터 충돌을 방지하기 위한 테스트 데이터 격리 기법.

 

End-to-End 테스트 (E2E Test)

 

1. E2E 테스트란?

  • End-to-End 테스트:
    • 애플리케이션의 전체 플로우를 검증하는 테스트.
    • 사용자가 애플리케이션을 실제로 사용하는 것과 동일한 환경에서 테스트를 진행.
    • 프론트엔드(웹, 모바일) → 백엔드 → 데이터베이스 → 외부 서비스 등 모든 구성 요소의 통합 동작 확인.

2. E2E 테스트의 목적

  1. 사용자 관점의 테스트:
    • 사용자가 애플리케이션을 사용하면서 마주칠 수 있는 모든 시나리오를 검증.
  2. 시스템 전체의 신뢰성 확인:
    • 프론트엔드, 백엔드, 데이터베이스 등 시스템 구성 요소 간의 통합 상태 확인.
  3. 버그 사전 탐지:
    • 단위 및 통합 테스트에서 발견되지 않은 전체 시스템의 결합 문제를 조기에 탐지.
  4. 운영 환경과 유사한 테스트:
    • 실제 배포 환경과 동일한 조건에서 테스트를 수행하여 배포 후 발생할 수 있는 문제를 예방.

3. E2E 테스트의 특징

특징 설명
테스트 범위 시스템의 전체 플로우 검증. 사용자와 애플리케이션의 모든 상호작용 확인.
실행 속도 단위 테스트와 통합 테스트보다 느림.
의존성 실제 시스템(데이터베이스, API, 네트워크 등)과의 상호작용 필요.
사용 사례 로그인, 회원가입, 결제 프로세스, 검색 기능 등 전체 플로우를 포함하는 기능 테스트.
자동화 도구 사용 Selenium, Cypress, Puppeteer, Playwright 등을 활용.

4. E2E 테스트의 장단점

장점 단점
실제 사용 시나리오를 기반으로 테스트하므로 사용자 관점에서 신뢰성을 높임. 테스트 속도가 느림.
시스템 전체의 결합 문제를 조기에 발견 가능. 설정 및 유지보수 비용이 높음.
운영 환경과 유사한 조건에서 실행 가능. 외부 의존성(네트워크, API)으로 인해 테스트 불안정성 증가.
주요 사용자 플로우를 검증하므로 배포 후 심각한 결함 발생 가능성을 줄임. 모든 경로를 테스트하려면 과도한 리소스가 필요.

5. E2E 테스트 작성 방법

5.1 주요 단계

  1. 테스트 환경 구성:
    • 운영 환경과 동일한 테스트 환경 준비.
    • 데이터베이스, API 서버 등 모든 구성 요소 설정.
  2. 사용자 플로우 정의:
    • 사용자가 실제로 수행할 시나리오 정의(예: 로그인 → 상품 검색 → 결제).
  3. 자동화 테스트 작성:
    • Selenium, Cypress 등의 자동화 도구를 사용해 테스트 작성.
  4. 테스트 실행 및 결과 검증:
    • 실행 후, 예상 결과와 실제 결과를 비교하여 테스트 통과 여부 확인.

5.2 E2E 테스트 예제 Cypress 예제

describe('User Login and Dashboard', () => {
    it('should allow the user to log in and view the dashboard', () => {
        // Visit the login page
        cy.visit('https://example.com/login');

        // Enter login credentials
        cy.get('#username').type('testuser');
        cy.get('#password').type('password123');
        cy.get('#login-button').click();

        // Verify redirection to dashboard
        cy.url().should('include', '/dashboard');

        // Check for welcome message
        cy.contains('Welcome, testuser');
    });
});

Selenium (Java) 예제

import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.By;
import org.openqa.selenium.chrome.ChromeDriver;

public class LoginTest {
    public static void main(String[] args) {
        System.setProperty("webdriver.chrome.driver", "/path/to/chromedriver");
        WebDriver driver = new ChromeDriver();

        try {
            // Visit login page
            driver.get("https://example.com/login");

            // Enter login credentials
            WebElement username = driver.findElement(By.id("username"));
            WebElement password = driver.findElement(By.id("password"));
            WebElement loginButton = driver.findElement(By.id("login-button"));

            username.sendKeys("testuser");
            password.sendKeys("password123");
            loginButton.click();

            // Verify redirection
            String currentUrl = driver.getCurrentUrl();
            if (currentUrl.contains("/dashboard")) {
                System.out.println("Test Passed: Dashboard loaded successfully.");
            } else {
                System.out.println("Test Failed: Dashboard not loaded.");
            }
        } finally {
            driver.quit();
        }
    }
}

6. E2E 테스트를 위한 도구

도구 특징
Cypress 빠르고 간단한 설정으로 프론트엔드 테스트 가능. JavaScript 기반.
Selenium 가장 널리 사용되는 브라우저 자동화 도구. 여러 언어(Java, Python 등) 지원.
Playwright Microsoft가 개발한 강력한 브라우저 자동화 도구. 크로스 브라우저 테스트 가능.
Puppeteer Node.js 기반, Chrome/Edge 브라우저 자동화 도구.

7. E2E 테스트 작성 시 주의사항

  1. 테스트 데이터 관리:
    • 테스트 실행 전, 초기 데이터를 설정하고, 테스트 후 정리(Clean-up).
  2. 운영 환경과 유사한 환경 사용:
    • 테스트 환경이 실제 환경과 다르면, 결과가 일관되지 않을 수 있음.
  3. 테스트 속도 최적화:
    • 불필요한 대기 시간 제거.
    • Mock 서버 사용을 통해 외부 의존성 제거.
  4. 결정적(Deterministic) 테스트:
    • 테스트 실행 결과가 항상 동일하도록 환경을 제어.
  5. 우선순위 정의:
    • 모든 사용자 플로우를 테스트하기 어렵기 때문에 핵심 플로우를 우선적으로 검증.

8. 단위 테스트, 통합 테스트와의 비교

  단위 테스트 (Unit Test) 통합 테스트 (Integration Test)  E2E 테스트 (End-to-End Test)
테스트 범위 개별 메서드 또는 클래스. 모듈 간의 상호작용 및 통합 검증. 시스템 전체 플로우.
실행 속도 가장 빠름. 단위 테스트보다 느림. 가장 느림.
외부 의존성 Mock으로 제거 가능. 외부 시스템 포함 가능. 실제 시스템 환경 필요.
사용 목적 개별 구성 요소의 동작을 검증. 구성 요소 간의 상호작용 확인. 사용자 관점에서 전체 시스템의 동작 검증.
복잡도 낮음. 중간. 높음.

9. 추가 학습이 필요한 주제

  1. 테스트 자동화 도구 심화:
    • Cypress, Selenium, Playwright 등의 고급 기능 학습.
  2. 테스트 데이터 관리:
    • 테스트 환경에서의 데이터 초기화 및 격리 전략.
  3. CI/CD 파이프라인 통합:
    • E2E 테스트를 자동화하여 지속적인 배포 파이프라인에 통합.
  4. 테스트 최적화 전략:
    • 테스트 실행 속도를 높이고, 불필요한 테스트를 줄이는 방법.

2.4 테스트코드를 구조적으로 깔끔하게 짜는 꿀팁

2.4.1 @Nested

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.*;

@DisplayName("OrderService 단위 테스트")
class OrderServiceTest {

    private OrderService orderService = new OrderService();

    @Nested
    @DisplayName("주문 생성 테스트")
    class CreateOrderTests {

        @Test
        @DisplayName("정상적으로 주문을 생성한다.")
        void should_CreateOrder_Successfully() {
            Order order = orderService.createOrder("ProductA", 3);
            assertNotNull(order);
            assertEquals("ProductA", order.getProductName());
        }
    }

    @Nested
    @DisplayName("주문 조회 테스트")
    class GetOrderTests {

        @Test
        @DisplayName("존재하는 주문을 조회한다.")
        void should_ReturnOrder_When_OrderExists() {
            Order order = orderService.createOrder("ProductA", 3);
            Order retrievedOrder = orderService.getOrderById(order.getId());
            assertEquals(order, retrievedOrder);
        }
    }
}

 

2.4.2 @DisplayName

  • @DisplayName은 테스트의 목적이나 동작을 설명하는 이름을 추가
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.*;

@DisplayName("OrderService 테스트")
class OrderServiceTest {

    private OrderService orderService = new OrderService();

    @Test
    @DisplayName("주문 생성 시 상품 이름과 수량이 올바르게 설정된다.")
    void should_CreateOrder_With_CorrectDetails() {
        Order order = orderService.createOrder("ProductA", 3);
        assertNotNull(order);
        assertEquals("ProductA", order.getProductName());
        assertEquals(3, order.getQuantity());
    }
}

 

2.4.3 @BeforeEach / @AfterEach

  • @BeforeEach: 각 테스트 실행 전 공통 작업을 수행
  • @AfterEach: 각 테스트 실행 후 정리 작업을 수행
import org.junit.jupiter.api.*;

import static org.junit.jupiter.api.Assertions.*;

class OrderServiceTest {

    private OrderService orderService;

    @BeforeEach
    void setUp() {
        orderService = new OrderService();
    }

    @Test
    @DisplayName("주문 생성 시 상품 이름이 설정된다.")
    void should_CreateOrder_With_ProductName() {
        Order order = orderService.createOrder("ProductA", 3);
        assertEquals("ProductA", order.getProductName());
    }

    @AfterEach
    void tearDown() {
        System.out.println("테스트 정리 작업 실행");
    }
}

 

2.4.4 @TestMethodOrder + @Order

import org.junit.jupiter.api.*;
import org.junit.jupiter.api.MethodOrderer.OrderAnnotation;

import static org.junit.jupiter.api.Assertions.*;

@TestMethodOrder(OrderAnnotation.class) // @Order 어노테이션 기준으로 순서 지정
@DisplayName("OrderService 순서 지정 테스트")
class OrderServiceTest {

    private static OrderService orderService;

    @BeforeAll
    static void setUp() {
        orderService = new OrderService();
    }

    @Test
    @Order(1)
    @DisplayName("1. 주문을 생성한다.")
    void testCreateOrder() {
        Order order = orderService.createOrder("ProductA", 3);
        assertNotNull(order);
        assertEquals("ProductA", order.getProductName());
    }

    @Test
    @Order(2)
    @DisplayName("2. 주문을 조회한다.")
    void testGetOrder() {
        Order order = orderService.getOrderById(1L); // 1번 ID의 주문 조회
        assertNotNull(order);
        assertEquals("ProductA", order.getProductName());
    }

    @Test
    @Order(3)
    @DisplayName("3. 주문 상태를 변경한다.")
    void testUpdateOrderStatus() {
        Order updatedOrder = orderService.updateOrderStatus(1L, "COMPLETED");
        assertEquals("COMPLETED", updatedOrder.getStatus());
    }
}

 

2.4.5 @TestMethodOrder(MethodName.class)

import org.junit.jupiter.api.*;
import org.junit.jupiter.api.MethodOrderer.MethodName;

import static org.junit.jupiter.api.Assertions.*;

@TestMethodOrder(MethodName.class) // 메서드 이름 순서대로 실행
class OrderServiceTest {

    @Test
    void a_testCreateOrder() {
        System.out.println("1. 주문 생성");
    }

    @Test
    void b_testGetOrder() {
        System.out.println("2. 주문 조회");
    }

    @Test
    void c_testUpdateOrderStatus() {
        System.out.println("3. 주문 상태 변경");
    }
}

'기본기' 카테고리의 다른 글

클린 아키텍처 (Clean Architecture)  (1) 2025.02.23
리팩토링  (0) 2025.02.15
백엔드 로드맵  (0) 2025.01.06
Clean Cord  (4) 2024.12.05
이름 짓기 규칙  (0) 2024.12.05

리팩토링 (Refactoring)

📌 기존 코드를 변경하여 코드의 구조를 개선하고, 가독성, 유지보수성을 높이는 프로세스.

  • 기능의 결과(동작)는 변경하지 않고, 내부 구조를 더 효율적으로 바꾸는 것이 목적.

2. 리팩토링의 필요성

  1. 코드 가독성 향상:
    • 다른 개발자와의 협업을 원활하게 하고, 새로운 팀원이 코드를 이해하기 쉽게 함.
  2. 유지보수성 개선:
    • 수정과 추가가 쉬워져서 변화에 빠르게 대응 가능.
  3. 버그 감소:
    • 복잡하고 중복된 로직을 단순화하여 버그 발생 가능성을 줄임.
  4. 기술 부채 관리:
    • 과거의 빠른 개발로 인해 생긴 기술 부채를 해소.

3. 리팩토링을 진행하는 시점

  1. 기능 개발 완료 후:
    • 동작이 정상적으로 구현된 후 코드 품질을 개선.
  2. 코드 리뷰 중:
    • 팀원들과 코드를 리뷰하며 리팩토링 방향 논의.
  3. 새로운 기능 추가 전:
    • 기존 코드가 복잡하다면 새로운 기능 추가 전에 정리.
  4. 코드에서 '나쁜 냄새'가 날 때:
    • 중복 코드, 지나치게 긴 메서드, 과도한 의존성 등이 있을 때.

4. 좋은 리팩토링을 위한 기법

  설명 예제
메서드 추출 길고 복잡한 메서드의 일부를 별도의 메서드로 분리. 복잡한 조건문을 메서드로 분리하여 가독성 향상.
클래스 분리 하나의 클래스가 너무 많은 책임을 질 경우, 역할별로 클래스를 나눔. 데이터 처리와 비즈니스 로직을 분리.
변수 이름 개선 의미 없는 이름 대신, 명확한 변수 이름을 사용. val a = 10 → val maxRetryCount = 10.
조건문 단순화 중첩된 조건문을 단순화하거나, Guard Clause 사용. if (age < 18) return "Minor"; else return "Adult"; → if (age < 18) return "Minor";
매직 넘버 제거 의미 없는 상수 값을 상수로 정의하여 사용. if (user.age > 18) → if (user.age > MINIMUM_AGE).
중복 코드 제거 여러 곳에서 반복되는 코드를 공통 메서드로 추출. 특정 포맷팅 로직을 formatText() 메서드로 추출하여 재사용.
인터페이스 도입 여러 클래스에서 공통 동작이 있다면 인터페이스로 추상화. 여러 데이터 저장소에 대해 Repository 인터페이스 도입.
의존성 주입 사용 객체 간 강한 결합을 줄이고, 외부에서 의존성을 주입받도록 수정. val repo = Repository() → constructor(private val repo: Repository).
불필요한 코드 제거 사용하지 않는 코드, 주석, 디버깅 코드 등을 제거. 오래된 주석 제거, 사용하지 않는 메서드 삭제.

5. 피해야 할 리팩토링 방식

  1. 과도한 추상화:
    • 너무 많은 클래스를 만들어 코드를 복잡하게 만드는 것.
    • 예: 간단한 작업을 위해 지나치게 많은 계층을 도입.
  2. 기능 변경:
    • 리팩토링은 기능을 바꾸지 않는 것이 원칙.
    • 예: 리팩토링 중에 기능을 추가하거나 수정하여 기존 동작이 변경됨.
  3. 불완전한 테스트 없이 진행:
    • 리팩토링 전에 충분한 테스트 코드가 없다면, 변경 후 기존 기능이 깨질 위험이 있음.
  4. 한 번에 너무 많은 변경:
    • 여러 파일과 클래스를 동시에 수정하면 오류 발생 가능성 증가.
    • 작은 단위로, 변경 후 테스트하며 진행해야 함.
  5. 성능에 지나친 초점:
    • 지나친 성능 최적화를 하다 보면 코드가 복잡해지고, 유지보수가 어려워짐.

6. 기술 부채 관리

  • 기술 부채(Technical Debt):
    • 빠른 개발을 위해 일시적으로 작성된 낮은 품질의 코드나 비효율적 설계.
    • 이후 더 많은 시간과 비용을 들여 수정해야 함.
종류 설명
디자인 부채 비효율적 설계로 유지보수 어려움.
코드 부채 품질이 낮거나 중복된 코드.
빌드/배포 부채 자동화되지 않은 빌드 및 배포 프로세스.
테스트 부채 충분한 테스트가 없는 상태로 기능 추가.

7. 리팩토링의 장점과 단점

장점 단점
코드 가독성 및 유지보수성 향상 시간과 노력이 많이 소요.
기술 부채 관리 가능 리팩토링 중 기능이 깨질 위험성 존재.
코드 중복 제거 및 성능 최적화 충분한 테스트 없이 진행하면 버그 발생 가능.
새로운 기능 추가 전 코드 안정화 가능 과도한 리팩토링 시 복잡성 증가.

8. 리팩토링의 단계

  1. 코드 분석:
    • 리팩토링이 필요한 영역 식별.
    • "나쁜 냄새"가 나는 코드를 찾음.
  2. 작은 단위로 변경:
    • 한 번에 너무 많은 코드를 수정하지 않고, 작은 단위로 작업.
  3. 테스트 코드 작성:
    • 리팩토링 전, 기존 코드의 동작을 보장할 테스트 작성.
  4. 변경 후 테스트:
    • 리팩토링 후, 기존 기능이 정상 동작하는지 테스트.
  5. 코드 리뷰 및 검토:
    • 팀원들과 코드 품질과 변경 사항 검토.

결론

리팩토링은 코드의 가독성과 유지보수성을 높이기 위한 중요한 과정입니다. 하지만 무분별한 리팩토링은 오히려 복잡성을 증가시키고, 프로젝트에 부정적인 영향을 미칠 수 있습니다. 적절한 시점과 범위를 설정하여, 작은 단위로 진행하는 것이 중요합니다. 

'기본기' 카테고리의 다른 글

클린 아키텍처 (Clean Architecture)  (1) 2025.02.23
테스트와 개발  (0) 2025.02.21
백엔드 로드맵  (0) 2025.01.06
Clean Cord  (4) 2024.12.05
이름 짓기 규칙  (0) 2024.12.05

아래의 사이트를 기반을 작성하였습니다.

 

2024 백엔드 개발자 로드맵 feat. 현직 백엔드 개발자 | zero-base

0에서 1은 만들어주는 백엔드 6개월 공부 로드맵

zero-base.co.kr

 

'기본기' 카테고리의 다른 글

클린 아키텍처 (Clean Architecture)  (1) 2025.02.23
테스트와 개발  (0) 2025.02.21
리팩토링  (0) 2025.02.15
Clean Cord  (4) 2024.12.05
이름 짓기 규칙  (0) 2024.12.05

클린 코드

📌 클린 코드는 단순히 잘 동작하는 코드가 아니라, 가독성, 유지보수성, 확장성이 뛰어난 코드를 의미합니다. 이는 협업과 장기적인 코드 품질을 유지하기 위한 필수적인 개발 철학 입니다.

  • Dirty Code는 폭탄이다. A를 수정하니 B가 터지는 연쇄반응이 수시로 나온다.
  • 협업의 필수 조건으로, 사실 본인이 혼자해도 이 규칙을 어느정도 지켜야한다.
  • 기술 부채가 쌓이면 나중에는 결국 리펙토링이 아니라 재개발하는게 더 좋을 수도 있다.
  • 프로그래머는 가뜩이나 이직이 많아 에일리언코드(담당자가 없어져서든 버전의 변화로든 알 수 없는 코드를 말한다.)가 많이 생성될 가능이 높다.

감당 못함

 

리팩터링

📌 결과의 변경 없이 코드의 구조를 재조정하는 것을 말합니다. 주로 가독성을 높이고 유지보수를 편하게 하기 위해 추후 수정하는 것을 말합니다. 버그를 없애거나 새로운 기능을 추가하는 행위는 아닙니다.

  • 코드가 읽기 어려운 순간 (코드를 읽으면 바로 이해가 되어야한다.)
  • 수정할 때마다 오류가 발생하는 경우
  • 같은 코드가 여기저기 복붙되어 있는 경우
  • 확장하려는데 코드 구조가 방해되는 경우
  • 단위 테스트 작성이 어려운 경우

보통 위와 같은 상황에서 진행하게 된다.

 

 

 

클린 코드의 기본 원칙

의미 있는 이름 짓기
  1. 구체적이고 의도를 담은 이름을 사용
  2. 매직 넘버를 피하라
  3. 데이터의 의미를 이름에 반영
함수 분리 하기
  1. 하나의 함수는 하나의 역할만 수행 (sendEmail은 이메일 발송의 흐름만 관리)
  2. 복잡한 작업은 작은 함수로 분리
  3. 함수 이름은 동작과 목적을 명확히 표현
불필요한 주석 제거
  1. 주석은 코드가 아닌 의도를 설명
    • “어떻게”가 아닌 “왜”를 설명
  2. 주석 대신 명확한 변수와 함수 이름으로 의도를 드러냄
  3. 불필요한 주석은 제거하고, 코드는 가능한 자체적으로 읽히게 작성
코드 중복 제거
DRY (Don’t Repeat Yourself) 원칙을 준수

복잡한 코드를 단순화하기
  1. 조건문이 복잡하거나 여러 논리를 포함한다면 메서드로 분리
부정 표현을 긍정 표현으로 바꾸기
  1. 긍정적 변수명 사용
  2. 긍정적 조건문 작성
  3. 이중 부정 지양
else 문 사용 지양하기
  1. else 문을 피하고 기본 동작을 명시
  2. 전처리와 핵심 로직을 분리
  3. 각 조건은 독립적으로 처리

 

의미 있는 이름 짓기

// 나쁜 예
public void processData(List<String> data) {
    for (String item : data) {
        if (item.length() > 5) {
            System.out.println(item);
        }
    }
}

// 좋은 예
public void printLongUserNames(List<String> userNames) {
    final int MIN_NAME_LENGTH = 5;
    for (String userName : userNames) {
        if (userName.length() > MIN_NAME_LENGTH) {
            System.out.println(userName);
        }
    }
}
  1. 구체적이고 의도를 담은 이름을 사용
  2. 매직 넘버를 피하라
  3. 데이터의 의미를 이름에 반영

 

 

함수 분리 하기

// 나쁜 예
public void sendEmail(String recipient, String subject, String body) {
    if (recipient == null || recipient.isEmpty()) {
        throw new IllegalArgumentException("Recipient cannot be null or empty");
    }
    System.out.println("Connecting to SMTP server...");
    System.out.println("Authenticating...");
    System.out.println("Sending email to: " + recipient);
    System.out.println("Subject: " + subject);
    System.out.println("Body: " + body);
    System.out.println("Email sent successfully.");
}

// 좋은 예
public void sendEmail(String recipient, String subject, String body) {
    validateRecipient(recipient);
    connectToSmtpServer();
    authenticate();
    deliverEmail(recipient, subject, body);
}

private void validateRecipient(String recipient) {
    if (recipient == null || recipient.isEmpty()) {
        throw new IllegalArgumentException("Recipient cannot be null or empty");
    }
}

private void connectToSmtpServer() {
    System.out.println("Connecting to SMTP server...");
}

private void authenticate() {
    System.out.println("Authenticating...");
}

private void deliverEmail(String recipient, String subject, String body) {
    System.out.println("Sending email to: " + recipient);
    System.out.println("Subject: " + subject);
    System.out.println("Body: " + body);
    System.out.println("Email sent successfully.");
}
  1. ★  하나의 함수는 하나의 역할만 수행   (sendEmail은 이메일 발송의 흐름만 관리)
  2. 복잡한 작업은 작은 함수로 분리
  3. 함수 이름은 동작과 목적을 명확히 표현

 

 

불필요한 주석 제거

// 나쁜 예
public void processTransaction(Account fromAccount, Account toAccount, double amount) {
    // 송금 금액이 0보다 커야 합니다.
    if (amount <= 0) {
        throw new IllegalArgumentException("송금 금액은 0보다 커야 합니다.");
    }

    // 잔액 확인
    if (fromAccount.getBalance() < amount) {
        throw new IllegalStateException("계좌 잔액이 부족합니다.");
    }

    // 같은 계좌인지 확인
    if (fromAccount.equals(toAccount)) {
        throw new IllegalArgumentException("같은 계좌로 송금할 수 없습니다.");
    }

    // 송금 실행
    fromAccount.withdraw(amount); // 돈을 출금합니다.
    toAccount.deposit(amount);    // 돈을 입금합니다.

    // 송금 로그
    System.out.println("송금 성공: " + amount + "원 전송됨.");
}

// 좋은 예
public void processTransaction(Account fromAccount, Account toAccount, double amount) {
    // 비즈니스 규칙: 송금 금액은 0보다 커야 함
    if (amount <= 0) {
        throw new IllegalArgumentException("송금 금액은 0보다 커야 합니다.");
    }

    // 비즈니스 규칙: 송금 계좌 잔액이 부족하면 송금 불가
    if (fromAccount.getBalance() < amount) {
        throw new IllegalStateException("계좌 잔액이 부족합니다.");
    }

    // 비즈니스 규칙: 동일 계좌 간 송금 금지 (실수 방지 목적)
    if (fromAccount.equals(toAccount)) {
        throw new IllegalArgumentException("같은 계좌로 송금할 수 없습니다.");
    }

    // 송금 실행
    fromAccount.withdraw(amount);
    toAccount.deposit(amount);

    // 로그 기록: 성공적인 송금을 기록 (보안 및 추적 목적)
    System.out.println("송금 성공: " + amount + "원 전송됨.");
}
  1. 주석은 코드가 아닌 의도를 설명
    • “어떻게”가 아닌 “왜”를 설명
  2. 주석 대신 명확한 변수와 함수 이름으로 의도를 드러냄
  3. 불필요한 주석은 제거하고, 코드는 가능한 자체적으로 읽히게 작성
  4. 사실 주석은 함부로 쓰지 않는게 좋다. 추후 관리가 어렵고 주석은 전부 개발자가 직접 달아야한다보니 더 문제가 많이 생긴다.

 

코드 중복 제거

// 나쁜 예
public void printUserName(String name) {
    System.out.println("User: " + name);
}

public void printAdminName(String name) {
    System.out.println("Admin: " + name);
}

// 좋은 예
public void printName(String role, String name) {
    System.out.println(role + ": " + name);
}

DRY (Don’t Repeat Yourself) 원칙을 준수

 

 

복잡한 코드를 단순화하기

// 나쁜 예
if (user != null && user.getAge() > 18 && user.isActive()) {
    // do something
}

// 좋은 예
if (isActiveAdultUser(user)) {
    // do something
}

private boolean isActiveAdultUser(User user) {
    return user != null && user.getAge() > 18 && user.isActive();
}
  1. 조건문이 복잡하거나 여러 논리를 포함한다면 메서드로 분리
  2. isActiveAdultUser가 중요한 것 = 왜 했는지

 

 

부정 표현을 긍정 표현으로 바꾸기

// 나쁜 예
if (!user.isInActive()) {
    return "Inactive User";
}
return "Active User";

// 좋은 예
if (user.isActive()) {
    return "Active User";
}
return "Inactive User";
  1. 긍정적 변수명 사용   -   이거 상당히 중요하다
  2. 긍정적 조건문 작성
  3. 이중 부정 지양 = 이건 반의어를 생각해봐라, 진짜 웬만해서는 피해야한다.
  4. isinactive 라면 긍정인 isactive로 수정한다. 부정은 결국 한번 더 생각을 해야한다..

 

 

else 문 사용 지양하기

// 나쁜 예
public void login(User user) {
    if (user != null) {
        if (user.isActive()) {
            if (user.isVerified()) {
                System.out.println("Login successful");
            } else {
                System.out.println("User is not verified");
            }
        } else {
            System.out.println("User is inactive");
        }
    } else {
        System.out.println("Invalid user");
    }
}

// 좋은 예
public void login(User user) {
    if (user == null) {
        System.out.println("Invalid user");
        return;
    }
    if (!user.isActive()) {
        System.out.println("User is inactive");
        return;
    }
    if (!user.isVerified()) {
        System.out.println("User is not verified");
        return;
    }

    // 모든 조건을 통과한 경우
    System.out.println("Login successful");
}

// 더 좋은 예
public void login(User user) {
    String validationResult = validateUser(user);

    if (!validationResult.equals("Valid")) {
        System.out.println(validationResult);
        return;
    }

    // 모든 조건을 통과한 경우
    System.out.println("Login successful");
}

private String validateUser(User user) {
    if (user == null) {
        return "Invalid user";
    }
    if (!user.isActive()) {
        return "User is inactive";
    }
    if (!user.isVerified()) {
        return "User is not verified";
    }

    return "Valid";
}
  1. else 문을 피하고 기본 동작을 명시
  2. 전처리와 핵심 로직을 분리
  3. 각 조건은 독립적으로 처리

 

클린 코드 이론 원칙

 

클린 코드를 작성하는 데 있어 추가적으로 고려할 수 있는 원칙과 실천 사항은 다음과 같습니다:


1. 코드의 가독성 향상

  • 일관성 유지: 코드 스타일(들여쓰기, 괄호 배치, 공백 등)을 일관되게 유지합니다.
  • 짧은 함수: 함수는 가능한 짧게 유지하여 한눈에 이해할 수 있도록 합니다.
  • 코드 정렬: 논리적 흐름을 고려하여 코드를 정렬합니다(예: 관련된 부분끼리 묶기).

2. 객체 지향 설계 원칙(SOLID)

  • S - 단일 책임 원칙(Single Responsibility Principle)
    클래스나 모듈은 하나의 책임만 가져야 합니다.
  • O - 개방-폐쇄 원칙(Open/Closed Principle)
    코드는 확장에는 열려 있고, 수정에는 닫혀 있어야 합니다.
  • L - 리스코프 치환 원칙(Liskov Substitution Principle)
    서브클래스는 언제나 기반 클래스의 역할을 대체할 수 있어야 합니다.
  • I - 인터페이스 분리 원칙(Interface Segregation Principle)
    클라이언트는 자신이 사용하지 않는 메서드에 의존하지 않아야 합니다.
  • D - 의존성 역전 원칙(Dependency Inversion Principle)
    상위 모듈은 하위 모듈에 의존해서는 안 됩니다. 둘 다 추상화에 의존해야 합니다.

3. 에러 처리

  • 명확한 예외 처리: 예외를 포괄적으로 잡기보다는 특정한 경우에만 잡아야 합니다.
  • 사전 조건 체크: 함수나 메서드의 입력값 검증을 통해 에러를 방지합니다.
  • null 사용 지양: null 대신 Optional, Maybe 같은 명시적 표현을 사용합니다.

4. 의존성 관리

  • 의존성 주입(DI): 객체 생성과 사용을 분리하여 의존성을 줄입니다.
  • 전역 상태 지양: 전역 변수를 사용하지 않고 명시적으로 데이터를 전달합니다.

5. 성능 및 최적화

  • 불필요한 작업 최소화: 반복 작업이나 연산을 줄이고 캐싱을 활용합니다.
  • 지연 계산(lazy evaluation): 필요할 때만 계산하도록 설계합니다.
  • 효율적인 자료구조 선택: 목적에 맞는 자료구조를 사용하여 성능을 개선합니다.

6. 테스트 가능 코드

  • 단위 테스트 작성: 각 함수와 모듈의 동작을 검증합니다.
  • 테스트 자동화: CI/CD 환경에서 자동화된 테스트를 실행합니다.
  • 모의 객체(Mock Object) 활용: 외부 의존성을 분리하여 테스트를 단순화합니다.

7. 유지보수성을 고려한 설계

  • 코드 중복 제거: 유틸리티 함수나 상수로 공통된 로직을 추출합니다.
  • 확장성 있는 설계: 변경 사항이 최소한의 영향으로 처리되도록 설계합니다.
  • 의도를 드러내는 코드: 코드만 읽어도 설계 의도를 알 수 있게 작성합니다.

8. 클린 아키텍처

  • 계층 분리: 비즈니스 로직, 프레젠테이션, 데이터 접근 계층을 분리합니다.
  • 엔티티 독립성: 엔티티는 프레임워크나 데이터베이스에 의존하지 않도록 설계합니다.
  • 경계(Interface) 설정: 외부 의존성과 내부 로직 사이에 명확한 경계를 만듭니다.

9. 적절한 캡슐화

  • 정보 은닉: 클래스 내부 상태를 외부에서 직접 접근하지 못하도록 합니다.
  • Getter/Setter 최소화: 단순히 데이터를 노출하는 Getter/Setter보다는 행동 중심의 메서드 사용.

10. 명확한 의사소통

  • 코드 리뷰: 동료와의 코드 리뷰를 통해 더 나은 코드를 작성합니다.
  • 컨벤션 문서화: 팀에서 사용하는 코딩 스타일을 문서로 명시합니다.

개방-폐쇄 원칙 (Open-Closed Principle)

📌 객체 지향 설계의 핵심 원칙 중 하나로, 소프트웨어 모듈, 클래스, 또는 함수가 다음과 같은 두 가지 상태를 만족해야 한다는 개념

  1. 확장에는 열려 있어야 한다 (Open for extension):
    • 새로운 기능을 추가하거나 요구사항의 변화에 따라 시스템의 동작을 확장할 수 있어야 한다.
    • 기존 코드를 수정하지 않고도 기능 추가가 가능해야 한다.
  2. 수정에는 닫혀 있어야 한다 (Closed for modification):
    • 기존의 잘 검증된 코드는 수정되지 않아야 한다.
    • 코드 변경이 없기 때문에 기존 시스템의 안정성을 유지할 수 있다.

목표

  • 코드를 재사용 가능하고 유지보수성이 높은 상태로 유지.
  • 새로운 요구사항이 생기더라도 기존 코드의 변경 없이 대응 가능.
class Notification {
    public void send(String type) {
        if (type.equals("Email")) {
            // 이메일 전송 코드
        } else if (type.equals("SMS")) {
            // SMS 전송 코드
        }
    }
}

--------------------------------------------

interface Notification {
    void send();
}

class EmailNotification implements Notification {
    public void send() {
        // 이메일 전송 코드
    }
}

class SMSNotification implements Notification {
    public void send() {
        // SMS 전송 코드
    }
}

class PushNotification implements Notification {
    public void send() {
        // 푸시 알림 전송 코드
    }
}

class NotificationService {
    public void sendNotification(Notification notification) {
        notification.send();
    }
}

새로운 알림 방식(예: 푸시 알림)을 추가하려면 send 메서드에 조건문을 추가해야 하므로 기존 코드를 수정해야 함.

=> 새로운 알림 방식을 추가하려면 새로운 클래스를 구현하기만 하면 된다. 기존 코드는 수정하지 않는다.

 

 

 

OOP (Object-Oriented Programming)

📌 객체 지향 프로그래밍(OOP)은 현실 세계의 사물이나 개념을 객체(Object)로 모델링하여 소프트웨어를 개발하는 방식

  • 객체를 사용해 데이터를 묶고, 이를 다루는 기능을 추가합니다.

[1] 캡슐화 (Encapsulation)

  • 내부 데이터를 보호하고, 외부에서 접근할 방법을 제한하는 것.
    • 약병은 뚜껑으로 약을 보호하지만, 우리가 약을 꺼낼 수 있는 기능(뚜껑 열기)을 제공해요.

[2] 상속 (Inheritance)

  • 부모가 가진 것을 자식이 물려받는 것.
    • 부모가 물려준 성격(코드)을 자식이 사용하거나 더 발전시킬 수 있어요!

[3] 다형성 (Polymorphism)

  • 같은 동작을 다양한 방식으로 처리할 수 있는 것.
    • “달려!“라는 명령에 강아지는 뛰고, 자동차는 굴러가요. 하지만 둘 다 “달리는 동작”이에요!

[4] 추상화 (Abstraction)

  • 복잡한 내부 내용은 숨기고, 필요한 부분만 드러내는 것.
    • 자동차의 내부 작동 방식은 몰라도, 운전대와 페달로 운전할 수 있잖아요!

 

SOLID 원칙 - 면접에서 자주 나온다

📌 OOP를 더 잘 설계하기 위한 5가지 규칙.

  • 쉽게 고치고, 확장할 수 있는 코드를 만드는 지침.

[1]  S - 단일 책임 원칙

  • 클래스는 하나의 역할만 가져야 한다.
    • 주방은 요리만, 침실은 잠만. (하나의 역할만!)
  • 왜?
    • 역할이 여러 개면 고칠 때 어디를 수정해야 할지 복잡해 짐.
public class User {
		private String name; // 사용자 정보
    public void login() { /* 로그인 기능 */ }
    public void saveUser() { /* 데이터베이스 저장 기능 */ }
}

--------------------------

public class User { /* 사용자 정보 관리 */ }

public class AuthService {
    public void login(User user) { /* 로그인 기능 */ }
}

public class UserRepository {
    public void saveUser(User user) { /* 데이터베이스 저장 */ }
}

[2]  O - 개방/폐쇄 원칙

  • 코드는 확장에 열려 있고, 수정에는 닫혀 있어야 한다.
    • 옥상에 방을 추가해도 기존 방은 그대로. (확장 가능!)
  • 왜?
    • 기존 코드를 수정하면 예기치 못한 문제가 생길 수 있음.

원래라면 새로운 도형이 추가될 때마다 AreaCalculator 클래스를 수정해야 한다. 하지만 개방 폐쇄 원칙을 적용하면 새로운 도형이 추가되더라도 shape 인터페이스만 구현하면 된다.

 

  • 다형성을 활용하여 해결한다.
    • 인터페이스를 implements 하여 구현한 새로운 클래스를 만들어서 새로운 기능을 구현한다.
    • 역할(도형)과 구현(원, 사각형, 삼각형 등)을 분리하면 된다.
public class Shape {
    public String type;
}

public class AreaCalculator {
    public double calculate(Shape shape) {
        if (shape.type.equals("circle")) {
            return /* 원의 넓이 계산 */;
        } else if (shape.type.equals("square")) {
            return /* 사각형의 넓이 계산 */;
        }
    }
}

------------------------

public interface Shape {
    double calculateArea();
}

public class Circle implements Shape {
    public double calculateArea() { return /* 원의 넓이 계산 */; }
}

public class Square implements Shape {
    public double calculateArea() { return /* 사각형의 넓이 계산 */; }
}

public class AreaCalculator {
    public double calculate(Shape shape) {
        return shape.calculateArea();
    }
}

문제점

// Circle을 계산하는 경우
public class Main {
		public static void main(String[]) {
		
				AreaCalculator areaCalculator = new AreaCalculator();
				Circle circle = new Circle();
				
				areaCalculator.calculate(circle);
			
		}
}

// Square를 계산하는 경우
public class Main {
		public static void main(String[]) {
		
				AreaCalculator areaCalculator = new AreaCalculator();
				// Circle circle = new Circle();
				Square square = new Square();
				
				areaCalculator.calculate(square);
			
		}
}
  • 구현 객체를 변경하기 위해서는 해당 코드를 사용하는 클라이언트측의 코드를 변경해야 한다.
  • 객체의 생성, 사용 등을 자동으로 설정해주는 무엇인가가 필요하다.
    • Spring Container의 역할

 

[3]  L - 리스코프 치환 원칙

  • 자식 클래스는 부모 클래스를 대체할 수 있어야 한다.
    • 새가 날 수 있는 것처럼, 모든 새는 ‘날기’ 동작을 가져야 한다. (일관성 유지!)
    • 부모 클래스를 사용하는 곳에서 자식 클래스를 사용해도 프로그램의 동작에 문제가 없어야 한다.
  • 왜?
    • 부모 클래스처럼 동작하지 않으면, 프로그램이 예외를 일으킴.

 

  • 예시
    • ElectricCar는 Car 클래스를 상속 받았지만, accelerate() 를 사용할 수 없다. LSP 위반

 

리스코프 치환 원칙 적용

  • 인터페이스를 구현한 구현체를 믿고 사용할 수 있도록 만들어준다.
  • 엑셀은 앞으로 가는 기능이다. 만약 뒤로 간다면 LSP를 위반한다.
class Car {
    public void accelerate() {
        System.out.println("자동차가 휘발유로 가속합니다.");
    }
}

class ElectricCar extends Car {
    @Override
    public void accelerate() {
        throw new UnsupportedOperationException("전기차는 이 방식으로 가속하지 않습니다.");
    }
}

public class Main {
    public static void main(String[] args) {
        Car car = new Car();
        car.accelerate(); // "자동차가 가속합니다."

        Car electricCar = new ElectricCar();
        electricCar.accelerate(); // UnsupportedOperationException 발생
    }
}

---------------------------------

// 가속 기능(역할)을 인터페이스로 분리
interface Acceleratable {
    void accelerate();
}

class Car implements Acceleratable {
    @Override
    public void accelerate() {
        System.out.println("내연기관 자동차가 가속합니다.");
    }
}

class ElectricCar implements Acceleratable {
    @Override
    public void accelerate() {
        System.out.println("전기차가 배터리로 가속합니다.");
    }
}

public class Main {
    public static void main(String[] args) {
        Acceleratable car = new Car();
        car.accelerate(); // "내연기관 자동차가 가속합니다."

        Acceleratable electricCar = new ElectricCar();
        electricCar.accelerate(); // "전기차가 배터리로 가속합니다."
    }
}

[4]  I - 인터페이스 분리 원칙

  • 클래스는 필요 없는 기능을 강요받지 말아야 한다.
    • 로봇은 먹지 않는데, ‘먹기’ 기능을 구현해야 한다면 비 효율적.
  • 왜?
    • 쓰지 않는 기능 때문에 코드가 불필요하게 복잡해 짐.
  • 특정 클라이언트를 위한 인터페이스 여러 개가 범용 인터페이스 하나보다 낫다.
    • 클라이언트는 자신이 사용하지 않는 메서드에 의존하지 않아야 한다.
    • 즉, 하나의 큰 인터페이스보다는 여러 개의 작은 인터페이스로 분리해야 한다.
public interface Animal {
    void fly();
    void run();
    void swim();
}

public class Dog implements Animal {
    public void fly() { /* 사용하지 않음 */ }
    public void run() { /* 달리기 */ }
    public void swim() { /* 수영 */ }
}


------------------------------


public interface Runnable {
    void run();
}

public interface Swimmable {
    void swim();
}

public class Dog implements Runnable, Swimmable {
    public void run() { /* 달리기 */ }
    public void swim() { /* 수영 */ }
}

[5]  D - 의존 역전 원칙

  • “구체적인 것보다, 추상적인 것에 의존하라.”
    • 예: 모든 가전제품은 콘센트만 꽂으면 작동. (표준 인터페이스!)
  • 왜?
    • 변경에 유연해지고, 재사용성이 높아짐.
  • 예시
    • NotificationService는 EmailNotifier 클래스를 의존한다.
// Email 알림 클래스
class EmailNotifier {
    public void sendEmail(String message) {
        System.out.println("Email 알림: " + message);
    }
}

// 알림 시스템
class NotificationService {
    private EmailNotifier emailNotifier;

    public NotificationService() {
		    // 구체적인 클래스인 EmailNotifier에 의존
        this.emailNotifier = new EmailNotifier();
    }

    public void sendNotification(String message) {
        emailNotifier.sendEmail(message);
    }
}

public class Main {
    public static void main(String[] args) {
        NotificationService service = new NotificationService();
        service.sendNotification("안녕하세요! 이메일 알림입니다.");
    }
}

[이메일 알림이 아닌 SMS 알림과 같은 기능이 추가되면 NotificationService 는 수정되어야 한다. DIP 위반]
-----------------------------------


// 알림 인터페이스(추상화)
interface Notifier {
    void send(String message);
}

// Email 알림 클래스
class EmailNotifier implements Notifier {
    @Override
    public void send(String message) {
        System.out.println("Email 알림: " + message);
    }
}

// SMS 알림 클래스
class SMSNotifier implements Notifier {
    @Override
    public void send(String message) {
        System.out.println("SMS 알림: " + message);
    }
}

// 알림 서비스 (높은 수준 모듈)
class NotificationService {
		// 추상화된 인터페이스에 의존
    private Notifier notifier;

    // 의존성 주입 (생성자를 통해 주입)
    public NotificationService(Notifier notifier) {
        this.notifier = notifier;
    }

    public void sendNotification(String message) {
		    // notifier가 어떤 구현체인지 상관하지 않음
        notifier.send(message);
    }
}

public class Main {
    public static void main(String[] args) {
        // Email 알림을 사용
        Notifier emailNotifier = new EmailNotifier();
        NotificationService emailService = new NotificationService(emailNotifier);
        emailService.sendNotification("안녕하세요! 이메일 알림입니다.");

        // SMS 알림을 사용
        Notifier smsNotifier = new SMSNotifier();
        NotificationService smsService = new NotificationService(smsNotifier);
        smsService.sendNotification("안녕하세요! SMS 알림입니다.");
    }
}
  • 추상화된 Notifier 인터페이스에만 의존한다.
    • 새로운 알림 방식이 추가되어도 NotificationService 는 변경되지 않아도 된다.
  • 필요한 Notifier 객체를 외부에서 주입받는다.
    • NotificationService는 어떤 알림 방식을 사용할지에 대한 세부 사항을 몰라도 되므로, 의존성이 약해진다.
  • 모듈간의 결합도를 낮추고 유연성과 확장성을 높일 수 있다.
  • 서로의 변경 사항에 독립적이어서 변경에 유연하다.

 

 

의존 역전 원칙 (Dependency Inversion Principle, DIP)

📌 객체 지향 설계의 5가지 원칙(SOLID) 중 하나로, 상위 수준의 모듈(비즈니스 로직이나 주요 흐름을 결정하는 코드)과 하위 수준의 모듈(구체적인 구현 코드) 간의 의존성을 분리하고, 둘 다 추상화에 의존하도록 만드는 원칙입니다.

 

= 말이 어렵지만, 추상화=공통의 기능,주제는 따로 분리하는 것 / 의존성=특정 불변의 기능 혹은 상수에 의해 코드가 유연성이 떨어지는것

= 코드는 구체적인 클래스를 구성하기 전, 공통의 기능을 가진 요소에 대해 인터페이스로 미리 추상화를 거쳐야, 추후 응용이나 변형이 쉽다는 것, 더 쉽게 말하면 그냥 추상화를 잘 유념해라

핵심 개념

  1. 상위 모듈은 하위 모듈에 의존해서는 안 된다:
    • 상위 모듈은 구체적인 구현이 아닌, 추상화(인터페이스 또는 추상 클래스)에 의존해야 한다.
  2. 추상화는 세부사항에 의존하지 않는다:
    • 구체적인 구현(세부사항)이 추상화에 의존해야 하며, 그 반대는 아니다.

 

목표

  • 시스템을 더 유연하고 확장 가능하게 설계.
  • 변경이 필요한 경우, 한 모듈의 수정이 다른 모듈에 영향을 미치는 것을 방지.
  • 고수준의 로직과 저수준의 세부사항을 분리하여 시스템의 안정성과 재사용성 향상.

 

class FileLogger {
    public void log(String message) {
        System.out.println("Logging to a file: " + message);
    }
}

class Application {
    private FileLogger logger;

    public Application() {
        logger = new FileLogger(); // 구체적 구현에 의존
    }

    public void run() {
        logger.log("Application started.");
    }
}


-----------------------------------

interface Logger {
    void log(String message);
}

class FileLogger implements Logger {
    public void log(String message) {
        System.out.println("Logging to a file: " + message);
    }
}

class DatabaseLogger implements Logger {
    public void log(String message) {
        System.out.println("Logging to a database: " + message);
    }
}

class Application {
    private Logger logger;

    public Application(Logger logger) {
        this.logger = logger; // 추상화에 의존
    }

    public void run() {
        logger.log("Application started.");
    }
}

// 사용:
Logger logger = new FileLogger();
Application app = new Application(logger);
app.run();

Application 클래스는 FileLogger의 구체적인 구현에 의존하기 때문에 다른 로거로 변경하려면 Application 코드를 수정해야 합니다.

 

=> 이제 로깅 구현을 교체하려면 Logger를 구현하는 다른 클래스를 제공하기만 하면 된다. Application은 수정이 필요하지 않다.

 

의존성 역전 원칙과 의존성 주입

  • 의존성 역전 원칙은 설계 원칙이고, **의존성 주입(Dependency Injection)**은 이를 구현하는 방법론 중 하나입니다.
  • 의존성 주입 방식:
    1. 생성자 주입(Constructor Injection)
    2. 세터 주입(Setter Injection)
    3. 인터페이스 주입(Interface Injection)

적용 사례

  • DI 프레임워크: 스프링(Spring), 구아바(Guice) 등에서 제공하는 의존성 관리 기능은 DIP를 기반으로 설계되었습니다.
  • 디자인 패턴: 전략 패턴(Strategy Pattern), 팩토리 패턴(Factory Pattern) 등에서 활용됩니다.

 

실무에서는 추상화 과정에서 비용(시간)이 발생하기 때문에 기능을 확장할 가능성이 없다면 구현 클래스를 직접 사용하고 추후 변경된다면 인터페이스로 리팩토링 하면된다.

'기본기' 카테고리의 다른 글

클린 아키텍처 (Clean Architecture)  (1) 2025.02.23
테스트와 개발  (0) 2025.02.21
리팩토링  (0) 2025.02.15
백엔드 로드맵  (0) 2025.01.06
이름 짓기 규칙  (0) 2024.12.05

이름 짓기 규칙

항목 규칙/원칙 예시
클래스(Class) - 파스칼 케이스(PascalCase) 사용: 단어 첫 글자를 대문자로 시작.
- 명사 또는 명사구로 작성.
- 객체나 개념을 표현.
UserManager, OrderProcessor
인터페이스(Interface) - I로 시작하거나 클래스와 구분할 수 있는 명확한 이름 사용.
- 추상적인 동작을 나타냄.
IUserService, Orderable
메서드(Method) - 카멜 케이스(CamelCase) 사용: 소문자로 시작.
- 동작을 나타내는 동사형으로 작성.
- 명확하고 구체적인 동작을 표현.
getUser, calculateTotalPrice
변수(Variable) - 카멜 케이스(CamelCase) 사용.
- 의미가 명확한 명사형으로 작성.
- boolean은 is로 시작.
userList, totalCount, isActive
상수(Constant) - 대문자 스네이크 케이스(UPPER_SNAKE_CASE) 사용.
- 변경되지 않는 값을 표현.
MAX_RETRY_COUNT, DEFAULT_TIMEOUT
패키지/모듈 - 소문자로 작성하며, 필요하면 단어를 _로 연결.
- 짧고 구체적인 이름 사용.
user_management, data_utils
테스트 메서드 - should + 동작/조건 설명 형태로 작성.
- 테스트 목적이 드러나게 작성.
shouldReturnValidUser, shouldFailWhenInputInvalid
테스트 클래스 - 테스트 대상 클래스 이름 + Test 또는 Tests 접미사를 사용. UserServiceTest, OrderProcessorTests
테스트 폴더 - tests 또는 test 디렉토리로 명명. src/tests, project_name/test
파일/스크립트 - 짧고 명확한 소문자 형태로 작성.
- 필요하면 단어를 _로 연결.
app_config.py, user_service.js

 

 

'기본기' 카테고리의 다른 글

클린 아키텍처 (Clean Architecture)  (1) 2025.02.23
테스트와 개발  (0) 2025.02.21
리팩토링  (0) 2025.02.15
백엔드 로드맵  (0) 2025.01.06
Clean Cord  (4) 2024.12.05

+ Recent posts