중복된 코드의 문제
토비의 스프링 1장에서는 DAO의 관심사를 분리하고, 중복된 코드를 한 곳에 모아 상속을 통해 구현하는 방식을 설명합니다. 오래된 개념이지만 현재까지도 자주 사용되는 패턴입니다.
예를 들어 알림 발송 모듈을 만든다고 가정해봅시다. 아래 코드에서 사용자 조회와 로그 저장 로직이 각 메서드마다 반복됩니다.
만약 사용자 조회가 다른 마이크로서비스 호출로 변경되거나, 로그 저장소가 RDB에서 NoSQL로 바뀐다면 모든 메서드를 수정해야 합니다. 이것이 관심사가 분리되지 않았을 때 발생하는 문제입니다.
// 중복이 많은 초기 코드
public class NotificationService {
public void sendKakaoAlimtalk(String userId, String message) {
// 1. 사용자 조회
User user = userRepository.findById(userId);
if (user == null) throw new UserNotFoundException();
// 2. 발송
KakaoClient client = new KakaoClient("api-key-123");
client.sendAlimtalk(user.getPhone(), message);
// 3. 로그 저장
notificationLogRepository.save(new NotificationLog(userId, "KAKAO", message));
}
public void sendAppPush(String userId, String message) {
// 1. 사용자 조회 (중복)
User user = userRepository.findById(userId);
if (user == null) throw new UserNotFoundException();
// 2. 발송
FirebaseClient client = new FirebaseClient("fcm-key-456");
client.sendPush(user.getDeviceToken(), message);
// 3. 로그 저장 (중복)
notificationLogRepository.save(new NotificationLog(userId, "PUSH", message));
}
public void sendEmail(String userId, String message) {
// 1. 사용자 조회 (중복)
User user = userRepository.findById(userId);
if (user == null) throw new UserNotFoundException();
// 2. 발송
SmtpClient client = new SmtpClient("smtp.example.com");
client.sendMail(user.getEmail(), "알림", message);
// 3. 로그 저장 (중복)
notificationLogRepository.save(new NotificationLog(userId, "EMAIL", message));
}
}
중복 로직의 제거
일단은 유저를 조회하는 로직의 중복을 제거해봅니다. getUser라는 메서드를 통해 유저를 조회하는 메서드를 분리합니다. 이제는 유저를 조회하는 로직이 변경되어도 getUser라는 메서드만 변경하면 되기 때문에 변경으로 인한 영향이 3개의 메서드에서 하나로 줄었습니다.
public class NotificationService {
// 분리된 메서드
private User getUser(String userId) {
User user = userRepository.findById(userId);
if (user == null) throw new UserNotFoundException();
return user;
}
public void sendKakaoAlimtalk(String userId, String message) {
User user = getUser(userId);
KakaoClient client = new KakaoClient("api-key-123");
client.sendAlimtalk(user.getPhone(), message);
notificationLogRepository.save(new NotificationLog(userId, "KAKAO", message));
}
public void sendAppPush(String userId, String message) {
User user = getUser(userId);
FirebaseClient client = new FirebaseClient("fcm-key-456");
client.sendPush(user.getDeviceToken(), message);
notificationLogRepository.save(new NotificationLog(userId, "PUSH", message));
}
public void sendEmail(String userId, String message) {
User user = getUser(userId);
SmtpClient client = new SmtpClient("smtp.example.com");
client.sendMail(user.getEmail(), "알림", message);
notificationLogRepository.save(new NotificationLog(userId, "EMAIL", message));
}
}
상속의 활용
앞서 getUser() 메서드로 중복을 제거했지만, 각 알림 채널마다 필요한 사용자 정보가 다릅니다. 알림톡은 휴대폰 번호, 앱푸시는 디바이스 토큰, 이메일은 이메일 주소가 필요합니다. 이처럼 공통된 흐름은 같지만 세부 구현이 다른 경우, 상속을 활용할 수 있습니다.
알림 발송 추상 클래스
public abstract class NotificationService {
// 공통 흐름 (템플릿)
public void send(String userId, String message) {
String target = getTargetInfo(userId);
doSend(target, message);
saveLog(userId, getChannelType(), message);
}
// 하위 클래스가 구현할 부분
protected abstract String getTargetInfo(String userId);
protected abstract void doSend(String target, String message);
protected abstract String getChannelType();
private void saveLog(String userId, String channelType, String message) {
notificationLogRepository.save(new NotificationLog(userId, channelType, message));
}
}
카카오 알림톡 서비스
public class KakaoAlimtalkService extends NotificationService {
@Override
protected String getTargetInfo(String userId) {
return userPhoneRepository.findByUserId(userId).getPhone();
}
@Override
protected void doSend(String target, String message) {
new KakaoClient("api-key-123").sendAlimtalk(target, message);
}
@Override
protected String getChannelType() {
return "KAKAO";
}
}
앱푸시 서비스
public class AppPushService extends NotificationService {
@Override
protected String getTargetInfo(String userId) {
return userDeviceRepository.findByUserId(userId).getToken();
}
@Override
protected void doSend(String target, String message) {
new FirebaseClient("fcm-key-456").sendPush(target, message);
}
@Override
protected String getChannelType() {
return "PUSH";
}
}
이와 같이 구현하면서 이제 유저의 메타 정보를 조회하는데 로직을 수정하더라도 각각의 상속된 객체 안에 영향도로 좁힐 수 있게 되었고 이러한 디자인 패턴을 템플릿 메소드 패턴이라고 합니다. 템플릿 메소드 패턴은 관심 사항을 분리하고 서로 독립적으로 변경 또는 확장하는데 유리한 패턴입니다.
추가적으로 책에는 나와있지 않지만, 상속은 되도록 미루는 것이 좋습니다.
상속을 사용하면 상위 클래스와 강하게 결합되어, 상위 클래스가 변경될 때 모든 하위 클래스도 함께 변경해야 하기 때문입니다. 예를 들어 이메일과 앱푸시에만 벌크 발송 기능을 추가한다고 가정해봅시다. 이 기능을 상위 클래스에 추가하면 벌크 발송이 필요 없는 알림톡 서비스까지 영향을 받게 됩니다.
그리고 테스트의 작성과 단일 상속의 제약, 컴파일 시점에 관계가 고정되어 유연하게 동작을 변경할 수 없기 때문에 is-a 관계 즉 "A는 B이다."와 같은 관계를 가질 경우에 활용하는 것을 추천드립니다.
클래스의 분리
이제 관심사를 아예 분리하여 유저를 조회하는 클래스와 알림을 보내는 클래스와 분리를 해봅시다. 분리를 통해 이제 서로의 관심사가 명확해지고 책임의 범위도 명확해졌지만 문제점이 있습니다. 바로 UserFinder를 사용하는 클래스에서 다른 유저 정보를 필요로 할 때 매번 의존관계를 수정을 해줘야 한다는 점입니다. 이런 문제를 해결할 수 있는 것이 바로 인터페이스입니다.
// 유저 조회 클래스
public class UserFinder {
private final UserRepository userRepository;
public User find(String userId) {
User user = userRepository.findById(userId);
if (user == null) throw new UserNotFoundException();
return user;
}
}
// 알림 서비스 - UserFinder에 직접 의존
public class NotificationService {
private final UserFinder userFinder; // 구체 클래스에 의존
private final NotificationSender sender;
public NotificationService(UserFinder userFinder, NotificationSender sender) {
this.userFinder = userFinder;
this.sender = sender;
}
public void send(String userId, String message) {
User user = userFinder.find(userId);
sender.send(user.getPhone(), message);
}
}
인터페이스를 사용했을 때 의존하게 되면서 이제는 Service와 Finder는 서로의 구현체를 알 필요없이 확장을 할 수 있는 구조로 변경이 되었습니다. 예시로 RpcUserFinder라는 새로운 모듈이 추가되더라도 구현체는 알 필요없이 확장할 수 있는 구조로 변경이 되었습니다.
public interface UserFinder {
User find(String userId);
}
// 구현체 1: DB 조회
public class DbUserFinder implements UserFinder {
private final UserRepository userRepository;
@Override
public User find(String userId) {
User user = userRepository.findById(userId);
if (user == null) throw new UserNotFoundException();
return user;
}
}
// 구현체 2: API 조회
public class ApiUserFinder implements UserFinder {
private final RestTemplate restTemplate;
@Override
public User find(String userId) {
return restTemplate.getForObject("/users/" + userId, User.class);
}
}
// 인터페이스에 의존
public class NotificationService {
private final UserFinder userFinder; // 인터페이스에 의존
private final NotificationSender sender;
public NotificationService(UserFinder userFinder, NotificationSender sender) {
this.userFinder = userFinder;
this.sender = sender;
}
public void send(String userId, String message) {
User user = userFinder.find(userId);
sender.send(user.getPhone(), message);
}
}
이전에 첫번째로 작성했던 코드와 비교해보면 확실히 어떤 클래시의 책임과 역활이 있는지 명확해졌고 코드를 변경 및 수정할 때 이전에 한곳에서 모든 코드를 수정했던 것과 다르게 수정되는 범위가 명확해지고 다른 코드에 전혀 영향을 주지 않은 채로 외부에서 흐름을 제어할 수 있는 것을 확인할 수 있습니다.
// 사용 시 원하는 구현체 주입
NotificationService dbService = new NotificationService(
new DbUserFinder(userRepository),
new KakaoSender()
);
NotificationService apiService = new NotificationService(
new ApiUserFinder(restTemplate),
new KakaoSender()
);
객체지향은 결국 변경으로 시작된다.
SOLID 원칙, 높은 응집성, 낮은 결합도. 이 개념들의 공통된 목적은 코드 변경으로부터 안전하게 보호하는 것입니다.
앞서 구현한 전략 패턴을 다시 살펴봅시다. DbUserFinder의 조회 로직을 수정하더라도 NotificationService는 전혀 영향을 받지 않습니다. 각자의 책임 범위 안에서만 변경이 일어나기 때문입니다.
처음에는 코드 양이 적어 와닿지 않을 수 있습니다. 하지만 하나의 클래스가 수천, 수만 줄이 된다면 어떨까요?
- 한 줄을 수정했을 때 다른 코드에 영향이 없다고 확신할 수 있을까요?
- 수많은 if문 사이에서 실제로 사용되는 코드와 죽은 코드를 구분할 수 있을까요?
만약 코드에서 안 좋은 냄새가 나고, 작업할 때마다 불안함이 느껴진다면 구조를 의심해볼 때입니다. 어떤 코드를 함께 묶어야 할지, 어떻게 서로를 모르게 분리할지 고민해보시기 바랍니다.

'spring' 카테고리의 다른 글
| 토비의 스프링 정복하기 3장 - 테스트의 필요성 (0) | 2026.01.14 |
|---|---|
| 토비의 스프링 정복하기 2편 - 제어의 역전 (1) | 2026.01.11 |