포스트

2023-07-02-TIL

2023-07-02-TIL

Today I Learned

Spring Boot에서 대소문자를 구분하지 않는 Enum 매핑

Enum.valueOf는 대소문자를 구분하므로 ?status=active나 {"status": "active"}는 ACTIVE로 변환되지 않는다. 변환 경로가 둘이라 해결도 둘로 나뉜다. 쿼리 파라미터와 경로 변수는 Spring의 변환 서비스를 거치므로 Converter<String, Status>(또는 모든 enum에 적용할 ConverterFactory)를 만들어 대문자로 바꾼 뒤 변환하도록 등록한다. JSON 본문은 Jackson이 처리하므로 MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS를 켜거나 @JsonCreator 팩터리 메서드를 둔다.

  • https://devwithus.com/enum-mapping-spring-boot/

JPA의 optional 속성과 nullable

@ManyToOne(optional = false)와 @Column(nullable = false)(또는 @JoinColumn(nullable = false))는 비슷해 보이지만 적용되는 시점이 다르다. 링크한 질문의 답변에 따르면 nullable = false는 스키마 생성 지시라 DDL에 NOT NULL을 붙이는 데 쓰이고, optional = false는 런타임 지시로 연관 대상이 반드시 있다고 JPA 구현체에 알려 준다. Hibernate는 이를 근거로 조회 시 외부 조인 대신 내부 조인을 쓰거나, null 대신 프록시를 만들어도 된다고 판단할 수 있다. 그래서 필수 연관관계라면 둘 다 지정해 DB 제약과 매핑 정보를 일치시키는 편이 명확하다.

Thorben Janssen의 팁은 다른 의미의 optional을 다룬다. JPA는 Optional 타입 필드를 매핑하지 않으므로, null일 수 있는 속성은 필드는 일반 타입으로 두고 getter만 Optional을 반환하게 만드는 방법을 소개한다.

  • https://stackoverflow.com/questions/3331907/what-is-the-difference-between-manytooneoptional-false-vs-columnnullable-f
  • https://www.objectdb.com/api/java/jpa/ManyToOne/optional
  • https://thorben-janssen.com/hibernate-tips-map-entity-attribute-optional/
  • https://docs.oracle.com/javaee/7/tutorial/persistence-intro001.htm#BNBQA

리포지토리에서 List 대신 Map 반환하기

Spring Data JPA의 쿼리 메서드는 Map<ID, Entity>를 반환 타입으로 지원하지 않는다. ID로 빠르게 찾을 수 있는 맵이 필요하면 리포지토리 인터페이스에 default 메서드를 두고 findAll 결과를 스트림으로 Collectors.toMap해서 돌려주는 방법이 간단하다.

1
2
3
4
5
6
public interface MemberRepository extends JpaRepository<Member, Long> {
    default Map<Long, Member> findAllByIdAsMap(Collection<Long> ids) {
        return findAllById(ids).stream()
                .collect(Collectors.toMap(Member::getId, Function.identity()));
    }
}
  • https://stackoverflow.com/questions/41876122/spring-data-jpa-repository-findall-to-return-map-instead-of-list

save vs saveAll

Spring Data JPA의 SimpleJpaRepository.saveAll() 구현은 내부에서 각 엔티티에 대해 save()를 반복 호출할 뿐이다. 그런데도 성능 차이가 나는 이유는 트랜잭션이다. SimpleJpaRepository의 메서드에는 @Transactional이 붙어 있어서, 바깥에 트랜잭션이 없는 상태로 반복문 안에서 save()를 부르면 호출마다 트랜잭션이 새로 열리고 커밋된다. saveAll()은 전체가 한 트랜잭션이다. 링크한 실험 글이 확인하는 것도 이 차이이고, 서비스 메서드에 @Transactional을 붙이면 save() 반복도 같은 효과를 낸다.

saveAll()이 INSERT를 한 문장으로 묶어 주는 것은 아니다. JDBC 배치 삽입을 하려면 hibernate.jdbc.batch_size를 설정해야 하고, ID 전략이 IDENTITY면 INSERT 직후 생성된 키를 받아야 해서 Hibernate가 배치를 적용하지 않는다.

  • https://sas-study.tistory.com/388
  • https://www.baeldung.com/spring-data-save-saveall

Tomcat 최대 스레드 풀

Spring Boot 내장 Tomcat은 요청 하나를 워커 스레드 하나가 처리한다. Spring Boot 프로퍼티 문서 기준으로 server.tomcat.threads.max(최대 워커 스레드 수) 기본값은 200이고, 모든 스레드가 사용 중일 때 대기할 수 있는 연결 요청 큐의 길이인 server.tomcat.accept-count 기본값은 100이다. server.tomcat.max-connections는 동시에 받아 처리하는 연결 수의 상한이다. Tomcat 문서의 Executor 설정에서도 maxThreads 기본값은 200이다.

스레드 수를 늘린다고 처리량이 그만큼 늘지는 않는다. 요청이 DB를 기다리는 시간이 길다면 DB 커넥션 풀 크기가 먼저 병목이 되고, 스레드가 많으면 문맥 전환과 메모리 비용이 커진다. 그래서 스레드 풀, 커넥션 풀, 하위 시스템의 처리량을 함께 맞춰 조정해야 한다.

  • https://velog.io/@mooh2jj/Tomcat-Thread-Pool-%EC%A0%95%EB%A6%AC
  • https://taes-k.github.io/2020/01/13/tomcat-thread/
  • https://tomcat.apache.org/tomcat-8.5-doc/config/executor.html

Circuit Breaker

외부 서비스가 느려지거나 죽었을 때 계속 호출하면 요청 스레드가 타임아웃까지 묶여 장애가 호출하는 쪽으로 번진다. 서킷 브레이커는 실패가 일정 비율을 넘으면 호출을 차단하고 바로 실패나 대체 응답(fallback)을 돌려준다. Resilience4j 문서는 이를 CLOSED, OPEN, HALF_OPEN 세 상태의 유한 상태 기계로 설명한다. CLOSED에서는 호출을 통과시키며 슬라이딩 윈도(호출 수 기준 또는 시간 기준)로 결과를 집계하고, 실패율이 임계치를 넘으면 OPEN이 되어 호출을 차단한다. 일정 시간이 지나면 HALF_OPEN에서 일부 호출만 시험해 보고 성공하면 다시 CLOSED로 돌아간다.

stateDiagram-v2
    CLOSED --> OPEN: 실패율 임계치 초과
    OPEN --> HALF_OPEN: 대기 시간 경과
    HALF_OPEN --> CLOSED: 시험 호출 성공
    HALF_OPEN --> OPEN: 시험 호출 실패
이 글은 저작권자의 CC BY 4.0 라이선스를 따릅니다.

댓글

아직 댓글이 없습니다