Repository

9단계: 실전 프로젝트 & 베스트 프랙티스 본문

Java

9단계: 실전 프로젝트 & 베스트 프랙티스

Mr.Manager 2025. 12. 11. 20:29
반응형

9단계: 실전 프로젝트 & 베스트 프랙티스

실무에서 겪은 문제들과 해결 방법을 바탕으로, 단순한 이론이 아닌 '실제로 작동하는 코드'와 '유지보수 가능한 설계'를 만드는 방법을 다룹니다.


9.1 코드 품질

Effective Java 핵심 정리

생성자 대신 정적 팩토리 메서드

정적 팩토리 메서드의 장점

// ❌ 전통적인 생성자
public class User {
    private String name;
    private String email;
    private int age;

    public User(String name, String email, int age) {
        this.name = name;
        this.email = email;
        this.age = age;
    }

    // 문제: 같은 타입의 파라미터로는 오버로딩 불가능
    // public User(String name, String email) { ... } // 컴파일 에러!
}

// ✅ 정적 팩토리 메서드
public class User {
    private String name;
    private String email;
    private int age;

    private User(String name, String email, int age) {
        this.name = name;
        this.email = email;
        this.age = age;
    }

    // 1. 의미 있는 이름 제공
    public static User createAdult(String name, String email) {
        return new User(name, email, 18);
    }

    public static User createWithAge(String name, String email, int age) {
        return new User(name, email, age);
    }

    // 2. 매번 새 인스턴스를 만들 필요 없음 (캐싱)
    private static final User GUEST = new User("Guest", "guest@example.com", 0);

    public static User guest() {
        return GUEST;
    }

    // 3. 반환 타입의 하위 타입 반환 가능
    public static User fromEmail(String email) {
        if (email.contains("@admin.")) {
            return new AdminUser(email);
        }
        return new RegularUser(email);
    }

    // 4. 입력 파라미터에 따라 다른 클래스 반환
    public static User of(String name, String email) {
        return new User(name, email, 0);
    }

    public static User of(String name, String email, int age) {
        return new User(name, email, age);
    }
}

// 사용 예제
public class UserExample {
    public static void main(String[] args) {
        // 의미가 명확함
        User adult = User.createAdult("John", "john@example.com");
        User guest = User.guest();

        // 생성자보다 훨씬 읽기 쉬움
        User user1 = User.of("Alice", "alice@example.com");
        User user2 = User.of("Bob", "bob@example.com", 25);
    }
}

실무 활용: 빌더 패턴과 함께

public class Product {
    private final String id;
    private final String name;
    private final BigDecimal price;
    private final String category;
    private final String description;
    private final LocalDateTime createdAt;

    private Product(Builder builder) {
        this.id = builder.id;
        this.name = builder.name;
        this.price = builder.price;
        this.category = builder.category;
        this.description = builder.description;
        this.createdAt = builder.createdAt;
    }

    // 정적 팩토리 메서드
    public static Builder builder() {
        return new Builder();
    }

    // 편의 메서드들
    public static Product createDefault(String name, BigDecimal price) {
        return builder()
            .name(name)
            .price(price)
            .category("GENERAL")
            .createdAt(LocalDateTime.now())
            .build();
    }

    public static Product fromExisting(Product existing, String newName) {
        return builder()
            .id(existing.id)
            .name(newName)
            .price(existing.price)
            .category(existing.category)
            .description(existing.description)
            .createdAt(existing.createdAt)
            .build();
    }

    public static class Builder {
        private String id;
        private String name;
        private BigDecimal price;
        private String category;
        private String description;
        private LocalDateTime createdAt;

        public Builder id(String id) {
            this.id = id;
            return this;
        }

        public Builder name(String name) {
            this.name = name;
            return this;
        }

        public Builder price(BigDecimal price) {
            this.price = price;
            return this;
        }

        public Builder category(String category) {
            this.category = category;
            return this;
        }

        public Builder description(String description) {
            this.description = description;
            return this;
        }

        public Builder createdAt(LocalDateTime createdAt) {
            this.createdAt = createdAt;
            return this;
        }

        public Product build() {
            // 필수 필드 검증
            if (name == null || name.isBlank()) {
                throw new IllegalArgumentException("Name is required");
            }
            if (price == null || price.compareTo(BigDecimal.ZERO) < 0) {
                throw new IllegalArgumentException("Price must be non-negative");
            }

            // 기본값 설정
            if (id == null) {
                id = UUID.randomUUID().toString();
            }
            if (category == null) {
                category = "GENERAL";
            }
            if (createdAt == null) {
                createdAt = LocalDateTime.now();
            }

            return new Product(this);
        }
    }
}

불필요한 객체 생성 피하기

오토박싱의 함정

public class ObjectCreationAntiPatterns {

    // ❌ 나쁜 예: 오토박싱으로 인한 불필요한 객체 생성
    public long sumBad() {
        Long sum = 0L;  // Long 객체 생성 (오토박싱)
        for (long i = 0; i < Integer.MAX_VALUE; i++) {
            sum += i;  // 매번 Long 객체 생성 및 GC 발생
        }
        return sum;
    }

    // ✅ 좋은 예: primitive 타입 사용
    public long sumGood() {
        long sum = 0L;  // primitive 타입
        for (long i = 0; i < Integer.MAX_VALUE; i++) {
            sum += i;  // 객체 생성 없음
        }
        return sum;
    }

    // ❌ 나쁜 예: String concatenation in loop
    public String concatenateBad(List<String> strings) {
        String result = "";
        for (String str : strings) {
            result += str;  // 매번 새로운 String 객체 생성
        }
        return result;
    }

    // ✅ 좋은 예: StringBuilder 사용
    public String concatenateGood(List<String> strings) {
        StringBuilder result = new StringBuilder();
        for (String str : strings) {
            result.append(str);
        }
        return result.toString();
    }

    // ✅✅ 더 좋은 예: Java 8+ String.join
    public String concatenateBest(List<String> strings) {
        return String.join("", strings);
    }
}

정규식 패턴 재사용

public class RegexPatternReuse {

    // ❌ 나쁜 예: 매번 Pattern.compile 호출
    public boolean isValidEmailBad(String email) {
        return email.matches("^[A-Za-z0-9+_.-]+@(.+)$");  // 매번 컴파일
    }

    // ✅ 좋은 예: Pattern을 상수로 재사용
    private static final Pattern EMAIL_PATTERN = 
        Pattern.compile("^[A-Za-z0-9+_.-]+@(.+)$");

    public boolean isValidEmailGood(String email) {
        return EMAIL_PATTERN.matcher(email).matches();
    }

    // 실무 활용: 여러 패턴 관리
    public class ValidationPatterns {
        private static final Pattern EMAIL = 
            Pattern.compile("^[A-Za-z0-9+_.-]+@(.+)$");
        private static final Pattern PHONE = 
            Pattern.compile("^\\d{3}-\\d{4}-\\d{4}$");
        private static final Pattern URL = 
            Pattern.compile("^https?://.+");

        public boolean isValidEmail(String email) {
            return EMAIL.matcher(email).matches();
        }

        public boolean isValidPhone(String phone) {
            return PHONE.matcher(phone).matches();
        }

        public boolean isValidUrl(String url) {
            return URL.matcher(url).matches();
        }
    }
}

equals와 hashCode 오버라이딩 규칙

올바른 equals/hashCode 구현

public class User {
    private final Long id;
    private final String name;
    private final String email;

    public User(Long id, String name, String email) {
        this.id = id;
        this.name = name;
        this.email = email;
    }

    // ✅ 올바른 equals 구현
    @Override
    public boolean equals(Object obj) {
        // 1. 자기 자신과의 비교
        if (this == obj) {
            return true;
        }

        // 2. null 체크
        if (obj == null) {
            return false;
        }

        // 3. 타입 체크 (instanceof 사용)
        if (!(obj instanceof User)) {
            return false;
        }

        // 4. 타입 캐스팅
        User other = (User) obj;

        // 5. 핵심 필드 비교
        return Objects.equals(this.id, other.id) &&
               Objects.equals(this.name, other.name) &&
               Objects.equals(this.email, other.email);
    }

    // ✅ 올바른 hashCode 구현
    @Override
    public int hashCode() {
        // equals에서 사용한 모든 필드를 포함
        return Objects.hash(id, name, email);
    }

    // ✅ toString도 함께 구현
    @Override
    public String toString() {
        return String.format("User{id=%d, name='%s', email='%s'}", 
            id, name, email);
    }
}

// Java 17+ Record 사용 시 자동 생성됨
public record UserRecord(Long id, String name, String email) {
    // equals, hashCode, toString 자동 생성!
}

equals/hashCode 계약 위반 예제

public class EqualsHashCodeContract {

    // ❌ 나쁜 예: hashCode 미구현
    public class BadUser {
        private String name;

        @Override
        public boolean equals(Object obj) {
            if (!(obj instanceof BadUser)) return false;
            return Objects.equals(this.name, ((BadUser) obj).name);
        }
        // hashCode 미구현 -> HashMap, HashSet에서 문제 발생!
    }

    // ❌ 나쁜 예: 가변 필드 사용
    public class MutableUser {
        private String name;

        @Override
        public boolean equals(Object obj) {
            if (!(obj instanceof MutableUser)) return false;
            return Objects.equals(this.name, ((MutableUser) obj).name);
        }

        @Override
        public int hashCode() {
            return Objects.hash(name);
        }

        public void setName(String name) {
            this.name = name;  // 필드 변경 시 hashCode도 변경됨!
        }
    }

    // 문제 상황
    public void demonstrateProblem() {
        MutableUser user = new MutableUser();
        user.setName("John");

        Set<MutableUser> set = new HashSet<>();
        set.add(user);  // hashCode: 12345

        user.setName("Jane");  // hashCode 변경됨: 67890

        System.out.println(set.contains(user));  // false! (잘못된 버킷에서 찾음)
    }

    // ✅ 좋은 예: 불변 객체 사용
    public final class ImmutableUser {
        private final String name;

        public ImmutableUser(String name) {
            this.name = name;
        }

        @Override
        public boolean equals(Object obj) {
            if (!(obj instanceof ImmutableUser)) return false;
            return Objects.equals(this.name, ((ImmutableUser) obj).name);
        }

        @Override
        public int hashCode() {
            return Objects.hash(name);
        }
    }
}

Java 예외 처리 베스트 프랙티스

Checked vs Unchecked Exception

예외 타입 선택 가이드

// ✅ Checked Exception: 복구 가능한 오류
public class FileProcessor {

    // Checked Exception: 호출자가 반드시 처리해야 함
    public String readFile(String path) throws IOException {
        try (BufferedReader reader = Files.newBufferedReader(Paths.get(path))) {
            return reader.lines().collect(Collectors.joining("\n"));
        }
    }

    // 사용 예제
    public void processFile(String path) {
        try {
            String content = readFile(path);
            // 파일 내용 처리
        } catch (IOException e) {
            // 복구 로직: 기본 파일 사용 또는 사용자에게 알림
            System.err.println("파일을 읽을 수 없습니다: " + e.getMessage());
            // 또는 기본값 사용
            String content = getDefaultContent();
        }
    }
}

// ✅ Unchecked Exception: 프로그래밍 오류
public class UserService {

    // Unchecked Exception: 호출자가 예상하지 못한 오류
    public User findUser(Long id) {
        if (id == null) {
            throw new IllegalArgumentException("User ID cannot be null");
        }

        if (id < 0) {
            throw new IllegalArgumentException("User ID must be positive");
        }

        // 비즈니스 로직
        return userRepository.findById(id)
            .orElseThrow(() -> new UserNotFoundException("User not found: " + id));
    }
}

// Custom Unchecked Exception
public class UserNotFoundException extends RuntimeException {
    public UserNotFoundException(String message) {
        super(message);
    }

    public UserNotFoundException(String message, Throwable cause) {
        super(message, cause);
    }
}

예외 처리 전략

예외 전파 vs 처리

public class ExceptionHandlingStrategies {

    // ✅ 전략 1: 예외를 상위로 전파 (가장 일반적)
    public void strategy1() throws IOException {
        // 예외를 처리할 수 없거나, 호출자가 처리해야 할 때
        Files.readAllLines(Paths.get("file.txt"));
    }

    // ✅ 전략 2: 예외를 로깅하고 기본값 반환
    public String strategy2(String path) {
        try {
            return Files.readString(Paths.get(path));
        } catch (IOException e) {
            // 로깅
            Logger.getLogger(getClass().getName())
                .log(Level.WARNING, "파일 읽기 실패: " + path, e);
            // 기본값 반환
            return "";
        }
    }

    // ✅ 전략 3: 예외를 다른 예외로 래핑
    public void strategy3() {
        try {
            // 낮은 수준의 예외 발생
            Files.readAllLines(Paths.get("file.txt"));
        } catch (IOException e) {
            // 비즈니스 로직에 맞는 예외로 변환
            throw new DataAccessException("데이터를 읽을 수 없습니다", e);
        }
    }

    // ✅ 전략 4: 예외 무시 (매우 신중하게!)
    public void strategy4() {
        try {
            // 중요하지 않은 작업
            cleanupTempFiles();
        } catch (IOException e) {
            // 예외를 무시하되 로깅은 필수
            Logger.getLogger(getClass().getName())
                .log(Level.FINE, "임시 파일 정리 실패 (무시됨)", e);
        }
    }

    private void cleanupTempFiles() throws IOException {
        // 임시 파일 정리 로직
    }
}

// Custom Exception 계층 구조
public class DataAccessException extends RuntimeException {
    public DataAccessException(String message) {
        super(message);
    }

    public DataAccessException(String message, Throwable cause) {
        super(message, cause);
    }
}

Try-with-resources

리소스 관리의 올바른 방법

public class ResourceManagement {

    // ❌ 나쁜 예: 수동 리소스 관리
    public void badResourceManagement() {
        BufferedReader reader = null;
        try {
            reader = new BufferedReader(new FileReader("file.txt"));
            String line = reader.readLine();
            // 처리 로직
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            // 리소스 해제 (실수하기 쉬움)
            if (reader != null) {
                try {
                    reader.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
    }

    // ✅ 좋은 예: Try-with-resources
    public void goodResourceManagement() {
        try (BufferedReader reader = Files.newBufferedReader(Paths.get("file.txt"))) {
            String line = reader.readLine();
            // 처리 로직
        } catch (IOException e) {
            e.printStackTrace();
        }
        // 자동으로 close() 호출됨
    }

    // ✅ 여러 리소스 동시 관리
    public void multipleResources() {
        try (
            BufferedReader reader = Files.newBufferedReader(Paths.get("input.txt"));
            BufferedWriter writer = Files.newBufferedWriter(Paths.get("output.txt"))
        ) {
            String line;
            while ((line = reader.readLine()) != null) {
                writer.write(line);
                writer.newLine();
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    // ✅ 커스텀 리소스 구현 (AutoCloseable)
    public class DatabaseConnection implements AutoCloseable {
        private Connection connection;

        public DatabaseConnection(String url) throws SQLException {
            this.connection = DriverManager.getConnection(url);
        }

        public void executeQuery(String sql) throws SQLException {
            // 쿼리 실행
        }

        @Override
        public void close() throws SQLException {
            if (connection != null && !connection.isClosed()) {
                connection.close();
            }
        }
    }

    // 사용 예제
    public void useCustomResource() {
        try (DatabaseConnection db = new DatabaseConnection("jdbc:mysql://localhost/db")) {
            db.executeQuery("SELECT * FROM users");
        } catch (SQLException e) {
            e.printStackTrace();
        }
    }
}

Clean Code in Java

의미 있는 이름 짓기

명명 규칙 베스트 프랙티스

public class NamingBestPractices {

    // ✅ 좋은 예: 의도가 명확한 이름
    public List<User> getActiveUsersOlderThan(int minAge) {
        return users.stream()
            .filter(User::isActive)
            .filter(user -> user.getAge() >= minAge)
            .collect(Collectors.toList());
    }

    // ❌ 나쁜 예: 의미 없는 이름
    public List<User> getUsers(int x) {
        return users.stream()
            .filter(u -> u.isActive())
            .filter(u -> u.getAge() >= x)
            .collect(Collectors.toList());
    }

    // ✅ 좋은 예: 검색 메서드
    public Optional<User> findUserByEmail(String email) {
        return users.stream()
            .filter(user -> email.equals(user.getEmail()))
            .findFirst();
    }

    // ✅ 좋은 예: 불린 메서드는 is/has/can으로 시작
    public boolean isActive() { return true; }
    public boolean hasPermission() { return true; }
    public boolean canEdit() { return true; }

    // ✅ 좋은 예: 컬렉션은 복수형 사용
    public List<User> getUsers() { return users; }
    public Map<String, User> getUserMap() { return userMap; }

    // ✅ 좋은 예: 상수는 대문자와 언더스코어
    private static final int MAX_RETRY_COUNT = 3;
    private static final String DEFAULT_USER_NAME = "Guest";

    // ✅ 좋은 예: 클래스는 명사, 메서드는 동사
    public class UserService {  // 명사
        public void createUser() { }  // 동사
        public void updateUser() { }  // 동사
        public User findUser() { }    // 동사
    }
}

함수는 한 가지만

단일 책임 원칙 적용

public class SingleResponsibilityPrinciple {

    // ❌ 나쁜 예: 여러 가지 일을 하는 함수
    public void processOrderBad(Order order) {
        // 1. 주문 검증
        if (order == null || order.getItems().isEmpty()) {
            throw new IllegalArgumentException("Invalid order");
        }

        // 2. 재고 확인
        for (OrderItem item : order.getItems()) {
            int stock = inventoryService.getStock(item.getProductId());
            if (stock < item.getQuantity()) {
                throw new InsufficientStockException("Not enough stock");
            }
        }

        // 3. 가격 계산
        BigDecimal total = BigDecimal.ZERO;
        for (OrderItem item : order.getItems()) {
            BigDecimal price = productService.getPrice(item.getProductId());
            total = total.add(price.multiply(BigDecimal.valueOf(item.getQuantity())));
        }

        // 4. 결제 처리
        paymentService.processPayment(order.getPaymentMethod(), total);

        // 5. 재고 차감
        for (OrderItem item : order.getItems()) {
            inventoryService.decreaseStock(item.getProductId(), item.getQuantity());
        }

        // 6. 주문 저장
        orderRepository.save(order);

        // 7. 이메일 발송
        emailService.sendOrderConfirmation(order.getCustomerEmail(), order);
    }

    // ✅ 좋은 예: 각 함수가 한 가지 일만
    public void processOrder(Order order) {
        validateOrder(order);
        checkInventory(order);
        BigDecimal total = calculateTotal(order);
        processPayment(order, total);
        decreaseInventory(order);
        saveOrder(order);
        sendConfirmationEmail(order);
    }

    private void validateOrder(Order order) {
        if (order == null || order.getItems().isEmpty()) {
            throw new IllegalArgumentException("Invalid order");
        }
    }

    private void checkInventory(Order order) {
        for (OrderItem item : order.getItems()) {
            int stock = inventoryService.getStock(item.getProductId());
            if (stock < item.getQuantity()) {
                throw new InsufficientStockException("Not enough stock");
            }
        }
    }

    private BigDecimal calculateTotal(Order order) {
        return order.getItems().stream()
            .map(item -> {
                BigDecimal price = productService.getPrice(item.getProductId());
                return price.multiply(BigDecimal.valueOf(item.getQuantity()));
            })
            .reduce(BigDecimal.ZERO, BigDecimal::add);
    }

    private void processPayment(Order order, BigDecimal total) {
        paymentService.processPayment(order.getPaymentMethod(), total);
    }

    private void decreaseInventory(Order order) {
        order.getItems().forEach(item ->
            inventoryService.decreaseStock(item.getProductId(), item.getQuantity())
        );
    }

    private void saveOrder(Order order) {
        orderRepository.save(order);
    }

    private void sendConfirmationEmail(Order order) {
        emailService.sendOrderConfirmation(order.getCustomerEmail(), order);
    }
}

9.2 테스트

JUnit 5로 단위 테스트 작성하기

테스트 작성 기본

JUnit 5 기본 사용법

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

public class UserServiceTest {

    private UserService userService;
    private UserRepository userRepository;

    // 각 테스트 전에 실행
    @BeforeEach
    void setUp() {
        userRepository = new InMemoryUserRepository();
        userService = new UserService(userRepository);
    }

    // 각 테스트 후에 실행
    @AfterEach
    void tearDown() {
        // 정리 작업
    }

    // 모든 테스트 전에 한 번 실행
    @BeforeAll
    static void setUpAll() {
        // 초기화 작업
    }

    // 모든 테스트 후에 한 번 실행
    @AfterAll
    static void tearDownAll() {
        // 정리 작업
    }

    @Test
    @DisplayName("사용자 생성 테스트")
    void testCreateUser() {
        // Given
        String name = "John Doe";
        String email = "john@example.com";

        // When
        User user = userService.createUser(name, email);

        // Then
        assertNotNull(user);
        assertEquals(name, user.getName());
        assertEquals(email, user.getEmail());
        assertTrue(user.getId() > 0);
    }

    @Test
    @DisplayName("중복 이메일로 사용자 생성 시 예외 발생")
    void testCreateUserWithDuplicateEmail() {
        // Given
        String email = "john@example.com";
        userService.createUser("John", email);

        // When & Then
        assertThrows(DuplicateEmailException.class, () -> {
            userService.createUser("Jane", email);
        });
    }

    @Test
    @DisplayName("사용자 조회 테스트")
    void testFindUser() {
        // Given
        User created = userService.createUser("John", "john@example.com");

        // When
        Optional<User> found = userService.findUser(created.getId());

        // Then
        assertTrue(found.isPresent());
        assertEquals(created.getId(), found.get().getId());
    }

    @Test
    @DisplayName("존재하지 않는 사용자 조회 시 빈 Optional 반환")
    void testFindNonExistentUser() {
        // When
        Optional<User> found = userService.findUser(999L);

        // Then
        assertTrue(found.isEmpty());
    }
}

Assertions와 Assumptions

다양한 Assertion 사용법

public class AssertionExamples {

    @Test
    void testBasicAssertions() {
        // 기본 Assertions
        assertEquals(5, 2 + 3);
        assertNotEquals(5, 2 + 2);
        assertTrue(5 > 3);
        assertFalse(5 < 3);
        assertNull(null);
        assertNotNull("not null");
    }

    @Test
    void testArrayAssertions() {
        int[] expected = {1, 2, 3};
        int[] actual = {1, 2, 3};

        assertArrayEquals(expected, actual);
    }

    @Test
    void testCollectionAssertions() {
        List<String> list = Arrays.asList("a", "b", "c");

        assertTrue(list.contains("a"));
        assertEquals(3, list.size());
        assertIterableEquals(Arrays.asList("a", "b", "c"), list);
    }

    @Test
    void testExceptionAssertions() {
        // 예외 발생 확인
        assertThrows(IllegalArgumentException.class, () -> {
            throw new IllegalArgumentException("Test exception");
        });

        // 예외 메시지 확인
        Exception exception = assertThrows(IllegalArgumentException.class, () -> {
            throw new IllegalArgumentException("Invalid input");
        });
        assertEquals("Invalid input", exception.getMessage());
    }

    @Test
    void testTimeoutAssertions() {
        // 실행 시간 제한
        assertTimeout(Duration.ofSeconds(1), () -> {
            Thread.sleep(500);
        });

        // 실행 시간 제한 (초과 시 즉시 중단)
        assertTimeoutPreemptively(Duration.ofSeconds(1), () -> {
            Thread.sleep(500);
        });
    }

    @Test
    void testAllAssertions() {
        // 모든 Assertion이 실행됨 (하나 실패해도 나머지 실행)
        assertAll(
            () -> assertEquals(2, 1 + 1),
            () -> assertEquals(4, 2 * 2),
            () -> assertTrue(3 > 2)
        );
    }

    @Test
    void testAssumptions() {
        // 조건이 맞을 때만 테스트 실행
        assumeTrue(System.getProperty("os.name").contains("Windows"));

        // Windows에서만 실행되는 테스트
        // ...
    }
}

@ParameterizedTest

파라미터화된 테스트

public class ParameterizedTestExamples {

    @ParameterizedTest
    @ValueSource(ints = {1, 3, 5, 7, 9})
    @DisplayName("홀수 검증 테스트")
    void testIsOdd(int number) {
        assertTrue(number % 2 == 1);
    }

    @ParameterizedTest
    @ValueSource(strings = {"", "  ", "   "})
    @DisplayName("빈 문자열 검증")
    void testIsBlank(String input) {
        assertTrue(input.isBlank());
    }

    @ParameterizedTest
    @CsvSource({
        "1, 2, 3",
        "4, 5, 9",
        "10, 20, 30"
    })
    @DisplayName("덧셈 테스트")
    void testAddition(int a, int b, int expected) {
        assertEquals(expected, a + b);
    }

    @ParameterizedTest
    @MethodSource("provideTestData")
    @DisplayName("사용자 생성 테스트")
    void testCreateUser(String name, String email, boolean shouldSucceed) {
        if (shouldSucceed) {
            assertDoesNotThrow(() -> {
                userService.createUser(name, email);
            });
        } else {
            assertThrows(IllegalArgumentException.class, () -> {
                userService.createUser(name, email);
            });
        }
    }

    static Stream<Arguments> provideTestData() {
        return Stream.of(
            Arguments.of("John", "john@example.com", true),
            Arguments.of("", "john@example.com", false),
            Arguments.of("John", "invalid-email", false)
        );
    }
}

Mockito로 모킹

Mockito 기본 사용법

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

public class MockitoExamples {

    @Mock
    private UserRepository userRepository;

    @Mock
    private EmailService emailService;

    @InjectMocks
    private UserService userService;

    @BeforeEach
    void setUp() {
        MockitoAnnotations.openMocks(this);
    }

    @Test
    @DisplayName("Mock 객체 사용 테스트")
    void testWithMock() {
        // Given
        User mockUser = new User(1L, "John", "john@example.com");
        when(userRepository.findById(1L)).thenReturn(Optional.of(mockUser));

        // When
        Optional<User> result = userService.findUser(1L);

        // Then
        assertTrue(result.isPresent());
        assertEquals("John", result.get().getName());
        verify(userRepository).findById(1L);  // 메서드 호출 확인
    }

    @Test
    @DisplayName("예외 발생 Mock")
    void testExceptionWithMock() {
        // Given
        when(userRepository.findById(1L))
            .thenThrow(new RuntimeException("Database error"));

        // When & Then
        assertThrows(RuntimeException.class, () -> {
            userService.findUser(1L);
        });
    }

    @Test
    @DisplayName("Argument Matcher 사용")
    void testArgumentMatcher() {
        // Given
        when(userRepository.findByEmail(anyString()))
            .thenReturn(Optional.empty());

        // When
        Optional<User> result = userService.findUserByEmail("any@example.com");

        // Then
        assertTrue(result.isEmpty());
        verify(userRepository).findByEmail(anyString());
    }

    @Test
    @DisplayName("Void 메서드 Mock")
    void testVoidMethod() {
        // Given
        User user = new User(1L, "John", "john@example.com");
        doNothing().when(emailService).sendWelcomeEmail(user);

        // When
        userService.sendWelcomeEmail(user);

        // Then
        verify(emailService).sendWelcomeEmail(user);
    }

    @Test
    @DisplayName("Spy 사용 (실제 객체 일부 Mock)")
    void testSpy() {
        List<String> list = new ArrayList<>();
        List<String> spy = spy(list);

        // 실제 메서드 호출
        spy.add("one");
        spy.add("two");

        // 특정 메서드만 Mock
        when(spy.size()).thenReturn(100);

        assertEquals(100, spy.size());  // Mock된 값
        assertEquals("one", spy.get(0));  // 실제 값
    }
}

테스트 주도 개발(TDD) 실천하기

TDD 사이클

Red-Green-Refactor 사이클

// 1. RED: 실패하는 테스트 작성
public class CalculatorTest {

    @Test
    void testAdd() {
        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 a + b;
    }
}

실전 TDD 예제: 간단한 계산기

// Step 1: RED - 테스트 작성
public class CalculatorTest {
    private Calculator calculator;

    @BeforeEach
    void setUp() {
        calculator = new Calculator();
    }

    @Test
    @DisplayName("두 수의 덧셈")
    void testAdd() {
        assertEquals(5, calculator.add(2, 3));
        assertEquals(0, calculator.add(-1, 1));
        assertEquals(-5, calculator.add(-2, -3));
    }

    @Test
    @DisplayName("두 수의 뺄셈")
    void testSubtract() {
        assertEquals(1, calculator.subtract(3, 2));
        assertEquals(-1, calculator.subtract(2, 3));
    }

    @Test
    @DisplayName("두 수의 곱셈")
    void testMultiply() {
        assertEquals(6, calculator.multiply(2, 3));
        assertEquals(0, calculator.multiply(5, 0));
    }

    @Test
    @DisplayName("두 수의 나눗셈")
    void testDivide() {
        assertEquals(2, calculator.divide(6, 3));
        assertEquals(2.5, calculator.divide(5, 2), 0.001);
    }

    @Test
    @DisplayName("0으로 나누기 시 예외 발생")
    void testDivideByZero() {
        assertThrows(ArithmeticException.class, () -> {
            calculator.divide(5, 0);
        });
    }
}

// Step 2: GREEN - 최소한의 구현
public class Calculator {
    public int add(int a, int b) {
        return a + b;
    }

    public int subtract(int a, int b) {
        return a - b;
    }

    public int multiply(int a, int b) {
        return a * b;
    }

    public double divide(int a, int b) {
        if (b == 0) {
            throw new ArithmeticException("Cannot divide by zero");
        }
        return (double) a / b;
    }
}

// Step 3: REFACTOR - 개선 (테스트는 계속 통과)
public class Calculator {
    // 메서드 시그니처 개선
    public double add(double a, double b) {
        return a + b;
    }

    public double subtract(double a, double b) {
        return a - b;
    }

    public double multiply(double a, double b) {
        return a * b;
    }

    public double divide(double a, double b) {
        if (b == 0) {
            throw new ArithmeticException("Cannot divide by zero");
        }
        return a / b;
    }
}

9.3 빌드 & 의존성 관리

Maven vs Gradle: 빌드 도구 선택 가이드

Maven 기본 사용법

pom.xml 예제

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 
         http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.example</groupId>
    <artifactId>my-app</artifactId>
    <version>1.0.0</version>
    <packaging>jar</packaging>

    <properties>
        <maven.compiler.source>17</maven.compiler.source>
        <maven.compiler.target>17</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <junit.version>5.9.2</junit.version>
    </properties>

    <dependencies>
        <!-- JUnit 5 -->
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter</artifactId>
            <version>${junit.version}</version>
            <scope>test</scope>
        </dependency>

        <!-- Mockito -->
        <dependency>
            <groupId>org.mockito</groupId>
            <artifactId>mockito-core</artifactId>
            <version>5.3.1</version>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.11.0</version>
                <configuration>
                    <source>17</source>
                    <target>17</target>
                </configuration>
            </plugin>

            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-surefire-plugin</artifactId>
                <version>3.0.0</version>
            </plugin>
        </plugins>
    </build>
</project>

Gradle 기본 사용법

build.gradle 예제

plugins {
    id 'java'
    id 'application'
}

group = 'com.example'
version = '1.0.0'
sourceCompatibility = '17'
targetCompatibility = '17'

repositories {
    mavenCentral()
}

dependencies {
    // JUnit 5
    testImplementation 'org.junit.jupiter:junit-jupiter:5.9.2'

    // Mockito
    testImplementation 'org.mockito:mockito-core:5.3.1'
}

test {
    useJUnitPlatform()
}

application {
    mainClass = 'com.example.Main'
}

Maven vs Gradle 비교

특징 Maven Gradle
설정 파일 XML (pom.xml) Groovy/Kotlin DSL (build.gradle)
가독성 XML로 인해 다소 장황 더 간결하고 읽기 쉬움
빌드 속도 상대적으로 느림 증분 빌드로 빠름
플러그인 풍부하지만 설정 복잡 간단한 설정
멀티 프로젝트 설정 복잡 간단한 설정
학습 곡선 낮음 (XML) 중간 (DSL)

9.4 실전 프로젝트

REST API 서버 구축 (Spring Boot 없이)

Java 11+ HttpServer 활용

import com.sun.net.httpserver.*;
import java.io.*;
import java.net.*;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.concurrent.*;

public class SimpleHttpServer {
    private HttpServer server;
    private final int port;
    private final Map<String, HttpHandler> routes = new HashMap<>();

    public SimpleHttpServer(int port) {
        this.port = port;
    }

    public void start() throws IOException {
        server = HttpServer.create(new InetSocketAddress(port), 0);

        // 라우팅 설정
        routes.forEach((path, handler) -> {
            server.createContext(path, handler);
        });

        // 기본 핸들러
        server.createContext("/", this::handleRoot);

        server.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
        server.start();
        System.out.println("Server started on port " + port);
    }

    public void stop() {
        if (server != null) {
            server.stop(0);
            System.out.println("Server stopped");
        }
    }

    public void get(String path, HttpHandler handler) {
        routes.put(path, exchange -> {
            if ("GET".equals(exchange.getRequestMethod())) {
                handler.handle(exchange);
            } else {
                sendResponse(exchange, 405, "Method Not Allowed");
            }
        });
    }

    public void post(String path, HttpHandler handler) {
        routes.put(path, exchange -> {
            if ("POST".equals(exchange.getRequestMethod())) {
                handler.handle(exchange);
            } else {
                sendResponse(exchange, 405, "Method Not Allowed");
            }
        });
    }

    private void handleRoot(HttpExchange exchange) throws IOException {
        String response = "Hello from Simple HTTP Server!";
        sendResponse(exchange, 200, response);
    }

    private void sendResponse(HttpExchange exchange, int statusCode, String response) 
            throws IOException {
        exchange.getResponseHeaders().set("Content-Type", "application/json; charset=UTF-8");
        exchange.sendResponseHeaders(statusCode, response.getBytes(StandardCharsets.UTF_8).length);

        try (OutputStream os = exchange.getResponseBody()) {
            os.write(response.getBytes(StandardCharsets.UTF_8));
        }
    }

    // 사용 예제
    public static void main(String[] args) throws IOException {
        SimpleHttpServer server = new SimpleHttpServer(8080);

        // GET 엔드포인트
        server.get("/api/users", exchange -> {
            String response = """
                {
                    "users": [
                        {"id": 1, "name": "John"},
                        {"id": 2, "name": "Jane"}
                    ]
                }
                """;
            server.sendResponse(exchange, 200, response);
        });

        // POST 엔드포인트
        server.post("/api/users", exchange -> {
            String requestBody = new String(
                exchange.getRequestBody().readAllBytes(),
                StandardCharsets.UTF_8
            );

            // 요청 처리 로직
            String response = """
                {
                    "status": "created",
                    "message": "User created successfully"
                }
                """;
            server.sendResponse(exchange, 201, response);
        });

        server.start();

        // 서버 종료를 위한 대기
        System.out.println("Press Enter to stop the server...");
        System.in.read();
        server.stop();
    }
}

마치며

이번 글에서는 실무에서 바로 적용할 수 있는 Java 베스트 프랙티스와 실전 프로젝트를 다뤘습니다.

핵심 요약:

코드 품질:

  1. Effective Java: 정적 팩토리 메서드, 불필요한 객체 생성 피하기, equals/hashCode 규칙
  2. 예외 처리: Checked vs Unchecked Exception, Try-with-resources
  3. Clean Code: 의미 있는 이름, 단일 책임 원칙

테스트:

  1. JUnit 5: 현대적인 테스트 프레임워크 활용
  2. TDD: Red-Green-Refactor 사이클로 견고한 코드 작성
  3. Mockito: 의존성 격리를 통한 단위 테스트

빌드 도구:

  1. Maven: XML 기반의 안정적인 빌드 도구
  2. Gradle: 빠르고 유연한 빌드 도구

실전 프로젝트:

  1. REST API 서버: Spring Boot 없이 순수 Java로 구현
  2. 테스트 주도 개발: 실전 TDD 예제

실무 적용 팁:

  1. 점진적 개선: 한 번에 모든 것을 바꾸지 말고, 작은 것부터 개선
  2. 코드 리뷰: 팀원들과 함께 베스트 프랙티스 공유
  3. 테스트 커버리지: 최소 70% 이상의 테스트 커버리지 목표
  4. 리팩토링: 기능 추가 전 기존 코드 리팩토링
반응형