클린 코드를 향한 여정: BandRoom 서비스 리팩토링으로 얻은 3가지 교훈
저희 프로젝트에서 BandRoom 관련 기능을 리팩토링하며 얻은 경험을 공유하고자 합니다. 처음에는 MVP구현을 위해 "그냥 동작하니까"라며 넘어갔던 코드들이 점차 새로운 기능을 추가하거나 수정하기 어렵게 만들고 있었습니다.
이번 리팩토링의 목표는 명확했습니다. 누가 읽어도 이해하기 쉬운 코드, 변경이 두렵지 않은 유연한 코드를 만드는 것이었습니다. 이 과정을 통해 얻게 된 3가지 핵심적인 개선 포인트와 그 효과를 기존 코드(Before)와 개선된 코드(After)를 비교하며 이야기해 보겠습니다.
1. 엔티티는 단순한 데이터 덩어리가 아니다: 로직을 품은 전문가로
가장 먼저 눈에 들어온 것은 서비스 레이어에 비대하게 집중된 비즈니스 로직이었습니다.
Before: 서비스 레이어의 과도한 책임
합주실 정보를 수정하는 로직이 서비스 클래스 안에 그대로 노출되어 있었습니다.
// BandRoomService.java (Before)
public void updateBandRoom(Long bandRoomId, BandRoomUpdateRequest request) {
// 1. 엔티티 조회
BandRoom bandRoom = bandRoomRepository.findById(bandRoomId).orElseThrow(...);
// 2. 서비스 레이어에서 직접 엔티티의 필드를 하나씩 수정 (!! 문제점)
bandRoom.setRoomName(request.getRoomName());
bandRoom.setCategory(request.getCategory());
bandRoom.setRoomDescription(request.getRoomDescription());
bandRoom.setParkable(request.isParkable());
// ... 수많은 set 메서드 호출 ...
// 3. 주소 정보까지 서비스에서 직접 처리
if (bandRoom.getAddress() != null && request.getAddress() != null) {
bandRoom.getAddress().setPostalCode(request.getAddress().getPostalCode());
// ... 주소 관련 set 메서드 호출 ...
}
// ...
}
문제점:
- 캡슐화 위반: 서비스가
BandRoom엔티티의 내부 구조를 너무 자세히 알고 있습니다. 필드가 하나 추가되거나 변경되면 서비스 코드를 직접 수정해야 합니다. - 가독성 저하:
set메서드가 나열되어 있어, "합주실 정보를 수정한다"는 핵심 비즈니스 로직을 한눈에 파악하기 어렵습니다. - 응집력 부족: 데이터(엔티티)와 그 데이터를 처리하는 로직(서비스)이 분리되어 코드의 응집력이 떨어집니다.
After: 엔티티에게 책임을 위임하다
이러한 로직을 엔티티 내부의 의미 있는 메서드로 옮겼습니다.
// BandRoom.java (After) - 엔티티 내부에 비즈니스 메서드 추가
public void updateInfo(String roomName, RoomCategory category, ...) {
this.roomName = roomName;
this.category = category;
// ... 내부 필드 변경 로직 ...
this.updatedAt = LocalDateTime.now();
if (this.address != null && addressInfo != null) {
this.address.updateDetails(...); // Address 엔티티도 스스로 정보를 수정
}
}
// BandRoomService.java (After) - 서비스는 이제 명령만 내린다
public void updateBandRoom(Long bandRoomId, BandRoomUpdateRequest request) {
BandRoom bandRoom = bandRoomRepository.findById(bandRoomId).orElseThrow(...);
// "Tell, Don't Ask" 원칙: 묻지 말고, 시켜라!
bandRoom.updateInfo(
request.getRoomName(), request.getCategory(), ...,
bandRoomMapper.toEntity(request.getAddress()) // 매핑은 매퍼에게
);
}
개선 효과:
- 객체지향 설계 강화: 엔티티가 자신의 데이터는 스스로 책임지고 변경하는, 진정한 '객체'가 되었습니다.
- 서비스 로직 간소화: 서비스는 이제
bandRoom.updateInfo()를 호출하기만 하면 됩니다. 코드가 놀랍도록 깔끔해지고 "무엇을 하는지"가 명확하게 보입니다. - 유지보수성 향상: 합주실 정보 수정 정책이 바뀌면
BandRoom엔티티만 수정하면 되므로, 변경의 영향 범위가 명확해지고 줄어듭니다.
2. 반복적인 변환 작업은 매퍼(Mapper)에게
DTO(Data Transfer Object)를 엔티티로, 혹은 엔티티를 이벤트 객체로 변환하는 코드가 여러 곳에 흩어져 있었습니다.
Before: 서비스 곳곳에 산재한 변환 로직
새로운 합주실을 생성할 때, DTO의 필드를 엔티티로 옮기는 작업을 서비스가 직접 처리했습니다.
// BandRoomService.java (Before)
public void createBandRoom(BandRoomCreateRequest request) {
BandRoom bandRoom = BandRoom.builder()
.ownerId(request.ownerId())
.roomName(request.bandRoomName())
// ... 수많은 빌더 체인 ...
.build();
// 주소 변환 로직까지 서비스에 포함
if (request.address() != null) {
Address address = new Address();
address.setRoadAddress(request.address().roadAddress());
// ...
bandRoom.setAddress(address);
}
bandRoomRepository.save(bandRoom);
}
문제점:
- 단일 책임 원칙(SRP) 위반:
BandRoomService는 비즈니스 로직 처리와 데이터 변환이라는 두 가지 책임을 가지고 있습니다. - 코드 중복: 만약 다른 곳에서도 유사한 변환이 필요하다면, 이 지저분한 코드를 복사-붙여넣기 해야 합니다.
After: 데이터 변환 전문가, 매퍼의 등장
데이터 변환만을 전담하는 BandRoomMapper와 BandRoomEventMapper 컴포넌트를 만들었습니다.
// BandRoomMapper.java (After)
@Component
public class BandRoomMapper {
public BandRoom toEntity(BandRoomCreateRequest request, long newRoomId) {
// ... DTO -> Entity 변환 로직 ...
}
// ... 다른 변환 메서드들 ...
}
// BandRoomService.java (After)
@RequiredArgsConstructor
public class BandRoomService {
private final BandRoomRepository bandRoomRepository;
private final BandRoomMapper bandRoomMapper; // 매퍼 주입
public void createBandRoom(BandRoomCreateRequest request) {
long newRoomId = snowflake.nextId();
// 서비스는 이제 변환 과정을 전혀 신경쓰지 않는다.
BandRoom bandRoom = bandRoomMapper.toEntity(request, newRoomId);
bandRoomRepository.save(bandRoom);
// ...
}
}
개선 효과:
- 책임의 명확한 분리: 서비스는 비즈니스 흐름에, 매퍼는 데이터 변환에만 집중하여 코드를 이해하고 테스트하기 쉬워졌습니다.
- 재사용성 극대화:
BandRoomMapper는 이제 어떤 서비스에서든 주입하여 사용할 수 있는 재사용 가능한 컴포넌트가 되었습니다. - 가독성 혁신:
bandRoomMapper.toEntity(request)한 줄이 수십 줄의 빌더 코드를 대체하여 서비스 로직이 한눈에 들어옵니다.
3. 더 나은 코드를 향한 디테일: Optional과 빌더 패턴
좋은 코드를 '더 멋진' 코드로 만드는 마지막 한 걸음이 남았습니다. 바로 null 처리와 객체 생성 방식의 세련미를 더하는 것입니다.
Before: 전통적인 null 체크와 불안정한 빌더 사용
매퍼 내부에서 if (object != null)과 같은 고전적인 null 체크를 사용했습니다.
// BandRoomEventMapper.java (Before)
public BandRoomInfoUpdateEvent toBandRoomInfoUpdateEvent(BandRoom bandRoom) {
var event = BandRoomInfoUpdateEvent.builder()
.bandRoomId(bandRoom.getRoomId())
// ...
.build(); // 먼저 빌드하고
if (bandRoom.getAddress() != null) { // 나중에 set으로 필드 추가
event.setUnionAddress(bandRoom.getAddress().getUnionAddress());
// ...
}
return event;
}
문제점:
null을 다루는 코드가 장황하고, 실수로NullPointerException을 유발할 여지가 있습니다.build()이후에set메서드를 호출하는 것은 빌더 패턴이 추구하는 '불변 객체 생성'의 이점을 살리지 못합니다.
After: Optional과 완전한 빌더 패턴으로 변환
Java 8+의 Optional을 사용하여 null 처리를 더 선언적이고 안전하게 바꿨습니다.
// BandRoomEventMapper.java (After)
public BandRoomInfoUpdateEvent toBandRoomInfoUpdateEvent(BandRoom bandRoom) {
var eventBuilder = BandRoomInfoUpdateEvent.builder()
// ... 기본 정보 설정 ...
.phoneNumber(bandRoom.getPhoneNumber());
// "주소가 존재하면(ifPresent), 이 로직을 실행해줘"
Optional.ofNullable(bandRoom.getAddress())
.ifPresent(address -> eventBuilder
.unionAddress(address.getUnionAddress())
// ... 주소 정보 빌더에 추가 ...
);
return eventBuilder.build(); // 모든 정보가 설정된 후 마지막에 빌드
}
개선 효과:
- 코드의 표현력 증가: 코드가 '만약
가 null이 아니면' 이라는 명령형 대신, '**가 존재할 경우 이 작업을 수행한다**'는 선언형으로 바뀌어 의도가 명확해졌습니다. - Null 안정성:
Optional은null일 수 있는 값을 감싸NullPointerException으로부터 코드를 보호하는 효과적인 수단입니다. - 진정한 불변성: 모든 필드를 설정한 뒤
build()를 마지막에 호출함으로써, 생성 시점에 완전한 상태를 갖는 불변 객체를 만들어 코드의 안정성을 높였습니다.
결론
이번 리팩토링은 단순히 코드를 예쁘게 만드는 작업이 아니었습니다. 객체지향의 원칙(SRP, 캡슐화)을 지키고, 코드의 각 부분이 명확한 책임을 갖도록 재설계하는 과정이었습니다. 그 결과 우리는 다음과 같은 귀중한 자산얻었습니다.
- 가독성과 유지보수성의 비약적인 향상
- 변경에 유연하고 확장에 용이한 소프트웨어 구조
- 버그 발생 가능성을 줄여주는 견고한 코드
'BindProject' 카테고리의 다른 글
| 스튜디오 예약 시스템의 설계와 구현 : 유연한 운영 시간 설계와 MSA의 시너지 (5) | 2025.07.27 |
|---|---|
| 클린코드를 위한 여정 : 빈혈증에 걸린 도메인 모델(Anemic Domain Model) 처리하기: USER_PROFILE (3) | 2025.07.26 |
| 이미지 모듈 리팩토링 여정 (3) | 2025.07.24 |
| 페이지네이션 완전 정복: `COUNT` 내 서비스에 맞는 최적의 전략 찾기 (1) | 2025.07.24 |
| 전역 에러 핸들러로 try-catch를 걷어내기(2)_모듈간 참조 해결 (0) | 2025.07.23 |