240523 Spring 숙련 - JPA 연관 끊기, Session 공부하기

노재원·2024년 5월 23일
0

내일배움캠프

목록 보기
45/90

JPA 연관관계 끊기

한 User는 Todo card를 들고 있을 수 있고 Todo card는 지속적으로 User name을 불러와서 Dto로 만든다.

이 구조에서 나는 Todo가 User를 @ManyToOne 으로 가지고 있게 했고 User repository를 직접 참조할 때는 아주 간결하고 멋진 구조였다.

하지만 Aggregate의 경계를 확실히 하면서 Todo가 더 이상 User repository, User Entity에 대한 정보를 알지 못하게 하면서부터 JPA가 지원해주는 @ManyToOne 관계를 유지하기 너무 어려워졌고 나는 이에 대한 해답으로 JPA 연관관계를 끊고 userId만 저장하게 바꿀 것이다.

Comment는 원래도 userId를 선택적으로 연결했기에 끊었지만 강결합인 Todo에서 분리하는 건 생각보다 여기저기서 내용이 쓰인지라 빠르게 진행하지 못했다.

연관관계를 끊었으니 결국 N+1 문제를 피할 수 없는가?

Todo 하나를 참고해야 할 때 이제 User service에 추가로 User 정보를 요청해야 하기에 N+1 문제를 피할 수 없게 됐다.

N+1 문제 해결을 할 수 있는 방법이 있는지 찾아보면 사실 Aggregate의 경계를 재정의 하거나 Repository를 직접 참조하는 수밖에 없다. 다른 Aggregate의 User service에서 User의 정보를 가져온다고 정의를 내린 이상 Todo repository는 Join이든 뭐든 Todo entity에 유저의 정보를 userId 이상으로 담으면 안되기 때문에 User 정보를 조회하는 N+1은 필수로 남게 됐다.

하지만 얻어가는 장점으로는 Aggregate의 경계를 확실히 했고 이에 따른 도메인의 책임도 확실하게 단일 서비스가 질 수 있게 됐다. Todo service는 Todo, Comment repository와 Entity에 대한 정보만 가졌고 User service는 User repository와 Entity에 대한 정보만 가졌다.

그리고 Service 자체가 독립적이 됐기 때문에 서비스의 분리 배포가 가능해지기도 했고 MSA를 미리 고려한 결과라고 생각해볼 수도 있다. 그리고 Entity가 서로의 정보를 모르기 때문에 단일 Aggregate에 대한 확장을 고려하기도 더 쉬운 구조가 됐다.

코드 레벨에서 고민해볼 수 있는 건 이정도라고 생각이 든다. JPA가 정말 똑똑하기 때문에 많이 공부했지만 Domain의 책임과 Aggregate의 경계를 알고 나서부터는 JPA가 제공해주는 개발 편의성은 달콤한 독이 될 수 있겠다는 생각에 이렇게까지 조사하게 된 것 같다.

다만 MSA를 고려하지 않아도 되는 사이즈라면 (특히 지금의 과제처럼) 이 방식은 실제 서비스였을 경우 오히려 비효율적이고 개발 리소스도 더 잡아먹는 원흉이 될 수 있겠지만 여러가지 상황을 의심하고 고민해야 한다면 이런 방식또한 팀과 긴밀하게 상의해서 방향성을 잡는 것이 가장 중요함을 잊으면 안된다.

세션에 쓰기 좋은 키워드 알아보기

인증/인가와 연관이 많아서 그런지 관련 Annotation이 참 많았다. servlet이 지원하는 HttpSession 이나 Spring은 @SessionAtriubte, @ModelAttribute 뭐 연관된 키워드는 이런 것들이 있어 보이는데 또 새로 공부하고 차이를 알아봐야 할 것 같다.

  • HttpSession
    기초적으론 HttpSession을 구현하는데 세션을 servlet이 저장하고 생성하고 지원해서 Key값으로 저장, 불러올 때 getAttribute, setAttribute를 써서 불러오는 느낌으로 전체적인 느낌은 사실 그냥 iOS에서 Keychain 쓰는 거랑 비슷하다.

  • @SessionAttributes("user")
    HttpSession 모델을 쉽게 만들고 쉽게 세션에 저장하기 위해 Spring이 지원하는 어노테이션이고 위처럼 Controller 레벨에 붙이면 user라는 이름의 모델을 생성해서 세션에 저장받는 역할을 하게 된다.
    HttpSession이랑 비슷하면서 다른 점은 HttpSession의 생성, 삭제, 요청과 조회의 관리또한 쉬워서 영속성을 관리할 때 EntityManager 대신 JPA를 쓰는 것처럼 더 쉽게 사용 가능한 인터페이스가 지원되고 있다고 생각하면 좋을 것 같다.
    배열로 넣으면 배열에 쓰이는 모델 이름들을 여러 개도 관리 가능한 것 같다.

  • @ModelAttribute
    컨트롤러 매개변수나 메소드에 쓰는 어노테이션으로 Http 요청에 있는 Attribute에 맞게 바인딩 되야함을 명시한다. 이걸 쓰면 @RequestBody처럼 Dto에 자동으로 매칭시켜줄 수 있다.
    매개변수 말고 메소드에 쓰면 객체 바인딩이 아닌 해당 메소드 자체가 실행되고 반환되는 값을 Model 에 저장해주고 매개변수에서 같은 이름이 있으면 이걸 불러온다.

두 개는 비슷한 어노테이션인줄 알았는데 꽤 달랐다.
@ModelAttribute를 쓸 땐 데이터를 바인딩할 뿐 데이터를 유지하는 역할을 하진 않고
@SessionAttributes를 쓸 때는 매개변수/메소드 단위의 데이터를 처리하진 못한다고 이해했다.

간단하게 요약하면 이런 느낌인데 막상 써보려니 세션은 앞으로 UserDto를 써야하는지 Id만 저장하는지, 모든 Controller에서 이렇게 어노테이션을 중복으로 많이 작성해야 하는지등이 거슬렸다. 그러니 기능 구현은 뒷전이고 이쪽부터 조사를 해야겠다.

전역 세션 설정?

우선은 모든 Controller가 앞으로도 인증 정보를 써야한다고 가정하고 접근해보기로 했다.

전역 설정하니 생각난 @RestControllerAdvice 에서 설정을 하면 어떨까? 나는 이미 이걸 쓰고 있었는데 전역 에러 핸들링을 위한 Handler를 작성할 때 @RestControllerAdvice를 사용하고 있었다. 똑같은 맥락으로 세션을 위한 핸들러를 작성하기로 했다.

@RestControllerAdvice
@SessionAttributes("user")
class GlobalSessionControllerAdvice

그런데 이 방법은 권장되지 않는다. 일반적으로 @SessionAttributes는 단일 Controller level에서 권장되고 있다고 한다. 대충 해석해보면 Advice는 전역적인 세션 처리를 위해서는 설계하지 않아서 예상치 못한 상황이 발생할 수 있으니 권장하지 않는다는 내용같다. 개발편의성을 위해 전역으로 설정 넣고 시작하려던 나 같은 사람에게 적절한 내용이다.
Make @SessionAttributes work with @ControllerAdvice

그래서 위에서 정리한 것처럼 @ModelAttribute를 사용한 메소드로 반환값을 지정해 세션의 데이터를 전역으로 사용 가능하게끔 설정하는 걸 일반적으로 사용하는 것 같아 바꾸고 있었는데 생각해보면 사용할 때 어차피 각 Controller에서 똑같이 @ModelAttribute를 사용해야 하니 글로벌 핸들링이라는 말이 무색해진다. 지금 핸들러를 작성해봤자 함수 하나가 끝이다.

여담으로 User service 조회가 귀찮아서 @ModelAttribute를 Dto로 사용하는 것도 생각해봤는데 편하긴 하겠지만 userDto에 담겨지는 내용이 로직을 거쳐 변경될 경우 Session도 함께 업데이트 해줘야 하니 Session 으로 유저 정보 조회 날먹하려다가 더 골치아픈 일이 생길 수도 있어 보류하기로 했다. 유저의 보증은 Service에서 확실히 처리하는게 좋을 것 같다.

결국 글로벌 핸들링은 집어 치우고 인증을 담당할 UserController 에서만 @SessionAttributes를 사용해서 인증/인가를 진행하고 @ModelAttribute도 여기에 정의했고 userId에 대해서만 생각하기로 했다.

그나마 @ModelAttribute를 사용해서 userId를 HttpSession 에서 가져오는 로직을 최소화했고 요청마다 인증 정보를 확인해야하는 건 어쩔 수 없는 부분인 것 같으니 효율을 높이긴 높였다고 생각하기로 했다.

새삼 느끼는 점이지만 Spring 어노테이션은 편한데 공부하기는 정말 어려운 것 같다.


코드카타 - 프로그래머스 체육복

점심시간에 도둑이 들어, 일부 학생이 체육복을 도난당했습니다. 다행히 여벌 체육복이 있는 학생이 이들에게 체육복을 빌려주려 합니다. 학생들의 번호는 체격 순으로 매겨져 있어, 바로 앞번호의 학생이나 바로 뒷번호의 학생에게만 체육복을 빌려줄 수 있습니다. 예를 들어, 4번 학생은 3번 학생이나 5번 학생에게만 체육복을 빌려줄 수 있습니다. 체육복이 없으면 수업을 들을 수 없기 때문에 체육복을 적절히 빌려 최대한 많은 학생이 체육수업을 들어야 합니다.

전체 학생의 수 n, 체육복을 도난당한 학생들의 번호가 담긴 배열 lost, 여벌의 체육복을 가져온 학생들의 번호가 담긴 배열 reserve가 매개변수로 주어질 때, 체육수업을 들을 수 있는 학생의 최댓값을 return 하도록 solution 함수를 작성해주세요.

제한사항
  • 전체 학생의 수는 2명 이상 30명 이하입니다.
  • 체육복을 도난당한 학생의 수는 1명 이상 n명 이하이고 중복되는 번호는 없습니다.
  • 여벌의 체육복을 가져온 학생의 수는 1명 이상 n명 이하이고 중복되는 번호는 없습니다.
  • 여벌 체육복이 있는 학생만 다른 학생에게 체육복을 빌려줄 수 있습니다.
  • 여벌 체육복을 가져온 학생이 체육복을 도난당했을 수 있습니다. 이때 이 학생은 체육복을 하나만 도난당했다고 가정하며, 남은 체육복이 하나이기에 다른 학생에게는 체육복을 빌려줄 수 없습니다.

문제 링크

fun solution(n: Int, lost: IntArray, reserve: IntArray): Int {
    val actualLost = lost.filterNot { it in reserve }.sorted()
    val actualReserve = reserve.filterNot { it in lost }.sorted().toMutableList()
    
    var answer = n - actualLost.size
    
    for (student in actualLost) {
        when {
            actualReserve.contains(student - 1) -> {
                actualReserve.remove(student - 1)
                answer++
            }
            actualReserve.contains(student + 1) -> {
                actualReserve.remove(student + 1)
                answer++
            }
        }
    }
    return answer
}

이 문제는 아주 골치가 아팠다. 테스트케이스가 추가되면서 반례가 너무나도 많아졌고 처음에 mutableList로 Lost를 기준으로 한 순회를 하며 적절하게 체육복을 건내주는 학생수를 계산했는데 내가 적어넣은 테스트케이스는 통과하지만 보이지 않는 테스트케이스에서 너무 많이 틀렸다.

결국 다음 방법으로 채용한 게 intarray를 val students = IntArray(n) { 1 } 처럼 선언하고 미리 체크해서 0 = 분실, 1 = 소지, 2 = 여벌 로 계산해서 n만큼의 사이즈로 만들고 순회하며 0과 2의 index를 지속적으로 참고해가며 계산했는데 그럼에도 틀렸다.

좌/우 참조 우선순위를 각각 다르게 해서 maxOf로 최대 값을 계산했는데도 몇 개는 틀렸다. 질문하기에서도 좌/우 순위를 바꾸면 달라질 때가 있다는 얘기가 나와서 중복 코드를 엄청 발생시키면서 일단 성공부터 하고 최적화하자고 마음 먹어도 안됐다.

결국 못풀겠어서 레퍼런스를 싸그리 뒤져보기 시작했고 이전엔 문제 조건이 달랐는지 Set를 쓰는 풀이가 많았고 이 때 당시의 코드를 그대로 넣어보면 실패하는 테스트 케이스가 존재한다.

지독하게 걸렸다는 생각에 참 많이 바꿨는데 마구잡이로 코드를 뒤져서 찾아낸 결과 최종 코드는 어쨌든 저랬다. 맨 처음에 작성한 mutableList에서 remove하는 형식으로 크게 다를 바가 없었는데 추가된 건 정렬이었다.

문제의 예제로 든 게 전부 정렬이 된 채로 나와있어서 당연히 정렬된 채로 나오겠거니 했는데 문제의 조건에 정렬되어 있다는 이야기는 존재하지 않는다.

결국 이는 체육복을 받을 학생들의 순서를 보장해주지 않고 이걸 정렬해주면 좌측 학생에게 먼저 빌린다는 걸 보증하면서 제출에 성공했다. 진짜 정렬만 했으면 되는 걸 한참을 돌고 돌아오니 골치가 아프다. 여태 수정하는 과정에서 lost, reserve에 대한 검증은 건드린 적이 없었다. 질문하기를 다시 읽어보니 정렬 질문도 몇개 있었는데 꼼꼼히 읽어볼 걸 그랬다.

제출하고나서 생각한 건데 조건문을 actualReserve in student - 1 형식으로 바꿔줄 걸 그랬다.

0개의 댓글