240619 JPA 심화 - 공부(1)

노재원·2024년 6월 19일
0

내일배움캠프

목록 보기
64/90
post-custom-banner

어느새 챕터로 치면 70%까지 왔다고 한다. 프로젝트 기간동안은 프로젝트에만 온 정신이 팔려서 생각을 정리할 시간도 없다보니 TIL이 좀 무성의했지만 다시 공부 챕터니 열심히 적어봐야겠다.

JPA 심화

오늘 재밌는 강의를 많이 들었다. 그런데 총 1-17까지 강의중 1-4까지밖에 못들었다. 여러 번 듣는 사람도 있고 나처럼 한번에 의문을 짚어가며 정리하는 사람도 있겠지만 이거 템포가 좀 걱정된다. 하루에 3~4강을 들으면 이 뒤로 4일내로 볼 수 있지 않을까 싶다.

JPA Persistence context

강의에선 JPA 심화라는 내용으로 JPA를 다시 다루게 되었고 영속성 컨텍스트에 대해서도 다시 보게 되었다. 1차 캐시, 동일성, 더티 체킹, 쓰기 지연등 다양한 기능을 이미 이전에 봤었지만 다시 보게 됐다.

여태 써오면서 느낀 것처럼 JPA 내부 동작방식의 핵심 구성요소고 JPA가 내부적으로 Entity를 관리하는지 알아야만 트러블 슈팅이 되는 경우가 있었다.

Entity manager는 기본적으로 @Transactional 어노테이션과 라이프사이클을 공유한다고 생각하면 되고 한 번 짚어봤던 내용이지만 이전 강의와 다른 튜터님의 강의라서 다시 한번 적어보며 기억 연장하게 정리하고 넘어가기로 했다.

우리가 프로젝트에서 사용중이던 Spring data JPA는 아래 JPA 인터페이스를 한 번 더 추상화한 것이다. 예를 들면 persist, merge를 합친 .save가 있다.

  • find(Entity, PK)
    조회한 PK가 영속성 컨텍스트에 있다면 Query를 발생시키지 않고 1차 캐시의 Entity를 반환한다. 즉 1차 캐시로 성능 향상과 동일성 보장을 제공한다.
  • createQuery(JPQL)
    JPQL을 사용한 Query는 1차 캐시를 아예 확인하지 않기 때문에 무조건 쿼리가 발생한다.
  • persist(Entity)
    비영속 상태인 Entity를 영속으로 만든다. 쓰기 지연 SQL 저장소에 INSERT 쿼리를 생성한다.
  • merge(Entity)
    준영속 상태인 Entity를 영속으로 만든다. 1차 캐시에 Entity 정보가 없으면 SELECT 쿼리를 날려 1차 캐시에 데이터를 생성하고 이후 1차 캐시에 있는 Entity와 merge로 전달된 Entity를 합쳐 새로운 객체를 1차 캐시에 저장해 반환한다.
  • remove(Entity)
    영속 상태인 Entity를 영속성 컨텍스트에서 제거하면서 쓰기지연 SQL 저장소에 DELETE 쿼리를 생성한다.
  • flush
    여태까지 쓰기 지연 SQL 저장소에 있떤 쿼리를 실제 DB에 날려 실행한다.
  • clear
    영속성 컨텍스트를 비우고 내부에 있던 Entity는 준영속 상태가 된다.
    사용할 일이 없어보이지만 JPQL로 1차 캐시를 거치지 않은 요청을 보내면 1차 캐시 <> DB의 정보 불일치가 발생할 수 있으므로 이럴 때에 1차 캐시를 비워주기 위해 사용한다고 볼 수 있다.

영속성 컨텍스트가 지원하는 기능

위에서 다룬 명령어가 아닌 기능 단위로 내용을 정리한다.

1차 캐시

  • 영속성 컨텍스트 내부에 존재하는 캐시로 영속 상태 Entity를 저장해두는 공간이다.
  • Key(@Id) : Entity : Entity Snapshot 구조로 저장된다.
  • 1차 캐시의 Entity를 활용한 기능
    1. 불필요한 조회 쿼리 방지
    2. Entity 동일성 보장
    3. Dirty checking

Entity 동일성 보장

  • 위에서 다룬 1차 캐시로 동일한 @Id를 가지는 Entity에 대한 동일성 보장을 해준다.
  • Entity 조회 순서
    1. 1차 캐시에 @Id에 해당하는 Entity 존재하는지 확인 (단, @Id를 이용한 조회만 해당)
      존재하면 즉시 반환한다.
    2. DB에 SELECT 쿼리를 실행해 Entity 정보를 조회
    3. 조회한 Entity를 영속 상태로 만들면서 1차 캐시에 저장
    4. 1차 캐시에 저장된 Entity를 반환
  • 즉 조회 결과 Entity는 항상 영속 상태고 1차 캐시에 저장되어 관리된다.
  • 1차 캐시에는 @Id당 1개의 Entity를 저장해두기 때문에 여러번 조회해도 모두 동일한 주소를 가진 인스턴스다. (단, 모든 Entity가 영속 상태일 경우)

쓰기지연

  • 영속성 컨텍스트 내부에 존재하는 SQL 저장소로 쿼리를 모아뒀다가 flush()가 호출되는 시점에 한번에 실행한다.
  • 이를 통해 최적화된 SQL 쿼리 실행 가능

변경 감지 (Dirty checking)

  • 트랜잭션이 종료될 때 1차 캐시에 저장된 Entity와 1차 캐시에 저장된 Entity Snapshot 객체를 비교해 달라진 점이 있으면 SQL 저장소에 UPDATE 쿼리를 자동으로 생성한다.
  • 별도의 .save() 호출이 필요없다.

Flush

  • Flush는 3가지 방법으로 호출된다.
    • EntityManager를 이용해서 직접 flush() 호출 또는 Spring data repoisotry의 saveAndFlush() -> 직접 호출
    • Transaction 이 commit 될 때 -> 자동 호출
    • JPQL 쿼리를 이용해 요청시 -> 자동 호출

이 중에서 JPQL 쿼리를 이용한 요청에서 내부적으로 어떻게 작동해야하는지 반드시 알아야하는 주의할 점이 있다.

JPQL 쿼리를 이용한 요청이 어떻게 발생하는가?

interface MemberRepository : JpaRepository<Member, Long> {
	
	@Query("SELECT m FROM Member m WHERE m.email = :email")
	fun findByEmail(@Param("email") email: String): Member?
}

JPQL을 사용할 때 우리는 EntityManager에 .createQuery를 직접 사용하진 않고 @Query 어노테이션을 통한 Spring data repository 에서 사용한다. 이건 명확하게 JPQL을 사용하니 쿼리가 발생하는 걸 알 수 있다.

그러면 메소드 호출로 사용하는 부분으로 비교해보자.

memberRepository.findByIdOrNull(1L)    // 1. PK 를 이용한 단건조회
memberRepository.findAll()             // 2. 전체조회
memberRepository.findByEmail(email)    // 3. JPA 추상메소드를 통한 조회
memberRepository.findAllEnabled()      // 4. @Query 를 이용한 JPQL 조회
memberRepository.findAllByPaging(page) // 5. QueryDSL 을 이용한 조회

---
// 4번 예제
@Query("SELECT m FROM Member m WHERE m.enabled = true")
fun findAllEnabled(): List<Member>
---
// 5번 예제
fun findAllByPaging(page: PageRequest) {
	// QueryDSL 을 이용한 구현
}

여기서 1번을 제외한 2, 3, 4, 5번 모두 JPQL Query를 통한 요청이다. 자주 써오던 findAll 또한 JPQL Query를 쓰는 건 몰랐다.

어쨌든 JPQL Query의 발생 시점을 알았으니 위 메소드를 사용한다면 flush() 또한 자동 호출 될 거라는 점을 알 수 있다. 그렇다면 flush()는 왜 JPQL 쿼리를 날리기 전에 자동 호출 되는 것일까?

이미 위에서 다룬 내용중에 JPQL 쿼리는 1차 캐시를 거치지 않는 다는 사실을 다뤘었다. 만약 JPQL 쿼리를 요청 보내는 시점에 1차 캐시에 있는 Entity를 Dirty checking 으로 감지해 변경사항을 쓰기지연 SQL 저장소에 넣어놨다면 1차 캐시 <> DB의 정보가 달라질 수 있다.

즉 데이터 정합성이 깨질 수 있는 것이고 JPQL 쿼리를 날리기 전에는 항상 쓰기지연 SQL 저장소의 쿼리문을 실행시키는 flush() 처리를 하는 것이다.

영속성 컨텍스트를 비우는 clear()와 다르다는 점은 명심하자. flush의 목적은 그냥 DB 상태를 영속성 컨텍스트와 맞출 뿐이다.

Merge와 준영속 상태의 내부 동작방식

준영속 상태는 detach(), clear(), close()를 통해 만들어진다.

  • detach
    영속 상태인 Entity를 준영속 상태로 만든다.
  • clear
    모든 영속성 컨텍스트 내의 영속 Entity를 준영속 상태로 만든다.
  • close
    영속성 컨텍스트를 닫고 모든 영속성 컨텍스트 내의 영속 Entity를 준영속 상태로 만든다.

준영속 상태의 Entity는 영속성 컨텍스트의 관리를 더 이상 받지 않게 되고 Lazy Loading같은 작업을 하면 LazyInitializedException 예외가 발생한다. 즉, 준영속 상태의 Entity는 영속성 컨텍스트의 기능/작업을 쓸 수 없다.

준영속을 다시 영속 상태로 만드는 merge(Entity)는 전달된 Entity와 Merge의 결과로 1차 캐시에 영속 상태가 될 Entity는 다른 객체임을 명심해야 한다. 즉, 내부적으로 준영속 상태가 되었던 Entity가 다시 영속 상태가 되는 것은 아니다.

Spring data repository의 내부를 보면 .save() 함수에서 isNew로 확인해 새로운 Entity면 persist를 진행하고 그 entity를 그대로 반환해서 동일한 객체임이 분명하지만 merge는 다르다.

@Transactional
fun main() {
    val member = MemberEntity(name = "ch4njun")
    entityManager.persist(member)
    
    val savedMember = entityManager.find(Member::class.java, 1L)
    // member 와 savedMember 는 동일한 인스턴스다! 즉, 객체의 주소가 동일하다.
    
    entityManager.detach(member)
    // member Entity 객체를 준영속 상태로 만든다.
    
    val mergedMember = entityManager.merge(member)
    // member 와 mergedMember 는 서로다른 인스턴스다! 즉, 객체의 주소가 다르다.
    // 정리하면 인스턴스 주소 기준으로 "member == savedMember != mergedMember" 라고 할 수 있다.
}

Merge의 동작 방식
1. 매개변수로 전달된 Entity 객체의 @Id 가 1차캐시에 존재하는 Key 인지 확인한다.
2. 1차캐시에 존재하지 않으면 데이터베이스에 SELECT 쿼리를 날려 1차캐시에 추가한다.
(이미 1차캐시에 존재한다면 별도의 SELECT 쿼리를 날리지 않는다)
3. 1차캐시에 저장된 Entity 객체와 파라미터로 전달된 Entity 객체를 합친다. -> Merge
4. 합친 결과로 새로운 Entity 객체를 생성하고 1차캐시에 저장한 후 반환한다.
결국 merge(Entity) 에서 반환된 Entity 객체는 파라미터로 전달한 Entity 객체와 항상 다른 객체다

위 코드를 보면 다른 객체인지 동일 객체인지 구분할 수 있다.

.save 내부의 isNew는 return getId() == null 의 형태로 Id가 null인지 체크해서 새 Entity라고 인식한다. 새로운 Entity를 체크할 때 검증을 바꾸고 싶다면 Entity가 Persistable<T>를 구현해서 바꿀 수 있다.

@Transactional
fun main() {
	val member = Member(name = "ch4njun")
	member.id = 1L  
	
	// member Entity 는 id 가 세팅됨으로써 isNew() 의 결과가 False 가 되고 merge(Entity) 가 호출된다.
	val savedMember = memberRepository.save(member)
	// 즉, member 와 savedMember 의 주소는 서로 다르다!!
	
	member.name = "박찬준"
	// 여기서 member 에 대한 변경감지(Dirty Checking) 이 될거라 기대했지만,
	// member 는 준영속(Detached) 상태기 때문에 변경감지가 되지 않게된다.
}

JPA를 쓰면서 .save()를 쓸 때 내부적으로 persist일지 merge일지 별로 의식하면서 개발한 적은 없지만 내부적으로 실행된 .merge()가 나도 모르게 이뤄지면서 버그가 발생할 수도 있다.

그러니 확실히 영속된 .save 에서 반환된 Entity 객체를 쓰는게 안전한 습관이다.

JPA 연관관계 매핑

JPA의 사용 이유인 DB-코드 매핑 작업에 대해 자세하게 알아봤다. RDB는 외래키를 토대로 두 테이블의 JOIN이 가능할 뿐이라서 방향이 존재하지 않지만 객체지향에선 서로 객체 참조로 이뤄지고 그렇기에 방향이 존재한다.

단방향 연관관계

RDB는 외래키만 있으면 A->B, B->A 모두 가능하지만 객체지향 프로그래밍에선 명확한 방향이 있어서 한쪽이 알면 다른 한쪽은 알 수가 없다.

이렇듯 객체지향적으로 관계를 지어 방향성을 만들 때 하나의 방향성만 가지게 만드는 것이 단방향 연관관계이다. (구체적으로 RDB - 객체지향의 관점이 다르기 때문에 방향성이라는 키워드는 ORM에서 쓰인다고 보면 될 것 같다.)

이제 각 관계를 쓸 때 생각해봐야 할 점을 짚어주셨는데 @ManyToOne은 가장 일반적이고 직관적인 방향으로 구성된 거라 강의에서는 추가로 설명하지 않았다.

@OneToOne

1:1 맵핑이라서 정말 테이블을 분리해야 하는지 고민해봐야 한다. 나같은 경우 이번 프로젝트에서 Refresh token을 관리하는 테이블을 따로 뒀는데 강의에서는 이걸 생명주기에 초점을 뒀다고 말했고 데이터가 사용되는 단위에 초점을 둬서 OneToOne이 될 때도 있다고 한다.

추가로 유저의 정보와 프로필 정보를 분리하는 것도 얘기하셨는데 민감정보 측면과 프로필은 수정이 자주 일어날 수 있으니 분리하는 측면도 생각해볼 수 있다고 하셨다.

@OneToMany

1:N은 지양하자는 얘기를 많이 들었었는데 RDB의 외래키 위치와 객체 참조의 위치가 달라져서 헷갈릴 수도 있고, INSERT 쿼리를 할 때 먼저 INSERT하고 빈 외래키를 UPDATE하는 불편함도 있다.

다만 무조건적으로 명확한 단점으로만 여길 필요는 없고 도메인을 정의할 때 흐름을 떠올리고 비즈니스 로직을 개발하는 과정에서 자연스러운 코드를 만들 수 있다면 사용하는게 좋다고 하셨다.

@ManyToMany

이건 사용해보지 않았는데 중간에 조인 테이블(나는 맵핑 테이블이라 불렀었다.)을 사용하던 그 관계인데 @ManyToMany을 사용하면 실제 테이블은 만들지만 객체는 생성하지 않아도 되는 장점이 있다.

그런데 객체가 없기 때문에 JPA상에서 해당 조인 테이블을 사용하는 건 불가능하고 객체는 없지만 쿼리로는 Join한다고 쿼리가 발생하기 때문에 개발자가 예측 못하는 쿼리가 나갈 수도 있는 단점이 있다고 한다.

그래서 이건 잘 안쓰고 그냥 @OneToMany, @ManyToOne 으로 중간 통로를 연결하는게 일반적이라고 한다.

양방향 연관관계

객체가 서로 의존하고 있는 관계로 객체지향에서 양방향 관계라는 개념은 없고 그냥 단방향이 서로 연결된 관계라고 볼 수 있다. 여태 강의에서 다뤄본 걸 보면 개발 편의성은 정말 좋긴 하나 단점은 역시나 존재한다.

우선 연관 관계의 주인을 mappedBy로 결정해줘야 하는데 주인이 아닌 쪽은 사실 조회만 가능하고 실제 데이터를 변경하지는 못하기 때문에 직관적이지 않다.

그리고 양방향 연관관계에서는 데이터의 추가/삭제/변경이 일어날 때 연관관계 주인에 대한 변경도 반드시 이뤄져야 한다.

// Team <-> Member 양방향 연관관계
// Member
@ManyToOne
@JoinColumn(name = "team_id")
val team: Team? = null,

// Team
@OneToMany(mappedBy = "team")
val members: MutableList<Member>

@Transactional
fun main() {
    val team = Team(name = "Sparta")
    val member = Member(username = "박찬준")
    team.members.add(member)
    teamRepository.save(team)
    memberRepository.save(member)
}

해당 코드는 Member의 team_id가 null로 설정된다. 관계 주인인 Member의 team은 초기화 된 적이 없어서 그냥 null로 세팅이 되어있고 JPA도 null로 봤으니 team_id는 자연스레 null이 된다.

다음 코드를 수정하면 다음과 같아진다.

val team = Team(name = "Sparta")
val member = Member(username = "박찬준", team = team)
// 연관관계 주인이 아닌쪽에는 세팅해주지 않아도 데이터베이스에 정상적으로 값이 저장된다.
// team.members.add(member) 
teamRepository.save(team)
memberRepository.save(member)

하지만 이 코드는 트랜잭션이 끝나기 전에 Team의 member 목록 Log를 찍거나 조회하려고 시도하면 DB에 반영이 안된 상태라 여전히 없다고 나올 것이다. 양방향 관계를 사용할 때는 명확하게 양쪽 객체 모두 참조 객체를 초기화해줘야 이런 문제를 예방할 수 있다.

member의 team을 nullable하게 만들었기 때문이고 val team: Team 으로 만들어줬다면 객체를 생성할 때 의무적으로 초기화해줘야 하니 위 코드의 문제를 방지할 수 있다.

하지만 변경/삭제는 여전히 문제가 생길 수 있다.

fun main() {
    val team = teamRepository.findByIdOrNull(1)!!
    team.members.clear()  // 이걸 지워도 데이터베이스에선 안지워진다!!
    teamRepository.save(team)
}

객체만 보면 이 코드를 이해하겠지만 Team은 연관관계의 주인이 아니기 때문에 DB상 변경, 추가, 삭제의 의미가 없고 주인쪽의 관계도 끊어줘야 의도한 대로 동작한다. 물론 이러면 고아객체가 될 수 있으므로 orphanRemoval 을 설정해주면 더 좋다.

team.members.forEach { it.team = null }

그리고 양방향은 순환 참조 문제를 발생시킬 가능성이 존재하기 때문에 주의가 더 필요하다.

이런 다양한 이슈들로 양방향 연관관계는 꼭 필요한 상황이 아니면 지양하자는 얘기가 이번 강의에서도 이야기가 노았다. 다만 지양일 뿐 편의를 위해서 추가되면 좋은 부분은 필요에 의해 추가하면 좋다.

강의에선 양방향 관계가 도움이 되는 경우도 소개해주셨다.

// Team -> Member 를 향하는 @OneToMany N:1 단방향 연관관계 상황
@Transactional
fun main() {
    val member = Member(username = "박찬준")
    val team = Team(name = "Sparta", members = listOf(member))
    teamRepository.save(team)
}

// INSERT INTO team(team_id, name) VALUES (default, ?)
// INSERT INTO member(member_id, username) VALUES (default, ?)
// UPDATE member SET team_id = ? WHERE member_id = ?  // .....!? 문제의 쿼리!

바로 @OneToMany를 사용할 때인데 차라리 이럴 때 양방향을 쓰면 불필요한 UPDATE 쿼리를 없앨 수 있다.

예상하기 머리가 아프면 updatable=false 를 설정해주는 것도 해결 방법이 될 수 있다. Member의 team_id를 업데이트 하는 쿼리 자체를 발생시키지 않게 설정한다.

단순 ID 참조 매핑

우리는 JPA의 연관관계를 위해 계속 @JoinColumn을 통해 객체를 매핑했는데 실무에서는 그냥 Id만 참조하는 방식을 쓰는 케이스도 많다고 한다.

@Entity
class Member(
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "member_id")
    var id: Long? = null,
    
    val teamId: Long,
    val username: String
)

teamId를 그냥 단순히 매핑해버렸다. 객체 참조라는 키워드는 더 이상 쓸 수 없는 코드고 사실상 JPA의 장점을 버리는 측면이 있다. 코드의 연관관계가 없으니 JPA가 지원해주는 기능을 전혀 쓰지 못하기 때문이다.

이거는 내가 TODO 과제를 할 때도 Aggregate의 경계 구분과 도메인간의 불필요한 참조 방지를 위해 JPA 연관 관계를 끊어버리는 행동을 했었는데 실무에서도 비슷한 이유로 이렇게 짜는 경우가 있다고 한다.

  • 관련 없는 Entity가 변경에 휘말리고 한 번에 변경될 수 있다.
  • DDD에서 정의하는 Aggregate 단위로 Domain을 묶었을 때 @JoinColumn으로 연관 관계를 맺어놓으면 경계를 명확히 정의하기 힘들어진다.
    • Aggregate 내부만 연관을 맺고 외부는 맺지 않으면 외부에서 변경을 할 수 없어 응집도가 높아진다.
  • 연관 관계가 타고타고 넘어가 길어질 수록 N+1 쿼리 문제를 제어하기 어렵다.
  • MSA를 구성할 때 시스템 확장에 있어 유연하지 못한 구조가 된다.

뭔가 지난 포스팅에서 정리한 내용들이 어느정도 강의에서 나오니 꽤 신기했다. 그 때 한 고민들이 맞아 떨어지는 측면이 있는 것 같다.

부록: FK를 사용하지 않기?

튜터님의 회사 정책은 FK를 사용하지 않는게 기본적인 전사 정책이라고 하셨다. 내가 생각하기엔 이정도까지 해야하나 싶은 생각이 바로 들었다. RDBMS의 장점은 FK에서 많이 발생한다고 생각하기 때문이다. (튜터님도 데이터 정합성, 무결성 보장을 위해 실제로 RDB는 FK를 사용하는게 맞다고 하셨다.)

당연히 안쓰는 이유를 말씀해주실 줄 알았는데 모두를 설득할 답을 찾지 못하셨다고 하고 깨달은 점을 공유해주셨다.

  • FK는 귀찮다
    • 외래키를 설정하면 개발하면서 신경을 더 써줘야 한다.
    • 개발 후 테스트에서 데이터를 생성할 때 연관을 순서대로 다 넣어줘야 한다.
    • 삭제할 때도 연관을 순서대로 다 삭제해야 한다.
    • DB 확장할 때 외래키가 걸려있으면 고민을 더 많이 해야한다.

개발 편의성, 확장성의 측면에서는 FK 설정을 안하는게 말이 될 수도 있다.
튜터님도 이성적으로는 FK 설정해야하지 않나 생각하셨지만 이 부분에서 Trade-off할만한 포인트라 생각해 부록으로 공유해주셨는데 재밌는 이야기인 것 같다.

이전 프로젝트에서도 개발 테스트용으로 팀과 공유하며 작게 쓰던 Supabase DB의 Column을 바꾸려고 하다가 기존 데이터가 걸릴 때도 있고 Swagger 테스트를 할 때도 순서대로 이어줘야 하는 귀찮음이 있긴 했었기 때문이다.

하지만 이성적으로는 성능과 정합성, 무결성때문에 FK가 걸려있어야 오히려 마음이 편한게 아직은 내 마음같다.

JPA의 유용한 기능들 (+QueryDSL)

@Where -> @SQLRestriction

특정 Entity를 조회하는 모든 쿼리에 Where 조건을 추가해주는 어노테이션이다.

강의에서 Soft delete를 소개해주면서 @Where(clause = “is_deleted = false”) 처럼 사용하셨는데 내가 썼던 @SqlRestriction 도 같은 기능아닌가 싶어 뭐지 싶었다. 알아보니 그냥 Hibernate 6.3부터 @Where는 Deprecated 됐고 @SQLRestriction로 대체됐다고 밑에 추가 설명이 있었다.

차이점은 다음과 같다.

  • @Where: Entity의 조회에 Where을 적용함
  • @SQLRestriction: 필드에 조금 더 구체적인 조건을 적용할 수 있게함

내가 엉뚱한 거 쓰는 줄 알고 잠깐 식겁했다.

JPQL, QueryDSL 모두 조건에 영향 받는건 똑같고 조건을 무시하고 싶으면 JPQL을 작성할 때 nativeQuery 옵션을 켜줘야만 가능한 것도 똑같다.

단, nativeQuery는 JPQL의 지원도 받지 못한다고 한다. 예외 상황 자체가 발생하지 않는게 좋다고 한다.

@DynamicInsert, @DynamicUpdate

평범하게 @Transactional 내부에서 is_deleted 필드를 TRUE로 바꾸는 간단한 Soft delete 로직의 쿼리를 보면 다음과 같다.

// 내가 예상한 쿼리!
UPDATE member SET is_deleted = true WHERE member_id = 1;

// 실제 발생한 쿼리!
UPDATE member SET email='slolee@naver.com',is_deleted=true,nickname='박찬준',password='1234' WHERE member_id=1;

JPA는 쿼리 재사용 관점에서 이득을 보기 위해 어플리케이션 실행 시점에 SQL 쿼리를 만들어놓고 재사용한다. 그래서 처음 만들어진 모든 Column에 대한 UPDATE 쿼리를 기본으로 사용하게 된다.

이게 싫으면 @DynamicUpdate를 쓸 수 있다. 다만 이걸 쓴 쿼리문이 자주 발생하면 오히려 성능 저하가 발생할 수도 있다고 한다.

@DynamicUpdate 어노테이션은 Entity class 레벨에 붙이면 된다. 그러면 더티체킹한 내용만큼만 쿼리를 발생시킨다.

@DynamicInsert 의 경우도 기본값이 없는 필드에 대해 null을 넣어 모든 Column에 대한 INSERT 쿼리를 사용하는 동일한 문제가 있지만 Kotlin에선 컴파일 할 때부터 Null이 쓰고싶으면 Nullable 타입을 사용하거나 기본 값을 지정해놓기 때문에 잘 사용할 일이 없다.

Spring Data JPA Auditing

Audit의 의미는 감사하다 (감시하는 의미의 감사), 심사하다고 어디서 이미 들어봤었는데 알고보니 이번 프로젝트에서 BaseTimeEntity 를 만들 때 썼었다. 기본적인 CreatedAt, updatedAt 필드를 공통으로 설정할 수 있게 만들었다.

테이블의 Row를 추적할 수 있는 옵션이 제공되고 Entity에 쓴다면 생성, 변경 시점을 감지해주는 역할이 가능하다. Spring Data JPA는 다음 기능을 제공한다.

  1. Entity 객체 생성 시점 (@CreatedDate)
  2. Entity 객체 생성자 (@CreatedBy)
  3. Entity 객체 수정 시점 (@LastModifiedDate)
  4. Entity 객체 수정자 (@LastModifiedBy)

JPA 자체적으로도 @PrePersist, @PostPersist, @PreUpdate, @PostUpdate 를 통해 Auditing 구현을 할 수 있지만 Spring Data JPA가 제공해주는 위 기능들이 꽤 강력하다고 하셨다.

우선 Application이나 @Configuration에 @EnabledJpaAuditing 설정을 하고 다음과 같은 BaseEntity를 생성하면 된다.

@EntityListeners(AuditingEntityListener::class)
@MappedSuperclass
class BaseEntity(

    @CreatedDate
    @Column(updatable = false)
    var createdAt: LocalDateTime = LocalDateTime.now(),

    @CreatedBy
    @Column(updatable = false)
    var createdBy: String = "system",

    @LastModifiedDate
    var updatedAt: LocalDateTime = LocalDateTime.now(),

    @LastModifiedBy
    var updatedBy: String = "system"
)

createdAt, updatedAt에는 써봤지만 createdBy, updatedBy는 처음 보는건데 누가 처리하는지 확인하려면 AccessToken 기준으로 인증시 Id 값을 활용해볼 수 있다고 한다.

@Component
class CustomAuditorAware : AuditorAware<Long> {
    override fun getCurrentAuditor(): Optional<Long> {
        return Optional.ofNullable(SecurityContextHolder.getContext())
            .map { it.authentication }
            .map { it.principal as MemberPrincipal }
            .map { it.memberId }
    }
}

AuditorAware<T> 인터페이스를 구현해서 JPA에 알려줄 수 있다고 한다. principal 쓰는 방식은 이번 프로젝트에서 겪어본 내용과 비슷하다.

이렇게 JPA가 사용자 정보를 알았으니 이제 memberId 를 CreatedBy, UpdatedBy 에 기록한다.

@CreationTimestamp 와 @CreatedDate의 차이


우리 프로젝트는 @CreationTimestamp을 써서 두 개의 차이를 조사해봤다.
막상 조사해보니 큰 차이는 아닌 걸로 보인다. 하지만 JPA를 쓴다면 @CreatedDate가 더 자연스러울 것 같다.


@CreationTimestamp
Hibernate 에서 지원함
엔티티가 처음 저장될 때 자동으로 타임스탬프를 설정함
지원 필드 유형: java.util.Date, java.util.Calendar, java.sql.Timestamp


@CreatedDate
Spring Data JPA 에서 지원함
엔티티가 처음 저장될 때 자동으로 타임스탬프를 설정함
지원 필드 유형: java.util.Date, java.util.Calendar, java.time.LocalDateTime, java.time.Instant


코드카타 - 프로그래머스 신고 결과 받기

신입사원 무지는 게시판 불량 이용자를 신고하고 처리 결과를 메일로 발송하는 시스템을 개발하려 합니다. 무지가 개발하려는 시스템은 다음과 같습니다.

  • 각 유저는 한 번에 한 명의 유저를 신고할 수 있습니다.
    • 신고 횟수에 제한은 없습니다. 서로 다른 유저를 계속해서 신고할 수 있습니다.
    • 한 유저를 여러 번 신고할 수도 있지만, 동일한 유저에 대한 신고 횟수는 1회로 처리됩니다.
  • k번 이상 신고된 유저는 게시판 이용이 정지되며, 해당 유저를 신고한 모든 유저에게 정지 사실을 메일로 발송합니다.
    • 유저가 신고한 모든 내용을 취합하여 마지막에 한꺼번에 게시판 이용 정지를 시키면서 정지 메일을 발송합니다.

다음은 전체 유저 목록이 ["muzi", "frodo", "apeach", "neo"]이고, k = 2(즉, 2번 이상 신고당하면 이용 정지)인 경우의 예시입니다.

유저 ID 유저가 신고한 ID 설명
"muzi" "frodo" "muzi"가 "frodo"를 신고했습니다.
"apeach" "frodo" "apeach"가 "frodo"를 신고했습니다.
"frodo" "neo" "frodo"가 "neo"를 신고했습니다.
"muzi" "neo" "muzi"가 "neo"를 신고했습니다.
"apeach" "muzi" "apeach"가 "muzi"를 신고했습니다.

각 유저별로 신고당한 횟수는 다음과 같습니다.

유저 ID 신고당한 횟수
"muzi" 1
"frodo" 2
"apeach" 0
"neo" 2

위 예시에서는 2번 이상 신고당한 "frodo"와 "neo"의 게시판 이용이 정지됩니다. 이때, 각 유저별로 신고한 아이디와 정지된 아이디를 정리하면 다음과 같습니다.

유저 ID 유저가 신고한 ID 정지된 ID
"muzi" ["frodo", "neo"] ["frodo", "neo"]
"frodo" ["neo"] ["neo"]
"apeach" ["muzi", "frodo"] ["frodo"]
"neo" 없음 없음

따라서 "muzi"는 처리 결과 메일을 2회, "frodo"와 "apeach"는 각각 처리 결과 메일을 1회 받게 됩니다.

이용자의 ID가 담긴 문자열 배열 id_list, 각 이용자가 신고한 이용자의 ID 정보가 담긴 문자열 배열 report, 정지 기준이 되는 신고 횟수 k가 매개변수로 주어질 때, 각 유저별로 처리 결과 메일을 받은 횟수를 배열에 담아 return 하도록 solution 함수를 완성해주세요.

문제 링크

fun solution(idList: Array<String>, report: Array<String>, k: Int): IntArray {
    val reportedCountMapByReporter = mutableMapOf<String, MutableSet<String>>()
    
    report.forEach {
        val splitedReport = it.split(" ")
        val reporter = splitedReport[0]
        val reported = splitedReport[1]
        if (reportedCountMapByReporter.containsKey(reported)) {
            reportedCountMapByReporter[reported]!!.add(reporter)
        } else {
            reportedCountMapByReporter[reported] = mutableSetOf(reporter)
        }
    }
    
    val answer = idList.associateWith { 0 }.toMutableMap()
    
    reportedCountMapByReporter.values.forEach { reporters ->
        if (reporters.size >= k) {
            reporters.forEach {
                answer[it] = answer[it]!! + 1
            }
        }
    }
    
    return answer.values.toIntArray()
}

간만에 풀려니 너무 뇌가 굳었다. 굳었다기 보다는 문제 풀이에 적합한 아이디어부터 잘 안떠오르는 것 같다. 프로젝트에서 내 관심사는 효율적인 비즈니스 로직과 쿼리보다는 코드의 중복이나 인증 인가의 책임같은 쪽으로 쏠려있었어서 이런 로직은 점점 더 골치 아프게 다가오는 것 같다.

의도는 신고된 사람 : 신고자 로 이루어진 콜렉션을 가지고 신고자를 중복없이 처리하면 횟수도 명확하기 때문에 신고 처리 메일을 받을 사람도 명확해져서 쉽게 풀 수 있을 거라는 생각이었다.

의도가 반영된 코드긴 한데 가끔 시간이 0.2ms까지 튀는걸 보면 마냥 만족스럽다고는 못할 것 같다. 문제 제한 시간은 10초긴 했다.

그리고 코드 가독성도 좋다고는 하기 힘들다. 변수가 너무 이거저거 많이 쓰인 것 같다.

다른 답안에서 가장 알고리즘을 해석 잘 한 코드도 가져와봤다.

fun solution2(id_list: Array<String>, report: Array<String>, k: Int): IntArray =
    report.map { it.split(" ") }
        .groupBy { it[1] }
        .asSequence()
        .map { it.value.distinct() }
        .filter { it.size >= k }
        .flatten()
        .map { it[0] }
        .groupingBy { it }
        .eachCount()
        .run { id_list.map { getOrDefault(it, 0) }.toIntArray() }

신고를 split으로 나누고
신고된 사람을 기준으로 묶은 다음 Map<String, List<List<String>>>
Map으로 중복을 제거한 신고한 사람 목록으로 바꾸고 Sequence<List<List<String>>>
차단될 사람만 filter후
flatten으로 평탄화한 후 Sequence<List<String>>
신고한 사용자 값으로 바꾸고 Sequence<String>
groupingBy로 묶고 Grouping<String, String>
각 사용자의 신고 횟수를 세고 Map<String, Int>
신고 횟수를 id_list 값과 순서로 조회해 변환후 IntArray로 최종 반환한다.

솔직히 역할은 알겠지만 각 타입에 대한 해석이 꽤 어려웠던 것 같다. 처음 보는 함수도 많았고 Sequence를 써서 지연 평가를 유도했는데 중간 연산이 많아지면 중간 컬렉션을 생성하지 않게 해서 데이터 효율을 아낄 수 있다고 한다.
GroupingBy는 Grouping 콜렉션을 반환하던데 이것도 처음 보는 것 같다.

속도의 효율은 내 코드보다 10% 가량 더 빨랐다.

post-custom-banner

0개의 댓글