| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | |
| 7 | 8 | 9 | 10 | 11 | 12 | 13 |
| 14 | 15 | 16 | 17 | 18 | 19 | 20 |
| 21 | 22 | 23 | 24 | 25 | 26 | 27 |
| 28 | 29 | 30 | 31 |
- OOP
- 백엔드
- 배치처리
- 레디스스트림
- 임베딩
- 마케팅 #퍼플카우 #새스고딘 #혁신 #독서 #이북
- DLT
- retry
- redis
- blockingqueue
- springboot
- 메세지브로커
- 메시지브로커
- rdb
- 레디스
- SaaS
- 테스트코드
- god object
- redisstreams
- 자연어캐싱
- aof
- 시맨틱캐싱
- redissearch
- 비동기처리
- 장애복구
- 객체지향적사고
- Kafka
- jedis
- 코사인
- 데이터유실방지
- Today
- Total
pandaterry's 개발로그
모던 자바 안티패턴: 설계 의도를 벗어나는 API 사용 사례 본문
모던 자바의 새로운 API들은 개발자에게 더 나은 코드를 작성할 수 있는 도구를 제공합니다. 하지만 이러한 API들의 설계 의도를 제대로 이해하지 못하고 사용하면 오히려 코드 복잡도를 높이고 성능을 저하시키며, 시스템 설계에 구조적인 문제를 야기할 수 있습니다. 이는 해외 자바 커뮤니티와 언어 설계자들도 지적하는 중요한 문제입니다.
Optional: 메서드 반환 타입으로만 설계된 API
설계 의도: null을 명시적으로 표현
Optional의 핵심 설계 의도는 "null을 반환할 수 있는 메서드의 반환 타입"으로 사용하는 것입니다. Java Language Architect Brian Goetz는 "Optional은 메서드 반환 타입으로만 사용하도록 설계되었다"고 명시했습니다.
안티패턴: 메서드 파라미터로 Optional 사용
가장 흔한 남용 사례는 Optional을 메서드 파라미터로 사용하는 것입니다.
// 설계 의도 위반
public void processOrder(Optional<String> couponCode) {
String code = couponCode.orElse("NONE");
// ...
}
// 호출자는 항상 래핑해야 함
processOrder(Optional.of("SUMMER2024"));
processOrder(Optional.empty());
이 패턴은 호출자에게 불필요한 부담을 주며, 코드베이스 전체를 오염시킵니다. 오버로딩이 더 나은 설계입니다:^4
Misusing Java’s Optional type
Java’s Optional type has been criticised for its flaws. It’s also being used for things it was perhaps not designed. This post shows some situations where I think Optional is being misused.
blog.indrek.io
public void processOrder(String couponCode) { /* ... */ }
public void processOrder() { processOrder("NONE"); }
// 호출이 훨씬 간단
processOrder("SUMMER2024");
processOrder();
안티패턴: Optional 필드 사용
JPA 엔티티나 일반 클래스의 필드로 Optional을 사용하면 직렬화 문제와 성능 저하가 발생합니다.^5
Reddit의 learnjava 커뮤니티
learnjava 커뮤니티에서 이 게시물을 비롯한 다양한 콘텐츠를 살펴보세요
www.reddit.com
@Entity
public class Order {
@Id
private Long id;
// 설계 의도 위반: Optional 필드
private Optional<String> comment; // 직렬화 실패
}
JPA 엔티티에 Optional 필드를 사용하면 Hibernate 직렬화가 실패하고, Jackson으로 JSON 변환 시에도 예상치 못한 결과가 발생합니다. 데이터베이스에 저장할 수도, API 응답으로 반환할 수도 없게 됩니다.
올바른 접근:
public class Order {
private String comment; // 실제 필드는 일반 타입
public Optional<String> getComment() {
return Optional.ofNullable(comment); // getter에서만 Optional 반환
}
}
안티패턴: 컬렉션을 Optional로 감싸기
컬렉션을 Optional로 감싸는 것은 "너무 과한" 사용 사례입니다.
// 설계 의도 위반: 컨테이너 in 컨테이너
public Optional<List<User>> getUsers() {
List<User> users = repository.findAll();
return users.isEmpty()
? Optional.empty()
: Optional.of(users);
}
Effective Java는 명시합니다: "컨테이너 타입(컬렉션, 맵, 스트림, 배열, Optional)은 Optional로 감싸면 안 된다". 빈 컬렉션 자체가 이미 "값 없음"을 표현하는 표준 방법이기 때문입니다.
올바른 설계:
public List<User> getUsers() {
return repository.findAll(); // 빈 리스트 반환 가능
}
안티패턴: Optional 자체를 null로 반환
Optional을 반환하는 메서드에서 null을 반환하는 것은 설계 의도를 완전히 배신하는 것입니다.
// 최악의 안티패턴
public Optional<User> findUser(Long id) {
if (id == null) return null; // Optional이 null!
// ...
}
// 호출자는 이중 검사가 필요
Optional<User> result = findUser(id);
if (result != null && result.isPresent()) { // 두 번 체크
User user = result.get();
}
Joshua Bloch는 "절대로 Optional을 반환하는 메서드에서 null을 반환하지 마라. 이는 이 기능의 전체 목적을 무너뜨린다"고 경고했습니다.
성능 영향: 메모리 오버헤드
Optional 객체의 메모리 비용은 실제 값보다 훨씬 큽니다. 단순한 Integer 하나를 Optional<Integer>로 감싸면:^7
Integer자체: 16바이트Optional객체: 16바이트- 총 32바이트 + 참조 오버헤드
null로 표현하면 0바이트인데, Optional을 사용하면 최소 32바이트가 필요합니다. 대용량 컬렉션이나 데이터베이스 엔티티에 Optional을 남발하면 메모리 사용량이 기하급수적으로 증가합니다.^7
실제 성능 측정 결과:^7
// 벤치마크: 100만 개의 Optional 처리
// null 체크: 12ms
// Optional 체크: 89ms
// 약 7배 느림 + GC 압력 증가
Stream API: 선언적 데이터 변환을 위한 도구
설계 의도: 복잡한 데이터 변환 파이프라인
Stream API의 핵심 설계 의도는 복잡한 데이터 변환과 파이프라인 처리를 선언적으로 표현하는 것입니다. 단순한 반복문을 대체하기 위한 것이 아닙니다.
안티패턴: 단순 작업에 Stream 남발
가장 근본적인 설계 의도 위반은 단순한 루프를 Stream으로 억지로 변환하는 것입니다.
// 설계 의도 위반
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
numbers.stream()
.filter(n -> n % 2 == 0)
.collect(Collectors.toList());
// 올바른 접근: 단순 작업은 루프로
List<Integer> evenNumbers = new ArrayList<>();
for (Integer number : numbers) {
if (number % 2 == 0) {
evenNumbers.add(number);
}
}
이 경우 Stream을 사용하면 오히려 코드가 복잡해지고, 88바이트의 추가 메모리가 소비되며, GC 압력이 증가합니다.
안티패턴: Stream to Collection to Stream 체이닝
터미널 연산으로 컬렉션을 반환한 후 즉시 다시 .stream()을 호출하는 것은 명백한 설계 의도 위반입니다.
// 설계 의도 위반
List<String> result = orderIds.stream()
.map(this::getOrderDetails)
.collect(Collectors.toList()) // 터미널 연산
.stream() // 다시 스트림 생성
.map(OrderDetails::getUserId)
.collect(Collectors.toList());
Stream은 단일 파이프라인으로 설계되었습니다. 중간에 컬렉션으로 변환했다가 다시 스트림으로 만드는 것은 불필요한 오버헤드를 발생시킵니다.
올바른 접근:
// 단일 파이프라인으로 유지
List<String> result = orderIds.stream()
.map(this::getOrderDetails)
.map(OrderDetails::getUserId)
.collect(Collectors.toList());
안티패턴: 반복적인 Stream 생성
같은 컬렉션에서 반복적으로 Stream을 생성하는 것은 Stream의 일회성 설계를 이해하지 못한 것입니다.
// 설계 의도 위반
List<String> allNames = new ArrayList<>(...);
for (int i = 0; i < 1000; i++) {
allNames.stream()
.filter(name -> name.startsWith("A"))
.forEach(System.out::println);
}
Stream은 한 번의 데이터 변환 파이프라인을 위해 설계되었습니다. 반복문 안에서 매번 Stream을 생성하는 것은 엄청난 성능 오버헤드를 발생시킵니다.
안티패턴: Stateful 연산 남발
sorted(), distinct() 같은 stateful 연산을 과도하게 사용하는 것은 Stream의 stateless 설계 원칙을 위반합니다.
// 설계 의도 위반
List<String> list = Arrays.asList("B", "C", "A", "D", "C");
list.stream()
.distinct() // Stateful
.sorted() // Stateful
.filter(s -> s.length() > 1)
.distinct() // 또 Stateful
.forEach(System.out::println);
Stream은 기본적으로 stateless 연산을 위해 설계되었습니다. Stateful 연산은 내부 상태를 유지해야 하므로 병렬 처리 시 성능 저하와 예측 불가능한 동작을 유발합니다.
CompletableFuture: 비동기 체이닝을 위한 API
설계 의도: 블로킹 없이 비동기 연산 체이닝
CompletableFuture의 핵심 설계 의도는 "블로킹 없이 비동기 연산을 체이닝"하는 것입니다. 그러나 개발자들은 이를 잘못 이해하고 있습니다.
안티패턴: 동기적 .join() 남발
비동기를 블로킹으로 되돌리는 패턴은 설계 의도의 정반대입니다:
// 설계 의도 위반: 비동기를 블로킹으로 되돌림
CompletableFuture<String> future1 = fetchDataAsync();
String data = future1.join(); // ❌ 메인 스레드 블로킹
CompletableFuture<Integer> future2 = processAsync(data);
Integer result = future2.join(); // ❌ 또 블로킹
이는 "비동기의 힘과 정반대"이며, 비동기 커뮤니티에서는 "매우 권장되지 않는" 패턴입니다.
올바른 접근: 체이닝으로 블로킹 제거:
안티패턴: thenCompose 대신 thenApply 남용
개발자들이 자주 혼동하는 부분은 thenApply와 thenCompose의 차이입니다.
// 설계 의도 위반: 중첩된 Future
CompletableFuture<CompletableFuture<User>> nested =
getUserIdAsync()
.thenApply(id -> fetchUserAsync(id)); // ❌ 이중 래핑
// 올바른 접근: flatMap 개념
CompletableFuture<User> flat =
getUserIdAsync()
.thenCompose(id -> fetchUserAsync(id)); // ✅ 평탄화
thenCompose는 Stream의 flatMap에 해당하며, "Future를 반환하는 함수"를 체이닝할 때 사용해야 합니다.
var 키워드: 타입 추론을 통한 가독성 향상
설계 의도: 중복 제거, 타입 숨기기 아님
JEP 286 설계자의 의도는 "더 나은 가독성(cleaner/better readability)"이지 타입 시스템 수정이 아닙니다.
공식 JEP 286 예제:
// 개선 전
URL url = new URL("http://www.oracle.com/");
URLConnection conn = url.openConnection();
Reader reader = new BufferedReader(
new InputStreamReader(conn.getInputStream()));
// 개선 후: 더 읽기 쉬움
var url = new URL("http://www.oracle.com/");
var conn = url.openConnection();
var reader = new BufferedReader(
new InputStreamReader(conn.getInputStream()));
설계 의도는 "타입이 명확할 때 중복을 제거"하는 것입니다.
안티패턴: 타입 추론이 불가능한 곳에 var 사용
커뮤니티 불만은 "타입이 복잡해서 모르겠으니 var로 해결"하는 패턴입니다.
// 설계 의도 위반
var result = someComplexBuilder()
.withOption1()
.withOption2()
.build(); // 무슨 타입인지 알 수 없음
// IDE 없이는 코드 이해 불가
var data = repository.findSomething(); // ❌ 뭘 반환?
설계자의 경고: "코드가 실제로 더 읽기 어려워진다. 타입을 알기 위해 소스를 열거나 디컴파일해야 한다." 이는 설계 의도의 정반대입니다.
Java Serialization: "재앙" 수준의 설계
Brian Goetz의 공식 입장
Brian Goetz의 공식 입장은 "Java Serialization은 재앙(disaster)이었다"는 것입니다. "마법적(magical) 동작"과 "언어 외적(extra-linguistic) 메커니즘"으로 인해 객체 모델의 무결성을 훼손합니다.
설계 의도 위반: 캡슐화 파괴
Serialization의 가장 큰 문제는 캡슐화를 완전히 우회한다는 것입니다.
public class BankAccount implements Serializable {
private final BigDecimal balance;
public BankAccount(BigDecimal balance) {
if (balance.compareTo(BigDecimal.ZERO) < 0) {
throw new IllegalArgumentException("음수 잔액 불가");
}
this.balance = balance;
}
}
이 클래스는 생성자로 음수 잔액을 방어하지만, 역직렬화는 생성자를 우회하므로 음수 잔액을 가진 BankAccount를 만들 수 있습니다. "코드를 검사해도 정확성과 보안을 검증할 수 없게" 만듭니다.
Brian Goetz의 발언 (2021):
"1997년에 악마와 거래를 했다. 플랫폼 성공에 중요했지만 끔찍한 방식으로 구현했다. 완전히 언어 밖에 존재하고 객체 모델 밖에 존재한다. 여기서 모든 악이 나온다. 캡슐화를 훼손하고, 진화를 어렵게 만들고, 보안을 추론하기 불가능하게 만든다."
Records와 Serialization의 개선
Java 14+ Records는 Serialization 문제를 부분적으로 해결합니다. Record는 "API가 곧 상태"이므로, 역직렬화 시 반드시 생성자를 거칩니다.
record BankAccount(BigDecimal balance) implements Serializable {
public BankAccount {
if (balance.compareTo(BigDecimal.ZERO) < 0) {
throw new IllegalArgumentException("음수 잔액 불가");
}
}
}
역직렬화가 생성자를 호출하므로 "객체를 잘못된 상태로 만들 수 없습니다".
sun.misc.Unsafe: "아무도 사용하면 안 되는" API
OpenJDK의 공식 경고
OpenJDK의 공식 경고는 "sun.misc.Unsafe는 종국적으로 폐기(terminally deprecated)될 것"이라는 점입니다. 그러나 수많은 라이브러리(Lombok, Netty, Hibernate)가 이를 사용합니다.
설계 의도: "내부 전용" 명시
Unsafe는 설계상 public API가 아닙니다. sun.misc 패키지는 "Java 표준이 아닌 내부 구현"을 의미하며, JavaDoc에도 명시되지 않았습니다.
14가지 사용 패턴이 발견되었고, 가장 흔한 것은:
- 바운드 체크 없는 메모리 접근
- 초기화되지 않은 객체 생성
- 생성자를 우회한 필드 직접 조작
안티패턴: Unsafe로 캡슐화 우회
// 설계 의도 완전 위반
public class UnsafeUser {
private static final Unsafe unsafe = getUnsafe();
public void breakEncapsulation(Object obj, long offset, int value) {
unsafe.putInt(obj, offset, value); // ❌ private 필드 직접 수정
}
}
Stack Overflow 경고: "해커도 당신 인터페이스의 클라이언트가 될 수 있다. 바운드 체크 없는 메모리 접근은 재앙적 결과를 초래할 수 있다."
Lombok의 문제: JDK 24에서 objectFieldOffset이 제거 예정이며, Lombok은 이를 대체할 방법을 모색 중입니다.
결론
모던 자바의 새로운 API들은 각각 명확한 설계 의도를 가지고 있습니다:
- Optional: 메서드 반환 타입으로만 사용하여 null을 명시적으로 표현
- Stream: 복잡한 데이터 변환 파이프라인을 선언적으로 표현
- CompletableFuture: 비동기 연산을 블로킹 없이 체이닝
- var: 타입이 명확할 때 중복을 제거하여 가독성 향상
이러한 API들을 설계 의도에 맞게 사용하면 코드의 가독성과 유지보수성이 향상됩니다. 하지만 설계 의도를 무시하고 남용하면 오히려 코드 복잡도를 높이고 성능을 저하시키며, 시스템 설계에 구조적인 문제를 야기할 수 있습니다.
가장 중요한 것은 각 API의 설계 의도를 이해하고, 적절한 상황에서만 사용하는 것입니다. "모던한 코드"처럼 보이기 위해 억지로 사용하는 것은 진정한 시니어의 모습이 아닙니다. 문제에 맞는 적절한 도구를 선택할 줄 아는 것이 진정한 전문성입니다.
'개발 > OOP' 카테고리의 다른 글
| JPA와 도메인 모델: 복잡한 조회 쿼리의 딜레마와 실전 해결책 (0) | 2025.11.20 |
|---|---|
| [책 | 오브젝트] CH5. 책임 할당하기 (0) | 2025.11.16 |
| [책 | 오브젝트] CH3. 역할/책임/협력 (0) | 2025.11.09 |
| [OOP 안티패턴] God Object? 이 정도는 괜찮지 않나요 (2) | 2025.06.23 |