240520 Spring 숙련 - 과제 피드백 적용하기

노재원·2024년 5월 20일
0

내일배움캠프

목록 보기
42/90

제출에 냈던 궁금점

이번 1차 제출에선 내 궁금점을 많이 적어내서 튜터님이 고생을 꽤나 하셨을 것 같다.

특히 제출 칸이 좁아서 질문이 굉장히 짧은 형태로 되어있었는데 제출 칸 좁든 말든 길게 질문을 드리는 게 맞았을 텐데 좀 확인하기 어려우셨을 것 같다는 생각이 들어 다음엔 그냥 주저리주저리 길게 쓰기로 했다.

우선 질문에 대해 해주신 답변을 정리해보기로 했다.

Service에서 필요한 만큼 Repository와 통신하는 건 단일 책임 원칙을 위반하지 않는지에 대한 고민

Service가 비즈니스 로직에 필요한 만큼 Repostiory를 주입 받는데 사실 이전 포스팅에서도 정리했지만 나도 단일 책임 원칙을 위반하는 건 아닌 걸로 판단을 내리긴 했었다.

다만 마음에 걸리는 건 CommentService는 Todo, User 모두 연관이 있기 때문에 Todo와 User Repository를 모두 주입받았고 모든 Repository가 3개인데 벌써 3개를 다 쓰는 서비스가 나왔다는게 좀 납득이 안됐었다.

튜터님도 이 부분을 잘 짚어주셔서 하나의 서비스가 여러 기능을 구현하다보면 이렇게 많은 책임을 서비스가 지게 되고 이걸 해결하기 위한 방법으로 UseCase 별로 구분하는 것을 추천해주셨다.

e.g. UserService -> signIn, signUp, ... ==> SignInUseCase, SignUpUseCase, ...

한 서비스가 처리하는 비즈니스 로직 함수가 10개만 된다고 생각해도 꽤 무거운 파일이 될 것 같은 생각을 한 적이 있었는데 {Domain}Service 로 구성하기 보다는 필요에 따라 Use Case 별로 분리해 생각해보는 걸 염두에 둬야겠다. 그런데 이러면 파일이 너무 많아 복잡도가 증가한다는 단점은 있을 것 같긴 하다.

지금의 Todo 앱은 Use case를 구분하는 난이도가 어려운 것도 아니고 로직이 기능별 Service에서 대량으로 발생하는 것도 아닌 것 같아서 이 부분은 짚어만 두고 가기로 했다.

Use case로 구별해서 서비스를 분리하고 입력 모델또한 따로 두며 철저하게 로직과 유효성을 검증하는 건 클린 아키텍쳐적인 측면에서도 권장된다고 한다. 난 아직 생각하기에 머리가 너무 아프다.

ManyToOne 단방향 맵핑에서 Soft delete의 흐름 제어 (@OnDelete를 통한 Cascade vs 로직에서 처리)

내가 지속적으로 고민해오던 단방향 맵핑에서의 흐름 질문이었는데 튜터님은 로직에서 처리를 택하신다고 하셨다.

인프라 설정은 DBMS 종속성이 높아지고 예측이 불가능해지지만 편리해지는 장점은 확실하다. 하지만 앱 논리를 통해 제어하는게 유연성이 좋아져서 커스텀하기 좋아지는 장점이 있다.

튜터님도 이 방법의 단점으로는 휴먼 에러, 구현 비용을 짚어주셨는데 이것도 해당 속성에 옵셔널 키워드를 지양해서 deleted 속성을 필수로 구현하게끔 하고 테스트에서 대부분 이슈를 잡는 걸 추천하셨다.

아무래도 구조적인 처리를 잘 해야할 것 같다.

같은 Aggregate 에 해당하게 Service 로직 통합 vs 실제 Domain로 분리하기 위한 Service 분리

강의를 보고 의아했던 부분이었는데 같은 Aggregate 안에 있는 서비스면 최상위 서비스에서 다 구현하는게 직관적이다. 라고 하셨던 부분인데 서비스가 지는 책임과 규모가 너무 커지지 않나? 라는 생각을 했었다.

나도 따지고 보면 Comment는 Todo에 종속적이니 Todo에서 Comment 서비스까지 처리하는게 말이 안되는 건 아닌데 챗봇과 계속 이야기하면서 별도로 분리하는 걸 선택했는데 아무래도 Comment 로직을 수정하기 위해서 Todo 가 아닌 Comment 서비스를 찾아가는게 나한텐 더 친숙했기 때문이다.

그런데 튜터님은 Aggregate 적인 관점에서 설계가 잘 되어있다면 변경에 대한 제어는 최상위에서 전역 식별자의 관점으로 서브 엔티티를 제어하는 흐름이 바람직하다고 하셨고 너무 코드적인 관점이 아닌 로직의 관점이었나 싶다.

예를 들어, 주문이란 애그리것이 있고 하위에 주문항목 엔터티가 있을 경우 주문 항목의 엔터티를 제어하기 위한 주문항목 서비스를 정의하는게 아닌 주문 서비스에서 주문의 ID(애그리것의 전역 식별자)를 통해 제어해야지 도메인의 불변식과 데이터 정합성 문제들을 잡을 수 있습니다.


(+ QnA. 주문항목 서비스에서 제어하면 문제가 어떤부분에서 발생하나요? => 주문의 상태는 성공 처리되어있는데, 주문 항목 서비스에서 주문 항목만 제거하는것을 열어버린다면 주문은 성공했으나 항목들이 모두 없는 주문이 생성되버릴 수 있습니다.)

지금 Todo 앱으로 치면 Todo가 삭제되어도 Comment가 확실히 처리되고 있는지를 판단하기 어려움이 설 수 있다는 생각이 들었다. 설명은 이해했으나 설계에서 내가 먼저 Aggregate root 에서 작성되야 하는가?를 고민하기엔 나에게 아직 판단하기 어려운 문제같다.

레퍼런스를 추가로 참조해보면 양방향으로 다룰 때 특히 Aggregate root에서 구현한게 직관적인 흐름이 눈에 띌 때가 많은데 단방향에서는 어찌해야하나 고민하며 참고해보니 단방향일 때는 상위 Entity가 하위 Entity를 아예 참조하지 않기 때문에 지금의 로직과 같은 모양새를 유지하고 있었다.

지금 내 코드에선 Aggregate의 Service 분리에 대해서만 생각하면 될 것 같다.

DDD 관련 첨언도 해주셨는데 도메인별로 패키징만 나뉘는 게 아니라 전술적, 전략적 설계 개념이 많이 있어 여기 나오는 패턴을 알맞게 고민하고 사용하는게 DDD고 Aggregate root에 대한 고민도 그 측면에 해당한다고 본다.

클라이언트에 가장 친화적인 URI, Request 구성 방법

사실 이건 내가 클라이언트에서 API를 쓸 때 고민하던 부분들이 정작 백엔드 와서 내가 짜기 시작하니까 쉽게 와닿지가 않아서 질문을 드렸던 건데 당연하지만 정답은 없다고 하셨다.

대신 프론트엔드에서 UX 친화적인 API를 주문하고 백엔드가 시스템의 사양과 함께 리뷰하는 형식으로 업무를 진행하는 방식도 있다고 했는데 이게 가장 적합한 URI, Request를 구성하는 데에 좋지 않을까 싶다.

프론트엔드 개발자가 API를 설계하는 이유

번외) Builder 패턴의 지양

레퍼런스 조사하다가 얼떨결에 같이 보게 된 건데 클린 아키텍쳐 책에서 나오는 내용 중 하나라고 한다.

값이 20개가 필요한 생성자가 있다면 어떻게 할 것인가?를 고민한 적이 있는데 사실 여기서 주로 쓰는게 Builder 패턴인건 익숙하고 잘 안다.

다만 개발 편의적인 측면에서 Builder로 접근했다간 유효하지 않은 객체가 등장할 여지가 있고 우리가 작성하는 생성자는 코드 퀄리티는 좀 떨어지겠지만 이를 확실히 방지하게 작성된 코드니 IDE의 도움을 받고 있는 개발자 입장에선 생성자의 parameter가 많아도 그대로 사용할 것을 권장한다고 한다.

코드에 대한 피드백

JPA Auditing

생성, 수정에 대한 감시를 JPA에서 지원하는 Auditing을 사용하면 변화를 쉽게 감지해서 데이터를 조작한다고 한다.

@CreatedDate, @LastModifiedDate를 쓰면 createdAt, updatedAt 관리가 쉽고 Auditable 이라는 추상 클래스를 구현해서 Entity에서 상속받으면 더욱 좋다고 한다.

코드적인 측면에서 어려운 건 아닌데 기존에 쓰던 @CreationTimeStamp 와는 무엇이 다른지 조사를 좀 했다.

알고보니 @CreationTimestamp, @UpdateTimeStamp 는 Hibernate가 지원하는 거였고 @CreatedDate, @LastModifiedDate는 JPA 표준 레벨에서 구현한 거라고 한다.

JPA 구현체로 거의 Hibernate를 쓴다고 알고 있긴 한데 확장성을 생각한 커스터마이징, 다른 JPA 구현체를 쓸 일이 생긴다고 하면 @CreatedDate를 쓰는게 좋을 것 같다.

기껏 피드백 해주셨는데 현재 기능적으론 같은 거니 나중에 시간 남을 때까지는 굳이 안건드리기로 했다.

Method design

유저 프로필 정보를 업데이트 할 때 Entity에 구현한 updateProfile로 서비스에서 처리하도록 구현했는데 지금 Nickname 말고 프로필이 없다보니 호출부에서 쓰기 귀찮아서 parameter를 String으로 지정했다가 걸렸다.

updateProfileNickname 또는 호출부에서도 Profile을 생성해서 업데이트 하게 해야 좋은 디자인이라고 지적받아 수정하기로 했다.

Request를 통한 생성 방식

현재 UpdateDto, CreateDto 등으로 받아서 서비스에서 Entity를 수정, 생성해 만들어나가고 있었는데 이를 Entity 내부에서 받아 생성하면 Entity 모델을 더 풍부하게 사용 가능하다고 알려주셨다. 추가적으로 Entity 내부에서 정의하면 도메인 불변식 검증까지 Entity 내부에서 공통되게 처리가 가능해지게 된다. 너무 상태에 대해서만 정의된 Data 모델같은 느낌의 Entity를 풍부하게 이용해보기로 했다.

생각해보면 Service의 부담도 커지고 Entity에 대해 가장 잘 아는 곳에서 변환을 진행하는 게 더 좋겠다는 생각이 들었었는데 이를 Dto 변환이 기존에 Entity에서 정의됐던 것 처럼 반대로 Dto에서의 변환도 정의를 하면 좋을 것 같다.

공통 유효성 검증

불변성 검증은 대충이지만 구조를 잡았으니 나중에 수정하면 되고 유효성 검증 측면에서 고민을 해보기 시작했다. 사실 @field:{validation} 으로 검증은 이미 너무나도 쉽게 진행이 되는데 내가 고민인건 공통 유효성 검증이었다.

생성 방법 4개, 수정 방법 6개정도 있다고 가정하면 이 10개가 모두 한 필드를 수정할 때 이 필드의 유효성은 어떻게 처리할 것인가? 그게 고민이었는데 이게 생각보다 명확한 답변이 없었다. 어디서 까먹고 8글자로 처리하던게 어디는 6글자라 다른 결과가 들어갈 것 같은데 사실 이런 건 게임에서도 은근 보던 유효성 검증의 실패같기도 하고 그렇다.

최종적으로 유효하지 않은 객체 생성을 방지해야하는 불변성 검증에서 이상한 값은 다 걸러지겠지만 유효성 검증을 통일하고 싶은 건 당연히 그럴 수 있다고 생각한다.

찾아본 내용중 Validator를 구현한 것도 있었는데 구현 비용이 꽤 크고 막상 원래 목적인 공통 유효성 검사에 적합하다는 생각은 들지 않아서 여기저기 찾아본게 결국 추상 클래스 또는 인터페이스를 이용하는 것이다. 그냥 공통되는 부분은 묶자는 거다.

다만 여기서도 갈피가 갈렸는데 추상 클래스를 쓰면 @field:를 사용 가능하고 인터페이스는 @get 밖에 사용하지 못한다. 하지만 추상 클래스를 쓰면 상속을 한 번밖에 못하고 인터페이스는 다양한 공통 조건을 상속받을 수 있다.

@field, @get의 차이
@field는 필드에 바로 접근해서 검증을 수행하고 @get은 값에 접근할 때(Getter) 검증을 수행한다. 따라서 @Valid로 Controller 에서 바로 유효성 검증을 할 수 있냐 없냐의 차이가 된다.

나는 추상과 인터페이스를 두세번 바꿔보며 판단한 결과 그냥 추상 클래스로 최대한 기본적인 부분만 묶어서 사용하기로 했다. 서비스 레이어에서 접근할 때 유효성 검사가 되는 건 본래 목적이었던 Presentation layer에서 가장 빠르게 에러를 반환하는 목적에 어긋난다고 생각해서 그렇게 진행하게 됐는데 막상 이러니 조금씩 dto마다 다른 필드를 쓰는 부분은 공통된 부분이 있더라도 추가로 구현 못하는게 아쉽긴 했다.

이게 정답인지는 모르겠고 커스터마이징 하기 좋은 Validator를 모든 Dto를 종합해 검증하는 기깔나는 방법이 존재할지도 모른다. 하지만 Controller 에서 체크하기엔 이게 맞다고 생각해서 이번엔 이렇게 진행하기로 했다.

일단 Domain 별로 나눠진 Controller의 경우 공통된 필드가 많으니 나름 성공적인 시도같다.

단방향 맵핑에서의 Aggregate Root Service 구성에 대해

튜터님의 피드백과 강의에서 모두 Aggregate Root에 하위 Entity까지 포함해서 관리하는 것에 대해 다뤘는데 사실 나는 아직도 이게 좀 의문이 가는 점이 있다.

가장 핵심은 @ManyToOne 으로 열심히 구성해놔서 상위 Entity가 하위 Entity에 대해 전혀 모르는데 비즈니스 로직을 Root에 합쳐서 구현해야 하는가? 이 부분이었다. 오늘 마침 GPT를 결제해서 4o에 대해 자유롭게 써볼 수도 있겠다 챗봇과 레퍼런스를 대차게 써가며 의문을 해소해봤다.

Aggregate root는 하위 뿐 아니라 모든 Entity의 일관성, 무결성을 책임진다. 이 말로 Domain 별로 나눠진 게 아닌 Root에 묶었다는 표현을 쓰게 되는 것 같다.

내가 생각했을 때의 구조는 사실 Aggregate와 관련 없이 단순히 Domain 별로 패키지를 분리해도 실제로 아무런 문제도 발생하지 않는다. 라는 생각에 꽂혀서 그랬다. Domain 별로 패키지를 나누는게 DDD가 아니라는 피드백도 받았지만 그럼에도 꽂힌 건 하위 Entity의 Service를 찾으러 갈때 굳이 별도로 작성된 하위 Entity Service가 아니라 특정 Aggregate에 속한 걸 알고 Root service 에서 처리해야 하는가? 라는 생각이 들었기 때문이다.

하지만 Root로 묶여있는 이유는 Aggregate를 구분한 이유 그 자체인데 하위 Entity인 Comment만 삭제하더라도 비즈니스 로직을 짤 때 다른 서비스도 필요 없고 실제로 영향을 받진 않지만 개념적인 측면에서 Comment는 Todo aggregate의 일부분인 로직이고, 이를 개념적인 부분에서 Root인 Todo service에서 처리하는 것이 올바르다는 것이다.

옛날에 내가 클라이언트를 구성할 땐 상당히 1:1로 패키지를 구성하고 클래스를 짜내는 경우가 많았는데 아무래도 거기에 사로 잡혀서 B 객체 관련한 내용이 A로 가야하는 이유에 대해 납득하지 못한게 큰 것 같다.

결국 Aggregate 안에서 처리되는 일에 대한 일관성, 무결성을 확인하기 쉽게 하는게 목적이고 서비스는 1:1 맵핑용으로 준비된 구조가 아니라는 점을 잘 생각하고 포함해야겠다.

챌린지반 - 객체지향(2)

  • 상속은 재활용의 기능을 하긴 하지만 가장 중요한 포인트는 타입계층을 만들어 낼 수 있다.
    • ex) Collection - List - LinkedList, ArrayList...
  • 타입계층의 핵심은 부모가 물려준 행위를 자식이 반드시 할 수있다고 보장해주는 것
    • ex) Student가 Person을 물려받았을 때 Student 객체는 Person을 대신할 수 있다고 보장함
      • person: Person 에 student를 넣어도 그 역할을 할 수 있다.
  • 컴파일(코드) 의존성 vs 런타임 의존성
    • 컴파일 의존성은 코드레벨에서 IDE에 적혀있는 OAuth2Client 라는 추상적인 것을 의존하고 있다면 OAuth2Client를 의존하고 있다는 것을 가리키고 런타임 의존성은 실행될 때 실제로 OAuth2Client의 구현체가 실행하는 것을 가리킨다.
      • 이렇게 시점에 따라 다른 것을 의존하는걸 가리키는 용어로 동적 바인딩이라고 한다.
  • 동적 바인딩은 유연한 구조를 설계할 수 있지만 추상화로 인해 코드의 흐름을 어렵게 만드는 단점도 있다.
  • Spring의 필드 주입 방식은 Spring이 없는 환경이 나오면 어려워지니 주의해야한다.

챌린지반 조사 - Spring IoC & DI는 객체지향적으로 어떤 도움이 되는가?

Spring은 내가 여태 써본 프레임워크중에서도 정말 내 코드가 Spring 위에서만 노는 구나 싶을 정도로 짜여져있다는 느낌을 받은 적이 있다. Container가 자기 알아서 Bean을 처리해주고 어노테이션만 딸깍 처리해주면 상태와 행위의 역할이 순식간에 뒤바뀌어버리고 옵션도 많이 제공해준다.

튜터님이 그래서 이런 질문을 하셨는데 Spring은 대체 객체지향적인 측면에서는 무슨 도움을 주는가?를 조사해보기로 했다.

SOLID 원칙 지원

SRP(Single Responsibility Principle): 단일 책임 원칙
OCP(Open Closed Priciple): 개방 폐쇄 원칙
LSP(Listov Substitution Priciple): 리스코프 치환 원칙
ISP(Interface Segregation Principle): 인터페이스 분리 원칙
DIP(Dependency Inversion Principle): 의존 역전 원칙

SRP는 객체의 구분에서 최근 많이 썼고 OCP는 개념적으론 장황하게 설명 못하겠지만 클래스 관계 구축에서 써먹는 추상화-상속의 개념이다.

LSP는 좀 익숙치않지만 마침 오늘 배운 부모의 역할을 대신 할 수 있다(치환)를 따진다.

ISP는 SRP와 약간 비슷하게 느껴졌는데 인터페이스는 다중 상속이 가능하기 때문에 최대한 책임을 분산하고 한 번 지어진 인터페이스는 변경을 자제하는 원칙이다. DIP는 또 의존성 관련해서 실컷 써먹었으니 넘어가자.

어쨌든 객체지향의 4대 특징인 캡상추다를 잘 써먹는 개발 원칙이라고 생각하면 될 것 같다. 다 정리하면서 느낀 점은 확실히 이번 과제에서 많이 써먹었고 Spring이 알아서 지원해준 건 더욱 많다.

각 클래스가 하나의 역할만 할 수 있도록 어노테이션을 제공했고 필요한 의존성은 DI로 주입받는 설계를 지원해주고, 인터페이스 기반 설계도 잘 되어있어서 구현체를 변경하지 않아도 되고 인터페이스를 주입받고 있어 DIP, OCP도 잘 되어 있다. 그리고 구현체가 달라질 수 있으면 LSP도 지원이 된다. 여태 써본걸로 보면 ISP를 지키기 위해 인터페이스를 더 분리해도 딱히 문제될 부분은 없다.

그리고 이 의존성을 전부 관리해주는 것도 Spring이 해주고 있고 의존성에 대한 원칙을 준수했기 때문에 객체 재사용과 테스트 또한 용이하다.

이 중 자주 쓰이는 키워드도 조사했는데

  • Spring IoC 컨테이너에 의해 관리되게 Bean으로 등록하기
  • Application을 등록하면 IoC 컨테이너가 구현되고 빈을 추가로 관리할 수 있다.
  • Autowired로 의존성의 자동 주입을 지원한다.
  • Component, Controller, Service, Repository 등 클래스가 Spring context에서 어떻게 관리되는지 구분한다.

자세히 구분하기엔 지금 Spring boot만 써봐서 개념적으로만 들은 키워드가 많지만 대충 파고 들어가보면 이게 나온다는 것 정도는 이해를 했다.

결국 SOLID 원칙을 지키게끔 설계된 Spring 기반의 앱은 객체지향적인 원칙을 잘 준수할 수 있게 개발을 도와준다고 볼 수 있을 것 같다.


코드카타 - 프로그래머스 로또의 최고 순위와 최저 순위

문제 링크

로또 6/45(이하 '로또'로 표기)는 1부터 45까지의 숫자 중 6개를 찍어서 맞히는 대표적인 복권입니다. 아래는 로또의 순위를 정하는 방식입니다. 1

순위 당첨 내용
1 6개 번호가 모두 일치
2 5개 번호가 일치
3 4개 번호가 일치
4 3개 번호가 일치
5 2개 번호가 일치
6(낙첨) 그 외

로또를 구매한 민우는 당첨 번호 발표일을 학수고대하고 있었습니다. 하지만, 민우의 동생이 로또에 낙서를 하여, 일부 번호를 알아볼 수 없게 되었습니다. 당첨 번호 발표 후, 민우는 자신이 구매했던 로또로 당첨이 가능했던 최고 순위와 최저 순위를 알아보고 싶어 졌습니다.
알아볼 수 없는 번호를 0으로 표기하기로 하고, 민우가 구매한 로또 번호 6개가 44, 1, 0, 0, 31 25라고 가정해보겠습니다. 당첨 번호 6개가 31, 10, 45, 1, 6, 19라면, 당첨 가능한 최고 순위와 최저 순위의 한 예는 아래와 같습니다.

당첨 번호 31 10 45 1 6 19 결과
최고 순위 번호 31 0→10 44 1 0→6 25 4개 번호 일치, 3등
최저 순위 번호 31 0→11 44 1 0→7 25 2개 번호 일치, 5등
  • 순서와 상관없이, 구매한 로또에 당첨 번호와 일치하는 번호가 있으면 맞힌 걸로 인정됩니다.
  • 알아볼 수 없는 두 개의 번호를 각각 10, 6이라고 가정하면 3등에 당첨될 수 있습니다.
    • 3등을 만드는 다른 방법들도 존재합니다. 하지만, 2등 이상으로 만드는 것은 불가능합니다.
  • 알아볼 수 없는 두 개의 번호를 각각 11, 7이라고 가정하면 5등에 당첨될 수 있습니다.
    • 5등을 만드는 다른 방법들도 존재합니다. 하지만, 6등(낙첨)으로 만드는 것은 불가능합니다.

민우가 구매한 로또 번호를 담은 배열 lottos, 당첨 번호를 담은 배열 win_nums가 매개변수로 주어집니다. 이때, 당첨 가능한 최고 순위와 최저 순위를 차례대로 배열에 담아서 return 하도록 solution 함수를 완성해주세요.

fun solution(lottos: IntArray, wins: IntArray): IntArray {
    val certainWinCount = lottos.count { wins.contains(it) }
    val zeroCount = lottos.count { it == 0 }
    
    return intArrayOf(
        minOf(7 - (certainWinCount + zeroCount), 6), 
        minOf(7 - certainWinCount, 6)
    )
}

중요한 건 등수를 계산하는 것 뿐이니 일치하는 숫자, 변형될 수 있는 숫자를 count를 적극 활용해서 풀었는데 그냥 등수 계산은 둘 째치고 정답 번호를 구할 때 contains를 사용할 수 밖에 없는가가 조금 걸렸다.

실제로 10ms를 넘는 실행시간을 보니 별거 아닌 숫자 배열에서 포함을 계산하는게 오래걸리는 것 같은데 짱구를 굴려봐도 순회를 적게 할 방법은 생각 안나서 그대로 제출하게 됐다.

다행히도 모르는 영역의 고민은 아니었고 다른 풀이도 비슷비슷해서 나쁘지 않은 풀이가 됐다.

0개의 댓글