자바의 "추상화"의 개념을 알아보고 스프링을 이것을 어떻게 사용하는지 알아보자
📌 서론
항상 추상화를 사용하고 있지만 누군가 "그래서 대체 추상화가 뭔데?"라고 물어볼때마다 바로바로 떠오르지 않았고 많은 고민을 해야했다. 이런 점에서 알지도 못하면서 사용하고 있다는 점에 답답함이 느껴져서 정리를 시작했다.
1. 추상화(Abstraction)의 기본 개념
추상화란?
- 추상화는 복잡한 시스템을 단순화하는 프로그래밍 기술이다. 이는 개발자가 복잡한 내부 작업을 몰라도 기능을 사용할 수 있게 해준다.
왜 필요한가?
- 복잡한 시스템을 이해하고 사용하기 위해서는 많은 시간과 노력이 필요하다. 추상화를 통해 이러한 복잡성을 줄이고, 빠르게 그리고 안전하게 시스템을 사용할 수 있다.
실생활 예시
- 예를 들어, 자동차를 운전할 때 운전자는 엔진이 어떻게 작동하는지, 브레이크가 어떻게 구성되어 있는지 알 필요가 없다. 운전자는 단순히 핸들, 가속페달, 브레이크 페달 등의 인터페이스를 통해 자동차를 제어한다. 이처럼 추상화는 복잡한 내부 로직을 감추고 사용자에게 필요한 기능만을 제공한다.
프로그래밍에서의 예시
- 파일을 저장하는 기능을 생각해 보자. 개발자는 하드 디스크의 어떤 섹터에 데이터를 어떻게 쓰는지 알 필요가 없다. 대신에, saveFile()이라는 함수를 호출하면 된다. 이 함수는 내부적으로 모든 복잡한 작업을 처리해 준다.
public void saveFile(String data) {
// 복잡한 로직
}
2. 추상화(Abstraction)의 종류
데이터 추상화
- 데이터 추상화는 복잡한 데이터 구조를 단순한 인터페이스로 변환하여 사용자에게 제공하는 것이다.
- 예시: JpaRepository를 사용한 CRUD 연산. 사용자는 복잡한 쿼리 작업 없이 간단한 메서드 호출로 데이터를 읽거나 쓸 수 있다.
public interface UserRepository extends JpaRepository<User, Long> {
User findByUsername(String username);
}
함수 추상화
- 함수 추상화는 함수의 내부 로직을 숨기고, 함수의 시그니처만을 공개하여 사용자가 쉽게 사용할 수 있도록 하는 것이다.
- 예시: 스프링의 @Transactional로 트랜잭션 관리. 사용자는 복잡한 트랜잭션 로직을 몰라도 이 애노테이션을 사용하여 트랜잭션을 관리할 수 있다.
@Transactional
public void registerUser(User user) {
userRepository.save(user);
}
객체 추상화
- 객체 추상화는 객체 지향 프로그래밍에서 클래스의 내부 구현을 숨기고, 필요한 속성과 메서드만을 노출하는 것이다.
- 예시: 스프링의 @Service나 @Repository로 객체의 역할을 정의한다.
@Service
public class UserService {
// 서비스 로직
}
프로세스 추상화
- 프로세스 추상화는 복잡한 시스템 프로세스를 단순한 단계로 나누어 사용자가 이해하기 쉽게 만드는 것이다.
- 예시: AWS Lambda로 서버리스 아키텍처 구현. 사용자는 서버 관리 없이 코드만 업로드하여 실행할 수 있다.
하드웨어 추상화
- 하드웨어 추상화는 하드웨어의 복잡한 작동 원리를 감추고, 사용자에게 간단한 인터페이스를 제공하는 것이다.
- 예시: AWS의 EC2나 S3 서비스로 하드웨어 관리 없이 서비스 이용. 사용자는 물리적인 서버나 스토리지 관리 없이 클라우드에서 자원을 할당받아 사용할 수 있다.
3. 추상화의 단계
저수준 추상화 (Low-Level Abstraction)
- 저수준 추상화는 기본적인 연산이나 단순한 기능을 감추고 사용자에게 제공하는 것이다. 이 단계에서는 주로 하드웨어와 가까운 작업이 이루어진다.
- 예시: 파일을 읽고 쓰는 기본적인 연산이 이에 해당한다. 개발자는 하드 디스크의 섹터에 직접 접근할 필요 없이, readFile()이나 writeFile() 같은 함수를 사용할 수 있다.
// Java에서의 파일 읽기 로직
public String readFile(String fileName) throws IOException {
Path path = Paths.get(fileName);
byte[] bytes = Files.readAllBytes(path);
return new String(bytes, StandardCharsets.UTF_8);
}
고수준 추상화 (High-Level Abstraction)
- 고수준 추상화는 저수준 추상화를 기반으로 더 복잡한 로직을 구현한다. 이 단계에서는 사용자가 특정 작업을 수행하기 위해 여러 저수준 작업을 조합해야 하는 경우가 많다.
- 예시: 데이터베이스에서 특정 조건에 맞는 데이터를 찾아서 정렬하고, 결과를 반환하는 것이 이에 해당한다. 이러한 작업을 findAndSortData()라는 하나의 함수로 추상화할 수 있다.
고수준 추상화 예시코드 작성
조건을 나타내는 Condition 클래스 선언
class Condition {
String field;
String operator;
Object value;
// getters and setters
}
데이터를 나타내는 Data 클래스 선언
class Data {
String field;
// getters and setters
}
데이터베이스 로직을 담당하는 Database 클래스 선언
class Database {
public List<Data> getData(Condition condition) {
// 데이터베이스에서 조건에 맞는 데이터를 가져오는 로직
// 여기서는 예시로 null을 반환합니다.
return null;
}
}
고수준의 추상화를 담당하는 Example 클래스 선언
- 이 예시에서 Condition 클래스는 데이터베이스 쿼리의 조건을 나타내고 Data 클래스는 데이터베이스에서 가져온 데이터를 나타낸다. Database 클래스는 데이터베이스에서 데이터를 가져오는 로직을 담당하고, Example 클래스에서는 이를 고수준으로 추상화하여 findAndSortData() 메서드를 제공한다. 이렇게 하면 사용자는 복잡한 데이터베이스 로직을 몰라도 원하는 데이터를 쉽게 가져올 수 있다.
public class Example {
private Database database = new Database();
public List<Data> findAndSortData(Condition condition) {
// 데이터베이스에서 조건에 맞는 데이터를 가져옵니다.
List<Data> dataList = database.getData(condition);
// 가져온 데이터를 정렬합니다.
dataList.sort(Comparator.comparing(Data::getField));
// 정렬된 데이터를 반환합니다.
return dataList;
}
}
사용자는 findAndSortData() 이것만 가져다 쓰면 된다.
- findAndSortData() 메서드는 고수준의 추상화를 제공하기 때문에, 사용자는 이 메서드를 호출하면서 필요한 조건(Condition)을 전달하기만 하면 된다. 메서드 내부에서는 데이터를 찾고 정렬하는 등의 복잡한 로직이 수행되지만, 사용자는 그 과정을 몰라도 된다. 이렇게 하면 사용자는 더 간단하고 직관적인 방법으로 원하는 기능을 사용할 수 있다.
예를 들어
- 아래의 코드에서 Example 클래스의 findAndSortData() 메서드를 호출하면, 내부적으로는 Database 클래스의 getData() 메서드를 사용하여 데이터를 가져오고, 그 후에 정렬을 수행한다. 하지만 사용자는 findAndSortData() 메서드를 호출하기만 하면 되므로, 내부 로직을 몰라도 원하는 작업을 쉽게 수행할 수 있다.
Example example = new Example();
Condition condition = new Condition();
// condition 설정 로직
List<Data> sortedData = example.findAndSortData(condition);
안정성과 테스트
- 추상화된 코드는 복잡한 로직을 간단한 인터페이스로 제공하기 위해 존재한다. 이 코드는 내부 로직에 대한 충분한 테스트와 검증을 거쳐야 사용자(개발자)가 안정적으로 사용할 수 있다.
근데 왜 안정성이 중요한지 궁금할 수 있다.
- 오류가 있는 추상화 코드는 전체 시스템에 문제를 일으킬 수 있다.
- 사용자(개발자)는 추상화된 부분의 세부 로직을 몰라도 되므로, 그 코드가 안정적이어야 한다.
- 안정성이 검증되지 않은 추상화된 코드를 사용하면, 예측하지 못한 버그나 시스템의 불안정을 초래할 수 있다. 이는 결국 프로젝트의 지연과 추가적인 비용을 발생시킬 수 있으므로, 검증되지 않은 코드는 사용을 피해야 한다.
그럼 안정성을 어떻게 확보해야 할까?
코드 리뷰
- 다른 개발자들이 코드를 검토하여 문제점을 찾고 수정하는 과정. 팀원 간의 지식 공유와 코드 품질 향상에 기여한다.
단위 테스트/통합 테스트/E2E 테스트 (End-to-End 테스트)/성능 테스트
- 개별 함수나 메서드의 동작을 검증하는 테스트. 코드의 각 부분이 올바르게 동작하는지 확인한다.
// JUnit을 사용한 단위 테스트 예시
@Test
public void testAddition() {
Calculator calculator = new Calculator();
assertEquals(5, calculator.add(2, 3));
}
4. 추상화의 장단점
추상화의 장점
모듈성 (Modularity)
- 추상화를 통해 시스템을 여러 개의 독립적인 모듈로 나눌 수 있다. 이로 인해 개발과 유지보수가 쉬워진다.
- 예시: Spring Framework에서는 다양한 모듈을 제공하여 개발자가 필요한 부분만 선택해서 사용할 수 있다.
코드 재사용성 (Code Reusability)
- 공통 로직을 추상화하여 여러 곳에서 재사용할 수 있다.
- 예시: StringUtils 클래스의 isEmpty 메서드와 같이, 일반적인 문자열 검사 로직을 한 곳에 모아 재사용할 수 있다.
유연성 (Flexibility)
- 추상화를 통해 구체적인 구현을 숨기고 인터페이스만 제공하면, 나중에 구현 방법을 변경해도 외부에 영향을 주지 않는다.
- 예시: JDBC 인터페이스는 데이터베이스 연결을 추상화해 놓았기 때문에, 다양한 데이터베이스에 동일한 코드로 접근할 수 있다.
추상화의 단점
성능 이슈 (Performance Issues)
- 추상화 계층이 많아질수록, 성능이 저하될 수 있다.
- 예시: ORM(Object-Relational Mapping)을 사용할 때, SQL 쿼리가 최적화되지 않아 성능이 저하될 수 있다.
복잡성 (Complexity)
- 추상화를 너무 많이 적용하면, 시스템이 복잡해질 수 있다.
- 예시: 마이크로서비스 아키텍처에서 너무 많은 서비스가 추상화되면, 전체 시스템을 이해하기 어려워질 수 있다.
오버헤드 (Overhead)
- 추상화를 통한 추가적인 레이어나 클래스는 시스템에 오버헤드를 발생시킬 수 있다.
- 예시: 스프링 AOP(Aspect-Oriented Programming)를 사용할 때, 프록시 객체가 생성되어 메서드 호출에 추가적인 오버헤드가 발생할 수 있다.
의존성 문제 (Dependency Issues)
- 추상화된 라이브러리나 프레임워크에 너무 의존하게 되면, 그것의 변경사항에 취약해질 수 있다.
- 예시: 특정 라이브러리의 메서드를 너무 많이 사용하다가 그 라이브러리가 업데이트되면서 해당 메서드가 사라지면 큰 문제가 발생할 수 있다.
학습 곡선 (Learning Curve)
- 추상화된 시스템은 처음에 익히기 어려울 수 있다.
- 예시: Spring Framework를 처음 사용할 때, 다양한 어노테이션과 설정 방법을 익혀야 하는 학습 곡선이 있다.
5. 실제 사례
Java/Spring 관련
Spring Security
- Spring Security는 웹 애플리케이션의 인증과 권한 부여를 추상화하여 제공한다.
- 예시: 개발자는 복잡한 인증 로직을 직접 구현할 필요 없이, @PreAuthorize와 같은 어노테이션을 사용하여 간단하게 접근 제어를 할 수 있다.
Spring Data JPA
- Spring Data JPA는 데이터베이스 CRUD(Create, Read, Update, Delete) 연산을 추상화하여 제공한다.
- 예시: JpaRepository 인터페이스를 상속받아 기본 CRUD 메서드를 자동으로 사용할 수 있다.
Spring Cloud
- Spring Cloud는 마이크로서비스 아키텍처에서 필요한 여러 기능(서비스 디스커버리, 로드 밸런싱 등)을 추상화한다.
- 예시: @LoadBalanced 어노테이션을 사용하여 로드 밸런싱을 쉽게 구현할 수 있다.
6. 왜 추상화가 중요한가?
코드의 품질과 유지보수
코드 재사용성
- 추상화를 통해 공통 로직을 한 곳에 모아두면, 코드의 재사용성이 높아진다.
- 예시: 유틸리티 클래스나 라이브러리를 만들어 여러 프로젝트에서 공통 로직을 재사용할 수 있다.
유지보수성
- 내부 구현이 변경되더라도 추상화된 인터페이스는 그대로이므로, 유지보수가 쉽다.
- 예시: 데이터베이스 엔진을 변경해도, 추상화된 데이터 접근 레이어 덕분에 애플리케이션 코드는 그대로 유지될 수 있다.
시스템 설계와 확장
확장성
- 추상화를 통해 새로운 클래스나 모듈을 쉽게 추가할 수 있다.
- 예시: 인터페이스를 구현하는 새로운 클래스를 추가함으로써 기능을 확장할 수 있다.
PaymentProcessor 인터페이스를 구현하여 다양한 결제 방식을 쉽게 확장할 수 있다.
// PaymentProcessor 인터페이스
public interface PaymentProcessor {
void processPayment(double amount);
}
// CreditCardProcessor 클래스
public class CreditCardProcessor implements PaymentProcessor {
@Override
public void processPayment(double amount) {
// 신용카드 결제 로직
}
}
// PayPalProcessor 클래스
public class PayPalProcessor implements PaymentProcessor {
@Override
public void processPayment(double amount) {
// PayPal 결제 로직
}
}
의존성 분리
- 각 컴포넌트는 추상화된 인터페이스만을 알기 때문에, 의존성이 낮아진다.
- 예시: DAO 패턴을 사용하여 데이터베이스 의존성을 분리할 수 있다.
UserDao 인터페이스를 통해 다양한 데이터베이스에서 유저 정보를 가져올 수 있다. 이를 통해 데이터베이스 의존성이 분리된다.
// UserDao 인터페이스
public interface UserDao {
User getUserById(int id);
}
// MySqlUserDao 클래스
public class MySqlUserDao implements UserDao {
@Override
public User getUserById(int id) {
// MySQL에서 유저 정보 가져오기
return new User();
}
}
// OracleUserDao 클래스
public class OracleUserDao implements UserDao {
@Override
public User getUserById(int id) {
// Oracle에서 유저 정보 가져오기
return new User();
}
}
테스트와 검증 (Spring 예시)
- Spring에서는 일반적으로 구현체를 기준으로 테스트를 작성한다. 그러나 인터페이스를 사용하여 테스트를 작성하는 것이 더 유연하고 재사용성이 높을 수 있다. 이를 위해 Spring의 @MockBean과 같은 기능을 사용하여 Mock 객체를 쉽게 생성할 수 있다.
테스트 코드 예시
- 아래의 코드 예시는 Spring에서 PaymentService라는 인터페이스를 사용하여 테스트를 작성하는 방법을 보여준다.
- 이렇게 하면, PaymentService 인터페이스를 구현하는 모든 클래스에 대해 이 테스트 코드를 적용할 수 있다. 나중에 새로운 결제 방식이 추가되더라도 테스트 코드는 그대로 유지될 수 있다.
public interface PaymentService {
void processPayment(double amount);
}
@SpringBootTest
public class PaymentServiceTest {
@Autowired
private PaymentService paymentService;
@MockBean
private PaymentService mockPaymentService;
@Test
public void testProcessPayment() {
double amount = 100.0;
mockPaymentService.processPayment(amount);
// Add verification and assertions
}
}
📌 마무리
여기까지가 잠깐이지만 내가 알아본 자바와 스프링의 추상화 개념이다. 중간중간 개발을 진행하면서 당연시 사용하던 @Transactional도 있었고 Mybatis를 사용할 때 봤던 DAO에서 interface를 통해서 데이터베이스에 연결해서 정보를 받아오던 방식도 있었다.
이렇게 조사를 해보며 내가 항상 사용했던 편리한 기능들은 주로 추상화 되어 있었다는 것을 알게되었다. 다음에 시간을 내서 조금 더 탐구를 진행하여 추상화의 깊은 이해를 해볼 예정이다.
'JAVA' 카테고리의 다른 글
[Java] 자바 리플렉션(Reflection) 실습하기 (1) | 2023.11.18 |
---|---|
[Java] 자바 리플렉션(reflection)이란? (1) | 2023.11.15 |
Java I/O: BufferedReader, BufferedWriter, Buffer 사용법 (0) | 2023.11.01 |
[Java] 동시성과 병렬 처리 part2: 함정, 고급 패턴, 성능 최적화 (1) | 2023.11.01 |
[Java] 동시성과 병렬 처리 part1 (1) | 2023.10.31 |