new keyword in a constructor or at field declaration
Static method calls in a constructor or at field declaration
Anything more than field assignment in constructors
Object not fully initialized after the constructor finishes (watch out for initialize methods)
Control flow (conditional or looping logic) in a constructor
Code does complex object graph construction inside a constructor rather than using a factory or builder
Adding or using an initialization block
Warning Signs
Objects are passed in but never used directly (only used to get access to other objects)
Law of Demeter violation: method call chain walks an object graph with more than one dot (.)
Suspicious names: context, environment, principal, container, or manager
Warning Signs
Adding or using singletons
Adding or using static fields or static methods
Adding or using static initialization blocks
Adding or using registries
Adding or using service locators
Warning Signs
Summing up what the class does includes the word “and”
Class would be challenging for new team members to read and quickly “get it”
Class has fields that are only used in some methods
Class has static methods that only operate on parameters
Static methods: (or living in a procedural world):
🔎 왜 Static 메서드는 테스트하기 어려운가?
Polymorphism(다형성) 부재란?
Polymorphism(다형성): 동일한 메서드 호출이 다른 구현으로 동작할 수 있는 성질.
Static 메서드는 컴파일 시점에 호출 대상이 고정되어 있어 다형성을 제공하지 못함.
📊 1. 다형성의 차이: Static 메서드 vs 인스턴스 메서드
특징
Static 메서드
인스턴스 메서드
호출 시점
컴파일 시점 (compile-time binding)
실행 시점 (run-time binding)
다형성 지원 여부
❌ 불가능
✅ 가능 (오버라이딩, 인터페이스 구현)
변경 가능성
❌ 불변 (항상 같은 메서드 호출)
✅ 유연 (동적 구현 변경 가능)
모킹(Mock)
❌ 불가능
✅ 가능 (인터페이스 활용 시)
활용 예시
Math.abs(), Collections.sort()
PaymentService.processPayment()
🔍 2. Static 메서드는 다형성이 불가능한 이유
컴파일 시점 고정
Static 메서드는 클래스 레벨에서 정의되며, 객체 생성과 무관하게 동작.
호출 시점에 메서드 구현체가 고정되기 때문에, 다른 구현체로 변경 불가.
public class PaymentUtil {
public static void processPayment(double amount) {
System.out.println("Processing payment: " + amount);
}
}
public class Main {
public static void main(String[] args) {
PaymentUtil.processPayment(1000.0);
}
}
위 예제에서는 PaymentUtil.processPayment()를 호출할 때
항상 같은 메서드가 실행됨.
→ 런타임에 다른 구현으로 교체 불가.
🔄 3. 인스턴스 메서드는 다형성이 가능한 이유
실행 시점 바인딩
인스턴스 메서드는 객체 생성에 기반하며, 실행 시점에 적합한 구현체를 결정.
인터페이스나 추상 클래스로 정의한 후, 다양한 구현을 선택 가능.
// 인터페이스 정의
public interface PaymentService {
void processPayment(double amount);
}
// 구현체 1: 카드 결제
public class CardPaymentService implements PaymentService {
@Override
public void processPayment(double amount) {
System.out.println("Processing card payment: " + amount);
}
}
// 구현체 2: 계좌 이체
public class BankTransferService implements PaymentService {
@Override
public void processPayment(double amount) {
System.out.println("Processing bank transfer: " + amount);
}
}
// 런타임에 다른 구현 선택
public class Main {
public static void main(String[] args) {
PaymentService service = new CardPaymentService();
service.processPayment(1000.0); // 카드 결제 로직 실행
service = new BankTransferService();
service.processPayment(2000.0); // 계좌 이체 로직 실행
}
}
✅ 장점: PaymentService의 구현체를 런타임에 변경 가능.
Static 메서드로 작성했다면, 변경이 불가능함.
🔨 4. Static 메서드의 문제점: 테스트 어려움
문제점: Static 메서드는 외부 의존성을 가질 경우, Mocking 불가.
해결책: Static 메서드를 인터페이스로 변경해 의존성 주입(DI) 활용.
❌ 잘못된 예제: Static 메서드로 결제 로직 구현
public class PaymentUtil {
public static boolean processPayment(double amount) {
// 외부 결제 서비스 연동 (모킹 불가)
return PaymentGateway.charge(amount);
}
}
문제: PaymentGateway는 Static 메서드라 Mocking이 불가능.
해결책: 인터페이스로 변경하여 다형성 확보.
✅ 인스턴스 메서드로 Mocking 가능한 테스트 (Mockito) 예제
📌 1. 결제 로직 코드
// 결제 처리 인터페이스
public interface PaymentProcessor {
boolean processPayment(double amount);
}
// 실제 결제 구현체
public class RealPaymentProcessor implements PaymentProcessor {
@Override
public boolean processPayment(double amount) {
return PaymentGateway.charge(amount); // 외부 결제 서비스 호출
}
}
// 결제 서비스 (의존성 주입 사용)
public class PaymentService {
private final PaymentProcessor paymentProcessor;
public PaymentService(PaymentProcessor paymentProcessor) {
this.paymentProcessor = paymentProcessor;
}
public boolean process(double amount) {
return paymentProcessor.processPayment(amount);
}
}
📊 2. Mocking 가능한 테스트 코드
import static org.mockito.Mockito.*;
import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.Test;
public class PaymentServiceTest {
@Test
public void testProcessPayment() {
// 1. PaymentProcessor의 Mock 객체 생성
PaymentProcessor mockProcessor = mock(PaymentProcessor.class);
// 2. Mock 객체의 동작 정의
when(mockProcessor.processPayment(1000.0)).thenReturn(true);
// 3. PaymentService에 Mock 주입
PaymentService paymentService = new PaymentService(mockProcessor);
// 4. 메서드 실행 및 결과 검증
assertTrue(paymentService.process(1000.0));
// 5. Mock 객체 호출 검증
verify(mockProcessor).processPayment(1000.0);
}
}
📌 5. 언제 Static 메서드를 사용해도 괜찮은가?
순수 함수(Pure Function): 외부 의존성 없음, 입력 → 출력만 반환.
예: Math.abs(), Objects.requireNonNull()
유틸리티 메서드: 상태를 변경하지 않고, 보조 기능만 수행.
예: Collections.sort(), LocalDate.now()
✅ Static으로 작성해도 괜찮은 예:
public class MathUtils {
public static int square(int number) {
return number * number;
}
}
📊 정리: Static 메서드 vs 인스턴스 메서드
구분
Static 메서드
인스턴스 메서드
다형성 (Polymorphism)
❌ 불가능 (컴파일 시점에 고정)
✅ 가능 (런타임에 구현체 변경 가능)
테스트(Mock)
❌ 불가능 (외부 의존성 모킹 불가)
✅ 가능 (의존성 주입으로 모킹 용이)
유연성
❌ 구현 변경 불가
✅ 다양한 구현을 런타임에 선택 가능
확장성
❌ 기능 확장 어려움
✅ 새로운 구현 추가 용이
사용 사례
순수 함수, 유틸리티 (예: Math.abs())
비즈니스 로직, 외부 의존성 (예: PaymentService)
7. "Favor Composition Over Inheritance"
📊 1. 핵심 개념
상속(Inheritance): 부모 클래스를 확장(extends)해 기능을 재사용
단점: 부모 클래스에 의존 → 변경이 어려움, 테스트 복잡성 증가
합성(Composition): 필요한 기능을 **객체 주입(Dependency Injection, DI)**으로 결합
장점: 유연성 증가, Mocking을 통한 테스트 용이
📌 2. 문제점: 상속을 남용하면 생기는 문제
테스트 어려움: 부모 클래스 로직까지 모두 실행 → 독립적 테스트 불가
유연성 부족: 런타임에 부모 클래스를 변경 불가
복잡성 증가: 부모-자식-손자 관계로 갈수록 복잡해짐
❌ 잘못된 예제: 상속 남용
// 부모 클래스: 인증 로직 포함 (테스트 어려움)
public class AuthenticatedServlet {
public void service() {
authenticate(); // 인증 로직 (항상 실행됨)
handleRequest();
}
private void authenticate() {
System.out.println("🔴 사용자 인증 수행");
}
protected void handleRequest() {
System.out.println("🔴 기본 요청 처리");
}
}
// 자식 클래스: 부모의 인증 로직 상속
public class UserServlet extends AuthenticatedServlet {
@Override
protected void handleRequest() {
System.out.println("🟡 사용자 정보 처리");
}
}
📉 문제점
UserServlet 테스트 시 항상 authenticate() 실행됨
테스트하려면 인증 로직까지 Mocking해야 해서 복잡해짐
부모-자식이 강하게 결합 → 유연성 부족
✅ 3. 해결책: 합성(Composition) 사용
상속 대신 인증 로직을 인터페이스로 분리하고 의존성 주입(DI) 활용!
🟢 리팩토링: 합성 적용
// 1. 인증 로직을 인터페이스로 분리
public interface Authenticator {
void authenticate();
}
// 2. 실제 인증 구현체
public class RealAuthenticator implements Authenticator {
@Override
public void authenticate() {
System.out.println("✅ 실제 사용자 인증 수행");
}
}
// 3. UserServlet에서 인증을 합성(주입)으로 사용
public class UserService {
private final Authenticator authenticator;
public UserService(Authenticator authenticator) {
this.authenticator = authenticator;
}
public void process() {
authenticator.authenticate(); // 인증 수행
System.out.println("🟢 사용자 정보 처리");
}
}
✅ 4. 테스트: Mocking으로 독립적 테스트
import static org.mockito.Mockito.*;
import org.junit.jupiter.api.Test;
public class UserServiceTest {
@Test
public void testProcess() {
// 1. Authenticator의 Mock 객체 생성
Authenticator mockAuthenticator = mock(Authenticator.class);
// 2. UserService에 Mock 객체 주입
UserService userService = new UserService(mockAuthenticator);
// 3. 메서드 실행
userService.process();
// 4. Mock 객체 호출 검증 (인증 로직만 확인 가능)
verify(mockAuthenticator).authenticate();
}
}