🌐 API 버전 관리의 필요성 & 요구사항
AI 뉴스 리더, 브리핑 앱은 출시 1.0.0 버전을 시작으로 1.1.0 업데이트를 한 차례 수행했으며, 2023년도 12월 23일 기준 2.0.0 버전 앱 심사를 요청하고 대기중인 상태입니다.
앱의 버전별로 API 요구사항이 조금씩 달랐고, 서버단에서는 업데이트를 하지 않은 앱 사용자들을 위해 1.x.x, 2.x.x 버전 API를 모두 지원해야했습니다. 요구사항은 아래와 같았습니다.
📰 1.x.x 버전 API 요구사항
GET /briefings
홈 화면 브리핑 목록 조회 v1- 해당 날짜의 브리핑 목록을 내려준다.
- 각 브리핑은 id, 순위, 제목, 부제목 정보를 가진다.
GET /briefings/{id}
브리핑 단 건 조회 (상세조회) v1- id에 해당하는 브리핑의 상세 정보를 내려준다.
- id, 순위, 제목, 부제목, 내용, 기사 목록, 스크랩 여부 정보를 가진다.
📰 2.x.x 버전 API 요구사항
GET /v2/briefings
홈 화면 브리핑 목록 조회 v2- 해당 날짜와 타입, 아침/저녁에 맞는 브리핑 목록을 내려준다.
- 각 브리핑은 id, 순위, 제목, 부제목, 스크랩 개수 정보를 가진다.
→ 스크랩 개수가 추가되었습니다.
GET /v2/briefings/{id}
브리핑 단 건 조회 (상세조회) v2- id에 해당하는 브리핑의 상세 정보를 내려준다.
- id, 순위, 제목, 부제목, 내용, 기사 목록, 스크랩 여부, 스크랩 개수, GPT 모델명, 아침/저녁 여부, 브리핑 타입 정보를 가진다.
→ 스크랩 개수, GPT 모델명, 아침/저녁 여부, 브리핑 타입이 추가 되었습니다.
💡 API 버전 관리 방법들
- URI 버전관리 (URL 버전관리)
- 설명 : API의 URI에 버전 정보를 포함시키는 방법입니다. 예를 들어,
api.example.com/v1/users
- 장점
- 사용자가 사용 중인 API 버전을 쉽게 식별할 수 있습니다.
- 버전 관리가 직관적이고 쉽습니다.
- 단점
- 새 버전이 나올 때마다 새로운 URL을 만들어야 하므로 관리가 복잡해질 수 있습니다.
- API의 엔드포인트가 자주 변경될 수 있어 사용자 입장에서 불편할 수 있습니다.
- 설명 : API의 URI에 버전 정보를 포함시키는 방법입니다. 예를 들어,
- 쿼리 매개변수 버전관리
- 설명 : 쿼리 매개변수를 통해 버전 정보를 전달하는 방식입니다. 예를 들어,
api.example.com/users?version=1
- 장점
- URL의 경로가 변경되지 않아 URL 관리가 수월합니다.
- URI와 헤더 방식의 중간적인 접근 방식을 제공합니다.
- 단점:
- 쿼리 매개변수를 잘못 입력하거나 누락할 수 있습니다.
- URI 버전관리와 마찬가지로 버전이 변경될 때마다 URL이 바뀔 수 있습니다.
- 설명 : 쿼리 매개변수를 통해 버전 정보를 전달하는 방식입니다. 예를 들어,
- 헤더 버전관리
- 설명 : HTTP 헤더를 사용하여 버전 정보를 전달하는 방식입니다. 클라이언트는 특정 HTTP 헤더에 버전 정보를 포함하여 요청을 보냅니다.
X-API-Version: v1
- 장점
- URI가 변경되지 않아 URL 설계가 더 깔끔하고 일관성을 유지할 수 있습니다.
- 서버 측에서 유연하게 버전을 관리할 수 있습니다.
- 단점
- 클라이언트 개발자가 HTTP 헤더에 대해 잘 알아야 합니다.
- 헤더를 누락하거나 잘못 설정하는 경우가 발생할 수 있어, 일부 사용자에게 어려울 수 있습니다.
- 설명 : HTTP 헤더를 사용하여 버전 정보를 전달하는 방식입니다. 클라이언트는 특정 HTTP 헤더에 버전 정보를 포함하여 요청을 보냅니다.
📰 회의를 통한 결정
초기에 서버 개발자들 간 회의를 했을 때는 API 경로를 변경하지 않고 버저닝이 필요한 API들에 한해서 쿼리 스트링으로 버전을 받기로 결정했었습니다.
그러나 이후 회의에서 클라이언트 개발자분들께 각 버전 관리의 선택지들의 장단점과 방식을 설명드렸고, 회의를 통해 클라이언트측에서 URI 버저닝 방식이 직관적이고 편하다는 요청을 수용하여 URI 버저닝을 도입하기로 결정했습니다.
또한 컨트롤러 계층에서는 API 버전(ex. APIVersion.V2)을 포함하여 서비스계층의 메소드를 호출하고 서비스 계층에서는 버전에 따라 적절히 쿼리를 수행하여 반환하기로 결정했습니다.
🚀 전략 패턴의 적용
API 버전별로 서비스단에서 수행해야하는 쿼리가 상이했고, 이를 단순하게 생각하면 아래와 같이 해결할 수 있었습니다.
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class BriefingQueryService {
private final BriefingRepository briefingRepository;
public List<Briefing> findBriefings(final BriefingType type, final LocalDate date, final APIVersion version) {
switch (version) {
case V1:
// V1 버전에 대한 쿼리 수행
break;
case V2:
// V2 버전에 대한 쿼리 수행
break;
default:
throw new BriefingException(ErrorCode.INVALID_API_VERSION);
}
}
public Briefing findBriefing(final Long id, final APIVersion apiVersion) {
switch (apiVersion) {
case V1:
// V1 버전에 대한 쿼리 수행
break;
case V2:
// V2 버전에 대한 쿼리 수행
break;
default:
throw new BriefingException(ErrorCode.INVALID_API_VERSION);
}
}
}
이렇게 서비스 클래스에서 버전별로 직접 분기하여 쿼리를 수행하는 방식은 아래와 같은 문제를 가지고 있었습니다.
- 유지보수의 어려움
시간이 지남에 따라 버전이 계속 추가되면 서비스 클래스가 커져서 관리하기 어려워질 수 있습니다. 각 버전의 로직이 한 클래스 내에 중첩되면서 클래스의 크기가 커지고, 책임이 명확히 분리되지 않습니다. - 확장성 문제
새로운 버전을 추가하기 위해서는 기존 서비스 클래스를 수정해야 하므로 개방-폐쇄 원칙(OCP)을 위반하며, 기존 코드에 영향을 줄 수 있습니다. - 단일 책임 원칙(SRP) 위반
한 클래스가 여러 버전의 로직을 처리하므로, 단일 책임 원칙에 어긋납니다.
이러한 문제들을 고려했을 때, 쿼리의 실행을 별도의 전략으로 구현하고 클라이언트 코드(Controller 계층)에서 요청한 버전 별로 동적으로 쿼리 전략을 선택할 수 있게끔 전략패턴을 적용했습니다.
전략패턴이란? → [Design Pattern] Strategy 패턴
클래스 다이어그램
- BriefingQueryService
- 역할: 이 클래스는 애플리케이션의 서비스 계층에 속하며, 클라이언트의 요청을 받아 적절한 쿼리 전략을 사용하여 데이터를 검색합니다.
- 책임: 요청을 받고, 요청에 맞는
BriefingQueryContext
를 가져와서 해당 컨텍스트를 통해 데이터를 조회하고 반환합니다.
- BriefingQueryContext
- 역할: 쿼리 전략을 보유하고, 서비스 레이어로부터의 요청을 전략으로 전달하는 역할을 합니다.
- 책임:
BriefingQueryStrategy
인터페이스에 정의된 메서드를 호출하여 구체적인 쿼리 실행을 전략에 위임합니다.
- BriefingQueryStrategy (인터페이스)
- 역할: 모든 쿼리 전략이 따라야 할 명세를 제공합니다.
- 책임: 구체적인 쿼리 전략 클래스들이 이 인터페이스를 구현하며, 각 메서드의 구현을 통해 데이터를 검색하는 방법을 정의합니다.
- BriefingV1QueryStrategy
- 역할: API 버전 1에 맞게 설계된 쿼리 실행 전략입니다.
- 책임: 버전 1의 API 요구사항에 맞추어 데이터 조회 로직을 구현합니다. 이 클래스는
BriefingQueryStrategy
인터페이스의 메서드를 구현하며 v1에 적합한 방식으로 데이터를 처리하고 조회합니다.
- BriefingV2QueryStrategy
- 역할: API 버전 2에 맞게 설계된 쿼리 실행 전략입니다.
- 책임: 버전 2의 API 요구사항에 맞추어 데이터 조회 로직을 구현합니다. 이 클래스 역시
BriefingQueryStrategy
인터페이스를 구현하며, v2에 적합한 방식으로 데이터를 처리하고 조회합니다.
🧑🏻💻 구현 코드
BriefingQueryService
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class BriefingQueryService {
private final BriefingQueryContextFactory briefingQueryContextFactory;
public List<Briefing> findBriefings(BriefingRequestParam.BriefingPreviewListParam params, APIVersion version) {
BriefingQueryContext briefingQueryContext = briefingQueryContextFactory.getContextByVersion(version);
return briefingQueryContext.findBriefings(params);
}
public Briefing findBriefing(final Long id, final APIVersion version) {
BriefingQueryContext briefingQueryContext = briefingQueryContextFactory.getContextByVersion(version);
return briefingQueryContext.findById(id)
.orElseThrow(() -> new BriefingException(ErrorCode.NOT_FOUND_BRIEFING));
}
}
BriefingQueryContext
@RequiredArgsConstructor
public class BriefingQueryContext {
private final BriefingQueryStrategy briefingQueryStrategy;
public List<Briefing> findBriefings(BriefingRequestParam.BriefingPreviewListParam params) {
return this.briefingQueryStrategy.findBriefings(params);
}
public Optional<Briefing> findById(Long id) {
return this.briefingQueryStrategy.findById(id);
}
}
BriefingQueryStrategy
public interface BriefingQueryStrategy {
List<Briefing> findBriefings(BriefingRequestParam.BriefingPreviewListParam params);
Optional<Briefing> findById(Long id);
APIVersion getVersion();
}
BriefingV1QueryStrategy
@Component
@RequiredArgsConstructor
public class BriefingV1QueryStrategy implements BriefingQueryStrategy {
private final BriefingRepository briefingRepository;
@Override
public List<Briefing> findBriefings(BriefingRequestParam.BriefingPreviewListParam params) {
final LocalDateTime startDateTime = params.getDate().atStartOfDay();
final LocalDateTime endDateTime = params.getDate().atTime(LocalTime.MAX);
List<Briefing> briefingList = briefingRepository.findAllByTypeAndCreatedAtBetweenOrderByRanks(params.getType(), startDateTime, endDateTime);
if(briefingList.isEmpty()) {
briefingList = briefingRepository.findTop10ByTypeOrderByCreatedAtDesc(BriefingType.SOCIAL);
Collections.reverse(briefingList);
}
return briefingList;
}
@Override
public Optional<Briefing> findById(Long id) {
return briefingRepository.findById(id);
}
@Override
public APIVersion getVersion() {
return APIVersion.V1;
}
}
BriefingV2QueryStrategy
@Component
@RequiredArgsConstructor
public class BriefingV2QueryStrategy implements BriefingQueryStrategy {
private final BriefingRepository briefingRepository;
@Override
public List<Briefing> findBriefings(BriefingRequestParam.BriefingPreviewListParam params) {
if(params.isPresentDate()) {
final LocalDateTime startDateTime = params.getDate().atStartOfDay();
final LocalDateTime endDateTime = params.getDate().atTime(LocalTime.MAX);
return briefingRepository.findBriefingsWithScrapCount(
params.getType(), startDateTime, endDateTime, params.getTimeOfDay());
}
List<Briefing> briefingList = briefingRepository.findTop10ByTypeOrderByCreatedAtDesc(params.getType());
Collections.reverse(briefingList);
return briefingList;
}
@Override
public Optional<Briefing> findById(Long id) {
return briefingRepository.findByIdWithScrapCount(id);
}
@Override
public APIVersion getVersion() {
return APIVersion.V2;
}
}
BriefingQueryContextFactory
@Component
public class BriefingQueryContextFactory {
private final Map<APIVersion, BriefingQueryContext> contextMap;
@Autowired
public BriefingQueryContextFactory(List<BriefingQueryStrategy> strategies) {
contextMap = new EnumMap<>(APIVersion.class);
for (BriefingQueryStrategy strategy : strategies) {
APIVersion version = strategy.getVersion();
contextMap.put(version, new BriefingQueryContext(strategy));
}
}
public BriefingQueryContext getContextByVersion(APIVersion version) {
BriefingQueryContext context = contextMap.get(version);
if (context == null) {
throw new IllegalArgumentException("Invalid API version: " + version);
}
return context;
}
}
전체코드는 아래 Github 리포지토리에서 확인하실 수 있습니다!
https://github.com/Team-Shaka/Briefing-Backend
📚 정리
처음에 생각했던 단순한 방법(서비스 계층에서 버전별로 직접 분기)과 전략 패턴을 적용한 방법을 비교하며 정리해보겠습니다.
단순한 방법
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class BriefingQueryService {
private final BriefingRepository briefingRepository;
public List<Briefing> findBriefings(final BriefingType type, final LocalDate date, final APIVersion version) {
switch (version) {
case V1:
// V1 버전에 대한 쿼리 수행
break;
case V2:
// V2 버전에 대한 쿼리 수행
break;
default:
throw new BriefingException(ErrorCode.INVALID_API_VERSION);
}
}
public Briefing findBriefing(final Long id, final APIVersion apiVersion) {
switch (apiVersion) {
case V1:
// V1 버전에 대한 쿼리 수행
break;
case V2:
// V2 버전에 대한 쿼리 수행
break;
default:
throw new BriefingException(ErrorCode.INVALID_API_VERSION);
}
}
}
- 유지보수의 어려움
시간이 지남에 따라 버전이 계속 추가되면 서비스 클래스가 커져서 관리하기 어려워질 수 있습니다. 각 버전의 로직이 한 클래스 내에 중첩되면서 클래스의 크기가 커지고, 책임이 명확히 분리되지 않습니다. - 확장성 문제
새로운 버전을 추가하기 위해서는 기존 서비스 클래스를 수정해야 하므로 개방-폐쇄 원칙(OCP)을 위반하며, 기존 코드에 영향을 줄 수 있습니다. - 단일 책임 원칙(SRP) 위반
한 클래스가 여러 버전의 쿼리 로직을 처리하므로, 단일 책임 원칙에 어긋납니다.
전략패턴 적용
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class BriefingQueryService {
private final BriefingQueryContextFactory briefingQueryContextFactory;
public List<Briefing> findBriefings(BriefingRequestParam.BriefingPreviewListParam params, APIVersion version) {
BriefingQueryContext briefingQueryContext = briefingQueryContextFactory.getContextByVersion(version);
return briefingQueryContext.findBriefings(params);
}
public Briefing findBriefing(final Long id, final APIVersion version) {
BriefingQueryContext briefingQueryContext = briefingQueryContextFactory.getContextByVersion(version);
return briefingQueryContext.findById(id)
.orElseThrow(() -> new BriefingException(ErrorCode.NOT_FOUND_BRIEFING));
}
}
- 유지보수 용이
전략 패턴을 사용함으로써, 각 버전의 로직이 별도의 전략 클래스로 분리됩니다. 이것은BriefingQueryService
가 비대해지는 것을 피하면서 각 버전의 쿼리를 독립적으로 관리할 수 있습니다. - 확장성
새로운 API 버전이 도입되어도, 기존의BriefingQueryService
클래스를 비롯한 다른 클래스들 수정 없이 새로운BriefingQueryStrategy
구현체를 추가하기만 하면됩니다. 이는 개방-폐쇄 원칙(OCP)을 준수하며, 기존 시스템을 유지하면서 확장할 수 있습니다.
ex) 버전 3으로 올라갔을 때, BriefingV3QueryStrategy 클래스만 추가 - 단일 책임 원칙(SRP) 준수
BriefingQueryService
가 단일 책임 원칙을 준수하도록 해, 해당 서비스는 단지 올바른 전략을 선택하고 사용하는 책임만을 가지게 됩니다. 각 전략 클래스는 특정 버전의 쿼리 로직을 수행하는 단일 책임을 가집니다.
이렇듯 전략패턴은 아래 상황들에 마주했다면 고려해볼 디자인 패턴인 것 같습니다.
- 분기문(if, switch case)을 사용하여 다양한 시나리오에 따른 행동을 처리해야 할 때
- 앞으로 시스템에 새로운 전략이 추가될 가능성이 있을 때
- 런타임에 객체의 행동을 교체해야할 때
'💻 Service > Briefing' 카테고리의 다른 글
[Briefing] nGrinder로 성능 테스트 해보기 (2) | 2024.02.07 |
---|---|
[Briefing] Facade로 계층 구조 개선하기 (2) | 2024.01.15 |
[Briefing] API 응답 캐싱을 통한 조회 속도 개선 (0) | 2024.01.06 |
[Briefing] Spotless로 코드 포맷 유지하기 (0) | 2023.12.27 |