자바 패키지와 접근제어 - public protected default private 와 프로젝트 구조 (자바 초급 8단원)
이 단원에서 배우는 것
7단원까지 만든 Book, LoanDesk, ReceiptPrinter 는 전부 한 폴더에 있다. 파일이 열 개를 넘어가는 순간 이 방식은 무너진다. 어느 클래스가 밖에서 쓰라고 만든 것이고 어느 것이 내부용인지 구분이 안 되고, 다른 사람이 만든 Book 과 이름이 겹치면 둘 중 하나는 쓸 수 없다.
초급 커리큘럼의 마지막 단원이다. 지금까지의 코드를 실제 프로젝트 모양으로 정리하면서, 6단원에서 클래스 안을 막았던 private 이 패키지 사이에서는 어떻게 확장되는지 본다.
- 패키지를 선언하고 디렉터리 구조를 맞춰
javac -d와java -cp로 빌드·실행한다. public·protected·default·private네 단계의 범위를 구분하고, 무엇을 공개할지 판단한다.import의 동작과 이름 충돌 해결 방법을 안다.
왜 필요한가
이름은 반드시 겹친다
클래스 이름으로 좋은 것은 정해져 있다. Book, User, Order, Service. 그래서 프로젝트가 조금만 커져도, 그리고 외부 라이브러리를 하나만 가져와도 이름이 겹친다. 실제로 표준 라이브러리 안에서도 java.util.Date 와 java.sql.Date 가 겹치고, java.util.List 와 java.awt.List 가 겹친다.
패키지는 클래스 이름 앞에 붙는 성(姓)이다. Book 의 진짜 이름은 kr.devin.library.domain.Book 이 되고, 남의 Book 과 절대 겹치지 않는다. 그래서 패키지 이름은 도메인을 거꾸로 쓴 것으로 시작하는 관례가 생겼다. 전 세계에서 유일함이 이미 보장된 문자열이기 때문이다.
그리고 무엇이 공개 API 인지 표시해야 한다
7단원에서 만든 ReceiptPrinter 를 다른 팀이 쓴다고 하자. 그 팀이 알아야 하는 것은 print(Book[]) 하나뿐이다. 내부에서 쓰는 계산 메서드까지 public 이면 어떻게 되는가. 남들이 그것을 쓰기 시작하고, 그 순간부터 고칠 수 없게 된다. 이름 한 글자만 바꿔도 다른 팀 코드가 깨진다.
접근제어는 "이건 써도 되고 저건 내가 언제든 바꾼다"를 코드로 선언하는 수단이다. 6단원에서 필드를 private 으로 막은 것과 목적이 같고, 범위만 클래스에서 패키지로 넓어졌다.
문법과 예제
패키지 선언과 디렉터리
package 선언은 파일의 맨 첫 줄이어야 한다(주석 제외). 그리고 디렉터리 구조가 패키지 이름과 정확히 같아야 한다. 이건 관례가 아니라 도구가 강제하는 규칙이다.
src/
└── kr/
└── devin/
└── library/
├── App.java package kr.devin.library;
├── domain/
│ ├── Book.java package kr.devin.library.domain;
│ └── BookLabel.java package kr.devin.library.domain;
└── service/
└── ReceiptPrinter.java package kr.devin.library.service;
패키지 이름은 전부 소문자로 쓴다. 밑줄이나 대문자를 섞지 않는다. 자바 표준 라이브러리(java.util, java.time)가 그 관례를 만들었다.
6단원 Book 을 패키지에 넣기
package kr.devin.library.domain;
public class Book {
private static final int MAX_OVERDUE_DAYS = 365;
private final String title;
private final String isbn;
private int overdueDays;
public Book(String title, String isbn) {
this(title, isbn, 0);
}
public Book(String title, String isbn, int overdueDays) {
this.title = title;
this.isbn = isbn;
setOverdueDays(overdueDays);
}
public String getTitle() {
return title;
}
public int getOverdueDays() {
return overdueDays;
}
public void setOverdueDays(int overdueDays) {
if (overdueDays < 0 || overdueDays > MAX_OVERDUE_DAYS) {
throw new IllegalArgumentException("연체일이 범위를 벗어났다: " + overdueDays);
}
this.overdueDays = overdueDays;
}
public int lateFee() {
if (overdueDays <= 0) return 0;
if (overdueDays <= 7) return overdueDays * 100;
return 700 + (overdueDays - 7) * 200;
}
String normalizedIsbn() { // 접근제어자가 없다 = default
return isbn.replace("-", "");
}
}
마지막 normalizedIsbn() 에 주목한다. 접근제어자를 안 붙였다. 이걸 default 또는 package-private 이라고 부르고, 같은 패키지 안에서만 보인다. 바코드 라벨을 만드는 클래스는 같은 domain 패키지에 있으므로 이 메서드를 쓸 수 있다.
package kr.devin.library.domain;
public class BookLabel {
public static String of(Book book) {
return "*" + book.normalizedIsbn() + "*"; // 같은 패키지라 접근 가능
}
}
반면 service 패키지에서는 못 쓴다. 이렇게 하면 "ISBN 정규화는 domain 내부의 사정"이라는 결정이 코드에 박힌다.
package kr.devin.library.service;
import kr.devin.library.domain.Book;
public class ReceiptPrinter {
public String print(Book[] books) {
StringBuilder sb = new StringBuilder();
sb.append("=== 대출 영수증 ===\n");
int sum = 0;
for (Book book : books) {
sb.append(book.getTitle()).append(" : ")
.append(book.lateFee()).append("원\n");
sum += book.lateFee();
}
sb.append("합계 : ").append(sum).append("원");
return sb.toString();
}
}
package kr.devin.library;
import kr.devin.library.domain.Book;
import kr.devin.library.domain.BookLabel;
import kr.devin.library.service.ReceiptPrinter;
public class App {
public static void main(String[] args) {
Book[] books = {
new Book("자바의 정석", "978-89-9449-203-2"),
new Book("이펙티브 자바", "978-89-6626-228-1", 12),
new Book("클린 코드", "978-89-6626-095-9", 3)
};
System.out.println(new ReceiptPrinter().print(books));
System.out.println(BookLabel.of(books[0]));
}
}
빌드하고 실행하기
-d 로 결과물을 둘 폴더를 지정한다. javac 가 패키지에 맞는 하위 폴더를 알아서 만든다. 실행할 때는 -cp 로 그 폴더를 알려 주고, 클래스는 패키지까지 포함한 전체 이름으로 지정한다.
$ javac -encoding UTF-8 -d out $(find src -name "*.java")
$ java -cp out kr.devin.library.App
=== 대출 영수증 ===
자바의 정석 : 0원
이펙티브 자바 : 1700원
클린 코드 : 300원
합계 : 2000원
*9788994492032*
out 폴더는 이렇게 생겼다. 소스 구조가 그대로 복제된다.
out/kr/devin/library/App.class
out/kr/devin/library/domain/Book.class
out/kr/devin/library/domain/BookLabel.class
out/kr/devin/library/service/ReceiptPrinter.class
배포할 때는 이 폴더를 통째로 .jar 하나로 묶는다.
$ jar --create --file library.jar --main-class kr.devin.library.App -C out .
$ java -jar library.jar
실무 프로젝트에서는 이 명령들을 직접 치지 않고 Gradle 이나 Maven 이 대신 한다. 다만 두 도구가 정한 표준 폴더 구조가 src/main/java 아래에 위 구조를 그대로 두는 것이므로, 지금 손으로 해 본 경험이 그대로 이어진다.
프로젝트/
├── build.gradle 또는 pom.xml
└── src/
├── main/java/kr/devin/library/... 운영 코드
├── main/resources/ 설정 파일
└── test/java/kr/devin/library/... 테스트 코드
접근제어 네 단계
| 제어자 | 같은 클래스 | 같은 패키지 | 다른 패키지의 하위 클래스 | 그 외 전부 |
|---|---|---|---|---|
private | O | X | X | X |
| default (안 붙임) | O | O | X | X |
protected | O | O | O | X |
public | O | O | O | O |
protected 는 상속과 묶여 있는데, 상속은 중급 단원에서 다룬다. 지금은 같은 패키지 전체 + 다른 패키지에 있더라도 이 클래스를 물려받은 클래스까지 열린다는 것만 알아 두면 된다. 상속을 안 쓰는 단계에서는 protected 를 쓸 일이 없다.
고르는 순서는 가장 좁은 것부터다. 일단 private 으로 두고, 같은 패키지에서 필요해지면 default 로 열고, 다른 패키지에서 정말 필요할 때만 public 으로 만든다. 반대 방향(넓게 열어 두고 나중에 좁히기)은 이미 남이 쓰고 있어서 불가능하다.
클래스 자체에는 public 과 default 만 붙일 수 있다. 그리고 한 파일에 public 클래스는 하나이고 그 이름이 파일 이름이어야 한다(1단원). default 클래스는 한 파일에 여러 개 둘 수 있지만, 파일 하나에 클래스 하나가 찾기 쉽다.
import
import 는 "이 짧은 이름을 쓰면 이 전체 이름을 뜻한다"는 별명 선언일 뿐이다. 파일을 불러오거나 복사하지 않는다. import 를 안 쓰고 매번 전체 이름을 적어도 똑같이 동작한다.
import kr.devin.library.domain.Book;
Book book = new Book("클린 코드", "978-89-6626-095-9");
// import 없이도 가능하다
kr.devin.library.domain.Book b2 = new kr.devin.library.domain.Book("클린 코드", "978-89-6626-095-9");
두 가지는 import 없이 쓸 수 있다. 같은 패키지의 클래스, 그리고 java.lang 패키지다. String, System, Integer, Math 를 지금까지 그냥 쓸 수 있었던 이유가 이것이다.
import java.util.*; 처럼 별표를 쓰면 그 패키지의 클래스들을 한꺼번에 별명 등록한다. 하위 패키지까지 포함하지는 않는다. import java.*; 로 java.util.List 를 쓸 수 없다.
static import 도 있다. 정적 멤버를 클래스 이름 없이 쓴다. 편해 보이지만 max 라는 이름만 봐서는 어디서 온 것인지 알 수 없으므로, 테스트 코드의 단언문 정도에만 제한적으로 쓴다.
import static java.lang.Math.max;
System.out.println(max(3, 12)); // 12
실무에서 자주 틀리는 것
1. 접근제어자를 안 붙인 것을 public 이라고 생각한다
자바에서 아무것도 안 붙이면 public 이 아니라 같은 패키지에서만 보이는 default 다. 그래서 패키지를 나누는 순간 갑자기 컴파일이 깨진다.
// kr.devin.library.domain.Book
String normalizedIsbn() { ... } // default
// kr.devin.library.service 에서
book.normalizedIsbn();
// error: normalizedIsbn() is not public in Book;
// cannot be accessed from outside package
클래스 선언에서도 마찬가지다. class Barcode { ... } 처럼 public 없이 선언한 클래스는 import 조차 안 된다.
import kr.devin.library.domain.Barcode;
// error: Barcode is not public in kr.devin.library.domain;
// cannot be accessed from outside package
한 폴더에서 연습하던 코드를 패키지로 옮길 때 이 에러가 무더기로 나온다. 당황해서 전부 public 으로 바꾸면 접근제어를 도입한 의미가 사라진다. 정말 밖에서 필요한 것만 하나씩 연다.
2. 기본 패키지에 둔 클래스를 패키지 안에서 못 쓴다
package 선언이 없는 클래스는 이름 없는 기본 패키지에 들어간다. 그리고 기본 패키지의 클래스는 다른 패키지에서 절대 쓸 수 없다. 이름이 없으니 import 에 적을 것이 없기 때문이다.
// Book.java 에 package 선언이 없는 상태에서
package kr.devin.library;
public class UseDefault {
public static void main(String[] args) {
Book b = new Book("x", 1);
// error: cannot find symbol
// symbol: class Book
}
}
"같은 src 안에 파일이 있는데 왜 못 찾지"의 답이 이것이다. 연습 파일이라도 처음부터 패키지를 붙여 두는 습관이 낫다.
3. 같은 이름의 클래스를 두 패키지에서 import 한다
단일 import 두 개가 이름이 겹치면 선언 자체가 에러다.
import java.util.Date;
import java.sql.Date;
// error: a type with the same simple name is already defined
// by the single-type-import of Date
별표 import 두 개면 선언은 통과하고 실제로 쓰는 줄에서 터진다. 원인을 찾기 더 어렵다.
import java.util.*;
import java.sql.*;
Date d;
// error: reference to Date is ambiguous
// both class java.sql.Date in java.sql and class java.util.Date in java.util match
해결은 자주 쓰는 쪽만 import 하고 나머지는 전체 이름으로 적는 것이다.
import java.util.*;
import java.sql.*;
java.util.Date d = new java.util.Date();
List<String> names = new ArrayList<>(); // 이건 그대로 짧게 쓴다
덧붙이면 별표 import 는 성능에 영향이 없다. 컴파일 시점의 이름 해석일 뿐이다. 그럼에도 실무에서 권하지 않는 이유는 나중에 라이브러리를 추가했을 때 갑자기 이름이 겹칠 수 있기 때문이다. IDE 가 자동으로 정리해 주므로 단일 import 를 쓴다.
4. 폴더와 package 선언을 어긋나게 만든다
파일을 IDE 밖에서 다른 폴더로 옮기고 package 줄을 안 고치면 이렇게 된다.
$ javac -d out src/kr/devin/library/domain/Book.java
$ ls out/kr/devin/library
Book.class <- domain/ 이 아니라 여기에 만들어졌다
$ java -cp out kr.devin.library.domain.Book
Error: Could not find or load main class kr.devin.library.domain.Book
Caused by: java.lang.ClassNotFoundException: kr.devin.library.domain.Book
javac -d 는 소스가 어느 폴더에 있든 package 선언을 보고 출력 폴더를 만든다. 그래서 컴파일은 성공하고 실행에서 실패한다. 소스 폴더 경로와 package 선언이 같은지 항상 맞춰 둔다.
5. 게터를 다 열어 두고 캡슐화했다고 생각한다
6단원에서 필드를 private 으로 막았지만, 모든 필드에 public 게터와 세터를 기계적으로 붙이면 결과적으로 public 필드와 다를 게 없다. 진짜 질문은 "필드를 감췄는가"가 아니라 "밖에서 이 값을 마음대로 바꿔도 객체가 여전히 올바른 상태인가"이다.
이번 단원의 Book 에 세터가 setOverdueDays 하나뿐이고 검증이 들어 있는 것, normalizedIsbn() 이 default 인 것이 그 판단의 결과다. 클래스가 늘어날 때마다 "이건 누가 써야 하는가"를 한 번씩 묻는 것이 이 단원에서 가져갈 습관이다.
스스로 확인하기
kr.devin.library.service.ReceiptPrinter클래스의 소스 파일은 어느 경로에 있어야 하는가? 그리고out폴더에 만들어지는.class파일의 경로는?- 다음 중
kr.devin.library.service패키지의 클래스에서 접근할 수 있는 것을 모두 고르라.Book은kr.devin.library.domain에 있는public클래스다.- (가)
public int lateFee() - (나)
String normalizedIsbn() - (다)
private final String isbn - (라)
protected void reset()— 접근하는 클래스가Book을 상속하지 않은 경우
- (가)
- 동료가
Book클래스에 통계용 메서드collectStats()를 추가하려 한다. 이 메서드는 같은domain패키지의BookLabel만 쓰고, 다른 패키지에서는 쓸 일이 없다. 어떤 접근제어자를 붙여야 하는가? 그리고 일단public으로 두면 나중에 무엇이 곤란해지는가?
정답
- 소스는
src/kr/devin/library/service/ReceiptPrinter.java, 클래스 파일은out/kr/devin/library/service/ReceiptPrinter.class다. 패키지 이름의 점이 폴더 구분자가 되고,javac -d out이 그 구조를 출력 폴더에 그대로 만든다. - (가)만 접근 가능하다. (나)는 default 라서 같은
domain패키지에서만 보인다. (다)는private이라Book클래스 안에서만 보인다. (라)는protected인데 다른 패키지에서 접근하려면Book을 상속한 클래스여야 하므로, 상속하지 않았다면 접근할 수 없다. - 접근제어자를 붙이지 않는 default 가 맞다. 같은 패키지의
BookLabel은 쓸 수 있고 다른 패키지에는 보이지 않는다.public으로 두면 다른 팀이 그 메서드를 쓰기 시작하고, 그 순간부터 이름·매개변수·반환 타입을 바꿀 수 없게 된다. 내부 구현을 고치려 할 때마다 남의 코드를 깨뜨릴지 확인해야 하고, 결국 고치지 못한 채 남는다. 넓게 열었다가 좁히는 것은 사실상 불가능하므로 항상 가장 좁은 것부터 시작한다.
초급 커리큘럼을 마치며
여덟 단원 동안 하나의 도서 대출 예제를 끌고 왔다. 1단원의 출력 한 줄이 2단원에서 변수가 되고, 3단원에서 정책이 되고, 4단원에서 여러 권이 되고, 5단원에서 메서드로 이름을 얻고, 6단원에서 객체가 되고, 7단원에서 영수증이 되고, 이번 단원에서 패키지로 정리됐다.
중급에서는 여기서 미뤄 둔 것들을 이어받는다. 4단원 끝에서 예고한 컬렉션, 6단원의 상속과 인터페이스, record, 그리고 setOverdueDays 에서 던지기만 했던 예외를 제대로 다루는 방법이다.
공식 문서는 다음 두 곳이면 충분하다. 문법 규칙의 최종 근거는 언어 명세이고, 표준 라이브러리 메서드의 정확한 동작은 API 문서에서 확인한다.