고민의 시작
백기선님의 이펙티브 자바 완벽 공략의 “아이템 15. 클래스와 멤버의 접근 권한을 최소화하라”에는
😄 : “public, protected 접근 제한자를 가지는 API는 하위호환성을 지키고 싶다면 영원히 관리해야하는 코드가 된다”
라는 말이 나옵니다.
응? 나는 스프링에서 지금까지 모든 클래스를 거의 public으로 박아놨는데….
라는 생각이 들었습니다.
여기서 하위호환성을 지킨다는 것은 버전이 올라갈 수록 이전 jar 파일로 배포해두었던 public으로 노출시킨 나의 코드를 누가 언제 어디서
쓰고 있는지 모르기 때문에 다음버전에서도 쉽게 바꾸지 못한다는 것을 의미합니다.
만약 바꾼다면 클라이언트에서 해당 코드에 해당하는 부분을 모두 바꾸어야하는 문제가 생깁니다.
그리고 말하시길
😄 : “정답은 없다. 책에서는 패키지 내에서만 쓸 거라면 public 대신 package-private을 사용하자라고 말하고 있다.”
한번 스프링에서 클래스의 접근 제한자는 어떻게 하는게 좋을지 생각을 해보고 service 레이어에서 인터페이스 적용건에 대해서도 이야기해보겠습니다.
‘기선’님의 spring 프로젝트 내 접근제한자
강의에선 기선님이 생각하시는 entity, service 구현체, service 인터페이스의 접근 제한자 범위를 이야기해주셨습니다.
Member Entity
public class Member
MemberService.java
public interface MemberService
MemberServiceImpl.java
class MemberServiceImpl implements MemberService
MemberService의 구현체는 package-private으로 접근제한자를 하고 있는 모습입니다.
구현체는 외부에 노출하지 않고 클라이언트 코드는 service의 인터페이스만 참고할 수 있게 내부구현은 은닉하고 있습니다.
이로써 코드 변경에 좀 더 유연하게 관리할 수 있습니다.
전체적인 이야기로 보았을 때 패키지 내에서만 사용하는 클래스는 package-private으로 하는 것이 좋다. 라고 생각이 됩니다.
추가고민 - Dto는 어떻게 해야할까?
아래와 같은 Dto 클래스가 있다고 합시다.
public record MemberRequest(
String name,
String email
) {
}
해당 record는 사용하는 범위가 어떻게 될까요?
코드 상으로는 member 패키지 내에서만 쓰이고 있습니다.
그럼 package-private으로 해서 아래와 같이 쓰면 안될까요?
record MemberRequest(
String name,
String email
) {
}
MemberRequest는 코드상으로는 member 패키지에서만 쓰이지만 사실 JSON으로 serialization하여 클라이언트까지 전송되는 데이터입니다.
따라서 package-private을 하여도 외부에 공개된 api이기 때문에 관리해줘야하는 코드입니다.
package-private의 장점이 없으므로 public으로 쓰는 것이 좋아보입니다.
제가 생각하는 dto의 접근제한자를 public으로 하면 생기는 장점 중 도메인 내에서 패키지를 분리해도 괜찮다는 점이 있습니다.
위와 같이 말이죠!
Service 인터페이스 적용해야할까?
Spring 프로젝트에서 service 레이어에서 인터페이스를 도입하냐 아니냐는 spring 개발자라면 한번쯤 해보셨을 것 같습니다.
사실 많은 프로젝트에서는 service를 인터페이스로 추상화할 만큼 service의 코드 변경이 일어나지 않아 이걸 굳이 interface까지 만들면 복잡성만 올라가는거 아닐까? 라는 생각이 듭니다.
물론 저도 지금까지는 그렇게 생각해서 serviceImpl과 service를 구분해서 구현하지 않았습니다.
하지만 장기적인 서비스 운영 목표를 가지고 프로젝트를 시작하게 되었고 코드 유지보수에 대해서 고민하게 되었습니다.
🤔 고민 지점 장기적인 관점으로 봤을 때 service 인터페이스의 도입이 필요하지 않을까?
관점1. service 코드 한번 구현하면 잘 안바꾸지않아?
관점2. 서비스 버전이 올라가다보면 service 구현체도 버전이 여러개 생기지 않을까?
고민 결과
서비스 인터페이스 도입은 크게 3가지 관점에서 볼 수 있을 것 같습니다.
- 서비스 구현 변동성
- 개발 인원과 코드 의존성
- 구현클래스의 다형성
1. 구현 변동성
인터페이스를 도입할 때 우리 서비스의 버전이 업데이트거나 유지보수가 없을 것 같다면 사실 인터페이스를 쓰는건 코드의 복잡도를 올리는 오버헤드가 될 수 있습니다.
또 서비스가 지속적으로 버저닝이 이루어지고 구현이 바뀔 가능성이 높다고 인터페이스를 도입하기에는 너무 미리부터 고민하는 듯한 느낌도 듭니다.
YAGNI(You Aren't Gonna Need It) 처럼 미래에 필요할 수도 있는 기능을 추가하지 말라는 개발원칙도 있는 것처럼요.
인터페이스가 필요해지는 타이밍에 도입을 해도 늦지 않다고 생각이 듭니다.
그럼 언제가 인터페이스가 필요해지는 타이밍일까? 생각이 들었습니다. 🤔
버전 업데이트에서의 service 인터페이스 도입
ControllerV1
@RestController
@RequiredArgsConstructor
public class ControllerV1 {
private final ServiceV1 service;
}
위와 같은 ServiceV1 구현체를 ControllerV1이 직접 의존하고 있다고 합시다.
이렇게 되면 해당 controller의 다형성도 적다고 할 수 있으며 ServiceV1 → ServiceV2 로 변경이 일어난다면 OCP(개방폐쇄 원칙)도 위반하고 있다고 할 수 있습니다.
이는 분명 좋은 설계는 아닙니다.
인터페이스 만들기
위와 같이 설계한다면 OCP 원칙을 지킬 수 있을 것 같습니다.
하지만 여기에도 문제가 있습니다.
ServiceV2Impl은 ServiceV1에서 이미 정의한 메서드만 재정의할 수 있다는 것 입니다.
버전을 업데이트하면서 새로운 메서드가 생길 수도 있는데 말이죠.
그렇다고 ServiceV1 인터페이스에 새로운 메서드를 추가하면 이전 버전인 ServiceImplV1에서도 해당 버전과 관련없는 메서드를 강제로 정의해야합니다.
인터페이스에 메서드가 추가된다면?
인터페이스 상속
service에 외부에 공개하는 메서드가 추가로 생긴다는 것은 controller에도 새로운 api가 추가된다고 볼 수 있습니다.
따라서 ControllerV2를 추가했습니다.
ControllerV2
@RestController
@RequiredArgsConstructor
public class ControllerV2 {
private final ServiceV2 serviceV2;
}
ServiceV2
public interface ServiceV2 extends ServiceV1 {
}
serviceV2 인터페이스가 ServiceV1 인터페이스를 상속합니다.
ServiceV2Impl
@Service
@RequiredArgsConstructor
public class ServiceV2Impl implements ServiceV2 {
private final Repository repository;
}
하지만 이렇게 하니 ServiceV2Impl이 ServiceV1 인터페이스의 모든 메서드를 구현해야하고 변경이 없다면 코드 중복이 발생하는 단점이 있습니다.
클래스 상속
그럼 ServiceV2Impl이 ServiceV1Impl을 상속하면 되지 않을까요?
public interface ServiceV1 {
void 기능1();
void 기능2();
}
class ServiceV1Impl implements ServiceV1 {
@Override
void 기능1() {}
@Override
void 기능2() {}
}
public interface ServiceV2 {
void 기능3(); // V2에 새롭게 추가된 메서드
}
class ServiceV2Impl extends ServiceV1Impl implements ServiceV2 {
@Override
void 기능3() {}
}
하지만 이러면 유연성 및 확장성이 떨어진다는 단점이 있습니다.
ServiceV2에서 개발을 하다보니 ServiceV1Impl의 메소드중 일부가 변경되어야한다고 해봅시다.
public interface ServiceV1 {
void 기능1();
void 기능2();
}
class ServiceV1Impl implements ServiceV1 {
@Override
void 기능1() {}
@Override
void 기능2() {}
}
public interface ServiceV2 {
void 기능3(); // V2에 새롭게 추가된 메서드
}
class ServiceV2Impl extends ServiceV1Impl implements ServiceV2 {
@Override
void 기능1() {
//코드 변경
}
@Override
void 기능3() {}
}
위와 같이 기능1() 다시 오버라이드하여 재정의를 해줘야할 겁니다.
여기서부터 코드 재사용성을 일부 살리겠다고 유연성을 포기하고 구현 클래스간 결합도를 높혔다는 생각이 듭니다.
더 나은 방법은 없을까요?
합성
interface ServiceV1 {
void createMember();
}
class ServiceV1Impl implements ServiceV1 {
@Override
public void createMember() {
System.out.println("create member");
}
}
interface ServiceV2 extends ServiceV1 {
void getMember(); // V2에 새롭게 추가된 메서드
}
class ServiceV2Impl implements ServiceV2 {
private ServiceV1 serviceV1; //서비스v1 구현체를 주입받음
ServiceV2Impl(ServiceV1 serviceV1) {
this.serviceV1 = serviceV1;
}
@Override
public void createMember() {
serviceV1.createMember() // V1 메서드를 재사용 or V2만의 코드를 짜도됨
}
@Override
void getMember() {
System.out.println("get member");
}
}
상위 버전 서비스가 하위 버전 서비스를 포함하는 구조로 V1의 코드를 재사용하면서 유연성과 결합도를 낮출 수 있습니다.
변동성에 따른 인터페이스 도입의 결론
다형성과 API의 버저닝을 생각하며 인터페이스를 도입하였을 때 어떻게 설계해야할지 고민해보았습니다.
마지막으로 나온 합성이 제일 좋아보이지만 사실 이 또한 코드의 복잡도를 늘리는 단점이 있습니다.
V3, V4, V5 버전이 나올 때마다 모든 버전을 합성을 통해 해결하는 것도 바람직하지 않아보입니다.
작은 코드 변경으로 버전을 바꾸는 것도 복잡성을 늘리므로 버전을 나누는 기준도 필요할 것이구요.
그러다보면 코드 중복을 허용하면서 V2는 V2대로, V1은 V1대로 관리하는 경우가 나을 수도 있을 것입니다.
각 팀의 상황에 맞게 상의를 해서 어떤 방법이 소통에 편리한지 고민이 필요할 것 같습니다.
2. 서비스 규모 - 개발 인원과 코드 의존성
개발 인원이 2명이상이고 서로가 개발하는 서비스가 의존성이 있다면 인터페이스를 도입하는게 좋습니다.
- 시스템 개발 속도를 높인다.
- 동시에 컴포넌트를 개발할 수 있도록 인터페이스를 정의하고, 각 팀이 해당 인터페이스를 구현하도록 한다면 여러 컴포넌트를 병렬로 개발할 수 있습니다.
- 시스템 개발 난이도를 낮춘다.
- 전체를 만들기 전에 각 팀원이 개별 컴포넌트를 검증할 수 있기 때문입니다.
3. 구현클래스의 다형성
인터페이스를 통한 다형성을 높이는 장점은 특히 상황에 따라 동적으로 여러개의 service 구현체 중 하나를 선택해야할 때 유용합니다.
만약 비밀번호 변경 방식이 두 가지가 있다고 가정해봅시다.
-
- 비밀번호 기반으로 비밀번호를 변경하는 기능
- 비밀번호를 잃어버렸을 때 다른 인증 기반으로 비밀번호를 변경하는 기능
이 상황에선 구현체를 2개 이상 갖게 되고 이럴 때 인터페이스를 두는 것이 바람직합니다.
// 비밀번호를 바꾸는 인터페이스
public interface ChangePasswordService {
public void change(MemberId id, PasswordDto.ChangeRequest dto);
}
// 비밀번호 기반으로 비밀번호를 변경하는 기능
class ByPasswordChangePasswordService implements ChangePasswordService {
private MemberFindService memberFindService;
@Override
public void change(MemberId id, PasswordDto.ChangeRequest dto) {
if (dto.getPassword().equals("비밀번호가 일치하는지 판단 로직...")) {
final Member member = memberFindService.findById(id);
final String newPassword = dto.getNewPassword().getValue();
member.changePassword(newPassword);
}
}
}
// 비밀번호를 잃어버렸을 때 다른 인증 기반으로 비밀번호를 변경하는 기능
class ByAuthChangePasswordService implements ChangePasswordService {
private MemberFindService memberFindService;
@Override
public void change(MemberId id, PasswordDto.ChangeRequest dto) {
if (dto.getAuthCode().equals("인증 코드가 적합한지 로직 추가...")) {
final Member member = memberFindService.findById(id);
final String newPassword = dto.getNewPassword().getValue();
member.changePassword(newPassword);
// 필요로직...
}
}
}
이렇게 하고 특정 상황에 맞는 구현체를 넣어주는 Factory 클래스를 생성해주면 됩니다.
public class ChangePasswordServiceFactory {
public static ChangePasswordService createService(int n) {
switch (n) {
case 1:
return new ByPasswordChangePasswordService();
case 2:
return new ByAuthChangePasswordService();
}
}
}
결론
인터페이스만 외부에 노출하는 방식의 장점
‘이펙티브 자바’에서는 인터페이스만 외부에 노출함으로써 생기는 이득을 아래와 같이 설명합니다.
- 시스템 개발 속도를 높인다. (여러 컴포넌트를 병렬로 개발할 수 있기 때문에)
- 시스템 개발 난이도를 낮춘다. (전체를 만들기 전에 개별 컴포넌트를 검증할 수 있기 때문에)
- 시스템 관리 비용을 낮춘다. (컴퍼넌트를 더 빨리 파악할 수 있기 때문에)
- 성능 최적화에 도움을 준다. (프로파일링을 통해 최적화할 컴포넌트를 찾고 다른 컴포넌트에 영향을 주지 않고 해당 컴포넌트만 개선할 수 있기 때문에)
- 소프트웨어 재사용성을 높인다. (독자적인 컴포넌트라면)
이렇게 위와 같이 인터페이스를 도입하는 것에는 다양한 이점들이 있습니다.
하지만 No Silver Bullet 처럼좋은 점이 있다면 나쁜 점이 있기마련입니다.
오늘 이야기했던 서비스 규모에 따라서 구현변동성, 개발 인원, 다형성 등의 관점들을 고민하면서 인터페이스 도입을 고민해보는 것이 좋을 것 같습니다.
'spring' 카테고리의 다른 글
[SpringBoot] Sentry On-premise 구축기 (2) | 2024.09.29 |
---|---|
Java reflection을 통해 RestDocs 생성 테스트 코드 작성 시간 1/2로 줄이기 (2) | 2024.09.27 |
[spring security] 왜 500번 에러도 401(Unauthorized)에러가 될까? (0) | 2024.04.07 |
이미지 저장/조회 서버 만들기(3) - AWS Presigned URL 이미지 업로드 (0) | 2024.02.26 |
이미지 저장/조회 서버 만들기(2) - 이미지 파일 어떻게 받아오지? (0) | 2024.02.12 |