Notice
Recent Posts
Recent Comments
Link
반응형
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | |||||
| 3 | 4 | 5 | 6 | 7 | 8 | 9 |
| 10 | 11 | 12 | 13 | 14 | 15 | 16 |
| 17 | 18 | 19 | 20 | 21 | 22 | 23 |
| 24 | 25 | 26 | 27 | 28 | 29 | 30 |
| 31 |
Tags
- 연습문제
- 백준
- Gradle
- 프로그래머스
- Java
- JPA
- 티스토리챌린지
- 트리
- 이진트리
- Unity
- code blocks
- redis
- 오블완
- 알고리즘
- jre
- 탐색
- bean
- docker
- event
- Kafka
- 플러스 백엔드
- jdk
- Spring
- Kotlin
- 아키텍처
- EDA
- 삽입
- 코딩테스트
- MSA
- stack
Archives
- Today
- Total
Repository
9단계: 실전 프로젝트 & 베스트 프랙티스 본문
반응형
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 베스트 프랙티스와 실전 프로젝트를 다뤘습니다.
핵심 요약:
코드 품질:
- Effective Java: 정적 팩토리 메서드, 불필요한 객체 생성 피하기, equals/hashCode 규칙
- 예외 처리: Checked vs Unchecked Exception, Try-with-resources
- Clean Code: 의미 있는 이름, 단일 책임 원칙
테스트:
- JUnit 5: 현대적인 테스트 프레임워크 활용
- TDD: Red-Green-Refactor 사이클로 견고한 코드 작성
- Mockito: 의존성 격리를 통한 단위 테스트
빌드 도구:
- Maven: XML 기반의 안정적인 빌드 도구
- Gradle: 빠르고 유연한 빌드 도구
실전 프로젝트:
- REST API 서버: Spring Boot 없이 순수 Java로 구현
- 테스트 주도 개발: 실전 TDD 예제
실무 적용 팁:
- 점진적 개선: 한 번에 모든 것을 바꾸지 말고, 작은 것부터 개선
- 코드 리뷰: 팀원들과 함께 베스트 프랙티스 공유
- 테스트 커버리지: 최소 70% 이상의 테스트 커버리지 목표
- 리팩토링: 기능 추가 전 기존 코드 리팩토링
반응형
'Java' 카테고리의 다른 글
| 8단계: Java 기능 (Java 17+) (0) | 2025.12.11 |
|---|---|
| 7단계: 디자인 패턴 & 아키텍처 (0) | 2025.12.11 |
| 6단계: JVM & 성능 최적화 (0) | 2025.12.11 |
| 5단계: Java 동시성 & 멀티스레딩 (0) | 2025.12.11 |
| 4단계: Java 함수형 프로그래밍 (Java 8+) (0) | 2025.12.11 |