들어가며
"이 코드 누가 짠 거야?" 레거시 프로젝트를 인수인계 받으면 가장 먼저 드는 생각입니다. 그런데 git blame을 해보면 3개월 전의 내가 짠 코드일 때도 있습니다. 깨끗한 코드를 작성하는 것은 단순히 미적 감각의 문제가 아닙니다. 코드의 가독성은 곧 유지보수 비용이고, 유지보수 비용은 곧 팀의 생산성입니다.
이 글에서는 실무에서 자주 만나는 7가지 안티패턴과 그 리팩토링 방법을 Before/After 코드로 보여드리겠습니다. 모든 예제는 Java/Spring 기반이며, 내일 당장 여러분의 코드베이스에 적용할 수 있는 실용적인 패턴들입니다.
패턴 1: God Class 분해 - 단일 책임 원칙(SRP)
하나의 클래스가 너무 많은 책임을 지고 있으면 변경 이유가 많아지고, 테스트가 어려워집니다.
Before: God Class
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
private final ProductRepository productRepository;
private final UserRepository userRepository;
private final PaymentGateway paymentGateway;
private final EmailSender emailSender;
private final SmsSender smsSender;
private final SlackNotifier slackNotifier;
private final InventoryRepository inventoryRepository;
// 주문 생성 (비즈니스 로직)
public OrderResponse createOrder(OrderRequest request) {
// 사용자 검증
User user = userRepository.findById(request.getUserId())
.orElseThrow(() -> new RuntimeException("사용자 없음"));
// 재고 확인 및 차감
for (OrderItemRequest item : request.getItems()) {
Inventory inv = inventoryRepository.findByProductId(item.getProductId());
if (inv.getQuantity() < item.getQuantity()) {
throw new RuntimeException("재고 부족");
}
inv.decrease(item.getQuantity());
inventoryRepository.save(inv);
}
// 결제 처리
PaymentResult result = paymentGateway.charge(
user.getPaymentMethod(), calculateTotal(request));
if (!result.isSuccess()) {
throw new RuntimeException("결제 실패");
}
// 주문 저장
Order order = Order.create(user, request.getItems());
orderRepository.save(order);
// 이메일 알림
emailSender.send(user.getEmail(), "주문 완료",
buildEmailBody(order));
// SMS 알림
smsSender.send(user.getPhone(), "주문이 완료되었습니다.");
// Slack 알림 (운영팀)
slackNotifier.notify("#orders", "새 주문: " + order.getId());
return OrderResponse.from(order);
}
// ... 200줄 이상의 private 메서드들
}
After: 책임 분리
// 1. 주문 서비스 - 오케스트레이션만 담당
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderValidator orderValidator;
private final InventoryService inventoryService;
private final PaymentService paymentService;
private final OrderRepository orderRepository;
private final OrderNotifier orderNotifier;
@Transactional
public OrderResponse createOrder(OrderRequest request) {
// 각 책임을 위임
User user = orderValidator.validateUser(request.getUserId());
inventoryService.reserve(request.getItems());
paymentService.charge(user, request.calculateTotal());
Order order = Order.create(user, request.getItems());
orderRepository.save(order);
orderNotifier.notifyOrderCreated(order, user);
return OrderResponse.from(order);
}
}
// 2. 재고 서비스 - 재고 관련 책임
@Service
@RequiredArgsConstructor
public class InventoryService {
private final InventoryRepository inventoryRepository;
@Transactional
public void reserve(List<OrderItemRequest> items) {
items.forEach(item -> {
Inventory inv = inventoryRepository
.findByProductIdWithLock(item.getProductId())
.orElseThrow(() -> new ProductNotFoundException(
item.getProductId()));
inv.reserve(item.getQuantity());
});
}
}
// 3. 알림 서비스 - 알림 관련 책임 (비동기)
@Service
@RequiredArgsConstructor
public class OrderNotifier {
private final EmailSender emailSender;
private final SmsSender smsSender;
private final SlackNotifier slackNotifier;
@Async
@EventListener
public void notifyOrderCreated(Order order, User user) {
emailSender.send(user.getEmail(), "주문 완료",
buildEmailBody(order));
smsSender.send(user.getPhone(), "주문이 완료되었습니다.");
slackNotifier.notify("#orders", "새 주문: " + order.getId());
}
}
효과: 각 클래스가 하나의 이유로만 변경됩니다. 결제 로직이 바뀌면 PaymentService만, 알림 채널이 추가되면 OrderNotifier만 수정하면 됩니다.
패턴 2: 긴 파라미터 목록 줄이기
메서드 파라미터가 4개 이상이면 가독성이 급격히 떨어집니다.
Before: 파라미터 지옥
public User createUser(String name, String email, String phone,
String address, String zipCode, String city,
String role, boolean isActive,
LocalDate birthDate) {
// ...
}
// 호출 시 - 어떤 파라미터가 뭔지 알 수 없음
createUser("김승원", "kim@email.com", "010-1234-5678",
"강남대로 123", "06000", "서울", "ADMIN", true,
LocalDate.of(1990, 1, 1));
After: DTO/Builder 패턴
// 요청 DTO
public record CreateUserRequest(
@NotBlank String name,
@Email String email,
@NotBlank String phone,
@Valid Address address,
@NotNull UserRole role,
@NotNull LocalDate birthDate
) {
public record Address(
@NotBlank String street,
@NotBlank String zipCode,
@NotBlank String city
) {}
}
// 호출 시 - 명확한 구조
CreateUserRequest request = new CreateUserRequest(
"김승원",
"kim@email.com",
"010-1234-5678",
new CreateUserRequest.Address("강남대로 123", "06000", "서울"),
UserRole.ADMIN,
LocalDate.of(1990, 1, 1)
);
userService.createUser(request);
패턴 3: 깊은 중첩 펼치기 - Early Return
if문이 3단계 이상 중첩되면 코드 흐름을 따라가기 어렵습니다.
Before: 중첩 지옥
public OrderResponse processOrder(Long orderId) {
Order order = orderRepository.findById(orderId).orElse(null);
if (order != null) {
if (order.getStatus() == OrderStatus.PENDING) {
User user = userRepository.findById(order.getUserId()).orElse(null);
if (user != null) {
if (user.isActive()) {
Payment payment = paymentService.getPayment(order.getPaymentId());
if (payment != null && payment.isCompleted()) {
order.complete();
orderRepository.save(order);
return OrderResponse.from(order);
} else {
throw new RuntimeException("결제 미완료");
}
} else {
throw new RuntimeException("비활성 사용자");
}
} else {
throw new RuntimeException("사용자 없음");
}
} else {
throw new RuntimeException("대기 상태가 아님");
}
} else {
throw new RuntimeException("주문 없음");
}
}
After: Early Return (Guard Clause)
public OrderResponse processOrder(Long orderId) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new OrderNotFoundException(orderId));
if (order.getStatus() != OrderStatus.PENDING) {
throw new InvalidOrderStateException(order.getStatus());
}
User user = userRepository.findById(order.getUserId())
.orElseThrow(() -> new UserNotFoundException(order.getUserId()));
if (!user.isActive()) {
throw new InactiveUserException(user.getId());
}
Payment payment = paymentService.getPayment(order.getPaymentId());
if (payment == null || !payment.isCompleted()) {
throw new PaymentNotCompletedException(order.getPaymentId());
}
order.complete();
orderRepository.save(order);
return OrderResponse.from(order);
}
효과: 예외 상황을 먼저 걸러내고, 핵심 로직은 메서드 마지막에 위치합니다. 들여쓰기가 1~2단계로 줄어들어 흐름이 명확해집니다.
패턴 4: 매직 넘버/문자열 제거
코드 안에 의미를 알 수 없는 숫자나 문자열이 있으면 이해하기 어렵습니다.
Before: 매직 넘버
public BigDecimal calculateShippingFee(Order order) {
if (order.getTotalAmount().compareTo(new BigDecimal("50000")) >= 0) {
return BigDecimal.ZERO;
}
if (order.getWeight() > 20.0) {
return new BigDecimal("5000");
}
if (order.getRegion().equals("jeju")) {
return new BigDecimal("3000");
}
return new BigDecimal("2500");
}
After: 의미 있는 상수
public class ShippingPolicy {
private static final BigDecimal FREE_SHIPPING_THRESHOLD =
new BigDecimal("50000");
private static final double HEAVY_WEIGHT_KG = 20.0;
private static final BigDecimal HEAVY_ITEM_FEE =
new BigDecimal("5000");
private static final BigDecimal JEJU_EXTRA_FEE =
new BigDecimal("3000");
private static final BigDecimal STANDARD_FEE =
new BigDecimal("2500");
private static final String JEJU_REGION = "jeju";
public BigDecimal calculateShippingFee(Order order) {
if (isFreeShipping(order)) {
return BigDecimal.ZERO;
}
if (isHeavyItem(order)) {
return HEAVY_ITEM_FEE;
}
if (isJejuRegion(order)) {
return JEJU_EXTRA_FEE;
}
return STANDARD_FEE;
}
private boolean isFreeShipping(Order order) {
return order.getTotalAmount()
.compareTo(FREE_SHIPPING_THRESHOLD) >= 0;
}
private boolean isHeavyItem(Order order) {
return order.getWeight() > HEAVY_WEIGHT_KG;
}
private boolean isJejuRegion(Order order) {
return JEJU_REGION.equals(order.getRegion());
}
}
패턴 5: Optional 제대로 쓰기
Optional은 null을 다루는 도구이지만, 잘못 사용하면 오히려 코드가 지저분해집니다.
Before: Optional 안티패턴들
// 안티패턴 1: isPresent() + get()
Optional<User> userOpt = userRepository.findById(id);
if (userOpt.isPresent()) {
User user = userOpt.get();
return UserResponse.from(user);
} else {
throw new UserNotFoundException(id);
}
// 안티패턴 2: Optional을 파라미터로 사용
public void updateUser(Long id, Optional<String> name,
Optional<String> email) {
// Optional은 반환 타입용이지, 파라미터용이 아닙니다
}
// 안티패턴 3: Optional.of(null 가능 값)
Optional<String> opt = Optional.of(maybeNull); // NPE 위험!
// 안티패턴 4: Optional을 필드로 사용
@Entity
public class User {
private Optional<String> nickname; // 직렬화 문제 발생
}
After: Optional 올바른 사용법
// 패턴 1: orElseThrow - 존재하지 않으면 예외
User user = userRepository.findById(id)
.orElseThrow(() -> new UserNotFoundException(id));
// 패턴 2: map + orElse - 변환 후 기본값
String displayName = userRepository.findById(id)
.map(User::getNickname)
.orElse("익명 사용자");
// 패턴 3: filter - 조건부 처리
userRepository.findByEmail(email)
.filter(User::isActive)
.orElseThrow(() -> new InactiveUserException(email));
// 패턴 4: ifPresent - 값이 있을 때만 실행
userRepository.findById(id)
.ifPresent(user -> notificationService.sendWelcome(user));
// 패턴 5: or - 대체 소스에서 조회
User user = userCache.findById(id)
.or(() -> userRepository.findById(id))
.orElseThrow(() -> new UserNotFoundException(id));
// 패턴 6: stream - 스트림과 결합
List<UserResponse> responses = userIds.stream()
.map(userRepository::findById)
.flatMap(Optional::stream) // 존재하는 것만 필터링
.map(UserResponse::from)
.toList();
패턴 6: Enum 활용한 전략 패턴
if-else 또는 switch문으로 분기가 많아지면 Enum에 전략을 위임할 수 있습니다.
Before: 분기문 지옥
public BigDecimal calculateDiscount(String memberGrade, BigDecimal amount) {
if ("BRONZE".equals(memberGrade)) {
return amount.multiply(new BigDecimal("0.01"));
} else if ("SILVER".equals(memberGrade)) {
return amount.multiply(new BigDecimal("0.03"));
} else if ("GOLD".equals(memberGrade)) {
BigDecimal discount = amount.multiply(new BigDecimal("0.05"));
BigDecimal maxDiscount = new BigDecimal("10000");
return discount.compareTo(maxDiscount) > 0 ? maxDiscount : discount;
} else if ("PLATINUM".equals(memberGrade)) {
BigDecimal discount = amount.multiply(new BigDecimal("0.10"));
BigDecimal maxDiscount = new BigDecimal("50000");
return discount.compareTo(maxDiscount) > 0 ? maxDiscount : discount;
} else {
return BigDecimal.ZERO;
}
}
After: Enum 전략 패턴
@Getter
@RequiredArgsConstructor
public enum MemberGrade {
BRONZE("브론즈", new BigDecimal("0.01"), null) {
@Override
public BigDecimal calculateDiscount(BigDecimal amount) {
return amount.multiply(discountRate);
}
},
SILVER("실버", new BigDecimal("0.03"), null) {
@Override
public BigDecimal calculateDiscount(BigDecimal amount) {
return amount.multiply(discountRate);
}
},
GOLD("골드", new BigDecimal("0.05"), new BigDecimal("10000")) {
@Override
public BigDecimal calculateDiscount(BigDecimal amount) {
BigDecimal discount = amount.multiply(discountRate);
return maxDiscount != null && discount.compareTo(maxDiscount) > 0
? maxDiscount : discount;
}
},
PLATINUM("플래티넘", new BigDecimal("0.10"), new BigDecimal("50000")) {
@Override
public BigDecimal calculateDiscount(BigDecimal amount) {
BigDecimal discount = amount.multiply(discountRate);
return maxDiscount != null && discount.compareTo(maxDiscount) > 0
? maxDiscount : discount;
}
};
private final String displayName;
protected final BigDecimal discountRate;
protected final BigDecimal maxDiscount;
public abstract BigDecimal calculateDiscount(BigDecimal amount);
}
// 사용 코드 - 분기문이 완전히 사라짐
public BigDecimal calculateDiscount(MemberGrade grade, BigDecimal amount) {
return grade.calculateDiscount(amount);
}
// 더 깔끔한 방법: 함수형 인터페이스 활용
@RequiredArgsConstructor
public enum MemberGradeV2 {
BRONZE("브론즈", amount -> amount.multiply(new BigDecimal("0.01"))),
SILVER("실버", amount -> amount.multiply(new BigDecimal("0.03"))),
GOLD("골드", amount -> {
BigDecimal discount = amount.multiply(new BigDecimal("0.05"));
return discount.min(new BigDecimal("10000"));
}),
PLATINUM("플래티넘", amount -> {
BigDecimal discount = amount.multiply(new BigDecimal("0.10"));
return discount.min(new BigDecimal("50000"));
});
private final String displayName;
private final UnaryOperator<BigDecimal> discountStrategy;
public BigDecimal calculateDiscount(BigDecimal amount) {
return discountStrategy.apply(amount);
}
}
효과: 새로운 등급이 추가되면 Enum에 항목 하나만 추가하면 됩니다. if-else 분기를 찾아다닐 필요가 없고, 컴파일 타임에 누락을 방지할 수 있습니다.
패턴 7: 테스트 가능한 코드로 변환
테스트가 어려운 코드는 대부분 설계가 잘못된 것입니다. 의존성을 주입 가능하게 만들면 자연스럽게 테스트하기 쉬운 코드가 됩니다.
Before: 테스트 불가능한 코드
@Service
public class ReportService {
public Report generateDailyReport() {
// 문제 1: 현재 시간에 직접 의존 → 특정 날짜 테스트 불가
LocalDate today = LocalDate.now();
// 문제 2: static 메서드 호출 → Mock 불가
List<Order> orders = OrderRepository.findByDate(today);
// 문제 3: new로 직접 생성 → 대체 불가
ExcelExporter exporter = new ExcelExporter();
byte[] excel = exporter.export(orders);
// 문제 4: 외부 API 직접 호출 → 테스트 시 실제 호출됨
HttpClient client = HttpClient.newHttpClient();
client.send(HttpRequest.newBuilder()
.uri(URI.create("https://api.slack.com/notify"))
.POST(BodyPublishers.ofByteArray(excel))
.build(), BodyHandlers.ofString());
return new Report(today, orders.size());
}
}
After: 테스트 가능한 코드
// 시간을 인터페이스로 추상화
public interface TimeProvider {
LocalDate today();
LocalDateTime now();
}
@Component
public class SystemTimeProvider implements TimeProvider {
@Override
public LocalDate today() { return LocalDate.now(); }
@Override
public LocalDateTime now() { return LocalDateTime.now(); }
}
// 내보내기 인터페이스
public interface ReportExporter {
byte[] export(List<Order> orders);
}
@Component
public class ExcelReportExporter implements ReportExporter {
@Override
public byte[] export(List<Order> orders) { /* ... */ }
}
// 알림 인터페이스
public interface ReportNotifier {
void notify(byte[] report);
}
@Component
public class SlackReportNotifier implements ReportNotifier {
@Override
public void notify(byte[] report) { /* Slack API 호출 */ }
}
// 리팩토링된 서비스 - 모든 의존성이 주입 가능
@Service
@RequiredArgsConstructor
public class ReportService {
private final OrderRepository orderRepository;
private final TimeProvider timeProvider;
private final ReportExporter reportExporter;
private final ReportNotifier reportNotifier;
public Report generateDailyReport() {
LocalDate today = timeProvider.today();
List<Order> orders = orderRepository.findByDate(today);
byte[] exported = reportExporter.export(orders);
reportNotifier.notify(exported);
return new Report(today, orders.size());
}
}
// 테스트 코드
@ExtendWith(MockitoExtension.class)
class ReportServiceTest {
@Mock private OrderRepository orderRepository;
@Mock private TimeProvider timeProvider;
@Mock private ReportExporter reportExporter;
@Mock private ReportNotifier reportNotifier;
@InjectMocks private ReportService reportService;
@Test
void 일일_리포트를_생성한다() {
// given
LocalDate fixedDate = LocalDate.of(2025, 6, 15);
given(timeProvider.today()).willReturn(fixedDate);
List<Order> orders = List.of(
Order.builder().id(1L).build(),
Order.builder().id(2L).build()
);
given(orderRepository.findByDate(fixedDate)).willReturn(orders);
given(reportExporter.export(orders)).willReturn(new byte[]{1, 2, 3});
// when
Report report = reportService.generateDailyReport();
// then
assertThat(report.getDate()).isEqualTo(fixedDate);
assertThat(report.getOrderCount()).isEqualTo(2);
verify(reportNotifier).notify(any());
}
@Test
void 주문이_없으면_빈_리포트를_반환한다() {
// given
LocalDate fixedDate = LocalDate.of(2025, 1, 1);
given(timeProvider.today()).willReturn(fixedDate);
given(orderRepository.findByDate(fixedDate))
.willReturn(Collections.emptyList());
given(reportExporter.export(anyList())).willReturn(new byte[0]);
// when
Report report = reportService.generateDailyReport();
// then
assertThat(report.getOrderCount()).isZero();
}
}
리팩토링 체크리스트
코드 리뷰나 리팩토링 시 참고할 수 있는 체크리스트입니다.
| 항목 | 질문 | 관련 패턴 |
|---|---|---|
| 클래스 크기 | 이 클래스가 변경되는 이유가 2가지 이상인가? | 패턴 1: God Class 분해 |
| 메서드 시그니처 | 파라미터가 4개 이상인가? | 패턴 2: 파라미터 줄이기 |
| 들여쓰기 | 3단계 이상 중첩되는가? | 패턴 3: Early Return |
| 숫자/문자열 | 의미를 알 수 없는 리터럴이 있는가? | 패턴 4: 매직 넘버 제거 |
| null 처리 | Optional을 올바르게 사용하고 있는가? | 패턴 5: Optional 활용 |
| 분기 로직 | if-else가 3개 이상 연속되는가? | 패턴 6: Enum 전략 패턴 |
| 테스트 가능성 | 이 메서드를 단위 테스트할 수 있는가? | 패턴 7: 의존성 주입 |
마치며
리팩토링은 한 번에 모든 것을 고치는 것이 아닙니다. 보이스카우트 규칙(캠프장을 떠날 때 왔을 때보다 깨끗하게)처럼, 코드를 수정할 때마다 조금씩 개선하는 것이 핵심입니다.
- 패턴 1 (God Class 분해): 하나의 클래스에 하나의 책임만 부여하세요. 서비스 클래스가 200줄이 넘는다면 분리 시점입니다.
- 패턴 2 (파라미터 줄이기): DTO와 Record를 적극 활용하세요. Java 17+의 Record는 이 용도에 완벽합니다.
- 패턴 3 (Early Return): 예외 상황을 먼저 처리하고, 핵심 로직을 메서드 끝에 두세요.
- 패턴 4 (매직 넘버 제거): 모든 리터럴에 이름을 붙이세요. "왜 50000인가?"라는 질문이 나오면 안 됩니다.
- 패턴 5 (Optional): orElseThrow, map, filter를 적극 사용하고, isPresent()+get() 조합은 피하세요.
- 패턴 6 (Enum 전략): 타입별 분기 로직은 Enum에 위임하세요. 새로운 타입 추가 시 수정 범위가 최소화됩니다.
- 패턴 7 (테스트 가능성): new, static, 현재 시간 직접 참조를 인터페이스로 추상화하세요.
이 7가지 패턴만 습관적으로 적용해도, 6개월 뒤의 나 자신과 팀 동료들에게 감사 인사를 받을 수 있을 것입니다.
'Java' 카테고리의 다른 글
| Java 디자인 패턴 실전 - 실무에서 자주 쓰는 10가지 패턴 (0) | 2026.04.07 |
|---|---|
| JVM 메모리 구조와 GC 튜닝 - G1, ZGC, Shenandoah 완벽 비교 (0) | 2026.04.07 |
| Java 22~26 새 기능 총정리 - Structured Concurrency부터 Primitive Types까지 (0) | 2026.04.07 |
| Java 동시성 프로그래밍 완벽 가이드 - synchronized부터 Virtual Threads까지 (0) | 2026.03.30 |
| Java 17~21 새 기능 총정리 - 실무에서 바로 쓸 수 있는 핵심 기능들 (0) | 2026.03.25 |