각 패턴의 코드는 java-src/beginner/06_method_patterns/Main.java에서 실행해 볼 수 있습니다.
메서드는 "무언가를 하고 끝나는" void형과 "값을 계산해 돌려주는" 반환형으로 나뉩니다. 출력이 목적이면 void, 결과를 다른 계산에 이어 쓰려면 반환형입니다. 반환값이 있으면 그것을 변수에 담거나 식 안에서 바로 쓸 수 있습니다.
static void greet(String name) { System.out.println("안녕하세요, " + name); }
static int add(int a, int b) { return a + b; }
static int square(int n) { return n * n; }
greet("Kim"); // 출력: 안녕하세요, Kim
int sum = add(3, 4);
System.out.println(sum); // 출력: 7
System.out.println(square(5) + square(2)); // 출력: 29주의: void 메서드의 결과를 int x = greet("Kim");처럼 받으려 하면 컴파일 오류입니다. 출력하는 메서드보다 값을 반환하는 메서드가 재사용성이 높습니다.
int, double 같은 원시 타입을 넘기면 값이 복사되어 새 스택 프레임에 들어갑니다. 메서드 안에서 아무리 바꿔도 호출한 쪽 변수는 다른 프레임에 있으므로 영향이 없습니다.
static void addTen(int n) { n += 10; } // 이 n은 복사본
int x = 10;
addTen(x);
System.out.println(x); // 출력: 10주의: 바꾼 값이 필요하면 return해서 받습니다: x = addTen(x);.
배열을 넘기면 주소가 복사됩니다. 주소를 따라가 요소를 바꾸면 같은 배열이므로 원본에 반영됩니다. 하지만 매개변수에 새 배열을 대입하면 복사된 주소만 바뀔 뿐, 원본 변수는 여전히 옛 배열을 가리킵니다.
static void doubleAll(int[] a) { for (int i = 0; i < a.length; i++) a[i] *= 2; }
static void replace(int[] a) { a = new int[]{9, 9, 9}; }
int[] arr = {1, 2, 3};
doubleAll(arr);
System.out.println(Arrays.toString(arr)); // 출력: [2, 4, 6]
int[] arr2 = {1, 2, 3};
replace(arr2);
System.out.println(Arrays.toString(arr2)); // 출력: [1, 2, 3]주의: 메서드가 인자로 받은 배열을 바꾸는 것은 호출자를 놀라게 할 수 있습니다. 바꿀 거면 이름에 드러내거나(sortInPlace), 새 배열을 반환하세요.
String은 참조 타입이지만 불변(immutable)이라 내용을 바꾸는 메서드가 없습니다. s.toUpperCase()는 s를 바꾸는 것이 아니라 새 문자열을 만들어 반환합니다. 반환값을 버리면 아무 일도 일어나지 않습니다.
static void upper(String s) { s.toUpperCase(); } // 반환값을 버림
static String upper2(String s) { return s.toUpperCase(); }
String s = "java";
upper(s);
System.out.println(s); // 출력: java
s = upper2(s);
System.out.println(s); // 출력: JAVA주의: s.trim();, s.replace(...)를 한 줄로 쓰고 끝내는 실수가 매우 흔합니다. String 메서드는 항상 결과를 대입하세요.
return은 하나만 돌려줄 수 있습니다. 같은 타입의 값 몇 개를 돌려줄 때 가장 단순한 방법은 배열입니다. 최솟값과 최댓값처럼 짝을 이루는 결과에 씁니다.
static int[] minMax(int[] a) {
int min = a[0], max = a[0];
for (int v : a) { if (v < min) min = v; if (v > max) max = v; }
return new int[]{min, max};
}
int[] mm = minMax(new int[]{7, 2, 9, 4});
System.out.println("min=" + mm[0] + " max=" + mm[1]); // 출력: min=2 max=9주의: mm[0]이 min인지 max인지 호출자가 기억해야 합니다. 의미가 있는 값이면 패턴 6의 record가 낫습니다.
타입이 다르거나 각 값에 이름이 필요하면 record로 묶어 반환합니다. 한 줄로 선언되고 min(), max() 접근자와 toString이 자동으로 생깁니다. 05 레슨의 record가 "반환 타입"으로 쓰이는 대표적인 경우입니다.
record Range(int min, int max) {}
static Range range(int[] a) {
int[] mm = minMax(a);
return new Range(mm[0], mm[1]);
}
Range r = range(new int[]{7, 2, 9, 4});
System.out.println(r.min() + " " + r.max() + " " + r); // 출력: 2 9 Range[min=2, max=9]주의: 메서드 안에 지역 record를 선언할 수도 있지만, 여러 메서드가 쓰는 결과 타입은 클래스 수준에 선언합니다.
인자 개수가 정해지지 않았을 때 타입... 이름으로 선언하면 0개부터 몇 개든 받을 수 있습니다. 메서드 안에서는 그냥 배열입니다. 배열을 직접 넘겨도 됩니다.
static int total(int... nums) { // nums는 int[]
int sum = 0;
for (int n : nums) sum += n;
return sum;
}
System.out.println(total()); // 출력: 0
System.out.println(total(5)); // 출력: 5
System.out.println(total(1, 2, 3, 4)); // 출력: 10
System.out.println(total(new int[]{10, 20})); // 출력: 30주의: 호출할 때마다 배열이 새로 만들어집니다. 성능이 중요한 곳에서는 total(int a, int b) 오버로딩을 따로 둡니다(List.of가 그렇게 되어 있습니다).
가변 인자는 마지막 매개변수 하나만 가능합니다. 구분자처럼 반드시 필요한 값은 앞에 일반 매개변수로 두고, 나머지를 가변 인자로 받습니다.
static String join(String sep, String... parts) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < parts.length; i++) {
if (i > 0) sb.append(sep);
sb.append(parts[i]);
}
return sb.toString();
}
System.out.println(join("-", "a", "b", "c")); // 출력: a-b-c
System.out.println("[" + join(", ") + "]"); // 출력: []주의: void f(String... a, int b)는 컴파일 오류입니다. 가변 인자가 2개인 것도 불가능합니다.
같은 일을 다른 입력으로 할 때 이름을 같게 하고 매개변수만 다르게 합니다. 호출자는 area라는 이름 하나만 기억하면 됩니다. 매개변수의 개수, 타입, 순서 중 하나가 달라야 합니다.
static String area(int radius) { return "원 " + Math.PI * radius * radius; }
static String area(int w, int h) { return "사각형 " + w * h; }
static String area(double a, double b, double c) {
double s = (a + b + c) / 2;
return "삼각형 " + Math.sqrt(s * (s - a) * (s - b) * (s - c));
}
System.out.println(area(5)); // 출력: 원 78.53981633974483
System.out.println(area(3, 4)); // 출력: 사각형 12
System.out.println(area(3.0, 4.0, 5.0)); // 출력: 삼각형 6.0주의: 반환 타입만 다른 오버로딩은 불가능합니다. 오버로딩은 같은 의미의 일에만 쓰세요. area(int)가 원이고 area(int, int)가 사각형인 것은 사실 경계선입니다. 실무라면 circleArea, rectArea가 더 분명합니다.
컴파일러는 2.4의 순서대로 후보를 찾습니다. 정확히 맞는 것이 없으면 확대 변환(char→int, float→double, int→long)을 먼저 시도하고, 그래도 없을 때만 박싱을 고려합니다.
public class Overload {
void call(int n) { System.out.println("int"); }
void call(long n) { System.out.println("long"); }
void call(double n) { System.out.println("double"); }
void call(Object o) { System.out.println("Object"); }
void callNoInt(long n) { System.out.println("long"); }
void callNoInt(Integer n) { System.out.println("Integer"); }
}
Overload o = new Overload();
o.call(1); // 출력: int
o.call('A'); // 출력: int (char → int 승격)
o.call(1L); // 출력: long
o.call(1.5f); // 출력: double (float → double)
o.call(Integer.valueOf(1)); // 출력: Object (Integer는 Object의 자식. 언박싱보다 우선)
o.callNoInt((short) 1); // 출력: long (short → long)
o.callNoInt(1); // 출력: long (int → long 확대가 Integer 박싱보다 우선)주의: callNoInt(1)이 Integer가 아니라 long으로 가는 것이 핵심입니다. "타입이 더 비슷해 보이는" 것이 아니라 규칙의 단계가 우선합니다.
null은 모든 참조 타입에 대입될 수 있어서, 참조 타입 오버로딩이 여러 개면 컴파일러가 고를 수 없습니다. 상속 관계가 있으면(String은 Object의 자식) 더 구체적인 String을 고르지만, String과 Integer처럼 관계가 없으면 ambiguous 오류입니다. 캐스팅으로 의도를 밝힙니다.
static String pick(String s) { return "String"; }
static String pick(Object o) { return "Object"; }
System.out.println(pick("text")); // 출력: String
System.out.println(pick((Object) null)); // 출력: Object
System.out.println(pick((String) null)); // 출력: String
// pick(null)만 쓰면? String이 Object보다 구체적이므로 "String".
// 만약 pick(Integer)가 하나 더 있었다면 pick(null)은 컴파일 오류(ambiguous)주의: null을 직접 넘기는 코드는 오버로딩이 늘어나는 순간 깨질 수 있습니다. 캐스팅하거나 아예 넘기지 마세요.
n! = n × (n-1)!이고 1! = 1이라는 정의를 코드로 그대로 옮긴 것이 재귀입니다. 기저 조건(n <= 1)이 먼저 오고, 그 다음 문제를 작게 만들어 자기 자신을 호출합니다.
static long factorial(int n) {
if (n <= 1) return 1; // 기저 조건
return n * factorial(n - 1); // 재귀 단계: 문제를 하나 작게
}
System.out.println(factorial(5)); // 출력: 120
System.out.println(factorial(20)); // 출력: 2432902008176640000주의: factorial(21)은 long 범위를 넘어 음수가 나옵니다. 재귀 자체의 문제가 아니라 오버플로우입니다. BigInteger가 필요합니다.
재귀 피보나치는 정의는 예쁘지만 fib(n-1)과 fib(n-2)가 같은 계산을 반복해 지수적으로 느립니다. fibRec(50)은 수십 초가 걸립니다. 반복문은 순식간입니다. 같은 문제를 두 방식으로 써 보고 차이를 느끼는 것이 목적입니다.
static long fibRec(int n) {
if (n < 2) return n;
return fibRec(n - 1) + fibRec(n - 2); // 호출 횟수가 2^n 에 가깝게 폭발
}
static long fibLoop(int n) {
long a = 0, b = 1;
for (int i = 0; i < n; i++) { long t = a + b; a = b; b = t; }
return a;
}
System.out.println(fibRec(10)); // 출력: 55
System.out.println(fibLoop(10)); // 출력: 55
System.out.println(fibLoop(50)); // 출력: 12586269025주의: 재귀가 느린 이유는 호출 비용이 아니라 중복 계산입니다. 고급 레슨의 메모이제이션으로 재귀를 유지하면서 빠르게 할 수 있습니다.
디렉터리 안에 디렉터리가 있고 그 안에 또 디렉터리가 있는 구조는 반복문으로 쓰기 어렵고 재귀로는 자연스럽습니다. "자기 자신을 출력하고, 자식마다 같은 일을 한다"가 전부입니다. 깊이를 매개변수로 넘겨 들여쓰기를 만듭니다.
public class Node { // 파일/폴더 흉내
String name; int size; Node[] children;
Node(String name, int size, Node... children) { ... }
}
static void printTree(Node n, int depth) {
System.out.println(" ".repeat(depth) + n.name);
for (Node child : n.children) printTree(child, depth + 1);
}
static int totalSize(Node n) {
int sum = n.size;
for (Node child : n.children) sum += totalSize(child);
return sum;
}
Node root = new Node("root", 0,
new Node("docs", 0, new Node("a.txt", 10), new Node("b.txt", 20)),
new Node("src", 0, new Node("Main.java", 5)));
printTree(root, 0);
// 출력:
// root
// docs
// a.txt
// b.txt
// src
// Main.java
System.out.println("total=" + totalSize(root)); // 출력: total=35주의: 파일이 없는 폴더(children.length == 0)가 기저 조건입니다. 명시적 if가 없어도 for문이 0번 돌면서 재귀가 멈춥니다.
재귀 깊이가 수만을 넘으면 스택이 가득 차 StackOverflowError가 납니다. 이것은 예외(Exception)가 아니라 오류(Error)라서 보통 잡지 않지만, 실험을 위해 잡아 봅니다. 같은 일을 반복문으로 바꾸면 깊이 제한이 없습니다.
static int countDownRec(int n) { return n == 0 ? 0 : countDownRec(n - 1); }
static int countDownLoop(int n) { while (n > 0) n--; return n; }
System.out.println(countDownRec(1000)); // 출력: 0
try {
countDownRec(10_000_000);
} catch (StackOverflowError e) {
System.out.println("StackOverflowError"); // 출력: StackOverflowError
}
System.out.println(countDownLoop(10_000_000)); // 출력: 0주의: 한계 깊이는 JVM 옵션(-Xss)과 프레임 크기에 따라 다릅니다(보통 1만~10만). "재귀 깊이 = 입력 크기"인 알고리즘은 반복문이나 명시적 스택(ArrayDeque)으로 바꿉니다.
clamp는 입력만으로 결과가 정해지고 아무 상태도 안 건드리므로 static입니다. Counter.increment는 객체마다 다른 count를 바꾸므로 인스턴스 메서드입니다. 이 구분이 흐릿하면 "이 메서드는 어느 객체의 값을 바꾸는가"를 알 수 없는 코드가 됩니다.
public class MathUtils {
static int clamp(int v, int min, int max) {
if (v < min) return min;
if (v > max) return max;
return v;
}
}
public class Counter {
private int count;
void increment() { count++; }
int value() { return count; }
}
System.out.println(MathUtils.clamp(15, 0, 10)); // 출력: 10
Counter c1 = new Counter();
Counter c2 = new Counter();
c1.increment(); c1.increment(); c2.increment();
System.out.println(c1.value() + " " + c2.value()); // 출력: 2 1주의: static 메서드가 static 필드를 바꾸기 시작하면 "전역 변수"가 됩니다. 테스트하기 어렵고 멀티스레드에서 위험합니다. 상태가 있으면 객체로 만드세요.
main에 평균, 최고점, 합격자 계산이 한 덩어리로 있으면 읽기도 고치기도 어렵습니다. 각 계산을 이름 있는 메서드로 빼면 printReport는 "무엇을 하는지"만 남고 "어떻게"는 각 메서드로 숨습니다. 이것이 리팩터링의 가장 기본인 메서드 추출(extract method)입니다.
static void printReport(int[] scores) {
System.out.println("평균: " + Stats.average(scores));
System.out.println("최고: " + Stats.max(scores));
System.out.println("합격: " + countPassed(scores, 70) + "명");
}
static int countPassed(int[] scores, int cut) {
int n = 0;
for (int s : scores) if (s >= cut) n++;
return n;
}
printReport(new int[]{88, 92, 75, 60, 95});
// 출력:
// 평균: 82.0
// 최고: 95
// 합격: 4명주의: 분리 기준은 "이 조각에 이름을 붙일 수 있는가"입니다. 이름이 안 떠오르면 아직 한 덩어리인 것이고, 이름에 And가 들어가면 둘로 나눠야 합니다.
예외적인 입력(null, 범위 밖)을 메서드 맨 앞에서 걸러내고 바로 반환하면 본문의 들여쓰기가 얕아지고 정상 흐름이 잘 보입니다. if-else if-else가 깊게 중첩되는 것을 막는 가장 쉬운 방법입니다.
static int discountRate(String grade, int amount) {
if (grade == null) return 0; // 가드 절
if (amount < 10_000) return 0; // 가드 절
if (grade.equals("VIP")) return 20;
return 10;
}
System.out.println(discountRate(null, 0)); // 출력: 0
System.out.println(discountRate("VIP", 50_000)); // 출력: 20
System.out.println(discountRate("NORMAL", 50_000)); // 출력: 10
System.out.println(discountRate("NORMAL", 5_000)); // 출력: 0주의: "메서드에 return은 하나만"이라는 옛 규칙은 자바에서 지키지 않아도 됩니다. 단, 가드 절 뒤에 정리 작업이 필요하면(파일 닫기 등) try-finally가 필요합니다.
메서드 이름은 동사로 시작하고, 결과가 boolean이면 is/has/can으로 시작해 if (isEmpty(s))처럼 문장으로 읽히게 합니다. 변환 메서드는 toXxx, 가져오는 것은 getXxx/findXxx, 계산은 calculateXxx/computeXxx가 관례입니다.
static boolean isEmpty(String s) { return s == null || s.length() == 0; }
static boolean hasDigit(String s) {
for (char c : s.toCharArray()) if (Character.isDigit(c)) return true;
return false;
}
static boolean canDivide(int a, int b) { return b != 0; }
static String toSlug(String title) { return title.toLowerCase().replace(' ', '-'); }
System.out.println(isEmpty("")); // 출력: true
System.out.println(hasDigit("abc1")); // 출력: true
System.out.println(canDivide(10, 0)); // 출력: false
System.out.println(toSlug("Hello World Java")); // 출력: hello-world-java주의: check(), process(), handle(), doIt()은 무엇을 하는지 알 수 없는 이름입니다. 이름을 못 짓겠으면 메서드가 너무 많은 일을 하는 것입니다.
사용자 입력 검증은 어디서나 필요하고, 같은 규칙을 여러 곳에서 씁니다. static 메서드로 모아 두면 규칙이 한 곳에서 관리됩니다. boolean을 돌려주는 것과 메시지를 돌려주는 것을 구분합니다.
public class Validator {
static boolean isValidEmail(String s) {
if (s == null) return false;
int at = s.indexOf('@');
return at > 0 && s.indexOf('.', at) > at + 1;
}
static boolean isInRange(int v, int min, int max) { return v >= min && v <= max; }
static String requireNonBlank(String s, String field) {
if (s == null || s.isBlank()) return field + "은 필수입니다";
return "OK";
}
}
System.out.println(Validator.isValidEmail("[email protected]")); // 출력: true
System.out.println(Validator.isValidEmail("a.b.com")); // 출력: false
System.out.println(Validator.isInRange(5, 1, 10)); // 출력: true
System.out.println(Validator.requireNonBlank(" ", "name")); // 출력: name은 필수입니다
System.out.println(Validator.requireNonBlank("Kim", "name")); // 출력: OK주의: 실제로는 requireNonBlank가 문자열 대신 예외를 던지는 것이 일반적입니다(중급 03 레슨). 초급에서는 반환값으로 결과를 알리는 방식으로 시작합니다.
금액에 콤마, 번호 앞에 0 채우기, 퍼센트, 마스킹은 어느 프로그램에나 나옵니다. String.format과 repeat을 감싸 이름 있는 메서드로 만들어 두면 포맷 규칙이 바뀌어도 한 곳만 고칩니다.
public class Fmt {
static String money(long won) { return String.format("%,d원", won); }
static String padLeft(String s, int width, char pad) {
StringBuilder sb = new StringBuilder();
for (int i = s.length(); i < width; i++) sb.append(pad);
return sb.append(s).toString();
}
static String percent(double ratio) { return String.format("%.1f%%", ratio * 100); }
static String mask(String phone) {
return phone.substring(0, 7) + "*".repeat(phone.length() - 7);
}
}
System.out.println(Fmt.money(1234567)); // 출력: 1,234,567원
System.out.println(Fmt.padLeft("42", 6, '0')); // 출력: 000042
System.out.println(Fmt.percent(0.256)); // 출력: 25.6%
System.out.println(Fmt.mask("01012345678")); // 출력: 0101234****주의: mask는 7자 미만 입력에서 예외가 납니다. 실무 코드라면 가드 절(패턴 18)을 추가해야 합니다.
작은 메서드들을 모아 두고 서로 호출하게 하면(average가 sum을 사용) 각 메서드가 짧아지고, 하나를 고치면 나머지에 자동으로 반영됩니다. "못 찾으면 -1"처럼 실패를 반환값으로 표현하는 관례도 익혀 둡니다.
public class Stats {
static int sum(int[] a) { int s = 0; for (int v : a) s += v; return s; }
static double average(int[] a) { return a.length == 0 ? 0 : (double) sum(a) / a.length; }
static int max(int[] a) { int m = a[0]; for (int v : a) if (v > m) m = v; return m; }
static int indexOf(int[] a, int target) {
for (int i = 0; i < a.length; i++) if (a[i] == target) return i;
return -1;
}
static int countIf(int[] a, int threshold) { int n = 0; for (int v : a) if (v >= threshold) n++; return n; }
}
int[] data = {3, 9, 1, 7, 5};
System.out.println(Stats.sum(data)); // 출력: 25
System.out.println(Stats.average(data)); // 출력: 5.0
System.out.println(Stats.max(data)); // 출력: 9
System.out.println(Stats.indexOf(data, 7)); // 출력: 3
System.out.println(Stats.indexOf(data, 100)); // 출력: -1
System.out.println(Stats.countIf(data, 5)); // 출력: 3주의: average에서 (double) 캐스팅이 빠지면 정수 나눗셈이 되어 5.0 대신 5가 잘립니다. max는 빈 배열에서 예외가 납니다.
복잡한 조건은 작은 boolean 메서드로 쪼개고 조합합니다. "2월의 일수"는 "윤년인가"에 의존하는데, isLeapYear를 따로 두면 규칙이 한 곳에 모이고 이름으로 의도가 읽힙니다.
static boolean isLeapYear(int y) { return (y % 4 == 0 && y % 100 != 0) || y % 400 == 0; }
static int daysInMonth(int y, int m) {
if (m == 2) return isLeapYear(y) ? 29 : 28;
if (m == 4 || m == 6 || m == 9 || m == 11) return 30;
return 31;
}
System.out.println(isLeapYear(2024)); // 출력: true
System.out.println(isLeapYear(1900)); // 출력: false
System.out.println(daysInMonth(2024, 2)); // 출력: 29주의: 조건식을 인라인으로 풀어 쓰면(if ((y % 4 == 0 && ...) ...)) 같은 규칙이 여러 곳에 복사됩니다.
"회문(palindrome)인가"는 "문자와 숫자만 남기고 소문자로 만든 뒤, 뒤집은 것과 같은가"입니다. 이 문장의 각 동사가 메서드 하나씩입니다. 상위 메서드는 하위 메서드를 부르기만 하므로 문장처럼 읽힙니다.
static boolean isPalindrome(String s) {
String clean = normalize(s);
return clean.equals(reverse(clean));
}
static String normalize(String s) {
StringBuilder sb = new StringBuilder();
for (char c : s.toCharArray())
if (Character.isLetterOrDigit(c)) sb.append(Character.toLowerCase(c));
return sb.toString();
}
static String reverse(String s) { return new StringBuilder(s).reverse().toString(); }
System.out.println(isPalindrome("A man, a plan, a canal: Panama")); // 출력: true
System.out.println(isPalindrome("hello")); // 출력: false주의: normalize, reverse는 다른 곳에서도 재사용됩니다. 한 메서드 안에 다 넣었다면 재사용은 불가능했을 것입니다.
상태를 바꾸는 메서드가 "성공했는지"를 boolean으로 알려 주면 호출자가 다음 행동을 정할 수 있습니다. 재고 차감이 대표적입니다. 실패 시에는 상태를 건드리지 않는 것이 중요합니다.
static boolean take(int[] stock, int qty) {
if (qty > stock[0]) return false; // 실패: 상태 변경 없음
stock[0] -= qty;
return true;
}
int[] stock = {5};
System.out.println(take(stock, 3) + " 남은 " + stock[0]); // 출력: true 남은 2
System.out.println(take(stock, 3) + " 남은 " + stock[0]); // 출력: false 남은 2주의: 호출자가 반환값을 무시하면(take(stock, 3);) 실패를 모르고 지나갑니다. 실패를 무시하면 안 되는 상황은 예외(중급 03)로 바꿉니다.