[LG U+ 유레카 4기] WEEK 02 - Java 기초 객체지향언어 (5)

Soohwan Lim·2026년 4월 13일

유레카부트캠프

목록 보기
7/31
post-thumbnail

사용자 정의 Exception, Java API 훑기, Generic, MVC 패턴 실습


1. 오늘의 학습 흐름

  • 사용자 정의 Exception (Checked / Unchecked)
  • java.base 모듈 주요 API: String, StringTokenizer, Calendar, Math, Random, Record, Wrapper, Pattern
  • Generic: 타입 파라미터, 제네릭 메서드, Bounded Generic, Wildcard
  • Collection: ArrayList vs LinkedList, Set, Map
  • DAO 패턴 계층 분리 실습 (DAO / Service / View)
  • 사용자 정의 Exception 실전 적용
  • 함수형 인터페이스, Anonymous Nested Class, Lambda
  • Comparator와 Arrays.sort 람다 적용

2. 사용자 정의 Exception

Checked vs Unchecked 구분 기준

DAY 04에서 예외 계층 구조를 배웠는데, 오늘은 직접 만드는 법을 배웠다. 어떤 클래스를 상속받느냐로 Checked/Unchecked가 결정되는데, 이 구분에는 명확한 철학이 있다.

UncheckedException (RuntimeException 계열 상속)은 개발자가 코드로 사전에 막을 수 있는 실수들이다.

NullPointerException           // null 체크 안 한 개발자 실수
ArrayIndexOutOfBoundsException // 범위 체크 안 한 개발자 실수
NumberFormatException          // 입력 검증 안 한 개발자 실수

이런 건 예외처리로 막아라가 아니라 애초에 그런 상황이 안 생기게 짜라는 철학이다. 그래서 컴파일러가 처리를 강제하지 않는다.

CheckedException (Exception 직접 상속)은 코드를 아무리 잘 짜도 개발자가 통제할 수 없는 외부 환경 문제다.

FileNotFoundException // 파일이 존재하는지는 런타임에만 알 수 있음
SQLException          // DB 서버가 죽었는지는 코드로 막을 수 없음
IOException           // 네트워크 상태는 개발자 통제 밖

코드가 완벽해도 파일이 없거나 DB가 죽으면 예외가 터지니까, 컴파일러가 반드시 처리해라라고 강제한다.

CheckedUnchecked
상속Exception 직접 상속RuntimeException 상속
발생 원인외부 환경 (파일, DB, 네트워크)개발자 실수 (null, 범위, 형변환)
컴파일 강제OX
철학막을 수 없으니 처리해라애초에 안 생기게 짜라
// CheckedException: Exception 상속
class MyChecked extends Exception {
    public MyChecked() {
        super("사용자 정의 Checked Exception이 발생했습니다.");
    }
    public MyChecked(String msg) {
        super(msg);  // 메시지를 부모 Exception에 전달
    }
}

// UncheckedException: RuntimeException 상속
class MyUnChecked extends RuntimeException {
    public MyUnChecked() {
        super("사용자 정의 UnChecked Exception이 발생했습니다.");
    }
    public MyUnChecked(String msg) {
        super(msg);
    }
}

사용 방식의 차이

class ExceptionUse {
    // throws MyChecked → 호출부에서 반드시 try-catch 해야 함 (컴파일 강제)
    public static int mod(int i, int j) throws MyChecked {
        if (j == 0) throw new MyChecked("0으로 나눌 수 없습니다.");
        return i / j;
    }

    // throws 없음 → 호출부에서 예외처리 선택적 (컴파일 강제 없음)
    public static int div(int i, int j) {
        if (j == 0) throw new MyUnChecked("0으로 나눌 수 없습니다.");
        return i / j;
    }
}

// mod()는 CheckedException이라 반드시 try-catch
try {
    ExceptionUse.mod(256, 4);
} catch (MyChecked e) {
    e.printStackTrace();
}

// div()는 UncheckedException이라 try-catch 없어도 컴파일 OK
System.out.println(ExceptionUse.div(4, 1));

설계 기준으로 생각해보면, 외부 환경(DB, 파일, 네트워크)에 의존하는 예외는 Checked로 만들어 호출부에서 반드시 처리하게 강제하고, 개발자 실수나 잘못된 인자처럼 사전에 막을 수 있는 건 Unchecked로 만드는 게 일반적이다.


3. java.base 모듈 주요 API

String - new 없이 객체 생성 가능한 유일한 API

String str1 = "hello";      // Literal Pool에 저장, 재사용됨 (interned String)
String str2 = "hello";      // str1과 같은 객체를 가리킴
String str3 = new String("hello");  // heap에 새 객체 생성
String str4 = new String("hello");  // heap에 또 다른 객체 생성

System.out.println(str1 == str2);          // true  → 같은 Literal Pool 주소
System.out.println(str1 == str3);          // false → heap vs Literal Pool
System.out.println(str3 == str4);          // false → 각각 다른 heap 주소
System.out.println(str1.equals(str4));     // true  → 내용 비교

String은 객체이므로 내용 비교 시 equals()를 쓰는 게 원칙이다. ==은 주소(참조) 비교인데, 경우에 따라 결과가 달라진다.

String str1 = "hello";
String str2 = "hello";
String str3 = new String("hello");

System.out.println(str1 == str2);       // true  → 둘 다 Literal Pool의 같은 객체를 가리킴
System.out.println(str1 == str3);       // false → str3는 heap에 새로 생성된 객체
System.out.println(str1.equals(str3)); // true  → 내용 비교는 항상 정확

Literal("hello")끼리는 ==도 true가 나온다. 컴파일러가 자동으로 intern해서 같은 Pool 객체를 가리키기 때문이다. intern()을 직접 호출해서 new String()으로 생성한 문자열도 Pool로 넣으면 == 비교가 가능하다.

String a = new String("hello").intern();
String b = "hello";
System.out.println(a == b);  // true → intern()으로 Pool에 넣었으니까

근데 Scanner, 네트워크, DB 등 런타임에 동적으로 생성된 문자열은 자동 intern되지 않아서 ==으로 비교하면 false가 나온다. 개발 테스트 때는 Literal로 비교해서 통과되다가, 실제 입력이 들어오면 터지는 버그가 여기서 나온다. 결론: 내용 비교는 항상 equals()를 쓰자. ==은 같은 객체인가(주소)를 확인할 때만 의도적으로 쓰는 거다.

str1 += "world";  // 새 String 객체가 만들어져 str1이 가리키는 곳이 바뀜
str2 += "world";  // 마찬가지로 새 객체 생성
System.out.println(str1 == str2);  // false → 각각 다른 새 객체

// replace는 원본을 수정하지 않는다 → String은 불변(immutable) 객체
System.out.println(str1.replace('h', 'k'));  // "kelloworld" 출력
System.out.println(str1);                    // "helloworld" 그대로
// str1 = str1.replace('h', 'k');  ← 이렇게 재할당해야 반영됨

String이 불변(immutable)인 이유는 Literal Pool 재사용 때문이다. 여러 변수가 같은 Pool 객체를 가리키고 있는데 내용을 바꿀 수 있으면 다른 변수에도 영향을 준다. 그래서 + 연산이나 replace()는 항상 새 객체를 만들어 반환한다. 문자열을 많이 이어붙여야 하는 상황이라면 StringBuilder가 더 효율적이다. (나중에 배울 것 같다)

StringTokenizer - 문자열 분리

// split: 정규식 기반, 배열 반환
String data1 = "홍길동&이수홍,박연수";
String[] arr = data1.split("&|,");  // & 또는 , 기준으로 분리
for (String token : arr) {
    System.out.println(token);
}

// StringTokenizer: 구분자 기반, 토큰 하나씩 꺼냄
String data2 = "홍길동/이수홍/박연수";
StringTokenizer st = new StringTokenizer(data2, "/");
while (st.hasMoreTokens()) {
    System.out.println(st.nextToken());
}

StringTokenizer는 나중에 실제로 쓰는 상황이 나올 때 다시 볼 것 같다. 지금은 split()이 정규식도 지원하고 더 유연해서 자주 쓰인다.

Calendar - Factory 패턴으로 가져온다

Calendar now = Calendar.getInstance();  // Factory 패턴 적용
int year  = now.get(Calendar.YEAR);
int month = now.get(Calendar.MONTH) + 1;  // 0부터 시작하므로 +1 필수
int day   = now.get(Calendar.DAY_OF_MONTH);

Calendar는 추상 클래스라 직접 생성이 안 되고, getInstance()가 로케일과 타임존에 따라 적절한 구현체를 새로 만들어 반환한다. Singleton처럼 이름이 getInstance()이지만, 매번 같은 인스턴스를 반환하지 않기 때문에 Singleton이 아니고 Factory 아닌가? Calendar.MONTH는 0~11로 반환되기 때문에 +1을 해줘야 한다는 점은 실수하기 쉬운 포인트.

Math - 전부 static

Math.ceil(5.3)          // 6.0  (올림)
Math.floor(5.3)         // 5.0  (내림)
Math.max(3, 7)          // 7
Math.min(3, 7)          // 3
Math.round(12.3456 * 100) / 100.0  // 12.35  (소수점 둘째 자리)
(int)(Math.random() * 50)  // 0 이상 50 미만 난수

Math.random()의 seed는 현재 시간이라 매번 다른 값이 나온다.

Random - seed 값으로 재현 가능한 난수

Random random = new Random(3);  // seed = 3 → 항상 같은 순서의 난수 생성
random.nextInt(45) + 1;         // 1~45 사이 정수

seed가 같으면 항상 동일한 난수 시퀀스가 나온다. 테스트 코드에서 결과를 재현해야 할 때 유용하다.

Record - DTO를 간결하게

public record MyRecord(String name, Integer age, String hobby) {}

단 한 줄로 accessor 메서드, toString(), equals(), hashCode()가 자동 생성된다. 기존 DTO(Data Transfer Object) 클래스를 만들 때 보일러플레이트 코드(반복적으로 작성하는 코드)가 엄청났는데, Java 14에서 preview로 등장해 Java 16부터 정식 도입된 Record로 대폭 줄었다.

주의할 점이 하나 있다. Record의 접근자는 일반 클래스의 getName() 같은 getter 방식이 아니라 필드명과 동일한 이름의 메서드가 생성된다.

MyRecord r1 = new MyRecord("ureca", 4, "코딩");
MyRecord r2 = new MyRecord("ureca", 4, "코딩");
System.out.println(r1.name());      // accessor: getName()이 아니라 name()
System.out.println(r1.age());       // accessor: getAge()가 아니라 age()
System.out.println(r1);             // toString() 자동 생성
System.out.println(r1.equals(r2));  // true - 내용 기반 equals() 자동 생성
System.out.println(r1.hashCode());  // r2.hashCode()와 동일

Wrapper - Primitive를 객체로

Integer iPrice = 10000;  // auto boxing: int → Integer 자동 변환
int price = iPrice;      // auto unboxing: Integer → int 자동 변환

Wrapper 클래스를 만든 이유는 Primitive가 객체가 아니기 때문이다. int, double 같은 Primitive는 순수한 값일 뿐 객체가 아닌데, 자바에서 객체만 받을 수 있는 상황들이 있다.

  1. Collection에 저장할 때
ArrayList<int> list = new ArrayList<>();     // 컴파일 에러! Primitive 못 넣음
ArrayList<Integer> list = new ArrayList<>(); // OK - Integer는 객체니까
list.add(10);  // auto boxing으로 int → Integer 자동 변환
  1. null을 표현해야 할 때
int score = null;      // 불가능 - Primitive는 null 없음
Integer score = null;  // 가능 - "값 없음"을 표현할 수 있음

DB에서 값이 없는 컬럼을 가져올 때 null로 표현해야 하는데, Primitive는 null이 없어서 Wrapper가 필요하다.

  1. 유틸리티 메서드가 필요할 때
int data  = Integer.parseInt("256");       // 문자열 → int 변환
double PI = Double.parseDouble("3.14");
boolean b = Boolean.parseBoolean("true");

Primitive는 메서드를 가질 수 없으니 변환 유틸리티들을 Wrapper 클래스에 담은 것이다. 실무에서 parseInt() 계열이 제일 많이 쓰인다.

// Integer.parseInt("256.1") → NumberFormatException 발생 (정수 형식 아님)

Character에는 isDigit(), isLetter(), isUpperCase() 같은 is 계열 메서드들이 있다. 문자 유효성 검사할 때 유용하다.

Pattern - 정규식

// 전화번호 검증
String regExp = "(02|010)-\\d{3,4}-\\d{4}";
boolean result = Pattern.matches(regExp, "010-123-4567");  // true

// 이메일 검증
regExp = "\\w+@\\w+\\.\\w+(\\.\\w+)?";
result = Pattern.matches(regExp, "angel@navercom");  // false (점 없음)

정규식은 외우는 게 아니라 필요할 때 찾아 쓰는 거다. 패턴의 의미만 읽을 수 있으면 된다.


4. Generic

왜 필요한가

결정되지 않은 타입을 파라미터로 처리하고, 실제 사용할 때 구체적인 타입으로 대체하는 기능이다. 동적인 자바를 더 동적으로 만드는 것.

Generic이 없으면 타입별로 클래스를 따로 만들거나, Object로 받고 매번 캐스팅해야 한다. Generic을 쓰면 컴파일 타임에 타입 체크가 되어 런타임 ClassCastException을 사전에 막을 수 있다.

타입 파라미터 명명 관례:

기호의미
TType (일반적인 타입 1개)
K, VKey, Value
EElement (배열/컬렉션의 원소)
NNumber

모든 알파벳이 되긴 하지만 위 관례를 따르는 게 가독성에 좋다.

제네릭 클래스

class Product<T, M> {
    private T kind;   // T 자리에 실제 타입이 들어옴
    private M model;  // M 자리에 실제 타입이 들어옴

    public T getKind() { return kind; }
    public M getModel() { return model; }
}

// 사용 시 타입 지정
Product<Tv, String> product1 = new Product<>();
product1.setKind(new Tv());       // T = Tv로 확정
product1.setModel("스마트Tv");    // M = String으로 확정

Tv tv = product1.getKind();       // 캐스팅 없이 Tv 타입으로 바로 받음
String model = product1.getModel();

제네릭 메서드

// <T>를 메서드 선언부에 붙임
public static <T> Box<T> boxing(T t) {
    Box<T> box = new Box<T>();
    box.set(t);
    return box;
}

// 호출 시 타입이 자동 추론됨
Box<Integer> box1 = boxing(100);      // T = Integer로 추론
Box<String>  box2 = boxing("홍길동"); // T = String으로 추론

오늘 수업 스크린샷에서 강사님이 Box<T>의 T와 메서드 파라미터의 T가 같은 T임을 강조해서 표시했다. 헷갈리기 쉬운 포인트인데, 메서드 선언부의 <T>가 이 메서드에서 쓸 T를 여기서 선언한다는 의미다.

Bounded Generic - 타입 제한

// T는 Number 또는 Number의 하위 타입만 허용
class Home<T extends Number> {
    T roomNumber;
}

public static <T extends Number> boolean compare(T t1, T t2) {
    double v1 = t1.doubleValue();  // Number의 메서드 사용 가능
    double v2 = t2.doubleValue();
    return v1 == v2;
}

compare(10, 20);    // Integer는 Number 하위 → OK
compare(4.5, 4.5);  // Double은 Number 하위 → OK
// compare("a", "b"); // String은 Number 하위 아님 → 컴파일 에러

<T extends Number>는 상한 제한이다. Number와 그 하위 타입(Integer, Double, Long 등)만 들어올 수 있다. 덕분에 T 타입으로 Number의 메서드(doubleValue(), intValue() 등)를 안전하게 호출할 수 있다.


5. Collection - List

오전에 Generic을 배운 이유가 여기서 나온다. 컬렉션이 ArrayList<Employee> 같은 형태로 Generic을 쓰기 때문이다.

ArrayList vs LinkedList

둘 다 List 인터페이스를 구현한다. 차이는 내부 구조다.

ArrayList  : 배열 기반. 인덱스 접근이 빠름(O(1)). 중간 삽입/삭제는 느림(뒤 요소 전부 밀어야 함)
LinkedList : 노드 연결 기반. 중간 삽입/삭제가 빠름(O(1)). 인덱스 접근은 느림(처음부터 순회)

실무에서는 보통 ArrayList를 쓴다. 맨 뒤에 add하는 경우가 대부분이고, 이 경우 ArrayList가 훨씬 빠르다. 처음부터 크기를 지정해주는 게 성능에 좋다.

// 크기 지정 없이 생성 → 내부적으로 꽉 차면 배열을 새로 만들어 복사함 (비용 발생)
ArrayList<Employee> emps = new ArrayList<>();

// 예상 크기 지정 → 불필요한 배열 재생성 방지
ArrayList<Employee> emps = new ArrayList<>(10);

6. DAO 패턴과 계층 분리 실습

오늘 오후 핵심이었다. 강사님이 MVC 패턴을 간단히 언급하셨는데, 오늘 실습의 진짜 핵심은 DAO 패턴이다.

MVC vs DAO - 다른 개념이다

MVC(Model-View-Controller)는 전체 애플리케이션을 어떻게 나눌지에 대한 아키텍처 패턴이다. DAO(Data Access Object)는 그 안에서 데이터 접근 로직을 분리하는 방법이다. 경쟁 관계가 아니라 같이 쓰인다.

MVC : 애플리케이션 전체 구조를 View / Controller / Model로 나누는 것
DAO : Model 안에서 데이터 저장/조회 로직을 별도 객체로 분리하는 것

오늘 실습 구조를 보면 이렇다.

View    → EmployeeUI.java (Swing GUI)
Service → EmployeeService / EmployeeServiceImp (비즈니스 로직)
DAO     → EmployeeDao / EmployeeDaoMemory (데이터 접근)
DTO     → Employee, Manager, Engineer (데이터 객체)

오늘 실습 구조

com.ureca
├── Main.java                          ← 진입점. Service와 View 연결
├── model
│   ├── dto                            ← 데이터 객체 (Employee, Manager, Engineer)
│   │   ├── Employee.java
│   │   ├── Manager.java
│   │   ├── Engineer.java
│   │   ├── EmployeeException.java     ← 사용자 정의 예외 (부모)
│   │   ├── CanNotFindException.java   ← 찾지 못했을 때
│   │   └── DuplicateException.java    ← 중복 등록 시
│   ├── dao                            ← 데이터 접근 계층
│   │   ├── EmployeeDao.java           ← 인터페이스
│   │   └── EmployeeDaoMemory.java     ← 메모리(ArrayList) 구현체
│   └── service                        ← 비즈니스 로직 계층
│       ├── EmployeeService.java       ← 인터페이스
│       └── EmployeeServiceImp.java    ← 구현체
├── util
│   ├── EmployeeFactory.java           ← DAO 객체 생성 (Simple Factory)
│   └── IoUtil.java
└── view
    ├── EmployeeUI.java                ← Swing GUI (View)
    └── MessageDialog.java             ← 알림 다이얼로그

계층을 나누는 이유

지금은 EmployeeDaoMemory(ArrayList)로 데이터를 저장한다. 나중에 File로 바꾸거나, DB로 바꾸거나, 네트워크로 바꿔도 EmployeeFactory.java 한 줄만 바꾸면 된다.

// EmployeeFactory.java
private static final EmployeeDao dao = new EmployeeDaoMemory();  // 지금
// private static final EmployeeDao dao = new EmployeeDaoFile();  // 파일로 바꾸면
// private static final EmployeeDao dao = new EmployeeDaoJdbc();  // DB로 바꾸면

Service는 DAO가 어떻게 구현됐는지 모른다. DAO 인터페이스만 알면 된다. View도 Service 인터페이스만 알면 된다. 이게 계층 분리의 핵심이고, 앞서 배운 다형성과 인터페이스가 실제로 쓰이는 방식이다.

사용자 정의 예외가 실제로 쓰이는 방식

오전에 배운 사용자 정의 Exception이 여기서 바로 적용됐다.

// EmployeeServiceImp.java
public Employee findEmployee(String empno) {
    Employee emp = dao.findEmployee(empno);
    if (emp == null) throw new CanNotFindException(empno);  // 못 찾으면 예외로 알림
    return emp;
}

public void add(Employee emp) {
    Employee find = dao.findEmployee(emp.getEmpno());
    if (find != null) throw new DuplicateException(emp.getEmpno());  // 중복이면 예외로 알림
    dao.add(emp);
}

View에서는 try-catch로 잡아서 다이얼로그로 보여준다.

// EmployeeUI.java - buttonHandler
try {
    if (src == insertBt) insert();
    ...
} catch (NumberFormatException err) {
    dialog.show("급여는 정수로 입력해 주세요");
} catch (Exception err) {
    dialog.show(err.getMessage());  // CanNotFindException, DuplicateException 메시지가 여기 출력
}

예외 메시지를 직접 만들어뒀기 때문에 View에서는 err.getMessage()만 호출하면 된다. Service나 DAO는 화면이 어떻게 생겼는지 몰라도 된다. 계층이 분리된 덕분이다.


7. Generic Wildcard - ?

GenericTest4.java에서 Bounded Generic의 확장 개념인 Wildcard를 배웠다.

// ? : 모든 타입 허용
public static void registerCourse1(Applicant<?> applicant) { }

// ? extends Student : Student 또는 Student의 하위 타입만 허용 (상한 제한)
public static void registerCourse2(Applicant<? extends Student> applicant) { }

// ? super Worker : Worker 또는 Worker의 상위 타입만 허용 (하한 제한)
public static void registerCourse3(Applicant<? super Worker> applicant) { }

오전에 배운 <T extends Number>와 헷갈릴 수 있는데 역할이 다르다.

<T extends Number>  : 타입 파라미터를 제한. 클래스/메서드를 선언할 때 씀
<? extends Student> : 와일드카드. 이미 만들어진 제네릭 타입을 받는 메서드 파라미터에 씀

직관적으로 기억하는 방법은 이렇다.

? extends X  →  X이거나 X보다 아래(자식)  →  상한 제한 (Upper Bounded)
? super X    →  X이거나 X보다 위(부모)    →  하한 제한 (Lower Bounded)

오늘 예제에서 학생 강의(registerCourse2)는 Student 하위만, 직장인 강의(registerCourse3)는 Worker 이상만 신청 가능한 것으로 표현했다.


8. Collection - Set과 Map

Set - 중복을 허용하지 않는 자료구조

HashSet<String> set1 = new HashSet<>();
set1.add("hello");  // true 반환
set1.add("hello");  // false 반환 - 중복이라 저장 안 됨
set1.add("world");  // true

Set은 동일한 객체를 저장하지 않는다. 여기서 "동일하다"는 기준이 equals()와 hashCode()다.
DAY 03에서 "equals()와 hashCode()는 반드시 함께 오버라이딩해야 한다"고 배웠는데, 여기서 그 이유가 나온다.

HashSet<Employee> set2 = new HashSet<>();
set2.add(new Employee("1", "1", 1));  // true
set2.add(new Employee("1", "1", 1));  // true - 중복인데 두 개 다 들어간다??

Employee 클래스에 hashCode()를 override하지 않으면 Object 기본 구현인 메모리 주소 기반 hashcode가 쓰인다.
new Employee()를 두 번 호출하면 각각 다른 주소니까 HashSet 입장에서는 다른 객체다.
내용이 같아도 다른 버킷에 들어가서 중복 체크를 아예 안 한다.

equals()만 override하고 hashCode()를 안 하면 이런 버그가 생긴다.
둘을 함께 override해야 Set/Map에서 의도한 대로 동작한다.

Set은 index가 없어서 for-each나 Iterator로 순회한다.

Iterator<Employee> iter = set2.iterator();
while (iter.hasNext()) {
    System.out.println(iter.next());
}

저장 순서를 보장하지 않는다는 것도 주의 포인트. 넣은 순서대로 출력되지 않는다.


Map - 가장 빠른 자료구조

key-value 쌍으로 데이터를 관리한다. key는 유일해야 한다.

HashMap<Integer, Object> map1 = new HashMap<>();
map1.put(1, "hello");
map1.put(1, "world");  // 같은 key로 다시 put하면 덮어쓰기
System.out.println(map1.get(1));  // "world"

map1.remove(1);  // 삭제하고 삭제된 값 반환

// key만 추출
Set<Integer> keys = map1.keySet();
for (Integer key : keys) {
    System.out.println(key + " : " + map1.get(key));
}

// value만 추출
Collection<Object> values = map1.values();

Map이 가장 빠른 이유는 hashCode를 이용해서 저장 위치를 바로 계산하기 때문이다.
배열처럼 index로 O(1) 접근이 가능하고, 전체를 순회할 필요가 없다.
key에 대한 hashCode와 equals가 잘 구현돼 있어야 정확하게 동작한다는 점도 Set과 같은 이유다.


9. 함수형 인터페이스와 Lambda

등장 배경

인터페이스를 구현하는 방법이 세 가지가 있다.

// 1. 클래스로 구현
class SubFun implements FunTest {
    public void test() {
        System.out.println("클래스로 구현하기");
    }
}
uf.use(new SubFun());

// 2. 익명 클래스로 구현 (Anonymous Nested Class)
uf.use(new FunTest() {
    @Override
    public void test() {
        System.out.println("익명으로 구현하기");
    }
});

// 3. Lambda
uf.use(() -> System.out.println("lambda로 구현하기"));

클래스로 구현하면 단 한 번 쓸 코드를 위해 파일을 만들어야 한다. 익명 클래스는 그 자리에서 바로 구현하니까 낫지만 코드가 여전히 장황하다. Lambda는 추상 메서드가 하나뿐인 인터페이스라면 구현부만 딱 적으면 된다.

함수형 인터페이스 조건

추상 메서드가 정확히 1개인 인터페이스만 Lambda로 쓸 수 있다. 추상 메서드가 하나니까 어떤 메서드를 구현하는지 명확해서 컴파일러가 알아서 연결할 수 있다.

interface FunTest {
    void test();  // 추상 메서드 1개 → 함수형 인터페이스
}

@FunctionalInterface 어노테이션을 붙이면 추상 메서드가 2개 이상일 때 컴파일 에러로 잡아준다.

Lambda 문법

// 기본 형태
() -> System.out.println("lambda");

// 인자가 있을 때
(e1, e2) -> e1.getSalary() - e2.getSalary()

// 여러 줄일 때
(e1, e2) -> {
    return e1.getSalary() - e2.getSalary();
}

SortTest - Lambda 실전 적용

Employee[] emps = {emp1, emp2};

// 급여 오름차순
Arrays.sort(emps, (e1, e2) -> e1.getSalary() - e2.getSalary());

// 급여 내림차순
Arrays.sort(emps, (e1, e2) -> e2.getSalary() - e1.getSalary());

// 이름 오름차순
Arrays.sort(emps, (e1, e2) -> e1.getName().compareTo(e2.getName()));

// 이름 내림차순
Arrays.sort(emps, (e1, e2) -> e2.getName().compareTo(e1.getName()));

Arrays.sort(T[], Comparator<T>)에서 Comparator가 함수형 인터페이스다. 추상 메서드가 compare() 하나라서 Lambda로 바로 넘길 수 있다. compare의 반환값 기준은 이렇다.

0 반환  → 같다
양수 반환 → 앞 요소가 크다 → 뒤로 보냄 (오름차순)
음수 반환 → 앞 요소가 작다 → 앞에 유지 (오름차순)

e1 - e2면 오름차순, e2 - e1이면 내림차순인 이유가 여기 있다.


10. 키워드 정리

사용자 정의 Exception Checked vs Unchecked String Literal Pool String 불변성 equals() vs == Calendar Factory 패턴 Record accessor auto boxing/unboxing parseInt() 정규식 Pattern Generic Bounded Generic Wildcard ? extends ? super ArrayList LinkedList DAO 패턴 Service 계층 계층 분리 Set Map hashCode + equals 함께 override 함수형 인터페이스 Anonymous Nested Class Lambda Comparator Arrays.sort


11. 내일의 목표

  • 정처기 기출
  • GSAT 논리
  • 5시 시험 준비
profile
developer

0개의 댓글