1. 객체지향이란?
JAVA는 알다시피 객체지향적인 언어다.
그렇다면 객체지향이라는 것이 무엇일까 한번 다들 고민해 봤을 거라고 생각한다.
검색해서 나오는 내용들을 보면 객체지향은 OOP다, SOLID원칙을 지켜야 한다라고 단순히 설명하면서 알려주는데 대부분의 사람도 마찬가지로 나도 이런 식으로 그냥 외우고 있었다.
또한 SOPT라는 동아리를 하고, 프리랜서로 외주를 맡아 일하면서 여러가지 프로젝트를 진행했는데 코드를 구성할 때 객체지향적으로 생각하면서 코드를 짜지는 않았던 것 같다.
그래서 다음과 같은 책을 읽고 코드를 리팩토링하면서 블로그를 작성해보려고 한다.
https://product.kyobobook.co.kr/detail/S000213447953
자바/스프링 개발자를 위한 실용주의 프로그래밍 | 김우근 - 교보문고
자바/스프링 개발자를 위한 실용주의 프로그래밍 | 소프트웨어 개발을 잘하고 싶다면 ‘개발’ 공부를 해야 합니다! 자바 개발자가 코틀린 같은 신생 언어를 다룰 수 있게 된다고 해서 개발을
product.kyobobook.co.kr
광고하는 것이 아니라 좋은 책이라 다들 한 번쯤 개발을 잘하는 개발자란 무엇인지 한 번 더 생각해 볼 수 있던 좋은 책이었다고 생각한다.
그래서 객체지향이란 무엇인가? 를 요약하자면 다음과 같다.
2. 객체간의 책임, 역할, 협력 이 중요하다.
이제 면접에서 객체지향이란 무엇인가요? 물어볼 때 객체란 실제 세계를 객체로 표현하여 프로그래밍하는 것입니다.라고 단순히 대답하는 경우가 많지 않았는가 생각해 볼 필요가 있다.
물론 틀린말은 아니지만 책에서 말하는 것은 위 3가지가 객체지향에서 가장 중요한 것이며, 다음 3가지를 중요하게 생각하며 코딩했을 때 SOLID와 , 객체지향의 4가지 특징인 캡슐화, 다형성, 상속성, 추상화가 따라온다는 것이다.
기본적인 내용이므로 각 내용이 무엇인지 설명하지는 않겠다. 그러니 가장 중요하게 생각하는 3가지를 중점적으로 코드를 보면서 설명해보겠다.
일단 나의 도메인은 소셜기반 매칭플랫폼 도메인으로 구분할 수 있다.
여기서 객체로 포트폴리오라는 객체가 있는데 이 도메인은 다음과 같은 코드로 구현되어 있다.
@RequiredArgsConstructor
@Getter
@Builder
public class UserPortfolio {
private Long portfolioId;
private Long userId;
private List<String> portfolioImages;
public UserPortfolio(Long portfolioId, Long userId, List<String> portfolioImages) {
this.portfolioId = portfolioId;
this.userId = userId;
this.portfolioImages = portfolioImages;
}
public void addPortfolioImage(String portfolioImageUrl){
this.portfolioImages.add(portfolioImageUrl);
}
public void addPortfolioImages(List<String> portfolioImageUrls){
this.portfolioImages.addAll(portfolioImageUrls);
}
}
즉 사진들의 이미지 Url들을 가지고 있는 객체가 있고 연관 Entity는 다음과 같다.
@Entity
@AllArgsConstructor
@NoArgsConstructor
@Builder
@Table(name = "user_portfolio_imgs",
indexes = @Index(name = "user_portfolio_imgs_user_id_idx", columnList = "user_id"))
@Getter
public class UserPortfolioImg {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotNull
@Column(name = "user_id")
private Long userId;
@NotNull
@Column(name = "portfolio_id")
private Long portfolioId;
@NotNull
@Getter
@Column(name = "portfolio_image_url")
private String portfolioImageUrl;
@NotNull
@Getter
private int sequence;
@Getter
@Column(name = "username")
private String userName;
public static UserPortfolioImg of(Long userId, Long portfolioId, String portfolioImageUrl, int sequence, String userName) {
return UserPortfolioImg.builder()
.userId(userId)
.portfolioId(portfolioId)
.portfolioImageUrl(portfolioImageUrl)
.sequence(sequence)
.userName(userName)
.build();
}
public void portfolioImageUrl(String portfolioImageUrl) {
this.portfolioImageUrl = portfolioImageUrl;
}
}
그러면 다음 Portfolio라는 객체는 어떤 책임을 져야 할까?라는 생각을 해볼 수 있을 것이다. 물론 나는 이 코드를 객체지향적으로 생각하면서 구성하지 못했기 때문에 아직 객체가 가진 책임이라고 볼 수 있는 단순히 값을 받아서 생성해 주는 생성자 정도이다.
3. 문제점
문제는 여기서 일어나는데 도메인 객체를 구성했음에도 불구하고 객체가 가지고 있는 책임과 역할이 아무것도 없다.
단순히 값을 매핑해서 생성해주는 것으로는 도메인 객체가 아닌 그냥 "값"객체일 뿐이다.
이렇게 값객체로써 코드를 작성하게 된다면 다음과 같은 문제점들이 발생할 수 있는데 직접 짠 코드를 보자.
CompletableFuture<List<UserPortfolioImg>> portfolioImgsFuture = CompletableFuture.supplyAsync(
() -> userUpdateUseCase.updateUserPortfolioImages(userId, userUpdateDto));
다음은 PortfolioImages를 update 하는 코드인데 이 메서드를 살펴보면 다음과 같다.
@Override
@Transactional
public List<UserPortfolioImg> updateUserPortfolioImages(final Long userId, UserUpdateDto userUpdateDto) {
Map<Integer, Object> changedPortfolioImages = userUpdateDto.getPortfolioImages();
List<UserPortfolioImg> existingPortfolioImgs = userPortfolioRepository.findAllByUserId(userId);
Long portfolioId = existingPortfolioImgs.getFirst().getPortfolioId();
Map<Integer, MultipartFile> multipartImages = new HashMap<>();
Map<Integer, String> stringImages = new HashMap<>();
if (changedPortfolioImages != null) {
for (Entry<Integer, Object> entry : changedPortfolioImages.entrySet()) {
Integer sequence = entry.getKey();
Object updatedImg = entry.getValue();
switch (updatedImg) {
case MultipartFile multipartFile -> multipartImages.put(sequence, multipartFile);
case String imageUrl -> stringImages.put(sequence, imageUrl);
default -> throw new ContactoException(FailureCode.USER_UPDATE_FAILED);
}
}
}
List<UserPortfolioImg> updatedPortfolioImgs = new ArrayList<>();
if (!multipartImages.isEmpty()) {
updatedPortfolioImgs.addAll(imageService.saveImagesWithSequence(multipartImages, userId, portfolioId, userUpdateDto.getUsername()));
}
for (Entry<Integer, String> entry : stringImages.entrySet()) {
Integer sequence = entry.getKey();
String imageUrl = entry.getValue();
UserPortfolioImg newImg = UserPortfolioImg.of(userId, portfolioId, imageUrl, sequence,userUpdateDto.getUsername());
updatedPortfolioImgs.add(newImg);
}
updatedPortfolioImgs.sort(Comparator.comparing(UserPortfolioImg::getSequence));
userPortfolioRepository.deleteAllByUserId(userId);
return updatedPortfolioImgs;
}
@Transactional
public List<UserPortfolioImg> saveImagesWithSequence(final Map<Integer, MultipartFile> images, final Long userId, final Long portfolioId, String userName) {
Queue<UserPortfolioImg> savedImages = new ConcurrentLinkedQueue<>();
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<CompletableFuture<Void>> futures = images.entrySet().stream()
.map(entry -> CompletableFuture.runAsync(() -> {
try {
String imagePath = s3Service.uploadImage(path, entry.getValue());
UserPortfolioImg newImage = UserPortfolioImg.of(userId, portfolioId, cachePath + imagePath, entry.getKey(),userName);
savedImages.add(newImage);
} catch (IOException e) {
throw new CompletionException(new BadRequestException(FailureCode.BAD_REQUEST));
}
}, executor))
.toList();
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
return saveSortedImages(savedImages, portfolioId, userId);
}
}
private List<UserPortfolioImg> saveSortedImages(Queue<UserPortfolioImg> savedImages, Long portfolioId, Long userId) {
List<UserPortfolioImg> sortedImages = new ArrayList<>(savedImages);
sortedImages.sort(Comparator.comparing(UserPortfolioImg::getSequence));
return sortedImages;
}
문제점이 보이는가? 일단 코드를 update 하고 구성하는 내용이 하나의 Service안에 모두 들어가 있고 각 객체가 가진 책임이라고는 전혀 없다. 모든 로직을 서비스코드 안에 몰아넣고 서비스에서 모든 로직들을 수행한다. 또 다른 문제는 각 서비스의 메서드에서 JPA Entity를 그대로 참조하고 있고 이는 DTO로 변경한다면 그나마 낫겠지만 그래도 그냥 도메인이 스스로 역할과 책임을 다하지 않는다면 객체지향적인 코드는 아니라는 것이다. 그리고 코드가 너무 길고 하나의 메서드 안에 DB에 접근하는 로직하고 여러 가지 로직이 한 번에 섞여있어 서비스 자체로도 어떠한 의미를 내포하고 있지 않다.
4. 어떻게 해결할 것인가?
그렇다면 이런 코드를 조금 더 객체지향적으로 생각한다면 어떻게 바꿀 수 있을까?
일단 객체 스스로 역할에 대한 책임을 지도록 만들 수 있다.
이 부분에서 객체가 어떠한 역할을 스스로 할 수 있을지 생각해 보는 시간이 필요하고 서비스 메서드에 불필요하게 객체가 할 수 있는 일을 외부로 빼내어 시킨 건지는 아닌지 의심해봐야 한다.
위와 같은 서비스 코드로 어떤 일을 하는지 보았을 때 확인해 보면 다음과 같다.
- Portfolio의 Url을 정렬한다.
- Update객체가 들어왔을 때 그 객체에서 Porfolio에서 update될 부분하고 아닌 부분을 찾는다.
- 각 PortfolioImg의 리스트가 들어왔을때 sequence 값에 따라 정렬한다.
그리고 변경할 수 있는 점은 또 존재한다. 현재 service 레이어, 즉 비즈니스 레이어에서 존재하는 레이어는 도메인을 의존하는 것이 아닌 JpaEntity를 의존하고 있다는 점 또한 문제로 들 수 있다. 이게 무슨 말인가 하면 다음 코드를 예시로 보자.
@Override
@Transactional
public List<UserPortfolioImg> updateUserPortfolioImages(final Long userId, UserUpdateDto userUpdateDto) {
Map<Integer, Object> changedPortfolioImages = userUpdateDto.getPortfolioImages();
... //코드 내용
return updatedPortfolioImgs;
}
위 코드에서는 UserPorfolioImg라는 객체를 반환값으로 사용 중이며 내부에서도 이를 직접 사용하고 있다. 이는 즉 service 레이어에서 JpaEntity인 UserPorfolioImg를 사용하고 있고 이는 Infrastructure 레이어가 변경되었을 때, 혹은 Jpa기술을 더 이상 사용하지 않았을 경우 이를 사용하는 코드 전부를 변경해줘야 한다는 점에서 확장성이 없다.
그렇다면 UserPorfolio라는 도메인 객체에 의존하게 하면 되지 않을까?라는 방법이 따라올 수 있다.
그럼 아까 봤던 코드를 다시 보자.
public class UserPortfolio {
private Long portfolioId;
private Long userId;
private List<String> portfolioImages;
...
}
다음 코드는 도메인 객체로 설정한 코드인데 뭐 나쁘지는 않지만 좀 더 효율적으로 개선할 수 있을 것 같다.
앞서 말한 객체의 역할, 책임, 협업으로 생각해 본다면 위에서 말했던 내용인
- Portfolio의 Url을 정렬한다.
- Update객체가 들어왔을 때 그 객체에서 Porfolio에서 update될 부분하고 아닌 부분을 찾는다.
- 각 PortfolioImg의 리스트가 들어왔을때 sequence 값에 따라 정렬한다.
다음의 역할을 할 수 있을 것 같다. 또한 객체 간의 협력을 생각해 보았을 때 List <String> 이 아닌 UserPortfolioImg를 가지는 것이 좋을 것으로 판단하지만 도메인에서 JpaEntity를 의존하는 것은 좋지 않다. 따라서 UserPortfolio라는 역할에 있어서 가져야 하는것은
확장가능성 있게 PortfolioItem이라는 객체를 의존하는 것이 좋다고 생각한다. 이렇게 된다면 PortfolioItem라는 역할을 담당하는 구현체들이 PortfolioItem이라는 역할에 할당되면서 어떤 구현체가 들어와도 도메인이 변경되지 않는다는 장점 또한 존재한다.
즉 확장성에 열려있고 변경에는 닫혀있는 OCP와 하위 타입 객체또한 부모타입으로 변경가능한 LSP 또한 지켜지면서 SOLID원칙또한 지킬 수 있다. 이처럼 객체의 역할,책임, 협업에 대해 중심을 두고 , 도메인에 의존하도록 설계하면 SOLID원칙 또한 자연스레 따라오게 된다.
또한 PortfolioItem은 다음과 같은 책임이 있다.
public interface UserPortfolioItem {
Long getId();
int getSequence();
Long getPortfolioId();
Long getUserId();
String getItemUrl();
String getUserName();
}
다음과 같아진다면 Portfolio 코드를 다음과 같이 변경할 수 있다.
public class UserPortfolio {
private final Long portfolioId;
private final Long userId;
private List<UserPortfolioItem> portfolioItems;
또한 객체에 위에서 말한 3가지 책임 또한 부여했으므로 객체가 동작하는 방식을 다음과 같이 수정할 수 있다.
@Getter
@Builder
public class UserPortfolio {
private final Long portfolioId;
private final Long userId;
private List<UserPortfolioItem> portfolioItems;
public void sort() {
this.portfolioItems.sort(Comparator.comparing(UserPortfolioItem::getSequence));
}
public Map<Integer, MultipartFile> findUpdateItem(Map<Integer, Object> items) {
Map<Integer, MultipartFile> updateItem = new HashMap<>();
if (items != null) {
for (Entry<Integer, Object> entry : items.entrySet()) {
Integer sequence = entry.getKey();
Object updatedImg = entry.getValue();
if (updatedImg instanceof MultipartFile file) {
updateItem.put(sequence, file);
}
}
}
return updateItem;
}
public void addOrUpdatePortfolioImages(List<UserPortfolioItem> newItems) {
if (this.portfolioItems.isEmpty()) {
this.portfolioItems = new ArrayList<>(newItems);
} else {
this.portfolioItems = new ArrayList<>(this.portfolioItems);
for (UserPortfolioItem newItem : newItems) {
this.portfolioItems.removeIf(item -> item.getSequence()==(newItem.getSequence()));
this.portfolioItems.add(newItem);
}
}
this.portfolioItems = List.copyOf(this.portfolioItems);
}
}
이렇게 된다면 객체에 책임을 부여하고 객체 스스로 능동적으로 동작하도록 객체를 변경할 수 있다.
그리고 서비스로직에 몰려있던 트랜잭션 스크립트나 비즈니스 로직들이 적어지도 객체가 스스로 하기 애매하거나 할수 없는 일들이 비즈니스 레이어에서 객체들의 협업을 위해 사용되도록 변경가능하다.
그렇다면 아까 봤던 PortfolioImages를 update 하는 코드를 어떻게 변경가능한지 확인해 보자.
@Override
@Transactional
public List<UserPortfolioImg> updateUserPortfolioImages(final Long userId, UserUpdateDto userUpdateDto) {
Map<Integer, Object> changedPortfolioImages = userUpdateDto.getPortfolioImages();
List<UserPortfolioImg> existingPortfolioImgs = userPortfolioRepository.findAllByUserId(userId);
Long portfolioId = existingPortfolioImgs.getFirst().getPortfolioId();
Map<Integer, MultipartFile> multipartImages = new HashMap<>();
Map<Integer, String> stringImages = new HashMap<>();
if (changedPortfolioImages != null) {
for (Entry<Integer, Object> entry : changedPortfolioImages.entrySet()) {
Integer sequence = entry.getKey();
Object updatedImg = entry.getValue();
switch (updatedImg) {
case MultipartFile multipartFile -> multipartImages.put(sequence, multipartFile);
case String imageUrl -> stringImages.put(sequence, imageUrl);
default -> throw new ContactoException(FailureCode.USER_UPDATE_FAILED);
}
}
}
List<UserPortfolioImg> updatedPortfolioImgs = new ArrayList<>();
if (!multipartImages.isEmpty()) {
updatedPortfolioImgs.addAll(imageService.saveImagesWithSequence(multipartImages, userId, portfolioId, userUpdateDto.getUsername()));
}
for (Entry<Integer, String> entry : stringImages.entrySet()) {
Integer sequence = entry.getKey();
String imageUrl = entry.getValue();
UserPortfolioImg newImg = UserPortfolioImg.of(userId, portfolioId, imageUrl, sequence,userUpdateDto.getUsername());
updatedPortfolioImgs.add(newImg);
}
updatedPortfolioImgs.sort(Comparator.comparing(UserPortfolioImg::getSequence));
userPortfolioRepository.deleteAllByUserId(userId);
return updatedPortfolioImgs;
}
이게 처음 봤던 코드이며 이후 객체에게 일을 시켰을 때의 코드는 다음과 같다.
@Override
@Transactional
public UserPortfolio updateUserPortfolioImages(final Long userId, UserUpdateDto userUpdateDto) {
Map<Integer, Object> changedPortfolioImages = userUpdateDto.getPortfolioImages();
List<UserPortfolioItem> userPortfolioItems = userPortfolioRepository.findAllByUserId(userId).stream()
.map(UserPortfolioImg::toModel).toList();
UserPortfolio userPortfolio = userPortfolioItems.isEmpty() ? UserPortfolio.withUserId(userId) :UserPortfolio.of(userPortfolioItems);
userPortfolio.addOrUpdatePortfolioImages(imageService.saveImagesS3WithSequence(changedPortfolioImages, userPortfolio, userUpdateDto.getUsername()));
userPortfolioRepository.deleteAllByUserId(userId);
return userPortfolio;
}
비즈니스 로직은 객체 내부에서 동작하며, 이는 객체 내부에서 무슨 동작을 하는지 알 수 없고 행위에 대한 책임만 외부에서 볼수 있다. 또한 비즈니스 로직이 객체 내부로 들어가고 객체가 능동적으로 동작하면서 외부에서는 DB에서 값을 가져오는 등의 외부와의 활동만 존재한다는것을 알수 있다. 또한 서비스 내부에서는 코드가 깔끔해진다.
다만 단점으로는 객체와 도메인 사이에 변경할 수 있는 매퍼 클래스를 따로 두거나 메서드를 추가해줘야 하며, 코드가 늘어날 수 있다는 것이 단점이지만 좀 더 객체지향적으로 변경하면서 도메인에 의존하는 서비스로 변경되었다.
4. 후기
이 책을 읽어본 뒤 객체지향이란? 에 대해서 좀 더 생각해 본 것 같다.
저자가 말하는 것은 각각의 패러다임에 따라 장단점이 있으며 너무 객체지향에만 빠져서 항상 모든 것을 객체지향적으로 생각하려고는 하지 않는 것을 말하고 있으며, 또한 책을 읽고 나서 객체지향이란 무엇인지 좀 더 깊게 이해할 수 있었다.
물론 위 코드도 다른 개발자들이 보기에 객체지향적이지 않을 수도 있지만 객체지향적으로 생각한다는 점에서 좋은 경험이었다고 생각한다.
그리고 초반에 개발을 잘하는 개발자란 무엇인가? 에 대해서 설명해 준 것이 있는데 간단히 요약해서 말하자면
개발자는 개발 + 기술이 합쳐져 소프트웨어를 만드는 사람인데 여기서 더 중요한 것은 개발이라는 것이다.
개발은 기술의 근간이 되는 CS 지식, OOP 등 몇십 년간에 걸쳐 변하지 않는 근본을 뜻하며, 기술은 Jpa , Spring , redis, Kafka 등의 좋은 최신 기술들을 뜻한다. 기술도 물론 중요하긴 하지만 개발을 잘하기 위해서는 "개발"이 중요하며 이러한 개발을 공부하는 것이 빠르게 변화하는 It 생태계에서 굳건히 버틸 수 있고 좋은 개발자라고 말하고 있다.
나도 취준하고 면접을 보면서 느꼈던 것은 결국 모든 것이 "개발"의 역할에 중심을 두고 물어본다고 생각하고 최근에는 기술보다는 개발에 좀 더 집중을 하고 공부를 하고 있다. (물론 구현을 하려면 기술이 필요하다...)
하지만 그럼에도 불구하고 개발을 잘하는 개발자는 기술에 상관없이 개발이든 기술이든 뭐든 잘한다고 느끼는 편이며
앞으로도 개발을 잘하는 개발자가 되기 위해 더욱 정진해 보도록 하겠다.
'Spring' 카테고리의 다른 글
Spring Boot 서비스 환경 스트레스 테스트 3 [Spring/Java] (0) | 2025.01.17 |
---|---|
Spring Boot 서비스 환경 스트레스 테스트 2 [Spring/Java] (0) | 2025.01.08 |
Spring Boot 서비스 환경 스트레스 테스트 [Spring/Java] (1) | 2025.01.04 |
Spring Boot 프로파일링 및 Stress Test [Spring] (0) | 2024.12.21 |
멀티모듈 프로젝트 Docker 빌드 전략: Path 기반 접근법[Docker&Github Action] (1) | 2024.12.14 |