website: 멤버 API 버그 수정 — created_by 해시값·LocalDateTime ms 저장
멤버 API에서 `created_by`에 이상한 해시값이 저장되던 문제와 `LocalDateTime`이 epoch 밀리초로 저장되던 문제를 함께 고친 PR입니다.
요약
이 PR은 멤버 생성/수정 시 created_by, updated_by 컬럼에 관리자 이메일 대신 객체 해시값이 들어가던 버그와, LocalDateTime 필드가 사람이 읽을 수 없는 epoch ms 형태로 DB에 저장되던 버그를 수정합니다. 2026-07-11에 올라와서 같은 날 바로 dev 브랜치에 머지됐습니다. 커밋은 두 개뿐이지만, 첫 번째 fix 커밋이 기존 테스트를 깨뜨려서 두 번째 커밋에서 테스트를 손봐야 했던 흐름이 그대로 드러납니다.
배경 및 목적
멤버 API를 만들면서 Authentication에서 사용자 정보를 꺼내 created_by, updated_by에 기록하는 로직이 있었는데, 실제 DB를 까보니 이메일이 아니라 likelion.khu.website.admin.auth.AdminPrincipal@7f3f99a 같은 값이 저장되고 있었습니다.
원인은 authentication.getName()의 동작 방식에 있었습니다. 이 메서드는 principal이 UserDetails나 Principal 인터페이스를 구현한 경우에만 적절한 이름을 반환하고, 그렇지 않으면 그냥 toString()을 호출합니다. 프로젝트에서 쓰는 AdminPrincipal은 이 두 인터페이스를 구현하지 않았기 때문에 기본 Object.toString() 결과인 해시값이 그대로 저장된 것입니다.
동시에 또 다른 문제도 발견됐는데, Hibernate 6과 SQLite 조합에서 LocalDateTime 필드가 ISO 문자열이 아니라 epoch 밀리초 정수로 저장되고 있었습니다. @Column(columnDefinition = "TEXT")로 컬럼 타입을 지정해도 이건 DDL 스키마에만 영향을 줄 뿐, Hibernate가 값을 어떻게 직렬화하는지는 바꾸지 못한다는 걸 파악하고 별도 해결책이 필요했습니다.
구현 내용
AdminPrincipal 캐스팅으로 이메일 직접 추출
MemberController에서 authentication.getName() 대신 authentication.getPrincipal()을 AdminPrincipal로 캐스팅해서 getEmail()을 직접 호출하도록 바꿨습니다.
public ResponseEntity<MemberResponse> create(
@Valid @RequestBody MemberCreateRequest request,
Authentication authentication) {
AdminPrincipal admin = (AdminPrincipal) authentication.getPrincipal();
return ResponseEntity.status(HttpStatus.CREATED)
.body(memberService.create(request, admin.getEmail()));
}
update 메서드도 동일하게 수정했습니다.
LocalDateTimeConverter 도입
AttributeConverter<LocalDateTime, String>을 구현한 LocalDateTimeConverter를 새로 만들고 @Converter(autoApply = true)로 등록해서 프로젝트 내 모든 엔티티의 LocalDateTime 필드에 자동으로 적용되게 했습니다.
@Converter(autoApply = true)
public class LocalDateTimeConverter implements AttributeConverter<LocalDateTime, String> {
private static final DateTimeFormatter FORMATTER =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
@Override
public String convertToDatabaseColumn(LocalDateTime attribute) {
return attribute == null ? null : attribute.format(FORMATTER);
}
@Override
public LocalDateTime convertToEntityAttribute(String dbData) {
if (dbData == null) return null;
try {
return LocalDateTime.parse(dbData, FORMATTER);
} catch (Exception e) {
// 기존에 epoch ms로 저장된 값 읽기 호환
return LocalDateTime.ofEpochSecond(Long.parseLong(dbData) / 1000, 0,
java.time.ZoneOffset.UTC);
}
}
}
읽을 때 yyyy-MM-dd HH:mm:ss 포맷 파싱을 먼저 시도하고, 실패하면 catch 블록에서 기존에 저장된 epoch ms 값을 LocalDateTime으로 변환하도록 만들어서 마이그레이션 없이도 과거 데이터를 그대로 읽을 수 있게 처리했습니다.
테스트 수정: @WithMockUser → @WithMockAdminUser
첫 번째 fix 커밋을 올리고 나니 MemberControllerTest가 깨졌습니다. @WithMockUser는 Spring Security의 기본 User 타입으로 principal을 설정하는데, 컨트롤러에서 (AdminPrincipal) authentication.getPrincipal()로 캐스팅하는 코드가 추가됐으니 ClassCastException이 날 수밖에 없었습니다.
그래서 SUPER_ADMIN 권한이 필요한 테스트들(createMember_SuperAdmin_Returns201, updateMember_SuperAdmin_Returns200, updateMember_EmptyPhotoUrl_ClearsPhoto, updateMember_NonExistentId_Returns404)의 @WithMockUser(roles = "SUPER_ADMIN") 어노테이션을 전부 @WithMockAdminUser로 교체했습니다. 이 커스텀 어노테이션은 실제 JwtAuthenticationFilter가 주입하는 것과 동일한 AdminPrincipal 타입으로 SecurityContext를 구성해주기 때문에, 프로덕션 코드와 테스트 코드의 인증 방식이 어긋나지 않게 됐습니다.
배운 점 및 개선점
authentication.getName()이 principal 타입에 따라 동작이 완전히 달라진다는 걸 실제 버그로 겪고 나서야 확실히 체감했습니다. 커스텀 principal 클래스를 쓸 때는 Principal이나 UserDetails를 구현할지, 아니면 이번처럼 getPrincipal()을 직접 캐스팅해서 쓸지 팀 컨벤션으로 정해두는 게 나을 것 같습니다.
또 하나는 ORM이 특정 DB 드라이버 조합에서 예상과 다르게 동작할 수 있다는 점입니다. columnDefinition으로 DDL만 바꿔서는 직렬화 방식까지 바뀌지 않는다는 걸 이번에 명확히 확인했고, AttributeConverter로 직접 제어하는 게 더 안전하다는 걸 배웠습니다. 다만 지금 방식은 과거 epoch ms 데이터를 catch 블록에서 예외 기반으로 감지하는 임시방편에 가까워서, 이후에 데이터 마이그레이션을 한 번 돌려서 완전히 통일하는 게 더 깔끔할 것 같습니다.
마지막으로 프로덕션 코드에서 인증 방식을 바꾸면 테스트의 mock 인증도 반드시 함께 맞춰야 한다는 걸 다시 확인했습니다. @WithMockAdminUser 같은 커스텀 어노테이션을 처음부터 표준으로 썼다면 이번 캐스팅 예외 문제를 겪지 않았을 수도 있었겠다는 생각이 듭니다.