들어가며
현재 사내에서 서비스중인 앱의 백엔드 코드에서 중복되는 코드가 심하게 일어나는 부분이 있었다.
회원과 관련된 추가정보(경력, 학력, 논문 등)들이 총 7개가 있는데 이 7개의 도메인들의 CRUD 로직이 100% 동일하다는 것에 템플릿 메서드 패턴을 적용하여 리팩토링을 해보면 어떨까 생각이 들었다.
추가정보 도메인 총 7개의 기존 서비스 로직의 코드 라인에는 생성, 조회, 리스트조회, 수정, 일괄 수정, 삭제, 일괄 삭제, 소프트 삭제, 삭제복원을 포함하여 약 210줄의 코드가 동일한 로직으로 중복되고 있었다.
문제 상황 분석
중복 코드의 심각성
7개의 서비스 클래스가 거의 동일한 구조를 가지고 있었다. 예를 들어 경력(Career) 서비스와 학력(Education) 서비스를 비교해보면 다음과 같았다.
해당 포스팅의 코드는 보안상 실제 서비스 코드 대신 단순화된 코드로 작성되었습니다.
// CareerService.java
@Service
@RequiredArgsConstructor
public class CareerService {
private final CareerRepository careerRepository;
private final UserRepository userRepository;
@Transactional
public CareerResponseDto createCareer(Long userId, CareerCreateRequest dto) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new NotFoundException("사용자를 찾을 수 없습니다"));
// 더미 데이터 삭제
careerRepository.deleteByUserIdAndIsDummyTrue(userId);
if (dto.isDummy()) {
Career dummy = dto.toDummyEntity(user);
dummy.markAsDummy();
return CareerResponseDto.from(careerRepository.save(dummy));
}
Career career = dto.toEntity(user);
return CareerResponseDto.from(careerRepository.save(career));
}
@Transactional(readOnly = true)
public List<CareerResponseDto> getCareers(Long userId) {
return careerRepository.findAllByUserId(userId).stream()
.map(CareerResponseDto::from)
.toList();
}
// ... 200여 줄의 추가 메서드들
}
// EducationService.java
@Service
@RequiredArgsConstructor
public class EducationService {
private final EducationRepository educationRepository;
private final UserRepository userRepository;
@Transactional
public EducationResponseDto createEducation(Long userId, EducationCreateRequest dto) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new NotFoundException("사용자를 찾을 수 없습니다"));
// 더미 데이터 삭제
educationRepository.deleteByUserIdAndIsDummyTrue(userId);
if (dto.isDummy()) {
Education dummy = dto.toDummyEntity(user);
dummy.markAsDummy();
return EducationResponseDto.from(educationRepository.save(dummy));
}
Education education = dto.toEntity(user);
return EducationResponseDto.from(educationRepository.save(education));
}
@Transactional(readOnly = true)
public List<EducationResponseDto> getEducations(Long userId) {
return educationRepository.findAllByUserId(userId).stream()
.map(EducationResponseDto::from)
.toList();
}
// ... 200여 줄의 추가 메서드들
}
두 서비스의 로직이 완전히 동일하다는 것을 알 수 있었다. 단지 엔티티 타입과 DTO 타입만 다를 뿐이었다.
중복으로 인한 문제점
하나의 로직을 수정하면 7개 파일을 모두 수정해야 한다. 예를 들어 로직에 추가적인 기능의 메서드 호출이 들어가야 한다면 추가정보 도메인 7개의 서비스로직에 모두 포함시켜야 한다. 이로써 유지보수가 매우 힘들어진다.
1,470줄(210줄 * 7)의 중복 코드가 프로젝트를 비대하게 만든다.
템플릿 메서드 패턴이란?
템플릿 메서드 패턴은 알고리즘의 구조는 상위 클래스에서 정의하고, 구체적인 구현은 하위 클래스에서 하도록 하는 디자인 패턴이다.
핵심 개념
public abstract class AbstractClass {
// 템플릿 메서드 - 알고리즘의 골격을 정의
public final void templateMethod() {
step1();
step2();
step3();
}
// 추상 메서드 - 하위 클래스에서 구현
protected abstract void step1();
protected abstract void step2();
protected abstract void step3();
}
public class ConcreteClass extends AbstractClass {
@Override
protected void step1() {
// 구체적인 구현
}
@Override
protected void step2() {
// 구체적인 구현
}
@Override
protected void step3() {
// 구체적인 구현
}
}
이 패턴의 장점은 다음과 같다.
중복 코드 제거
알고리즘 구조의 일관성 유지
변경에는 닫혀있고 확장에는 열려있는 구조 (OCP 원칙 준수)
리팩토링 설계
1. 공통 로직과 가변 로직 분리
먼저 서비스들의 로직을 분석하여 공통 로직과 가변 로직을 다음과 같이 구분했다.
공통 로직 (템플릿 메서드로 구현)
- 사용자 존재 확인
- 더미 데이터 삭제
- 엔티티 저장 및 조회
- 트랜잭션 관리
- 첨부파일 처리.
가변 로직 (추상 메서드로 정의)
- DTO to Entity 변환
- Entity to DTO 변환
- 엔티티 업데이트 로직
- 도메인별 특화 검증 로직.
2. 제네릭 타입 설계
7개의 서로 다른 엔티티와 DTO를 처리하기 위해 제네릭을 활용했다.
public abstract class BaseAdditionalInfoService<
T extends BaseEntity, // 엔티티 타입
CreateReqDto, // 생성 요청 DTO
UpdateReqDto, // 수정 요청 DTO
BulkUpdateReqDto, // 일괄 수정 요청 DTO
UpdateItemDto, // 일괄 수정 아이템 DTO
GetResDto, // 조회 응답 DTO
CreateResDto // 생성 응답 DTO
> {
// 공통 로직 구현
}
7개의 제네릭 타입 파라미터를 사용하게 된 이유는 각 도메인마다 생성/수정/조회 DTO가 모두 다르고 일괄 수정의 경우 별도의 DTO 구조가 필요하기 때문이며, 타입 안정성을 보장하기 위해서 사용했다.
구현 과정
1단계: 추상 클래스 생성
먼저 BaseAdditionalInfoService 추상 클래스를 생성하고 필요한 추상 메서드들을 정의했다.
public abstract class BaseAdditionalInfoService<
T extends BaseEntity,
CreateReqDto,
UpdateReqDto,
BulkUpdateReqDto,
UpdateItemDto,
GetResDto,
CreateResDto
> {
protected final UserRepository userRepository;
protected final AdditionalInfoRepository<T> repository;
// 생성 관련 추상 메서드
protected abstract T mapDtoToEntity(CreateReqDto dto, User user);
protected abstract T mapDtoToDummyEntity(CreateReqDto dto, User user);
protected abstract CreateResDto mapEntityToCreateResponse(T entity);
protected abstract Long getAttachmentId(CreateReqDto dto);
protected abstract boolean isDummy(CreateReqDto dto);
// 조회 관련 추상 메서드
protected abstract GetResDto mapEntityToResponse(T entity);
// 수정 관련 추상 메서드
protected abstract void updateEntity(T entity, UpdateReqDto dto);
protected abstract Long getNewAttachmentIdFromUpdate(UpdateReqDto dto);
// 일괄 수정 관련 추상 메서드
protected abstract List<UpdateItemDto> getUpdateItems(BulkUpdateReqDto dto);
protected abstract Long getItemId(UpdateItemDto item);
protected abstract void updateEntityFromItem(T entity, UpdateItemDto item);
protected abstract Long getNewAttachmentIdFromItem(UpdateItemDto item);
}
총 12개의 추상 메서드를 정의해주었다. 이 메서드들이 각 도메인별로 달라지는 부분이다.
2단계: 템플릿 메서드 구현
공통 로직을 템플릿 메서드로 구현한다. 생성 로직을 예로 들면 아래 코드와 같다.
@Transactional
public CreateResDto create(Long userId, CreateReqDto dto) {
// 1. 사용자 존재 확인 (공통)
User user = userRepository.findById(userId)
.orElseThrow(() -> new NotFoundException("사용자를 찾을 수 없습니다"));
// 2. 더미 데이터 삭제 (공통)
repository.deleteByUserIdAndIsDummyTrue(userId);
// 3. 더미 데이터 분기 처리 (공통)
if (isDummy(dto)) { // 추상 메서드 호출
T dummyEntity = mapDtoToDummyEntity(dto, user); // 추상 메서드 호출
dummyEntity.markAsDummy();
T saved = repository.save(dummyEntity);
return mapEntityToCreateResponse(saved); // 추상 메서드 호출
}
// 4. 실제 데이터 생성 (공통)
T entity = mapDtoToEntity(dto, user); // 추상 메서드 호출
T saved = repository.save(entity);
// 5. 첨부파일 처리 (공통)
Long attachmentId = getAttachmentId(dto); // 추상 메서드 호출
if (attachmentId != null) {
handleAttachmentCreation(saved.getId(), attachmentId);
}
return mapEntityToCreateResponse(saved); // 추상 메서드 호출
}
이런 식으로 전체 알고리즘의 흐름은 상위 클래스에서 제어하고 구체적인 동작만 하위 클래스에 위임해준다.
3단계: 하위 클래스 구현
아래처럼 각 도메인 서비스에서 추상 메서드들을 구현한다.
@Service
public class CareerService extends BaseAdditionalInfoService<
Career,
CareerCreateRequest,
CareerUpdateRequest,
CareerBulkUpdateRequest,
CareerBulkUpdateRequest.CareerUpdateItem,
CareerResponseDto,
CareerCreateResponseDto
> {
public CareerService(
AttachmentHelper attachmentHelper,
UserRepository userRepository,
CareerRepository careerRepository
) {
super(attachmentHelper, userRepository, careerRepository);
}
@Override
protected Career mapDtoToEntity(CareerCreateRequest dto, User user) {
return Career.builder()
.user(user)
.company(dto.company())
.position(dto.position())
.startDate(dto.startDate())
.endDate(dto.endDate())
.build();
}
@Override
protected Career mapDtoToDummyEntity(CareerCreateRequest dto, User user) {
return Career.builder()
.user(user)
.company("더미 회사")
.position("더미 직책")
.build();
}
@Override
protected CareerResponseDto mapEntityToResponse(Career career) {
return CareerResponseDto.from(career);
}
// 나머지 9개의 추상 메서드 구현...
}
이제 각 서비스는 도메인별 특화 로직만 구현하면 된다. 공통 로직은 상위 클래스가 처리한다.
4단계: 컨트롤러 수정
컨트롤러에서는 기존 메서드명 대신 공통 메서드명을 사용하도록 변경해준다.
@RestController
@RequestMapping("/api/careers")
@RequiredArgsConstructor
public class CareerController {
private final CareerService careerService;
@PostMapping
public ResponseEntity<ApiResponse<CareerCreateResponseDto>> createCareer(
@AuthenticationPrincipal CustomUserDetails userDetails,
@Valid @RequestBody CareerCreateRequest request
) {
// 기존: careerService.createCareer(userId, request)
// 변경: careerService.create(userId, request)
CareerCreateResponseDto response = careerService.create(userDetails.getId(), request);
return ResponseEntity.ok(ApiResponse.success(response));
}
@GetMapping
public ResponseEntity<ApiResponse<List<CareerResponseDto>>> getCareers(
@AuthenticationPrincipal CustomUserDetails userDetails
) {
// 기존: careerService.getCareers(userId)
// 변경: careerService.getAll(userId)
List<CareerResponseDto> response = careerService.getAll(userDetails.getId());
return ResponseEntity.ok(ApiResponse.success(response));
}
}
발생한 문제와 해결
Spring 트랜잭션과 final 키워드 충돌
처음에는 템플릿 메서드를 final로 선언하여 하위 클래스에서 오버라이드하지 못하도록 막았었다.
public abstract class BaseAdditionalInfoService<...> {
@Transactional
public final CreateResDto create(Long userId, CreateReqDto dto) {
}
}
하지만 이렇게 하면 다음과 같은 컴파일 에러가 발생한다.
Methods annotated with '@Transactional' must be overridable
원인
스프링의 @Transactional은 프록시 패턴을 사용하여 동작한다. 런타임에 원본 클래스를 상속받은 프록시 객체를 생성하여 메서드를 오버라이드하는데, final 메서드는 오버라이드할 수 없기 때문에 문제가 발생했다.
해결final 키워드를 제거하고, javadoc 주석을 통해 “이 메서드는 오버라이드하지 않아야 한다"는 것을 명시해주었다.
/**
* 추가정보 데이터를 생성합니다. (오버라이드 금지)
* 더미 데이터인 경우 기존 더미를 삭제하고 새로 생성합니다.
* * @param userId 사용자 ID * @param dto 생성 요청 DTO * @return 생성된 추가정보 응답DTO */
@Transactional
public CreateResDto create(Long userId, CreateReqDto dto) {
// ...
}
일반적으로는 구현체에서 템플릿 메서드를 오버라이딩하여 임의로 재정의 하는 것을 막기위해 final 키워드 사용이 권장사항이라고 한다.
결과
정량적 효과
| 항목 | 리팩토링 전 | 리팩토링 후 | 개선율 |
|---|---|---|---|
| 서비스 코드 라인 수 (각) | ~210줄 | ~50줄 | 76% 감소 |
| 전체 코드 라인 수 | ~1,470줄 | ~350줄 | 76% 감소 |
| 중복 로직 제거 | 0% | 100% | - |
| 공통 메서드 수 | 0개 | 9개 | - |
정성적 효과
유지보수성 향상
- 공통 로직 수정 시 한 곳만 수정하면 된다.
- 새로운 추가정보 도메인 추가 시 12개의 추상 메서드만 구현하면 됨.
코드 일관성 확보
- 모든 추가정보 서비스가 동일한 메서드명을 사용한다. (
create,getAll,update등)
테스트 코드 개선
- BaseAdditionalInfoService의 공통 로직은 한 번만 테스트하면 된다.
- 각 서비스는 도메인별 특화 로직만 테스트
확장성
- 7개의 추가정보 도메인에 대한 공통되는 기능을 새로 구현할 때, 해당 템플릿 메서드만 구현하면 된다.
마치며
210줄의 중복 코드를 50줄로 줄이는 리팩토링을 진행하면서, 디자인 패턴의 실용적 가치를 체감할 수 있었다. 단순히 코드 줄 수를 줄이는 것을 넘어서 유지보수하기 쉬운 코드, 확장하기 쉬운 구조, 일관성 있는 API
를 만들 수 있었다고 생각한다.
물론 과도한 추상화는 오히려 코드를 복잡하게 만들 수 있다. 하지만 명확한 중복이 있고, 알고리즘의 구조가 동일하다면 템플릿 메서드 패턴은 매우 훌륭한 선택지가 될 수 있다.