여러 프로젝트를 진행하면서 예외 처리 방식에 대한 고민이 깊어졌다. 특히 회원가입이나 주문 검증 같은 기능을 구현할 때마다 if-else문이 반복적으로 중첩되었고, 코드를 작성할 때는 문제 없다고 생각했지만 며칠 후 다시 보면 흐름을 파악하기 어려웠다. 단순히 조건을 검사하는 코드임에도 불구하고, 중첩이 깊어질수록 가독성이 급격히 떨어지는 것이 체감됐다.
실무에서는 이런 코드 중첩 문제를 어떻게 해결하고, 어떤 예외 처리 방식을 사용하는지 궁금해 관련 자료를 찾아보던 중 코딩애플의 "더러운 if문 예쁘게 바꾸기"라는 영상을 접하게 되었다. 이 영상을 통해 중첩된 조건문을 개선하는 여러 기법을 학습할 수 있었고, 이를 정리하여 나중에 비슷한 상황에 마주쳤을 때 참고할 수 있도록 문서화하기로 했다.
Nesting(중첩)은 조건문이나 반복문이 여러 단계로 깊게 들어가 있는 구조를 의미한다. if문이 중첩되면 코드가 왼쪽에서 오른쪽으로 삼각형 모양을 그리며 들여쓰기가 깊어지는 형태가 된다.
if (나이 > 19) {
if (간경화 == false) {
System.out.println("음주 가능");
if (수술 == true) {
System.out.println("음주 아마 불가능");
}
}
} else {
System.out.println("음주 불가능");
}
Linux Kernel coding style guide에서는 "3번 이상 nesting된 코드는 좋지 않은 코드"라고 명시하고 있다. 그 이유는 다음과 같다.
실제 회원가입 기능을 구현한다고 가정하자. 다음과 같은 검증 과정이 필요하다.
이를 중첩된 if문으로 작성하면:
public class SignupService {
public void signup(String username, String password, String email) {
if (!isDuplicate(username)) {
if (isValidUsernameLength(username)) {
if (isValidPasswordLength(password)) {
if (hasSpecialChar(password)) {
if (isValidEmail(email)) {
// 회원가입 처리
createUser(username, password, email);
System.out.println("가입 성공");
} else {
throw new IllegalArgumentException("이메일 형식이 올바르지 않습니다");
}
} else {
throw new IllegalArgumentException("비밀번호에 특수문자가 필요합니다");
}
} else {
throw new IllegalArgumentException("비밀번호가 너무 짧습니다");
}
} else {
throw new IllegalArgumentException("아이디 길이가 적절하지 않습니다");
}
} else {
throw new IllegalArgumentException("아이디가 중복됩니다");
}
}
}
위의 코드에서 실제로 사용된 것은 if-else문밖에 없지만, 5단계로 중첩되어 있어 가독성이 매우 떨어진다. 하지만 실제 로직에서는 이보다 더 많은 검증 과정이 필요할 수 있고, if-else 외의 비즈니스 로직이 추가될 가능성이 높다.
여러 조건을 논리 연산자로 묶어 평탄화하는 방법이다.
public void signup(String username, String password, String email) {
if (!isDuplicate(username) &&
isValidUsernameLength(username) &&
isValidPasswordLength(password) &&
hasSpecialChar(password) &&
isValidEmail(email)) {
createUser(username, password, email);
System.out.println("가입 성공");
} else {
throw new IllegalArgumentException("입력값이 올바르지 않습니다");
}
}
장점:
단점:
예외적인 상황을 먼저 처리하고 조기에 반환하는 패턴이다. Fail-Fast 원칙과 연결되는 개념으로, 문제가 발견되면 즉시 실행을 중단한다.
public void signup(String username, String password, String email) {
if (isDuplicate(username)) {
throw new IllegalArgumentException("아이디가 중복됩니다");
}
if (!isValidUsernameLength(username)) {
throw new IllegalArgumentException("아이디 길이가 적절하지 않습니다");
}
if (!isValidPasswordLength(password)) {
throw new IllegalArgumentException("비밀번호가 너무 짧습니다");
}
if (!hasSpecialChar(password)) {
throw new IllegalArgumentException("비밀번호에 특수문자가 필요합니다");
}
if (!isValidEmail(email)) {
throw new IllegalArgumentException("이메일 형식이 올바르지 않습니다");
}
// 모든 검증을 통과한 경우에만 실행
createUser(username, password, email);
System.out.println("가입 성공");
}
장점:
단점:
관련 있는 검증 로직을 별도 메서드로 분리하는 방법이다.
public class SignupService {
public void signup(String username, String password, String email) {
validateUsername(username);
validatePassword(password);
validateEmail(email);
createUser(username, password, email);
System.out.println("가입 성공");
}
private void validateUsername(String username) {
if (isDuplicate(username)) {
throw new IllegalArgumentException("아이디가 중복됩니다");
}
if (!isValidUsernameLength(username)) {
throw new IllegalArgumentException("아이디 길이가 적절하지 않습니다");
}
}
private void validatePassword(String password) {
if (!isValidPasswordLength(password)) {
throw new IllegalArgumentException("비밀번호가 너무 짧습니다");
}
if (!hasSpecialChar(password)) {
throw new IllegalArgumentException("비밀번호에 특수문자가 필요합니다");
}
}
private void validateEmail(String email) {
if (!isValidEmail(email)) {
throw new IllegalArgumentException("이메일 형식이 올바르지 않습니다");
}
}
}
장점:
단점:
언제 함수로 분리해야 하는가?
검증 로직을 함수로 분리하는 목적은 재사용성도 있지만, 다음과 같은 이유도 있다:
경험 법칙:
Spring Boot 환경에서는 더 나은 방법들이 있다.
Spring의 @Valid와 Bean Validation 어노테이션을 활용하면 선언적으로 검증할 수 있다.
public class SignupRequest {
@NotBlank(message = "아이디는 필수입니다")
@Size(min = 4, max = 20, message = "아이디는 4~20자여야 합니다")
private String username;
@NotBlank(message = "비밀번호는 필수입니다")
@Size(min = 8, message = "비밀번호는 8자 이상이어야 합니다")
@Pattern(regexp = ".*[!@#$%^&*()].*", message = "비밀번호에 특수문자가 필요합니다")
private String password;
@NotBlank(message = "이메일은 필수입니다")
@Email(message = "이메일 형식이 올바르지 않습니다")
private String email;
// getter, setter
}
@RestController
public class SignupController {
@PostMapping("/signup")
public ResponseEntity<String> signup(@Valid @RequestBody SignupRequest request) {
// 여기 도달했다면 모든 검증을 통과한 것
// 중복 확인 같은 비즈니스 검증만 수행
if (userService.isDuplicate(request.getUsername())) {
throw new DuplicateUsernameException("아이디가 중복됩니다");
}
userService.createUser(request);
return ResponseEntity.ok("가입 성공");
}
}
장점:
복잡한 검증 로직은 Custom Validator로 구현할 수 있다.
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = UniqueUsernameValidator.class)
public @interface UniqueUsername {
String message() default "아이디가 이미 존재합니다";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
public class UniqueUsernameValidator implements ConstraintValidator<UniqueUsername, String> {
@Autowired
private UserRepository userRepository;
@Override
public boolean isValid(String username, ConstraintValidatorContext context) {
if (username == null) {
return true; // @NotBlank가 처리
}
return !userRepository.existsByUsername(username);
}
}
// 사용
public class SignupRequest {
@UniqueUsername
@Size(min = 4, max = 20)
private String username;
// ...
}
조건 분기가 복잡하고 자주 변경되는 경우, 전략 패턴을 사용할 수 있다.
public interface ValidationStrategy {
void validate(SignupRequest request);
}
@Component
public class UsernameValidationStrategy implements ValidationStrategy {
@Autowired
private UserRepository userRepository;
@Override
public void validate(SignupRequest request) {
if (userRepository.existsByUsername(request.getUsername())) {
throw new IllegalArgumentException("아이디가 중복됩니다");
}
if (request.getUsername().length() < 4 || request.getUsername().length() > 20) {
throw new IllegalArgumentException("아이디 길이가 적절하지 않습니다");
}
}
}
@Component
public class PasswordValidationStrategy implements ValidationStrategy {
@Override
public void validate(SignupRequest request) {
if (request.getPassword().length() < 8) {
throw new IllegalArgumentException("비밀번호가 너무 짧습니다");
}
if (!request.getPassword().matches(".*[!@#$%^&*()].*")) {
throw new IllegalArgumentException("비밀번호에 특수문자가 필요합니다");
}
}
}
@Service
public class SignupService {
private final List<ValidationStrategy> strategies;
public SignupService(List<ValidationStrategy> strategies) {
this.strategies = strategies;
}
public void signup(SignupRequest request) {
// 모든 검증 전략 실행
for (ValidationStrategy strategy : strategies) {
strategy.validate(request);
}
createUser(request);
}
}
장점:
코딩은 크게 두 가지 목적으로 나뉜다.