JAVA

Java 제네릭 실전 패턴: 제네릭 메서드·인터페이스 설계와 자주 만나는 에러 해결법

IT Lab 2026. 2. 21. 10:00

Java 17 기준으로 제네릭 메서드와 제네릭 인터페이스를 실무 관점에서 설계하는 패턴을 정리하고, 타입 추론 실패·캡처 에러 등 흔한 컴파일 에러 해결법을 예제로 설명합니다.

 

제네릭을 “컴파일 타임 안전장치”라고는 하는데, 막상 공용 유틸을 만들거나 인터페이스를 설계할 때는 타입 파라미터를 어디에 둬야 할지 헷갈리실 때가 많아요. 특히 incompatible types, capture of ?, cannot infer type arguments 같은 에러가 한 번 나오면, 고치기보다 우회하게 되는 경우도 흔합니다. 이번 글에서는 실무에서 바로 써먹는 제네릭 메서드/인터페이스 패턴과, 자주 만나는 에러의 정석적인 해결법을 정리해 봅니다.

핵심 개념: “타입 파라미터를 어디에 두느냐”가 API 품질을 결정합니다

Producer-Extends / Consumer-Super(PECS)와 타입 파라미터 위치(메서드 vs 인터페이스)를 요약한 다이어그램

제네릭 설계의 핵심은 간단합니다. 타입이 “객체의 상태”에 속하면 클래스/인터페이스에, 타입이 “한 번의 호출(행위)”에만 필요하면 메서드에 둡니다. 이 기준만 지켜도 제네릭이 과도해지거나(타입 파라미터 남발), 반대로 재사용성이 떨어지는(캐스팅 남발) API를 크게 줄일 수 있어요.

제네릭 메서드가 유리한 순간

  • 유틸리티성 동작: firstOf(List<T>), swap(List<T>, i, j)
  • 타입을 “입력에서 받아 출력으로 전달”하는 형태: T identity(T value)
  • 호출 시점에 타입이 결정되는 경우(상태로 들고 있을 필요 없음)

반대로, 메서드마다 타입 파라미터가 제각각이면 호출자 입장에서 복잡해지니, 타입의 일관성이 중요한 컴포넌트(예: Repository, Serializer)는 보통 제네릭 인터페이스/클래스로 빼는 편이 낫습니다.

제네릭 인터페이스 설계 패턴(실전에서 가장 많이 씁니다)

실무에서는 아래 3가지 형태가 자주 등장합니다.

패턴 형태 장점 주의점
단일 타입 파라미터 Mapper<I, O> 또는 Codec<T> 가장 직관적이고 재사용 쉬움 타입 파라미터 의미를 이름으로 드러내기
상한 경계로 제약 <T extends Number> 잘못된 타입 사용을 컴파일 타임에 차단 제약이 과하면 확장성 저하
와일드카드로 유연성 List<? extends T> / Consumer<? super T> 호출자에 유리(더 많은 인자를 받음) 구현 내부에서 “쓰기/읽기” 제약 이해 필요

이전 글(#17)에서 와일드카드를 다뤘다면, 이번 글에서는 한 단계 더 나아가 “API 표면에는 와일드카드를, 구현 내부에는 타입 파라미터를” 두는 습관을 추천합니다. 호출자는 편해지고, 구현자는 캡처/추론 문제를 줄일 수 있어요.

자주 만나는 컴파일 에러 3종 세트와 해결 방향

  1. cannot infer type arguments (타입 추론 실패)
  • 원인: 입력 인자만으로 T를 결정할 정보가 부족하거나, 람다/메서드 레퍼런스가 모호함
  • 해결: (a) 타입 인자를 명시하거나, (b) 중간 변수를 두어 타입을 고정하거나, (c) 메서드 시그니처를 더 구체화
  1. capture of ? (와일드카드 캡처 문제)
  • 원인: List<?>처럼 “정체를 모르는 타입”에 값을 넣으려 해서 발생
  • 해결: (a) 메서드에 타입 파라미터를 도입해 캡처하거나, (b) ? super T로 받도록 API를 바꾸기
  1. incompatible types (제네릭은 불공변 invariance)
  • 원인: List<Integer>List<Number>의 하위 타입이 아님
  • 해결: 읽기 목적이면 ? extends, 쓰기 목적이면 ? super로 바꾸기(PECS)

아래 코드 예제에서 이 3가지를 한 번에 실전적으로 묶어 보겠습니다.

flowchart TD
  A[""API 설계 의도""] --> B[""읽기 위주(Producer)"""]
  A --> C[""쓰기 위주(Consumer)"""]
  B --> D[""? extends T 사용"""]
  C --> E[""? super T 사용"""]
  D --> F[""구현 내부는 <T>로 캡처"""]
  E --> F

제네릭 API는 “읽기/쓰기 방향”을 먼저 정하면 시그니처가 깔끔해집니다.

코드 예제: 제네릭 메서드 + 제네릭 인터페이스 + 에러 해결을 한 파일로 정리

아래 코드는 Java 17에서 그대로 실행됩니다. “제네릭 인터페이스 설계 → 유틸 제네릭 메서드 → 캡처/추론 문제 해결” 흐름으로 구성했습니다.

import java.util.*;
import java.util.function.Function;

public class GenericPatternsDemo {

    // 1) 제네릭 인터페이스 설계 패턴: 변환기(Mapper)
    // - I: 입력 타입, O: 출력 타입
    public interface Mapper<I, O> {
        O map(I input);

        // 2) 제네릭 메서드: mapper 합성 (상태가 아니라 "행위"에만 타입이 필요)
        // - andThen의 타입은 호출 시점에만 결정되므로 <N>을 메서드에 둡니다.
        default <N> Mapper<I, N> andThen(Mapper<? super O, ? extends N> next) {
            Objects.requireNonNull(next);
            return (I in) -> next.map(this.map(in));
        }
    }

    // 3) 제네릭 인터페이스 설계 패턴: 저장소(Repository)
    // - T는 "이 컴포넌트가 다루는 도메인 타입"이므로 인터페이스 타입 파라미터로 둡니다.
    public interface Repository<T, ID> {
        Optional<T> findById(ID id);
        void save(T entity);
    }

    public static final class InMemoryRepository<T, ID> implements Repository<T, ID> {
        private final Map<ID, T> store = new HashMap<>();
        private final Function<T, ID> idExtractor;

        public InMemoryRepository(Function<T, ID> idExtractor) {
            this.idExtractor = Objects.requireNonNull(idExtractor);
        }

        @Override
        public Optional<T> findById(ID id) {
            return Optional.ofNullable(store.get(id));
        }

        @Override
        public void save(T entity) {
            store.put(idExtractor.apply(entity), entity);
        }
    }

    // 4) 자주 만나는 에러: "capture of ?" 해결 패턴
    // - List<?>는 어떤 타입인지 모르므로 add가 막힙니다.
    // - 해결: 메서드에 <T>를 도입해서 "캡처"합니다.
    public static <T> void copyAll(List<? extends T> src, List<? super T> dst) {
        for (T t : src) {
            dst.add(t);
        }
    }

    // 5) 타입 추론 실패를 줄이는 제네릭 메서드 패턴: identity
    public static <T> T identity(T value) {
        return value;
    }

    // 도메인 샘플
    public record User(long id, String name) {}

    public static void main(String[] args) {
        // --- Mapper 예제 ---
        Mapper<String, Integer> parseInt = Integer::parseInt;
        Mapper<Integer, String> toHex = i -> Integer.toHexString(i);

        Mapper<String, String> parseThenHex = parseInt.andThen(toHex);
        System.out.println(parseThenHex.map("255")); // ff

        // --- Repository 예제 ---
        Repository<User, Long> userRepo = new InMemoryRepository<>(User::id);
        userRepo.save(new User(1L, "kim"));
        System.out.println(userRepo.findById(1L).orElseThrow().name()); // kim

        // --- 불공변(invariance) + PECS 예제 ---
        List<Integer> ints = List.of(1, 2, 3);
        List<Number> numbers = new ArrayList<>();

        // copyAll은 src: extends(읽기), dst: super(쓰기)로 설계되어 유연합니다.
        copyAll(ints, numbers);
        System.out.println(numbers); // [1, 2, 3]

        // --- 타입 추론 관련: 필요하면 타입 인자를 명시해 "힌트"를 줄 수 있습니다 ---
        // 보통은 아래처럼 잘 추론됩니다.
        String s = identity("hello");

        // 하지만 복잡한 제네릭 조합에서는 다음처럼 명시가 도움이 될 때가 있습니다.
        Integer n = GenericPatternsDemo.<Integer>identity(42);
        System.out.println(s + ", " + n);
    }
}

실무 팁

💡 실무에서는: “인터페이스에는 최소한의 제네릭만” 두는 게 유지보수에 유리합니다.

  • Repository<T, ID, ...>처럼 타입 파라미터가 늘어나면 호출부가 급격히 읽기 어려워져요.
  • 타입이 늘어날 것 같으면, 타입 파라미터로 계속 확장하기보다 요청/응답 DTO로 묶거나, **전략 객체(예: IdExtractor<T, ID>)**로 분리하는 편이 더 깔끔한 경우가 많습니다.

💡 실무에서는: 와일드카드 에러를 “캐스팅으로 땜질”하기 전에, 메서드로 캡처해 보세요.

  • List<?>를 받아서 뭔가 넣고 싶다면 거의 항상 설계 신호입니다.
  • 외부 API는 ? extends / ? super로 유연하게 받고, 내부 구현은 <T> 제네릭 메서드로 캡처하면 컴파일러가 도와줄 여지가 커집니다.

핵심 요약: 제네릭 타입은 “상태면 인터페이스/클래스, 행위면 메서드”에 둡니다.
핵심 요약: 읽기/쓰기 방향을 먼저 정하고 extends/super(PECS)로 시그니처를 잡아 보세요.
핵심 요약: capture of ?는 캐스팅보다 “제네릭 메서드로 캡처”가 정공법입니다.

다음 글: #19 람다식 — 콜백 지옥 탈출