티스토리 뷰
배경
회사에서 개발 중인 솔루션에서는 서로 다른 도메인(사용자, 업무 코드)에 속한 특정 필드 간에 중복이 허용되지 않는 제약 조건이 필요했다. 문제는 이 제약이 단일 테이블이나 단일 도메인 수준에서 해결될 수 있는 성격이 아니라는 점이었다.
해당 솔루션은 GitLab을 미들웨어로 연계하고 있으며, GitLab은 URL 기반 네임스페이스 구조를 사용한다.
https://gitlab.com/{namespace}/{project}
이 구조로 인해 GitLab 사용자 아이디와 GitLab 그룹명은 서로 중복될 수 없다. 네임스페이스 충돌은 곧 GitLab 리소스 접근 오류로 이어지기 때문이다.
솔루션에서는 이를 다음과 같은 정책으로 매핑하고 있었다.
- GitLab 사용자(User)는 솔루션의 "사용자 아이디"를 기준으로 생성
- GitLab 그룹(Group)은 솔루션의 "업무 코드"를 기준으로 생성
즉, 사용자 아이디와 업무 코드는 서로 다른 도메인에 속해 있지만, 시스템 전체 관점에서는 동일한 네임스페이스를 공유하는 값이 된다. 그 결과, 사용자 아이디 ≠ 업무 코드 간 중복 불가라는 Cross-Domain 제약이 필수적으로 요구되는 상황이 되었다.
문제 상황
해당 솔루션은 MSA 구조로 구성되어 있었고, 전체적으로 약 8개의 모듈로 분리되어 있었다.
이 중 본 문제와 직접적으로 연관된 모듈은 다음 두 가지다.
- A 모듈: GitLab 연계 중심의 도메인 로직
- B 모듈: 사용자, 권한, 공통 기능을 담당하는 공통 모듈
운영 정책상 GitLab에 직접 접근하여 사용자나 그룹을 생성하는 행위는 허용되지 않았으며, 모든 GitLab 사용자 및 그룹 생성은 솔루션 웹 UI → B 모듈을 통해서만 이루어지도록 제한되어 있었다.
GitLab의 핵심 개념과 정책은 A 모듈에 존재했지만, Create, Update, Delete와 같은 실제 변경 작업은 B 모듈이 담당하는 구조였다.
특히 업무 코드는 단일한 등록 경로만 존재했지만, 사용자 등록은 다음과 같이 다양한 유입 경로를 가지고 있었다.
- 단건 사용자 등록
- 사용자 일괄 등록
- LDAP 연동을 통한 배치 자동 등록
- (고객사 납품 버전) 고객사 인사 시스템 연동
업무 코드는 단일한 생성 경로를 가지고 있었지만, 사용자 등록은 다양한 채널을 통해 이루어지는 구조였다. 이로 인해 사용자 아이디에 대한 중복 검증과 제약 조건이 여러 위치에 중복 구현되었고, 각 경로별로 검증 기준이 미묘하게 달라지는 상황이 반복되었다.
결과적으로 동일한 정책을 보장하기 어려운 구조가 되었고, 유지보수 시마다 검증 로직을 함께 수정해야 하는 부담이 누적되고 있었다.
[ Web UI ]
|
v
────────────────────────────────────────
| B Module |
| (Common / User / Authority / GitLab)|
────────────────────────────────────────
| | |
| | |
v v v
[User API] [Batch API] [LDAP / HR Sync]
| | |
| | |
(중복 검증) (중복 검증) (중복 검증)
| | |
| | |
v v v
[============== User Table ==============]
^
|
| (업무 코드 중복 여부 확인)
|
[ Task Code Table ]
그리고 결국 문제 상황은 발생했다.
각 등록 채널별로 사용자 아이디에 대한 검증 범위 (길이 제한, 형식, 동일 도메인 중복 여부, 크로스 도메인 중복 여부)가 완전히 동일하게 적용되지 않았고, 그 결과 등록된 데이터가 GitLab에서 오류를 발생시키며 시스템 간 데이터 싱크가 깨지는 문제가 발생했다.
해결 - 검증 조건 추출
각 채널 별로 검증의 항목과 수준(깊이)가 모두 달라서, 이를 하나로 통일해야 했다. 검증의 기준을 어디까지 바라볼지에 대한 결정이 우선이었다.
- 정상 포맷 여부
- 중복 여부
- 크로스 도메인 중복 여부
검증은 매번 동일한 수준으로 해야 했으므로, 하나로 묶어야 했고 개념은 사용이 가능하냐(Usable)의 수준으로 정의하고 진행하기로 했다.
사용자 아이디
- 포맷에 대한 제한
- GitLab 에서 사용자 아이디 포맷에 대한 제한
- 업무 코드 (크로스 도메인) 중복 여부
- 사용자 아이디 중복 여부
업무 코드
- 포맷에 대한 제한
- GitLab 에서 그룹명 포맷에 대한 제한
- 업무 코드 중복 여부
- 사용자 아이디 (중복 여부)
포맷 제한과, 동일 도메인 중복 여부, 타 도메인 중복 여부로 나뉜다.
해결 - 설계 패턴 선택과 의도
검증 로직을 통합하면서 가장 중요하게 고려한 점은 기존 코드의 최소 변경과 사용성의 극대화였다. 기존에는 각 채널별로 검증 로직이 분산되어 있었기 때문에, 새로운 통합 검증 컴포넌트를 도입할 때 다음과 같은 요구사항이 있었다. 솔루션이 여러 버전을 거쳐오면서, 미처 놓친 부분들이었다.
1. 접근성 (Accessibility)
// 기존: 각 채널마다 서로 다른 검증 방식
userService.validateUserId(userId); // A 채널
batchValidator.checkUserIdFormat(userId); // B 채널
ldapSync.isValidUserId(userId); // C 채널
// 목표: 어디서든 동일하게 사용 가능한 단일 인터페이스
UniqueValueValidator.isUsableUserId(userId);
정적 메서드를 목표로한 이유는 유틸리티 성격의 검증 로직을 어디서든 쉽게 호출할 수 있도록 하기 위함이었다. 특히 다양한 레이어(Controller, Service, Batch, Sync 등)에서 동일한 검증을 수행해야 했기 때문에, 의존성 주입으로 인한 복잡성보다는 접근성을 우선했다.
2. 의존성 관리 (Dependency Management)
@Component
@RequiredArgsConstructor
public class UniqueValueValidator {
private final TaskCdService taskCdService; // 업무코드 조회
private final UserService userService; // 사용자 조회
하지만 검증 로직 내부에서는 반드시 데이터베이스 조회가 필요했다. 순수한 static utility로는 Spring의 의존성 주입을 활용할 수 없었기 때문에, Spring Bean의 장점과 Static Method의 편의성을 모두 가져가는 하이브리드 패턴을 고려했다.
3. 다양한 응답 패턴(Result Pattern)
기존 검증 로직들을 분석해보니 검증 실패에 대한 처리 방식이 채널별로 달랐다.
일반적인 사용자 등록이라면, 즉시 예외 발생을 시켜 응답한다. 하지만, 사용자 일괄 등록을 하는 곳에서는, 각 사용자 별로 오류 원인을 일종의 배열에 추가하여 Response 에 추가하는 방식이었다.
이러한 이유 때문에, 하나의 API로 다양한 오류 처리 패턴을 지원하기 위해 Result Pattern도 고려했다.
4. 타입 안정성과 명확한 계약
public record ValidationResult(ValidationStatus status, Object data) {
// 성공 시: Boolean(true)
// 실패 시: ValidationExceptionType
}
검증 결과의 타입을 명확하게 정의함으로써 컴파일 타임에 오류 처리 누락을 방지하고, 각 검증 실패 원인에 대한 구체적인 정보를 제공할 수 있게 했다.
5. 성능 최적화된 검증 순서
// 1. 정규식 검증 (메모리 내, 가장 빠름)
if(isInvalidUserIdFormat(userId)) {
return ValidationResult.exception(ValidationExceptionType.INVALID_USER_ID_FORMAT);
}
// 2. GitLab 포맷 검증 (메모리 내, 두 번째로 빠름)
if(isInValidForGitLabUserId(userId)) {
return ValidationResult.exception(ValidationExceptionType.INVALID_GITLAB_USER_ID_FORMAT);
}
// 3. 데이터베이스 조회 (가장 비용이 큰 작업을 마지막에)
if(isDuplicateTaskCd(userId)) {
return ValidationResult.exception(ValidationExceptionType.USER_ID_USED_AS_TASK_CODE);
}
Fail-Fast 원칙을 적용하여 비용이 적은 검증부터 수행하고, 실패 시 즉시 리턴하도록 설계했다. 이를 통해 불필요한 데이터베이스 조회를 방지할 수 있었다.
6. 확장성과 유지보수성
각 검증 단계를 독립적인 메서드로 분리함으로써, 새로운 검증 규칙 추가가 용이해지고, 기존 검증 로직 수정 시 영향 범위 최소화되며, 각 검증 단계별 단위 테스트 작성 가능해졌다.
결과적으로, 다양한 채널로 유입되는 데이터에 대해 일관된 검증을 보장하면서도 각 채널별 특성에 맞는 유연한 오류 처리를 지원하기 위해 Static + Dependency Injection 하이브리드 패턴과 Result Pattern을 결합한 설계를 선택했다.
구현 - UniqueValueValidator 구조
앞서 정의한 검증 조건과 설계 방향을 바탕으로, 모든 채널에서 공통으로 사용할 수 있는 통합 검증 컴포넌트인 UniqueValueValidator를 구현했다.
이 컴포넌트의 역할은 단순히 값을 검증하는 것이 아니라, 사용자 아이디와 업무 코드가 시스템 전체에서 '사용 가능한 상태(Usable)'인지 판단하는 것이다.
구현 아키텍처
┌──────────────────────────────────────────────────────────────────────────────────────────┐
│ UniqueValueValidator (Spring @Component) │
│ ┌─────────────────────────────────────────────────────────────────────────────────────┐ │
│ │ 정적 API (static) │ instance (PostConstruct 주입) │ │
│ │ • isUsableUserId(userId) │ → isUsableUserIdInstance(userId) │ │
│ │ • isUsableTaskCode(taskCode) │ → isUsableTaskCodeInstance(taskCode) │ │
│ └─────────────────────────────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────┬───────────────────────────────────────────────────┘
│
┌───────────────────────────┼───────────────────────────┐
▼ ▼ ▼
┌──────────────────────┐ ┌──────────────────────┐ ┌──────────────────────────────────────┐
│ ValidationResult │ │ Persistence (DAO) │ │ 상수/Enum (비즈니스 규칙) │
│ • success(Boolean) │ │ TaskCdQueryService │ │ UserInfoConstants.USER_ID_REGEX │
│ • exception(Type) │ │ UserQueryService │ │ GitLabFilteredGroupString │
│ • getResultOrThrow()│ │ → DB 조회 (중복 체크) │ │ GITLAB_USER_ID_PATTERN (내부) │
└──────────────────────┘ └──────────────────────┘ └──────────────────────────────────────┘
│
▼
┌────────────────────────┐ ┌──────────────────────┐
│ ValidationExceptionType│ │ErrorCodeCommonApi │
│ (실패 사유 enum) │ │SolutionException 변환 │
└────────────────────────┘ └──────────────────────┘
정적 메소드로 호출 구현
클래스명.메소드() 형태의 편한 정적 API를 제공하면서도, 내부는 Spring이 생성·주입한 하나의 빈을 쓰기 때문에 DI와 트랜잭션/DB 접근이 그대로 동작한다.
UniqueValueValidator는 @Component이므로 Spring이 생성하고 TaskCdService, UserService를 주입한다. 그리고 instance는 항상 Spring이 생성한 그 하나의 빈만 참조한다. 또한, 여러 곳에서 UniqueValueValidator.isUsableUserId(...)를 호출해도 같은 인스턴스가 사용되므로, 설정·상태가 일관된다.
@Slf4j
@Component
@RequiredArgsConstructor
public class UniqueValueValidator {
private TaskCdQueryService taskCdQueryService;
private UserQueryService userQueryService;
// 정적 메소드에서 사용할 인스턴스 참조
private static UniqueValueValidator instance;
@PostConstruct
public void init() {
instance = this;
}
/**
* 사용 가능한 사용자 아이디인지 확인 (정적 메소드)
*
* ...중략
*/
public static ValidationResult isUsableUserId(String userId) {
if (instance == null) {
throw new IllegalStateException("UniqueValueValidator가 초기화되지 않았습니다. Spring 컨텍스트에서 빈이 생성되었는지 확인하세요.");
}
return instance.isUsableUserIdInstance(userId);
}
/**
* 사용 가능한 업무코드인지 확인 (정적 메소드)
*
* ...중략
*/
public static ValidationResult isUsableTaskCode(String taskCode) {
if (instance == null) {
throw new IllegalStateException("UniqueValueValidator가 초기화되지 않았습니다. Spring 컨텍스트에서 빈이 생성되었는지 확인하세요.");
}
return instance.isUsableTaskCodeInstance(taskCode);
}
검증 성공/실패 응답의 유연한 구조
/**
* 검증 결과를 담는 응답 Record
* 성공 시에는 Boolean 타입, 실패 시에는 ValidationExceptionType을 반환
*
* @param status 검증 상태 (SUCCESS 또는 EXCEPTION)
* @param data 검증 결과 데이터 (성공: Boolean, 실패: ValidationExceptionType)
*/
public record ValidationResult(
ValidationStatus status,
Object data
) {
/**
* 검증 성공 시 결과 생성
*
* @param success 성공 여부 (항상 true)
* @return ValidationResult 인스턴스
*/
public static ValidationResult success(Boolean success) {
return new ValidationResult(ValidationStatus.SUCCESS, success);
}
/**
* 검증 실패 시 결과 생성
*
* @param exceptionType 실패 원인 enum
* @return ValidationResult 인스턴스
*/
public static ValidationResult exception(ValidationExceptionType exceptionType) {
return new ValidationResult(ValidationStatus.EXCEPTION, exceptionType);
}
/**
* 성공인지 확인
*
* @return 성공 여부
*/
public boolean isSuccess() {
return status == ValidationStatus.SUCCESS;
}
/**
* 실패인지 확인
*
* @return 실패 여부
*/
public boolean isException() {
return status == ValidationStatus.EXCEPTION;
}
/**
* 성공 데이터 반환 (타입 안전성을 위한 메소드)
*
* @return Boolean 값 (성공 시에만 호출)
*/
public Boolean getSuccessData() {
if (isSuccess()) {
return (Boolean) data;
}
throw new IllegalStateException("성공 상태가 아닐 때는 getSuccessData()를 호출할 수 없습니다.");
}
/**
* 실패 데이터 반환 (타입 안전성을 위한 메소드)
*
* @return ValidationExceptionType (실패 시에만 호출)
*/
public ValidationExceptionType getExceptionData() {
if (isException()) {
return (ValidationExceptionType) data;
}
throw new IllegalStateException("실패 상태가 아닐 때는 getExceptionData()를 호출할 수 없습니다.");
}
/**
* 검증 실패 시 SolutionException으로 변환하여 던지기
* 성공 시에는 아무것도 하지 않음
*
* @throws SolutionException 검증 실패 시
*/
public void throwIfFailed() {
if (isException()) {
ValidationExceptionType exceptionType = getExceptionData();
throw convertToSolutionException(exceptionType);
}
}
/**
* 성공이 아닌 경우 SolutionException 던지기
* isSuccess()가 false인 경우 예외 던짐
*
* @throws SolutionException 검증 실패 시
*/
public void throwIfNotSuccess() {
if (!isSuccess()) {
ValidationExceptionType exceptionType = getExceptionData();
throw convertToSolutionException(exceptionType);
}
}
/**
* 검증 결과를 boolean으로 반환
* 실패 시 SolutionException을 던지고, 성공 시 true 반환
*
* @return 성공 시 true
* @throws SolutionException 검증 실패 시
*/
public boolean getResultOrThrow() {
if (isException()) {
ValidationExceptionType exceptionType = getExceptionData();
throw convertToSolutionException(exceptionType);
}
return getSuccessData();
}
/**
* ValidationExceptionType을 SolutionException으로 변환
*
* <p>각 검증 오류 타입별로 적절한 HTTP 상태 코드와 메시지를 매핑합니다:</p>
* <ul>
* <li>DUPLICATE_TASK_CODE: 409 Conflict - 업무코드 중복</li>
* <li>DUPLICATE_USER_ID: 409 Conflict - 사용자 중복</li>
* <li>나머지: 400 Bad Request - 잘못된 요청 형식</li>
* </ul>
*
* @param exceptionType 변환할 예외 타입
* @return 적절한 ErrorCode가 설정된 SolutionException
*/
private static SolutionException convertToSolutionException(ValidationExceptionType exceptionType) {
return switch (exceptionType) {
// 중복 관련 오류 - 409 Conflict
case DUPLICATE_TASK_CODE ->
new SolutionException(ErrorCodeCommonApi.TASK_CD_ALREADY_IN_USE);
case DUPLICATE_USER_ID ->
new SolutionException(ErrorCodeCommonApi.USER_ALREADY_EXIST);
// 포맷/형식 관련 오류 - 400 Bad Request
case GITLAB_RESERVED_WORD,
TASK_CODE_USED_AS_USER_ID,
INVALID_USER_ID_FORMAT,
INVALID_GITLAB_USER_ID_FORMAT,
USER_ID_USED_AS_TASK_CODE ->
createBadRequestException(exceptionType);
};
}
/**
* BAD_REQUEST 타입의 SolutionException 생성 헬퍼 메소드
*
* @param exceptionType 예외 타입
* @return SolutionException
*/
private static SolutionException createBadRequestException(ValidationExceptionType exceptionType) {
return new SolutionException(
ErrorCodeCommonApi.BAD_REQUEST,
exceptionType.getMessage(), // rsltMsg로 사용
exceptionType.getCode() // rsltDetailMsg로 에러 코드 사용
);
}
/**
* 검증 상태 enum
*/
public enum ValidationStatus {
SUCCESS,
EXCEPTION
}
}
ValidationResult는 검증 성공과 실패를 동일한 타입으로 표현하면서, 호출부에서 성공/실패에 따라 다른 방식으로 처리할 수 있도록 설계했다. 공통 구조로는 status(SUCCESS / EXCEPTION)와 data(Object) 한 쌍으로 모든 검증 결과를 표현한다.
성공 시, data는 Boolean(사용 가능 여부)을 담고, getSuccessData()로 타입을 안전하게 꺼낼 수 있도록 하였다. 실패 시, data는 ValidationExceptionType을 담고, getExceptionData()로 실패 사유(코드·메시지)를 꺼내도록 했다.
이 구조 덕분에 단일 반환 타입으로 세 가지 흐름을 모두 지원한다.
- 결과만 확인
- isSuccess() / isException()으로 분기 후 getSuccessData() 또는 getExceptionData()로 데이터 활용
- 실패 시 예외로 전환
- getResultOrThrow()로 성공이면 true 반환, 실패 시 ValidationExceptionType에 맞는 SolutionException 발생
- 명시적 예외 처리
- throwIfFailed() / throwIfNotSuccess()로 “실패일 때만 예외” 또는 “성공이 아니면 예외”로 제어
즉, API 응답으로 내려줄지, 바로 예외를 던질지, 비즈니스 로직에서 분기만 할지를 호출하는 쪽에서 선택할 수 있어, 검증 결과를 다양한 레이어와 시나리오에서 유연하게 사용할 수 있도록 지원한다.
이렇게 설계하여 기존 채널에서 결과에 따른 처리 방식(예외, 오류 사항 확인과 저장)에 대한 케이스를 대응할 수 있게 되었다.
UniqueValueValidator 사용
Zero Configuration
검증이 필요한 순간, 어떤 클래스에서든 추가 설정 없이 바로 검증 로직을 사용할 수 있다.
public void someMethod() {
UniqueValueValidator.isUsableUserId("test_user").getResultOrThrow();
}
일관된 검증 기준
간편하게 호출하고, 검증 기준을 각 단계 별로 나누어 호출하지 않아, 기준이 일관성이 완벽하게 보장된다.
// 업무코드 서비스에서
UniqueValueValidator.isUsableTaskCode(taskCd.getTaskCd()).getResultOrThrow();
// 사용자 컨트롤러에서
UniqueValueValidator.isUsableUserId(userId).getResultOrThrow();
// 배치 처리에서
ValidationResult result = UniqueValueValidator.isUsableUserId(userId);
유연한 오류 처리
기존 사용자 일괄 등록 배치에서도 세밀하게 오류를 처리할 수 있도록 지원한다.
// 배치 처리에서 - 오류 타입별 분기 처리
ValidationResult result = UniqueValueValidator.isUsableUserId(userId);
if(!result.isSuccess()) {
ValidationExceptionType errorType = result.getExceptionData();
if(errorType == ValidationExceptionType.INVALID_USER_ID_FORMAT) {
// 포맷 오류 처리
} else if (errorType == ValidationExceptionType.DUPLICATE_USER_ID) {
// 중복 오류 처리
}
}
결론
이 문제의 본질은 단순히 중복 검증이 누락되었다는 점이 아니라, 검증 정책을 일관되게 대표하고 적용할 수 있는 구조가 존재하지 않았다는 데 있었다.
사용자 아이디와 업무 코드는 서로 다른 도메인에 속해 있었지만, GitLab 네임스페이스라는 외부 제약으로 인해 시스템 전체 관점에서는 동일한 식별자 공간을 공유하고 있었다. 그럼에도 검증 로직은 각 등록 채널과 흐름에 따라 B 모듈 내부 여러 지점에 분산되어 있었고, 이는 검증 기준의 불일치와 연계 시스템 오류로 이어질 수밖에 없는 구조였다.
UniqueValueValidator는 이러한 문제를 해결하기 위해 검증 로직을 하나의 정책 단위로 수렴시키고, 값이 ‘사용 가능한 상태(Usable)’인지를 판단하는 책임을 명확히 한 컴포넌트다. Static + Dependency Injection 하이브리드 패턴과 Result Pattern을 결합함으로써, 기존 코드 변경을 최소화하면서도 모든 채널에서 동일한 검증 기준과 유연한 오류 처리를 제공할 수 있었다.
그 결과 GitLab 네임스페이스 충돌로 인한 오류는 재발하지 않았으며, 검증 정책 변경이나 신규 채널 추가 시에도 수정 범위가 단일 지점으로 제한되는 구조를 확보할 수 있었다.
이번 경험을 통해, 검증 로직은 단순한 보조 유틸리티가 아니라 도메인 정책을 시스템 전반에 일관되게 적용하기 위한 핵심 아키텍처 요소이며, “어디에서 검증할 것인가”보다 “누가 검증의 책임을 가지는가”를 먼저 정의하는 것이 시스템의 안정성과 유지보수성에 결정적인 영향을 미친다는 점을 다시 한 번 확인할 수 있었다.
- Total
- Today
- Yesterday
- springboot
- Spring
- 자바스크립트
- java
- AWS
- 알고리즘
- docker
- oracle
- 쿼리
- 로그
- 크롤링
- MongoDB
- nodejs
- 파이썬
- 스프링부트
- MySQL
- 톰캣
- 오라클
- JavaScript
- 도커
- 쿠버네티스
- 마리아디비
- 스프링
- Minikube
- 클라우드
- BeautifulSoup
- k8s
- kubernetes
- mariadb
- server
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | 7 |
| 8 | 9 | 10 | 11 | 12 | 13 | 14 |
| 15 | 16 | 17 | 18 | 19 | 20 | 21 |
| 22 | 23 | 24 | 25 | 26 | 27 | 28 |