목차
- 개요 및 사건 요약
- 첫 번째 문제
- 두 번째 문제
- 세 번째 문제
- 결론
- 회고 및 제안
개요 및 사건 요약
한 서비스에 기능 고도화를 개발하고 배포한 일이 있었습니다. 서비스 배포 과정에서 발생한 에러에 대해 원인을 파악하고 해결한 경험을 기록하여 공유해보려고 합니다.
용어나 코드는 실제의 것으로 작성하지 않고 컨셉으로 대체하였습니다.
- 대상 서비스:
서비스A
- 배포 내용:
유니버설 템플릿
적용을 통한 주요 기능 변경 및 추가 로직 도입 - 일시: 최근 날짜 (예: 2023년 11월 8일 10:30부터 12:30까지)
- 발생한 문제: 실제 운영 환경에서의 스프링 부트 어플리케이션 실행 실패, 주요 API 기능 제공 불가
- 결론: 3가지 주요 실패 원인 분석
첫 번째 문제
첫 번째 시도에서 만난 에러 메시지는 다음과 같습니다. 이 에러로 빌드 자체가 안되었는데요.
에러 메시지
org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'entityManagerFactory' defined in class path resource [org/springframework/boot/autoconfigure/orm/jpa/JpaConfiguration.class]: Invocation of init method failed; nested exception is javax.persistence.PersistenceException: [PersistenceUnit: default] Unable to build JPA SessionFactory; nested exception is org.hibernate.tool.schema.spi.SchemaManagementException: Schema-validation: missing column [column_name] in table [table_name]
원인 및 해결
해당 에러의 원인은 생성 쿼리의 문제였습니다. 스테이지 db의 생성 쿼리를 다음과 같이 가져왔는데 해당 쿼리에서 케이스 컨벤션을 확인하지 못한 부분이 문제였습니다.
ADD COLUMN isDeleted BOOLEAN DEFAULT FALSE COMMENT '삭제 여부';
따라서 해당 부분은 isDeleted
로 작성된 칼럼명을 스네이크 케이스로 변경하여 해결할 수 있었습니다.
두 번째 문제
가벼운 문제인 줄 알고 안도했는데 문제는 해당 부분을 변경한 이후에도 다음과 같은 에러가 발생합니다.
에러 메시지
org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'entityManagerFactory' defined in class path resource [org/springframework/boot/autoconfigure/orm/jpa/HibernateJpaConfiguration.class]: Invocation of init method failed; nested exception is javax.persistence.PersistenceException: [PersistenceUnit: default] Unable to build Hibernate SessionFactory; nested exception is org.hibernate.tool.schema.spi.SchemaManagementException: Schema-validation: wrong column type encountered in column [is_deleted] in table [sample_table]; found [bit (Types#BIT)], but expecting [boolean comment '삭제 여부' (Types#BOOLEAN)]
원인 및 해결
에러의 내용은 칼럼 스키마 유효성 검증에서 발생한 에러로, 메시지의 내용에 따르면 칼럼이 데이터베이스에서 bit
로 발견되었지만 Hibernate
가 boolean
타입을 기대한다는 점입니다. isDelted
필드의 @Column
어노테이션에서 정의된 columnDefinition
이 다음과 같이Boolean으로 설정되어 있었기 때문입니다.
@Column(columnDefinition = "BOOLEAN COMMENT '삭제 여부'")
private Boolean isDeleted;
해결 방법으로 수행한 내용은
1) 필드의 타입을 Boolean
-> boolean
인 primitive
타입으로 변경하고
2) @Column
어노테이션에서 Boolean
타입을 명시하는 내용을 지운 뒤
3) 로컬에서 ddl-auto
설정을 update
를 통해 Hibernate가 테이블 및 칼럼을 생성하도록 하여 db의 isDeleted
칼럼의 타입 속성이 TINYINT
가 아닌 bit
로 생성되는 것을 확인한 뒤, ddl-auto
설정을 다시 validate
로 바꾸어
배포하였습니다.
이와 같은 방식으로 이번에는 어플리케이션 정상 구동에 성공합니다.
세 번째 문제
위에서 변경한 방식으로 문제가 해결되고 정상 배포될 것을 기대했는데 또 다른 문제가 발생합니다.
에러 메시지
11:14:24.832 [ INFO] [Thread-1] [com.example.log.impl.LogTrace] - [111f77f6] | |<GenericFacade.search(..) time=253ms ex=org.springframework.orm.jpa.JpaSystemException: Null value was assigned to a property [class com.example.domain.model.Entity.isFlag] of primitive type setter of com.example.domain.model.Entity.isFlag; nested exception is org.hibernate.PropertyAccessException: Null value was assigned to a property [class com.example.domain.model.Entity.isFlag] of primitive type setter of com.example.domain.model.Entity.isFlag
원인 및 해결
이번의 문제는 정상 배포는 되었는데 api 요청 발생시 정상 처리를 하지 못하는 문제였습니다.
해당 메시지에 따르면 org.hibernate.PropertyAccessException
로 Sample
엔티티의 isDeleted
속성에 null
값이 할당되면서 발생한 에러입니다.
위의 두 번째 문제를 해결하는 과정에서 속성을 원시 타입으로 변경하였는데 원시 타입인 필드에 기존의 저장되어 있던 null
값이 할당되려고 하면서 발생한 문제였습니다.
따라서 위에서 해결책으로 커밋한 원시타입 변경을 다시 래퍼 클래스인 Boolean
으로 변경해주어 해결할 수 있었습니다.
결론
첫 번째 문제는 단순한 휴먼 에러로 쿼리를 작성하는 부분에서 실수한 문제였습니다.
두 번째 문제와 세 번째 문제는 결론적으로는 엔티티의 불리언 타입의 속성에 대해 @Column
의 columnDefinition
을 Boolean
으로 명시하는 데서 발생한 문제였습니다. 문제를 해결하면서 추론했던 부분 중 하나는 MySql에서 사용하는 boolean 속성으로 TINYINT(1)
이 아니라 bit
여야 한다는 부분이었는데(에러 메시지를 그대로 해석한 내용이면서 두 번째 해결 방식으로 처리한 부분), 좀 더 리서치를 해보니 bit
혹은 TINYINT(1)
이 문제가 아니라 Hibernate가 매핑하는 과정에서 columnDefinition
을 Boolean
으로 설정한 것이 문제였던 것입니다.
해당 부분이 문제가 되는 부분은 Hibernate
는 MySQL에서 boolean
타입을 TINYINT(1)
로 다루지만, columnDefinition
에서 "Boolean"으로 지정하면, Hibernate
가 이를 매핑하지 못하기 때문이었습니다.
이에 대해 Hibernate
에서는 별도의 Boolean Converters
를 사용하는 방식으로 해결하기도 합니다.
@Convert(converter = TrueFalseConverter.class)
private Boolean shouldBeAsked;
아무튼 불리언 타입의 속성을 설정할 때는 columnDefinition
을 아예 정의를 하지 않거나 정의를 할 것이라면 다음과 같이 TINYINT
혹은 bit
으로 생성된 칼럼 타입에 맞게 정의해주어야 합니다.
@Column(columnDefinition = "TINYINT(1) COMMENT '수정자 아이디'")
private Boolean isOk;
회고 및 제안
위와 같은 에러를 겪으며 느낀점을 다음과 같이 정리해볼 수 있었습니다.
1. 운영 배포시 쿼리 및 db 부분 체크에 보다 섬세함 & 꼼꼼함이 필요하겠다.
두 번째 문제는 차치하더라도 첫 번째 문제는 쿼리를 조금만 더 꼼꼼히 봤더라면 발생하지 않았을 문제이기 때문입니다.
2. 왜 단위 테스트에서 잡아내지 못했을까 ?
세 번째 문제는 두 번째 문제보다 당황스러웠는데, 급한 와중에 고쳤다 하더라도 테스트 코드가 돌 때 어째서 해당 문제를 잡아내지 못했느냐에 대한 의심과 이에 대한 좌절감을 느꼈습니다. 예를 들어 다음과 같은 컨셉의 영속 계층 IO 테스트가 있었다면 해당 문제를 컴파일 시점에 발견할 수 있었을 것입니다.
@Test
public void whenAssignNullToPrimitive_thenThrowException() {
// 추상화된 템플릿 객체 생성
GenericTemplate template = new GenericTemplate();
Exception exception = assertDoesNotThrows(GenericDataAccessException.class, () -> {
// 추상화된 객체의 필드를 의미 있는 값으로 설정
template.setSomeField(null);
genericRepository.save(template);
});
}
운영 상황에서의 여러가지 케이스를 고려한 보다 더 꼼꼼한 테스트 코드 작성이 필요하다는 생각을 했습니다.
3. Stage에서 자체 테스트를 해볼 때 실제 운영상황과 최대한 비슷한 환경을 만들고 진행할 필요가 있겠다.
예를 들어, hibernate.ddl-auto
를 Stage
에서는 update
로 사용하였는데 운영 환경에서와 같이 validate
로 했다면 배포 전에 문제를 확인할 수 있었을 것입니다.
데이터베이스에 영속화되어 있는 데이터들 그리고 테스트 해볼 때 사용하는 이름 등 (e.g. 일반 배달료: 배민 회원 - 3000.0원) 최대한 운영환경과 비슷한 환경을 조성하고 테스트 케이스를 검증해보는 것이 필요하겠다는 생각을 했습니다.
개발에서 문제라는 게 막상 부딪힐 때는 커보이는데 막상 지나고 나면 별 거 아닌 경우가 많은 것 같습니다. 개발 중 만나는 오류는 무심코 지나가며 그만인데 배포시엔 그게 아니다 보니 긴장되고 그러다보니 정말 별 것 아닌데도 되게 크게 다가왔던 기억이 있습니다. 물론 방어 장치들이 있지만 혹여라도 잘못되면 비즈니스 장애로 이어지고 그게 금전적인 손실로 나타날 수 있으니까요. 배포시에 겪은 문제들을 또 다시 겪지 않는 것이 중요하다고 생각해 기록해둔 내용을 공유해보았습니다.
reference
'이슈와해결' 카테고리의 다른 글
엔티티 필드 매핑 전략 탐구: 성능 테스터 Gatling을 이용한 Json 필드 매핑 사례 분석 (0) | 2023.12.16 |
---|---|
MSA 도메인 서비스에서 id 채번 방식 - 멱등성 보장하기 (1) | 2023.12.15 |
엔티티가 연장되는 속성을 가진 경우 효율적인 참조 관계 맺기 (0) | 2023.12.14 |
리팩토링 회고 - QueryDsl 검색 로직을 좀 더 클린하게 만들어보기 (0) | 2023.12.13 |
마이크로서비스 아키텍처에서 하나의 도메인 서비스에 다른 도메인이 필요하다면 ? (0) | 2023.12.13 |