구인사이트만들기: Kotlin과 JPA 호환성 문제 엔티티 매핑 오류 및 타입 처리 해결 이야기

궁금하면 500원·2024년 7월 15일

코틀린 기반 스프링 프로젝트를 만든 이유

사이드프로젝트를 개발을 하면서 JetBrains의 Kotlin을 사용하게 되었고,
Kotlin의 현대적인 기능과 생산성 향상에 대해 깊은 인상을 받았습니다.
Kotlin은 간결한 문법과 강력한 타입 시스템을 제공하며,
자바에 비해 코드의 가독성과 유지 보수성이 크게 개선되는 것을 경험하게 된다는
영상물을 보게 되었습니다.

Kotlin의 장점

  • 간결한 문법
    Kotlin은 자바보다 코드가 더 간결하고 읽기 쉬워, 개발 속도가 빨라지고
    오류 가능성이 줄어듭니다.

  • 안전한 null 처리
    Kotlin의 타입 시스템은 null 안전성을 보장하여 런타임 오류를 줄여줍니다.

  • 강력한 함수형 프로그래밍 지원
    Kotlin은 함수형 프로그래밍을 지원하여 코드의 표현력과 재사용성을 높입니다.

프로젝트 배경

Kotlin의 장점을 스프링 프레임워크에서도 활용할 수 있지 않을까 하는 생각이 들었습니다.

스프링 프레임워크는 자바 기반으로 개발되어 있지만, Kotlin의 현대적이고
효율적인 문법을 적용하면 스프링 애플리케이션의 품질과 생산성을
크게 향상시킬 수 있을 것이라 판단했습니다.

코틀린과 Spring Boot를 기반으로 한 웹 애플리케이션입니다.
애플리케이션은 회원 관리 및 권한 설정 기능을 포함하고 있으며,
JPA를 통해 데이터베이스와 상호작용합니다.

문제 설명

애플리케이션 컨텍스트를 초기화하는 과정에서 다음과 같은 오류가 발생했습니다

org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'entityManagerFactory': Could not determine recommended JdbcType for Java type 'com.krstudy.kapi.domain.member.entity.RoleStrategy'

이 오류는 RoleStrategy 타입의 필드를 JPA가 데이터베이스 컬럼으로 매핑할 수 없어서 발생했습니다. 또한, CustomUserDetailsService 클래스에서 타입 불일치 문제도 발견되었습니다.

문제 해결

1. @Transient 어노테이션 추가

Member 엔티티의 RoleStrategy 필드는 데이터베이스와 매핑되지 않아야 했습니다.
JPA가 이 필드를 데이터베이스에 매핑하려고 시도하면서 오류가 발생했습니다.
이를 해결하기 위해 RoleStrategy 필드에 @Transient 어노테이션을 추가하여
JPA가 이 필드를 무시하도록 했습니다.

@Entity
class Member(
    @Column(nullable = false, unique = true)
    var userid: String = "",

    @Column(nullable = false)
    var username: String? = null,

    @Column(nullable = false)
    var roleType: String? = null,

    @Column(nullable = false)
    var password: String = "",

    // RoleStrategy를 주입받음
    @Transient
    private val roleStrategy: RoleStrategy = DefaultRoleStrategy()
) : BaseEntity() {
    // ...
}

2.타입 불일치 수정

CustomUserDetailsService 클래스에서 SecurityUser 객체를 생성할 때,
username 필드가 String? 타입으로 정의되어 있었지만,
String 타입이 요구되었습니다.
이를 수정하여 username 필드가 String? 타입일 때도 처리할 수 있도록 변경했습니다.

@Service
@Transactional(readOnly = true)
class CustomUserDetailsService(
    private val memberRepository: MemberRepository
) : UserDetailsService {

    @Throws(CustomException::class)
    override fun loadUserByUsername(username: String): UserDetails {
        val member = memberRepository.findByUsername(username)
            ?: throw CustomException(ErrorCode.NOT_FOUND_USER)

        return SecurityUser(
            id = member.id!!,  // member.id가 null이 아닌 경우만 이 부분이 실행됨
            userid = member.userid,
            username = member.username ?: "",  // nullable 처리
            password = member.password,
            authorities = member.authorities
        )
    }
}

Member 엔티티 리팩토링

Member 엔티티를 리팩토링하여 데이터베이스 매핑에 적합한 타입과 어노테이션을 사용했습니다.
특히, @Column 어노테이션을 통해 필드의 제약 조건을 명확히 하고,
@Transient를 활용하여 JPA의 잘못된 매핑을 방지했습니다.

Kotlin 타입 시스템 및 JPA 호환성 보장

Kotlin의 타입 시스템과 JPA 요구 사항에 맞게 메서드와 필드를 업데이트했습니다.
이를 통해 애플리케이션의 타입 안정성과 데이터베이스 매핑의 정확성을 확보했습니다.

개선 사항

이 커밋을 통해 다음과 같은 개선이 이루어졌습니다

  • 엔티티 매핑의 정확성

    JPA가 올바르게 매핑할 수 있도록 엔티티의 필드와 어노테이션을 적절히 설정했습니다.

  • 타입 안정성

    Kotlin의 nullable 타입과 JPA의 요구 사항을 맞추어 코드의 안정성을 높였습니다.

  • 유지 보수성
    코드가 명확해지고 오류 발생 가능성이 줄어들어 유지 보수가 용이해졌습니다.

회원 권한 설정 및 저장 문제 해결

문제 정의

회원 가입 시 roleType 값이 올바르게 저장되지 않아 ROLE_MEMBER와 같은 권한이
제대로 적용되지 않는 문제가 발생했습니다.

또한, 특정 userid(예: system, admin)일 때 권한을 ROLE_ADMIN으로 설정하는
로직이 필요했습니다.

해결 과정

  1. MemberServicejoin 메서드 수정: roleType이 빈 문자열이거나
    null일 경우 기본값을 ROLE_MEMBER로 설정하고,
    useridsystem 또는 admin일 경우 ROLE_ADMIN으로 설정하도록 수정했습니다.

  2. DefaultRoleStrategy 클래스 수정: roleType에 따른 권한을 올바르게 설정하고, 특정 userid에 대한 권한 추가 로직을 검토하고 수정했습니다.

@Service
@Transactional(readOnly = true)
class MemberService(
    private val memberRepository: MemberRepository,
    private val passwordEncoder: PasswordEncoder
) {
    @Transactional
    suspend fun join(userid: String, username: String, password: String, role: String): RespData<Member> {
        val existingMember = findByUsername(userid)
        if (existingMember != null) {
            return RespData.fromErrorCode(ErrorCode.UNAUTHORIZED)
        }

        val roleType = M_Role.values().find { it.authority.equals(role, ignoreCase = true) }?.authority
            ?: if (userid.equals("system", ignoreCase = true) || userid.equals("admin", ignoreCase = true)) {
                M_Role.ADMIN.authority
            } else {
                M_Role.MEMBER.authority
            }

        val member = Member().apply {
            this.userid = userid
            this.username = username
            this.password = passwordEncoder.encode(password)
            this.roleType = roleType
        }

        withContext(Dispatchers.IO) {
            memberRepository.save(member)
        }

        return RespData.of(
            ErrorCode.SUCCESS.code,
            "${member.userid}님 환영합니다. 회원가입이 완료되었습니다. 로그인 후 이용해주세요.",
            member
        )
    }
}

적용된 코드

class DefaultRoleStrategy : RoleStrategy {
    override fun getAuthorities(roleType: String?, userid: String): Collection<GrantedAuthority> {
        val authorities = mutableListOf<GrantedAuthority>()

        val role: M_Role = M_Role.values().find { it.authority == roleType } ?: M_Role.MEMBER
        authorities.add(SimpleGrantedAuthority(role.authority))

        if (userid.equals("system", ignoreCase = true) || userid.equals("admin", ignoreCase = true)) {
            authorities.add(SimpleGrantedAuthority(M_Role.ADMIN.authority))
        }

        return authorities
    }
}

해결 내용

roleType 값을 ROLE_MEMBER 또는 ROLE_ADMIN으로 정확히 설정할 수 있도록 수정했습니다.
특정 userid에 대한 권한 추가 로직을 추가하여 시스템 관리자 및 admin 사용자에게
적절한 권한을 부여했습니다.

결론

이 포스팅에서는 JPA의 엔티티 매핑 오류를 해결하는 과정을 상세히 설명했습니다.
@Transient 어노테이션을 통해 데이터베이스와 매핑되지 않아야 할 필드를 무시하고,
타입 불일치를 수정하여 코드의 안정성과 유지 보수성을 높였습니다.

코틀린으로 개발하면서 디버깅 및 문제를 해결하면서,
코드 품질을 높이기 위한 중요한 경험을 했습니다.

profile
에러가 나도 괜찮아 — 그건 내가 배우고 있다는 증거야.

0개의 댓글