3/30(월) 4계층 패키지 구조, 낙관락, 분산락

dev_joo·2026년 3월 30일

시저암호

문제분석

먼저 조건을 꼼꼼히 읽고 문제가 어떤 풀이를 요구하는지 생각해보았다.

문자열 s와 거리 n을 입력받아 sn만큼 민 암호문을 만드는 함수
s는 알파벳 소문자, 대문자, 공백으로만 이루어져 있습니다.
1. "z"는 1만큼 밀면 "a"가 됩니다. → 원형 큐 형태
2. 공백은 아무리 밀어도 공백입니다. → 따로 처리 필요
3. n은 1 이상, 25이하인 자연수입니다. → 알파벳 범위 내에서 순환 → % 26

문자열 밀기?

문제에서 주어진 동작 문자열 밀기를 어떻게 코드 상에서 구현할까 했다.
먼저 ASCII 숫자를 증가시켜 다음 문자로 밀면 되겠다 싶었지만
숫자는 숫자,소문자는 소문자끼리 순환해야한다.

그래서 문자의 각 끝 지점마다 다음으로 밀면 다시 처음의 문자로 돌아가는 처리를 따로 해주고, 나머지는 ASCII증가로 처리해보기로 했다.

class Solution {
    public String solution(String s, int n) {
        String answer = "";
        for(char c : s.toCharArray()){
            if(c == 'z' || c == 'Z'){}
        }
        return answer;
    }
}

하지만 이러면 예를 들어 n이 3 이상이라면,
z 이전의 x, y 도 한 바퀴를 돌아야 하지만 처리가 되지 않는다.

문자를 밀어 범위를 넘겼으면 다시 끌어오기

class Solution {
    public String solution(String s, int n) {
        StringBuilder sb = new StringBuilder();

        for(char c : s.toCharArray()){
            if(c == ' ') { sb.append(c); continue;}
            int C = c + n; // 변경 후 문자
            if ((c <= 'Z' && C > 'Z') ||
                 (c >= 'a' && C > 'z')) {
                    sb.append((char)(C - 26)); 
            }
            else {
                sb.append((char)(c + n));
            }
        }
        return sb.toString();
    }
}

% 연산 사용

% 연산을 사용하면 알파벳 울타리(26개)를 벗어났는지 일일이 검사할 필요 없이, 모든 문자를 하나의 수식으로 처리할 수 있다.

기준점(base)을 0으로 맞춘 뒤 n만큼 더하고 다시 26으로 나눈 나머지를 구하면, 범위를 넘어가도 자동으로 처음(a 또는 A)으로 돌아온다.

class Solution {
    public String solution(String s, int n) {
        StringBuilder sb = new StringBuilder();

        for (char c : s.toCharArray()) {
            if (c == ' ') {
                sb.append(c);
                continue;
            }

            // 기준점 - 대문자 : A, 소문자 : a;
            char base = (c <= 'Z') ? 'A' : 'a';
            
            char shifted = (char) ((c - base + n) % 26 + base);
            
            sb.append(shifted);
        }
        return sb.toString();
    }
}

MDC (Mapped Diagnostic Context)

목적: 멀티쓰레드 환경에서 로그의 문맥(Context)을 유지하여 추적성을 확보한다.

MDC에 포함되는 정보 (주로)

로그 출력 시 어떤 요청에서 발생한 일인지 식별하기 위해 주로 다음 정보를 담는다.

- traceId: 요청당 부여되는 고유 식별값
- requestUri: 요청이 들어온 엔드포인트
- httpMethod: 호출 방식 (GET, POST 등)

Thread Local과 비동기 처리

MDC는 내부적으로 ThreadLocal을 사용한다. 따라서 비동기(@Async) 처리를 할 경우 쓰레드가 바뀌면서 기존 MDC 정보가 소실되는데,
이를 해결하기 위해 Task Decorator를 사용한다.

부모 쓰레드의 MDC를 자식 쓰레드로 복사해줌으로써 로그의 연속성을 보장한다.


공통 모듈 (Common Module) 관리

설정 및 초기화

  • AppCtx: 스프링 부트 실행 시 공통 모듈의 설정 파일(Yaml 등)을 인식하여 초기화하도록 구성한다.
  • Component Scan: 메인 패키지 외부에 공통 모듈이 있다면 스캔 범위를 명시적으로 지정해야 빈(Bean)으로 인식된다.

의존성 주입: 자동 등록 vs 수동 등록

구분자동 빈 등록 (Component Scan)수동 빈 등록 (@Configuration)
방식@Component 등 어노테이션 사용@Bean 직접 정의
특징편리하고 빠름의존성 관계가 명확하고 선택적 로드 가능

[핵심 논리]
공통 모듈의 모든 기능을 자동 등록하면 사용하지 않는 서비스에서도 불필요한 빈이 생성된다. 기존 서비스의 의존성을 침범하지 않도록, 공통 모듈은 인터페이스와 구현체만 제공하고 사용하는 서비스에서 필요한 것만 수동 등록하여 주입하는 방식이 더 안전하다.


Swagger 문서화

  • 요청/응답 DTO에 @Schema 등의 어노테이션을 활용해 문서화를 진행한다.
  • API의 명세가 변경될 때마다 DTO 레벨에서 즉각 반영되어야 협업 시 혼선이 없다.

서버 분리의 기준: Bounded Context

MSA 설계의 핵심 경계인 Bounded Context를 기준으로 서버를 나눈다.
보통 하나의 Bounded Context당 하나의 마이크로서비스를 할당하여 시스템 간의 결합도를 최소화한다.
일관성 유지를 위해 바운디드 컨텍스트는 하나의 애그리거트 루트로만 접근한다.


4계층 패키지 구조로 보는 구현단계

[1] Domain 계층 (비즈니스 모델 개념화)

비즈니스의 핵심 규칙을 정의하는 '레시피' 단계로, 외부 기술에 의존하지 않는 순수한 자바 객체들로 구성된다.

모델링 도구:

  • 엔티티(Entity): 비즈니스의 핵심 개념. 식별자를 가짐.
  • VO(Value Object): 작은 개념 묶음. 불변성을 가지며 자체 도메인 로직을 포함할 수 있다. (예: 주소, 금액 등)

1. 루트 애그리거트 (Entity)정의: 해당 도메인에서 보호해야 할 비즈니스 규칙을 정리한다. 도메인 계층의 도메인 로직은 할 일만 알려주는 '레시피' 역할만 하며, 실제 실행은 서비스 계층이 담당한다.

2. Aggregate에 사용되는 VO 구성: VO로 각자의 데이터와 로직을 캡슐화한다. 엔티티의 Id도 VO가 될수도 있다. VO 안에도 VO가 포함될 수 있다.
정적 팩토리 메서드(of, from)를 통해 유효한 객체 생성을 강제한다.

정적 팩토리 메서드 (Static Factory Method)와 VO

VO는 불변 객체여야 하며, 생성 시점에 모든 값이 유효해야 한다.
이를 위해 생성자를 직접 노출하기보다 private으로 감추고,
의미 있는 이름을 가진 정적 팩토리 메서드를 통해 객체를 생성하도록 강제한다.

  • 장점: - 메서드 이름을 통해 생성 의도를 명확히 드러낼 수 있다. (예: of, from, createDefault)
    • 생성 로직 내에서 값의 유효성 검증(Validation)을 수행하여 잘못된 객체가 생성되는 것을 원천 차단한다.
    • 내부 구현을 캡슐화하여 클라이언트 코드가 도메인 모델의 세부 구조에 의존하지 않게 한다.

VO의 생명주기와 애그리거트

VO는 독립적인 식별자가 없으므로, 스스로 존재할 수 없다. VO의 생명주기는 해당 VO를 포함하고 있는 애그리거트 루트(Aggregate Root)의 생명주기와 완전히 일치해야 한다.

  • 일관성 유지: 애그리거트 루트가 생성될 때 VO도 함께 생성되고, 루트가 삭제될 때 VO도 함께 사라진다.
  • 불변성 보장: VO의 상태를 변경해야 한다면, 기존 VO를 수정하는 것이 아니라 새로운 VO 객체를 생성하여 교체하는 방식을 취한다. 이는 애그리거트 내의 데이터 일관성을 유지하는 가장 안전한 방법이다.

3. 도메인 서비스: User 관점에서 다른 서비스의 개념을 객체로 생성한다.
다른 서비스의 정보가 필요하거나, 특정 엔티티에 귀속시키기 모호한 비즈니스 로직을 정의한다. (정보를 가져오는 방식은 FeignClient나 Event 처리 등 마음대로 한다.)

4. 도메인 이벤트: 상태 변화를 외부에 알릴 이벤트 객체를 정의한다.(예: OrderCreated)

[2] Infrastructure 계층 (외부 기술 구현)

도메인에서 정의한 규칙들이 실제 세상(DB, 메시징 시스템 등)과 만나는 지점이다.

  • 외부 라이브러리(JPA, Kafka 등)와의 연동을 담당한다.
  • CQRS 패턴: 성능 최적화를 위해 명령(Repository)과 조회(Query Repository)를 분리한다. 조회가 잦은 기능은 MyBatis나 Querydsl로 성능을 높이고, 필요시 Elasticsearch 등으로 쉽게 교체할 수 있는 구조를 만든다.

도메인 서비스(Domain Service) vs 응용 서비스(Application Service)

두 서비스는 모두 '로직'을 담고 있지만, 그 성격과 책임이 명확히 다르다.

구분도메인 서비스응용 서비스 (Application Service)
핵심 역할비즈니스 요구사항의 개념화 및 규칙 정의비즈니스 로직의 실행 및 흐름 제어
의존성외부 구현 기술이나 프레임워크에 의존하지 않음Infrastructure(DB, 메시징 등)와 도메인을 연결
주요 특징한 애그리거트에 담기 모호한 로직을 처리실제 사용자 요청이 들어올 때 실행됨
비유요리의 레시피 (재료의 조합과 순서 규칙)요리사 (레시피대로 재료를 가져와 요리함)

1. 도메인 서비스가 필요한 경우

  • 비즈니스 로직이 여러 애그리거트를 가로질러 실행될 때 (예: 계좌 이체 로직)
  • 외부 시스템(FeignClient 등)의 정보가 도메인 로직 판단의 근거가 될 때
  • 특정 엔티티의 상태 변경만으로는 설명하기 힘든 도메인 개념을 구현할 때

2. 응용 서비스가 필요한 경우

  • 프레젠테이션 계층(Controller)에서 요청을 받아 도메인 모델에 일을 시킬 때
  • 트랜잭션 관리, 보안 설정, 로깅 등 기술적인 인프라 처리가 필요할 때
  • 도메인 서비스를 호출하여 전체적인 비즈니스 프로세스의 '흐름'을 완성할 때

5. 리포지토리(Repository) 구현: 도메인 계층에서 선언한 인터페이스를 JPA 등을 이용해 실제로 구현하고 DB와 연결한다.

Query 패키지

CQRS 패턴
Repository - 명령 담당, 상태관리: 추가,수정,삭제
'Query' Repository - 조회
성능을 높이기 위해선 빈도가 많은 조회쪽의 성능을 높여야 한다.

분리해서 쉽게 교체가 이루어지게 함.
예를 들어 추가,수정,삭제는 JPA를 사용하고, 조회에는 MyBatis 사용하여 명령 책임을 분리한다. 분리된 조회 쪽엔 엘라스틱 서치등을 추가할 수도 있다.

5+. CQRS 및 Query 패키지 구성: 명령(CUD)과 조회(R) 책임을 분리한다.

[3] Application 계층 (전체 흐름 지휘)

구현체가 무엇이든 상관하지 않고 비즈니스 흐름을 지휘하는 곳이다.

  • 지휘자 역할: 요리사 입장에서 재료의 출처를 따지지 않듯, 구현 기술에 의존하지 않고 로직을 실행한다.
  • 도메인 로직 소비: 외부 서버의 최소 정보를 가지고 내부의 도메인 로직을 소비한다.
  • 이벤트 관리: 이곳에서 이벤트 등록과 처리의 흐름만을 정리한다.

6. 응용 서비스(Application Service) 작성:
도메인 객체들에게 일을 시키고, 트랜잭션 관리와 보안 등 인프라와 도메인을 연결하는 흐름을 작성한다.

7. 이벤트 발행 및 소비 로직:
도메인 계층에서 정의한 이벤트를 실제로 던지거나(발행 Publish) 받는 (구독(Subscribe)) 흐름을 작성한다.

[4] Presentation 계층 (사용자 접점)

사용자가 실제 요청을 보냈을 때 처리의 흐름을 정의한다.

  • Controller 작성: 요청 창구 역할을 수행한다.
  • DTO 분리: 외부 API 스펙 변경이 내부 로직에 영향을 주지 않도록 Application 계층과 DTO를 따로 가진다.
  1. DTO 설계 및 Controller 작성:
    외부 API 스펙에 맞춘 DTO를 만들고, 요청을 받아 Application 계층으로 넘겨주는 컨트롤러를 구현한다.

낙관락

데이터 충돌이 적을 것이라 가정하고 JPA의 @Version 등을 활용해 제어하는 방식이다.

낙관락 예외가 발생했을 때 처리

낙관락 예외가 발생했을 때 성공할 때 까지 재시도를 해야한다고 생각했는데
대신 사용자에게 다시 시도해야 함을 전달하는 것이 원칙이다.

낙관적 락: 왜 이벤트처럼 자동 재시도(Retry)로 보정하지 않는가?

이벤트 기반 처리에서는 '어떻게든 성공'시키는 것이 중요하지만, 수정 로직에서는 '사용자가 보고 있는 데이터의 시점'이 곧 정합성의 기준이기 때문이다.

  • 이유: 사용자는 본인이 확인한 데이터(v1)를 기반으로 수정을 결정한다. 만약 시스템이 스스로 재시도해서 후속 요청(v2) 위에 덮어씌워 버린다면, 그것은 정합성 보정이 아니라 앞선 사용자의 요청을 무단으로 삭제하는 것과 같다.
  • 결론: 낙관적 락에서 발생하는 에러는 실패가 아니라, "데이터 시점이 맞지 않으니 최신 상태를 다시 소비(Read)하여 판단하라"는 적극적인 정합성 유도 장치이다. 따라서 시스템이 임의로 재시도하지 않고 사용자에게 제어권을 넘겨야 한다.

분산락 (Distributed Lock)

여러 서버 환경에서 확실한 자원 격리가 필요할 때는 Redisson 등을 활용해 분산락을 구현한다.

profile
풀스택 연습생. 끈기있는 삽질로 무대에서 화려하게 데뷔할 예정 ❤️🔥

0개의 댓글