[꾸물꿈] #2 객체 생성 기법과 데이터베이스 접근 및 관리

tkdwns414·2024년 8월 30일
1

꾸물꿈 로고

꾸물꿈 프로젝트

지난번 글에 이어서 작성한 두 번째 회고 글이다. 이번 글에서는 객체 생성 기법에 대해 고민했던 내용들과 데이터베이스 접근 및 관리에 대한 내용을 다뤄볼 예정이다.

객체 생성 기법

객체 생성 기법에는 많은 방법이 있으나 나는 일반적으로 객체 생성을 할 때 자주 사용하는 정적 팩토리 메소드와 빌더 패턴에 대해서 얘기해보도록 하겠다. 일반적으로 우리는 일반 생성자 new Class(~)를 사용하지 않고 정적 팩토리 메소드 패턴이나 빌더 패턴을 사용한다. 일반 생성자를 사용하는 것보다 적절한 생성자 패턴을 사용하는 것이 장점이 많기 때문인데 텔레피죤에서는 정적 팩토리 메소드 패턴을 사용했고 그보다 더 뒤에 진행한 꾸물꿈에서는 빌더 패턴을 사용했다. 그렇게 결정한 이유가 있었는데 먼저 각 정적 팩토리 메소드 패턴과 빌더 패턴의 장점과 단점에 대해서 간단하게 알아보자.

정적 팩토리 메소드

정적 팩토리 메소드는 생성자 대신 객체 생성을 위한 메소드를 제공하여 객체 생성을 좀 더 명확하고 제어된 방식으로 수행할 수 있도록 한다. 정적 팩토리 메소드는 이름을 통해 의미를 전달할 수 있는데 createWithDefaults, createWithAdmin과 같이 메소드명을 통해 어떤 조건의 객체를 생성하는지 추론할 수 있다는 장점이 있다. 또한 엔티티 생성에 필요한 로직을 캡슐화하고 생성 시점에 필요한 유효성 검사를 수행할 수 있다.

당연히 정적 팩토리 메소드의 단점 또한 존재한다. 우선 위에서 언급했듯 정적 팩토리 메소드의 사용을 보장하기 위해 클래스의 생성자를 Private으로 설정하게 될 경우 상속이 제한될 수 있다. 또 객체 생성시 요구사항이 많아지면 메소드가 많아질 수 있고 너무 많은 메소드가 생기게 될 경우에는 메소드 선택에 있어 혼란을 겪을 수 있다. 그렇게 될 경우 코드 복잡성이 커지며 유지보수에 있어서 어려움을 겪을 수도 있다.

사용 경험

텔레피죤에서는 위에서 언급한 장점들이 마음에 들어 정적 팩토리 메소드를 생성자 대신 사용하였다. 또한 정적 팩토리 메소드를 사용하게 된 이상 기본 생성자의 경우는 의도치 않은 기능을 할 수 있으니 기본 생성자의 접근 수준을 private으로 바꾸어서 사용하였다. 이는 '이펙티브 자바'라는 책에서 읽고 배운 내용을 적용해본 것이었다.

하지만 실제로 텔레피죤 프로젝트를 진행하던 중 문제가 발생했었는데 다음과 같은 문제들을 겪었다.

먼저, 엔티티는 일반적으로 많은 필드를 가지기기 때문에, 필드 순서를 잘못 입력하는 문제가 생겼다. 이는 일반 생성자에서도 나타나는 문제인데 IDE를 통해 어느정도 예방할 수 있기는 하나 실수를 아예 막기는 불가능하며 코드리뷰를 하는 입장에서도 이를 알아차리기가 어렵다.

두 번째 문제는 엔티티에 새로운 필드가 추가될 때 나타났다. 새로운 필드가 추가될 때 create라는 이름의 정적 팩토리 메소드에는 모든 필드가 들어가야하니 해당 내용을 가진 기존 코드에는 모두 null을 작성하러 돌아다녀야했다. 이는 장점이 될 수도 있다고 생각하나 그때 당시에는 불편했던 기억으로 남아있다.

마지막으로, 테스트 코드를 작성할 때 정적 팩토리 메소드와 private 레벨로 설정한 생성자가 우리를 불편하게 했다. 생성자를 Private 레벨로 설정하는 것이 잘못 사용되는 생성자를 방지하기 위해 맞는 선택인 것 같지만 정적 팩토리 메소드만을 사용하도록 강제하는 것이 테스트 코드 작성에 있어서 초기 상황 설정을 어렵게 했다. 특정 정보들만 입력된 엔티티가 필요한데 이를 만족하는 정적 팩토리 메소드가 없었고 테스트 코드를 위해 운영 코드에 정적 팩토리 메소드를 추가하는 것은 주객전도라는 생각이 들었다. 물론 이 문제는 프로젝트 구조 혹은 설계상의 문제일 수도 있고 테스트 코드 작성 경험이 부족한 초보라서 발생한 것일 수도 있으나 당시에 이런 문제로 마감을 지키기 위해 테스트 코드를 작성하지 않도록 결정했었다.

빌더 패턴

빌더 패턴은 복잡한 객체의 생성을 단계별로 분리하여, 객체를 더 유연하고 가독성 있게 생성할 수 있도록 하는 설계 기법이다. 빌더 패턴은 특히 필드가 많은 객체나 선택적 매개변수가 있는 객체를 생성할 때 유용하게 사용할 수 있다. 또한 빌더 패턴은 새로운 필드가 추가되더라도, 기존 코드를 크게 수정하지 않아도 되므로 확장성을 제공하며, 유지보수를 쉽게 한다. 마지막으로 앞서 말했던 다른 생성자를 이용할 때 나타나는 필드를 잘못 입력하는 문제를 예방할 수 있다.

그럼 단점에는 무엇이 있을까? 우선 빌더 패턴은 코드의 가독성을 높이는 대신, 코드가 길어질 수 있다. 필드가 많을 때 이를 선택적으로 사용할 수 있는 것은 좋으나 필드가 많을수록 빌더 패턴을 사용한 객체 생성의 코드가 아주 길어진다. 또한 객체 생성을 위해 빌더 객체를 정의하고 사용하는 데 추가적인 코드 작성이 필요하다.

사용 경험

꾸물꿈 프로젝트에서는 빌더 패턴을 사용했다. 앱잼은 데이터베이스 설계부터 최종 완성까지 대략 일주일 안에 작업을 완료해야하는데 거기다 꾸물꿈은 총 29개의 API가 있었다. 빠른 구현을 우선시해야했기에 꾸물꿈은 테스트코드 작성을 시도할 여유가 없었으나 후에 테스트코드 작성을 할 계획이 있었기 때문에 텔레피죤 때 겪어봤던 문제들을 해결할 수 있는 빌더 패턴을 이용하기로 팀원과 이야기를 마쳤었다.

빌더 패턴을 이용한다면 선택적 매개변수를 쉽게 관리할 수 있고 이를 통해 테스트코드에서 필요한 상태의 객체를 쉽게 만들 수 있을거라는 기대가 있었기 때문이다. 또한 이전까지는 아무 생각없이 빌더 패턴을 사용하다가 정적 팩토리 메소드 패턴을 사용해보고 장단점을 확실히 알았듯이 한 번 제대로 빌더 패턴을 이해하고 사용하고 싶었다.

아쉽게도 글을 작성하는 시점 기준 아직 테스트코드는 작성하지 못한 상태이지만 빌더 패턴을 사용하면서 딱히 큰 불편함을 느끼지는 못하였다. 그나마 느꼈던 불편함은 확실히 코드 길이가 길어진다는 점이며 객체 생성을 하는 부분이 한 클래스 내에 여러번 있다면 중복되는 코드가 길어진다는 점을 문제로 꼽을 수 있을 것 같다.

중복되는 빌더 패턴 구조에 관해서는 하나의 메소드로 뺄까 생각도 해보았지만 이는 결국 정적 팩토리 메소드와 달라지는 점이 없는 것 같고 둘을 혼용해서 쓰는 것 같은 느낌이 들어서 시도해보지는 않았다.

어떤 방법을 사용할까?

정적 팩토리 메소드와 빌더 패턴 중 어떤 것을 사용할지 고민하기에 앞서, 우선 어떤 방법을 사용하든 하나의 시스템 내에서의 일관성이 가장 중요하다고 생각한다. 두 방법을 혼용해서 사용하는 경우 어떤 코드에서는 정적 팩토리 메소드 패턴이 사용되는데 어떤 코드에서는 빌더 패턴이 사용될 수 있으므로 코드 이해와 유지보수에 혼란을 줄 수 있다.

또한 책 이펙티브 자바에서 찾아볼 수 있듯이 파라미터가 적다면 생성자를 파라미터가 많거나 동적으로 처리해야한다면 빌더를 이용하는 것이 더 좋을 것 같다는 판단을 내렸다. 앱잼 데모데이 때 멘토링으로 만나뵌 토스 코어 멘토님 께서도 위와 같은 방향으로 조언해 주셨다.

그리고 빌더 패턴 사용 때문에 코드가 길어지는 부분이 싫다면 가능할 경우 private 메소드로 빼서 사용하거나 굳이 빼지 않아도 괜찮을 것 같다고 하셨다. 멘토님 개인적으로는 코드의 가로 길이는 코드의 가독성 등을 해칠 수 있기에 중요하지만 세로 길이가 길어지는 것은 어쩔 수 없다고 생각하기 때문에 굳이 신경쓰지 않는 편이라고 하셨다.

그래서 나도 앞으로는 특별한 경우가 아니면 빌더 패턴을 주로 사용할 것 같다. 앞서 겪었던 정적 팩토리 메소드의 불편함을 해소할 수 있고 빌더 패턴이 내가 지향하는 코드에 더 맞다는 생각이 들어서이다. 물론 두 방법 중 어떤 것이 더 좋다 하는 우열은 없으며 모두 각자의 코드에 어떤 방법이 더 적합한지 찾는 것이 중요하다!

Transactional

@Transactional은 메소드나 클래스에 적용해서 트랜잭션 경계를 설정하는 스프링의 어노테이션이다. 이를 통해 우리는 특정 코드 블록이 시작될 때 트랜잭션을 시작하고, 끝날 때 변경 내용을 커밋하거나 롤백할 수 있다.

해당 어노테이션을 잘 사용하는 것은 프로젝트에서 중요한 요소 중 하나이다. Transactional을 꼭 필요한 곳에서만 사용해야 DB 부하를 줄이고 Transactional의 전파, 격리와 모드를 이해하는 것이 예상치 못한 트랜잭션 동작을 줄이고 최적화를 도와주기 때문이다.

Transactional의 작동 원리

Transactional은 보통 데이터베이스 상태를 변경하는 작업에서 필요하며 여러 데이터베이스 작업을 하나의 논리적인 작업으로 묶어서 관리해야할 때 사용된다. 이는 데이터베이스 일관성을 유지하는데에 도움이 된다.

@Transactional이 적용된 클래스나 메소드는 스프링에 의해서 프록시 객체로 감싸지게 된다. 프록시 객체는 원본 객체를 대리하며 Transactional이 붙은 메소드 호출 전후에 트랜잭션 시작과 종료를 개발자의 별다른 코드 작성 없이 수행할 수 있도록 해준다.

트랜잭션은 @Transactional이 적용된 메소드가 호출될 때 시작하고, 해당 메소드가 호출되는 동안은 유효한 트랜잭션이 유지되고 메소드가 종료되면 트랜잭션 또한 종료된다. 이때 RuntimeException 같은 unchecked Error가 발생하면 트랜잭션은 롤백되며, 예외가 발생하지 않거나 checked 예외가 발생한 경우에만 커밋이 된다. 참고로 @Transactional(rollbackFor = Exception.class)라고 적어줌을 통해 checked 예외에서도 롤백을 할 수 있다.

앞서 말한 프록시는 트랜잭션의 전파 및 격리 수준을 설정할 수 있게 도와준다. 전파는 하나의 트랜잭션이 다른 트랜잭션과 어떻게 상호작용하는지를 결정하고, 격리 수준은 트랜잭션 간의 데이터 일관성을 어떻게 유지할 것인지에 대한 결정을 할 수 있게한다.

프록시 때문에 일어나는 문제가 있는데 동일 클래스 내에서 @Transactional이 적용된 메소드를 다른 메소드에서 호출하게 된다면 프록시를 우회하게 돼 프록시가 동작하지 않기 때문에 트랜잭션이 적용되지 않는 상황이 발생할 수 있다. 이를 'self-invocation' 에러라고 한다.

Transactional에 대한 복잡한 내용은 다음에 따로 작성할 글을 통해 알아보도록 하자.

미숙한 사용 경험과 해결 방안

꾸물꿈 프로젝트에서 Transactional에 대한 미숙한 이해로 인해, 문제를 일으킬 수 있는 코드를 작성한 적이 있다. 바로 회원 탈퇴 부분이다. 꾸물꿈은 소셜 로그인을 통한 가입만을 지원하고 있는 서비스로 회원 탈퇴를 할 때 소셜 계정과 꾸물꿈과의 연결을 끊는 부분이 필요하다. 예를 들어 Kakao 연결 끊기, Apple revoke와 같은 통신을 통해 연결을 끊을 수 있다.

회원 탈퇴를 위해서는 현재 유저(JWT로 확인한)가 DB에 존재하는지, DB에 존재한다면 해당 유저를 삭제하고, 해당 유저의 소셜 계정 연결을 끊는 과정을 진행해야했다. 이때 고민했던 점은 DB에서 유저를 삭제하는 과정에서 오류가 날 수도 있고, 소셜 인증 서버가 그때 다운돼서 오류가 날 수도 있는데 그렇게 된다면 둘 중 하나의 작업만 진행될테고 그것이 문제가 되지 않을까? 라는 점이었다. 그래서 단순히 생각하여 unchecked Exception이 발생했을 때 DB가 롤백되는 것을 이용해 네트워크 통신이 성공하면 DB에서 회원을 지우고 커밋 시키기 위해 Transactional을 묶는 방법을 택했었다.

코드 사진

하지만 위와 같은 결정은 큰 문제가 될 수 있음을 깨달았다. 앞서 말했듯이 @Transactional이 붙어있는 메소드는 트랜잭션이 시작되면서 데이터베이스와의 연결이 이루어지며, 이 연결은 트랜잭션이 종료될 때까지 유지된다. 이때 네트워크 통신이 트랜잭션 내부에 들어가 있으면 네트워크 통신이 길어짐에 따라 커넥션을 사용하지 않고 있음에도 불구하고 필요 이상으로 오래 들고 있게 되며 결과적으로 다른 유저가 커넥션을 확보하기 위해 대기해야하는 상황이 발생한다. 결국 대기가 오래 진행되면 유저는 에러를 반환받게 된다. 멘토님께서 이 문제에 대해 조언을 주셨는데, 실제로 대부분의 장애가 데이터베이스로 인해 발생하므로 불필요한 커넥션을 유지하는 것은 위험하며 네트워크 통신을 트랜잭션에 결합하는 것은 아주 위험하다고 말씀하셨다.

생각할 수 있는 해결방안

해당 문제를 어떻게 해결할 수 있을지에 대해 고민해보았다. 사실 많은 사람들이 프로젝트 내에서 연결끊기까지 구현하지 않는 경우가 많아서 다른 친구들에게 물어보아도 그럴싸한 대답이 돌아오지 않았었다. 혼자 고민하면서 나는 문제를 하나의 코드로 모두 해결하려 했다는 점을 깨달았다.

확실히 네트워크 통신을 진행할 때 DB와의 커넥션을 들고 있는 것은 위험하니 Transactional을 네트워크 통신과 결합하는 것은 삭제하도록 한다. 그렇다면 기존에 내가 생각했던 '우리 서비스의 회원 삭제'나 '소셜 인증 서버의 연결 끊기'나 둘 중 하나만 성공하는 문제를 어떻게 해결할 수 있을까? 나는 이를 하나의 메소드 내에서 한 코드로 해결하려 했지만 그 관점을 벗어나서 보니 단순하게 생각할 있었다.

회원 삭제가 이루어질 때 우리 DB에 문제가 있으면 당연히 오류를 반환한다. 아마 이는 RuntimeException이 터질 확률이 높다. 하지만 회원 삭제가 정상적으로 진행된 후 '연결 끊기' 부분에서 문제가 생길 경우에는 로그를 통해 오류를 기록하고 후속 조치를 취할 수 있는 방법도 있다고 생각했다. 로그를 통해 어떤 회원의 연결 끊기에서 오류가 발생했는지 기록하고 이를 주기적으로 관리하거나, 오류가 발생한 회원에게 연결 끊기 방법을 알려주는 이메일을 발송하는 등의 방법을 고려할 수 있다.

해당 문제는 트랜잭션의 작동 방식에 대한 이해 부족과 너무 단순하게 하나의 코드로 모든 문제를 해결하려고 했던 것이 원인이라 생각한다. 항상 어떤 문제를 해결할 때 하나의 방법에 매몰되지 않게 주의하는데 그런 능력을 더 길러야 할 것 같다.

다음에는...

다음에는 꾸물꿈에서 깨달은 서비스 레이어 설계와 전반적인 코드 사용 전략에 대해서 알아보도록 하겠다.

0개의 댓글