처음 마주한 코드는 기능적으로는 동작할지 몰라도, 장기적인 관점에서 심각한 문제를 내포하고 있었습니다.
특히, 아래의 문제들은 소프트웨어의 유지보수성과 확장성을 크게 저해하는 요소였습니다.
// 기존 코드 - 심각한 문제점들
try {
cnt = egovYeyakDAO.yeyakDelete(yeyakVO);
} catch (Exception e) {
// 빈 catch 블록: 예외가 발생해도 아무런 처리 없이 넘어간다.
}
// 매직 넘버와 하드코딩
if("AP01".equals(category.gettState())) {
TrainingListIdNm = "대기";
}
문제점 심층 분석:
catch
블록: 모든 예외를 포괄하는 Exception
을 잡고 아무것도 하지 않는 것은 가장 위험한 안티패턴 중 하나입니다.AP01
과 같은 **매직 넘버(Magic Number)**는 코드를 이해하기 어렵게 만듭니다.이러한 코드 구조는 단순히 기능 구현에만 급급했음을 보여주며, 안정적인 서비스 운영을 위한 기본적인 고려조차 이루어지지 않았다는 것을 의미합니다.
기존 코드는 단순히 리팩토링(Refactoring)만으로 해결될 수 있는 수준이 아니었습니다. 시스템 전반에 걸친 구조적 결함이 명확히 드러났기 때문에, 근본적인 설계 개편이 필요했습니다.
주요 구조적 문제:
Controller
)가 사용자 요청 처리뿐만 아니라 비즈니스 로직, 데이터 접근 로직까지 모두 수행하고 있었습니다. 이는 **단일 책임 원칙(Single Responsibility Principle)**을 위반하며, 각 계층의 역할을 모호하게 만듭니다.DAO
와 Service
계층이 존재했으나, 그 역할이 명확히 구분되지 않아 오히려 복잡성만 가중시켰습니다.이러한 문제들을 해결하기 위해 도메인 주도 설계(Domain-Driven Design, DDD) 패턴을 적용하기로 결정했습니다.
DDD는 비즈니스 로직을 중심으로 코드를 설계함으로써 복잡한 비즈니스 도메인을 효과적으로 모델링하고 관리할 수 있게 해줍니다.
기존 문제점: TrainingMasterVO
와 같은 객체는 단순히 데이터를 담아 전달하는 VO(Value Object)
에 불과했습니다. 이 객체 자체에는 어떤 비즈니스 로직도 담겨 있지 않았습니다.
개선 방향: 도메인 엔티티인 Training
을 중심으로 설계하여, 비즈니스 규칙과 상태를 캡슐화했습니다.
// 개선: 도메인 중심의 엔티티
@Entity
public class Training {
// 비즈니스 로직을 담은 메서드
public boolean canAcceptApplication() {
return !isFull() && schedule.isOpenForApplication();
}
public TrainingApplication createApplication(User user, ApplicationForm form) {
// 도메인 규칙을 엔티티 내부에서 검증
if (!canAcceptApplication()) {
throw new TrainingNotAvailableException("Training is not available for application");
}
return new TrainingApplication(this, user, form);
}
}
개선 의도:
canAcceptApplication()
과 같이 비즈니스 규칙을 메서드 이름으로 명확히 표현했습니다.TrainingSchedule
과 같은 **값 객체(Value Object)**로 묶어 관리하고, 불변성을 보장하여 데이터의 일관성을 유지했습니다.Training
엔티티 스스로가 자신의 상태를 변경하거나 비즈니스 규칙을 검증하게 함으로써, **응집도(Cohesion)**를 높이고 **낮은 결합도(Low Coupling)**를 달성했습니다.기존 문제점: Controller
에 비즈니스 로직과 데이터 접근 로직이 혼재되어 있었습니다.
개선 방향:
Controller
: HTTP 요청을 받아 유효성 검사 후 Service
계층으로 전달하고, 반환된 결과를 클라이언트에 응답하는 역할에만 집중했습니다.Service
: 여러 도메인 엔티티를 조율하고, 복잡한 비즈니스 로직을 처리하는 애플리케이션 서비스의 역할을 맡겼습니다. 트랜잭션 경계를 설정하고, 유효성 검사, 이벤트 발행 등을 담당하게 했습니다.Repository
: 데이터 영속성(Persistence)에 관련된 책임만 담당하게 하여 Service
계층이 데이터 접근 방식에 구애받지 않도록 했습니다.// 개선: 비즈니스 로직은 서비스 계층에서
@Service
@Transactional
public class TrainingApplicationService {
public ApplicationResult applyForTraining(...) {
// 1. 비즈니스 규칙 검증: 중복 신청, 정원 초과 등
// 2. 도메인 엔티티 조작: 신청 객체 생성, 상태 변경 등
// 3. 영속화: Repository를 통해 데이터베이스에 저장
// 4. 이벤트 발행: 후속 작업(알림 전송 등)을 위한 이벤트 발행
}
}
개선 의도:
Service
계층의 비즈니스 로직은 Controller
와 독립적으로 테스트할 수 있어 단위 테스트(Unit Test) 작성이 용이해졌습니다.기존 문제점: 예외를 무시하거나, 반환값으로 오류를 처리하는 등 일관성이 없었습니다. 이는 오류 발생 시 시스템의 신뢰성을 저하시켰습니다.
개선 방향: 예외를 **비즈니스 예외(Business Exception)**와 **기술적 예외(Technical Exception)**로 구분하고, 스프링의 @ControllerAdvice
를 이용한 전역 예외 핸들링을 도입했습니다.
// 커스텀 비즈니스 예외 정의
public class TrainingNotFoundException extends RuntimeException {
// ...
}
// 전역 예외 처리
@ControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(TrainingNotFoundException.class)
public ResponseEntity<ErrorResponse> handleTrainingNotFound(TrainingNotFoundException e) {
// ...
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(ErrorResponse.of("TRAINING_NOT_FOUND", e.getMessage()));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleUnexpected(Exception e) {
log.error("Unexpected error occurred", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(ErrorResponse.of("INTERNAL_SERVER_ERROR", "An unexpected error occurred"));
}
}
개선 의도:
TrainingNotFoundException
과 같이 비즈니스 도메인에 특화된 예외를 정의하여, 어떤 문제가 발생했는지 명확하게 전달했습니다.ErrorResponse
라는 통일된 형식으로 반환하여 클라이언트가 오류를 예측 가능하게 처리할 수 있게 했습니다.기존 문제점: 테스트 코드가 전혀 없었기 때문에 리팩토링이나 기능 추가 시 기존 기능의 오작동 여부를 확인할 수 없어 불안했습니다.
개선 방향: **단위 테스트(Unit Test)**와 **통합 테스트(Integration Test)**를 병행하여 작성했습니다.
@ExtendWith(MockitoExtension.class)
class TrainingApplicationServiceTest {
// @Mock, @InjectMocks를 사용해 외부 의존성을 격리
@Mock private TrainingRepository trainingRepository;
@InjectMocks private TrainingApplicationService service;
@Test
@DisplayName("정원이 남아있을 때 신청하면 승인 상태가 된다")
void shouldApproveWhenCapacityAvailable() {
// given, when, then 패턴 적용
// ...
// then
assertThat(result.getApplication().getStatus()).isEqualTo(ApplicationStatus.APPROVED);
// ...
}
}
개선 의도:
"정원이 남아있을 때 신청하면 승인 상태가 된다"
와 같은 테스트명은 해당 기능의 의도를 명확히 전달합니다.이러한 과정을 통해 얻은 가장 큰 깨달음은 단순히 동작하는 코드를 만드는 것과 잘 설계된 소프트웨어를 만드는 것은 전혀 다르다는 것입니다.
이제는 "이 코드는 어떤 비즈니스 규칙을 담고 있는가?",
"이 구조는 미래의 변화에 유연하게 대응할 수 있는가?"와
같은 질문을 끊임없이 던지며 개발에 임하고 있습니다.
이러한 관점의 전환이야말로 개발 역량을 한 단계 더 성장시키는 중요한 계기가 되었습니다.