본문 바로가기
web/AI

[AI] Sample App(Backend) 만들기..

by 뽀리님 2026. 2. 5.

에이전트를 위한 Sample APP Backend 부터 먼저 만들어보기로 했다.

 

[개발환경]

OS : MacOS
AI IDE : Antigravity
SpringBoot 3.5 + Gradle
JDK21
H2 DB
DDD 구조

 

 

Sample 백엔드로 회원가입+로그인을 지원하는 공지사항 게시판을 구해보기로 하자.

 

 

후 처음에 열심히 짰는데도 불구하고

AI 🐕XX들… 내 소스에서 레거시 냄새난다고 그래서 진정한 DDD 클린아키텍처로 Sample 소스 짜본다..

 

사실 기능도 별거 없는 Sample 인데.. 기껏해봐야 ㅋㅋ 로그인+회원가입/공지사항 CRUD 이게 다인뎈ㅋㅋㅋ

 

RAG를 위해 교과서적으로 만들다 보니 구조 잡는데만 몇일 걸린거같다.

일단 내가 예전에 짠 스타일의 코드를 예시로 보여주겠다.

 

/**
 * UserService
 *
 * @author : 
 * @version 1.0
 * @since 2025-06-30
 */
@Slf4j
@Service
@RequiredArgsConstructor
public class UserService {
    private final UserRepository userRepository;
    private final CustomAuthenticationProvider authenticationProvider;
    private final PasswordEncoder passwordEncoder;
    private final AuthServiceClient authServiceClient;

    /**
     * 회원가입
     *
     * @param userDto
     * @return
     */
    @Transactional
    public CommonResponse registUserInfo(UserDto userDto) {
        log.debug("[사용자관리] 회원 가입 User ID: {}", userDto.getUserId());
        // 중복체크
        checkIdDuplication(userDto.getUserId());
        userDto.setPassword(passwordEncoder.encode(userDto.getPassword()));
        userDto.setRole(UserRole.USER);

        // 사용자생성
        authServiceClient.registerUser(
            AuthServiceUserRequestDto.builder()
                .userId(userDto.getUserId())
                .name(userDto.getName())
                .email(userDto.getEmail())
                .password(userDto.getPassword())
                .roles(List.of(userDto.getRole().name()))
                .build()
        );
        // 사용자등록
        userRepository.save(UserEntity.builder()
                            .userId(userDto.getUserId()).password(userDto.getPassword())
                            .createId(userDto.getUserId()).updateId(userDto.getUserId())
                            .name(userDto.getName()).email(userDto.getEmail()).build());
        log.debug("[사용자관리] 등록완료");
        return CommonResponse.ok();
    }


    /**
     * 로그인 수행
     * @param request
     * @return
     */
    @Transactional
    public CommonResponse loginProcess(LoginDto request) {
        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
            request.getUserId(), request.getPassword());
        Authentication authentication = authenticationProvider.authenticate(authenticationToken);
        updateUserLoginInfo(request.getUserId());
        LoginResponse response = new LoginResponse();
        Object principal = authentication.getPrincipal();
        if (principal instanceof CustomUserPrincipal userPrincipal)
            response = userPrincipal.getLoginResponse();
        return CommonResponse.ok().data(ConvertUtils.dtoToMap(response));
    }

    /**
     * 회원 조회
     *
     * @param userId
     * @return
     */
    private UserDto getUserInfo(String userId) {
        return UserMapper.MAPPER.toDto(
            userRepository.findByUserId(userId).orElse(new UserEntity()));
    }


    /**
     * ID 중복체크
     *
     * @param userId
     * @return
     */
    public CommonResponse checkIdDuplication(String userId) {
        String existId = getUserInfo(userId).getUserId();
        if (userId.equals(existId))
            throw new CommonException(ErrorCode.USER_ID_DUPLICATED);
        return CommonResponse.ok("사용가능한 ID 입니다.");
    }

    /**
     * 비밀번호 변경
     *
     * @param changeDto
     * @return
     */
    @Transactional
    public CommonResponse changePassword(LoginDto.ChangePasswordDto changeDto) {
        UserEntity userInfo = userRepository.findByUserId(changeDto.getUserId())
            .orElseThrow(() -> new CommonException(ErrorCode.USER_NOT_FOUND));
        if (!passwordEncoder.matches(changeDto.getCurrentPassword(), userInfo.getPassword()))
            throw new CommonException(ErrorCode.USER_PASSWORD_MISMATCH);
        // 비밀번호 변경
        userInfo.setPassword(passwordEncoder.encode(changeDto.getNewPassword()));
        return CommonResponse.ok("비밀번호가 성공적으로 변경되었습니다.");
    }

    /**
     * 사용자 상태 변경
     * @param dto
     * @return
     */
    @Transactional
    public CommonResponse updateUserStatus(UserDto dto) {
        if (StringUtils.isEmpty(getUserInfo(dto.getUserId()).getUserId()))
            throw new CommonException(ErrorCode.USER_NOT_FOUND);
        userRepository.save(
            UserEntity.builder().userId(dto.getUserId()).status(dto.getStatus()).build());
        return CommonResponse.ok();
    }

    /**
     * 접속시간 업데이트
     * @param id
     */
    private void updateUserLoginInfo(String id) {
        userRepository.updateLastLoginTime(id, LocalDateTime.now());
    }
}

 

⇒ 이 소스에 뭐가 잘못된지 알겠는가? ㅋㅋ 난 사실 오? 깔끔하군 이라 생각했지만 ㅋㅋㅋ 아주크나큰 착각이었지….

 

이건 그냥

스프링이 시키는 대로 짰어요~(Controller-Service-Dao) 패턴,

즉 전형적인 트랜잭션 스크립트 패턴이다. 2010년대 중반 SI 프로젝트에서 아주 흔하게 보던 패턴이다.

 

나는 아직도 여기서 벗어나지 못했던거지….

 

일단 이 Style 의 문제를 분석해보자.

 

✅ 문제점

① 도메인 모델이 멍청함 (Anemic Domain Model)

DDD의 핵심은 Domain이 주인공이다. 비즈니스 로직을 도메인에 넣어라!!! 이다.

근데 저 레거시 코드를 봐보자 Entity는 그냥 데이터 셔틀이다. 로직이 1도 없고 builder로 값만 우겨넣고 있다. 서비스에서 아주 북치고 장구치고 다 하는 구조이다.

 

 

② 계층 침투 (Layer Leakage)

UserService… 넌 너무많은걸 알고있어…

Service 레이어는 순수 비즈니스 흐름만 제어해야 하는데, Controller와 DB쪽 계층이 아주 혼합되어있다.

저기 파라미터로 UserDto, LoginDto 같은 컨트롤러용 View 객체가 서비스에 엮여있다. 이말은 즉슨 나중에

DTO가 바뀌면 서비스 로직도 바껴야 한단소리다. 의존적인 관계가 된다.

또 Spring 시큐리티 인증처리기가 저기에있다. 저건 인증 필터쪽이나 인증서비스 담당이지 이 UserService가 알 바가 아니다 ㅋㅋ

 

 

③ 치명적인 버그 .. (updateUserStatus) 이거 진짜 위험하다.

userRepository.save(UserEntity.builder().userId(dto.getUserId()).status(dto.getStatus()).build());

JPA save는 식별자(userId)가 있으면 merge를 시도하거나 덮어쓴다.

만약 UserEntity에 @DynamicUpdate가 안 붙어있거나 영속성 컨텍스트 관리가 꼬이면, 이름, 이메일, 비밀번호 다 NULL로 날아가고 상태값만 남은 좀비 데이터가 될 수도 있다.

(조회해서 변경 감지(Dirty Checking)로 풀어야지, 쌩으로 빌더 써서 저장하면 안 됨.)

 

 

 

✔️ 어떻게 수정해?

JPA를 쓸 때 가장 지켜야 할 철칙 중 하나가

"엔티티는 DB에서 꺼내와서 수정한다" 이다.

내가 짰던 방식은 엔티티를 새로 생성한거기 때문에, 조회를 먼저 한 후 수정하는 방식으로 고쳐야 한다.

@Transactional 
    public CommonResponse updateUserStatus(UserDto dto) {
	     // 먼저 조회함
        UserEntity user = userRepository.findByUserId(dto.getUserId())
            .orElseThrow(() -> new CommonException(ErrorCode.USER_NOT_FOUND));

		// 상태변경(더티체킹으로 알아서 Update 때려줌)
        user.changeStatus(dto.getStatus());
        return CommonResponse.ok();
    }

 

 

 

④ 리턴 타입이 CommonResponse??

Service가 아주 HTTP 응답스러운 놈을 바로 리턴하고 있다. ㅋㅋㅋ 이건 컨트롤러가 할일이지 서비스의 역할은 아니다.

서비스는 그냥 도메인 객체나 DTO를 던지고 예외시 Exception을 터뜨려야지 응답메시지 까지 결정하고 있다.

 

 어우… 현타빡시게 온다
.....

 

 

 

DDD 클린아키텍처로 가기위해 기억해야 하는건

Domain을 “기능중심” 으로 생각하는게 아니라 , “비즈니스 규칙” 으로 생각해야 한단 것이다.

 

이게 무슨소리냐 함은,

기능중심으로 생각하게 되면

서비스 안에 → 이메일 중복체크하고, 로그인시 회원활성 상태체크 해야하고…

⇒ 바로 다시 레거시가 되는것이에오!!!! (컄 ㅠㅠ)

 

But, 비즈니스 규칙으로 생각해보자.

이메일 중복체크하고, 회원상태 체크해야하는 부분...

이건 변하지 않는 핵심 비즈니스 규칙이다.

 

이런 규칙들이 Domain 에 녹여져 들어가야 한단 것이다.

이런 규칙들을 흐름에 맞게 잘 배치해주는곳이 바로 Service 이다.

결론적으로

“모든 계층들은 각각 독립적이어야 한다!!!!!“

 

그래야 DB가 MySQL에서 PostgreSQL로 바뀐다 해도 그 계층만 갈아끼우면 그만이고,

MSA로 가게 될 경우 이런 기능들 각각 하나씩 따로 떼서 조립할 수 있고

다른 프레임워크로 갈아 끼운다고 한들, 이 비즈니스 규칙은 절대적이기에 걍 갖다 꽂기만 하면 그만이다.

      [ 외부 세계 (Web, App) ]
                 |
      (어댑터: Controller)
                 ↓
      +-----------------------+
      |      [ 육각형 내부 ]    |
      |                       |
      |   Application (UseCase)| <--- "로그인해라" (지시)
      |           ↓           |
      |    Domain (Model)     | <--- "비번검증 로직" (핵심)
      |           |           |
      |   [ Port (Interface) ]| <--- "DB 저장해줘" (계약서)
      +-----------------------+
                 ↓
       (어댑터: RepositoryImpl)
                 |
       [ 인프라 (MySQL, Redis) ]  

이렇게 생각하면 아주 쉽다.

 

 

자, 이제 이 방식으로 Sample-App 을 한번 예쁘게 만들어보자.

 

 

  • 구조
com.example.sample/
├─ SampleAppApplication.java
│
├─ global/                              # 전역 공통 기능
│  ├─ common/
│  │  ├─ annotation/
│  │  │  └─ CurrentUserId.java
│  │  ├─ domain/
│  │  │  └─ BaseEntity.java
│  │  ├─ mapper/
│  │  │  └─ EntityMapper.java
│  │  ├─ ApiResult.java
│  │  └─ PageResult.java
│  ├─ config/
│  │  ├─ AuditingConfig.java
│  │  ├─ SecurityConfig.java
│  │  ├─ SwaggerConfig.java
│  │  └─ WebMvcConfig.java
│  ├─ error/
│  │  ├─ CommonErrorCode.java
│  │  ├─ CommonException.java
│  │  ├─ GlobalExceptionHandler.java
│  │  └─ SampleErrorCode.java
│  └─ security/
│     ├─ dto/
│     │  └─ Token.java
│     ├─ jwt/
│     │  ├─ JwtAuthenticationFilter.java
│     │  ├─ JwtConstants.java
│     │  ├─ JwtProperties.java
│     │  └─ JwtTokenProvider.java
│     ├─ service/
│     │  └─ CustomUserDetailsService.java
│     ├─ AuditorAwareImpl.java
│     └─ CustomAuthenticationEntryPoint.java
│
└─ user/                                # User 도메인
   ├─ application/                      # 서비스 계층
   │  ├─ command/
   │  │  ├─ ChangePasswordCommand.java
   │  │  ├─ LoginCommand.java
   │  │  ├─ SignUpCommand.java
   │  │  └─ UpdateUserCommand.java
   │  ├─ dto/
   │  │  ├─ LoginResult.java
   │  │  └─ UserInfoResult.java
   │  ├─ UserQueryService.java
   │  └─ UserService.java
   ├─ controller/                       # API 계층
   │  ├─ dto/
   │  │  ├─ LoginRequest.java
   │  │  ├─ LoginResponse.java
   │  │  ├─ PasswordChangeRequest.java
   │  │  ├─ SignUpRequest.java
   │  │  ├─ UpdateUserRequest.java
   │  │  └─ UserInfoResponse.java
   │  ├─ mapper/
   │  │  └─ UserWebMapper.java
   │  └─ UserController.java
   ├─ domain/                           # 도메인 계층
   │  ├─ User.java
   │  ├─ UserReader.java
   │  ├─ UserRole.java
   │  ├─ UserStatus.java
   │  └─ UserStore.java
   └─ infrastructure/                   # 인프라 계층
      ├─ entity/
      │  └─ UserEntity.java
      ├─ mapper/
      │  └─ UserEntityMapper.java
      ├─ UserJpaRepository.java
      ├─ UserReaderImpl.java
      └─ UserStoreImpl.java

 

 

 

 

  • 데이터흐름
    • Request(요청) -> Command(명령) -> Domain(로직) -> Entity(DB)

위의 핵심사상도 녹여져 있지만 구체적으로 신경 쓴부분 아래와 같다.

 

 

  • Mapstuct 삭제

→ 내 의도는 RAG를 위한 소스코드기 때문에 컴파일시 생성되는 소스코드를 AI들은 모른다. 그렇기 때문에 Mapstuct 는 사용하지 않았다.

 

 

  • CQS 원칙

데이터를 다루는 역할을 명확하게 분리하는 원칙으로 조회/저장 을 따로 분리하여 Reader와 Store로 각각 분리해주었다.

 

 

  • 명시적 함수방식 채용

중복체크시 findByEmail 이나 findByName을 이용해서 Optional 객체로 받은 뒤 .map isPresent() 으로 체크 할 수도 있지만 단순히 있냐없냐를 체크하는 로직이라 find블라블라 시리즈를 쓰면 모든 컬럼을 다 갖고 오기 때문에 비효율적일 뿐더러 가장 중요한건 RAG를 만들기위해 AI에게 제대로 된 지식을 전해 줘야 하는데 이렇게 짜게 되면 AI 가독성 면에서 정확한 데이터로 전달해줄 확률이 매우 높아 지므로 명시적인 함수방식을 썼다.

 

 

  • Record 사용

원래 기존에 DTO 놈들은 Class로 선언하고 lombok으로 Getter Setter 를 떡칠해줬더랬다.(@Data도 한몫함)

롬복을 쓰게 되면 일단 외부 라이브러리고 가끔 컴파일 시점에 꼬이기도 한다. 그리고 무엇보다 Setter기능 때문에 이 데이터가 어디선가 몰래 슥- 바뀔수도 있단 소리다. (→ 이렇게 되면 추적하기가 힘들어진다.)

하지만 record의 모든필드는 기본적으로 final 이다. 비즈니스 로직 중간에 데이터가 오염될 일이없다.

또한 Java 16부터 나오는 순정기능으로 별도 의존성 추가할 필요가 없다.

즉, 클린 아키텍처에서 불변의 객체를 보장하는 녀석이 되기에 이 경계를 지킬 수 있다.

 

 

자세한 소스는 아래 GIT 에 공개해두었다.

 

https://github.com/ssmyefrin/sample-app-public.git

 

GitHub - ssmyefrin/sample-app-public: 샘플 앱 공개용 입니다.

샘플 앱 공개용 입니다. Contribute to ssmyefrin/sample-app-public development by creating an account on GitHub.

github.com