Java 17 기준으로 유지보수하기 좋은 메서드를 만드는 핵심 규칙을 파라미터/반환 타입/오버로딩/메서드 길이 관점에서 정리합니다.
메서드를 “일단 동작하게” 만들어두면, 시간이 지나면서 파라미터가 계속 늘고 반환 값도 애매해져서 호출부가 복잡해지는 경험을 하실 때가 많습니다. 특히 오버로딩을 남발하거나, 너무 긴 메서드가 한 번에 여러 책임을 떠안기 시작하면 수정이 무서운 코드가 되기 쉽습니다.
핵심 개념: Java 메서드 설계에서 가장 중요한 4가지

1) 파라미터 설계: “적게, 명확하게, 안정적으로”
파라미터는 메서드의 “사용 설명서”입니다. 많아질수록 호출부는 실수하기 쉬워지고, 순서 기반(특히 같은 타입 여러 개)일수록 버그가 숨어들기 좋습니다.
- 0~2개가 이상적이고, 3개 이상이면 “이 메서드는 너무 많은 일을 하나?”를 의심해 보세요.
- 같은 타입이 반복되면
method(a, b, c)가 무슨 의미인지 호출부만 봐서는 알기 어렵습니다. - 선택 값(옵션)이 늘어날수록
null이 퍼지기 시작하는데,null은 “값이 없음”인지 “설정하지 않음”인지 의미가 섞이며 유지보수 비용을 올립니다.
파라미터가 많아지는 문제는 보통 아래 중 하나로 해결됩니다.
| 상황 | 나쁜 신호 | 개선 방향 |
|---|---|---|
| 값이 3개 이상 필요 | create(name, email, phone, address, ...) |
파라미터 객체(Record/DTO)로 묶기 |
| 같은 타입 여러 개 | range(int from, int to)는 괜찮지만 set(int a, int b, int c)는 위험 |
의미 있는 타입(값 객체) 도입 또는 빌더 |
| 옵션/플래그가 많음 | send(msg, true, false, 3) |
Enum/전략 객체로 의도를 드러내기 |
| null 허용이 많음 | find(id, null, null) |
Optional은 “반환”에, 파라미터는 오버로드/별도 메서드로 |
참고로
Optional은 파라미터로 받는 용도는 권장되지 않습니다(호출부가 장황해지고, 의미가 혼재되기 쉬움). 반환 타입에서 “없을 수 있음”을 표현할 때 더 자연스럽습니다.
2) 반환 타입 선택: 호출부를 단순하게 만드는 방향으로
반환 타입은 호출부의 제어 흐름을 바꿉니다. 좋은 반환 타입은 “호출자가 고민할 분기”를 줄여줍니다.
- **결과가 없으면
void**가 맞지만, “성공/실패”가 중요하면boolean또는 명확한 결과 객체가 낫습니다. - “없을 수 있음”은
null대신 **Optional<T>**가 의도를 잘 드러냅니다. - 컬렉션은 가능하면 빈 컬렉션을 반환해 호출부의
null체크를 없애세요. - 실패가 “정상 흐름”이 아니라면 예외가 자연스럽습니다. 다만 예외는 API의 일부이므로, 어떤 예외를 던지는지가 메서드 계약이 됩니다.
| 반환 형태 | 언제 쓰면 좋은가 | 호출부 영향 |
|---|---|---|
void |
결과가 의미 없고 부수효과가 핵심 | 성공 여부 확인이 어려움 |
boolean |
성공/실패만 필요 | 실패 이유는 표현 불가 |
Optional<T> |
값이 없을 수 있는 “조회” | isPresent/ifPresent/orElse로 명시적 처리 |
List<T> (빈 리스트) |
여러 건 조회 | null 체크 제거 |
결과 객체(예: Result) |
성공/실패 + 이유/데이터 필요 | 호출부 분기 명확, 테스트 쉬움 |
3) 오버로딩: “편의”가 “혼란”으로 바뀌는 경계
오버로딩은 잘 쓰면 호출부를 깔끔하게 만들지만, 과하면 API가 미로가 됩니다. 특히 아래 케이스는 사고가 잦습니다.
- 비슷한 시그니처(타입만 살짝 다름)로 오버로딩 → 어떤 메서드가 선택될지 헷갈림
null을 넘길 때 컴파일러가 어떤 오버로드를 고를지 모호해짐- 매개변수 순서가 다른 오버로딩 → 호출부가 더 위험해짐
오버로딩은 “기본값 제공” 정도로 제한하고, 의미가 달라지면 메서드 이름을 분리하는 편이 안전합니다. 예를 들어 parse(String)와 parse(Path)는 괜찮아도, parse(String, boolean)처럼 플래그가 늘기 시작하면 설계 신호를 다시 보셔야 합니다.
4) 메서드 길이 원칙: 길이보다 “한 가지 책임”이 핵심
“메서드는 몇 줄 이하여야 한다”는 절대 규칙은 없지만, 실무에서 유지보수성이 급격히 떨어지는 지점은 분명히 있습니다.
- 메서드가 길어지는 가장 흔한 이유는 검증/변환/저장/로깅/외부 호출이 한 덩어리로 섞이기 때문입니다.
- 좋은 메서드는 “위에서 아래로 읽을 때” 한 문단처럼 흐릅니다. (검증 → 핵심 로직 → 결과 반환)
- 분리 기준은 줄 수가 아니라 변경 이유입니다. 변경 이유가 다른 코드가 섞이면 분리하세요.
flowchart TD
A["입력 파라미터"] --> B["검증"]
B --> C["도메인 규칙 적용"]
C --> D["외부 의존 호출(저장/전송)"]
D --> E["반환 값 구성"]
메서드 내부 책임을 단계로 나누면, 분리할 지점이 자연스럽게 보입니다.
코드 예제: 복붙해서 실행 가능한 “메서드 설계” 샘플 (Java 17)
아래 예제는 파라미터를 Record로 묶고, 조회는 Optional, 목록은 빈 리스트, 오버로딩은 “기본값 제공” 수준으로 제한하고, 긴 메서드를 단계별로 쪼개는 방식을 보여줍니다.
import java.util.*;
import java.util.regex.Pattern;
public class MethodDesignDemo {
// 파라미터가 많아질 때는 record로 의미를 묶어줍니다(Java 16+).
public record CreateUserRequest(String name, String email, String phone) {}
public record User(long id, String name, String email, String phone) {}
// 성공/실패 + 이유를 호출부에 명확히 전달하고 싶을 때 결과 객체가 유용합니다.
public sealed interface CreateUserResult {
record Success(User user) implements CreateUserResult {}
record Failure(String reason) implements CreateUserResult {}
}
public static final class UserService {
private final Map<Long, User> store = new HashMap<>();
private long sequence = 1L;
// 오버로딩은 "기본값 제공" 정도로만 사용합니다.
public CreateUserResult createUser(String name, String email) {
return createUser(new CreateUserRequest(name, email, null));
}
public CreateUserResult createUser(CreateUserRequest request) {
// 긴 메서드가 되기 쉬운 부분을 단계별 private 메서드로 분리합니다.
var validation = validate(request);
if (validation.isPresent()) {
return new CreateUserResult.Failure(validation.get());
}
var normalized = normalize(request);
var user = persist(normalized);
return new CreateUserResult.Success(user);
}
// 조회는 없을 수 있음을 Optional로 표현합니다.
public Optional<User> findById(long id) {
return Optional.ofNullable(store.get(id));
}
// 컬렉션은 null 대신 빈 컬렉션을 반환합니다.
public List<User> findAll() {
return store.values().stream()
.sorted(Comparator.comparingLong(User::id))
.toList();
}
private Optional<String> validate(CreateUserRequest req) {
if (req == null) return Optional.of("request is required");
if (isBlank(req.name())) return Optional.of("name is required");
if (isBlank(req.email())) return Optional.of("email is required");
if (!EMAIL.matcher(req.email()).matches()) return Optional.of("email format is invalid");
return Optional.empty();
}
private CreateUserRequest normalize(CreateUserRequest req) {
// 핵심 포인트만: 입력 정규화는 별도 단계로 빼면 테스트가 쉬워집니다.
String name = req.name().trim();
String email = req.email().trim().toLowerCase(Locale.ROOT);
String phone = req.phone() == null ? null : req.phone().trim();
return new CreateUserRequest(name, email, phone);
}
private User persist(CreateUserRequest req) {
long id = sequence++;
User user = new User(id, req.name(), req.email(), req.phone());
store.put(id, user);
return user;
}
private static boolean isBlank(String s) {
return s == null || s.trim().isEmpty();
}
private static final Pattern EMAIL =
Pattern.compile("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+$");
}
public static void main(String[] args) {
UserService service = new UserService();
// 1) 오버로딩: 기본값 제공
CreateUserResult r1 = service.createUser("Alice", "Alice@Example.com");
print(r1);
// 2) 파라미터 객체: 의미가 명확하고 확장에 강함
CreateUserResult r2 = service.createUser(new CreateUserRequest("Bob", "bob@example.com", " 010-1234-5678 "));
print(r2);
// 3) 실패는 이유를 담아 반환(예외 대신 결과 객체)
CreateUserResult r3 = service.createUser(new CreateUserRequest(" ", "not-an-email", null));
print(r3);
// 4) Optional 조회
System.out.println("findById(1) = " + service.findById(1).map(User::email).orElse("(not found)"));
// 5) 빈 리스트 반환(Null-safe)
System.out.println("findAll size = " + service.findAll().size());
}
private static void print(CreateUserResult result) {
if (result instanceof CreateUserResult.Success s) {
System.out.println("SUCCESS: id=" + s.user().id() + ", email=" + s.user().email());
} else if (result instanceof CreateUserResult.Failure f) {
System.out.println("FAIL: " + f.reason());
}
}
}
실무 팁
💡 실무에서는: “파라미터 3개 이상”이 되면 리팩토링 알람으로 보세요
- 특히
String, String, int처럼 의미가 겹치는 기본 타입이 늘어나는 순간부터 호출부 실수가 급증합니다. - 이때 Record/DTO로 묶으면 “필드 이름”이 문서 역할을 하고, 추후 필드 추가에도 오버로딩 폭발을 막을 수 있어요.
💡 실무에서는: 반환 타입을 정할 때 “호출부가 어떤 분기를 하게 될지”부터 그려보세요
null을 반환하면 호출부 전체에 방어 코드가 퍼집니다. 조회는Optional, 목록은 빈 컬렉션, 실패 이유가 중요하면 결과 객체(또는 예외)를 고려해 보세요.- 예외를 선택한다면, 런타임 예외/체크 예외를 무작정 섞기보다 “복구 가능한가?”를 기준으로 일관성을 유지하는 게 중요합니다.
핵심 요약: 파라미터는 적고 의미 있게, 많아지면 객체로 묶어야 합니다.
핵심 요약: 반환 타입은 호출부를 단순하게 만드는 방향(Optional/빈 컬렉션/결과 객체)을 우선하세요.
핵심 요약: 오버로딩은 절제하고, 메서드는 한 가지 책임으로 쪼개면 유지보수가 쉬워집니다.
다음 글: #07 접근 제어자와 캡슐화
'JAVA' 카테고리의 다른 글
| Java 상속 vs 조합(Composition) — 실무에서의 선택 가이드 (0) | 2026.02.16 |
|---|---|
| Java 접근 제어자와 캡슐화 — public/private/protected/default 제대로 쓰기 (0) | 2026.02.15 |
| Java 클래스와 객체 — 왜 나눠야 할까? (설계 기초부터 생성자·this·네이밍까지) (0) | 2026.02.14 |
| Java 연산자와 제어문 핵심 정리 — if/switch, 반복문, 비교 연산 실수 방지 (0) | 2026.02.13 |
| Java 변수와 타입 — 이것만 알면 된다 (기본 타입 8가지, var, 타입 추론까지) (0) | 2026.02.13 |