다형성
다음은 코드를 통한 예시다.
아래의 코드에서 Animal 인터페이스는 sound()라는 메서드를 정의하고 있다. 이는 모든 동물이 소리를 내는 것을 추상화한 것이다.
Dog와 Cat 클래스는 이 Animal 인터페이스를 구현(implements)하고, 각자의 방식으로 sound 메서드를 오버라이드(@Override)한다.
이렇게 하면, 같은 Animal 인터페이스를 가지고(구현하고) 있지만 Dog와 Cat은 각자 다른 소리를 낼 수 있다.
public interface Animal {
void sound();
}
public class Dog implements Animal {
@Override
public void sound() {
System.out.println("Woof!");
}
}
public class Cat implements Animal {
@Override
public void sound() {
System.out.println("Meow!");
}
}
위의 인터페이스 사용예시
myDog와 myCat은 모두 Animal 타입의 참조 변수이지만, 각각 Dog와 Cat의 인스턴스를 참조하고 있다. 이 때 sound 메서드를 호출하면, 참조하고 있는 실제 객체의 sound 메서드가 호출된다.
이렇게 인터페이스를 통해 서로 다른 동작을 하는 객체를 동일하게 다룰 수 있게 되는 것이 다형성이다. 이로 인해 코드는 보다 유연해지고 확장성을 가지게 된다.
public class Main {
public static void main(String[] args) {
Animal myDog = new Dog();
Animal myCat = new Cat();
myDog.sound(); // Prints "Woof!"
myCat.sound(); // Prints "Meow!"
}
}
개방-폐쇄 원칙 OCP
개방-폐쇄 원칙(OCP)은 "소프트웨어 개체(클래스, 모듈, 함수 등등)는 확장에 대해서는 열려 있어야 하고, 수정에 대해서는 닫혀 있어야 한다"는 소프트웨어 디자인 원칙이다.
이는 기존 코드의 변경 없이 새로운 기능을 추가하거나 기존 기능을 변경할 수 있도록 해준다.
코드 예시
public class Product {
private String name;
private double price;
// constructors, getters and setters...
}
public class ProductSorter {
public List<Product> sortByPrice(List<Product> products) {
// sort products by price and return
}
}
위의 예시코드에서 상품을 이름으로 정렬하고 싶다면 ProductSorter에 sortByName() 메서드를 추가할 수 있다.
하지만 이렇게 되면 ProductSorter는 계속해서 변경되어야 한다.
이런 방식은 OCP를 위반하게 되는것이다. OCP를 적용하기 위해, 우선 정렬 방식을 인터페이스로 추상화시키자
// 인터페이스를 만들어서 추상화시켰다.
public interface ProductSortStrategy {
List<Product> sort(List<Product> products);
}
public class PriceSortStrategy implements ProductSortStrategy {
@Override
public List<Product> sort(List<Product> products) {
// sort by price and return
}
}
public class NameSortStrategy implements ProductSortStrategy {
@Override
public List<Product> sort(List<Product> products) {
// sort by name and return
}
}
public class ProductSorter {
private ProductSortStrategy strategy;
public ProductSorter(ProductSortStrategy strategy) {
this.strategy = strategy;
}
public List<Product> sort(List<Product> products) {
return strategy.sort(products);
}
}
이렇게 코드를 작성하면, ProductSorter는 정렬 방식에 따라 변경될 필요가 없게 된다. 새로운 정렬 방식이 필요하다면 새로운 ProductSortStrategy 구현체를 만들기만 하면 된다.
이것이 바로 OCP를 잘 따르는 코드인것이다.
1. 테스트 용이성
ProductService라는 서비스 클래스가 있고, 이 클래스는 ProductDao라는 DAO 인터페이스에 의존한다고 가정하자.
이 경우, 단위 테스트를 수행할 때 ProductDao의 실제 구현체를 사용하는 대신에, 모의 객체(Mock Object)를 만들어 사용할 수 있다. 이렇게 하면 데이터베이스 연결과 같은 외부 종속성 없이 ProductService의 비즈니스 로직만 테스트를 할수가 있다.
@Test
public void testGetProductDetails() {
ProductDao mockProductDao = mock(ProductDao.class);
ProductService productService = new ProductService(mockProductDao);
Product product = new Product(1L, "Test product");
when(mockProductDao.findById(1L)).thenReturn(product);
Product result = productService.getProductDetails(1L);
assertEquals(product, result);
}
2. 유연성
ProductDao 인터페이스의 두 가지 구현체, 예를 들어 ProductDaoJdbcImpl (JDBC를 사용하는 구현체)와 ProductDaoJpaImpl (JPA를 사용하는 구현체)가 있다고 가정해 보자.
이러한 경우, 데이터베이스 액세스 기술을 변경하려면 애플리케이션 코드를 변경할 필요 없이 구현체만 교체하면 된다.
3. 다형성
같은 Shape 인터페이스를 구현하는 Circle 클래스와 Rectangle 클래스가 있다고 가정하자. 이 인터페이스에는 draw()라는 메서드가 있다. 이 경우, draw() 메서드는 Circle과 Rectangle에서 서로 다른 방식으로 구현될 수 있다.
실행 시점에서 어떤 객체를 사용하냐에 따라 draw() 메서드의 동작이 달라진다.
public interface Shape {
void draw();
}
public class Circle implements Shape {
@Override
public void draw() {
System.out.println("Drawing a circle");
}
}
public class Rectangle implements Shape {
@Override
public void draw() {
System.out.println("Drawing a rectangle");
}
}
public class ShapeDrawer {
public void drawShape(Shape shape) {
shape.draw();
}
}
// In a client code
ShapeDrawer drawer = new ShapeDrawer();
Shape shape = new Circle(); // or new Rectangle();
drawer.drawShape(shape);
따라서, 인터페이스는 다형성을 지원하고, 코드의 유연성과 테스트 용이성을 높이며, 객체 간의 느슨한 결합(loose coupling)을 허용한다.
이러한 요소들은 전체적으로 소프트웨어의 품질을 높이고 유지보수를 용이하게 한다.
스프링 프레임워크에서는 이러한 인터페이스의 장점을 최대한 활용하여 효과적인 소프트웨어 설계와 구현을 지원한다.
스프링 프레임워크에서는 인터페이스를 적극적으로 활용하여 이 원칙을 구현한다.
예를 들어, DAO(Data Access Object)나 @Service와 같은 스프링 빈들은 특정 인터페이스를 구현한다.
이렇게 하면 인터페이스를 통해 다른 컴포넌트와 상호작용하며, 실제 구현체의 세부 사항에 대해 알 필요 없이 메서드를 호출할 수 있다.
이는 코드의 결합도(coupling)를 낮추고, 변경에 대한 영향을 최소화하여 유지보수성을 높인다.
인터페이스 활용예시1
public interface ProductDao {
List<Product> findAll();
Product findById(Long id);
// Other CRUD methods...
}
@Repository
public class ProductDaoImpl implements ProductDao {
@Autowired
private JdbcTemplate jdbcTemplate;
@Override
public List<Product> findAll() {
// Implementation of findAll...
}
@Override
public Product findById(Long id) {
// Implementation of findById...
}
// Other CRUD methods...
}
@Service
public class ProductService {
private final ProductDao productDao;
@Autowired
public ProductService(ProductDao productDao) {
this.productDao = productDao;
}
public List<Product> getAllProducts() {
return productDao.findAll();
}
public Product getProductById(Long id) {
return productDao.findById(id);
}
// Other methods...
}
이 코드에서 ProductService는 ProductDao 인터페이스에 의존하고 있으며, 구체적인 구현체인 ProductDaoImpl에 대해서는 알 필요가 없다.
이렇게 인터페이스를 통해 DAO의 구현을 추상화하면, 데이터 액세스 기술이 변경되더라도 ProductService는 변경될 필요가 없다.
인터페이스 활용예시2
JdbcTemplate를 사용하는 ProductDaoImpl과 JPA를 사용하는 ProductDaoJPAImpl를 생성할 수 있다.
@Repository
public class ProductDaoJPAImpl implements ProductDao {
@PersistenceContext
private EntityManager entityManager;
@Override
public List<Product> findAll() {
// JPA Implementation of findAll...
}
@Override
public Product findById(Long id) {
// JPA Implementation of findById...
}
// Other CRUD methods...
}
스프링 설정에서 어떤 구현체를 빈으로 등록할지 결정하면 된다. 예를 들어, JPA 구현체를 사용하려면 ProductDaoJPAImpl를 빈으로 등록하면 된다.
이 방식은 코드의 유연성을 높이고, 기술 스택의 변경에 따른 영향을 최소화한다. 이는 개방-폐쇄 원칙(Open/Closed Principle)에 따라 기존 코드의 수정 없이 새로운 기능을 추가하거나 기존 기능을 변경할 수 있도록 해주기 때문이다.