240522 Spring 숙련 - 선택 구현사항 공부하기

노재원·2024년 5월 22일
0

내일배움캠프

목록 보기
44/90

Service와의 의존에서 연관을 위해 Entity를 반환해도 되는가?

Service는 Dto 말고 Entity도 반환할 수 있나요?
Service의 의존성 분리

어제 회고때 나온 Aggregate Root의 경계를 확실히 하기 위해 오늘 Todo 과제도 수정을 진행하고 있다. Todo는 이제 User repository를 주입받지 않고 User Service를 주입하게 변경했다.

그런데 Todo는 여전히 @ManyToOne 관계로 User와 연관되어있고 Todo Entity에는 User Entity의 정보가 필요해서 수정이 불가피한 상황이었는데 Service는 기본적으로 Dto만 반환하는 형태로 전부 구성이 되어 있어 Service에 Entity를 반환하는 것도 추가할지, Dto를 받은 후 다시 Entity로 변환하는 과정을 거칠지 를 결정해야만 했다.

일단 Service가 Presentation Layer에 Entity를 반환하는 건 강의에서도 계속 들은 내용이지만 지양해야 한다. 이는 결합도가 상승하고 계층을 두 단계 뛰어넘어 전파하게 된다. Dto를 거치지 않으면 보안적인 문제도 있고 Entity의 변경이 API까지 영향을 미치는 꼴이다.

하지만 Service-Service 에서만 사용하는 건 어떨까? 계층, 결합적으로 말이 안되는 건 아니지만 이는 굳이 Repository가 Service로 바뀌어 주입되는 이유가 약간 약해지는 느낌은 있다. 그리고 Controller가 Service를 위해 만들어진 Entity 객체 반환을 쓰지 않을 거라고 보장하지도 못한다는 점이 걸린다. 다만 실제 로직은 Service에서 여전히 다룰 거기 때문에 Repository 직접 참고보다는 훨씬 결합도가 낮아지는 건 맞다고 생각한다.

그런데 Service-Service의 통신에서 Dto가 사용되면 User -> UserDto -> User 로 바뀌는 우스꽝스러운 일이 된다. 물론 변환에 대한 로직은 DtoConverter, User Entity에 정의되어 있어 도메인의 내부를 참조할 일은 없지만 쓰잘데기 없는 변환이 이루어지는게 아닌가 의심이 많이 됐다.

그래도 레퍼런스와 챗봇의 내용을 종합해서 나는 Dto를 다시 Entity로 변환 하기로 했다. User Repository가 User Entity를 반환하는 이유는 User가 이 도메인을 책임져야하는 확실한 의무가 있는데 이걸 다른 Service에 넘겨주는 건 경계가 허물어진다고 생각이 들었고 Controller에서 실수로 Domain을 조회하는 경우도 발생할 수 있기 때문이다.

그런데 연달아 아래와 같은 두 번째 문제가 발생했다.

불완전한 Entity는 확실하게 업데이트 되지 않는가?

User Dto -> User는 Password 같은 민감 정보가 없기 때문에 불완전한 Entity 객체로 변환된다. 현재 Password까지 채워서 User를 만들 땐 SignInDto, SignUpDto 두 개만 관리하고 있기 때문에 UserDto 자체가 모든 정보를 갖고 있을 일은 앞으로도 없다.

그러면 Password, 그리고 기록용 날짜들이 없는 불완전한 Entity가 생성될 거고 user_id 만이 이 Entity를 보증해줄텐데 영속성 컨텍스트로 업데이트가 되는 일이 생긴다면 User 객체는 연관은 되어있지만 내용이 비어버리는 상황이 일어날 수 있다고 추측할 수 있다.

이 고민을 확실하게 없애기 위해 @ManyToOne 관계의 Todo가 생성될 때 연관된 불완전한 User가 절대로 영속성의 영향을 받지 않을 수 있다고 단언할 수 있는지 찾아봤는데 영 쉽게 확답을 내릴 수가 없었다. (예를 들어 생성한 Todo가 준영속 상태로 Detach 되면 User 객체또한 불완전 상태로 남아있다가 나중에 Merge 과정에서 Update 되어버릴 수도 있다고 생각했다.)

찾아보니 JPA의 관계를 쓰지 않고 userId만 저장해서 서비스에서 쓰라는 말이 있었지만 이는 N+1 Query 문제와 JPA의 연관 패턴을 따르지 않는다는 문제가 잇달아 발생한다. 정말 골치가 아팠다.

결국 이러면 User Repository가 조회한 User Entity의 정보는 완전한 Entity인 상태로 Todo service가 받아내야 하는데 그러면 위에서 실컷 고민한 부분들을 다시 고민하는 순환 고민에 빠지게 됐고 이는 튜터님에게 실제 패턴에서 사용 가능한 방법을 추천 받아 선택하기로 했다.

고민 피드백 - 엄격한 관계에서 벗어나기

요즘 부쩍 공부만 하다보니 엄격한 개발 문화에 대한 낭만으로 모든 원칙을 지키는 원칙주의자처럼 패턴에 대해 끊임없이 공부하곤 했다.

그래서 위와 같은 고민들이 연달아 나오는 거였는데 오늘 튜터님과의 피드백 과정에서 실제로 나도 실무할 때는 그런 고민 안했었다는 걸 깨달았다. 애초에 혼자 공부했으니까 내가 곧 앱 구조의 주인이긴 했지만 이슈가 처리될 수 있는가, 이슈가 생길 수 있는가를 따져보는게 핵심임을 잊어서는 안됐다.

@ManyToOne을 지향하고 양방향을 지양하는 것은 절대적인 원칙이 아니다. 그저 DB와의 흐름을 확실히 하려는 작은 정책일 뿐이니 양방향을 쓰라고 대놓고 마련된 환경에서까지 고집을 부릴 필요는 없으니 Todo Card의 주인을 그렇게 확실히 하고 싶으면 정책적으로 문제가 없음을 확인 후 양방향 맵핑으로 변경하면 된다.

또는 연관 관계를 끊는 것도 방법이 될 수 있다. 이것은 JPA가 지원해주는 연관 관계에 얽매여 ORM 종속성을 높이는 것이고 익히 생각할 수 있는 Table의 구조대로 단순히 Id 값만 저장해서 받고 로직에서 필요에 따라 사용해도 되고, 아니면 아예 필요한 값을 정적으로 저장해도 되는 값인지에 대한 정책을 검토하는 것도 방법이 될 수 있다.

지난 번 피드백에서도 한 번 들어봤던 내용같은데 지금 고민할 점은 과제를 위한 엄격하고 멋있어보이는 코드를 짜려는 게 아니라 하나의 어플리케이션에 대한 완성도를 먼저 고민해보고 코드를 벗어나 정책적으로 수정의 여지가 필요한 부분을 검토해보기로 했다.

추가 피드백 - Aggregate 패키지의 구조

나는 Comment의 Service를 Aggregate root인 Todo service에만 구현했을 뿐 다른 Layer는 여전히 Comment의 package 안에 남아있었다.

튜터님은 정책적으로 Comment가 앞으로도 계속 Todo에 연관되는 도메인으로 남을 거라면 다른 모든 부분또한 Todo domain 안으로 패키지를 합쳐 관리하라는 얘기를 해주셨는데 Service는 빼놓고 다른 건 빼지 않는 구조도 지금 보면 좀 이상해보일 수 있다는 생각이 들었다.

Aggregate에 대한 판단은 개발팀 모두가 진행하는 것이겠지만 이런 정책은 실무에서도 항상 판단이 갈리는 부분이니 기획자와 긴밀하게 이야기를 나누며 고쳐나가라는 조언도 해주셔서 일단 지금 패키지 구조도 한 번 바꿔보기로 했다.

개인적으로도 원래 앱에서 Package 관리를 엉망으로 하던 사람 입장에선 1 Domain 1 Controller package 구조가 영 안예뻐보였는데 더 합리적인 구조가 되는 것 같다.

여담으로 Model 안에서도 Status, Embeddable 같은 관리용이 나뉘면 Entity만 Model 패키지에 놓고 별도로 분리하는 걸 추천해주셨다.

Pagination - Pageable 사용하기

Pagination이 꽤 귀찮다는 건 실무할 때 좀 느꼈었다. 클라이언트인 나도 귀찮았고 백엔드에서 처리해주던 분도 귀찮다고 하셨던 걸 들은 적 있는 것 같다.

그렇기에 Pagination이 Step 4까지 빠진 것 같은데 아니 이게 웬걸 Spring data는 Pageable을 지원해줘서 편하게 구현할 수 있다. JpaRepository에서 PagingAndSortingRepository를 상속받았다고 한다.

가장 대표적인 Pageable 의 구현체인 PageRequest는 PageRequest(int pageNumber, int pageSize, Sort sort) 형태로 구성되어 있고 offset, size가 아니라서 클라이언트에서 구현하기도 훨씬 쉽지 않을까 싶다.

백엔드에서의 Pagination 방식은 offset, cursor 방식으로 크게 나뉘는 것 같은데 offset은 offset, limit을 써서 가장 간단한 구현 비용이지만 뒤쪽 내용을 조회할 수록 offset으로 뛰어넘어야 하는 레코드가 커져서 쿼리의 시간이 늘어나는 단점이 있다.

cursor는 index를 활용해서 애초에 특정 범위부터 불러오는 식으로 마지막으로 읽은 위치를 기억하고 있다가 불러오기 때문에 일정한 쿼리 시간을 가진다.

이 단점은 쿼리에서 offset 자체가 가진 단점이라 볼 수 있겠다.

어쨌든 page, size, sort를 모두 가진 Pageable이지만 Sort의 유효성 검증이 되는가 조사해보니 그건 안된다고 해서 sort는 기존에 만들어뒀던 enum sort를 써서 유효성 검증을 받고 @PageableDefault를 쓰면 sort param이 중복으로 쓰여서 page, size, sort를 각각 따로 받아 PageRequest.of로 작성하기로 했다.

여기까지는 예상 범위인데 Repository에서 약간 달라지는 점이 List<Entity>의 형태였던 반환을 Page<Entity> 로 수정하게 된다는 점이었다.

Page는 Content 제외하고도 전체 데이터 수, 전체 페이지 수, 현재 페이지 번호, 현재 페이지 크기, 다음/이전 페이지 여부를 확인할 수 있는 메타데이터를 포함해서 반환해주기 때문에 클라이언트 입장에선 정말 편할 것 같아 수정했다.


코드카타 - 프로그래머스 숫자 짝꿍

두 정수 X, Y의 임의의 자리에서 공통으로 나타나는 정수 k(0 ≤ k ≤ 9)들을 이용하여 만들 수 있는 가장 큰 정수를 두 수의 짝꿍이라 합니다(단, 공통으로 나타나는 정수 중 서로 짝지을 수 있는 숫자만 사용합니다). X, Y의 짝꿍이 존재하지 않으면, 짝꿍은 -1입니다. X, Y의 짝꿍이 0으로만 구성되어 있다면, 짝꿍은 0입니다.

예를 들어, X = 3403이고 Y = 13203이라면, XY의 짝꿍은 XY에서 공통으로 나타나는 3, 0, 3으로 만들 수 있는 가장 큰 정수인 330입니다. 다른 예시로 X = 5525이고 Y = 1255이면 XY의 짝꿍은 XY에서 공통으로 나타나는 2, 5, 5로 만들 수 있는 가장 큰 정수인 552입니다(X에는 5가 3개, Y에는 5가 2개 나타나므로 남는 5 한 개는 짝 지을 수 없습니다.)
두 정수 X, Y가 주어졌을 때, X, Y의 짝꿍을 return하는 solution 함수를 완성해주세요.

문제 링크

fun solution(X: String, Y: String): String {
    val xArray = IntArray(10)
    val yArray = IntArray(10)
    var pairText = ""
    X.forEach {
        xArray[it.digitToInt()]++
    }
    Y.forEach {
        if (xArray[it.digitToInt()] > 0) {
            yArray[it.digitToInt()]++
        }
    }
    
    val stringBuilder = StringBuilder()
    (9 downTo 0).forEach { currentNum ->
        val xCount = xArray[currentNum]
        val yCount = yArray[currentNum]
        val minCount = minOf(xCount, yCount)
        repeat(minCount) { stringBuilder.append(currentNum) }
    }
    
    pairText = stringBuilder.toString()
    if (pairText.isEmpty()) return "-1"
    else if (pairText.all { it == '0' }) return "0"
    return pairText
}

이번 문제는 삽질의 기록이 꽤 많았다.

우선 처음엔 X, Y 문자열에서 remove로 짝꿍이 된 숫자를 하나씩 걸러가며 텍스트를 더하는 식으로 했는데 생각보다 시간이 오래 걸리진 않았지만 X, Y의 최대 자릿수는 300만개라 시간 초과로 실패했다.

그래서 다음 방법으로 Map을 써서 0~9까지 숫자를 Count하고 작은 숫자만큼 숫자를 더하는 방식으로 0의 중복을 Sort로 체크하는 것도 9 -> 0 순회로 없애고 시간 복잡도도 O(n)으로 확실하게 바꿨는데 그럼에도 시간 초과가 났다.

Map이 생각보다 무거운가보다 싶어 IntArray로 바꿔서 Count 하는 방식으로 해도 시간초과가 났다. 온갖 조건을 걸어보며 최대 300만번의 순회를 적게 할 방법을 고민해봤지만 일단 O(N)임은 변함이 없었고 대체 어디가 문제인지 파악을 못했다.

(참고로 실행 시간은 바꿀 때마다 첫번째 케이스 기준 25ms -> 20ms -> 17ms 정도로 줄어들었다.)

질문하기를 뒤져봐도 내 코드의 시간 복잡도는 문제가 없는걸 확인해서 더 미칠 노릇이었는데 갑자기 Java 코드를 보게 됐고 거기서 쓰는 StringBuilder의 존재를 오랜만에 확인하고 나서야 pairText += currentNum 부분이 눈에 띄었고 StringBuilder로 교체하니 실행 시간을 대폭 줄이고 제출에 성공할 수 있었다.

예상치 못한 부분이었는데 문자열을 더할 때는 습관적으로 String에 += 연산자로 바로 붙였기 때문에 당황스러웠다.

이것때문에 추가로 조사한 사실에서 알아낸 점은 Kotlin의 String은 불변 객체였다는 점이다. 내가 쓴 += 연산자도 사실 String에 더한게 아니라 String을 새로 만들어서 할당하는 방식이다.

이렇기에 최대 300만번의 String을 다시 만들어 할당하는 엉터리 코드였던 상태였고 StringBuilder는 가변 객체로 내부적으로 char 배열을 사용하여 문자열을 관리하기 때문에 Append 하더라도 내부적으로 배열을 사용하기 때문에 크게 문제될 바가 없다.

String 자체도 Char의 배열이라 봐야하고 이 때문에 편하게 써오던 연산자를 적을 때 실제로 어떻게 작동하는지를 간과한게 패착이라 할 수 있겠다.

여담으로 String에 덧붙여 연결할 때 최대 시간 복잡도는 O(n^2)라고 한다.

0개의 댓글