본문 바로가기
카테고리 없음

Java DAO 패턴 가이드 - 데이터 접근을 효율적으로 관리하는 방법

by silvertogold100 2025. 8. 13.
반응형

들어가며

현대적인 Java 애플리케이션 개발에서 DAO(Data Access Object) 패턴은 데이터베이스와 비즈니스 로직을 분리하는 핵심적인 설계 패턴입니다. 이 패턴을 올바르게 사용하면 코드의 유지보수성, 테스트 가능성, 그리고 확장성을 크게 향상시킬 수 있습니다.

1. DAO 패턴이란?

DAO의 정의와 목적

**DAO(Data Access Object)**는 데이터 저장소(데이터베이스, 파일 등)에 접근하는 로직을 캡슐화하는 디자인 패턴입니다. 주요 목적은 다음과 같습니다:

  • 관심사의 분리: 비즈니스 로직과 데이터 접근 로직을 분리
  • 코드 재사용성: 데이터 접근 로직을 여러 곳에서 재사용 가능
  • 유지보수성: 데이터 접근 방식이 변경되어도 비즈니스 로직은 영향받지 않음
  • 테스트 용이성: Mock 객체를 통한 단위 테스트 가능

DAO 패턴의 구조

┌─────────────────┐    uses    ┌──────────────┐    implements    ┌─────────────────┐
│  Client Code    │ ────────> │ DAO Interface │ ←──────────────── │ DAO Implementation │
│ (Business Logic)│            │              │                   │ (Data Access Logic)│
└─────────────────┘            └──────────────┘                   └─────────────────┘
                                       ↑                                     ↓
                                       │                                     │
                               ┌──────────────┐                   ┌─────────────────┐
                               │ Domain Model │                   │   Data Source   │
                               │   (Entity)   │                   │   (Database)    │
                               └──────────────┘                   └─────────────────┘

2. 실전 DAO 패턴 구현

2.1 도메인 객체 (Domain Object) 설계

먼저 데이터베이스 테이블과 매핑되는 도메인 모델을 정의합니다.

// User.java
package com.example.domain;

import java.time.LocalDateTime;

public class User {
    private Long id;
    private String name;
    private String email;
    private LocalDateTime createdAt;
    private LocalDateTime updatedAt;

    // 기본 생성자
    public User() {}

    // 모든 필드를 받는 생성자
    public User(Long id, String name, String email) {
        this.id = id;
        this.name = name;
        this.email = email;
        this.createdAt = LocalDateTime.now();
        this.updatedAt = LocalDateTime.now();
    }

    // 새 사용자 생성용 생성자 (ID 없음)
    public User(String name, String email) {
        this.name = name;
        this.email = email;
        this.createdAt = LocalDateTime.now();
        this.updatedAt = LocalDateTime.now();
    }

    // Getter와 Setter
    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    
    public String getName() { return name; }
    public void setName(String name) { 
        this.name = name;
        this.updatedAt = LocalDateTime.now();
    }
    
    public String getEmail() { return email; }
    public void setEmail(String email) { 
        this.email = email;
        this.updatedAt = LocalDateTime.now();
    }
    
    public LocalDateTime getCreatedAt() { return createdAt; }
    public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
    
    public LocalDateTime getUpdatedAt() { return updatedAt; }
    public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }

    // 비즈니스 로직 메서드
    public boolean isValidEmail() {
        return email != null && email.contains("@") && email.contains(".");
    }

    @Override
    public String toString() {
        return String.format("User{id=%d, name='%s', email='%s', createdAt=%s}", 
                           id, name, email, createdAt);
    }

    @Override
    public boolean equals(Object obj) {
        if (this == obj) return true;
        if (obj == null || getClass() != obj.getClass()) return false;
        User user = (User) obj;
        return id != null ? id.equals(user.id) : user.id == null;
    }

    @Override
    public int hashCode() {
        return id != null ? id.hashCode() : 0;
    }
}

2.2 DAO 인터페이스 설계

CRUD 작업과 비즈니스 요구사항을 고려한 인터페이스를 정의합니다.

// UserDao.java
package com.example.dao;

import com.example.domain.User;
import java.util.List;
import java.util.Optional;

/**
 * User 엔티티에 대한 데이터 접근 인터페이스
 * 데이터 저장소에 대한 CRUD 작업을 추상화합니다.
 */
public interface UserDao {
    
    /**
     * 모든 사용자를 조회합니다.
     * @return 모든 사용자 목록
     */
    List<User> findAll();
    
    /**
     * ID로 사용자를 조회합니다.
     * @param id 사용자 ID
     * @return 사용자 정보 (Optional)
     */
    Optional<User> findById(Long id);
    
    /**
     * 이메일로 사용자를 조회합니다.
     * @param email 이메일 주소
     * @return 사용자 정보 (Optional)
     */
    Optional<User> findByEmail(String email);
    
    /**
     * 이름으로 사용자들을 검색합니다.
     * @param name 검색할 이름 (부분 일치)
     * @return 검색 결과 사용자 목록
     */
    List<User> findByNameContaining(String name);
    
    /**
     * 새로운 사용자를 저장합니다.
     * @param user 저장할 사용자 정보
     * @return 저장된 사용자 정보 (ID 포함)
     */
    User save(User user);
    
    /**
     * 사용자 정보를 업데이트합니다.
     * @param user 업데이트할 사용자 정보
     * @return 업데이트 성공 여부
     */
    boolean update(User user);
    
    /**
     * 사용자를 삭제합니다.
     * @param id 삭제할 사용자의 ID
     * @return 삭제 성공 여부
     */
    boolean deleteById(Long id);
    
    /**
     * 전체 사용자 수를 조회합니다.
     * @return 사용자 총 개수
     */
    long count();
    
    /**
     * 해당 이메일을 가진 사용자가 존재하는지 확인합니다.
     * @param email 확인할 이메일
     * @return 존재 여부
     */
    boolean existsByEmail(String email);
}

2.3 DAO 구현 클래스

실제 데이터 접근 로직을 구현합니다. 이 예제에서는 메모리 기반으로 구현하지만, 실제로는 JDBC나 JPA를 사용합니다.

// UserDaoImpl.java
package com.example.dao;

import com.example.domain.User;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import java.util.stream.Collectors;

/**
 * UserDao 인터페이스의 메모리 기반 구현체
 * 실제 프로덕션에서는 JDBC, JPA, MyBatis 등을 사용합니다.
 */
public class UserDaoImpl implements UserDao {
    
    // 메모리 기반 데이터 저장소 (실제로는 데이터베이스)
    private final Map<Long, User> userStorage = new ConcurrentHashMap<>();
    private final AtomicLong idGenerator = new AtomicLong(1);
    
    public UserDaoImpl() {
        // 초기 샘플 데이터
        initializeSampleData();
    }
    
    private void initializeSampleData() {
        save(new User("Alice Johnson", "alice@example.com"));
        save(new User("Bob Smith", "bob@example.com"));
        save(new User("Charlie Brown", "charlie@example.com"));
    }

    @Override
    public List<User> findAll() {
        return new ArrayList<>(userStorage.values());
    }

    @Override
    public Optional<User> findById(Long id) {
        return Optional.ofNullable(userStorage.get(id));
    }

    @Override
    public Optional<User> findByEmail(String email) {
        return userStorage.values().stream()
                .filter(user -> email.equals(user.getEmail()))
                .findFirst();
    }

    @Override
    public List<User> findByNameContaining(String name) {
        return userStorage.values().stream()
                .filter(user -> user.getName().toLowerCase()
                        .contains(name.toLowerCase()))
                .collect(Collectors.toList());
    }

    @Override
    public User save(User user) {
        if (user.getId() == null) {
            // 새로운 사용자 생성
            Long newId = idGenerator.getAndIncrement();
            user.setId(newId);
        }
        
        // 이메일 중복 검사
        if (existsByEmail(user.getEmail()) && 
            !userStorage.get(user.getId()).getEmail().equals(user.getEmail())) {
            throw new IllegalArgumentException("Email already exists: " + user.getEmail());
        }
        
        userStorage.put(user.getId(), user);
        return user;
    }

    @Override
    public boolean update(User user) {
        if (user.getId() == null || !userStorage.containsKey(user.getId())) {
            return false;
        }
        
        // 이메일 중복 검사 (다른 사용자와)
        Optional<User> existingUserWithEmail = findByEmail(user.getEmail());
        if (existingUserWithEmail.isPresent() && 
            !existingUserWithEmail.get().getId().equals(user.getId())) {
            throw new IllegalArgumentException("Email already exists: " + user.getEmail());
        }
        
        userStorage.put(user.getId(), user);
        return true;
    }

    @Override
    public boolean deleteById(Long id) {
        return userStorage.remove(id) != null;
    }

    @Override
    public long count() {
        return userStorage.size();
    }

    @Override
    public boolean existsByEmail(String email) {
        return userStorage.values().stream()
                .anyMatch(user -> email.equals(user.getEmail()));
    }
}

2.4 서비스 레이어 구현

비즈니스 로직을 처리하는 서비스 클래스를 만들어 DAO와 클라이언트 코드 사이의 중간 계층을 구성합니다.

// UserService.java
package com.example.service;

import com.example.dao.UserDao;
import com.example.domain.User;
import java.util.List;
import java.util.Optional;

/**
 * 사용자 관련 비즈니스 로직을 처리하는 서비스 클래스
 */
public class UserService {
    
    private final UserDao userDao;
    
    public UserService(UserDao userDao) {
        this.userDao = userDao;
    }
    
    /**
     * 새로운 사용자를 등록합니다.
     */
    public User registerUser(String name, String email) {
        // 비즈니스 규칙 검증
        if (name == null || name.trim().isEmpty()) {
            throw new IllegalArgumentException("이름은 필수입니다.");
        }
        
        if (email == null || !isValidEmail(email)) {
            throw new IllegalArgumentException("유효한 이메일 주소를 입력해주세요.");
        }
        
        // 이메일 중복 검사
        if (userDao.existsByEmail(email)) {
            throw new IllegalArgumentException("이미 등록된 이메일입니다: " + email);
        }
        
        User newUser = new User(name.trim(), email.toLowerCase());
        return userDao.save(newUser);
    }
    
    /**
     * 사용자 정보를 업데이트합니다.
     */
    public boolean updateUser(Long id, String name, String email) {
        Optional<User> userOpt = userDao.findById(id);
        if (userOpt.isEmpty()) {
            throw new IllegalArgumentException("사용자를 찾을 수 없습니다: " + id);
        }
        
        User user = userOpt.get();
        user.setName(name.trim());
        user.setEmail(email.toLowerCase());
        
        return userDao.update(user);
    }
    
    /**
     * 사용자를 검색합니다.
     */
    public List<User> searchUsers(String keyword) {
        if (keyword == null || keyword.trim().isEmpty()) {
            return userDao.findAll();
        }
        
        return userDao.findByNameContaining(keyword.trim());
    }
    
    /**
     * 모든 사용자를 조회합니다.
     */
    public List<User> getAllUsers() {
        return userDao.findAll();
    }
    
    /**
     * ID로 사용자를 조회합니다.
     */
    public Optional<User> getUserById(Long id) {
        return userDao.findById(id);
    }
    
    /**
     * 사용자를 삭제합니다.
     */
    public boolean deleteUser(Long id) {
        if (!userDao.findById(id).isPresent()) {
            throw new IllegalArgumentException("사용자를 찾을 수 없습니다: " + id);
        }
        
        return userDao.deleteById(id);
    }
    
    /**
     * 사용자 통계 정보를 조회합니다.
     */
    public String getUserStatistics() {
        long totalUsers = userDao.count();
        return String.format("총 사용자 수: %d명", totalUsers);
    }
    
    private boolean isValidEmail(String email) {
        return email.contains("@") && email.contains(".") && email.length() > 5;
    }
}

2.5 실행 예제

// UserApplication.java
package com.example.app;

import com.example.dao.UserDao;
import com.example.dao.UserDaoImpl;
import com.example.domain.User;
import com.example.service.UserService;

public class UserApplication {
    
    public static void main(String[] args) {
        // 의존성 주입 (실제로는 Spring 등의 프레임워크 사용)
        UserDao userDao = new UserDaoImpl();
        UserService userService = new UserService(userDao);
        
        demonstrateUserOperations(userService);
    }
    
    private static void demonstrateUserOperations(UserService userService) {
        System.out.println("=== Java DAO 패턴 실행 예제 ===\n");
        
        // 1. 모든 사용자 조회
        printSection("1. 모든 사용자 조회");
        userService.getAllUsers().forEach(System.out::println);
        System.out.println(userService.getUserStatistics());
        
        // 2. 새 사용자 등록
        printSection("2. 새 사용자 등록");
        try {
            User newUser = userService.registerUser("David Wilson", "david@example.com");
            System.out.println("등록 성공: " + newUser);
        } catch (Exception e) {
            System.err.println("등록 실패: " + e.getMessage());
        }
        
        // 3. 사용자 검색
        printSection("3. 사용자 검색 ('John'으로 검색)");
        userService.searchUsers("John").forEach(System.out::println);
        
        // 4. 사용자 정보 업데이트
        printSection("4. 사용자 정보 업데이트");
        try {
            boolean updated = userService.updateUser(1L, "Alice Johnson Updated", "alice.updated@example.com");
            System.out.println("업데이트 " + (updated ? "성공" : "실패"));
            
            userService.getUserById(1L).ifPresent(user -> 
                System.out.println("업데이트된 사용자: " + user));
        } catch (Exception e) {
            System.err.println("업데이트 실패: " + e.getMessage());
        }
        
        // 5. 이메일 중복 등록 시도
        printSection("5. 이메일 중복 등록 테스트");
        try {
            userService.registerUser("Test User", "alice.updated@example.com");
        } catch (Exception e) {
            System.err.println("예상된 오류: " + e.getMessage());
        }
        
        // 6. 사용자 삭제
        printSection("6. 사용자 삭제");
        try {
            boolean deleted = userService.deleteUser(2L);
            System.out.println("삭제 " + (deleted ? "성공" : "실패"));
            System.out.println(userService.getUserStatistics());
        } catch (Exception e) {
            System.err.println("삭제 실패: " + e.getMessage());
        }
        
        // 7. 최종 상태 확인
        printSection("7. 최종 사용자 목록");
        userService.getAllUsers().forEach(System.out::println);
    }
    
    private static void printSection(String title) {
        System.out.println("\n" + "=".repeat(50));
        System.out.println(title);
        System.out.println("=".repeat(50));
    }
}

3. DAO 패턴의 장점과 실제 적용

장점

  1. 관심사의 분리: 데이터 접근 로직과 비즈니스 로직이 명확히 분리
  2. 코드 재사용성: 동일한 데이터 접근 로직을 여러 서비스에서 재사용
  3. 테스트 용이성: Mock 객체를 사용한 단위 테스트 가능
  4. 유지보수성: 데이터베이스 변경 시 DAO 구현체만 수정하면 됨
  5. 확장성: 새로운 데이터 소스 추가 시 인터페이스 구현만 추가

실제 프로덕션 환경에서의 적용

// JDBC를 사용한 실제 DAO 구현 예제
public class UserDaoJdbcImpl implements UserDao {
    
    private final DataSource dataSource;
    
    public UserDaoJdbcImpl(DataSource dataSource) {
        this.dataSource = dataSource;
    }
    
    @Override
    public Optional<User> findById(Long id) {
        String sql = "SELECT * FROM users WHERE id = ?";
        
        try (Connection conn = dataSource.getConnection();
             PreparedStatement pstmt = conn.prepareStatement(sql)) {
            
            pstmt.setLong(1, id);
            
            try (ResultSet rs = pstmt.executeQuery()) {
                if (rs.next()) {
                    return Optional.of(mapRowToUser(rs));
                }
            }
        } catch (SQLException e) {
            throw new RuntimeException("사용자 조회 실패", e);
        }
        
        return Optional.empty();
    }
    
    private User mapRowToUser(ResultSet rs) throws SQLException {
        User user = new User();
        user.setId(rs.getLong("id"));
        user.setName(rs.getString("name"));
        user.setEmail(rs.getString("email"));
        user.setCreatedAt(rs.getTimestamp("created_at").toLocalDateTime());
        user.setUpdatedAt(rs.getTimestamp("updated_at").toLocalDateTime());
        return user;
    }
}

4. 모범 사례와 주의사항

👍 모범 사례

  1. 인터페이스 우선 설계: 구현보다 인터페이스를 먼저 정의
  2. 적절한 예외 처리: 데이터베이스 예외를 비즈니스 예외로 변환
  3. 트랜잭션 관리: 복잡한 작업은 서비스 레이어에서 트랜잭션 처리
  4. 리소스 관리: try-with-resources를 사용한 안전한 리소스 해제

⚠️ 주의사항

  1. 과도한 추상화 피하기: 단순한 CRUD만 있다면 굳이 DAO 패턴을 사용하지 않아도 됨
  2. 성능 고려: N+1 쿼리 문제 등 성능 이슈 주의
  3. 의존성 관리: 순환 참조 방지
  4. 테스트 코드 작성: Mock을 활용한 단위 테스트 필수

마무리

DAO 패턴은 Java 애플리케이션에서 데이터 접근 로직을 체계적으로 관리할 수 있는 강력한 디자인 패턴입니다. 적절히 활용하면 유지보수가 쉽고 확장 가능한 애플리케이션을 개발할 수 있습니다.

현대적인 Java 생태계에서는 Spring Data JPA, MyBatis 등의 프레임워크가 DAO 패턴의 구현을 더욱 간편하게 만들어주지만, 기본 원리를 이해하는 것은 여전히 중요합니다. 이를 바탕으로 더욱 견고하고 효율적인 데이터 접근 계층을 구축하시기 바랍니다.

반응형