풀스택 개발자 과정 19일차

너구·2026년 6월 2일

풀스택 성장과정

목록 보기
19/79

지네릭 클래스 예제 (2)

ApiResponse의 데이터로 User를 입력하는 코드를 작성하라

import javax.lang.model.util.AbstractTypeVisitorPreview;

// 전송데이터
class User {

    private String username;
    private String email;

    public User(String username, String email) {
        this.username = username;
        this.email = email;
    }

    // 객체 출력용
    @Override
    public String toString() {
        return String.format
        ("User{username=%s, email=%s", username, email);
    }
}

// 응답 객체
class ApiResponse<T> {

    private int status; // 응답 상태
    private String message; // 응답 메시지
    private T data; // 응답 데이터: 어떤 데이터가 올지 모르기 때문에 지네릭 활용

    public ApiResponse(int status, String message, T data) {
        this.status = status;
        this.message = message;
        this.data = data;
    }

    public int getStatus() {
        return status;
    }

    public String getMessage() {
        return message;
    }

    public T getData() {
        return data;
    }
}

public class Main {
    public static void main(String[] args) {
        
        User user = new User
        ("johndoe", "johndoe@example.com");

        // 객체 생성시점에 타입(T)이 결정
        ApiResponse<User> response = new ApiResponse<>
        (200, "success", user);

        System.out.println(response.getStatus());
        System.out.println(response.getMessage());
        System.out.println(response.getData());
    }
}

ApiResponse< T >에서 T는 타입 매개변수이다.
즉 data에 어떤 타입이 들어올지 미리 정하지 않고 객체를 생성할 때 결정한다.
그렇기 때문에 T자리에 User가 들어간다.
즉 실제로는 class ApiResponse { private User dat; } 처럼 작동한다.
그래서 response.getData();의 반환타입도 User가 된다.

만약 지네릭이 없다면 ApiResonseUser, ApiResponseProduct, ApiResponseOrder... 처럼 데이터 타입마다 클래스를 따로 만들어야 한다.
하지만 지네릭을 사용한다면 ApiResponse < User >, ApiResponse< Product >, ApiResponse< Order > 처럼 하나의 클래스만으로 모든 타입을 처리할 수 있다.

지네릭 클래스 예제 (3)

Person의 1호 보물(numberOne)로 Car를 입력하는 코드를 작성하라.

class Car {

    private String modelName;
    private String color;
    private int year;

    public Car(String modelName, String color, int year) {
        this.modelName = modelName;
        this.color = color;
        this.year = year;
    }

    @Override
    public String toString() {
        // String의 정적 메서드
        // format(포멧, 입력값1, 입력값..)
        return String.format
        ("Car {modeleName=%s, color=%s, year=%s}", modelName, color, year);
    }
}

class Person<T> {

    private String name;
    private int age;
    private T numberOne;

    public Person(String name, int age, T numberOne) {
        this.name = name;
        this.age = age;
        this.numberOne = numberOne;
    }

    public String getName() {
        return name;
    }

    public int getAge() {
        return age;
    }

    public T getnumberOne() {
        return numberOne;
    }
}

public class Main {
    public static void main(String[] args) {
        
        Car car = new Car("아반떼", "흰색", 2018);

        Person<Car> person = new Person<>
        ("John Doe", 28, car);

        System.out.println(person.getAge());
        System.out.println(person.getName());
        System.out.println(person.getnumberOne());
    }
}

이 코드의 핵심은 Person< T >이다.
여기서 T는 타입 매개변수이며 객체를 생성할 때 실제 타입이 결정된다.
Person< Car > perosn 이 코드에서는 T자리에 Car가 들어간다.
실제로는 class Person { private car numberOne; } 형태로 동작한다고 볼 수있다.
따라서 numberOne에는 Car 객체만을 저장할 수 있다.

Car car = new Car("아반떼", "흰색", 2018);

Person< Car > person = new Person<>
("John Doe", 28, car);

이 코드는 Person 객체 안에 가장 소중한 물건(numberOne)으로 자동차 객체를 저장한 것이다.
또한 person.getnumberOne()을 호출하면 저장된 Car 객체가 반환된다.

System.out.println(person.getnumberOne());
이 때 Car클래스에서 toString()을 오버라이딩했기 때문에 객체 주소가 아닌 자동차 정보가 출력된다.

Car {modelName=아반떼, color=흰색, year=2018}
지네릭을 사용하지 않았다면 PersonCar, PersonBook, PersonPhone처럼 타입마다 클래스를 따로 만들어야 한다.
하지만 지네릭을 사용하면 Person< T > 하나만으로 어떤 타입의 객체든 저장할 수 있다.


Generic Methods

주로 유틸리티(Utility) 클래스를 만들 때 사용한다.
일반적으로 지네릭 클래스가 아닌 클래스가 지네릭이 필요한 경우에 많이 볼 수 있다.

< T > 반환_자료형 함수명


지네릭 메서드와 오버로딩

-지네릭: 타입만 다르고 내부 로직이 같은 경우에만 사용
-오버로딩: 타입뿐만 아니라 내부 로직 자체가 다른 경우에 사용


지네릭 메서드 예제

어떤 자료형의 데이터든 입력받아 그 데이터의 타입과 값을 출력하는 함수를 정의하라.

public class Main {
    
    static <T> void info(T d) {
        System.out.println("입력값 정보");
        System.out.println("자료형: " + d.getClass().getName());
        System.out.println("값: " + d);
        System.out.println();
    }

    public static void main(String[] args) {
        info(99);
        info(2.0);
        info("hello");
        info(true);
    }
}

이 코드는 지네릭 메서드를 사용한 예제이다.
static < T > void info(T d)
여기서 < T >는 타입 매개변수를 의미한다.
즉 이 메서드는 자료형을 미리 정하지 않고 호출할 때 전달된 값의 타입에 맞춰 동작한다.
예를 들어 info(99)를 호출하면 T -> Integer로 결정된다.
info("hello")를 호출하면 T -> String으로 결정된다.
따라서 하나의 메서드로 여러 자료형을 처리할 수 있다.

< T >를 사용했기 때문에 자료형마다 별도의 메서드를 만들 필요가 없으며, 전달된 값의 타입에 맞춰 자동으로 동작한다.


타입의 범위 제한하기

모든 타입이 아니라 특정 타입과 그의 자식들만 들어오도록 제한할 수 있다. (또는 인터페이스와 그의 구현 클래스들)
다음은 타입을 제한하는 방법이다.

class 클래스 이름< T extends 부모>

타입 제한 예제

Animal과 그의 자식 Dog, Cat, Duck 클래스들을 지네릭 인자로 취하는 클래스 AnimalFarm을 선언하라.

interface Animal {
    void cry();
}

class Dog implements Animal {
    @Override
    public void cry() {
        System.out.println("멍멍");
    }
}

class Cat implements Animal {
    @Override
    public void cry() {
        System.out.println("야옹");
    }
}

class Duck implements Animal {
    @Override
    public void cry() {
        System.out.println("꽥꽥");
    }
}

class AnimalFarm<T extends Animal> {
    T aniaml;

    AnimalFarm(T animal) {
        this.aniaml = animal;
        animal.cry();
    }
}

public class Main {
    public static void main(String[] args) {
        AnimalFarm<Dog> dogFarm = new AnimalFarm<>(new Dog());
        AnimalFarm<Cat> catFarm = new AnimalFarm<>(new Cat());
        AnimalFarm<Duck> duckFarm = new AnimalFarm<>(new Duck());
    }
}

이 코드는 제한된 지네릭(Bounded Generic)을 사용한 예제이다.
class AnimalFarm< T extends Animal > 여기서 T extends Animal은 T는 Animal을 상속받은 클래스만 사용할 수 있다라는 의미이다.

즉 AnimalFarm< Dog >, AnimalFarm< Cat >, AnimalFarm< Duck >은 가능하다.
왜냐면 Dog, Cat, Duck 모두 Animal 인터페이스를 구현했기 때문이다.

class AnimalFarm에서 T는 Dog가 될 수도 있고 Cat이 될 수도 있고 Duck이 될 수도 있다.
그래서 animal.cry();를 안전하게 호출할 수 있다.
왜냐면 T extends Animal 이므로 무조건 cry() 메서드가 존재한다는 것을 자바가 알고 있기 때문이다.

그렇기 때문에 main에서 AnimalFarm< Dog > dogFarm = new AnimalFarm<>(new Dog());이 실제로 Dog dog = ndw Dog(); dog.cry();와 비슷하게 동작한다.
그러니 출력하면 멍멍이 나온다.

제한을 두는 이유는 간단하다.
class AnimalFarm< T > 처럼 작성하면 AnimalFarm< String > farm = new AnimalFarm<>("hello")도 가능해진다.
그런데 String에는 cry() 메서드가 없다.
그래서 animal.cry();를 사용할 수 없게 된다.
이를 방지하기 위해 T extends Animal을 사용한 것이다.


지네릭과 인터페이스 예제

다음과 같이 지네릭을 갖는 인터페이스와 그를 구현한 클래스를 생성하라.

<< interface >>
Repository< T >
findById(int id): T

class User {
    int id;
    String username;

    User(int id, String username) {
        this.id = id;
        this.username = username;
    }
}

// 실제로 우리가 처리하는 부분
interface Repository<T> {
    T findById(int id);
}

class UserRespository implements Repository<User> {
    @Override
    public User findById(int id) {
        return new User(1, "John Doe");
    }
}

public class Main {
    public static void main(String[] args) {
        Repository<User> userRepository = new UserRespository();

        System.out.println(userRepository.findById(1));
    }
}

interface 안에 T를 사용해 어떤 타입의 데이터를 조회할지 나중에 결정하겠다는 의미를 주었다.
예를 들어 Repository< User >라고 사용하면 User findById(int id);처럼 동작한다.

그 다음 Repository< User >를 구현했기 때문에 T가 User로 바뀐다.
따라서 T findByID(int id);는 User findById(int id);가 된다.

Main에서는 Repository< User > userRepository = new UserRepository(); 인터페이스 타입으로 객체를 다루고 있다.
userRepository.findById(1);을 호출하면 new User(1, "John Doe")가 반환된다.

이 코드의 핵심은 인터페이스를 한 번만 만들고 지네릭을 사용해 다양한 타입에 재사용할 수 있다라는 점이다.
예를들어 나중에 class Product{} class ProductRepository implements Repository< Product >를 만들면 Repository< T > 인터페이스를 그대로 사용할 수 있다.

즉 인터페이스 -> 공통 기능 정의 지네릭 -> 어떤 데이터 타입이든 처리가능 이라는 장점을 얻을 수 있는 구조이다.


함수형 인터페이스 (Functional Interface)

추상메서드가 단 하나만 있는 인터페이스를 의미한다.
객체지향 언어인 자바에서 함수를 개별적으로 다루기 위한 방법을 제공한다.
함수형 인터페이스를 나타내기 위해 @FunctionalInterface 어노테이션을 활용한다.

함수형 인터페이스 정의 예제

다음을 참고해 함수형 인터페이스를 선언하라.

<< functional interface >>
MyFuncion
execute(): void

@FunctionalInterface
interface myFunction {
    // 하나의 메서드만 가져와야 한다
    void execute();
}

public class Main {
    
}

위 코드에서 execute()라는 추상 메서드가 하나뿐이므로 함수형 인터페이스가 된다.
@FunctionalInterface는 컴파일러에게 "이 인터페이스는 함수형 인터페이스로 사용할 것이다"라고 알려주는 어노테이션이다.
만약 실수로 추상 메서드를 하나 더 추가하면 컴파일 오류가 발생한다.


내장(표준) 함수형 인터페이스

자바는 목적에 맞는 다양한 함수형 인터페이스를 제공한다.


내장 FI 사용해보기 예제

아래 표를 참고해 내장 FI를 하나씩 활용하라

import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.function.Supplier;

public class Main {
    public static void main(String[] args) {

        // 필요한 import 전부 가져오기: 시프트 + alt + o

        // 데이터를 생성하고 제공(supply)합니다
        Supplier<String> supplier = () -> "데이터";
        System.out.println(supplier.get()); // 추상메서드 get()

        // 매개변수를 받아 소비(consume)합니다
        // 소비한다 = 반환값이 없을 때
        Consumer<Integer> consumer = (n) -> System.out.println(n);
        consumer.accept(2); // 추상메서드 accpet()

        // T타입 데이터를 받아 가공한 뒤 R타입 데이터로 변환합니다
        Function<String, Integer> function = (s) -> s.length();
        System.out.println(function.apply("foo")); // 추상메서드 apply()

        // 인자를 1개 받아 주어진 조건을 만족하는지 검사
        Predicate<Integer> predicate = (n) -> n > 0;
        System.out.println(predicate.test(2)); // 추상메서드 test()
    }
}

이 코드는 자바에서 제공하는 대표적인 함수형 인터페이스 4가지를 사용하는 예제이다.

  1. Supplier< T >
    값을 제공(Supply)하는 역할을 한다.
    입력값은 없고 반환값은 있다.
    suplier.get();을 호출하면 "데이터"가 반환된다.
    즉 데이터를 만들어서 반환하는 함수라고 볼 수 있다.

  2. Consumer< T >
    값을 소비(Consumer)하는 역할을 한다.
    입력값이 있고 반환값이 없다(void).
    consumer.accept(2);를 호출하면 2를 받아 출력한다.
    즉 값을 받아서 사용만 하는 함수이다.

  3. Function< T, R >
    입력값을 받아 다른 값으로 변환하는 역할을 한다.
    입력값과 반환값이 둘 다 존재한다.
    function.apply("foo");를 호출하면 문자열 "foo"의 길이인 3을 반환한다.
    입력을 받아 다른 형태의 값으로 변환하는 함수이다.

  4. Predicate< T >
    조건을 검사하는 역할을 한다.
    입력값이 있고 반환값은 boolean이다.
    predicate.test(2);를 호출하면 2 > 0의 결과인 true가 반환된다.
    즉 조건을 검사해서 참(true) 혹은 거짓(false)을 반환하는 함수이다.

이 네가지는 자바 람다식에서 가장 많이 사용하는 기본 함수형 인터페이스들이다.


람다

람다를 활용해 함수형 인터페이스(Functional Interface)를 구현할 수 있다.
다음은 람다 사용방법이다.

(int a, int b) -> {return a + b}

  • 타입추론: 매개변수의 타입을 생략할 수 있다.
    ex) (n) -> n * 2
  • 괄호생략: 매개 변수가 딱 1개라면 소괄호 ()를 생략할 수 있다.
    ex) n -> n * 2
  • 중괄호/리턴 생략: 실행문이 딱 한 줄이라면 중괄호{}와 return을 생략할 수 있다.
    ex) n -> n * 2

람다 예제 (1)

아래와 같이 함수형 인터페이스를 선언하고 람다로 구현하라.

<< functional interface >>
GetSquare
print(int n): void

// 함수형 인터페이스
@FunctionalInterface
interface GetSquare {
    void print(int n);
}

public class Main {
    public static void main(String[] args) {
        
        // 변수 = 추상매서드(print) 구현부
        // GetSquare getSquare = n -> System.out.println(n * n);
        // GetSquare getSquare = (int n) -> System.out.println(n * n);

        GetSquare getSquare = (int n) -> {
            System.out.println(n * n);
        };

        getSquare.print(11);
        getSquare.print(12);
        getSquare.print(13);
    }
}

이 코드는 람다식을 사용해 함수형 인터페이스를 구현한 예제다.
GetSquare는 추상 메서드가 하나만 있으므로 함수형 인터페이스이다.
void print(int n); 이 메서드는 정수 하나를 받아 어떤 작업을 수행하도록 정의되어 있다.

GetSquare getSquare = (int n) -> {System.out.println(n * n);}; 이 부분이 람다식이다.
원래는 익명클래스로 작성해야 한다.

하지만 함수형 인터페이스는 람다식으로 간단하게 표현할 수 있다.
(int n) -> {System.out.println(n * n);};의 의미는 정수 n을 받아서 n x n을 출력한다이다.

익명 클래스보다 코드가 훨씬 간결해진다라는 장점이 있다.

람다 예제 (2)

아래와 같이 함수형 인터페이스를 선언하고 람다로 구현하라.
(참고: Singer는 호출하면 항상 같은 노래를 부르는 함수이다.)

<< functional interface >>
Singer
song(): void

@FunctionalInterface
// 함수형 인터페이스 -> 함수의 규격을 정의한다
interface Singer {
    void song();
}

public class Main {
    public static void main(String[] args) {
        
        Singer singer = () -> System.out.println("lalala");

        singer.song();
    }    
}

Singer는 추상 메서드가 하나(song())만 존재하므로 함수형 인터페이스이다.
함수형 인터페이스는 람다식으로 구현할 수 있다.
Singer singer = () -> System.out.println("lalala");
이 부분이 람다식이다.
() -> System.out.println("lalala")의 의미는 song()이 호출되면 lalalal를 출력한다이다.
singer.song();을 실행하면 람다식에 작성한 코드가 실행된다.

람다 예제 (3)

아래와 같이 함수형 인터페이스를 선언하고 람다로 구현하라.
단 GetMax는 두 수를 입력받아 최대값을 반환하는 함수형 인터페이스이다.

<< functional interface >>
GetMax
get(int a, int b): int

@FunctionalInterface
interface GetMax {
    int get(int a, int b);
}

public class Main {
    public static void main(String[] args) {
        GetMax getmax = (a, b) -> a > b ? a : b;

        System.out.println(getmax.get(5, 10));
        System.out.println(getmax.get(3, 1));
        System.out.println(getmax.get(2, 10));
    }
}

FunctionalInterface를 통해 함수형 인터페이스를 정의하였다.
get(int a, int b)를 통해 두 개의 정수를 입력받을 수 있게 하였고 이를 람다식을 활용해 GetMax = getMax = (a, b) -> a > b ? a : b; 로구현하였다.
a와 b를 입력받고 a > b인지 확인 받는다.
참이면 a를 반환하고 거짓이면 b를 반환한다.
즉 두 값 중 더 큰 값을 반환하는 함수를 만든 것이다.


람다 활용방법

람다는 주로 스트림(Stream), Opitional 등 람다를 인자로 가지는 메서드를 가진 객체들과 사용된다.
ex) 스트림의 map, filter, forEach() 메서드 등

람다와 스트림 예제

아래와 같이 Persons 배열로부터 스트림을 생성하고 forEach 메서드와 람다를 활용해 내부 요소를 출력하라

{"John Doe", "Jane Doe", "Mary Doe"}

import java.util.Arrays;
import java.util.function.Consumer;

public class Main {
    public static void main(String[] args) {
        
        String[] persons = {"John Doe", "Jane Doe", "Mary Doe"};

        Consumer<String> consumer = person -> System.out.println(person);

        Arrays.stream(persons)
        .forEach(consumer);
    }
}

먼저 문자열 배열을 생성해서 persons 배열 안에 3개의 문자열을 저장하였다.

그 다음 Consumer를 생성하였다.
Consumer< T >는 값을 하나 받아서 처리만 하고 반환값은 없는 함수형 인터페이스다.
즉 consumer.accept("John Doe")가 실행되면 John Doe가 출력된다.

그 다음 Arrays.stream(persons)로 배열을 Stream 객체로 변환한다.
즉 3개의 문자열을 순차적으로 처리할 수 있는 형태로 바꾸는 것이다.

그 다음 .forEach(consumer);를 통해 Stream의 각 요소를 하나씩 꺼내서 consumer에게 전달한다.
consumer.accept("John Doe");
consumer.accept("Jane Doe");
consumer.accept("Mary Doe"); 와 거의 동일하다.

그렇기 때문에 출력하면
John Doe
Jane Doe
Mary Doe 가 나온다.


Stream

배열이나 컬렉션(List, Set, Map 등)에 저장된 데이터들을 가공/변환하는데 사용되는 객체이다.
스트림을 사용하면 반복문/조건문 없이 메서드 체이닝으로 데이터를 빠르게 처리할 수 있다.

다음의 3단계에 걸쳐 작업을 처리한다.
1. 스트림 생성
2. 가공/변환
3. 결과 처리

스트림 생성하기

컬렉션/ 배열로부터 데이터를 가공/처리하기 위해 스트림을 생성하는 단계이다.
다음은 자료형별 스트림 생성 방법이다.

스트림 자료형

기본 자료형과 참조형에 따라 다른 자료형을 가진다.

스트림 생성 예제 (1)

다음의 문자열 배열 persons로부터 스트림을 생성하라.

{"John Doe", "Jane Doe", "Mary Doe"}

import java.util.Arrays;
import java.util.stream.Stream;

public class Main {
    public static void main(String[] args) {
        
        // 문자열 배열
        String[] persons = {"John Doe", "Jane Doe", "Mary Doe"};

        // 문자열 배열 persons로부터 스트림을 생성
        Stream<String> personsStream = Arrays.stream(persons);

        System.out.println(personsStream);
    }
}

이 코드는 배열을 stream으로 변환하는 예제이다.
먼저 문자열 3개가 저장된 배열을 생성한다.
그 다음 Arrays.stream()을 사용해 persons 배열을 Stream< String >으로 변환한다.
즉 배열 -> 스트림으로 변환한 것이다.
스트림은 데이터를 하나씩 순서대로 처리하기 위한 객체이다.
이 코드는 스트림 안의 데이터를 출력하는 게 아니라 단순히 스트림 객체 자체를 출력하는 것이다.
그래서 메모리 주소와 유사한 정보가 출력이 된다.

스트림 생성 예제 (2)

다음과 같이 구성된 정수형 배열 nums로부터 스트림을 생성하라

{10, 20, 30}

import java.util.Arrays;
import java.util.stream.IntStream;

public class Main {
    public static void main(String[] args) {
        
        int[] nums = {10, 20, 30};

        // Arrays.stream: Array의 정적 메서드 stream
        // 배열 (Array)과 관련된 유틸리티 기능을 제공
        IntStream numsStream = Arrays.stream(nums);

        System.out.println(numsStream);
    }
}

이 코드는 정수배열을 IntStream으로 변환하는 예제이다.
먼저 정수 3개가 저장된 배열을 생성한다.
Arrays.stream(nums)를 사용해 배열을 IntStream으로 변환한다.
즉 int[] 배열이 IntStream으로 변환된 것이다.
여기서 IntStream은 int 전용 스트림이다.
문자열은 Stream< String >을 사용하였지만 정수는 성능을 위해 별도의 IntStream을 사용한다.
이 코드 또한 스트림 안의 값을 출력하는 게 아니라 스트림의 객체 자체를 출력하는 코드이다.
그렇기 때문에 출력시 객체 정보만 출력된다.


마무리

오늘은 지네릭, 함수형 인터페이스, 람다식, 그리고 Stream에 대해 학습해보았다.

지네릭을 활용해 하나의 클래스로 여러 타입을 처리하는 방법을 익혔고, 인터페이스와 함께 사용하는 구조도 살펴보았다. 또한 함수형 인터페이스와 람다식을 통해 동작을 간결하게 표현하는 방법을 배웠다.

특히 람다식을 직접 작성해보면서 익명 클래스를 더 짧고 읽기 쉽게 작성할 수 있다는 점이 인상적이었다. Stream도 배열을 효율적으로 처리하기 위한 도구라는 것을 확인할 수 있었다.

처음에는 다소 생소한 개념들이었지만 직접 코드를 작성하고 실행해보며 조금씩 이해할 수 있었고, 최근 자바에서 자주 사용되는 문법들을 익힐 수 있었던 의미 있는 시간이었다.

0개의 댓글