달라이브 출석체크 이벤트 전략패턴 적용
출석체크(방송/청취) 이벤트 코드 리뷰
/attendance/status 엔드포인트의 전략패턴(Strategy Pattern) 적용 사례
[1] 배경 및 문제 상황
기존 문제점
- 거대한 if-else 구조로 이벤트 타입별 처리
- 새로운 이벤트 추가 시 기존 코드 수정 필요
- 각 이벤트 로직이 한 곳에 집중되어 복잡도 증가
- 테스트와 유지보수 어려움
해결 목표
- 이벤트별 로직 분리 및 독립성 확보
- 새 이벤트 추가 시 기존 코드 수정 없이 확장 가능
- 코드 가독성과 유지보수성 향상
1. Strategy Pattern 소개
이벤트를 클래스로 캡슐화하여 런타임에 동적으로 교체할 수 있게 하는 패턴
- 알고리즘 군을 정의하고 각각 캡슐화하여 상호 교체 가능하게 만듦
- 클라이언트 코드 수정 없이 알고리즘을 변경할 수 있음
- 동일한 문제를 해결하는 여러 방법 중 상황에 맞는 방법을 선택
AttendanceType - 이벤트 타입 정의
먼저 우리가 다루는 이벤트 타입들을 살펴보겠습니다:
@Getter
public enum AttendanceType {
HOMERUN("homerun", "달라 야구장", "/event/daily?tab=tab-1", "/floating_icon/webp/baseball.webp"),
SHOOTING("shooting", "달라 사격장", "/event/daily?tab=tab-1", "/floating_icon/webp/shooting.webp"),
LOTTO("lotto", "달비 로또", "/event/attendance?tab=lotto", "/floating_icon/webp/lotto.webp");
private final String code; // API 응답에서 사용되는 코드
private final String description; // 이벤트 설명
private final String pathUrl; // 이벤트 페이지 URL
private final String imageUrl; // 플로팅 아이콘 이미지 경로
/**
* 현재 활성화된 이벤트인지 확인
* 여기서 진행 중인 이벤트들을 관리합니다
*/
public boolean isActiveEvent() {
return this == HOMERUN; // 현재 야구장 이벤트만 활성화
}
}
이벤트별 특징:
- HOMERUN: 방망이가 필요한 특별 이벤트 (현재 활성화)
- SHOOTING: 총알이 필요한 특별 이벤트 (비활성화)
- LOTTO: 항상 참여 가능한 기본 이벤트
활성화 제어:
isActiveEvent()
메서드로 중앙 집중식 이벤트 제어- enum 수정만으로 배포 없이 이벤트 on/off 가능
기존 구현 vs Strategy Pattern 적용
기존 구현 (if-else 구조)
// 기존 callAttendanceCheck() 메서드의 이벤트 선택 로직
int userEventCheck;
boolean isHomeRunTicketUsed = homeRunService.useTicketForAttendanceCheck(); // 방망이 1개 이상
boolean isLottoTicketUsed = lottoService.useLottoForAttendanceCheck(); // 로또응모권 1개 이상
if (isHomeRunTicketUsed) { // 방망이 1개 이상 있는 경우
userEventCheck = 6; // 달라야구장
} else if (isLottoTicketUsed) { // 방망이 없고 로또 응모권 1개이상 있는 경우
userEventCheck = 4; // 로또페이지
} else { // 방망이 없고 로또 없는 경우
userEventCheck = 6; // 기본값: 달라야구장
}
Strategy Pattern 적용한 현재 구현
// 깔끔한 Stream API + Strategy Pattern 활용
return attendanceCheckServices.stream()
.filter(service -> service.getAttendanceType().isActiveEvent())
.filter(AttendanceCheckStrategy::hasTicket)
.map(AttendanceCheckStrategy::getAttendanceType)
.findFirst();
개선된 점:
- 확장성: 새로운 이벤트 추가 시 기존 if-else 수정 불필요
- 가독성: 복잡한 조건문이 간결한 Stream 처리로 변경
- 유지보수: 각 이벤트 로직이 독립적인 클래스로 분리
- 클라이언트 독립성: 기존
userEventCheck
숫자 코드에서 의미있는 문자열로 변경- 기존: 네이티브/프론트에서
6
=야구장,4
=로또로 하드코딩 체크 - 현재: API에서
"homerun"
,"lotto"
등 명확한 문자열 제공 - 장점: API만 수정하면 클라이언트 코드 변경 없이 새 이벤트 추가 가능
- 기존: 네이티브/프론트에서
2. 현재 구현 분석
1. Strategy Interface
public interface AttendanceCheckStrategy {
AttendanceType getAttendanceType(); // 타입 반환
boolean hasTicket(); // 참여권 확인
String getServiceName(); // 서비스명
}
2. Concrete Strategy 구현체들
2-1. HomeRunAttendanceCheckService (야구장)
@Service
public class HomeRunAttendanceCheckService implements AttendanceCheckStrategy {
private final HomeRunService homeRunService;
@Override
public AttendanceType getAttendanceType() {
return AttendanceType.HOMERUN; // 야구장 이벤트
}
@Override
public boolean hasTicket() {
// 실제 방망이 보유 여부 확인 로직
return homeRunService.useTicketForAttendanceCheck();
}
@Override
public String getServiceName() {
return "HomeRun Attendance Check Service";
}
}
2-2. ShootingAttendanceCheckService (사격장)
@Service
public class ShootingAttendanceCheckService implements AttendanceCheckStrategy {
private final ShootingService shootingService;
@Override
public AttendanceType getAttendanceType() {
return AttendanceType.SHOOTING; // 사격장 이벤트
}
@Override
public boolean hasTicket() {
// 실제 총알 보유 여부 확인 로직
return shootingService.useTicketForAttendanceCheck();
}
@Override
public String getServiceName() {
return "Shooting Attendance Check Service";
}
}
2-3. LottoAttendanceCheckService (로또)
@Service
public class LottoAttendanceCheckService implements AttendanceCheckStrategy {
@Override
public AttendanceType getAttendanceType() {
return AttendanceType.LOTTO; // 로또 이벤트
}
@Override
public boolean hasTicket() {
// 로또는 항상 참여 가능 (기본 이벤트)
return true;
}
@Override
public String getServiceName() {
return "Lotto Attendance Check Service";
}
}
구현체별 특징:
- HomeRun/Shooting: 실제 서비스에 의존하여 티켓(방망이/총알) 확인
- Lotto: 항상
true
반환 (기본 이벤트로 항상 참여 가능) - 확장성: 새로운 이벤트 추가 시 동일한 패턴으로 구현
3. Context (AttendanceService) - Strategy 활용
3-1. 메인 메소드: attendanceStatus()
@Service
public class AttendanceService {
private final List<AttendanceCheckStrategy> attendanceCheckServices;
public ResVO<AttendanceStatusResponse> attendanceStatus(HttpServletRequest request) {
// 1. 로그인 체크
boolean isLogin = DalbitUtil.isLogin(request);
if (!isLogin) {
return createEmptyResponse(); // 로그인 안됐으면 빈 응답
}
// 2. Strategy Pattern 활용: 활성화된 특별 이벤트 확인
Optional<AttendanceType> activeSpecialEvent = getActiveSpecialEvent();
// 3. 우선순위 로직: 특별이벤트 > 기본 로또
if (activeSpecialEvent.isPresent()) {
return createResponse(activeSpecialEvent.get());
} else {
return createResponse(AttendanceType.LOTTO); // 기본값
}
}
}
3-2. 핵심 로직: getActiveSpecialEvent()
private Optional<AttendanceType> getActiveSpecialEvent() {
// 현재 로그인한 회원 번호 가져오기
long memNo = Long.parseLong(MemberUtil.getMemNo());
return attendanceCheckServices.stream() // ⭐️ 여기서 모든 구현체들이 나옴!
.filter(service -> {
String className = service.getClass().getSimpleName();
return !className.startsWith("$Proxy"); // 프록시 객체 제외
})
.filter(service -> {
AttendanceType attendanceType = service.getAttendanceType();
boolean isActiveEvent = attendanceType.isActiveEvent();
if (isActiveEvent) {
// ⭐️ Strategy Pattern의 핵심: 각 전략의 hasTicket() 호출
boolean hasTicket = service.hasTicket();
log.warn("회원 {}의 {} 티켓 보유: {}", memNo, attendanceType, hasTicket);
return hasTicket;
}
return false;
})
.map(AttendanceCheckStrategy::getAttendanceType)
.findFirst(); // 첫 번째 매칭되는 이벤트 반환
}
attendanceCheckServices에서 나오는 구현체들:
@Service
public class AttendanceService {
// Spring이 AttendanceCheckStrategy 구현체들을 자동으로 주입
private final List<AttendanceCheckStrategy> attendanceCheckServices;
}
실제 주입되는 구현체들:
HomeRunAttendanceCheckService
- 야구장 방망이 체크ShootingAttendanceCheckService
- 사격장 총알 체크LottoAttendanceCheckService
- 로또 (항상 true)
Spring 자동 등록 메커니즘:
@Service
어노테이션이 붙은 모든AttendanceCheckStrategy
구현체- 자동으로
List<AttendanceCheckStrategy>
에 주입 - 새로운 구현체 추가 시 자동으로 리스트에 포함됨
실행 시 동작:
// getActiveSpecialEvent() 호출 시
attendanceCheckServices.stream() // 3개 구현체 모두 순회
├── HomeRunAttendanceCheckService.hasTicket() 실행
├── ShootingAttendanceCheckService.hasTicket() 실행
└── LottoAttendanceCheckService.hasTicket() 실행
이벤트 활성화 상태 관리
앞서 정의한 AttendanceType enum의 isActiveEvent()
메서드를 통해 중앙 집중식으로 이벤트 상태를 관리합니다.
동작 방식:
.filter(service -> {
AttendanceType attendanceType = service.getAttendanceType();
boolean isActiveEvent = attendanceType.isActiveEvent(); // ⭐️ 여기서 활성화 체크
if (isActiveEvent) { // 활성화된 이벤트만 티켓 확인
boolean hasTicket = service.hasTicket();
return hasTicket;
}
return false; // 비활성화된 이벤트는 제외
})
현재 상태:
HOMERUN
- 활성화 (야구장 이벤트 진행 중)SHOOTING
- 비활성화LOTTO
- 기본 이벤트 (특별 이벤트 아님)
장점:
- 중앙 집중식 관리: 한 곳에서 모든 이벤트 상태 제어
- 배포 없는 이벤트 제어: enum만 수정하면 이벤트 on/off 가능
- 명확한 우선순위: 활성화된 특별 이벤트 > 기본 로또
4. 아키텍처 구조
전체 구조 개요
Strategy Pattern의 핵심은 Context(맥락), Strategy(전략 인터페이스), Concrete Strategy(구체적 전략) 3개 요소로 구성됩니다.
┌─────────────────────────────────────────────────────────────────┐
│ Client │
│ (AttendanceController) │
└─────────────────────┬───────────────────────────────────────────┘
│ 요청
▼
┌─────────────────────────────────────────────────────────────────┐
│ Context │
│ (AttendanceService) │
│ │
│ + attendanceStatus() │
│ + getActiveSpecialEvent() │
│ - List<AttendanceCheckStrategy> │
└─────────────────────┬───────────────────────────────────────────┘
│ uses
▼
┌─────────────────────────────────────────────────────────────────┐
│ <<interface>> │
│ AttendanceCheckStrategy │
│ │
│ + getAttendanceType(): AttendanceType │
│ + hasTicket(): boolean │
│ + getServiceName(): String │
└─────────────────────────┬───────────────────────────────────────┘
│ implements
┌─────────────────┼────────────────┐
▼ ▼ ▼
┌────────────────┐ ┌────────────────┐ ┌────────────────┐
│HomeRun │ │Shooting │ │Lotto │
│Attendance │ │Attendance │ │Attendance │
│CheckService │ │CheckService │ │CheckService │
│ │ │ │ │ │
│Condition Check │ │Condition Check │ │True │
└────────────────┘ └────────────────┘ └────────────────┘
각 구성 요소 역할
- AttendanceService (Context 역할)
- 클라이언트(AttendanceController)의 요청을 받아 적절한 전략을 선택하고 실행
List<AttendanceCheckStrategy>
를 통해 모든 전략 구현체를 관리- 비즈니스 로직(로그인 체크, 우선순위 처리)과 전략 실행을 조율
- AttendanceCheckStrategy (Strategy Interface 역할)
- 모든 구체적 전략이 구현해야 할 공통 인터페이스 정의
getAttendanceType()
,hasTicket()
,getServiceName()
메서드 제공- 새로운 이벤트 추가 시 구현해야 할 계약(Contract) 역할
- Concrete Strategy 구현체들
- HomeRunAttendanceCheckService: 야구장 이벤트의 방망이 보유 여부 확인
- ShootingAttendanceCheckService: 사격장 이벤트의 총알 보유 여부 확인
- LottoAttendanceCheckService: 로또 이벤트(항상 참여 가능한 기본 이벤트)
데이터 흐름
- 요청 접수:
AttendanceController
→AttendanceService
- 전략 수집: Spring DI가 모든
AttendanceCheckStrategy
구현체를 List로 주입 - 전략 필터링: 활성화된 이벤트만 선별 (
isActiveEvent()
체크) - 전략 실행: 각 전략의
hasTicket()
메서드 호출하여 참여 가능 여부 확인 - 결과 반환: 첫 번째 조건을 만족하는 이벤트 타입 반환
5. 동작 흐름
- 클라이언트 요청 →
AttendanceController.getAttendanceStatus()
- 전략 선택 →
AttendanceService.getActiveSpecialEvent()
- 전략 실행 → 각
Strategy.hasTicket()
호출 - 결과 반환 → 첫 번째 매칭되는 이벤트 타입 반환
6. 예외 처리 - hasTicket() 안전성
각 Strategy 구현체의 hasTicket()
메소드에서 DB 프로시저 오류 시 안전하게 false
처리
HomeRunService.useTicketForAttendanceCheck()
public boolean useTicketForAttendanceCheck() {
try {
HomeRunAttendanceCheck.EventInfoVO schedule = this.findSchedule();
if (schedule == null) return false; // 스케줄 없으면 false
String memNo = MemberUtil.getMemNo();
HomeRunBallDispenseVO.DataSel dataSel = homeRun.pEvtHomeRunMemBallDataSel(schedule.getEvtNo() - 1, memNo);
return dataSel != null && dataSel.getTicketCnt() >= 1;
} catch (Exception e) {
log.warn("useTicketForAttendanceCheck 실패: {}", e.getMessage());
return false; // ⭐️ 오류 발생 시 안전하게 false 반환
}
}
ShootingService.useTicketForAttendanceCheck()
public boolean useTicketForAttendanceCheck() {
String memNo = MemberUtil.getMemNo();
try {
// 현재 사격장 이벤트 회차 정보 조회
ShootingScheduleVo schedule = getShootingSchedule();
if (schedule == null) {
log.warn("사격장 이벤트 회차 정보가 없습니다.");
return false;
}
// 회원 정보 조회
ShootingMemberVo member = shootingRepository.getShootingMember(schedule.getEvtNo(), memNo);
if (member == null) {
log.warn("사격장 회원 정보가 없습니다. - 회원: {}, 회차: {}", memNo, schedule.getEvtNo());
return false;
}
// bullet_cnt가 1개 이상이면 참여 가능
boolean hasTicket = member.getBulletCnt() != null && member.getBulletCnt() >= 1;
log.info("사격장 참여 티켓 확인 - 회원: {}, 총알수: {}, 참여가능: {}", memNo, member.getBulletCnt(), hasTicket);
return hasTicket;
} catch (Exception e) {
log.error("사격장 참여 티켓 확인 중 오류 발생 - 회원: {}", memNo, e);
return false; // ⭐️ 오류 발생 시 안전하게 false 반환
}
}
LottoAttendanceCheckService.hasTicket()
@Override
public boolean hasTicket() {
// 로또는 항상 사용 가능 (기본값) - 예외 처리 불필요
return true;
}
[안전성 보장]
- DB 프로시저 오류: try-catch로 감싸서 false 반환
- 데이터 null 체크: 각 단계별로 null 검증
- 명확한 로깅: 오류 원인 추적 가능
- Fallback 전략: 오류 시 기본 로또 이벤트로 처리
[예외 처리의 장점]
- 서비스 안정성: 일부 이벤트 오류가 전체 서비스에 영향 없음
- 사용자 경험: 오류 시에도 최소한 로또 이벤트는 참여 가능
- 운영 편의성: 오류 로그로 문제 상황 빠른 파악
- 확장성: 새로운 이벤트 추가 시 동일한 패턴 적용 가능
3-3. 헬퍼 메소드들 - 응답 객체 생성
// 응답 객체 생성
private ResVO<AttendanceStatusResponse> createResponse(AttendanceType attendanceType) {
AttendanceStatusResponse response = new AttendanceStatusResponse();
response.setStatus(attendanceType.getCode()); // "homerun"
response.setPathUrl(attendanceType.getPathUrl()); // "/event/daily?tab=tab-1"
response.setImageUrl(attendanceType.getImageUrl()); // "/floating_icon/webp/baseball.webp"
return ResUtil.convert(CommonStatus.조회, response);
}
// 로그인하지 않은 사용자용 빈 응답
private ResVO<AttendanceStatusResponse> createEmptyResponse() {
AttendanceStatusResponse response = new AttendanceStatusResponse();
response.setStatus(""); // 빈 문자열
response.setPathUrl(""); // 빈 문자열
response.setImageUrl(""); // 빈 문자열
return ResUtil.convert(CommonStatus.조회, response);
}
실제 응답 객체 예제:
1. 야구장 이벤트 활성화 시 (방망이 보유한 경우)
{
"result": 200,
"message": "조회 성공",
"data": {
"status": "homerun",
"pathUrl": "/event/daily?tab=tab-1",
"imageUrl": "/floating_icon/webp/baseball.webp"
}
}
2. 특별 이벤트 없거나 티켓 없는 경우 (기본 로또)
{
"result": 200,
"message": "조회 성공",
"data": {
"status": "lotto",
"pathUrl": "/event/attendance?tab=lotto",
"imageUrl": "/floating_icon/webp/lotto.webp"
}
}
3. 로그인하지 않은 경우 (빈 응답)
{
"result": 200,
"message": "조회 성공",
"data": {
"status": "",
"pathUrl": "",
"imageUrl": ""
}
}
AttendanceService 메소드 분석:
- attendanceStatus(): 메인 진입점, 로그인 체크 + Strategy 활용
- getActiveSpecialEvent(): Strategy Pattern의 핵심 로직
- createResponse()/createEmptyResponse(): 응답 객체 생성 헬퍼
응답 객체 활용:
- status: 프론트엔드에서 이벤트 타입 구분
- pathUrl: 해당 이벤트 페이지로 이동할 URL
- imageUrl: 플로팅 버튼에 표시할 이미지 경로
7. 적용 효과 및 장점
1. 확장성
// 새로운 이벤트 추가 시
@Service
public class NewEventAttendanceCheckService implements AttendanceCheckStrategy {
// 기존 코드 수정 없이 새로운 전략 추가 가능
}
2. 의존성 주입을 통한 자동 등록
// Spring이 모든 구현체를 자동으로 List에 주입
private final List<AttendanceCheckStrategy> attendanceCheckServices;
3. SOLID 원칙 준수
- 단일 책임 원칙 (SRP): 각 이벤트별 로직이 독립적인 클래스로 분리
- 개방-폐쇄 원칙 (OCP): 새로운 이벤트 추가 시 기존 코드 수정 불필요
- 의존성 역전 원칙 (DIP): 인터페이스에 의존, 구현체에 의존하지 않음
4. 핵심 개선 효과
- 유지보수성 향상 - 각 이벤트 로직이 분리되어 수정이 용이
- 테스트 용이성 향상 - 각 전략을 독립적으로 테스트 가능
- 코드 가독성 향상 - 복잡한 조건문 제거
- 확장성 향상 - 새로운 이벤트 타입 추가가 간단
8. 기술적 이슈 해결 - 프록시 객체 필터링
문제 상황
.filter(service -> {
String className = service.getClass().getSimpleName();
return !className.startsWith("$Proxy"); // 프록시 객체 제외
})
문제의 원인: @MapperScan 광범위 스캔
ReplicationDatabaseConfig.java:31
에서:
@Configuration
@MapperScan(basePackages= "com.dalbit") // 전체 패키지 스캔!
public class ReplicationDatabaseConfig {
// MyBatis 설정
}
발생하는 문제
MyBatis의 @MapperScan
이 com.dalbit
전체 패키지를 스캔하면서:
- 정상적인 Mapper 인터페이스를 찾아 프록시 생성 (정상)
- AttendanceCheckStrategy 인터페이스도 Mapper로 오인하여 빈 프록시 생성 (문제)
실제 주입되는 List 상황
// Spring이 주입하는 List<AttendanceCheckStrategy>
List<AttendanceCheckStrategy> attendanceCheckServices = [
HomeRunAttendanceCheckService, // [정상] 정상 구현체
ShootingAttendanceCheckService, // [정상] 정상 구현체
LottoAttendanceCheckService, // [정상] 정상 구현체
$Proxy456@AttendanceCheckStrategy // [문제] MyBatis 빈 프록시!
];
해결 방법
현재 방어적 코딩 (운영 중)
.filter(service -> {
String className = service.getClass().getSimpleName();
return !className.startsWith("$Proxy"); // 빈 프록시 제외
})
근본적 해결책 (권장)
// @MapperScan 범위 축소
@MapperScan(basePackages = {
"com.dalbit.*.dao", // DAO 패키지만
"com.dalbit.*.mapper" // Mapper 패키지만
})
결론: 현재 프록시 필터링은 @MapperScan의 광범위한 스캔으로 인한 합리적인 방어 코드입니다. MyBatis가 잘못 생성한 빈 프록시 객체를 제외하여 시스템의 안정성을 보장합니다.
9. 토론 포인트
개선 및 확장 방향
- 현재 구조에서 개선할 점은?
- @MapperScan 범위 축소를 통한 근본적 해결
- 이벤트별 우선순위 로직 외부 설정 가능 여부
- 다른 도메인에서 적용 가능한 부분은?
- 결제 수단 선택 로직
- 알림 발송 방식 선택
- 보상 지급 방식 다양화
패턴 비교 및 고려사항
- Strategy Pattern vs Factory Pattern
- Strategy: 런타임 동적 선택 (현재 구조)
- Factory: 객체 생성 시점 결정
- 새로운 이벤트 추가 시 고려사항
- AttendanceType enum에 새 타입 추가
- isActiveEvent() 로직 업데이트
- 새로운 Strategy 구현체 생성
10. 참고자료
- Design Patterns: GoF Design Patterns - Strategy Pattern
- Spring Framework: Dependency Injection & Component Scanning
- MyBatis: @MapperScan Configuration Best Practices
- Clean Architecture: SOLID Principles Application