240516 Spring 숙련 - Todo 과제 비즈니스 로직 채우기

노재원·2024년 5월 16일
0

내일배움캠프

목록 보기
40/90

ERD, API 명세서 리뷰하기

과제 제출일이 코 앞으로 다가온 만큼 부랴부랴 과제에 대한 해석을 공유하는 시간을 가졌다. 아직 진척도가 다 달라서 팀원 분들이 다 발표하신 건 아니지만 꽤 유의미한 시간이 된게 정책, ERD의 설계 부분들이 역시 생각하기마다 다 달라서 놓친 부분, 꼼꼼한 부분이 다 달랐고 고작 Todo 앱이지만 유의미하게 차이가 발생한다는 걸 느꼈다.

나는 Soft delete, @CreationTimestamp 같은거 있다는 걸 알려드리고 다른 분들에게선 꼼꼼한 API 명세와 정책, 그리고 튜터님과 얘기했던 내용등을 공유 받아서 나도 API 명세를 조금 수정하고 추가했다.

Entity의 단일 책임 원칙

어제 챗봇과 한참 이야기해서 정리한 Entity의 단일 책임 원칙에 대해 다시 한 번 고민하게 됐다.

일단 발단은 Todo Service 에서 Todo Entity를 생성해 저장하려고 할 때 Null이 아닌 User 객체를 어떻게 컨트롤하는가? 였다. 어제까지는 User 객체를 가진 채로 Entity가 잘 연관지어질테니 TodoDto 에 user nickname을 변환하기에 문제가 없었는데 생성은 전혀 다른 문제였다.

생성자에 User 객체를 끼워넣게 되는 순간 나는 어제 실컷 떠들어댄 단일 책임 원칙에 대해 재고하는 시간을 가져야 했는데 일단 한 Service가 2개 이상의 Repository를 참고해도 여러 원칙에 위반되지 않는가?를 열심히 조사했다.

챗봇이랑 열심히 대화를 하고 Stackoverflow의 수많은 답변을 헤집어본 결과 챗봇이 어제 얘기하던 2개 이상의 Repository는 위반된다의 표현은 내가 프롬포트에서 연관관계를 제대로 설명하지 않아서 발생한 문제 같았다.

Todo Entity가 User와의 연관관계가 확실히 존재하는 이상 UserRepository를 주입받아 사용하는 건 단일 책임 원칙을 위반한 게 아니라 Todo와 관련된 모든 비즈니스 로직을 처리해야하는 입장이니 Todo에 필요한 User도 사용할 책임을 가졌고 위반이 아니다 라고 판결내리기로 했다.

다만 DDD 패턴 특성상 도메인 모델간의 결합도도 낮춰야 하는데 여기선 UserRepository와의 결합도도 추가되었으니 이건 최소화 과정에서 어쩔 수 없는 건지는 불분명하다.

이것보다 더 좋은 패턴과 이유가 존재할 것 같긴 한데 도저히 못찾겠고 강의에서도 써먹은 방식이지만 내가 조금 더 조사하다가 헛다리 짚은 거라고 치고 일단 이대로 진행하기로 했다.

JPA Soft delete

실무에서 Soft delete로 데이터를 관리해왔던 만큼 나도 첫 서버니까 실무에서 보던 모양새로 구현하려고 하는데 이게 생각보다 빡빡했다.

처음 생각은 Delete method로 들어오면 직접 Put으로 바꿔 정성스레 바꿔줄 생각이었는데 @SQLDelete 어노테이션으로 Delete query를 바꿔줄 수 있고 @SQLRestriction 으로 조회 제약조건을 설정해서 삭제되지 않은 것들만 불러올 수 있다.

SQLRestriction은 꽤 엄격하기 때문에 갑자기 삭제된 레코드까지 조회하고 싶다면 Native Query를 설정해줘야 한다.

그래서 구현을 마치고 Soft delete 관련 레퍼런스를 읽어보는데 주의사항이 있어서 좀 체크를 했다.

우선 Unique에 관한 문제가 있었는데 남아있는 삭제된 데이터의 값이 Unique 제약 조건을 위반하게 될 수 있어 적용을 할 때 신경을 써줘야 한다.

이걸 처리하기 위해서는 논리적 테이블에서 unique를 사용하는게 아닌 비즈니스 로직에서 살아있는 레코드의 중복 검사를 하는 방법을 쓰거나 여러 Column을 Unique로 묶어서 관리할 수도 있다는데 어디서는 Virtual column 을 사용해서 삭제 상태를 구분하는 column을 추가했다고 한다.
Virtual Column을 통한 Soft delete의 Unique 관리

그리고 OnDelete Cascade가 문제였는데 통상 Cascade는 사용이 안되고 다른 어노테이션으로 접근을 해야한다. 바로 @OnDelete 이다.

이게 누구는 되고 누구는 안된다는데 Cascade.REMOVE 와 다른 점은 Cascade.REMOVE는 JPA가 책임지고 외래 키를 찾아 Delete를 관리해줬다면 @OnDelete는 DB에서 처리한다는 점이다.

즉 DB의 DDL을 통해 on delete cascade constraint를 설정해줄 뿐이고 삭제 쿼리는 이용하지 않는다. 이게 더 좋은건가 싶다가도 DBMS의 사양까지 고려해야 하다보니 Soft delete 측면에서 이게 안될 수도 있겠구나 생각은 들었다. 나도 테스트 안해봤고 의도와 다르게 작동할 여지가 있다. 만약 그렇게 된다면 비즈니스 로직에서 추가로 처리가 필요할 수 있다고 한다. Soft delete가 마냥 쉽게 생각할 만한 것은 아닌가보다.

아직까지 테이블은 생성하지 않고 JPA로만 관리하고 있어 빌드시 JPA가 DB를 생성해줄텐데 예상치 못한 결과가 나올 수 있는 점은 염두에 둬야겠다.



오늘 기본적인 기능은 작동하게 개발을 마치긴 했는데 여러모로 경험이 부족해서 확신을 가지고 작성하기보단 하나하나 찾아보며 일반적인 경우를 찾고 자주 쓰는 경우를 찾고 하며 시간을 쓴게 꽤 많았던 것 같다.

그럼에도 아직 불확실한 요소가 많고 좋은 패턴이 있으면 재빨리 훔쳐와서 습관으로 만드는게 앞으로의 프로젝트에서 큰 도움이 될 것 같고 그래도 오늘까지 1차 과제를 작성한 건 나름은 마음에 든다고 할 수 있겠다.


코드카타 - 프로그래머스 덧칠하기

어느 학교에 페인트가 칠해진 길이가 n미터인 벽이 있습니다. 벽에 동아리 · 학회 홍보나 회사 채용 공고 포스터 등을 게시하기 위해 테이프로 붙였다가 철거할 때 떼는 일이 많고 그 과정에서 페인트가 벗겨지곤 합니다. 페인트가 벗겨진 벽이 보기 흉해져 학교는 벽에 페인트를 덧칠하기로 했습니다.

넓은 벽 전체에 페인트를 새로 칠하는 대신, 구역을 나누어 일부만 페인트를 새로 칠 함으로써 예산을 아끼려 합니다. 이를 위해 벽을 1미터 길이의 구역 n개로 나누고, 각 구역에 왼쪽부터 순서대로 1번부터 n번까지 번호를 붙였습니다. 그리고 페인트를 다시 칠해야 할 구역들을 정했습니다.

벽에 페인트를 칠하는 롤러의 길이는 m미터이고, 롤러로 벽에 페인트를 한 번 칠하는 규칙은 다음과 같습니다.

  • 롤러가 벽에서 벗어나면 안 됩니다.
  • 구역의 일부분만 포함되도록 칠하면 안 됩니다.

즉, 롤러의 좌우측 끝을 구역의 경계선 혹은 벽의 좌우측 끝부분에 맞춘 후 롤러를 위아래로 움직이면서 벽을 칠합니다. 현재 페인트를 칠하는 구역들을 완전히 칠한 후 벽에서 롤러를 떼며, 이를 벽을 한 번 칠했다고 정의합니다.

한 구역에 페인트를 여러 번 칠해도 되고 다시 칠해야 할 구역이 아닌 곳에 페인트를 칠해도 되지만 다시 칠하기로 정한 구역은 적어도 한 번 페인트칠을 해야 합니다. 예산을 아끼기 위해 다시 칠할 구역을 정했듯 마찬가지로 롤러로 페인트칠을 하는 횟수를 최소화하려고 합니다.

정수 n, m과 다시 페인트를 칠하기로 정한 구역들의 번호가 담긴 정수 배열 section이 매개변수로 주어질 때 롤러로 페인트칠해야 하는 최소 횟수를 return 하는 solution 함수를 작성해 주세요.

문제 링크

fun solution(n: Int, m: Int, section: IntArray): Int {
    var answer: Int = 0
    val wallStateList = (0 until n).map { !section.contains(it + 1) }.toMutableList()
    var loopIndex = 0
    while (loopIndex < n) {
        if (!wallStateList[loopIndex]) {
            answer += 1
            
            for (i in 0 until m) {
                if (loopIndex + i >= n) break
                wallStateList[loopIndex + i] = true
            }
            
            loopIndex += m
        } else {
            loopIndex++
        }
    }
    return answer
}

풀 때까지는 문제가 꽤 어렵다 싶어서 이번엔 코드 수정이 꽤 많이 들어갔다.

일단 처음엔 Map으로 관리하려다가 Index 정보가 더 중요한 것 같아서 바꾸게 되었고 While로 관리하던 부분도 지속적으로 For로 탈출하는게 나을지, ForEach로 순회를 마치는게 좋을지등을 고민했는데 그 이유는 일단 미친듯이 긴 실행시간 때문이었다.

머릿속으로 떠오르는 시나리오에선 딱히 엄청 길지 않았는데 실제로는 1000ms가 나오고 하니깐 문제가 너무 많아보여서 계속 수정하고 순회 횟수 줄이고 조건 줄이고를 엄청 반복했다. 최종적으로 제출한 건 지금의 Step 으로 다음 순회를 건너뛸 수 있게 loopIndex를 관리하는 것과 벽의 Index가 초과되면 빠르게 탈출하는 것 뿐이다.

어찌저찌 80~100ms 정도를 줄이긴 했지만 그래봤자 900ms고 그냥 문제의 조건이 지독하겠거니 하고 다른 풀이를 보니 애초에 접근 자체를 너무 어렵게 했다는 걸 알았다.

fun solution2(n: Int, m: Int, section: IntArray): Int {
    var answer = 0
    var nextSection = 0
    section.forEach {        
        if (it >= nextSection) {
            answer++
            nextSection = it + m
        }
    }
    return answer
}

벽의 위치만을 기준으로 판단해도 문제가 충분했다. 문제가 원하는 바는 단순히 칠 하는 횟수일 뿐이고 진짜로 벽 다 칠 했어? 같은 걸 물어본 적도 없다. 벽의 상태를 관리하려고 아득바득 순회돌리고 수정할 필요가 없었다.

놀랍게도 실행 시간은 1ms로 900배 차이가 났고 이런 이슈때문에 서버 효율이 그렇게 차이가 나는구나도 느껴졌다. 내 코드에서 10~900ms 널뛰기하는 걸 보고 알고리즘은 최선이었고 문제의 케이스가 지독할 뿐이라고 생각한 게 잘못된 생각이었다.

한 번 물린 알고리즘의 최적화를 고민하기 보단 다른 방법은 없는지부터 잘 고민해야겠고 이렇게 효율이 구려 터졌을때는 억지로 검색 최대한 안하지말고 그냥 구글도 좀 참고해서 개선이라도 해보자.

0개의 댓글