본문 바로가기

Design Pattern

객체지향이란 무엇인가 ? + 프로젝트 분석

1. 객체지향이란?

객체지향 프로그래밍은 프로그램을 단순히 데이터를 처리하는 함수의 집합으로 보는 것이 아니라, 각자 상태와 책임을 가진 객체들이 서로 협력하는 시스템으로 설계하는 방식이다.

객체지향의 핵심은 단순히 class를 사용하는 데 있지 않다.

어떤 객체에게 어떤 책임을 맡기고, 객체들이 어떤 메시지를 주고받으며 협력하게 할 것인가?

큰 문제를 무조건 작은 함수로 쪼개는 것보다, 작은 문제를 스스로 해결할 수 있는 독립적인 객체를 만들고 이 객체들을 조합해 전체 문제를 해결하는 상향식 접근에 가깝다.


2. 객체의 상태와 행동

객체는 일반적으로 상태와 행동을 가진다.

Object = State + Behavior

은행 계좌를 예로 들어보자.

public class BankAccount {

    private int balance;

    public void deposit(int amount) {
        balance += amount;
    }

    public void withdraw(int amount) {
        if (balance < amount) {
            throw new IllegalStateException("잔액이 부족합니다.");
        }

        balance -= amount;
    }
}

balance는 계좌의 상태이고, deposit()과 withdraw()는 계좌가 수행하는 행동이다.

외부에서 잔액을 직접 수정하게 하면 객체가 올바른 상태를 보장하기 어렵다.

// 잘못된 상태가 만들어질 수 있다.
account.balance = -100000;

따라서 상태를 숨기고 의미 있는 행동을 통해서만 변경하게 만든다.

account.withdraw(10000);

이것이 객체지향의 중요한 특징인 캡슐화다.


Memora 프로젝트 분석

3. 프로젝트 소개

Memora는 65세 이상 노년층을 위한 AI 기반 감정 일기 서비스다.

사용자는 하루 동안 기분, 사진, 위치, 짧은 메모를 중간 기록으로 남긴다. 백엔드는 이 데이터를 저장하고 AI에 전달해 일기 초안을 생성한다. 사용자는 AI 초안을 직접 확인하고 수정한 후 최종 일기로 완료할 수 있다.

백엔드는 다음 기능을 담당한다.

  • 일반 회원가입 및 JWT 로그인
  • 카카오 소셜 로그인
  • 중간 기록 및 최종 일기 관리
  • AI 일기 초안 생성
  • 주간·월간 감정 리포트
  • 이미지 업로드
  • FCM 푸시 알림
  • 이메일 기반 비밀번호 재설정

4. 기술 스택

구분기술
Language Java 21
Framework Spring Boot 4.0.5
Security Spring Security, JWT, BCrypt
Database PostgreSQL 14
ORM Spring Data JPA
AI OpenRouter, GPT-4o
Push Firebase Cloud Messaging
Email Gmail SMTP
API 문서 Swagger/OpenAPI
Build Gradle

5. 백엔드 요청 처리 구조

Memora 백엔드는 계층형 구조를 사용한다.

React Native
    ↓ HTTP 요청 + JWT
Spring Security Filter
    ↓ 사용자 인증
Controller
    ↓ 요청 전달
Service
    ↓ 비즈니스 규칙 처리
Repository
    ↓ JPA
PostgreSQL

각 객체는 서로 다른 책임을 가진다.

  • Controller: HTTP 요청과 응답 처리
  • Service: 비즈니스 흐름과 규칙 처리
  • Repository: 데이터베이스 접근
  • Entity: 데이터와 도메인 상태 관리
  • DTO: 프론트엔드와 주고받는 데이터 표현
  • Security Filter: JWT 인증 처리

계층을 나누는 것 자체가 객체지향을 보장하지는 않는다. 중요한 것은 각 클래스가 자신의 책임에 집중하고 다른 객체와 협력하도록 구현하는 것이다.


6. Controller와 Service의 책임 분리

일기 생성 Controller는 요청을 받고 Service에 작업을 요청한다.

@RestController
@RequestMapping("/api/v1/diaries")
@RequiredArgsConstructor
public class DiaryController {

    private final DiaryService diaryService;

    @PostMapping
    public ResponseEntity<DiaryResponse> createDiary(
            @AuthenticationPrincipal Integer userId) {

        return ResponseEntity.ok(
                diaryService.createDiary(userId)
        );
    }
}

Controller가 직접 DB를 조회하거나 일기 생성 조건을 판단하지 않는다. 실제 규칙은 DiaryService가 처리한다.

@Transactional
public DiaryResponse createDiary(Integer userId) {
    User user = findUserById(userId);
    LocalDate today = LocalDate.now();

    return diaryRepository.findByUserAndTargetDate(user, today)
            .map(DiaryResponse::from)
            .orElseGet(() -> {
                Diary diary = Diary.builder()
                        .user(user)
                        .targetDate(today)
                        .build();

                return DiaryResponse.from(
                        diaryRepository.save(diary)
                );
            });
}

오늘 일기가 존재하면 기존 일기를 반환하고, 존재하지 않으면 새 일기를 만든다.

오늘 일기 있음 → 기존 일기 반환
오늘 일기 없음 → 새로운 일기 생성

이처럼 요청 처리와 비즈니스 규칙을 분리하면 각각의 변경 이유가 명확해진다.


7. Entity와 JPA

Diary는 DB의 diaries 테이블과 연결되는 Entity다.

@Entity
@Table(name = "diaries", uniqueConstraints = {
    @UniqueConstraint(columnNames = {"user_id", "target_date"})
})
public class Diary {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer diaryId;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id", nullable = false)
    private User user;

    @Column(nullable = false)
    private LocalDate targetDate;

    @Enumerated(EnumType.STRING)
    private MoodType finalMood;

    @Enumerated(EnumType.STRING)
    private DiaryStatus status;
}

User와 Diary는 1:N 관계다. 한 명의 사용자가 여러 일기를 작성할 수 있지만, 하나의 일기는 한 명의 사용자에게 속한다.

user_id와 target_date에는 유니크 제약 조건이 적용되어 있다. 따라서 사용자 한 명당 하루에 하나의 일기만 생성할 수 있다.


8. Repository를 통한 DB 접근

Repository는 데이터 조회와 저장을 담당한다.

public interface DiaryRepository
        extends JpaRepository<Diary, Integer> {

    Optional<Diary> findByUserAndTargetDate(
            User user,
            LocalDate targetDate
    );

    List<Diary>
    findByUserAndTargetDateBetweenOrderByTargetDateDesc(
            User user,
            LocalDate startDate,
            LocalDate endDate
    );
}

Spring Data JPA는 메서드 이름을 분석해 쿼리를 생성한다.

findByUserAndTargetDate
→ 사용자와 날짜가 일치하는 일기 조회

findByUserAndTargetDateBetweenOrderByTargetDateDesc
→ 특정 기간의 일기를 날짜 내림차순으로 조회

Service는 실제 Repository 구현체가 아닌 DiaryRepository 인터페이스에 의존한다. 구현체는 Spring Data JPA가 실행 시점에 생성한다.


9. Entity의 캡슐화

Memora의 Entity는 데이터를 보관하는 역할만 하지 않는다. 자신의 상태를 변경하는 행동도 가진다.

public void complete() {
    this.status = DiaryStatus.COMPLETED;
    this.updatedAt = OffsetDateTime.now();
}

Service에서 필드를 직접 수정하지 않고 다음과 같이 객체에 작업을 요청한다.

diary.complete();

최종 일기를 초기화할 때도 의미 있는 도메인 메서드를 사용한다.

public void resetFinal() {
    this.finalMood = null;
    this.finalContent = null;
    this.aiDraft = null;
    this.isEdited = false;
    this.status = DiaryStatus.IN_PROGRESS;
    this.updatedAt = OffsetDateTime.now();
}

범용 Setter 대신 complete(), resetFinal(), updateAiDraft()처럼 의도가 드러나는 메서드를 사용하면 상태 변경 규칙을 한곳에서 관리할 수 있다.


10. JWT 인증

로그인에 성공하면 Access Token과 Refresh Token을 발급한다.

private TokenResponse createAndSaveTokens(User user) {
    String accessToken =
            jwtTokenProvider.createAccessToken(user.getUserId());

    String refreshToken =
            jwtTokenProvider.createRefreshToken(user.getUserId());

    user.updateRefreshToken(refreshToken);

    return new TokenResponse(accessToken, refreshToken);
}
  • Access Token은 일반 API 인증에 사용한다.
  • Refresh Token은 Access Token을 재발급할 때 사용한다.

프론트엔드는 요청 헤더에 Access Token을 포함한다.

Authorization: Bearer {ACCESS_TOKEN}

JWT에는 비밀번호 대신 사용자 ID, 발급 시각, 만료 시각이 들어간다.

private String createToken(Integer userId, long validity) {
    Date now = new Date();
    Date expiry = new Date(now.getTime() + validity);

    return Jwts.builder()
            .subject(userId.toString())
            .issuedAt(now)
            .expiration(expiry)
            .signWith(secretKey)
            .compact();
}

JWT는 암호화된 문자열이라기보다 서버의 비밀 키로 서명된 토큰이다.


11. JWT 인증 필터

요청은 Controller에 도착하기 전에 JWT 필터를 거친다.

String token = resolveToken(request);

if (token != null && jwtTokenProvider.validateToken(token)) {
    Integer userId = jwtTokenProvider.getUserId(token);

    UsernamePasswordAuthenticationToken authentication =
            new UsernamePasswordAuthenticationToken(
                    userId,
                    null,
                    List.of()
            );

    SecurityContextHolder.getContext()
            .setAuthentication(authentication);
}

filterChain.doFilter(request, response);

필터의 처리 순서는 다음과 같다.

Authorization 헤더에서 토큰 추출
→ 서명과 만료 시간 검증
→ userId 추출
→ Spring Security에 사용자 등록
→ Controller 실행

Controller에서는 등록된 사용자를 다음과 같이 가져온다.

@AuthenticationPrincipal Integer userId

12. 인증과 인가의 차이

JWT 인증이 성공했다고 모든 일기를 조회할 수 있는 것은 아니다. 해당 일기의 실제 소유자인지도 확인해야 한다.

private Diary findDiaryByIdAndUser(
        Integer diaryId,
        Integer userId
) {
    Diary diary = diaryRepository.findById(diaryId)
            .orElseThrow(() ->
                    new IllegalArgumentException(
                            "일기를 찾을 수 없습니다."
                    )
            );

    if (!diary.getUser().getUserId().equals(userId)) {
        throw new IllegalArgumentException(
                "접근 권한이 없습니다."
        );
    }

    return diary;
}

두 과정의 책임은 다르다.

인증(Authentication)
→ 요청한 사용자가 누구인지 확인

인가(Authorization)
→ 해당 사용자가 이 데이터에 접근할 권한이 있는지 확인

JWT 필터는 인증을 담당하고, 일기 Service는 데이터 소유권을 검사한다.


13. 중간 기록과 객체 관계

Memora의 핵심 데이터 관계는 다음과 같다.

User 1 ── N Diary 1 ── N DiarySegment 1 ── N SegmentPhoto
  │
  ├── 1 UserSettings
  └── N HealthReport

중간 기록을 생성하면 기존 기록 수를 기준으로 stepOrder를 부여한다.

List<DiarySegment> existing =
        segmentRepository.findByDiaryOrderByStepOrderAsc(diary);

int nextOrder = existing.size() + 1;

DiarySegment segment = DiarySegment.builder()
        .diary(diary)
        .stepOrder(nextOrder)
        .moodSnapshot(request.getMoodSnapshot())
        .locationName(request.getLocationName())
        .userContent(request.getUserContent())
        .build();
첫 번째 기록 → stepOrder 1
두 번째 기록 → stepOrder 2
세 번째 기록 → stepOrder 3

중간 기록을 삭제하면 남은 기록의 순서를 다시 정렬한다.

List<DiarySegment> remaining =
        segmentRepository.findByDiaryOrderByStepOrderAsc(diary);

for (int i = 0; i < remaining.size(); i++) {
    remaining.get(i).updateStepOrder(i + 1);
}

이를 통해 AI가 하루의 기록을 시간 흐름에 맞게 조합할 수 있다.


14. 카카오 로그인 객체 협력

카카오 로그인은 하나의 클래스가 모든 일을 처리하지 않는다.

AuthController
→ AuthService
→ KakaoService
→ Kakao API
→ UserRepository
→ JwtTokenProvider

핵심 흐름은 다음과 같다.

String kakaoAccessToken =
        kakaoService.getAccessToken(code, redirectUri);

KakaoService.KakaoUserInfo kakaoUser =
        kakaoService.getUserInfo(kakaoAccessToken);

User user = userRepository
        .findByKakaoId(kakaoUser.getKakaoId())
        .orElseGet(() -> registerKakaoUser(kakaoUser));

return createAndSaveTokens(user);

각 객체는 하나의 역할을 담당한다.

  • KakaoService: 카카오 서버와 통신
  • UserRepository: 사용자 조회 및 저장
  • JwtTokenProvider: Memora JWT 생성
  • AuthService: 전체 로그인 흐름 조정

카카오 Access Token은 카카오 사용자 정보를 조회하는 데만 사용한다. 이후 Memora API 인증에는 우리 서버가 발급한 JWT를 사용한다.


15. AI 기능의 객체 협력

AI 기능도 역할에 따라 나누어져 있다.

AiController
→ AiService
→ AiRateLimiter
→ OpenrouterService
  • AiController: AI 생성 요청 수신
  • AiService: 소유권 검사, 프롬프트 생성, 결과 저장
  • AiRateLimiter: 사용자별 호출 횟수 제한
  • OpenrouterService: 외부 AI API 호출
String systemPrompt = resolvePersona(
        SEGMENT_SYSTEM_PROMPT_TEMPLATE,
        segment.getDiary().getUser()
);

String userPrompt = buildSegmentUserPrompt(segment);

String draft = openrouterService.chatWithVision(
        systemPrompt,
        userPrompt,
        base64Image,
        mimeType
);

segment.updateAiDraft(draft);

AI API 키와 시스템 프롬프트는 서버 내부에서만 관리한다. AI 결과는 바로 최종 내용으로 확정하지 않고 aiDraft에 저장한다.

AI 초안 생성
→ 사용자가 내용 확인
→ 수정 또는 재생성
→ 최종 기분 선택
→ 최종 일기 완료

이 구조는 AI가 모든 것을 결정하는 완전 자동 방식이 아니라, 사용자가 최종 결정권을 갖는 반자동 방식이다.


16. 객체지향 관점에서 보완할 점

현재도 책임이 어느 정도 분리되어 있지만 개선할 부분이 있다.

도메인별 예외 분리

현재 여러 오류가 IllegalArgumentException으로 처리된다.

throw new IllegalArgumentException("접근 권한이 없습니다.");

다음과 같이 구체적인 예외로 분리할 수 있다.

throw new DiaryNotFoundException(diaryId);
throw new DiaryAccessDeniedException(diaryId);

AI 클라이언트 추상화

현재 AiService는 OpenrouterService에 직접 의존한다. 인터페이스를 도입하면 AI 제공 업체를 쉽게 교체할 수 있다.

public interface AiClient {
    String chat(String systemPrompt, String userPrompt);
}
@Service
public class OpenrouterAiClient implements AiClient {

    @Override
    public String chat(
            String systemPrompt,
            String userPrompt
    ) {
        // OpenRouter API 호출
    }
}

완료 조건을 Entity 내부로 이동

현재 최종 기분 검사는 Service에서 수행한다.

if (diary.getFinalMood() == null) {
    throw new IllegalArgumentException(
            "최종 기분을 선택해야 완료할 수 있습니다."
    );
}

diary.complete();

완료 조건까지 Entity가 책임지도록 변경할 수 있다.

public void complete() {
    if (this.finalMood == null) {
        throw new IllegalStateException(
                "최종 기분을 선택해야 합니다."
        );
    }

    this.status = DiaryStatus.COMPLETED;
    this.updatedAt = OffsetDateTime.now();
}

이렇게 하면 complete()가 어디서 호출되더라도 동일한 규칙이 적용된다.


17. 정리

Memora 백엔드는 다음과 같이 객체별 책임을 나누었다.

Controller → HTTP 요청과 응답
Service → 비즈니스 흐름과 규칙
Repository → 데이터베이스 접근
Entity → 상태와 도메인 행동
DTO → 외부 전달 데이터
Security Filter → JWT 인증
외부 연동 Service → 카카오, AI, FCM, 이메일 통신

이번 프로젝트를 분석하면서 객체지향은 클래스를 많이 만드는 것이 아니라는 점을 확인할 수 있었다. 중요한 것은 객체에 적절한 책임을 부여하고, 다른 객체와 협력하게 만드는 것이다.

Memora 백엔드는 Controller가 요청을 받고, Service가 전체 흐름을 조정하며, Entity가 자신의 상태를 관리한다. Repository와 외부 연동 Service는 DB와 외부 시스템의 세부 구현을 담당한다.

하나의 클래스가 모든 작업을 수행하지 않고, 각 객체가 맡은 책임을 수행하며 협력하도록 만드는 것이 객체지향 설계의 핵심이다.