나의 베스트 프랙티스 – Java

1. 루프 안에서는 String.replaceAll() 대신 Pattern.compile()

public String replaceAll(String regex, String replacement) {
    return Pattern.compile(regex).matcher(this).replaceAll(replacement);
}

Pattern은 매번 먼저 컴파일되어야 합니다. 같은 Pattern을 짧은 문자열에 자주 적용한다면, 컴파일한 Pattern을 재사용하는 편이 성능이 훨씬 좋습니다.

2. 루프 안에서는 + 대신 StringBuilder

루프 안에서는 +보다 new StringBuilder()가 더 빠릅니다.

3. equals()와 hashCode() 올바르게 사용하기

equals()나 hashCode()에 모든 필드를 포함하는 경우가 종종 있습니다. 엔티티가 변경되었는지 확인할 때는 편리하지만, 이는 잘못된 방법입니다. equals()의 목적은 엔티티가 수정되었는지 확인하는 것이 아니라, 객체 인스턴스가 다르더라도 같은 엔티티인지를 판단하는 것입니다. 즉, 기본 키만 사용하세요!

이는 모든 종류의 컬렉션에서 필요합니다. List에서 .contains()를 호출하면 Java는 equals 메서드를 사용합니다. 게다가 HashMap에는 hashCode()도 필요합니다. 두 메서드는 반드시 함께 재정의해야 합니다.

흔한 함정은 아직 저장되지 않아 ID가 없는 엔티티입니다. 문제를 피하려면 이 부분을 조심하고 일찍 영속화하세요.

참고:

이 메서드들의 계약은 항상 지켜야 합니다:

4. 여러 운영체제가 섞인 환경에서 DateTime의 함정

어떤 운영체제는 내부 시계가 나노초 단위이고, 어떤 운영체제는 밀리초 단위입니다. 그래서 ZonedDateTime.now()는 플랫폼마다 다르게 동작하고, equals()를 망가뜨립니다. 대신 항상 정밀도를 맞추세요!

/**
 * truncating the precision, because it relies on the internal clock of linux/macOs/windows.
 * windows and older macOS versions use only milliseconds, whereas linux and new macOS versions use microsecond precision.
 */
public static ZonedDateTime getNow() {
    return ZonedDateTime.now().truncatedTo(DateTimeUtil.PRECISION);
}

다음 JPA 어노테이션도 ZonedDateTime.now()에 의존하므로 그대로 사용해서는 안 됩니다:

5. 복잡한 데이터 타입을 인라인으로 선언하지 않기

Pair나 List 등을 중첩하지 말고, 복잡한 데이터 타입에는 항상 전용 클래스를 만드세요.

나쁜 예:

// unlesbar
public Triple<List<Pair<List<List<ChatMessage>>, ZonedDateTime>>, Language, ChatRoom> getChatMessagesGroupedByDay();

더 나은 예:

public class FTOChatMessageCollection {

    private List<FTOChatMessageCollectionPerDay> messagesPerDay;
    private Language language;
    private ChatRoom chatRoom;

    // constructor, getter & setter ...
}
public class FTOChatMessageCollectionPerDay {

    private List<FTOChatMessage> messages;
    private ZonedDateTime day;

    // constructor, getter & setter ...
}

장점:

예외:

단순한 관계라면 Pair가 최고입니다! 예를 들면:

Pair<User, Training> pair = this.addCurrentUserToTraining(training);

6. 캐싱 – 제대로 하기(최소 구성 가이드)

캐시(cache, 발음은 /kæʃ/로 “cash”와 같음)는 기본적으로 항목을 담고 있는 Map일 뿐입니다. 시간 기반 무효화 같은 복잡한 기능은 여기서 다루지 않습니다.

단순한 Map으로 충분하다면 굳이 라이브러리를 쓸 필요가 없습니다! 많은 개발자가 이 점을 이해하지 못하고, 불안한 마음에 무조건 라이브러리부터 가져다 씁니다. 캐싱에서 가장 중요한 것은 적절한 시점에 무효화하는 것입니다.

함정 #1: 메모리 누수(Memory Leak)

캐싱에서는 메모리 누수가 아주 쉽게 생깁니다. equals()와 hashCode()를 제대로 재정의하는 것을 깜빡하기만 해도, 메모리가 새는 HashMap이 생깁니다. 항상 안전한 방법은 Map의 키를 직접 만드는 것입니다. 엔티티의 ID만 키에 포함하는 것이 가장 좋습니다. 그렇지 않으면 필드 하나만 바꿔도 Map에서 메모리가 새기 시작합니다. 이는 당연한 일이기도 합니다. 사용자의 이름이 바뀌었다고 해서 다른 사용자가 되는 것은 아니니까요. 참고: 3. equals()와 hashCode() 올바르게 사용하기

함정 #2: 스레드 안전성(Thread Safety)

캐시에는 원자적이고 스레드 안전한 연산을 제공하는 ConcurrentHashMap을 쓰는 것이 가장 좋습니다. Map은 computeIfAbsent()라는 단 하나의 호출로만 수정합니다. 이렇게 해서 스레드 안전성이 확보됩니다:

private ConcurrentMap<String, T> cache = new ConcurrentHashMap<>();

protected T getOrCompute(final String key, final Supplier<T> computeFunction) {
    // thread safe, because `computeIfAbsent` is atomic, so no need for `synchronized` here.
    // if you extend this to more than 1 map operation, you should synchronize it
    return this.getCacheMap().computeIfAbsent(key, k -> computeFunction.get());
}

코드가 더 길고 호출이 여러 번 있다면, 메서드 전체를 synchronized로 만들어야 합니다.

7. 직렬화 – 아직도 필요할까?

내 질문: Sonar 규칙 중에 “Serializable” 클래스의 필드는 transient이거나 직렬화 가능해야 한다라는 규칙이 있습니다. 직렬화할 수 없는 필드를 클래스에 추가하면 이 규칙에 걸립니다. 규칙에는 이렇게 쓰여 있습니다:

예를 들어 부하가 걸리면 대부분의 J2EE 애플리케이션 프레임워크는 객체를 디스크로 내보냅니다. Serializable이라고 하면서 transient도 아니고 직렬화도 불가능한 데이터 멤버를 가진 객체는 프로그램을 비정상 종료시키고 공격자에게 문을 열어 줄 수 있습니다.

제게는 이 말이 낡은 이야기로 들립니다. 『Effective Java』 3판에는 새 소프트웨어를 개발할 때 직렬화는 중요하지 않다고 나와 있습니다. 또한 Removing Serialization from Java Is a 'Long-Term Goal' at Oracle이라는 기사도 있습니다. Java 수석 아키텍트인 Mark Reinhold 본인도 이렇게 말했습니다.

“직렬화는 1997년에 저지른 끔찍한 실수였다.”

Spring 프레임워크가 객체를 디스크로 내보낼 수 있는지에 대한 질문은 이미 여기에 올라와 있습니다:

그래서 이렇게 질문합니다:

최신 애플리케이션 서버나 프레임워크, 혹은 J2EE 구현 자체(특히 CDI, EJB, JPA와 상호 작용하는 Java 컴포넌트)가 높은 부하에서 객체를 디스크로 내보내는 일이 있나요?

아니면 이 설명은 이제 낡은 것일까요?

답변:

TL;DR 상태를 가진 분산 웹 애플리케이션을 구현하는 것이 아니고, 세션 영속화를 켜지 않았으며, 인메모리 캐시만 사용한다면 Serializable 없이도 문제없을 수 있습니다.

설명:

세션 복제를 하려면 최신 Servlet 명세에서도 여전히 Java 직렬화가 필수입니다: https://jakarta.ee/specifications/servlet/5.0/jakarta-servlet-spec-5.0.html#distributed-environments

흔한 사례는 로드 밸런서 뒤에 여러 서버가 있는 경우입니다. 한 서버에서 로그인하면 JSESSIONID 쿠키를 받습니다. 다음 요청에서 로드 밸런서가 다른 서버로 보내면, 로그인 속성과 그 밖의 세션 범위 속성이 직렬화되어 새 서버로 복제되므로, 그 서버에서도 JSESSIONID가 여전히 유효합니다.

세션 범위 속성에 직렬화가 필요한 또 다른 사례는 애플리케이션 서버가 세션을 디스크로 옮기기로 할 때입니다. 예를 들어 Tomcat 8에서는 메모리에 담아 두기에 세션이 너무 많을 때나, 서버를 재시작하면서 세션을 유지하고 싶을 때 이 기능이 작동하도록 설정할 수 있습니다. 참고: https://tomcat.apache.org/tomcat-9.0-doc/config/manager.html

Servlet 5.0과 호환되는 모든 애플리케이션 서버는 이런 사례를 위해 기본적으로 HTTP 세션 안의 Serializable 객체를 지원해야 합니다.

일부 서버에서는 Jackson-JSON 직렬화 같은 방법으로 이를 조정하거나 대체할 수 있습니다. 예를 들어 다음을 참고하세요: https://stackoverflow.com/questions/46459707/how-to-use-jackson-instead-of-jdkserializationredisserializer-in-spring

또 하나의 흔한 사례는 캐싱입니다. 객체에 최대한 빠르게 접근할 수 있도록 유지하는 것으로, 이상적으로는 메모리에 두지만 일시적으로 디스크나 외부 캐시 서버로 옮겨 둘 수도 있습니다.
이를 위한 표준 API(JSR-107)가 있는데, 이 API는 일부러 Serializable 객체를 필수로 요구하지 않았습니다. 그래도 대부분의 구현체는 디스크로 옮기는 부분에 기본적으로 Java 직렬화를 사용합니다.

이것 역시 다른 직렬화 방식을 지원하도록 조정할 수 있습니다. 예를 들어 ehcache의 경우: https://www.ehcache.org/documentation/3.8/serializers-copiers.html

8. Hibernate: 페치 전략

Hibernate 컨트리뷰터이자 엄청나게 카리스마 넘치는 잘난 척쟁이가 쓴 EAGER fetching is a code smell을 고려하면, FetchType으로는 LAZY를 택하는 것이 좋습니다. 외래 키 없이는 의미가 없는 데이터 타입이라도, 실제로는 그 타입만 단독으로 조회하는 경우가 분명히 있습니다(getCount(), 데이터 테이블 등).

아래는 해결책이 얼마나 만족스러운지에 따라 모든 시나리오를 정렬한 것으로, 가장 만족스럽지 않은 것부터 시작합니다:

1. 최악의 경우: LazyInitializationException

설명이 필요 없죠. 우리 모두가 겪어 본 고통입니다.

2. 임시방편

public void updateChatRoom(ChatMessage message) {
    // reload here or LazyInitializationException would be thrown
    message = this.chatMessageService.reload(message); // not optimal: second roundtrip to the database - could easily take 100ms!
    // we missed the chance to unproxy without a performance hit.

    ChatRoom chatRoom = message.getChatRoom().getUpdatedAt(DateTimeUtil.getNow());
}

3. 서비스에서 바로 unproxy하기

public ChatMessage getChatMessage(final Long id, boolean withRoom) {
    // assume this is the same service + the same transaction.
    // so we are in the same persistence context + everything will be performed together at the transaction flush.
    // perfect: no additional roundtrip like in example 2.!

    ChatMessage message = this.chatMessageService.getById(id);

    if (withRoom) {
        ChatRoom chatRoom = this.chatMessageService.unproxy(message.getChatRoom());
    }
   
    // we should not call message.setChatRoom() -> this getter should not even exist as Hibernate would 
    // map this relation for us! setting it again would perform an update on our foreign key!!
    // so we have no option to set the ChatRoom back to the message and have to pass it everywhere in a Tuple or something similar
    return ???; 
}

4. 서비스에서 바로 fetch하기(이렇게 하세요!)

public ChatMessage getChatMessage(final Long id, boolean withRoom) {
    // all aforementioned problems solved
    return this.executeSingleResultQuery((query, cb, root) -> {
            if (withRoom) {
                root.fetch(ChatRoomEntity_.partition).fetch(ChatPartitionEntity_.chatModule);
            }

            return cb.equal(root.get(ChatRoomEntity_.id), id);
        });
}

9. Hibernate Search

Hibernate Search는 Hibernate JPA와 매끄럽게 연동됩니다. 엔티티가 바뀌면 이를 감지하고, 설정도 간단합니다:

@Entity
@Indexed(interceptor = SearchableChatMessageInterceptor.class) // hier können Einträge von der Indexierung ausgeschlossen werden
@FullTextFilterDef(name = "SearchableChatMessageFilter", impl = SearchableChatMessageFilter.class) // hier wird der Key festgelegt
public class ChatMessageEntity implements Searchable { // das Searchable dient als Marker-Interface

    @Column
    @Field  // dieses Feld wird indexiert
    private String message;

    @ManyToOne(targetEntity = ChatRoomEntity.class, fetch = FetchType.LAZY, optional = false)
    @IndexedEmbedded(includeEmbeddedObjectId = true, targetElement = ChatRoomEntity.class)   // indexiert den Fremschlüssel mit.
                                                                                             // Über dieses Entity wird auch die Message gesucht,
                                                                                             // wie in @FullTextFilterDef -> SearchableChatMessageFilter definiert
    private ChatRoom chatRoom;

    // ...

}

로컬에서는 검색을 꺼 둡니다:

// DefaultInstanceInitializer.java

// we don't need the global search everywhere, givin an option to override this setting here
protected boolean isBuildSearchIndexes() {
    return !Constants.Mode.isDebug();
}

퍼지 검색과 검색 동작

if ((needle.split(" ").length > 1 || needle.contains("\"")) && !searchExact) {
  /*
   * Simple Query String. Provides basic functionality like:
   *
   *     boolean (AND using “+”, OR using “|”, NOT using “-“)
   *     prefix (prefix*)
   *     phrase (“some phrase”)
   *     precedence (using parentheses)
   *     fuzzy (fuzy~2)
   *     near operator for phrase queries (“some phrase”~3)
   */
  query = qb.simpleQueryString()
            .onField(field)
            .andFields(moreFields)
            .withAndAsDefaultOperator()
            .matching(needle)
            .createQuery();
} // ...

Lucene

Luke로 Lucene 디버깅하기

우리가 쓰는 Hibernate Search 버전은 Luke 6.6.0 버전으로 디버깅해야 합니다. 이 버전에서만 동작합니다!

ElasticSearch

Hibernate Search는 ElasticSearch도 지원합니다(이 경우 Lucene 대신 사용되며, 퍼지 검색이 더 뛰어나 Google 같은 느낌을 줍니다).