본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
5장 : 객체 책임과 캡슐화

패키지와 import

패키지 계층으로 Study Log 타입의 소속을 정하고 import와 완전한 이름으로 충돌을 명시적으로 해결합니다.

클래스가 늘면 이름만으로는 소속을 구분하기 어렵습니다. StudyLog, StudyEntry, ConsoleApp이 한 디렉터리에 섞이고 다른 기능도 같은 이름을 쓰기 시작합니다. 패키지는 타입의 완전한 이름을 만들고, import는 다른 패키지 타입을 소스에서 짧게 쓰게 합니다.

단순 이름 충돌

lab/AmbiguousDateImport.java
import java.sql.Date;
import java.util.Date;

public final class AmbiguousDateImport {
    public static void main(String[] args) {
        Date now = new Date();
        System.out.println(now);
    }
}
컴파일 실패 관찰
error: a type with the same simple name is already defined by the single-type-import of Date

java.sql.Datejava.util.Date는 완전한 이름이 달라 서로 다른 타입이지만 단순 이름은 둘 다 Date입니다. 한 파일에서 두 단순 이름을 동시에 import하면 Date가 어느 타입인지 결정할 수 없습니다.

한 타입만 import하고 다른 타입은 완전한 이름으로 씁니다.

import java.time.LocalDate;

LocalDate studyDay = LocalDate.of(2026, 7, 14);
java.sql.Date databaseDate = java.sql.Date.valueOf(studyDay);

별칭 import 문법은 자바에 없습니다. 이름 충돌이 자주 발생한다면 타입 역할이 드러나는 변수 이름과 완전한 이름을 사용합니다.

package와 타입 주소

src/studylog/app/PackageNameDemo.java
package studylog.app;

public final class PackageNameDemo {
    public static void main(String[] args) {
        System.out.println(PackageNameDemo.class.getName());
        System.out.println(PackageNameDemo.class.getSimpleName());
    }
}
studylog.app.PackageNameDemo
PackageNameDemo

첫 줄의 package studylog.app;가 완전한 클래스 이름을 studylog.app.PackageNameDemo로 만듭니다. package 선언은 주석과 import보다 앞에 와야 합니다. 관례상 디렉터리도 studylog/app/PackageNameDemo.java로 맞춰 소스 위치와 이름을 대응시킵니다.

패키지 이름은 모두 소문자를 사용하고 인터넷 도메인을 역순으로 시작하는 경우가 많습니다.

com.andongmin.studylog.model
com.andongmin.studylog.application
com.andongmin.studylog.console

디렉터리 중첩이 곧 접근 포함 관계를 뜻하지는 않습니다. studylog.modelstudylog.model.internal은 서로 다른 패키지입니다. 뒤의 패키지가 앞 패키지의 private 멤버에 접근할 수 있는 하위 범위가 아닙니다.

import의 역할

src/studylog/app/PackageImportDemo.java
package studylog.app;

import java.time.Duration;
import java.time.LocalDate;

public final class PackageImportDemo {
    public static void main(String[] args) {
        LocalDate day = LocalDate.of(2026, 7, 14);
        Duration duration = Duration.ofMinutes(90);

        System.out.println("day=" + day);
        System.out.println("minutes=" + duration.toMinutes());
    }
}
day=2026-07-14
minutes=90

importjava.time.LocalDate를 이 파일에서 LocalDate라고 쓰겠다는 이름 해석 규칙입니다. 클래스 파일을 복사하거나 런타임 의존성을 새로 만드는 문장이 아닙니다. 같은 패키지의 타입과 java.lang의 타입은 import 없이 사용할 수 있습니다.

별표 import는 특정 패키지의 타입 이름을 생략하게 하지만 하위 패키지까지 포함하지 않습니다.

import java.util.*;

이 선언은 java.util.List를 짧게 쓸 수 있게 하지만 java.util.concurrent.Executor는 포함하지 않습니다. 필요한 타입을 개별 import하면 의존성이 소스 위에 명확하게 드러나고 이름 충돌 원인을 찾기 쉽습니다.

책임 기반 패키지

다음 계층은 파일 종류가 아니라 책임 방향을 보여 줍니다.

studylog
├─ model
│  ├─ StudyEntry.java
│  └─ StudyLog.java
├─ application
│  └─ StudyLogService.java
└─ console
   └─ StudyLogCli.java
  • model은 기록과 저장 불변식을 가집니다.
  • application은 사용 사례의 순서를 조정합니다.
  • consoleScanner 입력과 문자열 출력을 담당합니다.

util, common, misc처럼 무엇이든 들어가는 패키지를 먼저 만들면 책임 경계가 흐려집니다. 실제로 함께 바뀌고 협력하는 타입을 가까이 두며, 외부에 공개할 타입과 내부 구현을 접근 제어로 나눕니다.

다음 단일 파일 예제는 패키지 내부 협력을 중첩 타입으로 축약해 실행 흐름을 보여 줍니다.

src/studylog/console/PackagedStudyLog.java
package studylog.console;

import java.util.Scanner;

public final class PackagedStudyLog {
    public static void main(String[] args) {
        Scanner scanner = new Scanner("arrays 40\nmethods 50\nquit\n");
        StudyLog log = new StudyLog(2);

        while (scanner.hasNextLine()) {
            String line = scanner.nextLine().trim();
            if (line.equals("quit")) break;

            String[] parts = line.split("\\s+");
            log.add(new StudyEntry(parts[0], Integer.parseInt(parts[1])));
        }
        System.out.println(log.summary());
    }

    private static final class StudyEntry {
        private final String topic;
        private final int minutes;

        private StudyEntry(String topic, int minutes) {
            if (topic.isBlank() || minutes <= 0) {
                throw new IllegalArgumentException("invalid entry");
            }
            this.topic = topic;
            this.minutes = minutes;
        }

        private String topic() { return topic; }
        private int minutes() { return minutes; }
    }

    private static final class StudyLog {
        private final StudyEntry[] entries;
        private int size;

        StudyLog(int capacity) {
            entries = new StudyEntry[capacity];
        }

        void add(StudyEntry entry) {
            if (size == entries.length) throw new IllegalStateException("full");
            entries[size++] = entry;
        }

        String summary() {
            int total = 0;
            for (int index = 0; index < size; index++) {
                total += entries[index].minutes();
            }
            return "count=" + size + ", total=" + total;
        }
    }
}
count=2, total=90

실제 프로젝트에서는 중첩 타입을 각각 model 패키지 파일로 옮기고 공개 API만 public으로 엽니다. console 패키지는 model의 공개 메서드를 import해 사용하며 내부 배열에는 접근하지 않습니다.

완전한 이름과 의존 방향

다른 패키지 타입을 사용할 때 선택지는 세 가지입니다.

studylog.model.StudyLog log = new studylog.model.StudyLog(10);

완전한 이름은 충돌을 해결하지만 반복이 깁니다.

import studylog.model.StudyLog;

StudyLog log = new StudyLog(10);

일반적인 경우 import로 단순 이름을 사용합니다. 같은 파일에 이름이 같은 다른 타입이 필요하면 하나만 import하고 나머지를 완전한 이름으로 씁니다.

패키지 이름은 물리 디렉터리 정리뿐 아니라 공개 API의 일부입니다. 패키지를 바꾸면 import와 완전한 이름을 사용하는 모든 코드가 영향을 받습니다. 처음부터 지나치게 세분화하지 말고 안정된 책임 경계부터 나눕니다.

연습 문제

java.time.LocalDate로 오늘을 만들고 java.sql.Date로 변환해 두 완전한 타입 이름을 출력하세요. 두 Date 계열 타입을 동시에 쓸 때 단순 이름 충돌을 만들지 마세요.

해설 보기
src/studylog/app/QualifiedDateNames.java
package studylog.app;

import java.time.LocalDate;

public final class QualifiedDateNames {
    public static void main(String[] args) {
        LocalDate day = LocalDate.of(2026, 7, 14);
        java.sql.Date stored = java.sql.Date.valueOf(day);

        System.out.println(day.getClass().getName());
        System.out.println(stored.getClass().getName());
    }
}
java.time.LocalDate
java.sql.Date

LocalDateimport하고 java.sql.Date는 완전한 이름을 사용했습니다. 두 타입의 소속과 변환 경계가 호출문에 드러납니다.