Java

[I Don't Know Java] 이것이 자바다 5

postbirds 2026. 6. 4. 12:13

본 시리즈는 저서 『이것이 자바다(신용권, 임경균 저)』 를 길잡이 삼아 자바를 다시 배워가는 기록입니다. 지난 번 글을 통해 자바클래스를 다루고, 객체지향 프로그래밍을 조금 알아봤습니다.

 

이번에는 '이것이 자바다' 책의 Ch08 인터페이스와 Ch09 중첩 선언과 익명 객체를 배울 것입니다. 우리가 판매할 어떤 물건이 있다고 가정하면, 그 물건을 판매하는 매장은 모두 다를 것입니다. 계산 시스템, 진열 방식 등 다양한 부분에서요. 판매할 곳이 모두 다르기 때문에 판매할 물건을 각 매장에 맞게할 수는 없습니다. 여기서 인터페이스는 규격이 다른 모든 매장에 판매할 물건이 들어가도록 해주는 '표준 규격 포장지' 같은 역할을 수행합니다.

 

중첩 선언과 익명 객체는, 오직 특정매장에서만 딱 한 번 사용하는 '팝업 행사 전용'같은 것 입니다. 따라서 정식 클래스 파일로 새로 정의하는 것보다 "이름 없이 빠르게 만들자"고 클래스 내부에 숨기거나 툭 만들어 쓰는 유연함을 제공합니다. 

 

Ch08. 인터페이스

인터페이스의 역할은 무엇이건 상관없으니 내가 하려고 하는 행위만 하게 해달라는 것이다. RPG 게임에서 무기를 다룬다고 하면 칼을 들건, 활을 들건 상관없이 무기를 장착해서 '공격'만 할 수 있으면 된다.

 

인터페이스가 없다면 캐릭터에게 Sword(칼) 클래스를 직접 연결하고, 칼의 slash()라는 메소드를 호출해서 공격할 것이다. Bow(활)을 사용할 때는 활의 fire()라는 메소드로 공격할 것이다. 공격을 위해서 매전 Sword와 Bow를 바꾸고, 메소드도 수정해야하는 번거로움이 발생한다. 

 

인터페이스가 있으면 캐릭터와 무기 사이에 Weapon(무기)라는 이름의 인터페이스를 두고, 전사 객체는 무기 인터페이스가 제공하는 '공격' 기능만 상요한다. 실제 무기에는 칼 객체, 활 객체를 바꿀 수 있고, 전사 객체는 어떤 무기를 들고 있는 지 그 구체성은 알 필요가 없다. 인터페이스를 통해서 전사와 구체적인 무기 사이의 강한 연결을 끊어줌으로서 '다형성'을 구현하는 것이다. 

 

인터페이스와 구현 클래스 선언

인터페이스는 *.java 형태의 소스파일로 작성되고 *.class 형태로 컴파일되므로 물리적 형태는 클래스와 동일하다.

접근 제한자는 같은 패키지 내에서만 사용 가능한 default와 패키지 상관없이 사용 가능한 public을 붙일 수 있다. 

interface 인터페이스명 { ... }
public interface 인터페이스명 {
// public 상수 필드
// public 추상 메소드
// public 디폴트 메소드
// public 정적 메소드
// private 메소드
// private 정적 메소드
}

 

인터페이스를 바탕으로 만들어진 클래스를 '구현 클래스' 라고 부르고 implements 키워드를 사용한다.

public class Sword implements Weapon{
	// Weapon 인터페이스가 가진 공격 기능을 반드시 구현해야 한다.
}

 

인터페이스도 하나의 타입이므로 변수 타입으로 선언할 수 있다. 이 경우 인터페이스는 참조 타입이므로 Weapon w = null; 처럼 null을 대입하는것도 가능하다. 

 

인터페이스의 멤버들

상수필드

모든 무기가 공통으로 가질 수 있는 규칙이나 절대 변하지 않는 값을 상수로 둔다. 인터페이스에 선언된 필드는 모두 public static final 특성을 가지므로 이를 생략해도 자동으로 컴파일 과정에서 붙는다. 

public interface Weapon {
    int MAX_ENCHANT_LEVEL = 10; // public static final이 자동으로 붙음
    int MIN_ENCHANT_LEVEL = 0;
}

상수는 구현 객체와 관련없는 인터페이스 소속 멤버이므로 Weapon.MAX_ENCHANT_LEVEL; 과 같이 바로 접근해서 사용할 수 있다.

 

추상 메소드

인터페이스는 구현 클래스가 재정의해야하는 public 추상 메소드를 멤버로 가질 수 있다. public abstract를 생략하더라도 컴파일 과정에서 자동으로 붙게된다. 

public interface Weapon {
    int MAX_ENCHANT_LEVEL = 10;
    
    // 추상 메소드 (public abstract가 자동으로 붙음)
    void attack();
    void unequip();
}

 

전사는 이 추상 메소드를 보고 attack(), unequip()을 호출하면 된다는 것을 알고, Weapon interface를 구현한 클래스들은 추상 메소드의 실체를 반드시 구현(오버라이딩) 해야 한다.

 

디폴트 메소드

인터페이스는 구현이 없는 추상메소드 말고, 완전한 실행 코드를 가진 디폴트 메소드를 가질 수 있다. 인터페이스를 구현하는 구현 클래스들 모두에서 공통적으로 사용하는 기능을 넣는다.

public interface Weapon {
    // 디폴트 메소드
    default void showInfo() {
        System.out.println("이것은 워터디플 정식 인가 무기입니다.");
    }
}

디폴트 메소드는 인터페이스를 구현한 실제 객체(인스턴스)가 있어야만 호출할 수 있다. 만약 특정무기에서 다르게 하고 싶다면 구현 클래스에서 public을 붙이고 default는 제외하고 오버라이드해서 쓸 수도 있다. 

 

정적 메소드

추상 메소드와 디폴트 메소드는 구현 객체가 필요하지만, 정적 메소드는 구현 객체가 없어도 인터페이스만으로 호출할 수 있다. public을 생략해도 자동으로 컴파일 과정에서 붙는다.

public interface Weapon {
    // 정적 메소드
    static void brandPolicy() {
        System.out.println("모든 무기는 타인을 해치는 데 남용되어서는 안 됩니다.");
    }
}

정적 메소드를 사용할 때 주의할 점은, 메소드 빌드를 할 때 인터페이스 내부의 상수 필드는 사용할 수 있지만, 추상 메소드나 디폴트 메소드는 호출할 수 없다. 왜냐하면 추상/디폴트 메소드는 반드시 '실제 객체'가 존재해야만 의미있는 인스턴스 메소드이기 때문이다.

 

private 메소드

default method나 static method 등을 여러 개 만드는데, 이 메소드들 내부에서 똑같은 코드가 반복해서 쓰일 수 있다. 중복되는 코드를 별도의 메소드로 분리하고 싶지만, 인터페이스 기본 멤버들(abstarct, default, static)을 선언할때 public을 생략해도 컴파일러가 자동으로 붙인다. 외부에 굳이 보여줄 필요가 없는 내부 짜집기용 메소드인데 외부에 강제로 노출된다. 이를 위해 private 메소드를사용한다. 인터페이스 내부에서만 필터링이나 중복 코드 제거용으로 사용하는 것이다.

  • private 메소드(인스턴스용): 디폴트 메소드 안에서 중복되는 코드를 줄이기 위해 사용
  • privtae static 메소드(정적 메소드용): 정적 메소드 안에서 중복되는 코드를 줄이기 위해 사용
public interface Weapon {
    
    default void normalAttack() {
        verifyWeapon(); // 중복 코드 분리!
        System.out.println("일반 공격을 수행합니다.");
    }

    default void specialAttack() {
        verifyWeapon(); // 중복 코드 분리!
        System.out.println("마나를 소비하여 특수 공격을 수행합니다.");
    }

    private void verifyWeapon() {
        System.out.println("[시스템 체크] 무기의 내구도와 불법 개조 여부를 확인합니다.");
    }


    static void systemAnnouncement() {
        logTime(); // 중복 코드 분리!
        System.out.println("서버 점검이 10분 뒤 시작됩니다.");
    }

    private static void logTime() {
        System.out.println("[로그 타임램프] " + java.time.LocalDateTime.now());
    }
}

 

다중 인터페이스

자바에서 클래스 상속은 '단일 상속'이 원칙이다. 그러나, 인터페이스는 다르다. 하나의 클래스가 여러 개의 인터페이스를 동시에 구현(implements)할 수 있다. 

public class MagicSword implements Weapon, MagicItem {
    // 1. Weapon의 추상 메소드 재정의 (공격하기 등)
    @Override
    public void attack() {
        System.out.println("마법이 깃든 칼을 휘두릅니다!");
    }

    // 2. MagicItem의 추상 메소드 재정의 (마나 소비하기 등)
    @Override
    public void castMagic() {
        System.out.println("파이어볼 마법을 시전합니다!");
    }
}

 

인터페이스 상속

클래스뿐만 아니라 인터페이스끼리도 상속을 할 수 있다. 인터페이스 상속은 클래스와 다르 게 여러 개의 인터페이스를 동시에 상속(extends)하는 것도 가능하다.

// 인터페이스 간의 다중 상속
public interface Artifact extends Weapon, MagicItem {
    void standardEffect(); // 유물 전용 특수 효과 추상 메소드
}

만약 어떤 클래스가 이 자식 인터페이스인 Artifact를 구현하게 된다면, 구현 클래스는 Artifact가 가진 메소드, 부모인 Weapon, MagicItem이 가진 모든 추상 메소드를 한꺼번에 재정의해야 한다. 또한, 이렇게 만들어진 구현 객체는 자식 인터페이스 변수뿐만 아니라, 부모 인터페이스(Weapon, MagicItem) 타입 변수에도 대입될 수 있다.  

 

타입 변환

Promotion(자동 타입 변환)

// 인터페이스 변수 = 구현 객체;
Weapon myWeapon = new Sword();

인터페이스 변수로는 오직 interface에 선언된 메소드만 호출할 수 있다. 구현 객체 클래스 내부에 따로 만들어둔 전용 필드나 메소드에 접근할 수 없다.

 

Casting(강제 타입 변환)

인터페이스 타입으로 변환된 변수를 통해 다시 구현 클래스만의 특수 기능을 쓰고 싶다면 캐스팅 기호를 사용해서 강제로 원래 타입으로 되돌려야 한다.

// 구현클래스 변수 = (구현클래스) 인터페이스변수;
Sword mySword = (Sword) myWeapon; 
mySword.sharpen(); // 이제 Sword 전용 메소드 사용 가능!

 

다형성

인터페이스는 자바에서 다형성을 구현하는 가장 주된 기술 수단이다. 상속과 마찬가지로 '재정의(Override)'와 '자동 타입 변환'이라는 특성을 활용한다. 

 

필드의 다형성

public class Warrior {
    // 무기라는 인터페이스 타입으로 필드를 선언
    public Weapon currentWeapon;
    
    public void useWeapon() {
        currentWeapon.attack(); // 어떤 무기가 연결되었든 간에 공격 실행!
    }
}

 

매개변수의 다형성

public class WeaponMerchant {
    // 매개변수로 어떤 무기든 다 받을 수 있음
    public void repair(Weapon weapon) {
        System.out.println("무기를 수리합니다.");
        weapon.attack(); // 수리가 잘 되었는지 테스트 공격!
    }
}

 

객체 타입 확인: instanceof 연산자

강제 타입 변환(Casting)할 때, 잘못된 타입으로 변환하려고 하면 ClassCastException이 발생한다. 이를 안전하게 확인하기 위해 instanceof 연산자를 사용한다. 

// 예전 방식 (Java 12 이전)
if (myWeapon instanceof Sword) {
    Sword sword = (Sword) myWeapon; // 번거롭게 한 번 더 변환해줘야 했음
    sword.sharpen();
}

// 요즘 방식 (Java 12 이후 스타일)
if (myWeapon instanceof Sword sword) {
    // 조건식이 true이면 우측의 'sword' 변수를 바로 사용할 수 있음!
    sword.sharpen(); // 캐스팅 코드가 생략되어 코드가 깔끔해집니다.
}

 

봉인된 인터페이스(sealed interface)

Java 15부터는 무분별한 자식 인터페이스가 생겨나 시스템이 꼬이는 것을 막기 위해 상속받을 수 있는 대상을 지정하는 '봉인 클래스'가 등장했다. 

예를 들어, 신화 등급 인터페이스인 LegendaryWeapon은 전설 검(LegendarySword)에게만 상속을 허용하고 싶다면 아래와 같이 작성한다.

// 1. sealed 키워드와 permits로 상속 가능한 자식 지정
public sealed interface LegendaryWeapon permits LegendarySword {
    void divinePower();
}

선택 받은 자식 인터페이스인 LegendarySword는 규칙상 다음 중 하나의 키워드를 반드시 붙여서 선언해야 한다.

  • non-sealed: 봉인을 해제한다. 내 밑으로는 아무나 상속받아라
  • sealed: 나도 나를 상속 받을 대상을 다시 지정한다.
// 2. 상속받은 자식은 non-sealed 또는 sealed를 명시해야 함
public non-sealed interface LegendarySword extends LegendaryWeapon {
    void swordDance();
}

이렇게 하면 개발자가 실수로 LegendaryWeapon을 엉뚱한 곳에 상속하려할 때 컴파일러가 미리 에러를 잡아준다.

 

Ch09. 중첩 선언과 익명 객체

중첩 클래스

클래스가 서로 사방으로 얽혀있으면 유지보수가 힘들어진다. 만약 "이 클래스는 오직 저 클래스 안에서만 쓰이는데?" 싶은 관계라면, 클래스 안에 클래스를 숨기는 '중첩 선언'이 답이 될 수 있다.

 

중컵 클래스는 선언되는 위치에 따라서 2가지로 분류 된다. 클래스의 멤버(필드, 메소드 위치)로 들어가는 멤버 클래스와 메소드 내부에서 잠깐 사용되는 로컬 클래스이다. 

class Character { 
    
    class Inventory { ... } // 인스턴스 멤버 클래스: 캐릭터 객체가 있어야만 존재함
    
    static class Ranking { ... } // 정적 멤버 클래스: 캐릭터 객체 없이도 존재함
    
    void openChest() {
        class EventReward { ... } // 로컬 클래스: 메소드가 실행될 때만 잠깐 존재함
    }
}

 

중첩 클래스도 클래스이므로 컴파일하면 각각의 *.class라는 바이트코드 파일이 만들어진다.

  • 멤버 클래스: 바깥클래스$멤버클래스.class
  • 로컬 클래스: 바깥클래스$1로컬클래스.class

 

인스턴스 멤버 클래스, 정적 멤버 클래스, 로컬 클래스

접근 제한자에 따라 이들의 활동 범위가 결정 된다.

 

인스턴스 멤버 클래스

  • public class B: 다른 패키지에서도 가져다 쓸 수 있다.
  • class B (default): 같은 패키지 내에서만 쓸 수 있다.
  • private class b: 바깥 클래스 내부에서만 사용할 수 있다.

정적(static 멤버 클래스)

정적 멤버 클래스는 바깥 클래스의 객체 없이도 독립적으로 외부에서 함께 쓰이는 경우가 많아서 주로 public이나 default로 선언한다.

 

로컬 클래스

메소드나 생성자 내부에 선언되어 그 안에서만 짝꿍으로 쓰이고 사라지는 클래스입니다.

  • Java 17 이후: Java 17 버전부터는 로컬 클래스 내부에도 정적 필드와 정적 메소드를 선언할 수 있게 되었다.
  • 로컬 변수 사용시 주의점: 로컬 클래스가 자신이 속한 메소드의 매개변수나 로컬 변수를 가져다 쓸 때, 그 변수들은 자동으로 final 특성을 가진다. 즉 값을 읽을 수는 있지만, 로컬 클래스 내부에서 마음대로 수정할 수 없다. Java 8 부터는 final을 생략해도 자동으로 상수가 되지만, 명확성을 위해서 final을 붙여주는 것도 좋다.

 

바깥 멤버 접근

중첩 클래스 내부에서 그냥 this를 쓰면 중첩 클래스 자신의 객체를 의미한다. 하지만 중첩 클래스 내부에서 바깥 클래스의 필드나 메소드에 접근하고 싶을 때는 바깥클래스이름.this를 사용하면 된다.

public class Hero {
	String name = "영웅의이름"; // 바깥 클래스의 인스턴스 필드
    
    void attack(){
    	System.out.println("기본 공격");
    }
    
    // 인스턴스 멤버 클래스
    class Skill{
    	String name = "스킬!"; // 중첩 클래스의 인스턴스 필드
        
        void execute(){
        	// 1. 중첩 클래스 자신의 필드/메소드 접근 (this 생략 가능)
            System.out.println("기술: " + this.name);
            // 2. 바깥 클래스의 필드/메소드 접근
            System.out.println("기술 사용자: " + Hero.this.name);
            Hero.this.attack();
        }
    }
}

 

중첩 인터페이스

인터페이스도 클래스 내부에 중첩으로 선언할 수 있다. 내부에 선언하는 이유는 이 인터페이스는 오직 이 클래스와 긴밀하게 매칭되는 구현 객체를만들 때 쓸 것이라 경계를 짓는 것이다.

 

중첩 인터페이스는 암시적으로 static이므로 생략해도 바깥 객체 없이 바로 접근이 가능하다. 안드로이드 등 UI 프로그램의 '버튼 클릭 이벤트'에서 단골로 사용된다.

public class GameButton{
	// 1. 중첩 인터페이스 선언(암묵적 static)
    public interface OnClickListener{
    	void onClick(); // 버튼이 눌렸을 때 실행될 추상 메소드
    }
    
    // 인터페이스 타입의 필드 선언
    private OnClickListener listener;
    
    // 외부에서 구현 객체를 주입받는 setter
    public void setOnClickListener(OnClickListener listener){
    	this.listener = listener;
    }
    
    // 유저가 버튼을 터치하는 행위를 시뮬레이션하는 메소드
    public void touch(){
    	if(listener != null){
        	listener.onClick(); // 주입된 구현 객체의 메소드 실행.
        }
    }
    
}

 

익명 객체

익명 객체는 이름이 없는 객체라는 뜻으로, 굳이 공식 클래스 파일(*.java)를 따로 만들지 않고 딱 한 번만 사용하고 더이상 사용하지 않을 객체를 코드 한 줄로 생성할 때 사용한다. 주로 필드값, 로컬 변수, 매개변수로 사용된다.

 

익명 객체는 단독으로 만들 수 없고 반드시 부모 클래스를 상속받거나 인터페이스를 구현해야만 만들 수 있다.

 

익명 자식 객체(클래스 상속 기반)

이미 존재하는 부모 클래스를 상속받아, 특정 메소드만 살짝 바꾼 일회용 자식 객체를 만드는 기법이다.

// 부모 클래스
class Potion{
	void drink(){
    	System.out.println("체력 회복");
    }
}

public class GameSystem{
	// 익명 필드에 자식 객체 대입
    Potion christmasEventPotion = new Potion(){
    	// 부모 클래스의 메소드를 즉석에서 재정의
        @Override
        void drink(){
        	System.out.println("[이벤트] 경험치 2배 등.");
        }
    }
}

 

익명 구현 객체(인터페이스 구현 기반)

Button 인터페이스를 생각하면, 버튼을 누를 때 일어날 행동을 계약시켜 놓고 이를 구현할 클래스가 필요하다. 하지만 A 버튼, B 버튼 등마다 하나하나 클래스를 새로 만드는 건 낭비이다. 이런 경우에 익명 구현 객체를 매개변수에 통째로 넣는다.

public class MainGame{
	public static void main(String[] args){
    	GameButton gameStartButton = new GameButton();
        
        // 매개 변수 값으로 익명 구현 객체를 즉석에서 대입
        gameStartButton.setOnClickListener(new GameButton.OnClickListener(){
        	@Override
            public void onClick(){
            	System.out.println("익명 구현 객체 이용");
            }
        });
        
        // 익명 객체 의 코드가 실행됨.
        gameStartButton.touch();
    
    }
}

 

후기

이번 장을 잘 이해하기 위해서는 클래스와 인터페이스를 잘 구별해야 한다는 생각이 들었습니다. 그럼으로 정리해보면 클래스는 Identity(정체성)에 대한 것으로 '~은 ~이다'라는 IS-A 관계를 의미합니다. 반면, 인터페이스는 Capability(능력)에 대한 것으로, 구현부가 없는 규칙을 가지므로 '~을 할 수 있다(CAN-DO)'와 '~ 역할을 한다(HAS-A role)'는 성격을 가집니다.

 

처음 자바를 학습하면서 interface를 알게 되었을 때를 생각해보면 abstract class와 차이가 무엇인가였습니다. 정리를 해보면 추상 클래스는 결국 클래스이므로 상속을 통한 extends의 개념을 가지고, IS-A 관계입니다. 인터페이스는 그저 이 인터페이스를 구현했다면 어떤 것이든 상관없이 '이 행동을 하자'고 지시하는 것에 가깝습니다. 다중 상속 가능 여부에서도 추상 클래스는 단일 상속만 가능하고 인터페이스는 다중 구현이 가능합니다. 인터페이스는 필드에서 public static final(상수)만 가능하다는 것도 큰 차기아 있습니다.

 

위와 같이 이론적인 차이를 정리해도, 실무에서 직접 구조를 설계하며 사용하지 않는다면 여전히 큰 도움이 안 된다는 생각을 하고 있습니다. 그럼에도 중첩 클래스 이후를 이해하기 위해서는 이 구분이 선행되어야 한다고 생각했습니다.

 

중첩 클래스는 Outer class 내부에서만 종속되고, 외부에 독립된파일로 존재할 이유가 없을 때 가치를 발휘합니다. 인터페이스 멤버 클래스는 어떤 주문 정보 클래스 안에서만 사용되는 결제 상세 내역 클래스처럼 종속적인 데이터 구조를 묶을 때 사용합니다. static 멤버 클래스는 Outter class를 보조할 때 사용되는데, 메모리 누스를 막기 위해 중첩 클래스를 쓸 때는 가급적 static을 붙이는 것이 안전합니다. Builder 패턴을 사용하거나 서버 응답을 위해 DTO를 만들 때 AResponse 클래스 내부에 static class AInfo 형태로 묶어서 관리하는 것이 대표적인 예시입니다.

 

중첩 인터페이스는 특정 클래스(컴포넌트)와 관련성이 극도로 큰 이벤트나 콜백 규칙을 정의할 때 제한적으로 사용됩니다. 익명 객체는 사소하고 한 번만 사용할 기능을 위해 클래스 파일을 무수히 많이 만들지 않도록 도와줍니다. 주로 필터링 기능이나 정렬 같은 일회성 로직에 강력한 도구가 됩니다.

 

참고자료